mirror of
https://github.com/bulwarkmail/native.git
synced 2026-11-04 08:07:29 +00:00
186 lines
8.1 KiB
TypeScript
186 lines
8.1 KiB
TypeScript
// Sending a message from the offline send queue (see lib/send-queue.ts for
|
|
// the state machine and the rule against sending twice).
|
|
|
|
import { jmapClient } from './jmap-client';
|
|
import { destroyEmails, sendEmail, type SendEmailResult } from './email';
|
|
import { requireMethodResult } from './jmap-result';
|
|
import { SUBMISSION_ONLY_USING } from './submissions';
|
|
import type { Mailbox } from './types';
|
|
import { sendTargetFor } from '../lib/compose-send-account';
|
|
import { supportsSubmissionExtension } from '../lib/submission-options';
|
|
import { stripMessageIdBrackets } from '../lib/email-threading';
|
|
import { holdForScheduled, verdictFromCopies, type CopyVerdict, type FoundCopy, type QueuedSend } from '../lib/send-queue';
|
|
|
|
/** How far before the queueing a copy can be dated (the server's clock may differ). */
|
|
const CLOCK_SKEW_MS = 10 * 60_000;
|
|
/** Messages filed in Sent and Drafts since the queueing that are compared. */
|
|
const LOOKUP_LIMIT = 200;
|
|
|
|
/**
|
|
* The messages in the given folders of `accountId` whose Message-ID is
|
|
* `messageId`, filed since `since` (ms). Not a header search: Stalwart
|
|
* answers `{ header: ['Message-ID', …] }` from its full-text index, which
|
|
* lags behind (0.16.27 and 1.0.1 found nothing seconds after the send), so
|
|
* the folders' recent messages are read and compared instead.
|
|
*/
|
|
export async function findCopiesByMessageId(
|
|
messageId: string,
|
|
opts: { accountId?: string; mailboxIds: string[]; since: number },
|
|
): Promise<FoundCopy[]> {
|
|
const id = stripMessageIdBrackets(messageId);
|
|
const mailboxIds = opts.mailboxIds.filter(Boolean);
|
|
if (!id || mailboxIds.length === 0) return [];
|
|
const account = opts.accountId ?? jmapClient.accountId;
|
|
const inFolders = mailboxIds.length === 1
|
|
? { inMailbox: mailboxIds[0] }
|
|
: { operator: 'OR', conditions: mailboxIds.map((m) => ({ inMailbox: m })) };
|
|
const filter = {
|
|
operator: 'AND',
|
|
conditions: [{ after: new Date(opts.since - CLOCK_SKEW_MS).toISOString() }, inFolders],
|
|
};
|
|
const res = await jmapClient.request([
|
|
['Email/query', {
|
|
accountId: account, filter, sort: [{ property: 'receivedAt', isAscending: false }], limit: LOOKUP_LIMIT,
|
|
}, '0'],
|
|
['Email/get', {
|
|
accountId: account,
|
|
'#ids': { resultOf: '0', name: 'Email/query', path: '/ids' },
|
|
properties: ['id', 'keywords', 'messageId'],
|
|
}, '1'],
|
|
]);
|
|
requireMethodResult(res, '0', 'Email/query');
|
|
const list = (requireMethodResult(res, '1', 'Email/get').list ?? []) as Array<{
|
|
id: string; keywords?: Record<string, boolean>; messageId?: string[] | null;
|
|
}>;
|
|
return list
|
|
.filter((e) => (e.messageId ?? []).some((m) => stripMessageIdBrackets(m) === id))
|
|
.map((e) => ({ id: e.id, keywords: e.keywords ?? {} }));
|
|
}
|
|
|
|
export type QueuedSendOutcome =
|
|
| { kind: 'sent'; result: SendEmailResult; accountId?: string }
|
|
/** An earlier attempt went out; nothing was sent now. */
|
|
| { kind: 'alreadySent'; accountId?: string };
|
|
|
|
/**
|
|
* Send a queued message. The account, identity and folders are worked out
|
|
* as the composer does (sendTargetFor, #1090). A message an earlier attempt
|
|
* may have sent is looked up first, and is not sent again when a copy of it
|
|
* went out; copies a refused attempt left in Drafts are removed before the
|
|
* new attempt.
|
|
*/
|
|
/** The account and folders a queued message goes through, as the composer works them out. */
|
|
async function queuedSendTarget(send: QueuedSend, mailboxes: Mailbox[]) {
|
|
const p = send.payload;
|
|
const target = await sendTargetFor(p.identity, p.fromEmail, mailboxes);
|
|
return {
|
|
target,
|
|
accountId: target.accountId ?? jmapClient.accountId,
|
|
draftAccountId: p.draftAccountId ?? jmapClient.accountId,
|
|
sentMailboxId: target.sentMailboxId ?? p.sentMailboxId,
|
|
draftsMailboxId: target.accountId ? target.draftsMailboxId : p.draftsMailboxId,
|
|
};
|
|
}
|
|
|
|
type Resolved = Awaited<ReturnType<typeof queuedSendTarget>>;
|
|
|
|
/**
|
|
* Whether an earlier, unanswered attempt at `send` went out. When it did, the
|
|
* old draft that send would have removed is removed now.
|
|
*/
|
|
async function earlierAttempt(send: QueuedSend, r: Resolved): Promise<CopyVerdict> {
|
|
const p = send.payload;
|
|
const copies = await findCopiesByMessageId(p.outgoing.messageId!, {
|
|
accountId: r.target.accountId,
|
|
mailboxIds: [r.sentMailboxId, r.draftsMailboxId ?? ''],
|
|
since: send.createdAt,
|
|
});
|
|
// The autosaved draft lives in this account only when the draft did.
|
|
const draftHere = p.draftId && r.draftAccountId === r.accountId ? p.draftId : undefined;
|
|
const verdict = verdictFromCopies(copies, draftHere);
|
|
if (verdict.kind === 'sent' && p.draftId && p.draftId !== verdict.copyId) {
|
|
// The send went out but its answer was lost, so the old draft it would
|
|
// have removed is still there.
|
|
await destroyEmails([p.draftId], r.draftAccountId).catch((err) => {
|
|
console.warn('[send-queue] could not remove the draft of a sent message', err);
|
|
});
|
|
}
|
|
return verdict;
|
|
}
|
|
|
|
/**
|
|
* What taking back a queued message finds on the server:
|
|
* - `notSent`: no earlier attempt reached it (or none was looked up).
|
|
* - `held`: an earlier attempt did, as a send held for later (a "send
|
|
* later", or the undo-send delay) that the server still holds; the caller
|
|
* cancels `emailSubmissionId` in `accountId` and decides where the copy
|
|
* `emailId` goes.
|
|
* - `released`: an earlier attempt did, and the server already let it go.
|
|
*/
|
|
export type QueuedSendTakeBack =
|
|
| { kind: 'notSent' }
|
|
| { kind: 'held'; accountId: string; emailId: string; emailSubmissionId: string }
|
|
| { kind: 'released' };
|
|
|
|
/**
|
|
* Before a queued message is taken back (moved to Drafts or discarded): a
|
|
* message no attempt reached the server with is never looked up. One that
|
|
* went out is looked for among the submissions of its account, so a held one
|
|
* can still be cancelled.
|
|
*/
|
|
export async function takeBackQueuedSend(send: QueuedSend, mailboxes: Mailbox[]): Promise<QueuedSendTakeBack> {
|
|
if (!send.maybeSent || !send.payload.outgoing.messageId) return { kind: 'notSent' };
|
|
const resolved = await queuedSendTarget(send, mailboxes);
|
|
const verdict = await earlierAttempt(send, resolved);
|
|
if (verdict.kind !== 'sent') return { kind: 'notSent' };
|
|
const accountId = resolved.accountId;
|
|
// EmailSubmission/query by emailIds finds it on 0.16.27 and 1.0.1.
|
|
const res = await jmapClient.request([
|
|
['EmailSubmission/query', { accountId, filter: { emailIds: [verdict.copyId] } }, '0'],
|
|
['EmailSubmission/get', {
|
|
accountId,
|
|
'#ids': { resultOf: '0', name: 'EmailSubmission/query', path: '/ids' },
|
|
properties: ['id', 'emailId', 'undoStatus'],
|
|
}, '1'],
|
|
], SUBMISSION_ONLY_USING);
|
|
requireMethodResult(res, '0', 'EmailSubmission/query');
|
|
const list = (requireMethodResult(res, '1', 'EmailSubmission/get').list ?? []) as Array<{
|
|
id: string; emailId?: string; undoStatus?: string;
|
|
}>;
|
|
const pending = list.find((sub) => sub.emailId === verdict.copyId && sub.undoStatus === 'pending');
|
|
return pending
|
|
? { kind: 'held', accountId, emailId: verdict.copyId, emailSubmissionId: pending.id }
|
|
: { kind: 'released' };
|
|
}
|
|
|
|
export async function runQueuedSend(send: QueuedSend, mailboxes: Mailbox[], now = Date.now()): Promise<QueuedSendOutcome> {
|
|
const p = send.payload;
|
|
const resolved = await queuedSendTarget(send, mailboxes);
|
|
const { target, accountId, sentMailboxId, draftsMailboxId } = resolved;
|
|
|
|
if (send.maybeSent && p.outgoing.messageId) {
|
|
const verdict = await earlierAttempt(send, resolved);
|
|
if (verdict.kind === 'sent') return { kind: 'alreadySent', accountId: target.accountId };
|
|
if (verdict.staleIds.length > 0) {
|
|
await destroyEmails(verdict.staleIds, accountId);
|
|
}
|
|
}
|
|
|
|
const result = await sendEmail(
|
|
p.outgoing,
|
|
target.identityId,
|
|
sentMailboxId,
|
|
holdForScheduled(p.scheduledAt, now),
|
|
{
|
|
draftsMailboxId,
|
|
draftId: p.draftId,
|
|
draftAccountId: p.draftId ? p.draftAccountId : undefined,
|
|
accountId: target.accountId,
|
|
// Only what the submitting account offers, as the composer does.
|
|
requestDsn: !!p.requestDsn && supportsSubmissionExtension('DSN', target.accountId),
|
|
requireTls: !!p.requireTls && supportsSubmissionExtension('REQUIRETLS', target.accountId),
|
|
},
|
|
);
|
|
return { kind: 'sent', result, accountId: target.accountId };
|
|
}
|