feat: signature above quoted text option #266
This commit is contained in:
@@ -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, '&').replace(/</g, '<').replace(/>/g, '>').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, '&').replace(/</g, '<').replace(/>/g, '>').replace(/\n/g, '<br>');
|
const escapedOriginal = replyTo.body.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').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 })}
|
||||||
|
|||||||
@@ -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).",
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
Reference in New Issue
Block a user