mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-10-31 08:13:50 +00:00
fix: wait for sends and keep quick replies as drafts before an automatic sign-out
This commit is contained in:
@@ -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',
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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 });
|
||||
});
|
||||
});
|
||||
@@ -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']);
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user