feat: signature above quoted text option #266

This commit is contained in:
Linus Rath
2026-05-11 17:05:59 +02:00
parent 4bce80b8ba
commit 5f3d2d3e4a
4 changed files with 83 additions and 10 deletions
+61 -10
View File
@@ -135,6 +135,24 @@ export function EmailComposer({
const autoSelectReplyIdentity = useSettingsStore((state) => state.autoSelectReplyIdentity); const autoSelectReplyIdentity = useSettingsStore((state) => state.autoSelectReplyIdentity);
const attachmentReminderEnabled = useSettingsStore((state) => state.attachmentReminderEnabled); const attachmentReminderEnabled = useSettingsStore((state) => state.attachmentReminderEnabled);
const attachmentReminderKeywords = useSettingsStore((state) => state.attachmentReminderKeywords); const attachmentReminderKeywords = useSettingsStore((state) => state.attachmentReminderKeywords);
const signaturePosition = useSettingsStore((state) => state.signaturePosition);
const identities = useIdentityStore((s) => s.identities);
const primaryIdentity = identities[0] ?? null;
// The signature identity used when embedding the signature into the initial
// body for "above quote" mode. Mirrors the signatureIdentity derivation
// below, but uses initialData (or primary) since selectedIdentityId state
// does not exist yet at this point.
const initialCurrentIdentityForSig = initialData?.selectedIdentityId
? identities.find((i) => i.id === initialData.selectedIdentityId) || primaryIdentity
: primaryIdentity;
const initialSignatureIdentity = (initialCurrentIdentityForSig?.htmlSignature || initialCurrentIdentityForSig?.textSignature)
? initialCurrentIdentityForSig
: primaryIdentity;
const shouldEmbedSignatureAboveQuote =
(mode === 'reply' || mode === 'replyAll' || mode === 'forward') &&
signaturePosition === 'above_quote' &&
!!(initialSignatureIdentity?.htmlSignature || initialSignatureIdentity?.textSignature);
// Initialize with reply/forward data if provided // Initialize with reply/forward data if provided
const getInitialTo = () => { const getInitialTo = () => {
@@ -184,10 +202,18 @@ export function EmailComposer({
const originalText = replyTo.body || (replyTo.htmlBody ? htmlToPlainText(replyTo.htmlBody) : ''); const originalText = replyTo.body || (replyTo.htmlBody ? htmlToPlainText(replyTo.htmlBody) : '');
const quotedText = originalText.split('\n').map(line => `> ${line}`).join('\n'); const quotedText = originalText.split('\n').map(line => `> ${line}`).join('\n');
// When "above quote" is configured, splice signature between the user's
// drafting area and the quoted content so it reads naturally as a
// closing for the reply body. Send-time append is skipped — see
// shouldEmbedSignatureAboveQuote.
const signatureBlock = shouldEmbedSignatureAboveQuote
? `\n\n-- \n${getPlainTextSignature(initialSignatureIdentity)}`
: '';
if (mode === 'forward') { if (mode === 'forward') {
return `${prefix}\n\n---------- Forwarded message ----------\nFrom: ${fromStr}\nDate: ${date}\nSubject: ${replyTo.subject || ''}\n\n${originalText}`; return `${prefix}${signatureBlock}\n\n---------- Forwarded message ----------\nFrom: ${fromStr}\nDate: ${date}\nSubject: ${replyTo.subject || ''}\n\n${originalText}`;
} else if (mode === 'reply' || mode === 'replyAll') { } else if (mode === 'reply' || mode === 'replyAll') {
return `${prefix}\n\nOn ${date}, ${fromStr} wrote:\n${quotedText}`; return `${prefix}${signatureBlock}\n\nOn ${date}, ${fromStr} wrote:\n${quotedText}`;
} }
return prefix; return prefix;
} }
@@ -199,20 +225,36 @@ export function EmailComposer({
const from = replyTo.from?.[0]; const from = replyTo.from?.[0];
const fromStr = from ? `${from.name || from.email}` : tCommon('unknown'); const fromStr = from ? `${from.name || from.email}` : tCommon('unknown');
// When "above quote" is configured, splice signature between the user's
// drafting area and the quoted content so it reads naturally as a closing
// for the reply body. Send-time append is skipped — see
// shouldEmbedSignatureAboveQuote.
const buildEmbeddedSignatureHtml = (): string => {
if (!shouldEmbedSignatureAboveQuote) return '';
if (initialSignatureIdentity?.htmlSignature) {
return `<br><br>-- <br>${sanitizeEmailHtml(initialSignatureIdentity.htmlSignature)}`;
}
if (initialSignatureIdentity?.textSignature) {
return `<br><br>-- <br>${initialSignatureIdentity.textSignature.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/\n/g, '<br>')}`;
}
return '';
};
const signatureBlock = buildEmbeddedSignatureHtml();
// Build quoted content as HTML // Build quoted content as HTML
if (replyTo.htmlBody && (mode === 'reply' || mode === 'replyAll' || mode === 'forward')) { if (replyTo.htmlBody && (mode === 'reply' || mode === 'replyAll' || mode === 'forward')) {
const quoteHeader = mode === 'forward' const quoteHeader = mode === 'forward'
? `---------- Forwarded message ----------<br>From: ${fromStr}<br>Date: ${date}<br>Subject: ${replyTo.subject || ''}<br><br>` ? `---------- Forwarded message ----------<br>From: ${fromStr}<br>Date: ${date}<br>Subject: ${replyTo.subject || ''}<br><br>`
: `On ${date}, ${fromStr} wrote:<br>`; : `On ${date}, ${fromStr} wrote:<br>`;
return `${prefix}<br><div>${quoteHeader}</div><blockquote style="margin:0 0 0 0.8ex;border-left:2px solid #ccc;padding-left:1ex">${replyTo.htmlBody}</blockquote>`; return `${prefix}${signatureBlock}<br><div>${quoteHeader}</div><blockquote style="margin:0 0 0 0.8ex;border-left:2px solid #ccc;padding-left:1ex">${replyTo.htmlBody}</blockquote>`;
} }
if (replyTo.body) { if (replyTo.body) {
const escapedOriginal = replyTo.body.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/\n/g, '<br>'); const escapedOriginal = replyTo.body.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/\n/g, '<br>');
if (mode === 'forward') { if (mode === 'forward') {
return `${prefix}<br><br>---------- Forwarded message ----------<br>From: ${fromStr}<br>Date: ${date}<br>Subject: ${replyTo.subject || ''}<br><br>${escapedOriginal}`; return `${prefix}${signatureBlock}<br><br>---------- Forwarded message ----------<br>From: ${fromStr}<br>Date: ${date}<br>Subject: ${replyTo.subject || ''}<br><br>${escapedOriginal}`;
} else if (mode === 'reply' || mode === 'replyAll') { } else if (mode === 'reply' || mode === 'replyAll') {
return `${prefix}<br><br>On ${date}, ${fromStr} wrote:<br><blockquote style="margin:0 0 0 0.8ex;border-left:2px solid #ccc;padding-left:1ex">${escapedOriginal}</blockquote>`; return `${prefix}${signatureBlock}<br><br>On ${date}, ${fromStr} wrote:<br><blockquote style="margin:0 0 0 0.8ex;border-left:2px solid #ccc;padding-left:1ex">${escapedOriginal}</blockquote>`;
} }
} }
return prefix; return prefix;
@@ -284,8 +326,6 @@ export function EmailComposer({
}); });
const { client } = useAuthStore(); const { client } = useAuthStore();
const identities = useIdentityStore((s) => s.identities);
const primaryIdentity = identities[0] ?? null;
const currentIdentity = selectedIdentityId const currentIdentity = selectedIdentityId
? identities.find((identity) => identity.id === selectedIdentityId) || primaryIdentity ? identities.find((identity) => identity.id === selectedIdentityId) || primaryIdentity
: primaryIdentity; : primaryIdentity;
@@ -988,8 +1028,16 @@ export function EmailComposer({
const envelopeMailFrom = overrideActive ? identityFromEmail : undefined; const envelopeMailFrom = overrideActive ? identityFromEmail : undefined;
// Body is already HTML from the rich text editor (or plain text in plain text mode). // Body is already HTML from the rich text editor (or plain text in plain text mode).
// When "above quote" mode is configured for replies/forwards, the signature
// was embedded into the body during init (see getInitialBody) so the
// trailing append must be skipped to avoid duplicating it.
const signatureAlreadyInBody =
(mode === 'reply' || mode === 'replyAll' || mode === 'forward') &&
signaturePosition === 'above_quote';
// Build HTML signature block (used only in rich text mode) // Build HTML signature block (used only in rich text mode)
const buildSignatureHtml = (): string => { const buildSignatureHtml = (): string => {
if (signatureAlreadyInBody) return '';
if (signatureIdentity?.htmlSignature) { if (signatureIdentity?.htmlSignature) {
return `<br><br>-- <br>${sanitizeEmailHtml(signatureIdentity.htmlSignature)}`; return `<br><br>-- <br>${sanitizeEmailHtml(signatureIdentity.htmlSignature)}`;
} }
@@ -1006,8 +1054,8 @@ export function EmailComposer({
// In plain text mode, send text/plain only (no HTML body) // In plain text mode, send text/plain only (no HTML body)
const finalBody = plainTextMode const finalBody = plainTextMode
? appendPlainTextSignature(body, signatureIdentity) ? (signatureAlreadyInBody ? body : appendPlainTextSignature(body, signatureIdentity))
: appendPlainTextSignature(htmlToPlainText(body), signatureIdentity); : (signatureAlreadyInBody ? htmlToPlainText(body) : appendPlainTextSignature(htmlToPlainText(body), signatureIdentity));
const rewritten = plainTextMode ? null : rewriteInlineImages(body); const rewritten = plainTextMode ? null : rewriteInlineImages(body);
const finalHtmlBody = plainTextMode const finalHtmlBody = plainTextMode
@@ -1584,7 +1632,10 @@ export function EmailComposer({
</div> </div>
)} )}
{plainTextMode ? ( {/* Hide the visual signature preview when the signature has already been
embedded into the body above the quote (otherwise it would appear twice). */}
{((mode === 'reply' || mode === 'replyAll' || mode === 'forward') && signaturePosition === 'above_quote') ? null
: plainTextMode ? (
getPlainTextSignature(signatureIdentity) ? ( getPlainTextSignature(signatureIdentity) ? (
<div className="px-4 pb-3 text-sm leading-6 text-muted-foreground break-words whitespace-pre-wrap font-mono"> <div className="px-4 pb-3 text-sm leading-6 text-muted-foreground break-words whitespace-pre-wrap font-mono">
{'-- \n'}{getPlainTextSignature(signatureIdentity)} {'-- \n'}{getPlainTextSignature(signatureIdentity)}
@@ -27,6 +27,7 @@ export function ComposingSettings() {
attachmentReminderEnabled, attachmentReminderEnabled,
attachmentReminderKeywords, attachmentReminderKeywords,
subAddressDelimiter, subAddressDelimiter,
signaturePosition,
updateSetting, updateSetting,
} = useSettingsStore(); } = useSettingsStore();
@@ -50,6 +51,17 @@ export function ComposingSettings() {
/> />
</SettingItem> </SettingItem>
<SettingItem label={t('signature_position.label')} description={t('signature_position.description')}>
<Select
value={signaturePosition}
onChange={(value) => updateSetting('signaturePosition', value as 'above_quote' | 'below_quote')}
options={[
{ value: 'above_quote', label: t('signature_position.above_quote') },
{ value: 'below_quote', label: t('signature_position.below_quote') },
]}
/>
</SettingItem>
<SettingItem <SettingItem
label={t('sub_address_delimiter.label')} label={t('sub_address_delimiter.label')}
description={t('sub_address_delimiter.description', { delimiter: subAddressDelimiter })} description={t('sub_address_delimiter.description', { delimiter: subAddressDelimiter })}
+6
View File
@@ -974,6 +974,12 @@
"label": "Reply From Received Address", "label": "Reply From Received Address",
"description": "When replying, send from the address the message was originally sent to. Matches identities first; for domain catch-all deliveries, rewrites the From header to the alias while sending through your primary identity." "description": "When replying, send from the address the message was originally sent to. Matches identities first; for domain catch-all deliveries, rewrites the From header to the alias while sending through your primary identity."
}, },
"signature_position": {
"label": "Signature Position",
"description": "Where to insert your signature in replies and forwards. Above the quoted text reads naturally as a closing for the reply; below keeps the original message contiguous.",
"above_quote": "Before quoted text",
"below_quote": "After quoted text"
},
"sub_address_delimiter": { "sub_address_delimiter": {
"label": "Sub-Address Delimiter", "label": "Sub-Address Delimiter",
"description": "Character separating your username from a sub-address tag. Match the delimiter your mail server uses (e.g. user{delimiter}tag@domain.com).", "description": "Character separating your username from a sub-address tag. Match the delimiter your mail server uses (e.g. user{delimiter}tag@domain.com).",
+4
View File
@@ -30,6 +30,7 @@ export type Density = 'extra-compact' | 'compact' | 'regular' | 'comfortable';
export type ListDensity = Density; export type ListDensity = Density;
export type DeleteAction = 'trash' | 'permanent'; export type DeleteAction = 'trash' | 'permanent';
export type ReplyMode = 'reply' | 'replyAll'; export type ReplyMode = 'reply' | 'replyAll';
export type SignaturePosition = 'above_quote' | 'below_quote';
export type DateFormat = 'regional' | 'iso' | 'custom'; export type DateFormat = 'regional' | 'iso' | 'custom';
export type TimeFormat = '12h' | '24h'; export type TimeFormat = '12h' | '24h';
export type FirstDayOfWeek = 0 | 1; // 0 = Sunday, 1 = Monday export type FirstDayOfWeek = 0 | 1; // 0 = Sunday, 1 = Monday
@@ -143,6 +144,7 @@ interface SettingsState {
autoSelectReplyIdentity: boolean; autoSelectReplyIdentity: boolean;
plainTextMode: boolean; // Send plain text only (no rich text editor) plainTextMode: boolean; // Send plain text only (no rich text editor)
subAddressDelimiter: string; // Character separating user from tag (e.g. "user+tag@") subAddressDelimiter: string; // Character separating user from tag (e.g. "user+tag@")
signaturePosition: SignaturePosition; // Position of the signature relative to quoted text in replies/forwards
// Privacy & Security // Privacy & Security
sessionTimeout: number; // minutes (0 = never) sessionTimeout: number; // minutes (0 = never)
@@ -295,6 +297,7 @@ const DEFAULT_SETTINGS = {
autoSelectReplyIdentity: false, autoSelectReplyIdentity: false,
plainTextMode: false, plainTextMode: false,
subAddressDelimiter: DEFAULT_SUB_ADDRESS_DELIMITER, subAddressDelimiter: DEFAULT_SUB_ADDRESS_DELIMITER,
signaturePosition: 'below_quote' as SignaturePosition,
// Privacy & Security // Privacy & Security
sessionTimeout: 0, // Never sessionTimeout: 0, // Never
@@ -466,6 +469,7 @@ export const useSettingsStore = create<SettingsState>()(
autoSelectReplyIdentity: state.autoSelectReplyIdentity, autoSelectReplyIdentity: state.autoSelectReplyIdentity,
plainTextMode: state.plainTextMode, plainTextMode: state.plainTextMode,
subAddressDelimiter: state.subAddressDelimiter, subAddressDelimiter: state.subAddressDelimiter,
signaturePosition: state.signaturePosition,
sessionTimeout: state.sessionTimeout, sessionTimeout: state.sessionTimeout,
emailNotificationsEnabled: state.emailNotificationsEnabled, emailNotificationsEnabled: state.emailNotificationsEnabled,
emailNotificationSound: state.emailNotificationSound, emailNotificationSound: state.emailNotificationSound,