Merge branch 'main' into feature/scheduled-send

This commit is contained in:
Lucas Gaitzsch
2026-05-20 08:17:46 +02:00
244 changed files with 20839 additions and 3479 deletions
@@ -48,7 +48,12 @@ export function CalendarSidebarPanel({
const tSub = useTranslations("calendar.subscription");
const tMgmt = useTranslations("calendar.management");
const isSubscriptionCalendar = useCalendarStore((s) => s.isSubscriptionCalendar);
const icalSubscriptions = useCalendarStore((s) => s.icalSubscriptions);
const allSubs = useCalendarStore((s) => s.icalSubscriptions);
const currentAccountId = client?.getAccountId();
const icalSubscriptions = useMemo(
() => allSubs.filter(s => !s.accountId || s.accountId === currentAccountId),
[allSubs, currentAccountId],
);
const refreshICalSubscription = useCalendarStore((s) => s.refreshICalSubscription);
const removeICalSubscription = useCalendarStore((s) => s.removeICalSubscription);
const timeFormat = useSettingsStore((s) => s.timeFormat);
+190 -70
View File
@@ -4,9 +4,9 @@ import { useState, useEffect, useCallback, useRef, useMemo } from "react";
import { useTranslations } from "next-intl";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { X, Trash2, Check, Users, CalendarDays, Copy, Pencil, Clock, MapPin, Video, Repeat, Bell, AlignLeft } from "lucide-react";
import { X, Trash2, Check, Users, CalendarDays, Copy, Pencil, Clock, MapPin, Video, Repeat, Bell, AlignLeft, Plus } from "lucide-react";
import { format, parseISO, addHours, addDays } from "date-fns";
import type { CalendarEvent, Calendar, CalendarParticipant } from "@/lib/jmap/types";
import type { CalendarEvent, Calendar, CalendarParticipant, CalendarEventAlert } from "@/lib/jmap/types";
import { parseDuration, getEventColor } from "./event-card";
import { buildAllDayDuration, getEventDisplayEndDate, getEventEndDate, getEventStartDate, getPrimaryCalendarId } from "@/lib/calendar-utils";
import { ParticipantInput, type ParticipantInputHandle } from "./participant-input";
@@ -76,7 +76,65 @@ function buildDuration(startDate: Date, endDate: Date): string {
}
type RecurrenceOption = "none" | "daily" | "weekly" | "monthly" | "yearly";
type AlertOption = "none" | "at_time" | "5" | "15" | "30" | "60" | "1440";
type AlertUnit = "at_time" | "minutes" | "hours" | "days" | "weeks";
interface AlertRow {
id: string;
value: number;
unit: AlertUnit;
}
let alertRowSeq = 0;
function newAlertRow(value: number, unit: AlertUnit): AlertRow {
alertRowSeq += 1;
return { id: `r${alertRowSeq}`, value, unit };
}
function alertRowToOffset(row: AlertRow): string | null {
if (row.unit === "at_time") return "PT0S";
const v = Math.max(0, Math.floor(row.value));
if (!Number.isFinite(v) || v <= 0) return null;
switch (row.unit) {
case "minutes": return `-PT${v}M`;
case "hours": return `-PT${v}H`;
case "days": return `-P${v}D`;
case "weeks": return `-P${v}W`;
}
}
function offsetToAlertRow(offset: string): AlertRow | null {
if (offset === "PT0S" || offset === "P0D" || offset === "PT0M") {
return newAlertRow(0, "at_time");
}
let m = offset.match(/^-?P(\d+)W$/);
if (m) return newAlertRow(parseInt(m[1], 10), "weeks");
m = offset.match(/^-?P(\d+)D$/);
if (m) return newAlertRow(parseInt(m[1], 10), "days");
m = offset.match(/^-?PT(\d+)H$/);
if (m) return newAlertRow(parseInt(m[1], 10), "hours");
m = offset.match(/^-?PT(\d+)M$/);
if (m) {
const mins = parseInt(m[1], 10);
if (mins > 0 && mins % 1440 === 0) return newAlertRow(mins / 1440, "days");
if (mins > 0 && mins % 60 === 0) return newAlertRow(mins / 60, "hours");
return newAlertRow(mins, "minutes");
}
return null;
}
function formatAlertRowLabel(
row: { value: number; unit: AlertUnit },
t: ReturnType<typeof useTranslations>
): string {
if (row.unit === "at_time") return t("alerts.at_time");
switch (row.unit) {
case "minutes": return t("alerts.minutes_before", { count: row.value });
case "hours": return t("alerts.hours_before", { count: row.value });
case "days": return t("alerts.days_before", { count: row.value });
case "weeks": return t("alerts.weeks_before", { count: row.value });
}
}
function formatDurationDisplay(minutes: number): string {
if (minutes < 60) return `${minutes}min`;
@@ -88,17 +146,15 @@ function formatDurationDisplay(minutes: number): string {
function getAlertLabel(event: CalendarEvent, t: ReturnType<typeof useTranslations>): string | null {
if (!event.alerts) return null;
const first = Object.values(event.alerts)[0];
if (!first || first.trigger["@type"] !== "OffsetTrigger") return null;
const offset = first.trigger.offset;
if (offset === "PT0S") return t("alerts.at_time");
const minMatch = offset.match(/-?PT(\d+)M$/);
if (minMatch) return t("alerts.minutes_before", { count: parseInt(minMatch[1]) });
const hourMatch = offset.match(/-?PT(\d+)H$/);
if (hourMatch) return t("alerts.hours_before", { count: parseInt(hourMatch[1]) });
const dayMatch = offset.match(/-?P(\d+)D/);
if (dayMatch) return t("alerts.days_before", { count: parseInt(dayMatch[1]) });
return null;
const labels: string[] = [];
for (const alert of Object.values(event.alerts)) {
if (alert.trigger["@type"] !== "OffsetTrigger") continue;
const row = offsetToAlertRow(alert.trigger.offset);
if (!row) continue;
labels.push(formatAlertRowLabel(row, t));
}
if (labels.length === 0) return null;
return labels.join(", ");
}
function getRecurrenceLabel(event: CalendarEvent, t: ReturnType<typeof useTranslations>): string | null {
@@ -216,22 +272,36 @@ export function EventModal({
if (!event?.recurrenceRules?.length) return "none";
return event.recurrenceRules[0].frequency as RecurrenceOption;
});
const [alert, setAlert] = useState<AlertOption>(() => {
if (!event?.alerts) return "none";
const first = Object.values(event.alerts)[0];
if (!first) return "none";
if (first.trigger["@type"] === "OffsetTrigger") {
const offset = first.trigger.offset;
if (offset === "PT0S") return "at_time";
const minMatch = offset.match(/-?PT(\d+)M$/);
if (minMatch) return minMatch[1] as AlertOption;
const hourMatch = offset.match(/-?PT(\d+)H$/);
if (hourMatch) return String(parseInt(hourMatch[1]) * 60) as AlertOption;
const dayMatch = offset.match(/-?P(\d+)D/);
if (dayMatch) return String(parseInt(dayMatch[1]) * 1440) as AlertOption;
const preservedAlertsRef = useRef<Record<string, CalendarEventAlert>>({});
const [alertRows, setAlertRows] = useState<AlertRow[]>(() => {
if (!event?.alerts) return [];
const rows: AlertRow[] = [];
for (const [id, alert] of Object.entries(event.alerts)) {
// Preserve alerts we can't represent in this UI (absolute triggers,
// email actions, offsets with non-canonical shapes) so they survive a save.
if (alert.trigger["@type"] !== "OffsetTrigger" || alert.action !== "display") {
preservedAlertsRef.current[id] = alert;
continue;
}
const row = offsetToAlertRow(alert.trigger.offset);
if (!row) {
preservedAlertsRef.current[id] = alert;
continue;
}
rows.push(row);
}
return "none";
return rows;
});
const addAlertRow = useCallback(() => {
setAlertRows((prev) => [...prev, newAlertRow(10, "minutes")]);
}, []);
const updateAlertRow = useCallback((id: string, patch: Partial<Omit<AlertRow, "id">>) => {
setAlertRows((prev) => prev.map((r) => (r.id === id ? { ...r, ...patch } : r)));
}, []);
const removeAlertRow = useCallback((id: string) => {
setAlertRows((prev) => prev.filter((r) => r.id !== id));
}, []);
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
const [isSaving, setIsSaving] = useState(false);
@@ -400,17 +470,23 @@ export function EventModal({
if (event.excludedRecurrenceRules) data.excludedRecurrenceRules = null;
}
if (alert !== "none") {
const offset = alert === "at_time" ? "PT0S" : `-PT${alert}M`;
data.alerts = {
alert1: {
"@type": "Alert",
trigger: { "@type": "OffsetTrigger", offset, relativeTo: "start" },
action: "display",
acknowledged: null,
relatedTo: null,
},
const builtAlerts: Record<string, CalendarEventAlert> = { ...preservedAlertsRef.current };
let alertIdx = 0;
for (const row of alertRows) {
const offset = alertRowToOffset(row);
if (offset === null) continue;
let key = `alert${++alertIdx}`;
while (key in builtAlerts) key = `alert${++alertIdx}`;
builtAlerts[key] = {
"@type": "Alert",
trigger: { "@type": "OffsetTrigger", offset, relativeTo: "start" },
action: "display",
acknowledged: null,
relatedTo: null,
};
}
if (Object.keys(builtAlerts).length > 0) {
data.alerts = builtAlerts;
} else if (event && event.alerts && Object.keys(event.alerts).length > 0) {
data.alerts = null;
}
@@ -435,7 +511,7 @@ export function EventModal({
} finally {
setIsSaving(false);
}
}, [title, description, location, virtualLocation, startDate, startTime, endDate, endTime, allDay, calendarId, recurrence, alert, attendees, sendInvitations, currentUserEmails, existingParticipants, event, onSave, isSaving]);
}, [title, description, location, virtualLocation, startDate, startTime, endDate, endTime, allDay, calendarId, recurrence, alertRows, attendees, sendInvitations, currentUserEmails, existingParticipants, event, onSave, isSaving]);
const handleRsvp = useCallback((status: CalendarParticipant['participationStatus']) => {
if (!event || !userParticipantId || !onRsvp) return;
@@ -987,37 +1063,81 @@ export function EventModal({
</div>
)}
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
<div>
<label className="text-sm font-medium mb-1 block">{t("recurrence.title")}</label>
<select
value={recurrence}
onChange={(e) => setRecurrence(e.target.value as RecurrenceOption)}
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-ring"
>
<option value="none">{t("recurrence.none")}</option>
<option value="daily">{t("recurrence.daily")}</option>
<option value="weekly">{t("recurrence.weekly")}</option>
<option value="monthly">{t("recurrence.monthly")}</option>
<option value="yearly">{t("recurrence.yearly")}</option>
</select>
</div>
<div>
<label className="text-sm font-medium mb-1 block">{t("alerts.title")}</label>
<select
value={alert}
onChange={(e) => setAlert(e.target.value as AlertOption)}
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-ring"
>
<option value="none">{t("alerts.none")}</option>
<option value="at_time">{t("alerts.at_time")}</option>
<option value="5">{t("alerts.minutes_before", { count: 5 })}</option>
<option value="15">{t("alerts.minutes_before", { count: 15 })}</option>
<option value="30">{t("alerts.minutes_before", { count: 30 })}</option>
<option value="60">{t("alerts.hours_before", { count: 1 })}</option>
<option value="1440">{t("alerts.days_before", { count: 1 })}</option>
</select>
</div>
<div>
<label className="text-sm font-medium mb-1 block">{t("recurrence.title")}</label>
<select
value={recurrence}
onChange={(e) => setRecurrence(e.target.value as RecurrenceOption)}
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-ring"
>
<option value="none">{t("recurrence.none")}</option>
<option value="daily">{t("recurrence.daily")}</option>
<option value="weekly">{t("recurrence.weekly")}</option>
<option value="monthly">{t("recurrence.monthly")}</option>
<option value="yearly">{t("recurrence.yearly")}</option>
</select>
</div>
<div>
<label className="text-sm font-medium mb-1 block">{t("alerts.title")}</label>
{alertRows.length === 0 ? (
<p className="text-sm text-muted-foreground">{t("alerts.none")}</p>
) : (
<div className="space-y-2">
{alertRows.map((row) => (
<div key={row.id} className="flex items-center gap-2">
{row.unit !== "at_time" && (
<Input
type="number"
min={1}
max={999}
value={row.value}
onChange={(e) => {
const n = parseInt(e.target.value, 10);
updateAlertRow(row.id, { value: Number.isFinite(n) ? Math.max(1, n) : 1 });
}}
className="w-20"
aria-label={t("alerts.amount")}
/>
)}
<select
value={row.unit}
onChange={(e) => {
const unit = e.target.value as AlertUnit;
updateAlertRow(row.id, {
unit,
value: unit === "at_time" ? 0 : (row.value || 1),
});
}}
className="flex-1 rounded-md border border-input bg-background px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-ring"
aria-label={t("alerts.unit")}
>
<option value="at_time">{t("alerts.at_time")}</option>
<option value="minutes">{t("alerts.unit_minutes_before")}</option>
<option value="hours">{t("alerts.unit_hours_before")}</option>
<option value="days">{t("alerts.unit_days_before")}</option>
<option value="weeks">{t("alerts.unit_weeks_before")}</option>
</select>
<button
type="button"
onClick={() => removeAlertRow(row.id)}
className="p-2 rounded-md text-muted-foreground hover:text-foreground hover:bg-muted transition-colors"
aria-label={t("alerts.remove")}
>
<Trash2 className="w-4 h-4" />
</button>
</div>
))}
</div>
)}
<button
type="button"
onClick={addAlertRow}
className="mt-2 inline-flex items-center gap-1 text-sm text-primary hover:underline"
>
<Plus className="w-4 h-4" />
{t("alerts.add")}
</button>
</div>
{attendees.length > 0 && (
+4 -3
View File
@@ -17,6 +17,7 @@ interface ICalImportModalProps {
calendars: Calendar[];
client: IJMAPClient;
onClose: () => void;
initialUrl?: string;
}
const MAX_FILE_SIZE = 10 * 1024 * 1024; // 10MB
@@ -25,7 +26,7 @@ const ACCEPTED_EXTENSIONS = [".ics", ".ical"];
type ImportStep = "select" | "preview" | "importing";
type ImportMode = "file" | "url";
export function ICalImportModal({ calendars, client, onClose }: ICalImportModalProps) {
export function ICalImportModal({ calendars, client, onClose, initialUrl }: ICalImportModalProps) {
const t = useTranslations("calendar.import");
const tCal = useTranslations("calendar");
const tCommon = useTranslations("common");
@@ -43,8 +44,8 @@ export function ICalImportModal({ calendars, client, onClose }: ICalImportModalP
const [isParsing, setIsParsing] = useState(false);
const [isDragging, setIsDragging] = useState(false);
const [error, setError] = useState<string | null>(null);
const [importMode, setImportMode] = useState<ImportMode>("file");
const [urlInput, setUrlInput] = useState("");
const [importMode, setImportMode] = useState<ImportMode>(initialUrl ? "url" : "file");
const [urlInput, setUrlInput] = useState(initialUrl || "");
const [isFetchingUrl, setIsFetchingUrl] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null);
const modalRef = useRef<HTMLDivElement>(null);
@@ -13,9 +13,11 @@ interface ICalSubscriptionModalProps {
client: IJMAPClient;
onClose: () => void;
editSubscription?: ICalSubscription;
initialUrl?: string;
initialName?: string;
}
export function ICalSubscriptionModal({ client, onClose, editSubscription }: ICalSubscriptionModalProps) {
export function ICalSubscriptionModal({ client, onClose, editSubscription, initialUrl, initialName }: ICalSubscriptionModalProps) {
const t = useTranslations("calendar.subscription");
const tCommon = useTranslations("common");
const addICalSubscription = useCalendarStore((s) => s.addICalSubscription);
@@ -23,8 +25,8 @@ export function ICalSubscriptionModal({ client, onClose, editSubscription }: ICa
const isEdit = !!editSubscription;
const [url, setUrl] = useState(editSubscription?.url || "");
const [name, setName] = useState(editSubscription?.name || "");
const [url, setUrl] = useState(editSubscription?.url || initialUrl || "");
const [name, setName] = useState(editSubscription?.name || initialName || "");
const [color, setColor] = useState(editSubscription?.color || "#3b82f6");
const [refreshInterval, setRefreshInterval] = useState(editSubscription?.refreshInterval || 60);
const [isSubmitting, setIsSubmitting] = useState(false);
@@ -76,7 +78,7 @@ export function ICalSubscriptionModal({ client, onClose, editSubscription }: ICa
} finally {
setIsSubmitting(false);
}
}, [url, name, color, refreshInterval, client, addICalSubscription, onClose, t]);
}, [url, name, color, refreshInterval, client, isEdit, editSubscription, addICalSubscription, updateICalSubscription, onClose, t]);
useEffect(() => {
const handleKey = (e: KeyboardEvent) => {
+47 -32
View File
@@ -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');
+362 -55
View File
@@ -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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/\n/g, '<br>')}</div>`
const composerSignatureHtml = signatureIdentity?.htmlSignature
? `<div>${sanitizeSignatureHtml(signatureIdentity.htmlSignature)}</div>`
: signatureIdentity?.textSignature
? `<div>${getPlainTextSignature(signatureIdentity).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/\n/g, '<br>')}`;
if (signatureIdentity?.textSignature) {
return `${sep}${signatureIdentity.textSignature.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').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
+16 -8
View File
@@ -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>
)}
</>
+4 -1
View File
@@ -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}
File diff suppressed because it is too large Load Diff
+7 -3
View File
@@ -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
)}
>
+62 -2
View File
@@ -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;
+73 -16
View File
@@ -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 */}
+11 -1
View File
@@ -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 */}
+35 -14
View File
@@ -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}
/>
))
+19 -5
View File
@@ -182,7 +182,7 @@ export function FilePreviewModal({ name, onClose, onDownload, getFileContent }:
</div>
</div>
<div className="flex-1 flex items-center justify-center overflow-auto p-4" onClick={(e) => e.stopPropagation()}>
<div className="flex-1 flex items-center justify-center overflow-auto p-4">
{loading && (
<div className="flex flex-col items-center gap-2 text-muted-foreground">
<Loader2 className="w-8 h-8 animate-spin" />
@@ -194,13 +194,19 @@ export function FilePreviewModal({ name, onClose, onDownload, getFileContent }:
)}
{!loading && !error && (fileType === "text") && content !== null && (
<pre className="bg-background rounded-lg p-6 max-w-4xl w-full max-h-full overflow-auto text-sm font-mono whitespace-pre-wrap break-words">
<pre
className="bg-background rounded-lg p-6 max-w-4xl w-full max-h-full overflow-auto text-sm font-mono whitespace-pre-wrap break-words"
onClick={(e) => e.stopPropagation()}
>
{content}
</pre>
)}
{!loading && !error && fileType === "markdown" && content !== null && (
<div className="bg-background rounded-lg p-6 max-w-4xl w-full max-h-full overflow-auto text-sm">
<div
className="bg-background rounded-lg p-6 max-w-4xl w-full max-h-full overflow-auto text-sm"
onClick={(e) => e.stopPropagation()}
>
<SimpleMarkdown content={content} />
</div>
)}
@@ -211,6 +217,7 @@ export function FilePreviewModal({ name, onClose, onDownload, getFileContent }:
alt={name}
className="max-w-full max-h-full object-contain rounded-lg bg-background"
draggable={false}
onClick={(e) => e.stopPropagation()}
/>
)}
@@ -220,6 +227,7 @@ export function FilePreviewModal({ name, onClose, onDownload, getFileContent }:
sandbox=""
className="w-full max-w-5xl h-full rounded-lg bg-white"
title={name}
onClick={(e) => e.stopPropagation()}
/>
)}
@@ -229,6 +237,7 @@ export function FilePreviewModal({ name, onClose, onDownload, getFileContent }:
type="application/pdf"
className="w-full max-w-5xl h-full rounded-lg bg-white"
aria-label={name}
onClick={(e) => e.stopPropagation()}
>
<Button onClick={() => void onDownload()}>
<Download className="w-4 h-4 mr-2" />
@@ -238,14 +247,19 @@ export function FilePreviewModal({ name, onClose, onDownload, getFileContent }:
)}
{!loading && !error && fileType === "audio" && objectUrl && (
<div className="bg-background rounded-lg p-8 max-w-lg w-full">
<div className="bg-background rounded-lg p-8 max-w-lg w-full" onClick={(e) => e.stopPropagation()}>
<p className="text-sm font-medium mb-4 text-center">{name}</p>
<audio controls className="w-full" src={objectUrl} />
</div>
)}
{!loading && !error && fileType === "video" && objectUrl && (
<video controls className="max-w-4xl max-h-full rounded-lg" src={objectUrl} />
<video
controls
className="max-w-4xl max-h-full rounded-lg"
src={objectUrl}
onClick={(e) => e.stopPropagation()}
/>
)}
</div>
</div>
+68 -16
View File
@@ -8,13 +8,38 @@ import type { Identity, EmailAddress } from '@/lib/jmap/types';
import { sanitizeSignatureHtml } from '@/lib/email-sanitization';
import { getEmailValidationError, validateEmailList } from '@/lib/validation';
// JMAP Identity/set caps signature fields at 2047 UTF-8 bytes per RFC 8621 §6.1.
const SIGNATURE_MAX_BYTES = 2047;
const utf8Encoder = new TextEncoder();
function utf8ByteLength(s: string): number {
return utf8Encoder.encode(s).length;
}
function truncateToUtf8Bytes(s: string, maxBytes: number): string {
if (utf8ByteLength(s) <= maxBytes) return s;
let lo = 0;
let hi = s.length;
while (lo < hi) {
const mid = (lo + hi + 1) >>> 1;
if (utf8ByteLength(s.slice(0, mid)) <= maxBytes) lo = mid;
else hi = mid - 1;
}
// Don't split a surrogate pair: if we landed right after a high surrogate, back off one code unit.
if (lo > 0) {
const prev = s.charCodeAt(lo - 1);
if (prev >= 0xD800 && prev <= 0xDBFF) lo -= 1;
}
return s.slice(0, lo);
}
interface IdentityFormData {
name: string;
email: string;
replyTo?: EmailAddress[];
bcc?: EmailAddress[];
textSignature?: string;
htmlSignature?: string;
replyTo?: EmailAddress[] | null;
bcc?: EmailAddress[] | null;
textSignature?: string | null;
htmlSignature?: string | null;
}
interface IdentityFormProps {
@@ -96,14 +121,16 @@ export function IdentityForm({ identity, onSave, onCancel }: IdentityFormProps)
setIsSubmitting(true);
try {
// Sanitize HTML signature before sending to server
// JMAP needs explicit null to clear a field; undefined would be dropped
// from the JSON payload and leave the server-side value untouched.
const trimmedText = formData.textSignature?.trim() ?? '';
const trimmedHtml = formData.htmlSignature?.trim() ?? '';
const sanitizedData: IdentityFormData = {
...formData,
replyTo: parseEmailList(replyToInput),
bcc: parseEmailList(bccInput),
htmlSignature: formData.htmlSignature
? sanitizeSignatureHtml(formData.htmlSignature)
: undefined,
textSignature: trimmedText ? formData.textSignature : null,
htmlSignature: trimmedHtml ? sanitizeSignatureHtml(formData.htmlSignature!) : null,
replyTo: parseEmailList(replyToInput) ?? null,
bcc: parseEmailList(bccInput) ?? null,
};
await onSave(sanitizedData);
@@ -246,14 +273,15 @@ export function IdentityForm({ identity, onSave, onCancel }: IdentityFormProps)
</label>
<textarea
id="identity-text-sig"
maxLength={2000}
value={formData.textSignature}
onChange={(e) => setFormData({ ...formData, textSignature: e.target.value })}
value={formData.textSignature ?? ''}
onChange={(e) => setFormData({ ...formData, textSignature: truncateToUtf8Bytes(e.target.value, SIGNATURE_MAX_BYTES) })}
rows={3}
disabled={isSubmitting}
aria-label={t('text_signature_label')}
aria-describedby="identity-text-sig-counter"
className="flex w-full rounded-md border border-input bg-background px-3 py-2 text-sm text-foreground transition-all duration-200 placeholder:text-muted-foreground hover:border-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:border-ring disabled:cursor-not-allowed disabled:opacity-50"
/>
<SignatureByteCounter id="identity-text-sig-counter" value={formData.textSignature || ''} />
</div>
{/* HTML Signature */}
@@ -263,14 +291,15 @@ export function IdentityForm({ identity, onSave, onCancel }: IdentityFormProps)
</label>
<textarea
id="identity-html-sig"
maxLength={5000}
value={formData.htmlSignature}
onChange={(e) => setFormData({ ...formData, htmlSignature: e.target.value })}
value={formData.htmlSignature ?? ''}
onChange={(e) => setFormData({ ...formData, htmlSignature: truncateToUtf8Bytes(e.target.value, SIGNATURE_MAX_BYTES) })}
rows={5}
disabled={isSubmitting}
aria-label={t('html_signature_label')}
aria-describedby="identity-html-sig-counter"
className="flex w-full rounded-md border border-input bg-background px-3 py-2 text-sm text-foreground font-mono transition-all duration-200 placeholder:text-muted-foreground hover:border-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:border-ring disabled:cursor-not-allowed disabled:opacity-50"
/>
<SignatureByteCounter id="identity-html-sig-counter" value={formData.htmlSignature || ''} />
{formData.htmlSignature && (
<div className="mt-2 p-2 border rounded bg-muted">
<div className="text-xs text-muted-foreground mb-1">{tDisplay('preview')}</div>
@@ -304,3 +333,26 @@ export function IdentityForm({ identity, onSave, onCancel }: IdentityFormProps)
</form>
);
}
function SignatureByteCounter({ id, value }: { id: string; value: string }) {
const t = useTranslations('identities.form');
const bytes = utf8ByteLength(value);
const atLimit = bytes >= SIGNATURE_MAX_BYTES;
const nearLimit = !atLimit && bytes >= Math.floor(SIGNATURE_MAX_BYTES * 0.9);
const tone = atLimit
? 'text-destructive'
: nearLimit
? 'text-amber-600 dark:text-amber-400'
: 'text-muted-foreground';
return (
<p
id={id}
className={`text-xs mt-1 tabular-nums ${tone}`}
role="status"
aria-live="polite"
>
{t('signature_byte_counter', { bytes, max: SIGNATURE_MAX_BYTES })}
{atLimit && <span className="ml-1">{t('signature_byte_limit_reached')}</span>}
</p>
);
}
@@ -32,10 +32,10 @@ function emailMatchesUsername(email: string, username: string): boolean {
interface IdentityFormData {
name: string;
email: string;
replyTo?: EmailAddress[];
bcc?: EmailAddress[];
textSignature?: string;
htmlSignature?: string;
replyTo?: EmailAddress[] | null;
bcc?: EmailAddress[] | null;
textSignature?: string | null;
htmlSignature?: string | null;
}
interface IdentityManagerModalProps {
+15 -15
View File
@@ -6,9 +6,10 @@ import { Check, Plus, LogOut, Star, ChevronDown, AlertCircle } from "lucide-reac
import { useTranslations } from "next-intl";
import { useAccountStore, type AccountEntry } from "@/stores/account-store";
import { useAuthStore } from "@/stores/auth-store";
import { getInitials, getMaxAccounts } from "@/lib/account-utils";
import { getMaxAccounts } from "@/lib/account-utils";
import { cn } from "@/lib/utils";
import { useRouter } from "@/i18n/navigation";
import { Avatar } from "@/components/ui/avatar";
interface AccountSwitcherProps {
/** "rail" = small avatar only (NavigationRail), "expanded" = avatar + name + email (Sidebar) */
@@ -17,17 +18,15 @@ interface AccountSwitcherProps {
}
function AccountAvatar({ account, size = "sm" }: { account: AccountEntry; size?: "sm" | "md" }) {
const initials = getInitials(account.displayName || account.label, account.email || account.username);
const sizeClasses = size === "sm" ? "w-8 h-8 text-xs" : "w-9 h-9 text-sm";
return (
<div
className={cn("rounded-full flex items-center justify-center text-white font-medium flex-shrink-0", sizeClasses)}
style={{ backgroundColor: account.avatarColor }}
title={account.label}
>
{initials}
</div>
<Avatar
name={account.displayName || account.label}
email={account.email || account.username}
size="sm"
className={cn("flex-shrink-0", size === "md" && "w-9 h-9 text-sm")}
disableFavicon
fallbackColor={account.avatarColor}
/>
);
}
@@ -49,7 +48,6 @@ export function AccountSwitcher({ variant = "rail", className }: AccountSwitcher
const switchAccount = useAuthStore((s) => s.switchAccount);
const logout = useAuthStore((s) => s.logout);
const logoutAll = useAuthStore((s) => s.logoutAll);
const primaryIdentity = useAuthStore((s) => s.primaryIdentity);
const updatePosition = useCallback(() => {
if (!buttonRef.current) return;
@@ -115,9 +113,11 @@ export function AccountSwitcher({ variant = "rail", className }: AccountSwitcher
setDefaultAccount(accountId);
};
// Display name for the active account
const displayName = primaryIdentity?.name || activeAccount?.displayName || activeAccount?.label || "";
const displayEmail = primaryIdentity?.email || activeAccount?.email || activeAccount?.username || "";
// Show the account's own identity, not the preferred sending identity -
// primaryIdentity can be an alias (e.g. info@korazo.net) that differs from
// the actually logged-in account (info@linusrath.de).
const displayName = activeAccount?.displayName || activeAccount?.label || "";
const displayEmail = activeAccount?.email || activeAccount?.username || "";
return (
<>
@@ -20,6 +20,7 @@ import {
Pencil,
FolderX,
RefreshCw,
Upload,
} from "lucide-react";
interface Position {
@@ -84,6 +85,7 @@ interface MailboxContextMenuProps {
onCreateFolder?: () => void;
onRenameFolder?: (mailboxId: string) => void;
onDeleteFolder?: (mailboxId: string) => void;
onImportEmail?: (mailboxId: string) => void;
onRefresh?: () => void;
}
@@ -102,6 +104,7 @@ export function MailboxContextMenu({
onCreateFolder,
onRenameFolder,
onDeleteFolder,
onImportEmail,
onRefresh,
}: MailboxContextMenuProps) {
const t = useTranslations("mailbox_context_menu");
@@ -149,6 +152,7 @@ export function MailboxContextMenu({
const canCreateChild = mailbox.myRights?.mayCreateChild !== false;
const canSetSeen = mailbox.myRights?.maySetSeen !== false;
const canRemoveItems = mailbox.myRights?.mayRemoveItems !== false;
const canAddItems = mailbox.myRights?.mayAddItems !== false;
const fullPath = getMailboxPath(mailbox, mailboxes);
@@ -190,6 +194,15 @@ export function MailboxContextMenu({
<ContextMenuSeparator />
<ContextMenuItem
icon={Upload}
label={t("import_email")}
onClick={() => handleAction(() => onImportEmail?.(mailbox.id))}
disabled={!onImportEmail || !canAddItems}
/>
<ContextMenuSeparator />
<ContextMenuItem
icon={FolderX}
label={isTrashOrJunk ? t("empty_folder") : t("empty_folder_generic")}
+9 -2
View File
@@ -3,6 +3,7 @@
import { Menu, ArrowLeft, Plus, Search, X } from "lucide-react";
import { Button } from "@/components/ui/button";
import { useUIStore } from "@/stores/ui-store";
import { useIsDesktop } from "@/hooks/use-media-query";
import { cn } from "@/lib/utils";
import { useTranslations } from "next-intl";
@@ -25,6 +26,12 @@ export function MobileHeader({
}: MobileHeaderProps) {
const t = useTranslations('sidebar');
const { toggleSidebar, goBack, sidebarOpen } = useUIStore();
// Pane-aware: in Pro split mode the viewport is desktop-wide while the
// pane is narrow. The Tailwind `lg:hidden` variant alone would never fire
// there, so we additionally hide via JS when the surrounding pane is
// desktop-sized. Outside of Pro this still returns the viewport value.
const isPaneDesktop = useIsDesktop();
if (isPaneDesktop) return null;
const handleLeftAction = () => {
if (showBack && onBack) {
@@ -40,7 +47,6 @@ export function MobileHeader({
<header
className={cn(
"flex items-center justify-between px-4 h-14 border-b border-border bg-background shrink-0",
"lg:hidden", // Only visible on mobile/tablet
className
)}
>
@@ -118,12 +124,13 @@ export function MobileViewerHeader({
className,
}: MobileViewerHeaderProps) {
const t = useTranslations('sidebar');
const isPaneDesktop = useIsDesktop();
if (isPaneDesktop) return null;
return (
<header
className={cn(
"flex items-center justify-between px-2 h-14 border-b border-border bg-background shrink-0",
"lg:hidden", // Only visible on mobile/tablet
className
)}
>
+57 -15
View File
@@ -17,11 +17,12 @@ import { useAuthStore } from "@/stores/auth-store";
import { useAccountStore } from "@/stores/account-store";
import { useUpdateStore, selectHasUpdate } from "@/stores/update-store";
import { getActiveAccountSlotHeaders } from "@/lib/auth/active-account-slot";
import { getInitials, getMaxAccounts } from "@/lib/account-utils";
import { getMaxAccounts } from "@/lib/account-utils";
import { cn, formatFileSize } from "@/lib/utils";
import { PluginSlot } from "@/components/plugins/plugin-slot";
import { KeyboardShortcutsModal } from "@/components/keyboard-shortcuts-modal";
import { apiFetch } from "@/lib/browser-navigation";
import { Avatar } from "@/components/ui/avatar";
interface NavItem {
id: string;
@@ -44,6 +45,19 @@ interface NavigationRailProps {
onInlineApp?: (appId: string, url: string, name: string) => void;
onCloseInlineApp?: () => void;
activeAppId?: string | null;
/**
* If provided, intercepts the rail's built-in route navigation. Return
* `true` to prevent the underlying `<Link>` from navigating — used by the
* Pro interface to open the route as a tab instead. The visual rail is
* unchanged.
*/
onNavigate?: (itemId: 'mail' | 'calendar' | 'contacts' | 'files' | 'settings') => boolean | void;
/**
* When `onNavigate` is in use, this controls which nav item the rail
* highlights as active (since the URL alone no longer reflects the
* active app).
*/
activeItemId?: 'mail' | 'calendar' | 'contacts' | 'files' | 'settings' | null;
}
function StorageQuotaCircle({ quota, usagePercent }: { quota: { used: number; total: number }; usagePercent: number }) {
@@ -159,6 +173,8 @@ export function NavigationRail({
onInlineApp,
onCloseInlineApp,
activeAppId,
onNavigate,
activeItemId,
}: NavigationRailProps) {
const t = useTranslations("sidebar");
const pathname = usePathname();
@@ -174,6 +190,7 @@ export function NavigationRail({
const showRailAccountList = useSettingsStore((s) => s.showRailAccountList);
const sidebarAppsEnabled = usePolicyStore((s) => s.isFeatureEnabled('sidebarAppsEnabled'));
const filesEnabled = usePolicyStore((s) => s.isFeatureEnabled('filesEnabled'));
const contactsEnabled = usePolicyStore((s) => s.isFeatureEnabled('contactsEnabled'));
const visibleSidebarApps = sidebarAppsEnabled ? sidebarApps : [];
const inboxUnread = mailboxes.find(m => m.role === "inbox")?.unreadEmails || 0;
const [isStalwartAdmin, setIsStalwartAdmin] = useState(false);
@@ -253,37 +270,58 @@ export function NavigationRail({
const navItems: NavItem[] = [
{ id: "mail", icon: Mail, labelKey: "mail", href: "/", badge: inboxUnread },
{ id: "calendar", icon: Calendar, labelKey: "calendar", href: "/calendar", hidden: !supportsCalendar },
{ id: "contacts", icon: BookUser, labelKey: "contacts", href: "/contacts", hidden: !supportsContacts },
{ id: "contacts", icon: BookUser, labelKey: "contacts", href: "/contacts", hidden: !supportsContacts || !contactsEnabled },
{ id: "files", icon: HardDrive, labelKey: "files", href: "/files", hidden: !supportsFiles || !filesEnabled },
];
const isSettingsActive = !activeAppId && pathname.startsWith("/settings");
// When the host (e.g. the Pro shell) takes over navigation via `onNavigate`,
// it tells us which item is active; otherwise we infer it from the URL.
const isSettingsActive = onNavigate
? activeItemId === 'settings'
: !activeAppId && pathname.startsWith("/settings");
const visibleItems = navItems.filter((item) => !item.hidden);
const getIsActive = (href: string) => {
const getIsActive = (href: string, itemId: string) => {
if (activeAppId) return false;
if (onNavigate) {
return activeItemId === itemId;
}
if (href === "/") {
return pathname === "/" || pathname === "";
}
return pathname.startsWith(href);
};
const handleNavClick = (itemId: 'mail' | 'calendar' | 'contacts' | 'files' | 'settings') =>
(e: React.MouseEvent) => {
if (onNavigate) {
const intercepted = onNavigate(itemId);
if (intercepted !== false) {
e.preventDefault();
}
return;
}
if (activeAppId) {
onCloseInlineApp?.();
}
};
if (orientation === "horizontal") {
return (
<nav
className={cn("flex items-center bg-background border-t border-border shrink-0 overflow-x-auto mobile-scroll-hidden", className)}
className={cn("flex items-center bg-background border-t border-border shrink-0 overflow-x-auto mobile-scroll-hidden pb-[calc(env(safe-area-inset-bottom)/2)]", className)}
role="navigation"
aria-label={t("nav_label")}
>
{visibleItems.map((item) => {
const isActive = getIsActive(item.href);
const isActive = getIsActive(item.href, item.id);
const Icon = item.icon;
return (
<Link
key={item.id}
href={item.href}
onClick={activeAppId ? () => onCloseInlineApp?.() : undefined}
onClick={handleNavClick(item.id as 'mail' | 'calendar' | 'contacts' | 'files' | 'settings')}
className={cn(
"flex flex-col items-center justify-center gap-1 py-2 px-1 min-h-[44px] grow shrink-0 basis-[64px]",
"transition-colors duration-150",
@@ -373,7 +411,7 @@ export function NavigationRail({
{/* Settings */}
<Link
href="/settings"
onClick={activeAppId ? () => onCloseInlineApp?.() : undefined}
onClick={handleNavClick('settings')}
className={cn(
"flex flex-col items-center justify-center gap-1 py-2 px-1 min-h-[44px] grow shrink-0 basis-[64px]",
"transition-colors duration-150",
@@ -427,13 +465,13 @@ export function NavigationRail({
aria-label={t("nav_label")}
>
{visibleItems.map((item) => {
const isActive = getIsActive(item.href);
const isActive = getIsActive(item.href, item.id);
const Icon = item.icon;
return (
<Link
key={item.id}
href={item.href}
onClick={activeAppId ? () => onCloseInlineApp?.() : undefined}
onClick={handleNavClick(item.id as 'mail' | 'calendar' | 'contacts' | 'files' | 'settings')}
data-tour={`nav-${item.id}`}
className={cn(
"relative flex items-center gap-2.5 rounded-md transition-colors duration-150",
@@ -553,7 +591,7 @@ export function NavigationRail({
<Link
href="/settings"
onClick={activeAppId ? () => onCloseInlineApp?.() : undefined}
onClick={handleNavClick('settings')}
data-tour="nav-settings"
className={cn(
"flex items-center justify-center w-10 h-10 rounded-md transition-colors",
@@ -610,7 +648,6 @@ export function NavigationRail({
<div className="flex flex-col items-center gap-3">
{accounts.map((account) => {
const isActive = account.id === activeAccountId;
const initials = getInitials(account.displayName || account.label, account.email || account.username);
return (
<button
key={account.id}
@@ -618,15 +655,20 @@ export function NavigationRail({
if (!isActive) switchAccount(account.id);
}}
className={cn(
"relative flex items-center justify-center w-8 h-8 rounded-full text-white text-[11px] font-medium transition-all flex-shrink-0",
"relative w-8 h-8 rounded-full transition-all flex-shrink-0",
isActive
? "ring-2 ring-primary ring-offset-2 ring-offset-background"
: "opacity-70 hover:opacity-100"
)}
style={{ backgroundColor: account.avatarColor }}
title={`${account.displayName || account.label} (${account.email || account.username})`}
>
{initials}
<Avatar
name={account.displayName || account.label}
email={account.email || account.username}
size="sm"
disableFavicon
fallbackColor={account.avatarColor}
/>
{isActive && (
<span className="absolute -bottom-0.5 -right-0.5 w-3 h-3 rounded-full bg-primary flex items-center justify-center">
<Check className="w-2 h-2 text-primary-foreground" />
+23 -14
View File
@@ -8,38 +8,46 @@ interface ResizeHandleProps {
onResize: (delta: number) => void;
onResizeEnd?: () => void;
onDoubleClick?: () => void;
orientation?: "vertical" | "horizontal";
className?: string;
}
const KEYBOARD_STEP = 10;
export function ResizeHandle({ onResizeStart, onResize, onResizeEnd, onDoubleClick, className }: ResizeHandleProps) {
export function ResizeHandle({ onResizeStart, onResize, onResizeEnd, onDoubleClick, orientation = "vertical", className }: ResizeHandleProps) {
const isDragging = useRef(false);
const startX = useRef(0);
const startPos = useRef(0);
const isHorizontal = orientation === "horizontal";
const handleMouseDown = useCallback((e: React.MouseEvent) => {
e.preventDefault();
isDragging.current = true;
startX.current = e.clientX;
document.body.style.cursor = "col-resize";
startPos.current = isHorizontal ? e.clientY : e.clientX;
document.body.style.cursor = isHorizontal ? "row-resize" : "col-resize";
document.body.style.userSelect = "none";
onResizeStart?.();
}, [onResizeStart]);
}, [onResizeStart, isHorizontal]);
const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
let delta = 0;
if (e.key === "ArrowLeft") delta = -KEYBOARD_STEP;
else if (e.key === "ArrowRight") delta = KEYBOARD_STEP;
else return;
if (isHorizontal) {
if (e.key === "ArrowUp") delta = -KEYBOARD_STEP;
else if (e.key === "ArrowDown") delta = KEYBOARD_STEP;
else return;
} else {
if (e.key === "ArrowLeft") delta = -KEYBOARD_STEP;
else if (e.key === "ArrowRight") delta = KEYBOARD_STEP;
else return;
}
e.preventDefault();
onResize(delta);
onResizeEnd?.();
}, [onResize, onResizeEnd]);
}, [onResize, onResizeEnd, isHorizontal]);
useEffect(() => {
const handleMouseMove = (e: MouseEvent) => {
if (!isDragging.current) return;
const delta = e.clientX - startX.current;
const delta = (isHorizontal ? e.clientY : e.clientX) - startPos.current;
onResize(delta);
};
@@ -57,24 +65,25 @@ export function ResizeHandle({ onResizeStart, onResize, onResizeEnd, onDoubleCli
document.removeEventListener("mousemove", handleMouseMove);
document.removeEventListener("mouseup", handleMouseUp);
};
}, [onResize, onResizeEnd]);
}, [onResize, onResizeEnd, isHorizontal]);
return (
<div
role="separator"
aria-orientation="vertical"
aria-orientation={isHorizontal ? "horizontal" : "vertical"}
aria-label="Resize"
tabIndex={0}
onMouseDown={handleMouseDown}
onKeyDown={handleKeyDown}
onDoubleClick={onDoubleClick}
className={cn(
"w-1 flex-shrink-0 cursor-col-resize hover:bg-primary/30 active:bg-primary/50 transition-colors relative group",
"flex-shrink-0 hover:bg-primary/30 active:bg-primary/50 transition-colors relative group",
"focus-visible:outline-none focus-visible:bg-primary/40 focus-visible:ring-2 focus-visible:ring-primary/50",
isHorizontal ? "h-1 cursor-row-resize bg-border" : "w-1 cursor-col-resize",
className
)}
>
<div className="absolute inset-y-0 -left-1 -right-1" />
<div className={cn("absolute", isHorizontal ? "inset-x-0 -top-1 -bottom-1" : "inset-y-0 -left-1 -right-1")} />
</div>
);
}
+22
View File
@@ -20,6 +20,7 @@ import {
Folder,
FolderOpen,
User,
Users,
Palmtree,
Settings,
X,
@@ -28,7 +29,10 @@ import {
FlaskConical,
PlayCircle,
Loader2,
AlertTriangle,
NotebookPen,
CalendarClock,
BellOff,
} from "lucide-react";
import { cn, buildMailboxTree, MailboxNode } from "@/lib/utils";
import { Mailbox } from "@/lib/jmap/types";
@@ -67,6 +71,7 @@ interface SidebarProps {
onCreateFolder?: () => void;
onRenameFolder?: (mailboxId: string) => void;
onDeleteFolder?: (mailboxId: string) => void;
onImportEmail?: (mailboxId: string) => void;
onRefreshMailboxes?: () => void;
scheduledTotal?: number;
className?: string;
@@ -89,6 +94,11 @@ const getIconForMailbox = (role?: string, name?: string, hasChildren?: boolean,
if (role === "trash" || lowerName.includes("trash") || lowerName.includes("deleted")) return Trash2;
if (role === "junk" || role === "spam" || lowerName.includes("junk") || lowerName.includes("spam")) return Ban;
if (role === "archive" || lowerName.includes("archive")) return Archive;
if (role === "shared" || lowerName.includes("shared")) return Users;
if (role === "important" || lowerName.includes("important")) return AlertTriangle;
if (role === "memos" || lowerName.includes("memo")) return NotebookPen;
if (role === "scheduled" || lowerName.includes("scheduled")) return CalendarClock;
if (role === "snoozed" || lowerName.includes("snoozed")) return BellOff;
if (lowerName.includes("star") || lowerName.includes("flag")) return Star;
if (hasChildren) {
@@ -105,6 +115,11 @@ const ROLE_ICON_COLOR: Record<string, string> = {
trash: "text-muted-foreground",
junk: "text-red-600/80 dark:text-red-400/80",
archive: "text-amber-600/80 dark:text-amber-400/80",
shared: "text-cyan-600/80 dark:text-cyan-400/80",
important: "text-orange-600/80 dark:text-orange-400/80",
memos: "text-yellow-600/80 dark:text-yellow-400/80",
scheduled: "text-sky-600/80 dark:text-sky-400/80",
snoozed: "text-slate-500/80 dark:text-slate-400/80",
};
function resolveRoleKey(role?: string, name?: string): string | undefined {
@@ -115,6 +130,11 @@ function resolveRoleKey(role?: string, name?: string): string | undefined {
if (role === "trash" || lowerName.includes("trash") || lowerName.includes("deleted")) return "trash";
if (role === "junk" || role === "spam" || lowerName.includes("junk") || lowerName.includes("spam")) return "junk";
if (role === "archive" || lowerName.includes("archive")) return "archive";
if (role === "shared" || lowerName.includes("shared")) return "shared";
if (role === "important" || lowerName.includes("important")) return "important";
if (role === "memos" || lowerName.includes("memo")) return "memos";
if (role === "scheduled" || lowerName.includes("scheduled")) return "scheduled";
if (role === "snoozed" || lowerName.includes("snoozed")) return "snoozed";
return undefined;
}
@@ -638,6 +658,7 @@ export function Sidebar({
onCreateFolder,
onRenameFolder,
onDeleteFolder,
onImportEmail,
onRefreshMailboxes,
scheduledTotal = 0,
className,
@@ -1051,6 +1072,7 @@ export function Sidebar({
onCreateFolder={onCreateFolder}
onRenameFolder={onRenameFolder}
onDeleteFolder={onDeleteFolder}
onImportEmail={onImportEmail}
onRefresh={onRefreshMailboxes}
/>
</div>
@@ -0,0 +1,16 @@
'use client';
import { PluginSlot } from '@/components/plugins/plugin-slot';
import { useAuthStore } from '@/stores/auth-store';
/**
* Mounts the `app-top-banner` plugin slot with the current session
* username + serverUrl as extraProps. Drop this at the top of every
* authenticated page so plugins like impersonation-notice render
* everywhere, not just on the mail page.
*/
export function AppTopBannerSlot() {
const username = useAuthStore((s) => s.username);
const serverUrl = useAuthStore((s) => s.serverUrl);
return <PluginSlot name="app-top-banner" extraProps={{ username, serverUrl }} />;
}
@@ -0,0 +1,114 @@
'use client';
// Modal shown the first time a plugin is enabled, listing every permission
// the plugin's manifest declares. Accepting persists the grant on the
// plugin record so future enables skip the prompt.
import React, { useEffect, useSyncExternalStore } from 'react';
import { head, resolveHead, subscribe, describePermission } from '@/lib/plugin-sandbox/consent';
export function PluginConsentDialog(): React.JSX.Element | null {
const current = useSyncExternalStore(subscribe, head, () => null);
useEffect(() => {
if (!current) return;
function onKey(e: KeyboardEvent) {
if (e.key === 'Escape') {
e.preventDefault();
resolveHead(false);
}
}
document.addEventListener('keydown', onKey, true);
return () => document.removeEventListener('keydown', onKey, true);
}, [current]);
if (!current) return null;
return (
<div
role="dialog"
aria-modal="true"
aria-labelledby="plugin-consent-title"
style={{
position: 'fixed', inset: 0,
background: 'rgba(0,0,0,0.55)',
display: 'flex', alignItems: 'center', justifyContent: 'center',
zIndex: 100001,
}}
onMouseDown={(e) => { if (e.target === e.currentTarget) resolveHead(false); }}
>
<div style={{
background: 'var(--background, #fff)',
color: 'var(--foreground, #0f172a)',
border: '1px solid var(--border, #e2e8f0)',
borderRadius: 12,
padding: 20,
maxWidth: 560,
width: '92%',
boxShadow: '0 16px 48px rgba(0,0,0,0.35)',
}}>
<h2 id="plugin-consent-title" style={{ fontSize: 16, fontWeight: 600, margin: '0 0 6px 0' }}>
Allow {current.pluginName} to access your data?
</h2>
<p style={{ fontSize: 12, color: 'var(--muted-foreground, #64748b)', margin: '0 0 14px 0' }}>
This plugin is requesting the permissions below. You can revoke them by uninstalling the plugin.
</p>
<ul style={{ listStyle: 'none', padding: 0, margin: '0 0 16px 0', maxHeight: 320, overflowY: 'auto' }}>
{current.permissions.map((perm) => {
const desc = describePermission(perm);
return (
<li
key={perm}
style={{
padding: '10px 12px',
marginBottom: 6,
borderRadius: 8,
background: 'var(--accent, #f1f5f9)',
border: '1px solid var(--border, #e2e8f0)',
}}
>
<div style={{ fontSize: 13, fontWeight: 600, marginBottom: 2 }}>{desc.title}</div>
<div style={{ fontSize: 12, color: 'var(--muted-foreground, #64748b)' }}>{desc.body}</div>
<code style={{ fontSize: 10, color: 'var(--muted-foreground, #94a3b8)', display: 'block', marginTop: 4 }}>{perm}</code>
</li>
);
})}
</ul>
<div style={{ display: 'flex', gap: 8, justifyContent: 'flex-end' }}>
<button
type="button"
onClick={() => resolveHead(false)}
style={{
padding: '8px 16px', borderRadius: 8, fontSize: 13, fontWeight: 500,
cursor: 'pointer',
border: '1px solid var(--border, #e2e8f0)',
background: 'transparent',
color: 'inherit',
}}
>
Deny
</button>
<button
type="button"
autoFocus
onClick={() => resolveHead(true)}
style={{
padding: '8px 16px', borderRadius: 8, fontSize: 13, fontWeight: 500,
cursor: 'pointer',
border: '1px solid transparent',
background: '#3b82f6',
color: '#fff',
}}
>
Allow
</button>
</div>
<div style={{ marginTop: 12, fontSize: 11, color: 'var(--muted-foreground, #94a3b8)', textAlign: 'right' }}>
Plugin: {current.pluginId}
</div>
</div>
</div>
);
}
+113
View File
@@ -0,0 +1,113 @@
'use client';
// Host-rendered modal for plugin-requested confirm/alert dialogs.
// Subscribes to the host-dialog queue and renders the head request, one at
// a time. Closing the modal advances the queue.
import React, { useEffect, useSyncExternalStore } from 'react';
import { head, resolveHead, subscribe } from '@/lib/plugin-sandbox/host-dialog';
export function PluginDialogHost(): React.JSX.Element | null {
const current = useSyncExternalStore(subscribe, head, () => null);
useEffect(() => {
if (!current) return;
function onKey(e: KeyboardEvent) {
if (e.key === 'Escape') {
e.preventDefault();
resolveHead(false);
} else if (e.key === 'Enter') {
e.preventDefault();
resolveHead(true);
}
}
document.addEventListener('keydown', onKey, true);
return () => document.removeEventListener('keydown', onKey, true);
}, [current]);
if (!current) return null;
const confirmLabel = current.confirmLabel ?? (current.kind === 'alert' ? 'OK' : 'Confirm');
const cancelLabel = current.cancelLabel ?? 'Cancel';
return (
<div
role="dialog"
aria-modal="true"
aria-labelledby="plugin-dialog-title"
style={{
position: 'fixed',
inset: 0,
background: 'rgba(0,0,0,0.5)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
zIndex: 100000,
}}
onMouseDown={(e) => {
if (e.target === e.currentTarget) resolveHead(false);
}}
>
<div
style={{
background: 'var(--background, #fff)',
color: 'var(--foreground, #0f172a)',
border: '1px solid var(--border, #e2e8f0)',
borderRadius: 12,
padding: 20,
maxWidth: 480,
width: '92%',
boxShadow: '0 16px 48px rgba(0,0,0,0.35)',
}}
>
<h2 id="plugin-dialog-title" style={{ fontSize: 16, fontWeight: 600, margin: '0 0 10px 0' }}>
{current.title}
</h2>
<p style={{ fontSize: 13, lineHeight: 1.5, margin: '0 0 16px 0', color: 'var(--muted-foreground, #64748b)', whiteSpace: 'pre-wrap' }}>
{current.message}
</p>
<div style={{ display: 'flex', gap: 8, justifyContent: 'flex-end' }}>
{current.kind === 'confirm' && (
<button
type="button"
autoFocus={!!current.danger}
onClick={() => resolveHead(false)}
style={{
padding: '8px 16px',
borderRadius: 8,
fontSize: 13,
fontWeight: 500,
cursor: 'pointer',
border: '1px solid var(--border, #e2e8f0)',
background: 'transparent',
color: 'inherit',
}}
>
{cancelLabel}
</button>
)}
<button
type="button"
autoFocus={current.kind === 'alert' || !current.danger}
onClick={() => resolveHead(true)}
style={{
padding: '8px 16px',
borderRadius: 8,
fontSize: 13,
fontWeight: 500,
cursor: 'pointer',
border: '1px solid transparent',
background: current.danger ? '#dc2626' : '#3b82f6',
color: '#fff',
}}
>
{confirmLabel}
</button>
</div>
<div style={{ marginTop: 12, fontSize: 11, color: 'var(--muted-foreground, #94a3b8)', textAlign: 'right' }}>
From plugin: {current.pluginId}
</div>
</div>
</div>
);
}
+74
View File
@@ -0,0 +1,74 @@
'use client';
// Sandboxed slot mount. One iframe per (plugin, slot) — created lazily after
// the background instance confirms `shouldShow(context)` (if defined). The
// iframe renders the plugin's slot component using the plugin's bundle in a
// null-origin context; its height is pushed back via postMessage and applied
// to a wrapper <div>.
import React, { useEffect, useRef, useState } from 'react';
import type { SlotName } from '@/lib/plugin-types';
import { get as getActivePlugin } from '@/lib/plugin-sandbox/registry';
import { createSlotInstance, type SandboxInstance } from '@/lib/plugin-sandbox/host-bridge';
interface Props {
pluginId: string;
slot: SlotName;
extraProps?: Record<string, unknown>;
}
export function PluginIframeSlot({ pluginId, slot, extraProps }: Props) {
const wrapperRef = useRef<HTMLDivElement>(null);
const instanceRef = useRef<SandboxInstance | null>(null);
const [height, setHeight] = useState<number>(0);
// null = pending, true/false = decided
const [show, setShow] = useState<boolean | null>(null);
// Decide whether to mount based on the plugin's shouldShow (background-side).
useEffect(() => {
const active = getActivePlugin(pluginId);
if (!active) { setShow(false); return; }
const offer = active.slotOffers.find((o) => o.name === slot);
if (!offer) { setShow(false); return; }
if (!offer.hasShouldShow) { setShow(true); return; }
let cancelled = false;
active.background
.evaluateShouldShow(slot, extraProps ?? {})
.then((s) => { if (!cancelled) setShow(s); })
.catch(() => { if (!cancelled) setShow(false); });
return () => { cancelled = true; };
}, [pluginId, slot, extraProps]);
// Spawn / tear down the slot iframe.
useEffect(() => {
if (show !== true) return;
const active = getActivePlugin(pluginId);
if (!active || !wrapperRef.current) return;
const locale = (globalThis as unknown as { __APP_LOCALE__?: string }).__APP_LOCALE__ ?? 'en';
const inst = createSlotInstance({
plugin: active.plugin,
slot,
code: active.code,
locale,
extraProps: extraProps ?? {},
hostContainer: wrapperRef.current,
onResize: (h) => setHeight(h),
});
instanceRef.current = inst;
return () => {
try { inst.destroy(); } catch { /* ignore */ }
instanceRef.current = null;
};
// We intentionally don't depend on extraProps here — propagating prop
// changes happens via postMessage below to avoid iframe churn.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [show, pluginId, slot]);
// Push prop updates without remount.
useEffect(() => {
instanceRef.current?.updateProps(extraProps ?? {});
}, [extraProps]);
if (show !== true) return null;
return <div ref={wrapperRef} style={{ height, minHeight: height }} data-plugin-iframe-slot={`${pluginId}:${slot}`} />;
}
+11 -9
View File
@@ -1,9 +1,9 @@
'use client';
import React from 'react';
import React, { useSyncExternalStore } from 'react';
import type { SlotName } from '@/lib/plugin-types';
import { usePluginStore } from '@/stores/plugin-store';
import { PluginSlotRenderer } from './plugin-slot-renderer';
import { offersForSlot, subscribe } from '@/lib/plugin-sandbox/registry';
import { PluginIframeSlot } from './plugin-iframe-slot';
interface PluginSlotProps {
name: SlotName;
@@ -12,16 +12,18 @@ interface PluginSlotProps {
}
export function PluginSlot({ name, className, extraProps }: PluginSlotProps) {
const registrations = usePluginStore(s => s.slots[name]);
const getSnapshot = () => offersForSlot(name);
const offers = useSyncExternalStore(subscribe, getSnapshot, getSnapshot);
if (!registrations || registrations.length === 0) return null;
if (offers.length === 0) return null;
return (
<div className={className} data-plugin-slot={name}>
{registrations.map((reg, i) => (
<PluginSlotRenderer
key={`${reg.pluginId}-${i}`}
registration={reg}
{offers.map((offer) => (
<PluginIframeSlot
key={offer.pluginId}
pluginId={offer.pluginId}
slot={name}
extraProps={extraProps}
/>
))}
+137
View File
@@ -0,0 +1,137 @@
"use client";
import { useCallback, useEffect, useRef } from "react";
import { useTranslations } from "next-intl";
import { EmailComposer, type ComposerDraftData } from "@/components/email/email-composer";
import { ErrorBoundary, ComposerErrorFallback } from "@/components/error";
import { useAuthStore } from "@/stores/auth-store";
import { useEmailStore } from "@/stores/email-store";
import { toast } from "@/stores/toast-store";
import { useProTabStore, type ProComposeTabData } from "@/stores/pro-tab-store";
import { debug } from "@/lib/debug";
interface ProComposeTabBodyProps {
tabId: string;
data: ProComposeTabData;
}
/**
* Renders a standalone `<EmailComposer />` inside its own Pro tab. Sending,
* draft autosave, and discard all flow through the shared `email-store`, so
* the result is identical to composing inline in the mail page — the
* composer is just hosted in its own tab instead of in the right pane.
*/
export function ProComposeTabBody({ tabId, data }: ProComposeTabBodyProps) {
const t = useTranslations();
const client = useAuthStore((s) => s.client);
const sendEmail = useEmailStore((s) => s.sendEmail);
const fetchEmails = useEmailStore((s) => s.fetchEmails);
const selectedMailbox = useEmailStore((s) => s.selectedMailbox);
const closeTab = useProTabStore((s) => s.closeTab);
const updateTabTitle = useProTabStore((s) => s.updateTabTitle);
const updateComposeDraft = useProTabStore((s) => s.updateComposeDraft);
// Keep stable references for the callbacks below so the composer's
// `key={sessionId}` doesn't churn.
const tabIdRef = useRef(tabId);
tabIdRef.current = tabId;
const handleSend = useCallback(async (sendData: Parameters<NonNullable<React.ComponentProps<typeof EmailComposer>['onSend']>>[0]) => {
if (!client) return;
try {
await sendEmail(
client,
sendData.to,
sendData.subject,
sendData.body,
sendData.cc,
sendData.bcc,
sendData.identityId,
sendData.fromEmail,
sendData.draftId,
sendData.fromName,
sendData.htmlBody,
sendData.attachments,
sendData.inReplyTo,
sendData.references,
sendData.delayedUntil,
sendData.envelopeMailFrom,
);
// Mark the original message as $answered / $forwarded so the standard
// viewer and list reflect the action (same behaviour as inline compose).
if (data.sourceEmailId && (data.mode === 'reply' || data.mode === 'replyAll')) {
try {
await client.setKeyword(data.sourceEmailId, '$answered');
} catch (e) {
debug.error('Failed to set $answered keyword:', e);
}
} else if (data.sourceEmailId && data.mode === 'forward') {
try {
await client.setKeyword(data.sourceEmailId, '$forwarded');
} catch (e) {
debug.error('Failed to set $forwarded keyword:', e);
}
}
// Refresh the currently-active mail list so the new sent message /
// updated keyword status shows up.
await fetchEmails(client, selectedMailbox);
closeTab(tabIdRef.current);
} catch (error) {
console.error('Failed to send email:', error);
toast.error(t('notifications.error_sending'));
}
}, [client, sendEmail, fetchEmails, selectedMailbox, closeTab, data.sourceEmailId, data.mode, t]);
const handleClose = useCallback(() => {
closeTab(tabIdRef.current);
}, [closeTab]);
const handleDiscardDraft = useCallback(async (draftId: string) => {
if (!client) return;
try {
await client.deleteEmail(draftId);
} catch (error) {
console.error('Failed to discard draft:', error);
}
}, [client]);
const handleSaveState = useCallback((state: ComposerDraftData) => {
updateComposeDraft(tabIdRef.current, state);
// Keep the tab title in sync with the working subject.
const subject = state.subject?.trim() || t('email_composer.new_message');
updateTabTitle(tabIdRef.current, subject);
}, [updateComposeDraft, updateTabTitle, t]);
// On first mount, ensure the tab title matches whatever subject we were
// initialised with (replies start with "Re: …", forwards with "Fwd: …").
useEffect(() => {
const initialSubject = data.initialData?.subject?.trim()
?? data.replyTo?.subject
?? '';
const title = initialSubject || t('email_composer.new_message');
updateTabTitle(tabIdRef.current, title);
// Run once on mount only.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
return (
<div className="flex h-full w-full flex-col bg-background">
<ErrorBoundary fallback={ComposerErrorFallback}>
<EmailComposer
key={data.sessionId}
mode={data.initialData?.mode ?? data.mode}
replyTo={data.replyTo}
initialDraftText={data.initialDraftText}
initialData={data.initialData}
onSend={handleSend}
onClose={handleClose}
onDiscardDraft={handleDiscardDraft}
onSaveState={handleSaveState}
className="flex-1"
/>
</ErrorBoundary>
</div>
);
}
+255
View File
@@ -0,0 +1,255 @@
"use client";
import { useCallback, useEffect, useState, useMemo, useRef } from "react";
import { useTranslations } from "next-intl";
import { EmailViewer } from "@/components/email/email-viewer";
import { ErrorBoundary, EmailViewerErrorFallback } from "@/components/error";
import { useAuthStore } from "@/stores/auth-store";
import { useEmailStore } from "@/stores/email-store";
import { useSettingsStore } from "@/stores/settings-store";
import { toast } from "@/stores/toast-store";
import { useProTabStore, type ProEmailTabData, type ProReplyContext } from "@/stores/pro-tab-store";
import type { Email } from "@/lib/jmap/types";
interface ProEmailTabBodyProps {
tabId: string;
data: ProEmailTabData;
}
function buildReplyContext(email: Email): ProReplyContext {
const textPartId = email.textBody?.[0]?.partId ?? '';
const htmlPartId = email.htmlBody?.[0]?.partId ?? '';
return {
from: email.from,
replyToAddresses: email.replyTo,
to: email.to,
cc: email.cc,
bcc: email.bcc,
subject: email.subject,
body: email.bodyValues?.[textPartId]?.value || email.preview || '',
htmlBody: email.bodyValues?.[htmlPartId]?.value || undefined,
receivedAt: email.receivedAt,
accountId: email.accountId,
attachments: email.attachments,
messageId: email.messageId,
inReplyTo: email.inReplyTo,
references: email.references,
};
}
/**
* Renders a single email in its own Pro tab. Fetches the email content on
* mount via `email-store.fetchEmailContent` so the tab is self-sufficient
* it doesn't depend on what the Mail tab has selected.
*/
export function ProEmailTabBody({ tabId, data }: ProEmailTabBodyProps) {
const t = useTranslations();
const tNotifications = useTranslations('notifications');
const client = useAuthStore((s) => s.client);
const fetchEmailContent = useEmailStore((s) => s.fetchEmailContent);
const deleteEmail = useEmailStore((s) => s.deleteEmail);
const markAsRead = useEmailStore((s) => s.markAsRead);
const toggleStar = useEmailStore((s) => s.toggleStar);
const moveToMailbox = useEmailStore((s) => s.moveToMailbox);
const setEmailKeywordsLocal = useEmailStore((s) => s.setEmailKeywordsLocal);
const mailboxes = useEmailStore((s) => s.mailboxes);
const settingsKeywords = useSettingsStore((s) => s.emailKeywords);
const closeTab = useProTabStore((s) => s.closeTab);
const openComposeTab = useProTabStore((s) => s.openComposeTab);
const updateTabTitle = useProTabStore((s) => s.updateTabTitle);
const [email, setEmail] = useState<Email | null>(null);
const [isLoading, setIsLoading] = useState(true);
const composerSessionIdRef = useRef(0);
useEffect(() => {
let cancelled = false;
if (!client) return;
setIsLoading(true);
fetchEmailContent(client, data.emailId)
.then((loaded) => {
if (cancelled) return;
setEmail(loaded);
if (loaded?.subject) {
updateTabTitle(tabId, loaded.subject);
}
})
.catch((err) => {
console.error('Failed to fetch email for Pro tab:', err);
})
.finally(() => {
if (!cancelled) setIsLoading(false);
});
return () => {
cancelled = true;
};
}, [client, data.emailId, fetchEmailContent, tabId, updateTabTitle]);
const currentMailboxRole = useMemo(() => {
if (!email) return undefined;
const mb = email.mailboxIds
? Object.keys(email.mailboxIds).map((id) => mailboxes.find((m) => m.id === id)).find(Boolean)
: undefined;
return mb?.role;
}, [email, mailboxes]);
const handleReply = useCallback((draftText?: string) => {
if (!email) return;
composerSessionIdRef.current += 1;
openComposeTab({
sessionId: composerSessionIdRef.current,
mode: 'reply',
replyTo: buildReplyContext(email),
sourceEmailId: email.id,
initialDraftText: draftText,
title: `Re: ${email.subject || t('email_composer.new_message')}`,
});
}, [email, openComposeTab, t]);
const handleReplyAll = useCallback(() => {
if (!email) return;
composerSessionIdRef.current += 1;
openComposeTab({
sessionId: composerSessionIdRef.current,
mode: 'replyAll',
replyTo: buildReplyContext(email),
sourceEmailId: email.id,
title: `Re: ${email.subject || t('email_composer.new_message')}`,
});
}, [email, openComposeTab, t]);
const handleForward = useCallback(() => {
if (!email) return;
composerSessionIdRef.current += 1;
openComposeTab({
sessionId: composerSessionIdRef.current,
mode: 'forward',
replyTo: buildReplyContext(email),
sourceEmailId: email.id,
title: `Fwd: ${email.subject || t('email_composer.new_message')}`,
});
}, [email, openComposeTab, t]);
const handleDelete = useCallback(async () => {
if (!client || !email) return;
try {
await deleteEmail(client, email.id);
closeTab(tabId);
} catch (err) {
console.error('Delete failed:', err);
toast.error(tNotifications('error_deleting'));
}
}, [client, email, deleteEmail, closeTab, tabId, tNotifications]);
const handleArchive = useCallback(async () => {
if (!client || !email) return;
const archiveMb = mailboxes.find((m) => m.role === 'archive');
if (!archiveMb) return;
try {
await moveToMailbox(client, email.id, archiveMb.id);
toast.success(tNotifications('email_archived'));
closeTab(tabId);
} catch (err) {
console.error('Archive failed:', err);
}
}, [client, email, mailboxes, moveToMailbox, closeTab, tabId, tNotifications]);
const handleToggleStar = useCallback(async () => {
if (!client || !email) return;
try {
await toggleStar(client, email.id);
// Reflect locally — the viewer re-reads from email-store's selectedEmail
// shape only for the mail tab; here we update our local copy too.
setEmail((prev) => prev ? {
...prev,
keywords: {
...prev.keywords,
$flagged: !prev.keywords?.$flagged,
},
} : prev);
} catch (err) {
console.error('Toggle star failed:', err);
}
}, [client, email, toggleStar]);
const handleMarkAsRead = useCallback(async (emailId: string, read: boolean) => {
if (!client) return;
try {
await markAsRead(client, emailId, read);
setEmail((prev) => prev && prev.id === emailId ? {
...prev,
keywords: { ...prev.keywords, $seen: read },
} : prev);
} catch (err) {
console.error('Mark as read failed:', err);
}
}, [client, markAsRead]);
const handleSetColorTag = useCallback((emailId: string, color: string | null) => {
if (!email || email.id !== emailId) return;
// Drop existing color keywords, optionally add the new one. Matches the
// mail page's local optimistic update.
const keywords = { ...(email.keywords ?? {}) };
for (const kw of settingsKeywords) {
delete keywords[`$label:${kw.id}`];
}
if (color) {
const def = settingsKeywords.find((k) => k.color === color);
if (def) keywords[`$label:${def.id}`] = true;
}
setEmailKeywordsLocal(emailId, keywords);
setEmail({ ...email, keywords });
}, [email, settingsKeywords, setEmailKeywordsLocal]);
const handleMoveToMailbox = useCallback(async (mailboxId: string) => {
if (!client || !email) return;
try {
await moveToMailbox(client, email.id, mailboxId);
closeTab(tabId);
} catch (err) {
console.error('Move failed:', err);
}
}, [client, email, moveToMailbox, closeTab, tabId]);
const handleDownloadAttachment = useCallback(async (blobId: string, name: string, type?: string) => {
if (!client) return;
try {
await client.downloadBlob(blobId, name, type);
} catch (err) {
console.error('Download failed:', err);
}
}, [client]);
const handleQuickReply = useCallback(async (body: string) => {
handleReply(body);
}, [handleReply]);
return (
<div className="flex h-full w-full flex-col bg-background">
<ErrorBoundary fallback={EmailViewerErrorFallback}>
<EmailViewer
email={email}
isLoading={isLoading}
onReply={handleReply}
onReplyAll={handleReplyAll}
onForward={handleForward}
onDelete={handleDelete}
onArchive={handleArchive}
onToggleStar={handleToggleStar}
onMarkAsRead={handleMarkAsRead}
onSetColorTag={handleSetColorTag}
onDownloadAttachment={handleDownloadAttachment}
onQuickReply={handleQuickReply}
onMoveToMailbox={handleMoveToMailbox}
currentUserEmail={client?.getUsername()}
currentUserName={client?.getUsername()?.split('@')[0]}
currentMailboxRole={currentMailboxRole}
mailboxes={mailboxes}
className="flex-1"
/>
</ErrorBoundary>
</div>
);
}
+239
View File
@@ -0,0 +1,239 @@
"use client";
import { useRef, useState, type DragEvent } from "react";
import { useTranslations } from "next-intl";
import { Mail, Calendar, BookUser, HardDrive, Settings, PenSquare, MailOpen, X, type LucideIcon } from "lucide-react";
import { cn } from "@/lib/utils";
import { useProTabStore, type ProTab, type ProTabKind } from "@/stores/pro-tab-store";
/** Custom MIME type used to carry the dragged Pro tab id between handlers. */
export const PRO_TAB_DRAG_MIME = "application/x-pro-tab-id";
interface ProTabBarProps {
/** All tabs (both panes). Order in the array is the order in the bar. */
tabs: ProTab[];
activeMainTabId: string | null;
activeSplitTabId: string | null;
onActivate: (id: string) => void;
onClose: (id: string) => void;
onDragStateChange?: (dragging: boolean) => void;
className?: string;
}
const TAB_ICONS: Record<ProTabKind, LucideIcon> = {
mail: Mail,
calendar: Calendar,
contacts: BookUser,
files: HardDrive,
settings: Settings,
compose: PenSquare,
email: MailOpen,
};
type DropIndicator = { targetId: string; edge: "before" | "after" } | null;
export function ProTabBar({
tabs,
activeMainTabId,
activeSplitTabId,
onActivate,
onClose,
onDragStateChange,
className,
}: ProTabBarProps) {
const tSidebar = useTranslations("sidebar");
const reorderTab = useProTabStore((s) => s.reorderTab);
const focusedPaneId = useProTabStore((s) => s.focusedPaneId);
const [dropIndicator, setDropIndicator] = useState<DropIndicator>(null);
const dragLeaveTimer = useRef<number | null>(null);
const isProTabDrag = (e: DragEvent) =>
e.dataTransfer.types.includes(PRO_TAB_DRAG_MIME);
const handleDragStart = (e: DragEvent<HTMLDivElement>, tab: ProTab) => {
e.dataTransfer.setData(PRO_TAB_DRAG_MIME, tab.id);
e.dataTransfer.effectAllowed = "move";
onDragStateChange?.(true);
};
const handleDragEnd = () => {
setDropIndicator(null);
onDragStateChange?.(false);
};
const handleTabDragOver = (e: DragEvent<HTMLDivElement>, tab: ProTab) => {
if (!isProTabDrag(e)) return;
e.preventDefault();
e.dataTransfer.dropEffect = "move";
const rect = e.currentTarget.getBoundingClientRect();
const edge: "before" | "after" =
e.clientX < rect.left + rect.width / 2 ? "before" : "after";
setDropIndicator((prev) =>
prev && prev.targetId === tab.id && prev.edge === edge
? prev
: { targetId: tab.id, edge },
);
if (dragLeaveTimer.current !== null) {
window.clearTimeout(dragLeaveTimer.current);
dragLeaveTimer.current = null;
}
};
const handleStripDragLeave = (e: DragEvent<HTMLDivElement>) => {
const next = e.relatedTarget as Node | null;
if (next && e.currentTarget.contains(next)) return;
if (dragLeaveTimer.current !== null) window.clearTimeout(dragLeaveTimer.current);
dragLeaveTimer.current = window.setTimeout(() => {
setDropIndicator(null);
dragLeaveTimer.current = null;
}, 40);
};
const handleTabDrop = (e: DragEvent<HTMLDivElement>, tab: ProTab) => {
if (!isProTabDrag(e)) return;
e.preventDefault();
const draggedId = e.dataTransfer.getData(PRO_TAB_DRAG_MIME);
if (!draggedId || draggedId === tab.id) {
handleDragEnd();
return;
}
const edge = dropIndicator?.targetId === tab.id ? dropIndicator.edge : "after";
reorderTab(draggedId, tab.id, edge);
handleDragEnd();
};
const handleStripEndDrop = (e: DragEvent<HTMLDivElement>) => {
if (!isProTabDrag(e)) return;
e.preventDefault();
const draggedId = e.dataTransfer.getData(PRO_TAB_DRAG_MIME);
if (!draggedId) {
handleDragEnd();
return;
}
const last = tabs[tabs.length - 1];
if (last && last.id !== draggedId) {
reorderTab(draggedId, last.id, "after");
}
handleDragEnd();
};
const handleStripEndDragOver = (e: DragEvent<HTMLDivElement>) => {
if (!isProTabDrag(e)) return;
e.preventDefault();
e.dataTransfer.dropEffect = "move";
const last = tabs[tabs.length - 1];
if (last) {
setDropIndicator({ targetId: last.id, edge: "after" });
}
};
return (
<div
className={cn(
"flex items-stretch h-9 bg-secondary px-1 overflow-x-auto scroll-hidden flex-shrink-0",
className,
)}
style={{ borderBottom: '1px solid rgba(128, 128, 128, 0.3)' }}
role="tablist"
onDragLeave={handleStripDragLeave}
>
{tabs.map((tab) => {
const Icon = TAB_ICONS[tab.kind];
const isActiveMain = tab.id === activeMainTabId && tab.paneId === 'main';
const isActiveSplit = tab.id === activeSplitTabId && tab.paneId === 'split';
const isActive = isActiveMain || isActiveSplit;
const isFocusedActive =
(isActiveMain && focusedPaneId === 'main')
|| (isActiveSplit && focusedPaneId === 'split');
const label = tab.title ?? tSidebar(tab.labelKey);
const showBefore = dropIndicator?.targetId === tab.id && dropIndicator.edge === "before";
const showAfter = dropIndicator?.targetId === tab.id && dropIndicator.edge === "after";
return (
<div
key={tab.id}
role="tab"
aria-selected={isActive}
data-tab-id={tab.id}
data-pane-id={tab.paneId}
draggable
onClick={() => onActivate(tab.id)}
onMouseDown={(e) => {
if (e.button === 1 && tab.closeable) {
e.preventDefault();
onClose(tab.id);
}
}}
onDragStart={(e) => handleDragStart(e, tab)}
onDragOver={(e) => handleTabDragOver(e, tab)}
onDrop={(e) => handleTabDrop(e, tab)}
onDragEnd={handleDragEnd}
className={cn(
"group relative flex items-center gap-1.5 px-3 h-9 text-sm cursor-pointer select-none transition-colors",
"min-w-0 flex-1 basis-0 max-w-[200px] [min-width:80px]",
"border-r border-border first:border-l",
isActive
? "bg-background text-foreground"
: "text-muted-foreground hover:bg-muted hover:text-foreground",
isFocusedActive && "font-medium",
)}
style={
isActive
? { borderRightColor: 'rgba(128, 128, 128, 0.3)', borderLeftColor: 'rgba(128, 128, 128, 0.3)' }
: undefined
}
>
<Icon className={cn("w-4 h-4 flex-shrink-0", isFocusedActive && "text-primary")} />
<span className="truncate flex-1 min-w-0" title={label}>{label}</span>
{tab.closeable && (
<button
onClick={(e) => {
e.stopPropagation();
onClose(tab.id);
}}
className={cn(
"ml-1 flex items-center justify-center w-4 h-4 rounded-sm transition-colors flex-shrink-0",
"text-muted-foreground hover:bg-muted-foreground/20 hover:text-foreground",
!isActive && "opacity-0 group-hover:opacity-100 focus-visible:opacity-100",
)}
aria-label={tSidebar("close")}
tabIndex={isActive ? 0 : -1}
>
<X className="w-3 h-3" />
</button>
)}
{isActive && (
<span
className="absolute left-0 right-0 -bottom-px h-px bg-background"
aria-hidden="true"
/>
)}
{showBefore && (
<span
className="pointer-events-none absolute top-1 bottom-1 left-0 w-0.5 -translate-x-1/2 bg-primary rounded-full"
aria-hidden="true"
/>
)}
{showAfter && (
<span
className="pointer-events-none absolute top-1 bottom-1 right-0 w-0.5 translate-x-1/2 bg-primary rounded-full"
aria-hidden="true"
/>
)}
</div>
);
})}
{/* Trailing area soaks up drops past the last tab. */}
<div
className="flex-1 min-w-[8px]"
onDragOver={handleStripEndDragOver}
onDrop={handleStripEndDrop}
aria-hidden="true"
/>
</div>
);
}
@@ -0,0 +1,107 @@
"use client";
import { useEffect } from "react";
import { parseMailto } from "@/lib/protocol-handlers/mailto";
import { requestOpenMailtoInExistingClient, savePendingMailto } from "@/lib/protocol-handlers/session";
import { useSettingsStore } from "@/stores/settings-store";
type StandaloneNavigator = Navigator & { standalone?: boolean };
function getProtocolPathPrefix(): string {
const marker = "/protocol/mailto";
const index = window.location.pathname.indexOf(marker);
return index > 0 ? window.location.pathname.slice(0, index) : "";
}
function returnToSourcePage() {
window.close();
window.setTimeout(() => {
if (window.history.length > 1) {
window.history.back();
}
}, 150);
}
function openFallbackAppTab(raw: string): boolean {
const url = `${getProtocolPathPrefix()}/protocol/mailto?url=${encodeURIComponent(raw)}&fallback=1`;
const opened = window.open(url, "_blank");
if (!opened) return false;
opened.opener = null;
return true;
}
function shouldOpenFallbackAppTab(): boolean {
const standalone = window.matchMedia?.("(display-mode: standalone)").matches
|| (navigator as StandaloneNavigator).standalone === true;
return !standalone && window.history.length > 1;
}
async function focusExistingClient() {
if (!("serviceWorker" in navigator)) return;
try {
const registration = await navigator.serviceWorker.ready;
const worker = navigator.serviceWorker.controller ?? registration.active;
worker?.postMessage({ type: "focus-existing-mailto-client" });
} catch {
// Focusing is a progressive enhancement; the composer handoff still works.
}
}
interface MailtoProtocolClientProps {
openingText: string;
}
export function MailtoProtocolClient({ openingText }: MailtoProtocolClientProps) {
useEffect(() => {
let cancelled = false;
async function handleMailto() {
const params = new URLSearchParams(window.location.search);
const raw = params.get("url");
const isFallbackAppTab = params.get("fallback") === "1";
const openMode = useSettingsStore.getState().protocolOpenMode;
const parsed = raw ? parseMailto(raw) : null;
if (parsed) {
if (!isFallbackAppTab && openMode === "new-tab") {
if (raw && shouldOpenFallbackAppTab() && openFallbackAppTab(raw)) {
returnToSourcePage();
return;
}
} else if (!isFallbackAppTab) {
const delivered = await requestOpenMailtoInExistingClient(parsed);
if (cancelled) return;
if (delivered) {
void focusExistingClient();
returnToSourcePage();
return;
}
if (raw && shouldOpenFallbackAppTab() && openFallbackAppTab(raw)) {
returnToSourcePage();
return;
}
}
savePendingMailto(parsed);
}
window.location.replace(`${getProtocolPathPrefix()}/`);
}
void handleMailto();
return () => {
cancelled = true;
};
}, []);
return (
<main className="flex min-h-screen items-center justify-center">
<p>{openingText}</p>
</main>
);
}
@@ -0,0 +1,163 @@
"use client";
import { Loader2, X } from "lucide-react";
import { useTranslations } from "next-intl";
import type { ParsedMailto } from "@/lib/protocol-handlers/mailto";
import type { ParsedWebcal } from "@/lib/protocol-handlers/webcal";
import type { AccountEntry } from "@/stores/account-store";
import { cn } from "@/lib/utils";
import { Avatar } from "@/components/ui/avatar";
type ProtocolAccountPickerProps = {
accounts: AccountEntry[];
activeAccountId: string | null;
isSwitching?: boolean;
onSelect: (accountId: string) => void;
onCancel: () => void;
} & (
| { kind: "mailto"; operation?: ParsedMailto }
| { kind: "webcal"; operation?: ParsedWebcal }
);
function getHost(value: string): string {
try {
return new URL(value).hostname;
} catch {
return value;
}
}
export function ProtocolAccountPicker({
kind,
accounts,
activeAccountId,
isSwitching = false,
onSelect,
onCancel,
operation,
}: ProtocolAccountPickerProps) {
const t = useTranslations("protocol_handlers");
const tCommon = useTranslations("common");
const details = operation
? kind === "mailto"
? [
{ label: t("detail_to"), value: operation.to.join(", ") || "-" },
{ label: t("detail_subject"), value: operation.subject || t("detail_no_subject") },
]
: [
{ label: t("detail_calendar"), value: operation.suggestedName },
{ label: t("detail_source"), value: getHost(operation.subscriptionUrl) },
]
: [];
return (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
<div className="absolute inset-0 bg-black/50 backdrop-blur-[1px]" onClick={onCancel} aria-hidden="true" />
<div
role="dialog"
aria-modal="true"
aria-label={t("select_account_title")}
className="relative w-full max-w-md rounded-lg border border-border bg-background shadow-xl animate-in zoom-in-95 duration-200"
>
<div className="flex items-start justify-between gap-4 border-b border-border px-5 py-4">
<div>
<h2 className="text-lg font-semibold text-foreground">{t("select_account_title")}</h2>
<p className="mt-1 text-sm text-muted-foreground">
{kind === "mailto" ? t("select_mailto_account") : t("select_webcal_account")}
</p>
</div>
<button
type="button"
onClick={onCancel}
className="rounded-md p-1.5 text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
aria-label={tCommon("close")}
>
<X className="h-5 w-5" />
</button>
</div>
{details.length > 0 && (
<div className="border-b border-border bg-muted/40 px-5 py-3">
<dl className="space-y-1.5 text-sm">
{details.map((detail) => (
<div key={detail.label} className="grid grid-cols-[5.5rem_minmax(0,1fr)] gap-3">
<dt className="text-xs font-medium uppercase tracking-wide text-muted-foreground">{detail.label}</dt>
<dd className="truncate text-foreground" title={detail.value}>{detail.value}</dd>
</div>
))}
</dl>
</div>
)}
<div className="max-h-80 overflow-y-auto p-2">
{accounts.map((account) => {
const isActive = account.id === activeAccountId;
let host = account.serverUrl;
try {
host = new URL(account.serverUrl).hostname;
} catch {
// Keep the configured value when it is not an absolute URL.
}
return (
<button
key={account.id}
type="button"
disabled={isSwitching}
onClick={() => onSelect(account.id)}
className={cn(
"flex w-full items-center gap-3 rounded-md px-3 py-2.5 text-left transition-colors",
isActive ? "bg-accent/50" : "hover:bg-muted",
isSwitching && "cursor-wait opacity-70"
)}
>
<Avatar
name={account.displayName || account.label}
email={account.email || account.username}
size="md"
className="shrink-0"
disableFavicon
fallbackColor={account.avatarColor}
/>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<span className="truncate text-sm font-medium text-foreground">
{account.displayName || account.label}
</span>
{isActive && (
<span className="rounded-full bg-primary/10 px-2 py-0.5 text-[10px] font-medium text-primary">
{t("active_account")}
</span>
)}
</div>
<p className="truncate text-xs text-muted-foreground">{account.email || account.username}</p>
<p className="truncate text-[10px] text-muted-foreground">{host}</p>
</div>
</button>
);
})}
</div>
<div className="flex items-center justify-between border-t border-border px-5 py-3">
{isSwitching ? (
<span className="inline-flex items-center gap-2 text-sm text-muted-foreground">
<Loader2 className="h-4 w-4 animate-spin" />
{t("switching_account")}
</span>
) : (
<span className="text-xs text-muted-foreground">{t("select_account_note")}</span>
)}
<button
type="button"
onClick={onCancel}
disabled={isSwitching}
className="rounded-md px-3 py-1.5 text-sm text-muted-foreground transition-colors hover:bg-muted hover:text-foreground disabled:opacity-50"
>
{tCommon("cancel")}
</button>
</div>
</div>
</div>
);
}
@@ -0,0 +1,133 @@
"use client";
import { useEffect } from "react";
import type { ReactNode } from "react";
import { useTranslations } from "next-intl";
import { usePathname, useRouter } from "@/i18n/navigation";
import { getPathPrefix } from "@/lib/browser-navigation";
import { parseMailto } from "@/lib/protocol-handlers/mailto";
import { parseWebcal } from "@/lib/protocol-handlers/webcal";
import {
listenForMailtoRequests,
notifyPendingMailto,
notifyPendingWebcal,
requestOpenMailtoInExistingClient,
savePendingMailto,
savePendingWebcal,
} from "@/lib/protocol-handlers/session";
import { useSettingsStore } from "@/stores/settings-store";
type LaunchParams = { targetURL?: string };
type StandaloneNavigator = Navigator & { standalone?: boolean };
declare global {
interface Window {
launchQueue?: {
setConsumer: (consumer: (launchParams: LaunchParams) => void) => void;
};
}
}
function getProtocolLaunch(targetURL: string):
| { kind: "mailto"; raw: string }
| { kind: "webcal"; raw: string }
| null {
let url: URL;
try {
url = new URL(targetURL, window.location.origin);
} catch {
return null;
}
if (url.origin !== window.location.origin) return null;
const raw = url.searchParams.get("url");
if (!raw) return null;
if (url.pathname.includes("/protocol/mailto")) return { kind: "mailto", raw };
if (url.pathname.includes("/protocol/webcal")) return { kind: "webcal", raw };
return null;
}
function isStandaloneDisplayMode() {
return window.matchMedia?.("(display-mode: standalone)").matches
|| (navigator as StandaloneNavigator).standalone === true;
}
function openProtocolInNewTab(protocol: "mailto" | "webcal", raw: string): boolean {
const url = `${getPathPrefix()}/protocol/${protocol}?url=${encodeURIComponent(raw)}&fallback=1`;
const opened = window.open(url, "_blank");
if (!opened) return false;
opened.opener = null;
return true;
}
interface ProtocolLaunchHandlerProviderProps {
children: ReactNode;
}
export function ProtocolLaunchHandlerProvider({ children }: ProtocolLaunchHandlerProviderProps) {
const t = useTranslations("protocol_handlers");
const router = useRouter();
const pathname = usePathname();
useEffect(() => {
if (pathname.startsWith("/protocol/")) return;
return listenForMailtoRequests((pending) => {
savePendingMailto(pending);
notifyPendingMailto();
if (pathname !== "/") router.push("/");
}, () => ({
path: pathname,
standalone: isStandaloneDisplayMode(),
focusNotificationTitle: t("focus_notification_title"),
focusNotificationBody: t("focus_notification_body"),
}));
}, [pathname, router, t]);
useEffect(() => {
if (typeof window === "undefined" || !window.launchQueue) return;
window.launchQueue.setConsumer((launchParams) => {
if (!launchParams.targetURL) return;
const launch = getProtocolLaunch(launchParams.targetURL);
if (!launch) return;
if (launch.kind === "mailto") {
const parsed = parseMailto(launch.raw);
if (!parsed) return;
if (useSettingsStore.getState().protocolOpenMode === "new-tab") {
if (openProtocolInNewTab("mailto", launch.raw)) return;
savePendingMailto(parsed);
notifyPendingMailto();
if (pathname !== "/") router.push("/");
return;
}
void requestOpenMailtoInExistingClient(parsed).then((delivered) => {
if (delivered) return;
savePendingMailto(parsed);
notifyPendingMailto();
if (pathname !== "/") router.push("/");
});
return;
}
const parsed = parseWebcal(launch.raw);
if (!parsed) return;
if (useSettingsStore.getState().protocolOpenMode === "new-tab") {
if (openProtocolInNewTab("webcal", launch.raw)) return;
}
savePendingWebcal(parsed);
notifyPendingWebcal();
if (pathname !== "/calendar") router.push("/calendar");
});
}, [pathname, router]);
return children;
}
@@ -0,0 +1,73 @@
"use client";
import { useEffect } from "react";
import { parseWebcal } from "@/lib/protocol-handlers/webcal";
import { savePendingWebcal } from "@/lib/protocol-handlers/session";
import { useSettingsStore } from "@/stores/settings-store";
type StandaloneNavigator = Navigator & { standalone?: boolean };
function getProtocolPathPrefix(): string {
const marker = "/protocol/webcal";
const index = window.location.pathname.indexOf(marker);
return index > 0 ? window.location.pathname.slice(0, index) : "";
}
function returnToSourcePage() {
window.close();
window.setTimeout(() => {
if (window.history.length > 1) {
window.history.back();
}
}, 150);
}
function openFallbackAppTab(raw: string): boolean {
const url = `${getProtocolPathPrefix()}/protocol/webcal?url=${encodeURIComponent(raw)}&fallback=1`;
const opened = window.open(url, "_blank");
if (!opened) return false;
opened.opener = null;
return true;
}
function shouldOpenFallbackAppTab(): boolean {
const standalone = window.matchMedia?.("(display-mode: standalone)").matches
|| (navigator as StandaloneNavigator).standalone === true;
return !standalone && window.history.length > 1;
}
interface WebcalProtocolClientProps {
openingText: string;
}
export function WebcalProtocolClient({ openingText }: WebcalProtocolClientProps) {
useEffect(() => {
const params = new URLSearchParams(window.location.search);
const raw = params.get("url");
const isFallbackAppTab = params.get("fallback") === "1";
if (raw) {
const parsed = parseWebcal(raw);
if (parsed) {
if (!isFallbackAppTab
&& useSettingsStore.getState().protocolOpenMode === "new-tab"
&& shouldOpenFallbackAppTab()
&& openFallbackAppTab(raw)) {
returnToSourcePage();
return;
}
savePendingWebcal(parsed);
}
}
window.location.replace(`${getProtocolPathPrefix()}/calendar`);
}, []);
return (
<main className="flex min-h-screen items-center justify-center">
<p>{openingText}</p>
</main>
);
}
@@ -12,6 +12,14 @@ export function EmbeddedBridgeProvider({ children }: { children: React.ReactNode
useEffect(() => {
if (!embeddedMode || !isEmbedded()) return;
// Refuse to attach the listener without a pinned parent origin —
// otherwise any cross-origin frame could forge sso:trigger-logout.
if (!parentOrigin) {
console.error(
"[embedded-bridge] embeddedMode is enabled but parentOrigin is not configured; refusing to attach message listener",
);
return;
}
const unsubscribe = listenFromParent((msg) => {
switch (msg.type) {
@@ -26,7 +34,7 @@ export function EmbeddedBridgeProvider({ children }: { children: React.ReactNode
logout();
break;
}
}, parentOrigin || undefined);
}, parentOrigin);
return unsubscribe;
}, [embeddedMode, parentOrigin, logout]);
+8 -6
View File
@@ -4,13 +4,14 @@ import { useEffect, useState } from 'react';
import { NextIntlClientProvider } from 'next-intl';
import { useLocaleStore } from '@/stores/locale-store';
import csMessages from '@/locales/cs/common.json';
import daMessages from '@/locales/da/common.json';
import deMessages from '@/locales/de/common.json';
import enMessages from '@/locales/en/common.json';
import esMessages from '@/locales/es/common.json';
import frMessages from '@/locales/fr/common.json';
import itMessages from '@/locales/it/common.json';
import jaMessages from '@/locales/ja/common.json';
import koMessages from '@/locales/ko/common.json';
import esMessages from '@/locales/es/common.json';
import itMessages from '@/locales/it/common.json';
import deMessages from '@/locales/de/common.json';
import lvMessages from '@/locales/lv/common.json';
import nlMessages from '@/locales/nl/common.json';
import plMessages from '@/locales/pl/common.json';
@@ -23,13 +24,14 @@ import zhMessages from '@/locales/zh/common.json';
// Pre-loaded translations (loaded at build time, not runtime)
const ALL_MESSAGES = {
cs: csMessages,
da: daMessages,
de: deMessages,
en: enMessages,
es: esMessages,
fr: frMessages,
it: itMessages,
ja: jaMessages,
ko: koMessages,
es: esMessages,
it: itMessages,
de: deMessages,
lv: lvMessages,
nl: nlMessages,
pl: plMessages,
+15
View File
@@ -10,5 +10,20 @@ export function ThemeProvider({ children }: { children: React.ReactNode }) {
initializeTheme();
}, [initializeTheme]);
useEffect(() => {
if (process.env.NODE_ENV === 'production') return;
const handleKeyDown = (e: KeyboardEvent) => {
if ((e.ctrlKey || e.metaKey) && e.shiftKey && e.key.toLowerCase() === 'l') {
e.preventDefault();
const { resolvedTheme, setTheme } = useThemeStore.getState();
setTheme(resolvedTheme === 'dark' ? 'light' : 'dark');
}
};
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, []);
return <>{children}</>;
}
@@ -12,6 +12,7 @@ import { useAccountSecurityStore, type AppPasswordInfo, type ApiKeyInfo, type Ap
import { useAuthStore } from '@/stores/auth-store';
import { toast } from '@/stores/toast-store';
import { cn } from '@/lib/utils';
import { sanitizeI18nHtml } from '@/lib/email-sanitization';
function PasswordChangeSection() {
const t = useTranslations('settings.security');
@@ -671,7 +672,7 @@ export function AccountSecuritySettings() {
if (isStalwart === false) {
return (
<SettingsSection title={t('title')} description={t('description')}>
<div className="text-sm text-muted-foreground py-4" dangerouslySetInnerHTML={{ __html: t('not_available') }} />
<div className="text-sm text-muted-foreground py-4" dangerouslySetInnerHTML={{ __html: sanitizeI18nHtml(t('not_available')) }} />
</SettingsSection>
);
}
+8 -1
View File
@@ -67,7 +67,7 @@ export function AppearanceSettings() {
const tAdvanced = useTranslations('settings.advanced');
const tTour = useTranslations('tour');
const { theme, setTheme } = useThemeStore();
const { fontSize, density, animationsEnabled, senderFavicons, showAvatarsInJunk, updateSetting } = useSettingsStore();
const { fontSize, density, animationsEnabled, senderFavicons, showAvatarsInJunk, showOnboardingOnNewDevices, updateSetting } = useSettingsStore();
const { startTour, resetTourCompletion } = useTour();
const { isSettingLocked, isSettingHidden } = usePolicyStore();
@@ -145,6 +145,13 @@ export function AppearanceSettings() {
{tTour('restart_button')}
</Button>
</SettingItem>
<SettingItem label={tTour('show_on_new_devices_title')} description={tTour('show_on_new_devices_desc')}>
<ToggleSwitch
checked={showOnboardingOnNewDevices}
onChange={(checked) => updateSetting('showOnboardingOnNewDevices', checked)}
/>
</SettingItem>
</SettingsSection>
);
}
@@ -1,6 +1,6 @@
"use client";
import { useState, useRef, useEffect } from 'react';
import { useState, useRef, useEffect, useMemo } from 'react';
import { useTranslations } from 'next-intl';
import { useCalendarStore } from '@/stores/calendar-store';
import { useAuthStore } from '@/stores/auth-store';
@@ -10,7 +10,7 @@ import { SettingsSection } from './settings-section';
import { Plus, Pencil, Trash2, Calendar as CalendarIcon, Copy, Link, Upload, Globe, RefreshCw, Eraser, Users } from 'lucide-react';
import { ShareCollectionDialog } from './share-collection-dialog';
import type { CalendarRights } from '@/lib/jmap/types';
import { cn, formatDateTime } from '@/lib/utils';
import { cn, formatDateTime, redactUrlCredentials } from '@/lib/utils';
import { ICalImportModal } from '@/components/calendar/ical-import-modal';
import { ICalSubscriptionModal } from '@/components/calendar/ical-subscription-modal';
import { useSettingsStore } from '@/stores/settings-store';
@@ -155,7 +155,14 @@ export { CalendarColorPicker, CALENDAR_COLORS };
export function CalendarManagementSettings() {
const t = useTranslations('calendar.management');
const { client, serverUrl, username } = useAuthStore();
const { calendars, updateCalendar, shareCalendar, createCalendar, removeCalendar, clearCalendarEvents, fetchCalendars, icalSubscriptions, removeICalSubscription, refreshICalSubscription, isSubscriptionCalendar } = useCalendarStore();
const { calendars, updateCalendar, shareCalendar, createCalendar, removeCalendar, clearCalendarEvents, fetchCalendars, icalSubscriptions: allSubs, removeICalSubscription, refreshICalSubscription, isSubscriptionCalendar } = useCalendarStore();
// Subscriptions are persisted globally but scoped per JMAP account via
// accountId. Legacy entries with no accountId show in the active account.
const currentAccountId = client?.getAccountId();
const icalSubscriptions = useMemo(
() => allSubs.filter(s => !s.accountId || s.accountId === currentAccountId),
[allSubs, currentAccountId],
);
const [discoveredCalDavUrls, setDiscoveredCalDavUrls] = useState<Record<string, string | null>>({});
const [wellKnownCalDavUrl, setWellKnownCalDavUrl] = useState<string | null>(null);
@@ -652,9 +659,14 @@ export function CalendarManagementSettings() {
<Globe className="w-4 h-4 text-muted-foreground flex-shrink-0" />
<div className="flex-1 min-w-0">
<span className="text-sm font-medium truncate block">{sub.name}</span>
<span className="text-xs text-muted-foreground truncate block" title={sub.url}>
{sub.url}
</span>
{(() => {
const safeUrl = redactUrlCredentials(sub.url);
return (
<span className="text-xs text-muted-foreground truncate block" title={safeUrl}>
{safeUrl}
</span>
);
})()}
{sub.lastRefreshed && (
<span className="text-xs text-muted-foreground">
{tSub('last_refreshed', { time: formatDateTime(sub.lastRefreshed, timeFormat, { month: 'short', day: 'numeric', year: 'numeric' }) })}
+22 -35
View File
@@ -1,14 +1,12 @@
"use client";
import { useState, useCallback } from 'react';
import { useState } from 'react';
import { useTranslations } from 'next-intl';
import { useConfig } from '@/hooks/use-config';
import { useSettingsStore } from '@/stores/settings-store';
import type { SendDelaySeconds } from '@/stores/settings-store';
import { useAuthStore } from '@/stores/auth-store';
import { SettingsSection, SettingItem, Select, ToggleSwitch } from './settings-section';
import { Mail, X } from 'lucide-react';
import { getPathPrefix } from '@/lib/browser-navigation';
import { X } from 'lucide-react';
import {
SUPPORTED_SUB_ADDRESS_DELIMITERS,
isSupportedSubAddressDelimiter,
@@ -20,8 +18,6 @@ const DEFAULT_CUSTOM_DELIMITER = '~';
export function ComposingSettings() {
const t = useTranslations('settings.email_behavior');
const { appName } = useConfig();
const [defaultMailStatus, setDefaultMailStatus] = useState<'idle' | 'success' | 'error'>('idle');
const [newKeyword, setNewKeyword] = useState('');
const {
@@ -30,22 +26,13 @@ export function ComposingSettings() {
attachmentReminderKeywords,
sendDelaySeconds,
subAddressDelimiter,
signaturePosition,
signatureSeparatorEnabled,
updateSetting,
} = useSettingsStore();
const { client } = useAuthStore();
const delayedSendSupported = client?.hasDelayedSend() ?? false;
const handleSetDefaultMailProgram = useCallback(() => {
try {
if (typeof navigator !== 'undefined' && navigator.registerProtocolHandler) {
navigator.registerProtocolHandler('mailto', `${window.location.origin}${getPathPrefix()}/compose?mailto=%s`);
setDefaultMailStatus('success');
}
} catch {
setDefaultMailStatus('error');
}
}, []);
return (
<SettingsSection title={t('title')} description={t('description')}>
<SettingItem label={t('auto_select_reply_identity.label')} description={t('auto_select_reply_identity.description')}>
@@ -73,6 +60,24 @@ export function ComposingSettings() {
</div>
</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 label={t('signature_separator.label')} description={t('signature_separator.description')}>
<ToggleSwitch
checked={signatureSeparatorEnabled}
onChange={(checked) => updateSetting('signatureSeparatorEnabled', checked)}
/>
</SettingItem>
<SettingItem
label={t('sub_address_delimiter.label')}
description={t('sub_address_delimiter.description', { delimiter: subAddressDelimiter })}
@@ -171,24 +176,6 @@ export function ComposingSettings() {
</form>
</div>
)}
<SettingItem label={t('default_mail_program.label')} description={t('default_mail_program.description', { appName: appName || 'Bulwark' })}>
<div className="flex flex-col items-end gap-1">
<button
onClick={handleSetDefaultMailProgram}
className="flex items-center gap-2 px-3 py-1.5 bg-muted hover:bg-accent rounded-md transition-colors"
>
<Mail className="w-4 h-4" />
<span className="text-sm text-foreground">{t('default_mail_program.button')}</span>
</button>
{defaultMailStatus === 'success' && (
<p className="text-xs text-green-600 dark:text-green-400">{t('default_mail_program.success')}</p>
)}
{defaultMailStatus === 'error' && (
<p className="text-xs text-destructive">{t('default_mail_program.error')}</p>
)}
</div>
</SettingItem>
</SettingsSection>
);
}
@@ -12,7 +12,7 @@ import { useContactStore } from '@/stores/contact-store';
export function ContentSendersSettings() {
const t = useTranslations('settings.email_behavior');
const [showTrustedModal, setShowTrustedModal] = useState(false);
const { isSettingLocked, isSettingHidden } = usePolicyStore();
const { isSettingLocked, isSettingHidden, isFeatureEnabled } = usePolicyStore();
const {
externalContentPolicy,
@@ -32,7 +32,7 @@ export function ContentSendersSettings() {
return (
<SettingsSection title={t('title')} description={t('description')}>
{!isSettingHidden('externalContentPolicy') && (
{isFeatureEnabled('externalContentEnabled') && !isSettingHidden('externalContentPolicy') && (
<SettingItem label={t('external_content.label')} description={t('external_content.description')} locked={isSettingLocked('externalContentPolicy')}>
<Select
value={externalContentPolicy}
+6
View File
@@ -13,6 +13,7 @@ import {
Inbox, Send, FileText, Trash, ShieldAlert, Archive,
Star, Heart, Bookmark, Tag, Flag, Briefcase, Users,
Bell, Zap, Globe, Lock, Eye, MessageSquare, Mail,
AlertTriangle, NotebookPen, CalendarClock, BellOff,
type LucideIcon,
} from 'lucide-react';
import { cn, buildMailboxTree, type MailboxNode } from '@/lib/utils';
@@ -27,6 +28,11 @@ const ROLE_ICONS: Record<string, LucideIcon> = {
trash: Trash,
junk: ShieldAlert,
archive: Archive,
shared: Users,
important: AlertTriangle,
memos: NotebookPen,
scheduled: CalendarClock,
snoozed: BellOff,
};
const ICON_CHOICES: { name: string; icon: LucideIcon }[] = [
+60 -9
View File
@@ -1,11 +1,13 @@
"use client";
import { useTranslations } from 'next-intl';
import { Link } from '@/i18n/navigation';
import { useSettingsStore, type ToolbarPosition, type MailLayout } from '@/stores/settings-store';
import { SettingsSection, SettingItem, RadioGroup, ToggleSwitch } from './settings-section';
import { cn } from '@/lib/utils';
import { usePolicyStore } from '@/stores/policy-store';
import { useAccountStore } from '@/stores/account-store';
import { useMediaQuery } from '@/hooks/use-media-query';
const MAIL_LAYOUT_PREVIEW_ROWS = [
{ sender: 'Alice', subject: 'Quarterly roadmap', preview: 'The draft is ready for review.', selected: false },
@@ -13,6 +15,12 @@ const MAIL_LAYOUT_PREVIEW_ROWS = [
{ sender: 'Billing', subject: 'Invoice 1042', preview: 'Your receipt is attached.', selected: false },
];
const MAIL_LAYOUT_PREVIEW_ROWS_FOCUS = [
...MAIL_LAYOUT_PREVIEW_ROWS,
{ sender: 'Sam', subject: 'Lunch?', preview: '', selected: false },
{ sender: 'Newsletter', subject: 'Weekly digest', preview: '', selected: false },
];
function MailLayoutPreview({
value,
t,
@@ -20,8 +28,6 @@ function MailLayoutPreview({
value: MailLayout;
t: (key: string) => string;
}) {
const isSplit = value === 'split';
return (
<div className="mt-3 rounded-xl border border-border bg-background p-3">
<div>
@@ -33,7 +39,7 @@ function MailLayoutPreview({
<div className="flex h-28">
<div className="w-11 border-r border-border bg-muted/40" />
{isSplit ? (
{value === 'split' && (
<>
<div className="w-28 border-r border-border bg-background">
{MAIL_LAYOUT_PREVIEW_ROWS.map((row) => (
@@ -56,15 +62,36 @@ function MailLayoutPreview({
<div className="mt-1.5 h-2 w-2/3 rounded bg-foreground/10" />
</div>
</>
) : (
<div className="flex-1 bg-background px-2 py-2">
<div className="space-y-1.5">
)}
{value === 'focus' && (
<div className="flex-1 bg-background">
{MAIL_LAYOUT_PREVIEW_ROWS_FOCUS.map((row) => (
<div
key={row.subject}
className={cn(
'border-b border-border px-2 py-1 text-[10px] last:border-b-0',
row.selected && 'bg-primary/10'
)}
>
<div className="truncate text-foreground">
<span className="font-medium">{row.sender}</span>
<span className="mx-1.5 text-muted-foreground">{row.subject}</span>
</div>
</div>
))}
</div>
)}
{value === 'horizontal' && (
<div className="flex-1 flex flex-col bg-background">
<div className="border-b border-border bg-background">
{MAIL_LAYOUT_PREVIEW_ROWS.map((row) => (
<div
key={row.subject}
className={cn(
'rounded-md px-2 py-1 text-[10px]',
row.selected ? 'bg-primary/10' : 'bg-muted/20'
'border-b border-border px-2 py-1 text-[10px] last:border-b-0',
row.selected && 'bg-primary/10'
)}
>
<div className="truncate text-foreground">
@@ -74,6 +101,11 @@ function MailLayoutPreview({
</div>
))}
</div>
<div className="flex-1 bg-background px-3 py-2">
<div className="h-2 w-20 rounded bg-foreground/10" />
<div className="mt-1.5 h-1.5 w-full rounded bg-foreground/10" />
<div className="mt-1 h-1.5 w-5/6 rounded bg-foreground/10" />
</div>
</div>
)}
</div>
@@ -85,9 +117,10 @@ function MailLayoutPreview({
export function LayoutSettings() {
const t = useTranslations('settings.appearance');
const tEmail = useTranslations('settings.email_behavior');
const { toolbarPosition, showToolbarLabels, hideAccountSwitcher, showRailAccountList, enableUnifiedMailbox, colorfulSidebarIcons, mailLayout, updateSetting } = useSettingsStore();
const { toolbarPosition, showToolbarLabels, hideAccountSwitcher, showRailAccountList, enableUnifiedMailbox, colorfulSidebarIcons, mailLayout, proInterface, updateSetting } = useSettingsStore();
const { isSettingLocked, isSettingHidden } = usePolicyStore();
const accounts = useAccountStore(s => s.accounts);
const isDesktop = useMediaQuery('(min-width: 1024px)');
return (
<SettingsSection title={t('title')} description={t('description')}>
@@ -100,6 +133,7 @@ export function LayoutSettings() {
options={[
{ value: 'split', label: tEmail('mail_layout.split') },
{ value: 'focus', label: tEmail('mail_layout.focus') },
{ value: 'horizontal', label: tEmail('mail_layout.horizontal') },
]}
/>
<MailLayoutPreview value={mailLayout} t={tEmail} />
@@ -157,6 +191,23 @@ export function LayoutSettings() {
/>
</SettingItem>
)}
<SettingItem label={t('pro_interface.label')} description={t('pro_interface.description')}>
<div className="flex items-center gap-3">
{proInterface && isDesktop && (
<Link
href="/pro"
className="text-sm font-medium text-primary hover:underline"
>
{t('pro_interface.open_label')}
</Link>
)}
<ToggleSwitch
checked={proInterface}
onChange={(v) => updateSetting('proInterface', v)}
/>
</div>
</SettingItem>
</SettingsSection>
);
}
@@ -52,11 +52,14 @@ export function NotificationSettings() {
useEffect(() => {
if (!supported) return;
if (!client) return;
const accountId = client.getAccountId();
if (!accountId) return;
void (async () => {
const enabled = await isWebPushEnabled();
if (enabled) setPushStatus({ kind: 'enabled' });
const enabled = await isWebPushEnabled(accountId);
setPushStatus(enabled ? { kind: 'enabled' } : { kind: 'idle' });
})();
}, [supported]);
}, [supported, client]);
const trimmedRelay = relayUrl.trim().replace(/\/+$/, '');
const isValidRelay = /^https?:\/\/.+/i.test(trimmedRelay);
@@ -0,0 +1,108 @@
"use client";
import { useEffect, useState } from "react";
import { useTranslations } from "next-intl";
import { Button } from "@/components/ui/button";
import { getPathPrefix } from "@/lib/browser-navigation";
import { useSettingsStore } from "@/stores/settings-store";
import type { ProtocolOpenMode } from "@/stores/settings-store";
import { toast } from "@/stores/toast-store";
import { SettingsSection, SettingItem, Select } from "./settings-section";
type Protocol = "mailto" | "webcal";
function canRegisterProtocolHandler(): boolean {
return typeof navigator !== "undefined"
&& "registerProtocolHandler" in navigator
&& typeof window !== "undefined"
&& window.isSecureContext;
}
function getProtocolHandlerUrl(protocol: Protocol) {
return `${window.location.origin}${getPathPrefix()}/protocol/${protocol}?url=%s`;
}
function registerProtocolHandler(protocol: Protocol) {
navigator.registerProtocolHandler(
protocol,
getProtocolHandlerUrl(protocol),
);
}
interface ProtocolHandlerSettingsProps {
supportsCalendar: boolean;
}
export function ProtocolHandlerSettings({ supportsCalendar }: ProtocolHandlerSettingsProps) {
const t = useTranslations("protocol_handlers");
const protocolOpenMode = useSettingsStore((state) => state.protocolOpenMode);
const updateSetting = useSettingsStore((state) => state.updateSetting);
const [supported, setSupported] = useState(false);
useEffect(() => {
setSupported(canRegisterProtocolHandler());
}, []);
const handleOpenModeChange = async (value: string) => {
const openMode = value as ProtocolOpenMode;
if (openMode === "active-session"
&& typeof window !== "undefined"
&& "Notification" in window
&& Notification.permission === "default") {
await Notification.requestPermission();
}
updateSetting("protocolOpenMode", openMode);
};
const handleRegister = (protocol: Protocol) => {
try {
registerProtocolHandler(protocol);
toast.success(protocol === "mailto" ? t("mailto_registered") : t("webcal_registered"));
} catch {
toast.error(t("registration_failed"));
}
};
const renderRegistrationControl = (protocol: Protocol) => {
return (
<Button size="sm" onClick={() => handleRegister(protocol)} disabled={!supported}>
{protocol === "mailto" ? t("register_mailto") : t("register_webcal")}
</Button>
);
};
return (
<SettingsSection title={t("title")} description={t("description")}>
{!supported && (
<div className="rounded-md border border-border bg-muted/40 px-3 py-2 text-sm text-muted-foreground">
{t("unsupported")}
</div>
)}
<SettingItem label={t("mailto_label")} description={t("mailto_description")}>
{renderRegistrationControl("mailto")}
</SettingItem>
{supportsCalendar && (
<SettingItem label={t("webcal_label")} description={t("webcal_description")}>
{renderRegistrationControl("webcal")}
</SettingItem>
)}
<SettingItem label={t("protocol_open_mode_label")} description={t("protocol_open_mode_description")}>
<Select
value={protocolOpenMode}
onChange={handleOpenModeChange}
options={[
{ value: "new-tab", label: t("protocol_open_mode_new_tab") },
{ value: "active-session", label: t("protocol_open_mode_active_session") },
]}
/>
</SettingItem>
<p className="text-xs text-muted-foreground">{t("browser_note")}</p>
</SettingsSection>
);
}
-12
View File
@@ -31,7 +31,6 @@ export function SmimeSettings() {
identityKeyBindings,
defaultSignIdentity,
defaultEncrypt,
rememberUnlockedKeys,
autoImportSignerCerts,
isLoading,
error,
@@ -44,7 +43,6 @@ export function SmimeSettings() {
lockKey,
setSignDefault,
setEncryptDefault,
setRememberUnlockedKeys,
setAutoImportSignerCerts,
isKeyUnlocked,
setError,
@@ -465,16 +463,6 @@ export function SmimeSettings() {
/>
</SettingItem>
<SettingItem
label={t("remember_unlocked")}
description={t("remember_unlocked_desc")}
>
<ToggleSwitch
checked={rememberUnlockedKeys}
onChange={setRememberUnlockedKeys}
/>
</SettingItem>
<SettingItem
label={t("auto_import_signer_certs")}
description={t("auto_import_signer_certs_desc")}
+29 -4
View File
@@ -5,6 +5,7 @@ import { useRouter, usePathname } from "@/i18n/navigation";
import { useAuthStore } from "@/stores/auth-store";
import { useCalendarStore } from "@/stores/calendar-store";
import { useWebDAVStore } from "@/stores/webdav-store";
import { useSettingsStore } from "@/stores/settings-store";
import { getTourSteps, type TourStep } from "./tour-steps";
import { TourOverlay } from "./tour-overlay";
@@ -38,6 +39,9 @@ export function TourProvider({ children }: { children: ReactNode }) {
const { isDemoMode } = useAuthStore();
const { supportsCalendar } = useCalendarStore();
const { supportsWebDAV } = useWebDAVStore();
const tourCompleted = useSettingsStore((s) => s.tourCompleted);
const showOnboardingOnNewDevices = useSettingsStore((s) => s.showOnboardingOnNewDevices);
const updateSetting = useSettingsStore((s) => s.updateSetting);
const [isActive, setIsActive] = useState(false);
const [currentStep, setCurrentStep] = useState(0);
@@ -46,10 +50,29 @@ export function TourProvider({ children }: { children: ReactNode }) {
const steps = getTourSteps({ isDemoMode, supportsCalendar, supportsWebDAV: supportsWebDAV !== false });
useEffect(() => {
// One-time migration: if the legacy per-device flag is set but the synced
// setting isn't yet, mirror it into synced state.
try {
setHasCompletedTour(localStorage.getItem(TOUR_COMPLETED_KEY) === "true");
const legacy = localStorage.getItem(TOUR_COMPLETED_KEY) === "true";
if (legacy && !tourCompleted) {
updateSetting("tourCompleted", true);
}
} catch { /* */ }
}, []);
}, [tourCompleted, updateSetting]);
useEffect(() => {
if (!tourCompleted) {
setHasCompletedTour(false);
return;
}
if (showOnboardingOnNewDevices) {
try {
setHasCompletedTour(localStorage.getItem(TOUR_COMPLETED_KEY) === "true");
return;
} catch { /* */ }
}
setHasCompletedTour(true);
}, [tourCompleted, showOnboardingOnNewDevices]);
const startTour = useCallback(() => {
let resumeStep = 0;
@@ -85,11 +108,12 @@ export function TourProvider({ children }: { children: ReactNode }) {
const completeTour = useCallback(() => {
setIsActive(false);
setHasCompletedTour(true);
updateSetting("tourCompleted", true);
try {
localStorage.setItem(TOUR_COMPLETED_KEY, "true");
localStorage.removeItem(TOUR_CURRENT_STEP_KEY);
} catch { /* */ }
}, []);
}, [updateSetting]);
const nextStep = useCallback(() => {
if (currentStep >= steps.length - 1) {
@@ -131,11 +155,12 @@ export function TourProvider({ children }: { children: ReactNode }) {
const resetTourCompletion = useCallback(() => {
setHasCompletedTour(false);
updateSetting("tourCompleted", false);
try {
localStorage.removeItem(TOUR_COMPLETED_KEY);
localStorage.removeItem(TOUR_CURRENT_STEP_KEY);
} catch { /* */ }
}, []);
}, [updateSetting]);
const value: TourContextValue = {
isActive,
+11 -8
View File
@@ -142,9 +142,13 @@ interface AvatarProps {
className?: string;
/** When true, suppress all image sources (favicons, plugin avatars, profile pics, contact photos) and render initials only. */
disableImages?: boolean;
/** When true, do not fall through to the sender's domain favicon. Use for the user's own account avatar where the mail-provider logo is not meaningful. */
disableFavicon?: boolean;
/** Background color used when no image source resolves. Overrides the hash-based default. */
fallbackColor?: string;
}
export function Avatar({ name, email, contactPhotoUri, size = "md", className, disableImages = false }: AvatarProps) {
export function Avatar({ name, email, contactPhotoUri, size = "md", className, disableImages = false, disableFavicon = false, fallbackColor }: AvatarProps) {
const [imgError, setImgError] = useState(false);
const [pluginAvatarUrl, setPluginAvatarUrl] = useState<string | null>(null);
const [pluginAvatarFailed, setPluginAvatarFailed] = useState(false);
@@ -226,16 +230,15 @@ export function Avatar({ name, email, contactPhotoUri, size = "md", className, d
const profilePic = email && domain ? getProfilePictureUrl(email, domain, devMode, name) : null;
const showFavicon =
senderFavicons && faviconDomain && !PERSONAL_DOMAINS.has(faviconDomain) && !imgError && !domainFailed;
!disableFavicon && senderFavicons && faviconDomain && !PERSONAL_DOMAINS.has(faviconDomain) && !imgError && !domainFailed;
// Priority: contact photo > plugin avatar (e.g. Gravatar) > custom avatar > profile picture > company favicon > initials
const customAvatar = devMode && email ? CUSTOM_AVATARS[email.toLowerCase()] : null;
const pluginAvatar = pluginAvatarFailed ? null : pluginAvatarUrl;
const imgSrc = disableImages
? null
: !imgError && !domainFailed
? resolvedContactPhoto || pluginAvatar || customAvatar || profilePic || (showFavicon ? `/api/favicon?domain=${encodeURIComponent(faviconDomain!)}` : null)
: (resolvedContactPhoto || pluginAvatar || customAvatar || profilePic || null);
const photoSrc = resolvedContactPhoto || pluginAvatar || customAvatar || profilePic || null;
const faviconSrc = !imgError && !domainFailed && showFavicon ? `/api/favicon?domain=${encodeURIComponent(faviconDomain!)}` : null;
const imgSrc = disableImages ? null : (photoSrc || faviconSrc);
const isFavicon = imgSrc !== null && imgSrc === faviconSrc;
const handleImgError = useCallback(() => {
// If the plugin avatar just failed, mark it and fall through to the next source
@@ -257,7 +260,7 @@ export function Avatar({ name, email, contactPhotoUri, size = "md", className, d
sizeClasses[size],
className
)}
style={{ backgroundColor: imgSrc ? "#ffffff" : getBackgroundColor() }}
style={{ backgroundColor: imgSrc ? (isFavicon ? "#ffffff" : "transparent") : (fallbackColor ?? getBackgroundColor()) }}
title={name || email}
>
{imgSrc ? (
+15 -4
View File
@@ -201,15 +201,27 @@ export function FlagCS(props: FlagProps) {
);
}
/** Denmark Red with a white Nordic cross */
export function FlagDK(props: FlagProps) {
return (
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 37 28" width={W} height={H} className={flagClass} {...props}>
<path fill="#C8102E" d="M0,0H37V28H0Z" />
<path stroke="#fff" strokeWidth="4" d="M0,14h37M14,0v28" />
</svg>
);
}
/** Map locale codes to flag components */
export const flagComponents: Record<string, (props: FlagProps) => ReactElement> = {
cs: FlagCS,
da: FlagDK,
de: FlagDE,
en: FlagGB,
es: FlagES,
fr: FlagFR,
it: FlagIT,
ja: FlagJP,
ko: FlagKR,
es: FlagES,
it: FlagIT,
de: FlagDE,
lv: FlagLV,
nl: FlagNL,
pl: FlagPL,
@@ -218,5 +230,4 @@ export const flagComponents: Record<string, (props: FlagProps) => ReactElement>
tr: FlagTR,
uk: FlagUA,
zh: FlagCN,
cs: FlagCS,
};
+8 -7
View File
@@ -9,20 +9,21 @@ import { flagComponents } from './flag-icons';
const languages = [
{ value: 'cs', label: 'Česky' },
{ value: 'en', label: 'English' },
{ value: 'fr', label: 'Français' },
{ value: 'ja', label: '日本語' },
{ value: 'ko', label: '한국어' },
{ value: 'es', label: 'Español' },
{ value: 'it', label: 'Italiano' },
{ value: 'da', label: 'Dansk' },
{ value: 'de', label: 'Deutsch' },
{ value: 'en', label: 'English' },
{ value: 'es', label: 'Español' },
{ value: 'fr', label: 'Français' },
{ value: 'it', label: 'Italiano' },
{ value: 'lv', label: 'Latviešu' },
{ value: 'nl', label: 'Nederlands' },
{ value: 'pl', label: 'Polski' },
{ value: 'pt', label: 'Português' },
{ value: 'ru', label: 'Русский' },
{ value: 'tr', label: 'Türkçe' },
{ value: 'ru', label: 'Русский' },
{ value: 'uk', label: 'Українська' },
{ value: 'ko', label: '한국어' },
{ value: 'ja', label: '日本語' },
{ value: 'zh', label: '简体中文' },
];
+29 -4
View File
@@ -6,6 +6,7 @@ import { X, Lightbulb, Settings, PlayCircle } from "lucide-react";
import { Button } from "@/components/ui/button";
import { useRouter } from "@/i18n/navigation";
import { useTour } from "@/components/tour/tour-provider";
import { useSettingsStore } from "@/stores/settings-store";
const ONBOARDING_KEY = "onboarding_completed";
@@ -13,23 +14,47 @@ export function WelcomeBanner() {
const t = useTranslations("welcome");
const router = useRouter();
const { startTour } = useTour();
const onboardingCompleted = useSettingsStore((s) => s.onboardingCompleted);
const showOnboardingOnNewDevices = useSettingsStore((s) => s.showOnboardingOnNewDevices);
const updateSetting = useSettingsStore((s) => s.updateSetting);
const [visible, setVisible] = useState(false);
const [dismissed, setDismissed] = useState(false);
useEffect(() => {
// One-time migration: if the legacy per-device flag is set but the synced
// setting isn't yet, mirror it into synced state so the user isn't shown
// the banner again on this device after the upgrade.
try {
if (!localStorage.getItem(ONBOARDING_KEY)) {
setVisible(true);
const legacy = localStorage.getItem(ONBOARDING_KEY) === "true";
if (legacy && !onboardingCompleted) {
updateSetting("onboardingCompleted", true);
}
} catch { /* localStorage unavailable */ }
}, []);
}, [onboardingCompleted, updateSetting]);
useEffect(() => {
if (!onboardingCompleted) {
setVisible(true);
return;
}
if (showOnboardingOnNewDevices) {
try {
if (localStorage.getItem(ONBOARDING_KEY) !== "true") {
setVisible(true);
return;
}
} catch { /* localStorage unavailable */ }
}
setVisible(false);
}, [onboardingCompleted, showOnboardingOnNewDevices]);
const dismiss = useCallback(() => {
setDismissed(true);
updateSetting("onboardingCompleted", true);
try {
localStorage.setItem(ONBOARDING_KEY, "true");
} catch { /* localStorage unavailable */ }
}, []);
}, [updateSetting]);
useEffect(() => {
if (!visible) return;