Merge branch 'main' into feature/scheduled-send
This commit is contained in:
@@ -388,41 +388,56 @@ export function CalendarInvitationBanner({ email }: CalendarInvitationBannerProp
|
||||
setActionNotice(null);
|
||||
setActionError(null);
|
||||
try {
|
||||
const events = await client.parseCalendarEvents(client.getCalendarsAccountId(), attachment.blobId);
|
||||
if (events.length > 0) {
|
||||
const parsed = events[0];
|
||||
setParsedEvent(parsed);
|
||||
|
||||
// JMAP strips parameters from Content-Type (RFC 8621), so method=REQUEST
|
||||
// is lost. Fetch raw ICS to extract METHOD as a reliable fallback.
|
||||
try {
|
||||
const blob = await client.fetchBlob(attachment.blobId, 'invite.ics', 'text/calendar');
|
||||
const rawText = await blob.text();
|
||||
const icsMethod = extractMethodFromRawIcs(rawText);
|
||||
if (icsMethod !== 'unknown') {
|
||||
setRawIcsMethod(icsMethod);
|
||||
// JMAP strips parameters from Content-Type (RFC 8621), so method=REQUEST
|
||||
// is lost. Fetch raw ICS to extract METHOD as a reliable fallback — in
|
||||
// parallel with parsing to save a roundtrip.
|
||||
const [events, rawText] = await Promise.all([
|
||||
client.parseCalendarEvents(client.getCalendarsAccountId(), attachment.blobId),
|
||||
(async () => {
|
||||
try {
|
||||
const blob = await client.fetchBlob(attachment.blobId, 'invite.ics', 'text/calendar');
|
||||
return await blob.text();
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
} catch { /* ignore - fall back to heuristic detection */ }
|
||||
})(),
|
||||
]);
|
||||
|
||||
if (parsed.uid && supportsCalendar) {
|
||||
const storeHasIt = useCalendarStore.getState().events.some((e) => e.uid === parsed.uid);
|
||||
if (!storeHasIt) {
|
||||
try {
|
||||
const serverEvents = await client.queryCalendarEvents({});
|
||||
const matching = serverEvents.filter((e) => e.uid === parsed.uid);
|
||||
if (matching.length > 0) {
|
||||
useCalendarStore.setState((s) => {
|
||||
const existingIds = new Set(s.events.map((e) => e.id));
|
||||
const newEvents = matching.filter((e) => !existingIds.has(e.id));
|
||||
return newEvents.length > 0 ? { events: [...s.events, ...newEvents] } : s;
|
||||
});
|
||||
}
|
||||
} catch { /* ignore lookup failure */ }
|
||||
}
|
||||
}
|
||||
setState('parsed');
|
||||
} else {
|
||||
if (events.length === 0) {
|
||||
setState('error');
|
||||
return;
|
||||
}
|
||||
|
||||
const parsed = events[0];
|
||||
setParsedEvent(parsed);
|
||||
|
||||
if (rawText) {
|
||||
const icsMethod = extractMethodFromRawIcs(rawText);
|
||||
if (icsMethod !== 'unknown') {
|
||||
setRawIcsMethod(icsMethod);
|
||||
}
|
||||
}
|
||||
|
||||
setState('parsed');
|
||||
|
||||
// Hydrate the calendar store with the matching event in the background —
|
||||
// only needed for the "already in calendar" pill, must not block the banner.
|
||||
// Filter by UID server-side; the previous unfiltered query fetched up to
|
||||
// 1000 events plus multiple /get batches just to find one match.
|
||||
if (parsed.uid && supportsCalendar) {
|
||||
const storeHasIt = useCalendarStore.getState().events.some((e) => e.uid === parsed.uid);
|
||||
if (!storeHasIt) {
|
||||
client.queryCalendarEvents({ uid: parsed.uid })
|
||||
.then((matching) => {
|
||||
if (matching.length === 0) return;
|
||||
useCalendarStore.setState((s) => {
|
||||
const existingIds = new Set(s.events.map((e) => e.id));
|
||||
const newEvents = matching.filter((e) => !existingIds.has(e.id));
|
||||
return newEvents.length > 0 ? { events: [...s.events, ...newEvents] } : s;
|
||||
});
|
||||
})
|
||||
.catch(() => { /* ignore lookup failure */ });
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
setState('error');
|
||||
|
||||
@@ -9,7 +9,7 @@ import { X, Paperclip, Send, Save, Check, Loader2, AlertCircle, FileText, Bookma
|
||||
import { cn, formatFileSize, formatDateTime, generateUUID } from "@/lib/utils";
|
||||
import { debug } from "@/lib/debug";
|
||||
import { toast } from "@/stores/toast-store";
|
||||
import { sanitizeEmailHtml } from "@/lib/email-sanitization";
|
||||
import { sanitizeSignatureHtml } from "@/lib/email-sanitization";
|
||||
import { emailHooks, contactHooks } from "@/lib/plugin-hooks";
|
||||
import type { OutgoingEmail, RecipientSuggestion } from "@/lib/plugin-types";
|
||||
import { useAuthStore } from "@/stores/auth-store";
|
||||
@@ -32,9 +32,10 @@ import { TemplatePicker } from "@/components/templates/template-picker";
|
||||
import { TemplateForm } from "@/components/templates/template-form";
|
||||
import type { EmailTemplate } from "@/lib/template-types";
|
||||
import { appendPlainTextSignature, getPlainTextSignature } from "@/lib/signature-utils";
|
||||
import { findReplyIdentityId } from "@/lib/reply-identity";
|
||||
import { resolveReplyFrom } from "@/lib/reply-identity";
|
||||
import { computeReplyThreadingHeaders } from "@/lib/email-threading";
|
||||
import { RichTextEditor } from "@/components/email/rich-text-editor";
|
||||
import type { Editor } from "@tiptap/react";
|
||||
|
||||
/** Strip HTML tags and decode entities to get a plain-text version */
|
||||
function htmlToPlainText(html: string): string {
|
||||
@@ -55,6 +56,10 @@ export interface ComposerDraftData {
|
||||
mode: 'compose' | 'reply' | 'replyAll' | 'forward';
|
||||
replyTo?: EmailComposerProps['replyTo'];
|
||||
draftId: string | null;
|
||||
/** When set, overrides the header From: - sent through the selected identity's envelope. */
|
||||
fromOverrideEmail?: string;
|
||||
fromOverrideName?: string;
|
||||
fromOverrideEnabled?: boolean;
|
||||
}
|
||||
|
||||
interface EmailComposerProps {
|
||||
@@ -69,6 +74,7 @@ interface EmailComposerProps {
|
||||
fromEmail?: string;
|
||||
fromName?: string;
|
||||
identityId?: string;
|
||||
envelopeMailFrom?: string;
|
||||
attachments?: Array<{ blobId: string; name: string; type: string; size: number; disposition?: 'attachment' | 'inline'; cid?: string }>;
|
||||
inReplyTo?: string[];
|
||||
references?: string[];
|
||||
@@ -99,6 +105,14 @@ interface EmailComposerProps {
|
||||
messageId?: string;
|
||||
inReplyTo?: string[];
|
||||
references?: string[];
|
||||
// Pre-built quote header block. Supplied by the composer opener after it
|
||||
// runs emailHooks.onBuildQuoteHeader through plugin transforms. When set,
|
||||
// the composer uses these verbatim instead of building its own default
|
||||
// "On X, Y wrote:" / "---------- Forwarded message ----------" block.
|
||||
quoteHeaderHtml?: string;
|
||||
quoteHeaderText?: string;
|
||||
/** Mirror of QuoteHeader.wrapInBlockquote. Defaults to true. */
|
||||
quoteWrapInBlockquote?: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
@@ -113,6 +127,39 @@ type ComposerAttachment = {
|
||||
abortController?: AbortController;
|
||||
};
|
||||
|
||||
type SignatureIdentityLike = {
|
||||
htmlSignature?: string;
|
||||
textSignature?: string;
|
||||
} | null | undefined;
|
||||
|
||||
// Render the embedded signature for "above quote" mode. Bracketed with
|
||||
// `data-signature-block` marker paragraphs so we can swap the inner content
|
||||
// when the user switches identity without losing the surrounding draft or
|
||||
// quoted message. The markers are preserved through TipTap by the
|
||||
// StyledParagraph extension.
|
||||
function buildEmbeddedSignatureHtml(
|
||||
identity: SignatureIdentityLike,
|
||||
options: { embed: boolean; separator: boolean }
|
||||
): string {
|
||||
if (!options.embed) return '';
|
||||
const startMarker = options.separator
|
||||
? `<p data-signature-block="separator">-- </p>`
|
||||
: `<p data-signature-block="start"></p>`;
|
||||
const endMarker = `<p data-signature-block="end"></p>`;
|
||||
if (identity?.htmlSignature) {
|
||||
return `${startMarker}${sanitizeSignatureHtml(identity.htmlSignature)}${endMarker}`;
|
||||
}
|
||||
if (identity?.textSignature) {
|
||||
const escaped = identity.textSignature
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/\n/g, '<br>');
|
||||
return `${startMarker}<p>${escaped}</p>${endMarker}`;
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
export function EmailComposer({
|
||||
onSend,
|
||||
onScheduledSendCreated,
|
||||
@@ -134,6 +181,25 @@ export function EmailComposer({
|
||||
const attachmentReminderEnabled = useSettingsStore((state) => state.attachmentReminderEnabled);
|
||||
const attachmentReminderKeywords = useSettingsStore((state) => state.attachmentReminderKeywords);
|
||||
const sendDelaySeconds = useSettingsStore((state) => state.sendDelaySeconds);
|
||||
const signaturePosition = useSettingsStore((state) => state.signaturePosition);
|
||||
const signatureSeparatorEnabled = useSettingsStore((state) => state.signatureSeparatorEnabled);
|
||||
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
|
||||
const getInitialTo = () => {
|
||||
@@ -183,10 +249,25 @@ export function EmailComposer({
|
||||
const originalText = replyTo.body || (replyTo.htmlBody ? htmlToPlainText(replyTo.htmlBody) : '');
|
||||
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 plainSep = signatureSeparatorEnabled ? '\n\n-- \n' : '\n\n';
|
||||
const signatureBlock = shouldEmbedSignatureAboveQuote
|
||||
? `${plainSep}${getPlainTextSignature(initialSignatureIdentity)}`
|
||||
: '';
|
||||
|
||||
// Plugin override (resolved at composer open via onBuildQuoteHeader).
|
||||
if (replyTo.quoteHeaderText !== undefined && (mode === 'reply' || mode === 'replyAll' || mode === 'forward')) {
|
||||
const body = mode === 'forward' ? originalText : quotedText;
|
||||
return `${prefix}${signatureBlock}\n\n${replyTo.quoteHeaderText}\n${body}`;
|
||||
}
|
||||
|
||||
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') {
|
||||
return `${prefix}\n\nOn ${date}, ${fromStr} wrote:\n${quotedText}`;
|
||||
return `${prefix}${signatureBlock}\n\nOn ${date}, ${fromStr} wrote:\n${quotedText}`;
|
||||
}
|
||||
return prefix;
|
||||
}
|
||||
@@ -198,20 +279,38 @@ export function EmailComposer({
|
||||
const from = replyTo.from?.[0];
|
||||
const fromStr = from ? `${from.name || from.email}` : tCommon('unknown');
|
||||
|
||||
const signatureBlock = buildEmbeddedSignatureHtml(initialSignatureIdentity, {
|
||||
embed: shouldEmbedSignatureAboveQuote,
|
||||
separator: signatureSeparatorEnabled,
|
||||
});
|
||||
|
||||
// Plugin override (resolved at composer open via onBuildQuoteHeader).
|
||||
if (replyTo.quoteHeaderHtml !== undefined && (mode === 'reply' || mode === 'replyAll' || mode === 'forward')) {
|
||||
const wrap = replyTo.quoteWrapInBlockquote !== false;
|
||||
const originalHtml = replyTo.htmlBody
|
||||
?? (replyTo.body
|
||||
? replyTo.body.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/\n/g, '<br>')
|
||||
: '');
|
||||
const bodyHtml = wrap
|
||||
? `<blockquote style="margin:0 0 0 0.8ex;border-left:2px solid #ccc;padding-left:1ex">${originalHtml}</blockquote>`
|
||||
: originalHtml;
|
||||
return `${prefix}${signatureBlock}<br>${replyTo.quoteHeaderHtml}${bodyHtml}`;
|
||||
}
|
||||
|
||||
// Build quoted content as HTML
|
||||
if (replyTo.htmlBody && (mode === 'reply' || mode === 'replyAll' || mode === 'forward')) {
|
||||
const quoteHeader = mode === 'forward'
|
||||
? `---------- Forwarded message ----------<br>From: ${fromStr}<br>Date: ${date}<br>Subject: ${replyTo.subject || ''}<br><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) {
|
||||
const escapedOriginal = replyTo.body.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/\n/g, '<br>');
|
||||
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') {
|
||||
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;
|
||||
@@ -225,9 +324,17 @@ export function EmailComposer({
|
||||
const [showCc, setShowCc] = useState(initialData?.showCc ?? !!getInitialCc());
|
||||
const [showBcc, setShowBcc] = useState(initialData?.showBcc ?? false);
|
||||
const [draftId, setDraftId] = useState<string | null>(initialData?.draftId ?? null);
|
||||
// Mirror of draftId for synchronous reads inside chained saves; React's
|
||||
// setDraftId is async, so a queued saveDraft would otherwise see the old
|
||||
// value and try to destroy a draft that was just replaced.
|
||||
const draftIdRef = useRef<string | null>(initialData?.draftId ?? null);
|
||||
const [saveStatus, setSaveStatus] = useState<'idle' | 'saving' | 'saved' | 'error'>('idle');
|
||||
const saveTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||
const lastSavedDataRef = useRef<string>("");
|
||||
// Tracks the currently-running saveDraft so concurrent callers (autosave
|
||||
// timer + send button) serialize instead of issuing parallel destroy/create
|
||||
// requests with the same draftId. See bug #303.
|
||||
const inflightSaveRef = useRef<Promise<string | null> | null>(null);
|
||||
const [attachments, setAttachments] = useState<ComposerAttachment[]>(() => {
|
||||
if (mode === 'forward' && replyTo?.attachments?.length) {
|
||||
return replyTo.attachments
|
||||
@@ -249,6 +356,9 @@ export function EmailComposer({
|
||||
const [shakeField, setShakeField] = useState<string | null>(null);
|
||||
const [selectedIdentityId, setSelectedIdentityId] = useState<string | null>(initialData?.selectedIdentityId ?? null);
|
||||
const [subAddressTag, setSubAddressTag] = useState<string>(initialData?.subAddressTag ?? '');
|
||||
const [fromOverrideEnabled, setFromOverrideEnabled] = useState<boolean>(initialData?.fromOverrideEnabled ?? false);
|
||||
const [fromOverrideEmail, setFromOverrideEmail] = useState<string>(initialData?.fromOverrideEmail ?? '');
|
||||
const [fromOverrideName, setFromOverrideName] = useState<string>(initialData?.fromOverrideName ?? '');
|
||||
const [showTemplatePicker, setShowTemplatePicker] = useState(false);
|
||||
const [showSaveAsTemplate, setShowSaveAsTemplate] = useState(false);
|
||||
const [showCloseDialog, setShowCloseDialog] = useState(false);
|
||||
@@ -283,24 +393,96 @@ export function EmailComposer({
|
||||
});
|
||||
|
||||
const { client } = useAuthStore();
|
||||
const identities = useIdentityStore((s) => s.identities);
|
||||
const primaryIdentity = identities[0] ?? null;
|
||||
const currentIdentity = selectedIdentityId
|
||||
? identities.find((identity) => identity.id === selectedIdentityId) || primaryIdentity
|
||||
: primaryIdentity;
|
||||
// Alias identities often lack a configured signature - fall back to the primary
|
||||
// identity's signature so replies (which auto-select a matching alias) still
|
||||
// populate the user's signature.
|
||||
const signatureIdentity = (currentIdentity?.htmlSignature || currentIdentity?.textSignature)
|
||||
? currentIdentity
|
||||
: primaryIdentity;
|
||||
|
||||
// Hold the TipTap editor instance so we can swap the embedded signature
|
||||
// when the user switches identity in "above quote" mode without rebuilding
|
||||
// the whole body (which would lose user edits to the surrounding draft).
|
||||
const editorRef = useRef<Editor | null>(null);
|
||||
const prevSignatureIdentityIdRef = useRef<string | null | undefined>(signatureIdentity?.id);
|
||||
const prevSignatureSeparatorRef = useRef<boolean>(signatureSeparatorEnabled);
|
||||
|
||||
useEffect(() => {
|
||||
const editor = editorRef.current;
|
||||
const identityChanged = prevSignatureIdentityIdRef.current !== signatureIdentity?.id;
|
||||
const separatorChanged = prevSignatureSeparatorRef.current !== signatureSeparatorEnabled;
|
||||
prevSignatureIdentityIdRef.current = signatureIdentity?.id;
|
||||
prevSignatureSeparatorRef.current = signatureSeparatorEnabled;
|
||||
if (!editor) return;
|
||||
if (!identityChanged && !separatorChanged) return;
|
||||
if (plainTextMode) return;
|
||||
if (mode !== 'reply' && mode !== 'replyAll' && mode !== 'forward') return;
|
||||
if (signaturePosition !== 'above_quote') return;
|
||||
|
||||
const currentHtml = editor.getHTML();
|
||||
const doc = new DOMParser().parseFromString(currentHtml, 'text/html');
|
||||
const startEl = doc.querySelector('[data-signature-block="separator"], [data-signature-block="start"]');
|
||||
if (!startEl) return;
|
||||
const endEl = doc.querySelector('[data-signature-block="end"]');
|
||||
|
||||
const newSignature = buildEmbeddedSignatureHtml(signatureIdentity, {
|
||||
embed: true,
|
||||
separator: signatureSeparatorEnabled,
|
||||
});
|
||||
if (!newSignature) return;
|
||||
|
||||
// Build a temporary container holding the replacement nodes so we can
|
||||
// splice them in without re-serializing/parsing twice.
|
||||
const replacementHost = doc.createElement('div');
|
||||
replacementHost.innerHTML = newSignature;
|
||||
const replacementNodes = Array.from(replacementHost.childNodes);
|
||||
|
||||
const parent = startEl.parentNode;
|
||||
if (!parent) return;
|
||||
|
||||
// Remove the existing signature range [startEl … endEl] inclusive, or
|
||||
// from startEl to the next blockquote if no end marker is present.
|
||||
const removeUntil = endEl && endEl.parentNode === parent ? endEl : null;
|
||||
const toRemove: Node[] = [];
|
||||
let cursor: Node | null = startEl;
|
||||
while (cursor) {
|
||||
toRemove.push(cursor);
|
||||
if (cursor === removeUntil) break;
|
||||
const next: Node | null = cursor.nextSibling;
|
||||
if (!removeUntil && next && (next as Element).tagName === 'BLOCKQUOTE') break;
|
||||
cursor = next;
|
||||
}
|
||||
const insertBefore = toRemove[toRemove.length - 1]?.nextSibling ?? null;
|
||||
toRemove.forEach((node) => parent.removeChild(node));
|
||||
replacementNodes.forEach((node) => parent.insertBefore(node, insertBefore));
|
||||
|
||||
const nextHtml = doc.body.innerHTML;
|
||||
if (nextHtml !== currentHtml) {
|
||||
editor.commands.setContent(nextHtml, { emitUpdate: true });
|
||||
}
|
||||
}, [signatureIdentity?.id, signatureIdentity?.htmlSignature, signatureIdentity?.textSignature, signatureSeparatorEnabled, signaturePosition, mode, plainTextMode]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!autoSelectReplyIdentity) return;
|
||||
if (selectedIdentityId || initialData?.selectedIdentityId) return;
|
||||
if (mode !== 'reply' && mode !== 'replyAll') return;
|
||||
|
||||
const matchedIdentityId = findReplyIdentityId(identities, {
|
||||
const resolved = resolveReplyFrom(identities, {
|
||||
to: replyTo?.to,
|
||||
cc: replyTo?.cc,
|
||||
bcc: replyTo?.bcc,
|
||||
});
|
||||
|
||||
if (matchedIdentityId) {
|
||||
setSelectedIdentityId(matchedIdentityId);
|
||||
if (resolved) {
|
||||
setSelectedIdentityId(resolved.identityId);
|
||||
if (resolved.overrideEmail && !fromOverrideEnabled) {
|
||||
setFromOverrideEnabled(true);
|
||||
setFromOverrideEmail(resolved.overrideEmail);
|
||||
if (resolved.overrideName) setFromOverrideName(resolved.overrideName);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -319,6 +501,7 @@ export function EmailComposer({
|
||||
}
|
||||
}, [
|
||||
autoSelectReplyIdentity,
|
||||
fromOverrideEnabled,
|
||||
identities,
|
||||
initialData?.selectedIdentityId,
|
||||
mode,
|
||||
@@ -329,10 +512,10 @@ export function EmailComposer({
|
||||
selectedIdentityId,
|
||||
]);
|
||||
|
||||
const composerSignatureHtml = currentIdentity?.htmlSignature
|
||||
? `<div>${sanitizeEmailHtml(currentIdentity.htmlSignature)}</div>`
|
||||
: currentIdentity?.textSignature
|
||||
? `<div>${getPlainTextSignature(currentIdentity).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/\n/g, '<br>')}</div>`
|
||||
const composerSignatureHtml = signatureIdentity?.htmlSignature
|
||||
? `<div>${sanitizeSignatureHtml(signatureIdentity.htmlSignature)}</div>`
|
||||
: signatureIdentity?.textSignature
|
||||
? `<div>${getPlainTextSignature(signatureIdentity).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/\n/g, '<br>')}</div>`
|
||||
: '';
|
||||
const getAutocomplete = useContactStore((s) => s.getAutocomplete);
|
||||
const addToTrustedSendersBook = useContactStore((s) => s.addToTrustedSendersBook);
|
||||
@@ -368,8 +551,8 @@ export function EmailComposer({
|
||||
}, [currentSmimeIdentityId]);
|
||||
|
||||
// Keep a ref to current state for the unmount save
|
||||
const stateRef = useRef({ to, cc, bcc, subject, body, showCc, showBcc, selectedIdentityId, subAddressTag, draftId });
|
||||
stateRef.current = { to, cc, bcc, subject, body, showCc, showBcc, selectedIdentityId, subAddressTag, draftId };
|
||||
const stateRef = useRef({ to, cc, bcc, subject, body, showCc, showBcc, selectedIdentityId, subAddressTag, draftId, fromOverrideEnabled, fromOverrideEmail, fromOverrideName });
|
||||
stateRef.current = { to, cc, bcc, subject, body, showCc, showBcc, selectedIdentityId, subAddressTag, draftId, fromOverrideEnabled, fromOverrideEmail, fromOverrideName };
|
||||
|
||||
// Track initial values for dirty detection (captured once on first render)
|
||||
const initialValuesRef = useRef({ to, cc, bcc, subject, body, attachmentCount: attachments.length });
|
||||
@@ -729,7 +912,7 @@ export function EmailComposer({
|
||||
};
|
||||
|
||||
// Auto-save draft functionality
|
||||
const saveDraft = async (): Promise<string | null> => {
|
||||
const saveDraftOnce = async (): Promise<string | null> => {
|
||||
if (!client) return null;
|
||||
|
||||
const toAddresses = to.split(",").map(e => e.trim()).filter(Boolean);
|
||||
@@ -755,20 +938,27 @@ export function EmailComposer({
|
||||
|
||||
// Only save if data has changed
|
||||
if (currentData === lastSavedDataRef.current) {
|
||||
return draftId;
|
||||
return draftIdRef.current;
|
||||
}
|
||||
|
||||
setSaveStatus('saving');
|
||||
|
||||
// Get the selected identity or primary identity
|
||||
// Generate sub-addressed email if tag is set
|
||||
const fromEmail = currentIdentity?.email
|
||||
const identityFromEmail = currentIdentity?.email
|
||||
? subAddressTag
|
||||
? generateSubAddress(currentIdentity.email, subAddressTag, subAddressDelimiter)
|
||||
: currentIdentity.email
|
||||
: undefined;
|
||||
const fromEmail = (fromOverrideEnabled && fromOverrideEmail.trim())
|
||||
? fromOverrideEmail.trim()
|
||||
: identityFromEmail;
|
||||
const fromName = (fromOverrideEnabled && fromOverrideEmail.trim())
|
||||
? (fromOverrideName.trim() || undefined)
|
||||
: (currentIdentity?.name || undefined);
|
||||
|
||||
try {
|
||||
const previousDraftId = draftIdRef.current;
|
||||
const savedDraftId = await client.createDraft(
|
||||
toAddresses,
|
||||
subject || t('no_subject'),
|
||||
@@ -777,12 +967,15 @@ export function EmailComposer({
|
||||
bccAddresses,
|
||||
currentIdentity?.id,
|
||||
fromEmail,
|
||||
draftId || undefined,
|
||||
previousDraftId || undefined,
|
||||
uploadedAttachments,
|
||||
currentIdentity?.name || undefined,
|
||||
fromName,
|
||||
plainTextMode ? undefined : body
|
||||
);
|
||||
|
||||
// Update the ref synchronously so a queued save sees the new id and
|
||||
// doesn't try to destroy the just-replaced draft.
|
||||
draftIdRef.current = savedDraftId;
|
||||
setDraftId(savedDraftId);
|
||||
lastSavedDataRef.current = currentData;
|
||||
setSaveStatus('saved');
|
||||
@@ -799,6 +992,28 @@ export function EmailComposer({
|
||||
}
|
||||
};
|
||||
|
||||
// Serialize saves: each call waits for the previous in-flight save before
|
||||
// running. This prevents the autosave timer and the send button from
|
||||
// racing two `Email/set { destroy, create }` requests against the same
|
||||
// draftId, which left orphan drafts and (when EmailSubmission failed)
|
||||
// looked like "send didn't happen" (#303).
|
||||
const saveDraft = (): Promise<string | null> => {
|
||||
const previous = inflightSaveRef.current;
|
||||
const promise = (async (): Promise<string | null> => {
|
||||
if (previous) {
|
||||
try { await previous; } catch { /* prior failure already reported */ }
|
||||
}
|
||||
return saveDraftOnce();
|
||||
})();
|
||||
inflightSaveRef.current = promise;
|
||||
promise.finally(() => {
|
||||
if (inflightSaveRef.current === promise) {
|
||||
inflightSaveRef.current = null;
|
||||
}
|
||||
});
|
||||
return promise;
|
||||
};
|
||||
|
||||
// Keep saveDraftRef pointing to latest saveDraft
|
||||
saveDraftRef.current = saveDraft;
|
||||
|
||||
@@ -816,6 +1031,10 @@ export function EmailComposer({
|
||||
|
||||
// Set new timeout for auto-save (2 seconds after last change)
|
||||
saveTimeoutRef.current = setTimeout(() => {
|
||||
// Clear the ref so handleSend can distinguish "save scheduled" from
|
||||
// "save in flight" - the former still needs flushing, the latter is
|
||||
// tracked via inflightSaveRef.
|
||||
saveTimeoutRef.current = null;
|
||||
// Plugin observers (AI assist, grammar, …) get a debounced snapshot here.
|
||||
emailHooks.onDraftChange.emit({
|
||||
to: to.split(',').map(s => s.trim()).filter(Boolean),
|
||||
@@ -966,33 +1185,66 @@ export function EmailComposer({
|
||||
}
|
||||
}
|
||||
|
||||
let finalDraftId = draftId;
|
||||
// Resolve the freshest draftId we can. Two cases:
|
||||
// 1. An autosave is currently in flight - wait for it; don't issue a
|
||||
// parallel destroy/create that would race with it on the same id.
|
||||
// 2. A debounced save is scheduled (timer set) - cancel it and flush
|
||||
// now so the latest body content lands on the server.
|
||||
// Use draftIdRef (not the React state) because state updates from
|
||||
// the in-flight save may not have rendered yet when we read here.
|
||||
let finalDraftId = draftIdRef.current;
|
||||
if (inflightSaveRef.current) {
|
||||
try {
|
||||
const savedId = await inflightSaveRef.current;
|
||||
if (savedId) finalDraftId = savedId;
|
||||
} catch (err) {
|
||||
debug.error('In-flight draft save failed before send:', err);
|
||||
}
|
||||
}
|
||||
if (saveTimeoutRef.current) {
|
||||
clearTimeout(saveTimeoutRef.current);
|
||||
saveTimeoutRef.current = null;
|
||||
try {
|
||||
const savedId = await saveDraft();
|
||||
if (savedId) {
|
||||
finalDraftId = savedId;
|
||||
}
|
||||
if (savedId) finalDraftId = savedId;
|
||||
} catch (err) {
|
||||
debug.error('Failed to save draft before send:', err);
|
||||
}
|
||||
}
|
||||
|
||||
const fromEmail = currentIdentity?.email
|
||||
const identityFromEmail = currentIdentity?.email
|
||||
? subAddressTag
|
||||
? generateSubAddress(currentIdentity.email, subAddressTag, subAddressDelimiter)
|
||||
: currentIdentity.email
|
||||
: undefined;
|
||||
// When the user has typed a From override, that becomes the header From
|
||||
// (and MIME-builder From in the S/MIME path). The identity still drives
|
||||
// the SMTP envelope MAIL FROM - set explicitly so it doesn't mistakenly
|
||||
// default to the override address.
|
||||
const overrideActive = fromOverrideEnabled && fromOverrideEmail.trim().length > 0;
|
||||
const fromEmail = overrideActive ? fromOverrideEmail.trim() : identityFromEmail;
|
||||
const fromName = overrideActive
|
||||
? (fromOverrideName.trim() || undefined)
|
||||
: (currentIdentity?.name || undefined);
|
||||
const envelopeMailFrom = overrideActive ? identityFromEmail : undefined;
|
||||
|
||||
// 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)
|
||||
const buildSignatureHtml = (): string => {
|
||||
if (currentIdentity?.htmlSignature) {
|
||||
return `<br><br>-- <br>${sanitizeEmailHtml(currentIdentity.htmlSignature)}`;
|
||||
if (signatureAlreadyInBody) return '';
|
||||
const sep = signatureSeparatorEnabled ? `<br><br>-- <br>` : `<br><br>`;
|
||||
if (signatureIdentity?.htmlSignature) {
|
||||
return `${sep}${sanitizeSignatureHtml(signatureIdentity.htmlSignature)}`;
|
||||
}
|
||||
if (currentIdentity?.textSignature) {
|
||||
return `<br><br>-- <br>${currentIdentity.textSignature.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/\n/g, '<br>')}`;
|
||||
if (signatureIdentity?.textSignature) {
|
||||
return `${sep}${signatureIdentity.textSignature.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/\n/g, '<br>')}`;
|
||||
}
|
||||
return '';
|
||||
};
|
||||
@@ -1003,9 +1255,10 @@ export function EmailComposer({
|
||||
: null;
|
||||
|
||||
// In plain text mode, send text/plain only (no HTML body)
|
||||
const signatureOpts = { separator: signatureSeparatorEnabled };
|
||||
const finalBody = plainTextMode
|
||||
? appendPlainTextSignature(body, currentIdentity)
|
||||
: appendPlainTextSignature(htmlToPlainText(body), currentIdentity);
|
||||
? (signatureAlreadyInBody ? body : appendPlainTextSignature(body, signatureIdentity, signatureOpts))
|
||||
: (signatureAlreadyInBody ? htmlToPlainText(body) : appendPlainTextSignature(htmlToPlainText(body), signatureIdentity, signatureOpts));
|
||||
|
||||
const rewritten = plainTextMode ? null : rewriteInlineImages(body);
|
||||
const finalHtmlBody = plainTextMode
|
||||
@@ -1041,6 +1294,12 @@ export function EmailComposer({
|
||||
if (smimeSign_ && !smimeKeyRecord) {
|
||||
throw new Error('No S/MIME key bound to this identity');
|
||||
}
|
||||
// S/MIME binds to the identity's key; sending from an override address
|
||||
// would produce a signature whose Subject differs from the visible
|
||||
// From, which most clients reject or flag. Refuse up front.
|
||||
if (overrideActive) {
|
||||
throw new Error('Cannot use From override with S/MIME - disable one to send.');
|
||||
}
|
||||
|
||||
// 2. Ensure key is unlocked for signing
|
||||
if (smimeSign_ && smimeKeyRecord && !smimeStore.isKeyUnlocked(smimeKeyRecord.id)) {
|
||||
@@ -1194,8 +1453,9 @@ export function EmailComposer({
|
||||
htmlBody: outgoing.htmlBody || undefined,
|
||||
draftId: finalDraftId || undefined,
|
||||
fromEmail,
|
||||
fromName: currentIdentity?.name || undefined,
|
||||
fromName,
|
||||
identityId: outgoing.identityId || currentIdentity?.id,
|
||||
envelopeMailFrom,
|
||||
attachments: uploadedAttachments.length > 0 ? uploadedAttachments : undefined,
|
||||
inReplyTo: threadingHeaders?.inReplyTo,
|
||||
references: threadingHeaders?.references,
|
||||
@@ -1220,6 +1480,7 @@ export function EmailComposer({
|
||||
setBcc("");
|
||||
setSubject("");
|
||||
setBody("");
|
||||
draftIdRef.current = null;
|
||||
setDraftId(null);
|
||||
setSubAddressTag("");
|
||||
setValidationErrors({});
|
||||
@@ -1227,7 +1488,7 @@ export function EmailComposer({
|
||||
setScheduleValue('');
|
||||
setScheduleError('');
|
||||
// Clear ref so unmount effect doesn't re-save
|
||||
stateRef.current = { to: '', cc: '', bcc: '', subject: '', body: '', showCc: false, showBcc: false, selectedIdentityId: null, subAddressTag: '', draftId: null };
|
||||
stateRef.current = { to: '', cc: '', bcc: '', subject: '', body: '', showCc: false, showBcc: false, selectedIdentityId: null, subAddressTag: '', draftId: null, fromOverrideEnabled: false, fromOverrideEmail: '', fromOverrideName: '' };
|
||||
} catch (err) {
|
||||
debug.error('Failed to send email:', err);
|
||||
toast.error(err instanceof Error ? err.message : t('send_failed'));
|
||||
@@ -1251,7 +1512,7 @@ export function EmailComposer({
|
||||
if (saveTimeoutRef.current) {
|
||||
clearTimeout(saveTimeoutRef.current);
|
||||
}
|
||||
stateRef.current = { to: '', cc: '', bcc: '', subject: '', body: '', showCc: false, showBcc: false, selectedIdentityId: null, subAddressTag: '', draftId: null };
|
||||
stateRef.current = { to: '', cc: '', bcc: '', subject: '', body: '', showCc: false, showBcc: false, selectedIdentityId: null, subAddressTag: '', draftId: null, fromOverrideEnabled: false, fromOverrideEmail: '', fromOverrideName: '' };
|
||||
onClose?.();
|
||||
};
|
||||
|
||||
@@ -1261,7 +1522,7 @@ export function EmailComposer({
|
||||
clearTimeout(saveTimeoutRef.current);
|
||||
}
|
||||
await saveDraft();
|
||||
stateRef.current = { to: '', cc: '', bcc: '', subject: '', body: '', showCc: false, showBcc: false, selectedIdentityId: null, subAddressTag: '', draftId: null };
|
||||
stateRef.current = { to: '', cc: '', bcc: '', subject: '', body: '', showCc: false, showBcc: false, selectedIdentityId: null, subAddressTag: '', draftId: null, fromOverrideEnabled: false, fromOverrideEmail: '', fromOverrideName: '' };
|
||||
onClose?.();
|
||||
};
|
||||
|
||||
@@ -1273,7 +1534,7 @@ export function EmailComposer({
|
||||
if (draftId && onDiscardDraft) {
|
||||
onDiscardDraft(draftId);
|
||||
}
|
||||
stateRef.current = { to: '', cc: '', bcc: '', subject: '', body: '', showCc: false, showBcc: false, selectedIdentityId: null, subAddressTag: '', draftId: null };
|
||||
stateRef.current = { to: '', cc: '', bcc: '', subject: '', body: '', showCc: false, showBcc: false, selectedIdentityId: null, subAddressTag: '', draftId: null, fromOverrideEnabled: false, fromOverrideEmail: '', fromOverrideName: '' };
|
||||
onClose?.();
|
||||
};
|
||||
|
||||
@@ -1392,7 +1653,25 @@ export function EmailComposer({
|
||||
<div className="flex items-center gap-2 px-4 py-2.5 border-b border-border/50">
|
||||
<span className="text-sm text-muted-foreground w-12 md:w-16 shrink-0">{t('from')}:</span>
|
||||
<div className="flex-1 flex items-center gap-1 min-w-0">
|
||||
{identities.length > 1 ? (
|
||||
{fromOverrideEnabled ? (
|
||||
<div className="flex-1 flex items-center gap-1 min-w-0">
|
||||
<Input
|
||||
value={fromOverrideName}
|
||||
onChange={(e) => setFromOverrideName(e.target.value)}
|
||||
placeholder={t('from_override.name_placeholder')}
|
||||
className="h-7 text-sm w-32 md:w-40 shrink-0"
|
||||
aria-label={t('from_override.name_label')}
|
||||
/>
|
||||
<Input
|
||||
value={fromOverrideEmail}
|
||||
onChange={(e) => setFromOverrideEmail(e.target.value)}
|
||||
placeholder={t('from_override.email_placeholder')}
|
||||
type="email"
|
||||
className="h-7 text-sm flex-1 min-w-0 font-mono"
|
||||
aria-label={t('from_override.email_label')}
|
||||
/>
|
||||
</div>
|
||||
) : identities.length > 1 ? (
|
||||
<select
|
||||
value={selectedIdentityId || primaryIdentity?.id || ''}
|
||||
onChange={(e) => setSelectedIdentityId(e.target.value)}
|
||||
@@ -1424,16 +1703,18 @@ export function EmailComposer({
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
<SubAddressHelper
|
||||
baseEmail={
|
||||
(selectedIdentityId
|
||||
? identities.find(id => id.id === selectedIdentityId)?.email
|
||||
: primaryIdentity?.email) || ''
|
||||
}
|
||||
recipientEmails={to.split(',').map(e => e.trim()).filter(Boolean)}
|
||||
onSelectTag={setSubAddressTag}
|
||||
/>
|
||||
{subAddressTag && (
|
||||
{!fromOverrideEnabled && (
|
||||
<SubAddressHelper
|
||||
baseEmail={
|
||||
(selectedIdentityId
|
||||
? identities.find(id => id.id === selectedIdentityId)?.email
|
||||
: primaryIdentity?.email) || ''
|
||||
}
|
||||
recipientEmails={to.split(',').map(e => e.trim()).filter(Boolean)}
|
||||
onSelectTag={setSubAddressTag}
|
||||
/>
|
||||
)}
|
||||
{!fromOverrideEnabled && subAddressTag && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
@@ -1445,6 +1726,28 @@ export function EmailComposer({
|
||||
<X className="w-3 h-3" />
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
type="button"
|
||||
variant={fromOverrideEnabled ? 'outline' : 'ghost'}
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
if (fromOverrideEnabled) {
|
||||
setFromOverrideEnabled(false);
|
||||
} else {
|
||||
setFromOverrideEnabled(true);
|
||||
if (!fromOverrideEmail && currentIdentity?.email) {
|
||||
setFromOverrideEmail(currentIdentity.email);
|
||||
}
|
||||
if (!fromOverrideName && currentIdentity?.name) {
|
||||
setFromOverrideName(currentIdentity.name);
|
||||
}
|
||||
}
|
||||
}}
|
||||
className="h-6 px-2 text-xs shrink-0"
|
||||
title={t('from_override.toggle_tooltip')}
|
||||
>
|
||||
{fromOverrideEnabled ? t('from_override.toggle_on') : t('from_override.toggle_off')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1591,20 +1894,24 @@ export function EmailComposer({
|
||||
onImageUpload={handleImageUpload}
|
||||
placeholder={t('body_placeholder')}
|
||||
hasError={validationErrors.body}
|
||||
onEditorReady={(ed) => { editorRef.current = ed; }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{plainTextMode ? (
|
||||
getPlainTextSignature(currentIdentity) ? (
|
||||
{/* 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) ? (
|
||||
<div className="px-4 pb-3 text-sm leading-6 text-muted-foreground break-words whitespace-pre-wrap font-mono">
|
||||
{'-- \n'}{getPlainTextSignature(currentIdentity)}
|
||||
{signatureSeparatorEnabled ? '-- \n' : ''}{getPlainTextSignature(signatureIdentity)}
|
||||
</div>
|
||||
) : null
|
||||
) : composerSignatureHtml ? (
|
||||
<div
|
||||
className="px-4 pb-3 text-sm leading-6 text-foreground break-words [&_a]:text-primary [&_a]:underline-offset-2 [&_a:hover]:underline"
|
||||
dangerouslySetInnerHTML={{ __html: `<div>-- </div>${composerSignatureHtml}` }}
|
||||
dangerouslySetInnerHTML={{ __html: `${signatureSeparatorEnabled ? '<div>-- </div>' : ''}${composerSignatureHtml}` }}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
@@ -1662,7 +1969,7 @@ export function EmailComposer({
|
||||
)}
|
||||
|
||||
{/* Bottom toolbar */}
|
||||
<div className="flex items-center justify-between px-4 py-2.5 border-t bg-background shrink-0">
|
||||
<div className="flex items-center justify-between px-4 py-2.5 border-t bg-background shrink-0 pb-[calc(env(safe-area-inset-bottom)/2)]">
|
||||
{/* Left side actions */}
|
||||
<div className="flex items-center gap-1">
|
||||
<input
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useCallback } from "react";
|
||||
import { formatDate } from "@/lib/utils";
|
||||
import { formatDate, stripInvisibleLeading } from "@/lib/utils";
|
||||
import { Email } from "@/lib/jmap/types";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Avatar } from "@/components/ui/avatar";
|
||||
@@ -21,6 +21,7 @@ interface EmailListItemProps {
|
||||
email: Email;
|
||||
selected?: boolean;
|
||||
onClick?: () => void;
|
||||
onDoubleClick?: () => void;
|
||||
onContextMenu?: (e: React.MouseEvent, email: Email) => void;
|
||||
onToggleStar?: () => void;
|
||||
onMarkAsRead?: (read: boolean) => void;
|
||||
@@ -30,7 +31,7 @@ interface EmailListItemProps {
|
||||
onMarkAsSpam?: () => void;
|
||||
}
|
||||
|
||||
export function EmailListItem({ email, selected, onClick, onContextMenu, onToggleStar, onMarkAsRead, onDelete, onArchive, onSetColorTag, onMarkAsSpam }: EmailListItemProps) {
|
||||
export function EmailListItem({ email, selected, onClick, onDoubleClick, onContextMenu, onToggleStar, onMarkAsRead, onDelete, onArchive, onSetColorTag, onMarkAsSpam }: EmailListItemProps) {
|
||||
const t = useTranslations('email_viewer');
|
||||
const { selectedEmailIds, toggleEmailSelection, selectRangeEmails, selectedMailbox, mailboxes, clearSelection } = useEmailStore();
|
||||
const showPreview = useSettingsStore((state) => state.showPreview);
|
||||
@@ -51,7 +52,8 @@ export function EmailListItem({ email, selected, onClick, onContextMenu, onToggl
|
||||
const sender = showRecipient ? (email.to?.[0] ?? email.from?.[0]) : email.from?.[0];
|
||||
const isFocusedMailLayout = mailLayout === 'focus';
|
||||
const hideJunkAvatarImages = currentMailboxRole === 'junk' && !showAvatarsInJunk;
|
||||
const inlinePreview = showPreview && email.preview ? ` ${email.preview}` : '';
|
||||
const trimmedPreview = stripInvisibleLeading(email.preview ?? '');
|
||||
const inlinePreview = showPreview && trimmedPreview ? ` ${trimmedPreview}` : '';
|
||||
|
||||
// Resolve color tags using keyword definitions from settings; unknown tags fall back to gray
|
||||
const colorTagIds = getEmailColorTags(email.keywords);
|
||||
@@ -124,12 +126,18 @@ export function EmailListItem({ email, selected, onClick, onContextMenu, onToggl
|
||||
onClick?.();
|
||||
}
|
||||
}}
|
||||
onDoubleClick={(e) => {
|
||||
if (e.ctrlKey || e.metaKey || e.shiftKey) return;
|
||||
if (!onDoubleClick) return;
|
||||
e.preventDefault();
|
||||
onDoubleClick();
|
||||
}}
|
||||
onContextMenu={handleContextMenu}
|
||||
style={{ minHeight: isFocusedMailLayout ? undefined : 'var(--list-item-height)' }}
|
||||
>
|
||||
<div
|
||||
className={cn('px-4', isFocusedMailLayout ? 'flex items-center py-2.5' : 'flex items-start')}
|
||||
style={isFocusedMailLayout ? { gap: '12px' } : { gap: 'var(--density-item-gap)', paddingBlock: 'var(--density-item-py)' }}
|
||||
className={cn('px-4', isFocusedMailLayout ? 'flex items-center' : 'flex items-start')}
|
||||
style={{ gap: 'var(--density-item-gap)', paddingBlock: 'var(--density-item-py)' }}
|
||||
>
|
||||
{/* Checkbox - only visible when in selection mode */}
|
||||
{selectedEmailIds.size > 0 && (
|
||||
@@ -160,11 +168,11 @@ export function EmailListItem({ email, selected, onClick, onContextMenu, onToggl
|
||||
)}
|
||||
|
||||
{/* Avatar */}
|
||||
{!isFocusedMailLayout && density !== 'extra-compact' && (
|
||||
{density !== 'extra-compact' && (
|
||||
<Avatar
|
||||
name={sender?.name}
|
||||
email={sender?.email}
|
||||
size="md"
|
||||
size={isFocusedMailLayout ? "sm" : "md"}
|
||||
className="flex-shrink-0 shadow-sm"
|
||||
disableImages={hideJunkAvatarImages}
|
||||
/>
|
||||
@@ -295,7 +303,7 @@ export function EmailListItem({ email, selected, onClick, onContextMenu, onToggl
|
||||
? "text-muted-foreground"
|
||||
: "text-muted-foreground/80"
|
||||
)}>
|
||||
{email.preview || "No preview available"}
|
||||
{trimmedPreview || t('no_preview_available')}
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
|
||||
@@ -24,6 +24,7 @@ interface EmailListProps {
|
||||
emails: Email[];
|
||||
selectedEmailId?: string;
|
||||
onEmailSelect?: (email: Email) => void;
|
||||
onEmailDoubleClick?: (email: Email) => void;
|
||||
className?: string;
|
||||
isLoading?: boolean;
|
||||
onOpenConversation?: (thread: ThreadGroup) => void;
|
||||
@@ -50,6 +51,7 @@ export function EmailList({
|
||||
emails,
|
||||
selectedEmailId,
|
||||
onEmailSelect,
|
||||
onEmailDoubleClick,
|
||||
className,
|
||||
isLoading = false,
|
||||
onOpenConversation,
|
||||
@@ -121,7 +123,7 @@ export function EmailList({
|
||||
|
||||
const estimateSize = useCallback(() => {
|
||||
if (isFocusedMailLayout) {
|
||||
return { 'extra-compact': 32, compact: 40, regular: 46, comfortable: 54 }[density];
|
||||
return { 'extra-compact': 28, compact: 40, regular: 56, comfortable: 64 }[density];
|
||||
}
|
||||
const base = { 'extra-compact': 32, compact: 60, regular: 84, comfortable: 104 }[density];
|
||||
return (showPreview && density !== 'extra-compact') ? base + 36 : base;
|
||||
@@ -494,6 +496,7 @@ export function EmailList({
|
||||
expandedEmails={threadEmailsCache.get(thread.threadId)}
|
||||
onToggleExpand={() => handleToggleThreadExpansion(thread.threadId)}
|
||||
onEmailSelect={(email) => onEmailSelect?.(email)}
|
||||
onEmailDoubleClick={onEmailDoubleClick ? (email) => onEmailDoubleClick(email) : undefined}
|
||||
onContextMenu={openContextMenu}
|
||||
onOpenConversation={onOpenConversation}
|
||||
onToggleStar={onToggleStar ? (email) => onToggleStar(email) : undefined}
|
||||
|
||||
+711
-479
File diff suppressed because it is too large
Load Diff
@@ -39,7 +39,11 @@ export function RecipientPopover({ name, email, displayLabel, onViewContact, cla
|
||||
const phones = contact?.phones ? Object.values(contact.phones) : [];
|
||||
const orgs = contact?.organizations ? Object.values(contact.organizations) : [];
|
||||
|
||||
const handleOpen = () => {
|
||||
const handleToggle = () => {
|
||||
if (isOpen) {
|
||||
handleClose();
|
||||
return;
|
||||
}
|
||||
if (!triggerRef.current) return;
|
||||
const rect = triggerRef.current.getBoundingClientRect();
|
||||
const popoverWidth = 300;
|
||||
@@ -125,9 +129,9 @@ export function RecipientPopover({ name, email, displayLabel, onViewContact, cla
|
||||
<>
|
||||
<button
|
||||
ref={triggerRef}
|
||||
onClick={handleOpen}
|
||||
onClick={handleToggle}
|
||||
className={cn(
|
||||
"text-foreground hover:text-primary hover:underline cursor-pointer transition-colors",
|
||||
"text-foreground hover:text-primary hover:underline cursor-pointer transition-colors min-w-0 break-words",
|
||||
className
|
||||
)}
|
||||
>
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
"use client";
|
||||
|
||||
import React, { useEffect, useCallback, useState, useRef } from "react";
|
||||
import { useEditor, EditorContent } from "@tiptap/react";
|
||||
import { useEditor, EditorContent, type Editor } from "@tiptap/react";
|
||||
import StarterKit from "@tiptap/starter-kit";
|
||||
import Paragraph from "@tiptap/extension-paragraph";
|
||||
import Heading from "@tiptap/extension-heading";
|
||||
import Underline from "@tiptap/extension-underline";
|
||||
import Link from "@tiptap/extension-link";
|
||||
import TextAlign from "@tiptap/extension-text-align";
|
||||
@@ -44,6 +46,51 @@ export interface InlineImageUpload {
|
||||
cid?: string;
|
||||
}
|
||||
|
||||
// Pasted email content (signatures, replies, quoted text) commonly carries
|
||||
// inline styles on block elements. StarterKit's default Paragraph/Heading
|
||||
// drop unknown attributes; extend them to round-trip `style` and `class` so
|
||||
// signature formatting survives the editor.
|
||||
const styledBlockAttributes = {
|
||||
style: {
|
||||
default: null as string | null,
|
||||
parseHTML: (el: HTMLElement) => el.getAttribute("style"),
|
||||
renderHTML: (attrs: Record<string, string | null>) =>
|
||||
attrs.style ? { style: attrs.style } : {},
|
||||
},
|
||||
class: {
|
||||
default: null as string | null,
|
||||
parseHTML: (el: HTMLElement) => el.getAttribute("class"),
|
||||
renderHTML: (attrs: Record<string, string | null>) =>
|
||||
attrs.class ? { class: attrs.class } : {},
|
||||
},
|
||||
"data-signature-block": {
|
||||
default: null as string | null,
|
||||
parseHTML: (el: HTMLElement) => el.getAttribute("data-signature-block"),
|
||||
renderHTML: (attrs: Record<string, string | null>) =>
|
||||
attrs["data-signature-block"]
|
||||
? { "data-signature-block": attrs["data-signature-block"] }
|
||||
: {},
|
||||
},
|
||||
};
|
||||
|
||||
const StyledParagraph = Paragraph.extend({
|
||||
addAttributes() {
|
||||
return {
|
||||
...this.parent?.(),
|
||||
...styledBlockAttributes,
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
const StyledHeading = Heading.extend({
|
||||
addAttributes() {
|
||||
return {
|
||||
...this.parent?.(),
|
||||
...styledBlockAttributes,
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
interface RichTextEditorProps {
|
||||
content: string;
|
||||
onChange: (html: string) => void;
|
||||
@@ -51,6 +98,7 @@ interface RichTextEditorProps {
|
||||
placeholder?: string;
|
||||
className?: string;
|
||||
hasError?: boolean;
|
||||
onEditorReady?: (editor: Editor) => void;
|
||||
}
|
||||
|
||||
function ToolbarButton({
|
||||
@@ -131,17 +179,23 @@ export function RichTextEditor({
|
||||
placeholder,
|
||||
className,
|
||||
hasError,
|
||||
onEditorReady,
|
||||
}: RichTextEditorProps) {
|
||||
const onImageUploadRef = React.useRef(onImageUpload);
|
||||
onImageUploadRef.current = onImageUpload;
|
||||
const onEditorReadyRef = React.useRef(onEditorReady);
|
||||
onEditorReadyRef.current = onEditorReady;
|
||||
|
||||
const editor = useEditor({
|
||||
extensions: [
|
||||
StarterKit.configure({
|
||||
heading: { levels: [1, 2] },
|
||||
heading: false,
|
||||
paragraph: false,
|
||||
link: false,
|
||||
underline: false,
|
||||
}),
|
||||
StyledParagraph,
|
||||
StyledHeading.configure({ levels: [1, 2] }),
|
||||
Underline,
|
||||
Link.configure({
|
||||
openOnClick: false,
|
||||
@@ -239,6 +293,12 @@ export function RichTextEditor({
|
||||
}
|
||||
}, [content, editor]);
|
||||
|
||||
// Expose the editor instance once it's ready so parents can target
|
||||
// specific nodes (e.g. swap the embedded signature on identity change).
|
||||
useEffect(() => {
|
||||
if (editor) onEditorReadyRef.current?.(editor);
|
||||
}, [editor]);
|
||||
|
||||
const addLink = useCallback(() => {
|
||||
if (!editor) return;
|
||||
const previousUrl = editor.getAttributes("link").href;
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useMemo } from "react";
|
||||
import { useState, useEffect, useMemo, useRef, useCallback } from "react";
|
||||
import DOMPurify from "dompurify";
|
||||
import { Email, ThreadGroup } from "@/lib/jmap/types";
|
||||
import { EMAIL_SANITIZE_CONFIG, collapseBlockedImageContainers, plainTextToSafeHtml } from "@/lib/email-sanitization";
|
||||
import { EMAIL_SANITIZE_CONFIG, collapseBlockedImageContainers, plainTextToSafeHtml, sanitizePlainTextRenderedHtml } from "@/lib/email-sanitization";
|
||||
import { hasMeaningfulHtmlBody } from "@/lib/signature-utils";
|
||||
import { transformInlineStyles, transformColorForDarkMode, transformBgColorForDarkMode } from "@/lib/color-transform";
|
||||
import { useThemeStore } from "@/stores/theme-store";
|
||||
@@ -331,7 +331,7 @@ function EmailCard({
|
||||
htmlContent = email.bodyValues[email.htmlBody[0].partId].value;
|
||||
// Prefer textBody when HTML is auto-generated minimal wrapper (no rich formatting).
|
||||
// Server-generated HTML from text/plain emails often lacks <br> tags, collapsing newlines.
|
||||
// Per RFC 8621, an HTML-only email exposes the same partId in both htmlBody and textBody —
|
||||
// Per RFC 8621, an HTML-only email exposes the same partId in both htmlBody and textBody -
|
||||
// in that case there is no real plain-text alternative, so always render the HTML.
|
||||
const textPartId = email.textBody?.[0]?.partId;
|
||||
const htmlPartId = email.htmlBody[0].partId;
|
||||
@@ -440,6 +440,50 @@ function EmailCard({
|
||||
return { html: "", isHtml: false };
|
||||
}, [email, allowExternal, resolvedTheme, emailAlwaysLightMode, cidBlobUrls]);
|
||||
|
||||
// Render the sanitized HTML body inside a sandboxed iframe so a malicious
|
||||
// (or accidentally-bypassed) email cannot inject styles/scripts/forms into
|
||||
// the host page. CSP <meta> is defense-in-depth in case the sanitizer ever
|
||||
// emits a <script> tag through a parser quirk.
|
||||
const iframeRef = useRef<HTMLIFrameElement>(null);
|
||||
const emailIframeSrcDoc = useMemo(() => {
|
||||
if (!emailContent.isHtml || !emailContent.html) return '';
|
||||
const csp = "default-src 'none'; img-src data: blob: http: https:; style-src 'unsafe-inline'; font-src data: http: https:; media-src data: blob: http: https:; base-uri 'none'; form-action 'none'; frame-src 'none'";
|
||||
return `<!DOCTYPE html><html><head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<meta http-equiv="Content-Security-Policy" content="${csp}">
|
||||
<style>
|
||||
html, body { overflow: hidden; }
|
||||
body { margin: 0; padding: 0; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; font-size: 14px; line-height: 1.6; color: #1a1a1a; background: #ffffff; word-wrap: break-word; overflow-wrap: break-word; }
|
||||
img { max-width: 100% !important; height: auto !important; }
|
||||
a { color: #1a73e8; }
|
||||
table { max-width: 100% !important; table-layout: auto; overflow-wrap: break-word; }
|
||||
td, th { word-break: break-word; padding: 0.5rem; }
|
||||
pre { white-space: pre-wrap; word-wrap: break-word; }
|
||||
</style></head><body>${emailContent.html}</body></html>`;
|
||||
}, [emailContent.isHtml, emailContent.html]);
|
||||
|
||||
const handleIframeLoad = useCallback(() => {
|
||||
const iframe = iframeRef.current;
|
||||
if (!iframe) return;
|
||||
try {
|
||||
const doc = iframe.contentDocument;
|
||||
if (!doc?.body) return;
|
||||
const resize = () => {
|
||||
iframe.style.height = doc.documentElement.scrollHeight + 'px';
|
||||
};
|
||||
resize();
|
||||
const ro = new ResizeObserver(resize);
|
||||
ro.observe(doc.body);
|
||||
doc.querySelectorAll('a').forEach((a) => {
|
||||
a.setAttribute('target', '_blank');
|
||||
a.setAttribute('rel', 'noopener noreferrer');
|
||||
});
|
||||
} catch {
|
||||
// contentDocument may be inaccessible under stricter sandboxes; ignore.
|
||||
}
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className={cn(
|
||||
"rounded-lg border border-border overflow-hidden transition-all duration-200",
|
||||
@@ -483,7 +527,7 @@ function EmailCard({
|
||||
</div>
|
||||
{!isExpanded && density !== 'extra-compact' && (
|
||||
<p className="text-sm text-muted-foreground mt-1 line-clamp-2">
|
||||
{email.preview || "No preview available"}
|
||||
{email.preview || t('email_viewer.no_preview_available')}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
@@ -534,18 +578,31 @@ function EmailCard({
|
||||
|
||||
{/* Email Body */}
|
||||
<div style={{ padding: 'var(--density-card-p)' }}>
|
||||
<div
|
||||
className={cn(
|
||||
"prose prose-sm max-w-none",
|
||||
!emailAlwaysLightMode && "dark:prose-invert",
|
||||
"prose-p:my-2 prose-headings:my-3",
|
||||
"prose-a:text-primary prose-a:no-underline hover:prose-a:underline",
|
||||
"[&_table]:border-collapse [&_td]:p-2 [&_th]:p-2",
|
||||
"[&_img]:max-w-full [&_img]:h-auto"
|
||||
)}
|
||||
style={!emailContent.isHtml ? { whiteSpace: 'pre-wrap', fontFamily: 'ui-monospace, "SF Mono", Consolas, monospace', fontSize: '13px' } : undefined}
|
||||
dangerouslySetInnerHTML={{ __html: emailContent.html }}
|
||||
/>
|
||||
{emailContent.isHtml ? (
|
||||
<iframe
|
||||
ref={iframeRef}
|
||||
srcDoc={emailIframeSrcDoc}
|
||||
sandbox="allow-same-origin allow-popups allow-popups-to-escape-sandbox"
|
||||
title="Email content"
|
||||
className="w-full border-0 block"
|
||||
scrolling="no"
|
||||
style={{ minHeight: '60px' }}
|
||||
onLoad={handleIframeLoad}
|
||||
/>
|
||||
) : (
|
||||
<div
|
||||
className={cn(
|
||||
"prose prose-sm max-w-none",
|
||||
!emailAlwaysLightMode && "dark:prose-invert",
|
||||
"prose-p:my-2 prose-headings:my-3",
|
||||
"prose-a:text-primary prose-a:no-underline hover:prose-a:underline",
|
||||
"[&_table]:border-collapse [&_td]:p-2 [&_th]:p-2",
|
||||
"[&_img]:max-w-full [&_img]:h-auto"
|
||||
)}
|
||||
style={{ whiteSpace: 'pre-wrap', fontFamily: 'ui-monospace, "SF Mono", Consolas, monospace', fontSize: '13px' }}
|
||||
dangerouslySetInnerHTML={{ __html: sanitizePlainTextRenderedHtml(emailContent.html) }}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Attachments */}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { formatDate } from "@/lib/utils";
|
||||
import { Email } from "@/lib/jmap/types";
|
||||
import { cn } from "@/lib/utils";
|
||||
@@ -17,6 +18,7 @@ interface ThreadEmailItemProps {
|
||||
selected?: boolean;
|
||||
isLast?: boolean;
|
||||
onClick?: () => void;
|
||||
onDoubleClick?: () => void;
|
||||
onContextMenu?: (e: React.MouseEvent, email: Email) => void;
|
||||
}
|
||||
|
||||
@@ -25,8 +27,10 @@ export function ThreadEmailItem({
|
||||
selected,
|
||||
isLast = false,
|
||||
onClick,
|
||||
onDoubleClick,
|
||||
onContextMenu,
|
||||
}: ThreadEmailItemProps) {
|
||||
const t = useTranslations('email_viewer');
|
||||
const isUnread = !email.keywords?.$seen;
|
||||
const isStarred = email.keywords?.$flagged;
|
||||
const isAnswered = email.keywords?.$answered;
|
||||
@@ -94,6 +98,12 @@ export function ThreadEmailItem({
|
||||
isPressed && "bg-muted scale-[0.98] ring-2 ring-primary/30"
|
||||
)}
|
||||
onClick={handleClick}
|
||||
onDoubleClick={(e) => {
|
||||
if (e.ctrlKey || e.metaKey || e.shiftKey) return;
|
||||
if (!onDoubleClick) return;
|
||||
e.preventDefault();
|
||||
onDoubleClick();
|
||||
}}
|
||||
onContextMenu={handleContextMenu}
|
||||
style={{ paddingBlock: 'var(--density-item-py)' }}
|
||||
>
|
||||
@@ -177,7 +187,7 @@ export function ThreadEmailItem({
|
||||
? "text-muted-foreground"
|
||||
: "text-muted-foreground/70"
|
||||
)}>
|
||||
{email.preview || "No preview"}
|
||||
{email.preview || t('no_preview_available')}
|
||||
</span>
|
||||
|
||||
{/* Date */}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import React, { useCallback } from "react";
|
||||
import { formatDate } from "@/lib/utils";
|
||||
import { formatDate, stripInvisibleLeading } from "@/lib/utils";
|
||||
import { Email, ThreadGroup } from "@/lib/jmap/types";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Avatar } from "@/components/ui/avatar";
|
||||
@@ -25,6 +25,7 @@ interface ThreadListItemProps {
|
||||
expandedEmails?: Email[];
|
||||
onToggleExpand: () => void;
|
||||
onEmailSelect: (email: Email) => void;
|
||||
onEmailDoubleClick?: (email: Email) => void;
|
||||
onContextMenu?: (e: React.MouseEvent, email: Email) => void;
|
||||
onOpenConversation?: (thread: ThreadGroup) => void;
|
||||
onToggleStar?: (email: Email) => void;
|
||||
@@ -39,6 +40,7 @@ interface SingleEmailItemProps {
|
||||
email: Email;
|
||||
selected: boolean;
|
||||
onClick: () => void;
|
||||
onDoubleClick?: () => void;
|
||||
onContextMenu?: (e: React.MouseEvent, email: Email) => void;
|
||||
showPreview: boolean;
|
||||
colorTag: string | null;
|
||||
@@ -51,7 +53,8 @@ interface SingleEmailItemProps {
|
||||
}
|
||||
|
||||
const SingleEmailItem = React.forwardRef<HTMLDivElement, SingleEmailItemProps>(
|
||||
function SingleEmailItem({ email, selected, onClick, onContextMenu, showPreview, colorTag, onToggleStar, onMarkAsRead, onDelete, onArchive, onSetColorTag, onMarkAsSpam }, ref) {
|
||||
function SingleEmailItem({ email, selected, onClick, onDoubleClick, onContextMenu, showPreview, colorTag, onToggleStar, onMarkAsRead, onDelete, onArchive, onSetColorTag, onMarkAsSpam }, ref) {
|
||||
const t = useTranslations('email_viewer');
|
||||
const isUnread = !email.keywords?.$seen;
|
||||
const isStarred = email.keywords?.$flagged;
|
||||
const isAnswered = email.keywords?.$answered;
|
||||
@@ -71,7 +74,8 @@ const SingleEmailItem = React.forwardRef<HTMLDivElement, SingleEmailItemProps>(
|
||||
const accountColor = email.accountId ? getAccountById(email.accountId)?.avatarColor : undefined;
|
||||
const isChecked = selectedEmailIds.has(email.id);
|
||||
const isFocusedMailLayout = mailLayout === 'focus';
|
||||
const inlinePreview = showPreview && email.preview ? ` ${email.preview}` : '';
|
||||
const trimmedPreview = stripInvisibleLeading(email.preview ?? '');
|
||||
const inlinePreview = showPreview && trimmedPreview ? ` ${trimmedPreview}` : '';
|
||||
|
||||
// Resolve color tags using keyword definitions; unknown tags fall back to gray
|
||||
const tagIds = getEmailColorTags(email.keywords);
|
||||
@@ -144,12 +148,18 @@ const SingleEmailItem = React.forwardRef<HTMLDivElement, SingleEmailItemProps>(
|
||||
isPressed && "bg-muted scale-[0.98] ring-2 ring-primary/30"
|
||||
)}
|
||||
onClick={handleClick}
|
||||
onDoubleClick={(e) => {
|
||||
if (e.ctrlKey || e.metaKey || e.shiftKey) return;
|
||||
if (!onDoubleClick) return;
|
||||
e.preventDefault();
|
||||
onDoubleClick();
|
||||
}}
|
||||
onContextMenu={handleContextMenu}
|
||||
style={{ minHeight: isFocusedMailLayout ? undefined : 'var(--list-item-height)' }}
|
||||
>
|
||||
<div
|
||||
className={cn('px-3', isFocusedMailLayout ? 'flex items-center py-2.5' : 'flex items-start')}
|
||||
style={isFocusedMailLayout ? { gap: '12px' } : { gap: 'var(--density-item-gap)', paddingBlock: 'var(--density-item-py)' }}
|
||||
className={cn('px-3', isFocusedMailLayout ? 'flex items-center' : 'flex items-start')}
|
||||
style={{ gap: 'var(--density-item-gap)', paddingBlock: 'var(--density-item-py)' }}
|
||||
>
|
||||
{/* Checkbox - only visible when in selection mode */}
|
||||
{selectedEmailIds.size > 0 && (
|
||||
@@ -178,11 +188,11 @@ const SingleEmailItem = React.forwardRef<HTMLDivElement, SingleEmailItemProps>(
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isFocusedMailLayout && density !== 'extra-compact' && (
|
||||
{density !== 'extra-compact' && (
|
||||
<Avatar
|
||||
name={sender?.name}
|
||||
email={sender?.email}
|
||||
size="md"
|
||||
size={isFocusedMailLayout ? "sm" : "md"}
|
||||
className="flex-shrink-0 shadow-sm"
|
||||
disableImages={hideJunkAvatarImages}
|
||||
/>
|
||||
@@ -316,7 +326,7 @@ const SingleEmailItem = React.forwardRef<HTMLDivElement, SingleEmailItemProps>(
|
||||
? "text-muted-foreground"
|
||||
: "text-muted-foreground/80"
|
||||
)}>
|
||||
{email.preview || "No preview available"}
|
||||
{trimmedPreview || t('no_preview_available')}
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
@@ -349,6 +359,7 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
|
||||
expandedEmails,
|
||||
onToggleExpand,
|
||||
onEmailSelect,
|
||||
onEmailDoubleClick,
|
||||
onContextMenu,
|
||||
onOpenConversation,
|
||||
onToggleStar,
|
||||
@@ -359,6 +370,7 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
|
||||
onMarkAsSpam,
|
||||
}, ref) {
|
||||
const t = useTranslations('threads');
|
||||
const tEmailViewer = useTranslations('email_viewer');
|
||||
const showPreview = useSettingsStore((state) => state.showPreview);
|
||||
const density = useSettingsStore((state) => state.density);
|
||||
const mailLayout = useSettingsStore((state) => state.mailLayout);
|
||||
@@ -366,7 +378,8 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
|
||||
const isMobile = useUIStore((state) => state.isMobile);
|
||||
const { latestEmail, participantNames, hasUnread, hasStarred, hasAttachment, hasAnswered, hasForwarded, emailCount } = thread;
|
||||
const isFocusedMailLayout = mailLayout === 'focus';
|
||||
const inlinePreview = showPreview && latestEmail.preview ? ` ${latestEmail.preview}` : '';
|
||||
const trimmedPreview = stripInvisibleLeading(latestEmail.preview ?? '');
|
||||
const inlinePreview = showPreview && trimmedPreview ? ` ${trimmedPreview}` : '';
|
||||
|
||||
const { selectedMailbox, mailboxes, selectedEmailIds, toggleEmailSelection, selectRangeEmails, clearSelection, isUnifiedView } = useEmailStore();
|
||||
const getAccountById = useAccountStore((state) => state.getAccountById);
|
||||
@@ -416,6 +429,7 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
|
||||
email={latestEmail}
|
||||
selected={selectedEmailId === latestEmail.id}
|
||||
onClick={() => onEmailSelect(latestEmail)}
|
||||
onDoubleClick={onEmailDoubleClick ? () => onEmailDoubleClick(latestEmail) : undefined}
|
||||
onContextMenu={onContextMenu}
|
||||
showPreview={showPreview}
|
||||
colorTag={colorTag}
|
||||
@@ -502,12 +516,18 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
|
||||
isThreadPressed && "bg-muted scale-[0.98] ring-2 ring-primary/30"
|
||||
)}
|
||||
onClick={handleHeaderClick}
|
||||
onDoubleClick={(e) => {
|
||||
if (e.ctrlKey || e.metaKey || e.shiftKey) return;
|
||||
if (!onEmailDoubleClick) return;
|
||||
e.preventDefault();
|
||||
onEmailDoubleClick(latestEmail);
|
||||
}}
|
||||
onContextMenu={handleContextMenu}
|
||||
style={{ minHeight: isFocusedMailLayout ? undefined : 'var(--list-item-height)' }}
|
||||
>
|
||||
<div
|
||||
className={cn('px-3', isFocusedMailLayout ? 'flex items-center py-2.5' : 'flex items-start')}
|
||||
style={isFocusedMailLayout ? { gap: '12px' } : { gap: 'var(--density-item-gap)', paddingBlock: 'var(--density-item-py)' }}
|
||||
className={cn('px-3', isFocusedMailLayout ? 'flex items-center' : 'flex items-start')}
|
||||
style={{ gap: 'var(--density-item-gap)', paddingBlock: 'var(--density-item-py)' }}
|
||||
>
|
||||
{/* Checkbox for thread selection - only visible when in selection mode */}
|
||||
{selectedEmailIds.size > 0 && (
|
||||
@@ -562,11 +582,11 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isFocusedMailLayout && density !== 'extra-compact' && (
|
||||
{density !== 'extra-compact' && (
|
||||
<Avatar
|
||||
name={avatarPerson?.name}
|
||||
email={avatarPerson?.email}
|
||||
size="md"
|
||||
size={isFocusedMailLayout ? "sm" : "md"}
|
||||
className="flex-shrink-0 shadow-sm"
|
||||
disableImages={hideJunkAvatarImages}
|
||||
/>
|
||||
@@ -722,7 +742,7 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
|
||||
? "text-muted-foreground"
|
||||
: "text-muted-foreground/80"
|
||||
)}>
|
||||
{latestEmail.preview || "No preview available"}
|
||||
{trimmedPreview || tEmailViewer('no_preview_available')}
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
@@ -758,6 +778,7 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
|
||||
selected={email.id === selectedEmailId}
|
||||
isLast={index === emailsToShow.length - 1}
|
||||
onClick={() => onEmailSelect(email)}
|
||||
onDoubleClick={onEmailDoubleClick ? () => onEmailDoubleClick(email) : undefined}
|
||||
onContextMenu={onContextMenu}
|
||||
/>
|
||||
))
|
||||
|
||||
Reference in New Issue
Block a user