diff --git a/src/components/email/QuickReplyBox.tsx b/src/components/email/QuickReplyBox.tsx index e857060c..e57522a7 100644 --- a/src/components/email/QuickReplyBox.tsx +++ b/src/components/email/QuickReplyBox.tsx @@ -13,7 +13,8 @@ import { jmapClient } from '../../api/jmap-client'; import { buildReplyRecipients } from '../../lib/reply-recipients'; import { buildReplySubject } from '../../lib/subject-prefix'; import { computeReplyThreadingHeaders } from '../../lib/email-threading'; -import { findReceivingIdentity } from '../../lib/email-headers'; +import { resolveReplyIdentity } from '../../lib/reply-identity'; +import { signatureIdentityFor, signPlainTextReply } from '../../lib/signature-utils'; import { pickEmailBody, plainTextBody } from '../../lib/email-body'; import { htmlToPlainText } from '../../lib/compose-html'; import { mailboxesOfAccount } from '../../lib/mailbox-tree'; @@ -41,6 +42,8 @@ export function QuickReplyBox({ email, jmapAccountId, onMoreOptions, onSent }: P const timeFormat = useSettingsStore((s) => s.timeFormat); const identities = useSettingsStore((s) => s.identities); const sendDelaySeconds = useSettingsStore((s) => s.sendDelaySeconds); + const signaturePosition = useSettingsStore((s) => s.signaturePosition); + const signatureSeparatorEnabled = useSettingsStore((s) => s.signatureSeparatorEnabled); const mailboxes = useEmailStore((s) => s.mailboxes); const [text, setText] = React.useState(''); const [sending, setSending] = React.useState(false); @@ -53,7 +56,14 @@ export function QuickReplyBox({ email, jmapAccountId, onMoreOptions, onSent }: P const send = async () => { const body = text.trim(); if (!body || sending) return; - const identity = findReceivingIdentity(identities, email) ?? identities[0]; + const ownEmails = identities.map((i) => i.email).filter(Boolean); + // The own identity the message was delivered to (or, for our own message, + // the one that sent it). Never the catch-all From rewrite: this box has no + // From row to show or correct it. + const resolved = resolveReplyIdentity(identities, { + from, to: email.to ?? undefined, cc: email.cc ?? undefined, bcc: email.bcc ?? undefined, + }, { ownEmails, catchAll: false }); + const identity = identities.find((i) => i.id === resolved?.identityId) ?? identities[0]; if (!identity) { Alert.alert(t('common.error', 'Error'), t('email_viewer.unsubscribe_banner.no_identity', 'No sending identity available')); return; @@ -68,7 +78,6 @@ export function QuickReplyBox({ email, jmapAccountId, onMoreOptions, onSent }: P } setSending(true); try { - const ownEmails = identities.map((i) => i.email).filter(Boolean); const recipients = buildReplyRecipients( { from: email.from, replyToAddresses: email.replyTo, to: email.to, cc: email.cc }, 'reply', @@ -85,7 +94,12 @@ export function QuickReplyBox({ email, jmapAccountId, onMoreOptions, onSent }: P to: recipients.to.filter((r) => !!r.email).map((r) => ({ email: r.email!, name: r.name })), cc: recipients.cc.filter((r) => !!r.email).map((r) => ({ email: r.email!, name: r.name })), subject: buildReplySubject(email.subject, t('email_composer.prefix.reply', 'Re:')), - textBody: `${body}\n\n${header}\n${quoted}`, + // Signed as the composer signs a plain-text reply; an alias without a + // signature of its own carries the primary identity's. + textBody: signPlainTextReply(body, `${header}\n${quoted}`, signatureIdentityFor(identity, identities), { + position: signaturePosition, + separator: signatureSeparatorEnabled, + }), inReplyTo: threading?.inReplyTo, references: threading?.references, }, diff --git a/src/lib/__tests__/signature-utils.test.ts b/src/lib/__tests__/signature-utils.test.ts index ab8e8434..b23aafca 100644 --- a/src/lib/__tests__/signature-utils.test.ts +++ b/src/lib/__tests__/signature-utils.test.ts @@ -10,6 +10,8 @@ import { spliceSignature, stripEmbeddedSignature, insertSignatureAboveQuote, + signatureIdentityFor, + signPlainTextReply, } from '../signature-utils'; describe('sanitizeSignatureHtml', () => { @@ -79,3 +81,37 @@ describe('embedded signature range', () => { expect(insertSignatureAboveQuote('

x

', sig, marker)).toBe(`

x

${sig}`); }); }); + +describe('signatureIdentityFor', () => { + const primary = { id: 'p', mayDelete: false, textSignature: 'Primary sig' }; + const alias = { id: 'a', mayDelete: true }; + const signedAlias = { id: 's', mayDelete: true, htmlSignature: 'Alias' }; + + it('uses the identity\'s own signature, else the primary\'s', () => { + expect(signatureIdentityFor(signedAlias, [alias, primary, signedAlias])).toBe(signedAlias); + expect(signatureIdentityFor(alias, [alias, primary, signedAlias])).toBe(primary); + }); + + it('returns null when neither has one', () => { + expect(signatureIdentityFor(alias, [alias, { id: 'p', mayDelete: false }])).toBeNull(); + expect(signatureIdentityFor(null, [primary])).toBeNull(); + }); +}); + +describe('signPlainTextReply', () => { + const sig = { textSignature: 'Jane' }; + + it('puts the signature between the reply and the quote above the quote', () => { + expect(signPlainTextReply('Thanks', 'Bob wrote:\n> hi', sig, { position: 'above_quote', separator: true })) + .toBe('Thanks\n\n-- \nJane\n\nBob wrote:\n> hi'); + }); + + it('puts it at the very end below the quote', () => { + expect(signPlainTextReply('Thanks', 'Bob wrote:\n> hi', sig, { position: 'below_quote', separator: false })) + .toBe('Thanks\n\nBob wrote:\n> hi\n\nJane'); + }); + + it('leaves the reply alone without a signature', () => { + expect(signPlainTextReply('Thanks', 'q', null, { position: 'above_quote', separator: true })).toBe('Thanks\n\nq'); + }); +}); diff --git a/src/lib/signature-utils.ts b/src/lib/signature-utils.ts index 963585bc..f162a940 100644 --- a/src/lib/signature-utils.ts +++ b/src/lib/signature-utils.ts @@ -74,6 +74,22 @@ export function hasSignature(signature?: SignatureSource): boolean { return !!(signature?.htmlSignature?.trim() || signature?.textSignature?.trim()); } +/** + * The identity whose signature a message sent as `identity` carries: its own, + * or - for an alias or shared identity without one - the primary identity's + * (the first that can't be deleted, else the first). Null when neither has a + * signature. + */ +export function signatureIdentityFor( + identity: T | null | undefined, + identities: T[], +): T | null { + if (!identity) return null; + if (hasSignature(identity)) return identity; + const primary = identities.find((i) => !i.mayDelete) ?? identities[0]; + return primary && hasSignature(primary) ? primary : null; +} + export function getPlainTextSignature(signature?: SignatureSource): string { if (signature?.textSignature?.trim()) { return normalizeSignatureLineBreaks(signature.textSignature); @@ -100,6 +116,23 @@ export function appendPlainTextSignature( return `${body}${sep}${plainTextSignature}`; } +/** + * A plain-text reply signed the way the composer signs one: the signature + * between the reply and the quote (`above_quote`), or at the very end. + */ +export function signPlainTextReply( + reply: string, + quote: string, + signature: SignatureSource, + options: { position: 'above_quote' | 'below_quote'; separator: boolean }, +): string { + const quoted = quote ? `\n\n${quote}` : ''; + if (options.position === 'above_quote') { + return `${appendPlainTextSignature(reply, signature, options)}${quoted}`; + } + return appendPlainTextSignature(`${reply}${quoted}`, signature, options); +} + /** * Whether a plain-text body already ends with the identity's signature. */ diff --git a/src/screens/ComposeScreen.tsx b/src/screens/ComposeScreen.tsx index 99e91644..bdaf094b 100644 --- a/src/screens/ComposeScreen.tsx +++ b/src/screens/ComposeScreen.tsx @@ -60,7 +60,7 @@ import { } from '../lib/reply-identity'; import { shouldBlockEditorRemoteImages } from '../lib/editor-html'; import { - hasSignature, buildEmbeddedSignatureHtml, containsEmbeddedSignature, spliceSignature, + signatureIdentityFor, buildEmbeddedSignatureHtml, containsEmbeddedSignature, spliceSignature, insertSignatureAboveQuote, getPlainTextSignature, appendPlainTextSignature, plainTextBodyHasSignature, plainTextBodyWithoutSignature, SIGNATURE_RANGE_MARKER, } from '../lib/signature-utils'; @@ -914,12 +914,10 @@ export default function ComposeScreen({ route, navigation }: Props) { }, [identities, selectedIdentityId]); // An alias without a signature falls back to the primary identity's. - const signatureIdentity = React.useMemo(() => { - if (!primaryIdentity) return null; - if (hasSignature(primaryIdentity)) return primaryIdentity; - const fallback = identities.find((i) => !i.mayDelete) ?? identities[0]; - return fallback && hasSignature(fallback) ? fallback : null; - }, [primaryIdentity, identities]); + const signatureIdentity = React.useMemo( + () => signatureIdentityFor(primaryIdentity, identities), + [primaryIdentity, identities], + ); const openIdentityPicker = () => { if (identities.length <= 1) return;