mirror of
https://github.com/bulwarkmail/native.git
synced 2026-11-01 08:06:13 +00:00
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:
@@ -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,
|
||||
},
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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.
|
||||
*/
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user