// 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 { 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; 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>; /** * 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 { 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 { 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 { 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 }; }