mirror of
https://github.com/bulwarkmail/native.git
synced 2026-10-31 16:06:32 +00:00
fix: reply to Reply-To, drop own addresses on reply-all, thread with the Message-ID and keep attachments on forward (#234, #703)
This commit is contained in:
+78
-13
@@ -1,27 +1,92 @@
|
|||||||
import type { EmailAddress } from '../api/types';
|
import type { Attachment, EmailAddress } from '../api/types';
|
||||||
|
|
||||||
|
/** The message a reply / forward / draft edit starts from. */
|
||||||
|
export interface ComposeReplyContext {
|
||||||
|
from: EmailAddress;
|
||||||
|
to?: EmailAddress[];
|
||||||
|
cc?: EmailAddress[];
|
||||||
|
bcc?: EmailAddress[];
|
||||||
|
/** Original `Reply-To` header - wins over `from` for a reply (RFC 5322). */
|
||||||
|
replyToAddresses?: EmailAddress[];
|
||||||
|
subject: string;
|
||||||
|
/** Plain-text body used for the quote when no HTML body exists. */
|
||||||
|
body?: string;
|
||||||
|
/** Sanitised HTML body for a layout-preserving quote. */
|
||||||
|
htmlBody?: string;
|
||||||
|
receivedAt?: string;
|
||||||
|
sentAt?: string;
|
||||||
|
/**
|
||||||
|
* RFC 5322 threading, bare msg-ids. `messageId` is the original's
|
||||||
|
* Message-ID; the composer derives In-Reply-To/References from it and the
|
||||||
|
* original's `references`. (The JMAP object id is never a valid msg-id.)
|
||||||
|
*/
|
||||||
|
messageId?: string[] | null;
|
||||||
|
references?: string[] | null;
|
||||||
|
/** @deprecated legacy string form; prefer `messageId`/`references`. */
|
||||||
|
inReplyTo?: string;
|
||||||
|
/** Original attachments to carry along on a forward (blob refs). */
|
||||||
|
attachments?: Attachment[];
|
||||||
|
/** JMAP id of the original message, for `$answered` / `$forwarded`. */
|
||||||
|
originalEmailId?: string;
|
||||||
|
/** Owning JMAP account when the original lives in a shared/group account. */
|
||||||
|
jmapAccountId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** An existing server draft being re-opened for editing. */
|
||||||
|
export interface ComposeDraftContext {
|
||||||
|
id: string;
|
||||||
|
jmapAccountId?: string;
|
||||||
|
from?: EmailAddress[];
|
||||||
|
to?: EmailAddress[];
|
||||||
|
cc?: EmailAddress[];
|
||||||
|
bcc?: EmailAddress[];
|
||||||
|
subject?: string;
|
||||||
|
htmlBody?: string;
|
||||||
|
textBody?: string;
|
||||||
|
attachments?: Attachment[];
|
||||||
|
messageId?: string[] | null;
|
||||||
|
inReplyTo?: string[] | null;
|
||||||
|
references?: string[] | null;
|
||||||
|
}
|
||||||
|
|
||||||
export type RootStackParamList = {
|
export type RootStackParamList = {
|
||||||
MainTabs: undefined;
|
MainTabs: undefined;
|
||||||
EmailThread: { emailId: string; threadId: string; subject?: string; jmapAccountId?: string };
|
EmailThread: {
|
||||||
|
emailId: string;
|
||||||
|
threadId: string;
|
||||||
|
subject?: string;
|
||||||
|
jmapAccountId?: string;
|
||||||
|
/**
|
||||||
|
* Ids to page over when the message was opened from a list other than
|
||||||
|
* the active folder (unified inbox, contact activity). When absent the
|
||||||
|
* viewer pages over the store's current folder.
|
||||||
|
*/
|
||||||
|
emailIds?: string[];
|
||||||
|
};
|
||||||
EmailSource: { emailId: string; blobId: string; subject?: string; jmapAccountId?: string };
|
EmailSource: { emailId: string; blobId: string; subject?: string; jmapAccountId?: string };
|
||||||
Compose:
|
Compose:
|
||||||
| {
|
| {
|
||||||
mode?: 'reply' | 'replyAll' | 'forward';
|
mode?: 'reply' | 'replyAll' | 'forward';
|
||||||
replyTo?: {
|
replyTo?: ComposeReplyContext;
|
||||||
from: EmailAddress;
|
draft?: ComposeDraftContext;
|
||||||
to?: EmailAddress[];
|
|
||||||
cc?: EmailAddress[];
|
|
||||||
subject: string;
|
|
||||||
body?: string;
|
|
||||||
receivedAt?: string;
|
|
||||||
inReplyTo?: string;
|
|
||||||
references?: string;
|
|
||||||
};
|
|
||||||
prefillTo?: EmailAddress[];
|
prefillTo?: EmailAddress[];
|
||||||
|
prefillCc?: EmailAddress[];
|
||||||
|
prefillBcc?: EmailAddress[];
|
||||||
|
prefillSubject?: string;
|
||||||
|
prefillBody?: string;
|
||||||
|
/** Files shared into the app (content:// or file:// URIs). */
|
||||||
|
prefillAttachments?: Array<{ uri: string; name: string; type: string; size?: number }>;
|
||||||
}
|
}
|
||||||
| undefined;
|
| undefined;
|
||||||
ContactDetail: { contactId: string };
|
ContactDetail: { contactId: string };
|
||||||
ContactForm: { contactId?: string; addressBookId?: string; asGroup?: boolean };
|
ContactForm: {
|
||||||
|
contactId?: string;
|
||||||
|
addressBookId?: string;
|
||||||
|
asGroup?: boolean;
|
||||||
|
prefill?: { email: string; name?: string };
|
||||||
|
/** Pre-selected member ids when creating a group. */
|
||||||
|
memberIds?: string[];
|
||||||
|
};
|
||||||
GroupDetail: { groupId: string };
|
GroupDetail: { groupId: string };
|
||||||
AddAccount: undefined;
|
AddAccount: undefined;
|
||||||
Scheduled: undefined;
|
Scheduled: undefined;
|
||||||
|
|||||||
+239
-54
@@ -35,9 +35,13 @@ import {
|
|||||||
matchesContactSearch,
|
matchesContactSearch,
|
||||||
} from '../lib/contact-utils';
|
} from '../lib/contact-utils';
|
||||||
import { getIdentities } from '../api/identity';
|
import { getIdentities } from '../api/identity';
|
||||||
import { sendEmail, type OutgoingAttachment } from '../api/email';
|
import { sendEmail, patchKeywordsForEmails, type OutgoingAttachment } from '../api/email';
|
||||||
import { jmapClient } from '../api/jmap-client';
|
import { jmapClient, RequestTimeoutError } from '../api/jmap-client';
|
||||||
import { uploadBlob } from '../api/blob';
|
import { uploadBlob, uploadBytes } from '../api/blob';
|
||||||
|
import { buildReplyRecipients, type ReplySource } from '../lib/reply-recipients';
|
||||||
|
import { buildReplySubject, buildForwardSubject } from '../lib/subject-prefix';
|
||||||
|
import { computeReplyThreadingHeaders } from '../lib/email-threading';
|
||||||
|
import { escapeHtml } from '../lib/email-html';
|
||||||
import { buildInitialHtml, htmlToPlainText, rewriteInlineImages } from '../lib/compose-html';
|
import { buildInitialHtml, htmlToPlainText, rewriteInlineImages } from '../lib/compose-html';
|
||||||
import { stripDangerousTags } from '../lib/email-html';
|
import { stripDangerousTags } from '../lib/email-html';
|
||||||
import type { EmailAddress, Identity, ContactCard } from '../api/types';
|
import type { EmailAddress, Identity, ContactCard } from '../api/types';
|
||||||
@@ -261,8 +265,12 @@ export default function ComposeScreen({ route, navigation }: Props) {
|
|||||||
// need to add it on top — pad by whichever is larger.
|
// need to add it on top — pad by whichever is larger.
|
||||||
const bottomPad = Math.max(kbObstruction, insets.bottom);
|
const bottomPad = Math.max(kbObstruction, insets.bottom);
|
||||||
const replyTo = route.params?.replyTo;
|
const replyTo = route.params?.replyTo;
|
||||||
|
const draft = route.params?.draft;
|
||||||
const mode = route.params?.mode ?? 'compose';
|
const mode = route.params?.mode ?? 'compose';
|
||||||
const prefillTo = route.params?.prefillTo;
|
const prefillTo = route.params?.prefillTo;
|
||||||
|
const prefillCc = route.params?.prefillCc;
|
||||||
|
const prefillSubject = route.params?.prefillSubject;
|
||||||
|
const prefillBody = route.params?.prefillBody;
|
||||||
const mailboxes = useEmailStore((s) => s.mailboxes);
|
const mailboxes = useEmailStore((s) => s.mailboxes);
|
||||||
// Always the user's own Sent — composing on behalf of a shared account
|
// Always the user's own Sent — composing on behalf of a shared account
|
||||||
// isn't supported, so a group account's Sent must never be picked up here.
|
// isn't supported, so a group account's Sent must never be picked up here.
|
||||||
@@ -270,6 +278,12 @@ export default function ComposeScreen({ route, navigation }: Props) {
|
|||||||
() => ownMailboxes(mailboxes).find((m) => m.role === 'sent'),
|
() => ownMailboxes(mailboxes).find((m) => m.role === 'sent'),
|
||||||
[mailboxes],
|
[mailboxes],
|
||||||
);
|
);
|
||||||
|
// The message is created in Drafts and moved to Sent by the submission's
|
||||||
|
// onSuccessUpdateEmail, so a failed send never leaves a fake sent copy (#188).
|
||||||
|
const draftsMailbox = React.useMemo(
|
||||||
|
() => ownMailboxes(mailboxes).find((m) => m.role === 'drafts'),
|
||||||
|
[mailboxes],
|
||||||
|
);
|
||||||
|
|
||||||
const [identities, setIdentities] = React.useState<Identity[]>([]);
|
const [identities, setIdentities] = React.useState<Identity[]>([]);
|
||||||
const [identityError, setIdentityError] = React.useState<string | null>(null);
|
const [identityError, setIdentityError] = React.useState<string | null>(null);
|
||||||
@@ -288,70 +302,129 @@ export default function ComposeScreen({ route, navigation }: Props) {
|
|||||||
const attachmentReminderKeywords = useSettingsStore((s) => s.attachmentReminderKeywords);
|
const attachmentReminderKeywords = useSettingsStore((s) => s.attachmentReminderKeywords);
|
||||||
const sendDelaySeconds = useSettingsStore((s) => s.sendDelaySeconds);
|
const sendDelaySeconds = useSettingsStore((s) => s.sendDelaySeconds);
|
||||||
|
|
||||||
const initialTo = React.useMemo<Recipient[]>(() => {
|
// Every address that is "us": the login, the account's primary address and
|
||||||
if (!replyTo) {
|
// every identity. Reply-all must not send the user a copy, and replying to
|
||||||
if (prefillTo && prefillTo.length > 0) {
|
// a self-sent message continues to its original recipients (#703).
|
||||||
return prefillTo
|
const ownEmails = React.useMemo(() => {
|
||||||
.filter((r) => !!r.email)
|
const out = new Set<string>();
|
||||||
.map((r) => ({ name: r.name ?? '', email: r.email }));
|
const active = useAccountStore.getState().getActiveAccount();
|
||||||
}
|
for (const e of [active?.email, active?.username, jmapClient.username]) {
|
||||||
return [];
|
if (e && e.includes('@')) out.add(e);
|
||||||
}
|
}
|
||||||
if (mode === 'forward') return [];
|
for (const i of identities) if (i.email) out.add(i.email);
|
||||||
const base: Recipient[] = replyTo.from.email
|
return Array.from(out);
|
||||||
? [{ name: replyTo.from.name ?? '', email: replyTo.from.email }]
|
}, [identities]);
|
||||||
: [];
|
|
||||||
if (mode === 'replyAll' && replyTo.to) {
|
|
||||||
for (const r of replyTo.to) {
|
|
||||||
if (r.email && !base.some((b) => b.email === r.email)) {
|
|
||||||
base.push({ name: r.name ?? '', email: r.email });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return base;
|
|
||||||
}, [replyTo, mode, prefillTo]);
|
|
||||||
|
|
||||||
const initialCc = React.useMemo<Recipient[]>(() => {
|
const replySource = React.useMemo<ReplySource | undefined>(
|
||||||
if (mode !== 'replyAll' || !replyTo?.cc) return [];
|
() => (replyTo
|
||||||
return replyTo.cc
|
|
||||||
.filter((r) => !!r.email)
|
|
||||||
.map((r) => ({ name: r.name ?? '', email: r.email }));
|
|
||||||
}, [replyTo, mode]);
|
|
||||||
|
|
||||||
const initialSubject = React.useMemo(() => {
|
|
||||||
if (!replyTo) return '';
|
|
||||||
const s = replyTo.subject ?? '';
|
|
||||||
if (mode === 'forward') {
|
|
||||||
return /^fwd?:/i.test(s) ? s : `Fwd: ${s}`;
|
|
||||||
}
|
|
||||||
return /^re:/i.test(s) ? s : `Re: ${s}`;
|
|
||||||
}, [replyTo, mode]);
|
|
||||||
|
|
||||||
const initialBodyHtml = React.useMemo(
|
|
||||||
() => buildInitialHtml(mode, replyTo
|
|
||||||
? {
|
? {
|
||||||
from: { name: replyTo.from.name, email: replyTo.from.email },
|
from: replyTo.from.email ? [replyTo.from] : [],
|
||||||
|
replyToAddresses: replyTo.replyToAddresses,
|
||||||
to: replyTo.to,
|
to: replyTo.to,
|
||||||
cc: replyTo.cc,
|
cc: replyTo.cc,
|
||||||
subject: replyTo.subject,
|
|
||||||
body: replyTo.body,
|
|
||||||
receivedAt: replyTo.receivedAt,
|
|
||||||
}
|
}
|
||||||
: null,
|
: undefined),
|
||||||
{ timeFormat, locale, unknownLabel: t('common.unknown', 'Unknown') },
|
[replyTo],
|
||||||
),
|
|
||||||
[mode, replyTo, timeFormat, locale, t],
|
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const toRecipientList = (list: Array<{ name?: string; email?: string }> | undefined): Recipient[] =>
|
||||||
|
(list ?? [])
|
||||||
|
.filter((r): r is { name?: string; email: string } => !!r.email)
|
||||||
|
.map((r) => ({ name: r.name ?? '', email: r.email }));
|
||||||
|
|
||||||
|
const initialTo = React.useMemo<Recipient[]>(() => {
|
||||||
|
if (draft) return toRecipientList(draft.to);
|
||||||
|
if (!replyTo) return toRecipientList(prefillTo);
|
||||||
|
if (mode === 'forward') return [];
|
||||||
|
return toRecipientList(
|
||||||
|
buildReplyRecipients(replySource, mode === 'replyAll' ? 'replyAll' : 'reply', ownEmails).to,
|
||||||
|
);
|
||||||
|
// Seeds state once; identity-based refinement happens in the effect below.
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [replyTo, draft, mode, prefillTo]);
|
||||||
|
|
||||||
|
const initialCc = React.useMemo<Recipient[]>(() => {
|
||||||
|
if (draft) return toRecipientList(draft.cc);
|
||||||
|
if (!replyTo) return toRecipientList(prefillCc);
|
||||||
|
if (mode !== 'replyAll') return [];
|
||||||
|
return toRecipientList(buildReplyRecipients(replySource, 'replyAll', ownEmails).cc);
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [replyTo, draft, mode, prefillCc]);
|
||||||
|
|
||||||
|
const initialBcc = React.useMemo<Recipient[]>(
|
||||||
|
() => (draft ? toRecipientList(draft.bcc) : toRecipientList(route.params?.prefillBcc)),
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
[draft],
|
||||||
|
);
|
||||||
|
|
||||||
|
const initialSubject = React.useMemo(() => {
|
||||||
|
if (draft) return draft.subject ?? '';
|
||||||
|
if (!replyTo) return prefillSubject ?? '';
|
||||||
|
// Strip stacked / foreign-language prefixes (AW:, WG:, Re[2]:) before
|
||||||
|
// adding the locale's own so the chain doesn't grow on every hop.
|
||||||
|
if (mode === 'forward') {
|
||||||
|
return buildForwardSubject(replyTo.subject, t('email_composer.prefix.forward', 'Fwd:'));
|
||||||
|
}
|
||||||
|
return buildReplySubject(replyTo.subject, t('email_composer.prefix.reply', 'Re:'));
|
||||||
|
}, [replyTo, draft, mode, prefillSubject, t]);
|
||||||
|
|
||||||
|
const initialBodyHtml = React.useMemo(
|
||||||
|
() => {
|
||||||
|
if (draft) {
|
||||||
|
if (draft.htmlBody) return stripDangerousTags(draft.htmlBody);
|
||||||
|
if (draft.textBody) return `<div>${escapeHtml(draft.textBody).replace(/\r?\n/g, '<br>')}</div>`;
|
||||||
|
return '<p><br></p>';
|
||||||
|
}
|
||||||
|
if (!replyTo) {
|
||||||
|
return prefillBody
|
||||||
|
? `<div>${escapeHtml(prefillBody).replace(/\r?\n/g, '<br>')}</div><p><br></p>`
|
||||||
|
: '<p><br></p>';
|
||||||
|
}
|
||||||
|
return buildInitialHtml(mode, {
|
||||||
|
from: { name: replyTo.from.name, email: replyTo.from.email },
|
||||||
|
to: replyTo.to,
|
||||||
|
cc: replyTo.cc,
|
||||||
|
subject: replyTo.subject,
|
||||||
|
body: replyTo.body,
|
||||||
|
htmlBody: replyTo.htmlBody,
|
||||||
|
receivedAt: replyTo.sentAt ?? replyTo.receivedAt,
|
||||||
|
}, { timeFormat, locale, unknownLabel: t('common.unknown', 'Unknown') });
|
||||||
|
},
|
||||||
|
[mode, replyTo, draft, prefillBody, timeFormat, locale, t],
|
||||||
|
);
|
||||||
|
|
||||||
|
// Attachments carried over from the original (forward) or the re-opened
|
||||||
|
// draft. Blobs are account-scoped: a message in a shared/group account has
|
||||||
|
// to be re-uploaded into the user's own account before it can be sent.
|
||||||
|
const seedAttachments = replyTo?.attachments ?? draft?.attachments;
|
||||||
|
const seedOwnerAccountId = replyTo?.jmapAccountId ?? draft?.jmapAccountId;
|
||||||
|
const initialAttachments = React.useMemo<AttachmentEntry[]>(() => {
|
||||||
|
if (!seedAttachments?.length) return [];
|
||||||
|
return seedAttachments
|
||||||
|
.filter((a) => !!a.blobId)
|
||||||
|
.map((a) => ({
|
||||||
|
localId: `seed-${a.blobId}`,
|
||||||
|
name: a.name || 'attachment',
|
||||||
|
type: a.type || 'application/octet-stream',
|
||||||
|
size: a.size ?? 0,
|
||||||
|
uri: '',
|
||||||
|
inline: false,
|
||||||
|
blobId: seedOwnerAccountId ? undefined : a.blobId,
|
||||||
|
uploading: !!seedOwnerAccountId,
|
||||||
|
}));
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, []);
|
||||||
|
|
||||||
const [toRecipients, setToRecipients] = React.useState<Recipient[]>(initialTo);
|
const [toRecipients, setToRecipients] = React.useState<Recipient[]>(initialTo);
|
||||||
const [ccRecipients, setCcRecipients] = React.useState<Recipient[]>(initialCc);
|
const [ccRecipients, setCcRecipients] = React.useState<Recipient[]>(initialCc);
|
||||||
const [ccVisible, setCcVisible] = React.useState(initialCc.length > 0);
|
const [bccRecipients, setBccRecipients] = React.useState<Recipient[]>(initialBcc);
|
||||||
|
const [ccVisible, setCcVisible] = React.useState(initialCc.length > 0 || initialBcc.length > 0);
|
||||||
const [toInput, setToInput] = React.useState('');
|
const [toInput, setToInput] = React.useState('');
|
||||||
const [ccInput, setCcInput] = React.useState('');
|
const [ccInput, setCcInput] = React.useState('');
|
||||||
const [subject, setSubject] = React.useState(initialSubject);
|
const [subject, setSubject] = React.useState(initialSubject);
|
||||||
const [bodyHtml, setBodyHtml] = React.useState(initialBodyHtml);
|
const [bodyHtml, setBodyHtml] = React.useState(initialBodyHtml);
|
||||||
const [activeField, setActiveField] = React.useState<'to' | 'cc' | null>(null);
|
const [activeField, setActiveField] = React.useState<'to' | 'cc' | null>(null);
|
||||||
const [attachments, setAttachments] = React.useState<AttachmentEntry[]>([]);
|
const [attachments, setAttachments] = React.useState<AttachmentEntry[]>(initialAttachments);
|
||||||
const [selState, setSelState] = React.useState<RichTextSelectionState>({
|
const [selState, setSelState] = React.useState<RichTextSelectionState>({
|
||||||
bold: false, italic: false, underline: false, strikeThrough: false,
|
bold: false, italic: false, underline: false, strikeThrough: false,
|
||||||
ul: false, ol: false, blockquote: false, h1: false, h2: false,
|
ul: false, ol: false, blockquote: false, h1: false, h2: false,
|
||||||
@@ -418,6 +491,59 @@ export default function ComposeScreen({ route, navigation }: Props) {
|
|||||||
return () => { cancelled = true; };
|
return () => { cancelled = true; };
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
// Once the identities are known, recompute the reply recipients so every
|
||||||
|
// own alias is dropped from a reply-all (the initial seed only knew the
|
||||||
|
// login address). Skipped when the user already edited the fields.
|
||||||
|
const recipientsRefinedRef = React.useRef(false);
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (recipientsRefinedRef.current || identities.length === 0) return;
|
||||||
|
if (!replyTo || mode === 'forward') return;
|
||||||
|
recipientsRefinedRef.current = true;
|
||||||
|
const sameList = (a: Recipient[], b: Recipient[]) =>
|
||||||
|
a.length === b.length && a.every((r, i) => r.email === b[i].email);
|
||||||
|
if (!sameList(toRecipients, initialTo) || !sameList(ccRecipients, initialCc)) return;
|
||||||
|
const { to, cc } = buildReplyRecipients(
|
||||||
|
replySource,
|
||||||
|
mode === 'replyAll' ? 'replyAll' : 'reply',
|
||||||
|
ownEmails,
|
||||||
|
);
|
||||||
|
const nextTo = toRecipientList(to);
|
||||||
|
const nextCc = toRecipientList(cc);
|
||||||
|
if (!sameList(nextTo, toRecipients)) setToRecipients(nextTo);
|
||||||
|
if (!sameList(nextCc, ccRecipients)) {
|
||||||
|
setCcRecipients(nextCc);
|
||||||
|
if (nextCc.length > 0) setCcVisible(true);
|
||||||
|
}
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [identities]);
|
||||||
|
|
||||||
|
// Re-upload attachments that live in another account's blob store (forward
|
||||||
|
// from a shared folder) into the user's own account.
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (!seedOwnerAccountId || !seedAttachments?.length) return;
|
||||||
|
let cancelled = false;
|
||||||
|
void (async () => {
|
||||||
|
for (const a of seedAttachments) {
|
||||||
|
if (!a.blobId) continue;
|
||||||
|
const localId = `seed-${a.blobId}`;
|
||||||
|
try {
|
||||||
|
const buf = await jmapClient.fetchBlobArrayBuffer(a.blobId, a.name, a.type, seedOwnerAccountId);
|
||||||
|
const up = await uploadBytes(new Uint8Array(buf), a.type || 'application/octet-stream');
|
||||||
|
if (!cancelled) updateAttachment(localId, { blobId: up.blobId, size: up.size, uploading: false });
|
||||||
|
} catch (e) {
|
||||||
|
if (!cancelled) {
|
||||||
|
updateAttachment(localId, {
|
||||||
|
uploading: false,
|
||||||
|
error: e instanceof Error ? e.message : 'Upload failed',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
return () => { cancelled = true; };
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, []);
|
||||||
|
|
||||||
// Choose the identity once we know both the loaded identities and the reply
|
// Choose the identity once we know both the loaded identities and the reply
|
||||||
// context. With auto-select on, prefer the identity that received the
|
// context. With auto-select on, prefer the identity that received the
|
||||||
// original message (matches webmail's reply-identity behaviour).
|
// original message (matches webmail's reply-identity behaviour).
|
||||||
@@ -879,8 +1005,23 @@ export default function ComposeScreen({ route, navigation }: Props) {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Synchronous re-entry guard: `sending` state only flips after two awaits
|
||||||
|
// below, so two quick taps could both pass `canSend` and submit twice.
|
||||||
|
const sendingRef = React.useRef(false);
|
||||||
|
|
||||||
const performSend = async (holdForSeconds?: number, scheduledAt?: Date) => {
|
const performSend = async (holdForSeconds?: number, scheduledAt?: Date) => {
|
||||||
if (!canSend || !primaryIdentity || !sentMailbox) return;
|
if (!canSend || !primaryIdentity || !sentMailbox) return;
|
||||||
|
if (sendingRef.current) return;
|
||||||
|
sendingRef.current = true;
|
||||||
|
try {
|
||||||
|
await performSendInner(holdForSeconds, scheduledAt);
|
||||||
|
} finally {
|
||||||
|
sendingRef.current = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const performSendInner = async (holdForSeconds?: number, scheduledAt?: Date) => {
|
||||||
|
if (!primaryIdentity || !sentMailbox) return;
|
||||||
|
|
||||||
// Read the body straight from the editor DOM at send time. The `change`
|
// Read the body straight from the editor DOM at send time. The `change`
|
||||||
// messages that feed `bodyHtml` are async and best-effort — trusting them
|
// messages that feed `bodyHtml` are async and best-effort — trusting them
|
||||||
@@ -938,6 +1079,21 @@ export default function ComposeScreen({ route, navigation }: Props) {
|
|||||||
|
|
||||||
const outgoing = [...inlineFromBody, ...fileAttachments];
|
const outgoing = [...inlineFromBody, ...fileAttachments];
|
||||||
|
|
||||||
|
// RFC 5322 §3.6.4: only replies continue the thread; a forward starts
|
||||||
|
// a new one. The original's Message-ID (never its JMAP id) seeds
|
||||||
|
// In-Reply-To, and References accumulates the chain (#234).
|
||||||
|
const threading = replyTo && mode !== 'forward'
|
||||||
|
? computeReplyThreadingHeaders({ messageId: replyTo.messageId, references: replyTo.references })
|
||||||
|
: draft && (draft.inReplyTo?.length || draft.references?.length)
|
||||||
|
? { inReplyTo: draft.inReplyTo ?? [], references: draft.references ?? draft.inReplyTo ?? [] }
|
||||||
|
: null;
|
||||||
|
|
||||||
|
const identityBcc = (primaryIdentity.bcc ?? []).filter((r) => !!r.email);
|
||||||
|
const bccAll = [
|
||||||
|
...bccRecipients.map((r) => ({ name: r.name || undefined, email: r.email })),
|
||||||
|
...identityBcc,
|
||||||
|
];
|
||||||
|
|
||||||
const result = await sendEmail(
|
const result = await sendEmail(
|
||||||
{
|
{
|
||||||
from,
|
from,
|
||||||
@@ -945,19 +1101,36 @@ export default function ComposeScreen({ route, navigation }: Props) {
|
|||||||
cc: finalCc.length
|
cc: finalCc.length
|
||||||
? finalCc.map((r) => ({ name: r.name || undefined, email: r.email }))
|
? finalCc.map((r) => ({ name: r.name || undefined, email: r.email }))
|
||||||
: undefined,
|
: undefined,
|
||||||
|
bcc: bccAll.length ? bccAll : undefined,
|
||||||
|
// The identity's Reply-To rides along on every message sent with it.
|
||||||
|
replyTo: primaryIdentity.replyTo?.length ? primaryIdentity.replyTo : undefined,
|
||||||
subject,
|
subject,
|
||||||
// Plain-text mode: skip the HTML part entirely so receiving clients
|
// Plain-text mode: skip the HTML part entirely so receiving clients
|
||||||
// render the text/plain alternative without any formatting.
|
// render the text/plain alternative without any formatting.
|
||||||
htmlBody: plainTextMode ? undefined : finalHtml,
|
htmlBody: plainTextMode ? undefined : finalHtml,
|
||||||
textBody: finalText,
|
textBody: finalText,
|
||||||
attachments: outgoing.length ? outgoing : undefined,
|
attachments: outgoing.length ? outgoing : undefined,
|
||||||
inReplyTo: replyTo?.inReplyTo,
|
inReplyTo: threading?.inReplyTo,
|
||||||
references: replyTo?.references,
|
references: threading?.references,
|
||||||
|
messageId: draft?.messageId?.[0],
|
||||||
},
|
},
|
||||||
primaryIdentity.id,
|
primaryIdentity.id,
|
||||||
sentMailbox.id,
|
sentMailbox.id,
|
||||||
holdForSeconds,
|
holdForSeconds,
|
||||||
|
{ draftsMailboxId: draftsMailbox?.id, draftId: draft?.id },
|
||||||
);
|
);
|
||||||
|
if (result.filingWarning) {
|
||||||
|
console.warn('[compose] post-send filing warning:', result.filingWarning);
|
||||||
|
}
|
||||||
|
// Flag the original so the list shows the reply/forward arrow; best
|
||||||
|
// effort - the message already left.
|
||||||
|
if (replyTo?.originalEmailId) {
|
||||||
|
void patchKeywordsForEmails(
|
||||||
|
[replyTo.originalEmailId],
|
||||||
|
{ [mode === 'forward' ? '$forwarded' : '$answered']: true },
|
||||||
|
replyTo.jmapAccountId,
|
||||||
|
).catch(() => undefined);
|
||||||
|
}
|
||||||
// Confirm an explicit "send later" so the user knows it didn't go out
|
// Confirm an explicit "send later" so the user knows it didn't go out
|
||||||
// now. The brief undo-send delay stays silent — it's meant to be
|
// now. The brief undo-send delay stays silent — it's meant to be
|
||||||
// invisible unless the user cancels from the Scheduled view.
|
// invisible unless the user cancels from the Scheduled view.
|
||||||
@@ -973,6 +1146,18 @@ export default function ComposeScreen({ route, navigation }: Props) {
|
|||||||
}
|
}
|
||||||
navigation.goBack();
|
navigation.goBack();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
if (e instanceof RequestTimeoutError) {
|
||||||
|
// The request may have reached the server; a blind retry would send
|
||||||
|
// the message twice (#702).
|
||||||
|
Alert.alert(
|
||||||
|
t('email_composer.send_timeout_title', 'No answer from the server'),
|
||||||
|
t(
|
||||||
|
'email_composer.send_timeout_body',
|
||||||
|
'The message may already have gone out. Check your Sent folder before sending it again.',
|
||||||
|
),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
Alert.alert(
|
Alert.alert(
|
||||||
t('email_composer.send_failed', 'Send failed'),
|
t('email_composer.send_failed', 'Send failed'),
|
||||||
e instanceof Error ? e.message : 'Failed to send email',
|
e instanceof Error ? e.message : 'Failed to send email',
|
||||||
|
|||||||
@@ -367,16 +367,41 @@ export default function EmailThreadScreen({ route, navigation }: Props) {
|
|||||||
if (!email) return;
|
if (!email) return;
|
||||||
const from = email.from?.[0];
|
const from = email.from?.[0];
|
||||||
if (!from && mode !== 'forward') return;
|
if (!from && mode !== 'forward') return;
|
||||||
|
// Quote the HTML part when there is one so layout and inline images
|
||||||
|
// survive (#163). RFC 8621 §4.1.4: an HTML-only message exposes the same
|
||||||
|
// part in `textBody` and `htmlBody`, so the text part is only a real
|
||||||
|
// alternative when its partId differs - otherwise `plainTextBody` would
|
||||||
|
// hand the composer raw HTML source (#649).
|
||||||
|
const htmlPart = email.htmlBody?.[0];
|
||||||
|
const textPart = email.textBody?.[0];
|
||||||
|
const htmlValue = htmlPart?.partId ? email.bodyValues?.[htmlPart.partId]?.value : undefined;
|
||||||
|
const htmlIsHtml = !htmlPart?.type || /text\/html/i.test(htmlPart.type);
|
||||||
|
const quoteHtml = htmlValue && htmlIsHtml ? htmlValue : undefined;
|
||||||
|
const hasDistinctText = !!textPart?.partId && textPart.partId !== htmlPart?.partId;
|
||||||
|
const body = !quoteHtml || hasDistinctText ? plainTextBody(email) : undefined;
|
||||||
navigation.navigate('Compose', {
|
navigation.navigate('Compose', {
|
||||||
mode,
|
mode,
|
||||||
replyTo: {
|
replyTo: {
|
||||||
from: from ?? { email: '' },
|
from: from ?? { email: '' },
|
||||||
to: email.to,
|
to: email.to,
|
||||||
cc: email.cc,
|
cc: email.cc,
|
||||||
|
// RFC 5322: a reply goes to Reply-To when the sender set one.
|
||||||
|
replyToAddresses: email.replyTo,
|
||||||
subject: email.subject ?? '',
|
subject: email.subject ?? '',
|
||||||
body: plainTextBody(email),
|
body,
|
||||||
|
htmlBody: quoteHtml,
|
||||||
receivedAt: email.receivedAt,
|
receivedAt: email.receivedAt,
|
||||||
inReplyTo: email.id,
|
sentAt: email.sentAt,
|
||||||
|
// Threading needs the RFC Message-ID, never the JMAP object id (#234).
|
||||||
|
messageId: email.messageId ?? undefined,
|
||||||
|
references: email.references ?? undefined,
|
||||||
|
// Forward carries the original attachments as blob refs; cid-embedded
|
||||||
|
// inline images are already part of the quoted HTML.
|
||||||
|
attachments: mode === 'forward'
|
||||||
|
? (email.attachments ?? []).filter((a) => !(a.disposition === 'inline' && a.cid))
|
||||||
|
: undefined,
|
||||||
|
originalEmailId: email.id,
|
||||||
|
jmapAccountId: ownerAccountId,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user