diff --git a/components/email/__tests__/composer-sign-out-flush.test.tsx b/components/email/__tests__/composer-sign-out-flush.test.tsx index 2f55cdf10..be454dee1 100644 --- a/components/email/__tests__/composer-sign-out-flush.test.tsx +++ b/components/email/__tests__/composer-sign-out-flush.test.tsx @@ -1,4 +1,4 @@ -import { render, screen, fireEvent, act } from '@testing-library/react'; +import { render, screen, fireEvent, act, waitFor } from '@testing-library/react'; import { afterEach, beforeEach, describe, it, expect, vi } from 'vitest'; import React from 'react'; import { EmailComposer, type ComposerDraftData } from '../email-composer'; @@ -70,7 +70,6 @@ vi.mock('@/stores/account-store', () => { vi.mock('@/stores/email-store', () => { const state = { - draftSaveEnabled: false, sendRawEmail: async () => ({ sent: true }), }; const hook = (sel?: (s: typeof state) => unknown) => @@ -247,6 +246,50 @@ describe('composer flush before an automatic sign-out', () => { expect(createDraft.mock.calls[0][1]).toBe('Half-written'); }); + it('waits for a send in flight and saves no draft of the sent message', async () => { + let finishSend!: () => void; + const onSend = vi.fn(() => new Promise((resolve) => { finishSend = resolve; })); + render(); + fireEvent.change(screen.getByDisplayValue('Half-written'), { target: { value: 'Half-written more' } }); + fireEvent.click(screen.getAllByTestId('composer-send')[0]); + await waitFor(() => expect(onSend).toHaveBeenCalled()); + const savesBeforeSignOut = createDraft.mock.calls.length; + + let done = false; + const flushed = flushComposers(5000).then((r) => { done = true; return r; }); + await new Promise((r) => setTimeout(r, 300)); + expect(done).toBe(false); + + await act(async () => { finishSend(); }); + await expect(flushed).resolves.toMatchObject({ total: 1, failed: 0, timedOut: false }); + expect(createDraft).toHaveBeenCalledTimes(savesBeforeSignOut); + }); + + it('reports a save the server refused, so the login page can say so', async () => { + createDraft.mockRejectedValue(new Error('refused')); + const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {}); + try { + render(); + fireEvent.change(screen.getByDisplayValue('Half-written'), { target: { value: 'Half-written more' } }); + + let result: Awaited> | undefined; + await act(async () => { result = await flushComposers(5000); }); + expect(result).toMatchObject({ total: 1, failed: 1 }); + } finally { + consoleError.mockRestore(); + } + }); + + it('hands an already-saved state back as saved', async () => { + const onSaveState = vi.fn(); + const view = render(); + fireEvent.change(screen.getByDisplayValue('Half-written'), { target: { value: 'Half-written more' } }); + await act(async () => { await flushComposers(5000); }); + view.unmount(); + + expect(onSaveState).toHaveBeenCalledWith(expect.objectContaining({ subject: 'Half-written more', unsaved: false })); + }); + it('marks the state it hands to the host on unmount as unsaved', () => { const onSaveState = vi.fn(); const view = render(); diff --git a/components/email/__tests__/viewer-send-failures.test.tsx b/components/email/__tests__/viewer-send-failures.test.tsx index 27250067a..fb791f74a 100644 --- a/components/email/__tests__/viewer-send-failures.test.tsx +++ b/components/email/__tests__/viewer-send-failures.test.tsx @@ -6,6 +6,7 @@ import { toast } from '@/stores/toast-store'; import { useAuthStore } from '@/stores/auth-store'; import { useSettingsStore } from '@/stores/settings-store'; import { useEmailStore } from '@/stores/email-store'; +import { flushComposers, resetComposeFlushRegistry } from '@/lib/compose-flush-registry'; // Sending from the viewer - quick reply, "Send now" / "Reschedule" of a // scheduled message, and read receipts - used to fail silently or as an @@ -110,6 +111,21 @@ describe('viewer send failures', () => { expect((box as HTMLTextAreaElement).value).toBe('Thanks!'); }); + it('keeps typed quick-reply text as a draft before an automatic sign-out', async () => { + resetComposeFlushRegistry(); + const onSaveQuickReplyDraft = vi.fn().mockResolvedValue(undefined); + render( + , + ); + await act(async () => { await flushComposers(1000); }); + expect(onSaveQuickReplyDraft).not.toHaveBeenCalled(); + + fireEvent.change(screen.getByPlaceholderText('quick_reply_placeholder'), { target: { value: 'On it.' } }); + await act(async () => { await flushComposers(1000); }); + expect(onSaveQuickReplyDraft).toHaveBeenCalledWith('On it.'); + }); + const scheduled = () => makeEmail({ isScheduled: true, scheduledUndoStatus: 'pending', diff --git a/components/email/email-composer.tsx b/components/email/email-composer.tsx index 11f303fd3..6da50a5b9 100644 --- a/components/email/email-composer.tsx +++ b/components/email/email-composer.tsx @@ -142,6 +142,9 @@ import { toUnicodeEmail } from "@/lib/idn"; *

blocks are separated by a blank line, matching their visual rendering (#421). * Quotes get "> " and list items their markers, like any plain-text mail. */ +/** How often an automatic sign-out looks whether a send in flight has finished. */ +const SEND_WAIT_POLL_MS = 100; + function htmlToPlainText(html: string): string { return htmlToPlainTextShared(html, { paragraphSpacing: true, mailMarkup: true }); } @@ -1656,7 +1659,9 @@ export function EmailComposer({ mode, replyTo, priority: priorityRef.current, - unsaved: true, + // Edits the last save didn't cover; a minimised composer with + // none needs no save before an automatic sign-out. + unsaved: changeVersionRef.current !== savedVersionRef.current || saveFailedRef.current, }); } }; @@ -2709,8 +2714,13 @@ export function EmailComposer({ saveDraftRef.current = saveDraft; // An automatic sign-out saves what the server doesn't have yet, with the - // same save the close and send paths use. + // same save the close and send paths use. A send in flight goes first: + // the sign-out waits for it (within its own time limit), and a message + // that went out needs no draft. A save the server refused throws, so the + // login page can say a draft wasn't saved. useEffect(() => registerComposeFlush(async () => { + while (isSendingRef.current) await new Promise((resolve) => setTimeout(resolve, SEND_WAIT_POLL_MS)); + if (sentRef.current) return; const unsaved = changeVersionRef.current !== savedVersionRef.current || saveFailedRef.current; if (!unsaved) return; if (saveTimeoutRef.current) { @@ -2718,6 +2728,7 @@ export function EmailComposer({ saveTimeoutRef.current = null; } await saveDraftRef.current(); + if (saveFailedRef.current) throw new Error('The draft was not saved'); }), []); // The draft as plugins see it: onDraftChange, the frames above and below diff --git a/components/email/email-viewer.tsx b/components/email/email-viewer.tsx index 54a8409d6..7ab8f5dac 100644 --- a/components/email/email-viewer.tsx +++ b/components/email/email-viewer.tsx @@ -102,6 +102,7 @@ import type { Attachment as PostalMimeAttachment } from 'postal-mime'; import { useSettingsStore } from "@/stores/settings-store"; import { useShallow } from "zustand/react/shallow"; import { useEffectiveMailLayout } from "@/hooks/use-effective-mail-layout"; +import { registerComposeFlush } from "@/lib/compose-flush-registry"; import { useUIStore } from "@/stores/ui-store"; import { useContactStore, getContactDisplayName, getContactPrimaryEmail } from "@/stores/contact-store"; import { toast } from "@/stores/toast-store"; @@ -175,6 +176,8 @@ interface EmailViewerProps { onSetTag?: (emailId: string, tagId: string | null) => void; onDownloadAttachment?: (blobId: string, name: string, type?: string, forceDownload?: boolean, source?: EmailBlobContext) => void; onQuickReply?: (body: string) => Promise; + /** Keeps the typed quick reply as a draft before an automatic sign-out (lib/compose-flush-registry.ts). */ + onSaveQuickReplyDraft?: (body: string) => Promise; onMarkAsSpam?: () => void; onUndoSpam?: () => void; onMoveToMailbox?: (mailboxId: string) => void; @@ -1044,6 +1047,7 @@ export function EmailViewer({ onSetTag, onDownloadAttachment, onQuickReply, + onSaveQuickReplyDraft, onMarkAsSpam, onUndoSpam, onMoveToMailbox, @@ -1369,6 +1373,19 @@ export function EmailViewer({ const [quickReplyText, setQuickReplyText] = useState(""); const [isQuickReplyFocused, setIsQuickReplyFocused] = useState(false); const [isSendingQuickReply, setIsSendingQuickReply] = useState(false); + // An automatic sign-out keeps typed quick-reply text as a draft; text on + // its way out as a reply needs none. + const quickReplyDraftRef = useRef({ text: quickReplyText, sending: isSendingQuickReply, save: onSaveQuickReplyDraft }); + quickReplyDraftRef.current = { text: quickReplyText, sending: isSendingQuickReply, save: onSaveQuickReplyDraft }; + const hasQuickReplyDraft = quickReplyText.trim().length > 0 && !!onSaveQuickReplyDraft; + useEffect(() => { + if (!hasQuickReplyDraft) return; + return registerComposeFlush(async () => { + const { text, sending, save } = quickReplyDraftRef.current; + if (sending || !text.trim() || !save) return; + await save(text); + }); + }, [hasQuickReplyDraft]); const handleSendQuickReply = async () => { if (!quickReplyText.trim() || !onQuickReply || isSendingQuickReply) return; setIsSendingQuickReply(true); diff --git a/components/mail/mail-app.tsx b/components/mail/mail-app.tsx index 49581ae72..30bdb9dd7 100644 --- a/components/mail/mail-app.tsx +++ b/components/mail/mail-app.tsx @@ -115,8 +115,7 @@ import { isFilePreviewable } from "@/lib/file-preview"; import { prepareQuickReply } from "@/lib/outgoing/quick-reply"; import { computeReplyThreadingHeaders } from "@/lib/email-threading"; import { EML_IMPORT_ACCEPT, expandImportableEmails } from "@/lib/eml-import"; -import { findDraftIdentityId, findReplyIdentityId, resolveComposeAccountEmail } from "@/lib/reply-identity"; -import { buildReplyRecipients, isSelfSent } from "@/lib/reply-recipients"; +import { resolveComposeAccountEmail } from "@/lib/reply-identity"; import { useProMultiAccountIdentities } from "@/hooks/use-pro-multi-account-identities"; import { Filter, ChevronDown, X, Paperclip, Star, Mail, MailOpen, RotateCcw, PenSquare, PenLine, CheckSquare, Square, AlertTriangle, ArrowLeft } from "@/components/icons"; import { ResizeHandle } from "@/components/layout/resize-handle"; @@ -159,7 +158,8 @@ import { consumePendingDeepLink, subscribePendingDeepLink } from "@/lib/deep-lin import { useProInterfaceActive } from "@/components/pro/pro-interface-redirect"; import type { QuoteHeader } from "@/lib/plugin-types"; import { initialLinkSearch, useLiteLinkSegments } from "@/hooks/use-lite-link-segments"; -import { registerComposeFlush, waitForComposeFlush } from "@/lib/compose-flush-registry"; +import { useMinimisedComposerFlush } from "@/hooks/use-minimised-composer-flush"; +import { quickReplyEnvelope, saveQuickReplyDraft } from "@/lib/outgoing/quick-reply-envelope"; export interface MailAppProps { /** @@ -199,9 +199,6 @@ function freshMailboxes(previous: Mailbox[], timeoutMs = 10_000): Promise = {}; @@ -2272,16 +2269,7 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) { // A minimised composer's edits live only in pendingDraft. Before an // automatic sign-out it is opened again and saves them itself. - const minimisedUnsaved = !showComposer && pendingDraft?.unsaved === true; - useEffect(() => { - if (!minimisedUnsaved) return; - return registerComposeFlush(async () => { - const reopened = waitForComposeFlush(MINIMISED_COMPOSER_REOPEN_MS); - setShowComposer(true); - const flush = await reopened; - await flush?.(); - }); - }, [minimisedUnsaved]); + useMinimisedComposerFlush(!showComposer && pendingDraft?.unsaved === true, () => setShowComposer(true)); const handleReply = async (draftText?: string) => { if (selectedEmail) { @@ -3864,49 +3852,21 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) { }; }, [previewAttachment]); + // Before an automatic sign-out: the typed quick reply is kept as a draft. + const handleSaveQuickReplyDraft = async (body: string) => { + if (!client || !selectedEmail) return; + await saveQuickReplyDraft({ client, email: selectedEmail, identities, body, replyPrefix: t('email_composer.prefix.reply') }); + }; + const handleQuickReply = async (body: string) => { if (!client || !selectedEmail) return; - // Quick reply follows the same addressing rules as the composer: Reply-To - // over From, and for our own messages in a thread the original recipients - // instead of ourselves (#703). - const ownIdentityEmails = identities.map(i => i.email).filter(Boolean); - const replySource = { - from: selectedEmail.from, - replyToAddresses: selectedEmail.replyTo, - to: selectedEmail.to, - cc: selectedEmail.cc, - }; - const recipients = buildReplyRecipients(replySource, 'reply', ownIdentityEmails).to - .map(r => r.email) - .filter((email): email is string => Boolean(email)); + // Quick reply follows the composer's addressing rules and sends from the + // address the message was delivered to (lib/outgoing/quick-reply-envelope.ts). + const { recipients, sendingIdentity, primaryIdentity } = quickReplyEnvelope(selectedEmail, identities); if (recipients.length === 0) { throw new Error("No sender email found"); } - - const primaryIdentity = identities[0]; - - // Send from the address the message was delivered to, so a reply out of a - // shared or aliased mailbox does not go out as the account owner. Our own - // message keeps the identity it was sent from - the recipients are the - // other party, so resolving from them would send as their address. - // - // Quick reply resolves the user's OWN identities only. It deliberately does - // NOT take the domain catch-all `From:` rewrite that the full composer - // offers: this surface is a bare text box with no From row, so a rewritten - // From would be applied with nothing on screen to show it - or to correct - // it. A catch-all delivery quick-replies as the matching own identity, and - // the user can open the full composer when they need the rewrite. - const selfSentIdentityId = isSelfSent(replySource, ownIdentityEmails) - ? findDraftIdentityId(identities, selectedEmail.from?.[0]) - : null; - const sendingIdentityId = selfSentIdentityId ?? findReplyIdentityId(identities, { - to: selectedEmail.to, - cc: selectedEmail.cc, - bcc: selectedEmail.bcc, - }); - const sendingIdentity = - (sendingIdentityId ? identities.find((i) => i.id === sendingIdentityId) : undefined) || primaryIdentity; const headerFromEmail = sendingIdentity?.email; const headerFromName = sendingIdentity?.name || undefined; @@ -5465,6 +5425,7 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) { }} onDownloadAttachment={handleDownloadAttachment} onQuickReply={handleQuickReply} + onSaveQuickReplyDraft={handleSaveQuickReplyDraft} onBack={handleMobileBack} onNavigateNext={handleNavigateNext} onNavigatePrev={handleNavigatePrev} diff --git a/components/pro/pro-email-tab-body.tsx b/components/pro/pro-email-tab-body.tsx index 255f9fa39..8c8288b87 100644 --- a/components/pro/pro-email-tab-body.tsx +++ b/components/pro/pro-email-tab-body.tsx @@ -24,6 +24,7 @@ import { cn } from "@/lib/utils"; import { getDraftTextBody } from '@/lib/email-body-selection'; import { messagePriorityFromHeaders } from '@/lib/message-priority'; import { useArchiveActionEnabled } from '@/lib/archive-gate'; +import { saveQuickReplyDraft } from '@/lib/outgoing/quick-reply-envelope'; interface ProEmailTabBodyProps { tabId: string; @@ -399,6 +400,12 @@ export function ProEmailView({ emailId, client: clientOverride, clientAccountId, handleReply(body); }, [handleReply]); + // Before an automatic sign-out: the typed quick reply is kept as a draft. + const handleSaveQuickReplyDraft = useCallback(async (body: string) => { + if (!client || !email) return; + await saveQuickReplyDraft({ client, email, identities, body, replyPrefix: t('email_composer.prefix.reply') }); + }, [client, email, identities, t]); + const handleEditDraft = useCallback(() => { if (!email) return; @@ -473,6 +480,7 @@ export function ProEmailView({ emailId, client: clientOverride, clientAccountId, onSetTag={handleSetTag} onDownloadAttachment={handleDownloadAttachment} onQuickReply={handleQuickReply} + onSaveQuickReplyDraft={handleSaveQuickReplyDraft} onEditDraft={handleEditDraft} onMoveToMailbox={handleMoveToMailbox} currentUserEmail={client?.getUsername()} diff --git a/hooks/__tests__/use-minimised-composer-flush.test.tsx b/hooks/__tests__/use-minimised-composer-flush.test.tsx new file mode 100644 index 000000000..61031d1ae --- /dev/null +++ b/hooks/__tests__/use-minimised-composer-flush.test.tsx @@ -0,0 +1,53 @@ +import { renderHook } from '@testing-library/react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { MINIMISED_COMPOSER_REOPEN_MS, useMinimisedComposerFlush } from '../use-minimised-composer-flush'; +import { flushComposers, registerComposeFlush, resetComposeFlushRegistry } from '@/lib/compose-flush-registry'; + +/** + * mail-app keeps a minimised composer's edits in memory only. Before an + * automatic sign-out the hook opens the composer again and lets it save. + */ +describe('minimised composer flush', () => { + beforeEach(() => { + resetComposeFlushRegistry(); + vi.useFakeTimers(); + }); + afterEach(() => { + vi.useRealTimers(); + }); + + it('opens the minimised composer again and runs its own save', async () => { + const save = vi.fn(async () => {}); + // Opening the composer mounts it, and it registers its flush. + const reopen = vi.fn(() => { registerComposeFlush(save); }); + renderHook(() => useMinimisedComposerFlush(true, reopen)); + + const result = await flushComposers(8000); + + expect(reopen).toHaveBeenCalledTimes(1); + expect(save).toHaveBeenCalledTimes(1); + expect(result).toEqual({ total: 1, settled: 1, failed: 0, timedOut: false }); + }); + + it('registers nothing without unsaved minimised edits', async () => { + const reopen = vi.fn(); + renderHook(() => useMinimisedComposerFlush(false, reopen)); + + await expect(flushComposers(8000)).resolves.toMatchObject({ total: 0 }); + expect(reopen).not.toHaveBeenCalled(); + }); + + it('counts a composer that does not come back as a draft not saved', async () => { + renderHook(() => useMinimisedComposerFlush(true, () => {})); + + const done = flushComposers(8000); + await vi.advanceTimersByTimeAsync(MINIMISED_COMPOSER_REOPEN_MS); + await expect(done).resolves.toEqual({ total: 1, settled: 1, failed: 1, timedOut: false }); + }); + + it('stops registering once the composer is open again', async () => { + const { rerender } = renderHook(({ minimised }) => useMinimisedComposerFlush(minimised, () => {}), { initialProps: { minimised: true } }); + rerender({ minimised: false }); + await expect(flushComposers(8000)).resolves.toMatchObject({ total: 0 }); + }); +}); diff --git a/hooks/use-minimised-composer-flush.ts b/hooks/use-minimised-composer-flush.ts new file mode 100644 index 000000000..28b1ccccd --- /dev/null +++ b/hooks/use-minimised-composer-flush.ts @@ -0,0 +1,26 @@ +import { useEffect, useRef } from 'react'; +import { registerComposeFlush, waitForComposeFlush } from '@/lib/compose-flush-registry'; + +/** How long an automatic sign-out waits for a minimised composer to open again and register. */ +export const MINIMISED_COMPOSER_REOPEN_MS = 3000; + +/** + * A minimised composer's edits live only in its host's memory. While there + * are unsaved ones, an automatic sign-out opens the composer again (`reopen`) + * and lets it save itself (lib/compose-flush-registry.ts). A composer that + * doesn't come back in time counts as a draft that wasn't saved. + */ +export function useMinimisedComposerFlush(minimisedUnsaved: boolean, reopen: () => void): void { + const reopenRef = useRef(reopen); + reopenRef.current = reopen; + useEffect(() => { + if (!minimisedUnsaved) return; + return registerComposeFlush(async () => { + const reopened = waitForComposeFlush(MINIMISED_COMPOSER_REOPEN_MS); + reopenRef.current(); + const flush = await reopened; + if (!flush) throw new Error('The minimised message did not open again'); + await flush(); + }); + }, [minimisedUnsaved]); +} diff --git a/lib/outgoing/__tests__/quick-reply-envelope.test.ts b/lib/outgoing/__tests__/quick-reply-envelope.test.ts new file mode 100644 index 000000000..cf6d32a44 --- /dev/null +++ b/lib/outgoing/__tests__/quick-reply-envelope.test.ts @@ -0,0 +1,46 @@ +import { describe, expect, it, vi } from 'vitest'; +import { quickReplyEnvelope, saveQuickReplyDraft } from '../quick-reply-envelope'; +import type { Email, Identity } from '@/lib/jmap/types'; + +const me: Identity = { id: 'id-me', name: 'Org Alice', email: 'org-alice@example.org', mayDelete: false }; +const team: Identity = { id: 'id-team', name: 'Org Team', email: 'org-team@example.org', mayDelete: false }; + +const message = { + id: 'm1', + subject: 'Quarterly numbers', + from: [{ name: 'Bob', email: 'org-bob@example.org' }], + replyTo: [{ name: 'Bob (desk)', email: 'desk@example.org' }], + to: [{ name: 'Team', email: 'org-team@example.org' }], + cc: [], + messageId: ['abc@example.org'], + references: ['root@example.org'], +} as unknown as Email; + +describe('quick reply addressing', () => { + it('answers Reply-To from the identity the message was delivered to', () => { + const envelope = quickReplyEnvelope(message, [me, team]); + expect(envelope.recipients).toEqual(['desk@example.org']); + expect(envelope.sendingIdentity?.id).toBe('id-team'); + expect(envelope.primaryIdentity?.id).toBe('id-me'); + }); +}); + +describe('saving a quick reply as a draft before an automatic sign-out', () => { + it('saves the typed text as a threaded reply draft', async () => { + const createDraft = vi.fn().mockResolvedValue('d1'); + await saveQuickReplyDraft({ client: { createDraft } as never, email: message, identities: [me, team], body: 'On it.', replyPrefix: 'Re:' }); + + expect(createDraft).toHaveBeenCalledTimes(1); + const [to, subject, body, , , identityId, fromEmail, draftId, , fromName, htmlBody, inReplyTo, references] = createDraft.mock.calls[0]; + expect(to).toEqual(['desk@example.org']); + expect(subject).toBe('Re: Quarterly numbers'); + expect(body).toBe('On it.'); + expect(identityId).toBe('id-team'); + expect(fromEmail).toBe('org-team@example.org'); + expect(fromName).toBe('Org Team'); + expect(draftId).toBeUndefined(); + expect(htmlBody).toBeUndefined(); + expect(inReplyTo).toEqual(['abc@example.org']); + expect(references).toEqual(['root@example.org', 'abc@example.org']); + }); +}); diff --git a/lib/outgoing/quick-reply-envelope.ts b/lib/outgoing/quick-reply-envelope.ts new file mode 100644 index 000000000..9b863865c --- /dev/null +++ b/lib/outgoing/quick-reply-envelope.ts @@ -0,0 +1,74 @@ +import type { IJMAPClient } from '@/lib/jmap/client-interface'; +import type { Email, Identity } from '@/lib/jmap/types'; +import { computeReplyThreadingHeaders } from '@/lib/email-threading'; +import { findDraftIdentityId, findReplyIdentityId } from '@/lib/reply-identity'; +import { buildReplyRecipients, isSelfSent } from '@/lib/reply-recipients'; +import { buildReplySubject } from '@/lib/subject-prefix'; + +type ReplySource = Pick; + +export interface QuickReplyEnvelope { + recipients: string[]; + /** The identity the reply goes out as, the primary one when none matches. */ + sendingIdentity: Identity | undefined; + primaryIdentity: Identity | undefined; +} + +/** + * Who a quick reply goes to and from whom: the composer's addressing rules + * (Reply-To over From; for our own messages in a thread, the original + * recipients instead of ourselves, #703), from the address the message was + * delivered to, or for our own message the identity it was sent from. + * + * Quick reply resolves the user's OWN identities only. It deliberately does + * NOT take the domain catch-all `From:` rewrite that the full composer + * offers: this surface is a bare text box with no From row, so a rewritten + * From would be applied with nothing on screen to show it - or to correct + * it. + */ +export function quickReplyEnvelope(email: ReplySource, identities: Identity[]): QuickReplyEnvelope { + const ownIdentityEmails = identities.map((i) => i.email).filter(Boolean); + const replySource = { from: email.from, replyToAddresses: email.replyTo, to: email.to, cc: email.cc }; + const recipients = buildReplyRecipients(replySource, 'reply', ownIdentityEmails).to + .map((r) => r.email) + .filter((address): address is string => Boolean(address)); + const primaryIdentity = identities[0]; + const selfSentIdentityId = isSelfSent(replySource, ownIdentityEmails) + ? findDraftIdentityId(identities, email.from?.[0]) + : null; + const sendingIdentityId = selfSentIdentityId ?? findReplyIdentityId(identities, { to: email.to, cc: email.cc, bcc: email.bcc }); + const sendingIdentity = (sendingIdentityId ? identities.find((i) => i.id === sendingIdentityId) : undefined) || primaryIdentity; + return { recipients, sendingIdentity, primaryIdentity }; +} + +/** + * Saves the text typed into a quick reply as a draft reply, before an + * automatic sign-out (lib/compose-flush-registry.ts). Plain text, threaded + * like the reply would be; the signature is added when it is sent. + */ +export async function saveQuickReplyDraft(input: { + client: IJMAPClient; + email: Email; + identities: Identity[]; + body: string; + replyPrefix: string; +}): Promise { + const { client, email, identities, body, replyPrefix } = input; + const { recipients, sendingIdentity } = quickReplyEnvelope(email, identities); + const threading = computeReplyThreadingHeaders({ messageId: email.messageId, references: email.references }); + await client.createDraft( + recipients, + buildReplySubject(email.subject || '', replyPrefix), + body, + undefined, + undefined, + sendingIdentity?.id, + sendingIdentity?.email, + undefined, + undefined, + sendingIdentity?.name || undefined, + undefined, + threading?.inReplyTo, + threading?.references, + ); +}