fix: wait for sends and keep quick replies as drafts before an automatic sign-out

This commit is contained in:
Linus Rath
2026-10-28 04:06:05 +01:00
parent 65e83e4b0d
commit ecbbe137c4
10 changed files with 312 additions and 57 deletions
@@ -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<void>((resolve) => { finishSend = resolve; }));
render(<EmailComposer initialData={DRAFT} onClose={vi.fn()} onSend={onSend} />);
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(<EmailComposer initialData={DRAFT} onClose={vi.fn()} />);
fireEvent.change(screen.getByDisplayValue('Half-written'), { target: { value: 'Half-written more' } });
let result: Awaited<ReturnType<typeof flushComposers>> | 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(<EmailComposer initialData={DRAFT} onClose={vi.fn()} onSaveState={onSaveState} />);
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(<EmailComposer initialData={DRAFT} onClose={vi.fn()} onSaveState={onSaveState} />);
@@ -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(
<EmailViewer email={makeEmail()} mailboxes={mailboxes} selectedMailbox="inbox" currentMailboxRole="inbox"
onQuickReply={vi.fn()} onSaveQuickReplyDraft={onSaveQuickReplyDraft} />,
);
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',
+13 -2
View File
@@ -142,6 +142,9 @@ import { toUnicodeEmail } from "@/lib/idn";
* <p> 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
+17
View File
@@ -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<void>;
/** Keeps the typed quick reply as a draft before an automatic sign-out (lib/compose-flush-registry.ts). */
onSaveQuickReplyDraft?: (body: string) => Promise<void>;
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);
+14 -53
View File
@@ -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<Mailbo
*/
const MV_CONVERSATION_WAIT_MS = 800;
/** How long an automatic sign-out waits for a minimised composer to open again and register. */
const MINIMISED_COMPOSER_REOPEN_MS = 3000;
/** Plugin buttons in the top bar are not about any one item. */
const TOP_BAR_CONTEXT: Record<string, unknown> = {};
@@ -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}
+8
View File
@@ -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()}
@@ -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 });
});
});
+26
View File
@@ -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]);
}
@@ -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']);
});
});
+74
View File
@@ -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<Email, 'from' | 'replyTo' | 'to' | 'cc' | 'bcc'>;
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<void> {
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,
);
}