fix: sign quick replies with the sending identity's signature

Quick replies went out with no signature at all. They are now signed
the way the composer signs a plain-text reply: between the reply and
the quote or at the end, following the signature position setting, with
the "-- " separator when it is on, and with the primary identity's
signature when the sending alias has none of its own.

The quick reply also resolves its sending identity like the composer
(webmail 4fc7b8b5): recipients in To, Cc, Bcc order, and a reply to our
own message from the identity that sent it. It still never takes the
catch-all From rewrite, having no From row to show it.
This commit is contained in:
Linus Rath
2026-09-24 19:10:45 +02:00
parent 472c4d8f26
commit 3faac98627
4 changed files with 92 additions and 11 deletions
+18 -4
View File
@@ -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,
},
+36
View File
@@ -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('<p>x</p>', sig, marker)).toBe(`<p>x</p>${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: '<b>Alias</b>' };
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');
});
});
+33
View File
@@ -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<T extends { textSignature?: string; htmlSignature?: string; mayDelete?: boolean }>(
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.
*/
+5 -7
View File
@@ -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;