import { type ClassValue, clsx } from "clsx"; import { twMerge } from "tailwind-merge"; import { Mailbox } from "./jmap/types"; import { debug } from "./debug"; import { getEffectiveLocale } from '@/i18n/detect-locale'; import { useSettingsStore } from "@/stores/settings-store"; import type { DateFormat, DateLocale } from "@/stores/settings-store"; import { getEffectiveTimeZone, getWallClock } from "./timezone"; export function cn(...inputs: ClassValue[]) { return twMerge(clsx(inputs)); } /** * Strip embedded Basic-auth credentials from a URL for display. * `https://user:pass@host/path` → `https://host/path`. Falls back to * regex stripping if URL parsing fails. */ export function redactUrlCredentials(rawUrl: string): string { try { const parsed = new URL(rawUrl); if (parsed.username || parsed.password) { parsed.username = ''; parsed.password = ''; return parsed.toString(); } return rawUrl; } catch { return rawUrl.replace(/^([a-zA-Z][a-zA-Z0-9+.-]*:\/\/)[^/@\s]+@/, '$1'); } } export function generateUUID(): string { if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') { return crypto.randomUUID(); } // Fallback: construct a v4 UUID from crypto.getRandomValues const bytes = new Uint8Array(16); crypto.getRandomValues(bytes); bytes[6] = (bytes[6] & 0x0f) | 0x40; // version 4 bytes[8] = (bytes[8] & 0x3f) | 0x80; // variant 1 const hex = Array.from(bytes, b => b.toString(16).padStart(2, '0')).join(''); return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`; } /** * Resolve the Intl locale used to render NUMERIC date parts, honouring the * user's regional `dateLocale` override while leaving weekday/month names on * the UI language. `auto` returns `fallback` unchanged (prior behaviour); the * explicit regions force a fixed numeric ordering (#456): * - `iso` → `en-CA` (YYYY-MM-DD) * - `en-GB` → `en-GB` (DD/MM/YYYY) * - `en-US` → `en-US` (MM/DD/YYYY) */ export function resolveDateLocale( dateLocale: DateLocale | undefined, fallback: T, ): string | T { switch (dateLocale) { case "iso": return "en-CA"; case "en-GB": return "en-GB"; case "en-US": return "en-US"; case "auto": default: return fallback; } } const dateFormatters = new Map(); /** * An Intl.DateTimeFormat for `locale` and `options`, built once. Each * toLocale*String call constructs one afresh, and the list formats every * row's date on every render. Only for option sets that name a date or time * field: then toLocaleString, toLocaleDateString and toLocaleTimeString all * format exactly as the formatter does. */ function cachedDateFormat(locale: string, options: Intl.DateTimeFormatOptions): Intl.DateTimeFormat { const key = `${locale}|${JSON.stringify(options)}`; let formatter = dateFormatters.get(key); if (!formatter) { formatter = new Intl.DateTimeFormat(locale, options); dateFormatters.set(key, formatter); } return formatter; } /** * Formats a received-at date for the email list. The output style is * controlled by the `dateFormat` user setting: * * - `smart` (default) — locale-aware, age-bucketed: * today → time only ("15:31" or "3:31 PM") * last 7 days → short weekday+time ("Fr 15:31", "Fri 3:31 PM") * older → full locale date ("28.04.2026", "04/28/2026") * With `listStyle: 'mountain-view'` the buckets are Gmail's instead: * today → time, this year → "Oct 10", older → "10/10/25". * - `relative` — legacy en-US relative format ("1h ago", "2d ago"). * - `full` — always the full locale date+time. * * The numeric date ordering is additionally governed by the `dateLocale` * region setting (`auto` = follow the UI language, unchanged; or a fixed * ISO / DD-MM / MM-DD ordering). Weekday and month names always follow the * UI language. * * The locale (from the language picker), the region override and the 12h/24h * preference are all read via `getState()` so this stays SSR-safe. * * All output is rendered in the user's effective time zone (the `timeZone` * setting when set, else the browser's), including the today / this-week * bucketing - a browser pinned to UTC for anti-fingerprinting must not * roll a 00:30 local mail into "yesterday" (#755). */ export function formatDate( date: Date | string, // Settings preview: render as if these were picked, without saving them. overrides?: { dateFormat?: DateFormat; dateLocale?: DateLocale; listStyle?: 'mountain-view' }, ): string { const d = typeof date === "string" ? new Date(date) : date; // An unparsable receivedAt (e.g. a 5-digit year) makes Intl throw // "RangeError: Invalid time value" and takes the whole list down with it. // Leave the row's date slot blank; the viewer header shows the raw value. (#1099) if (!d || Number.isNaN(d.getTime())) return ""; const now = new Date(); const locale = getEffectiveLocale(); // Names (weekday, month) follow the UI language: `en` alone resolves to // en-US in Intl; everything else uses the language subtag as-is and lets // the runtime pick a sensible default region. const uiLocale = locale === "en" ? "en-US" : locale; const { dateFormat, dateLocale, timeFormat } = { ...useSettingsStore.getState(), ...overrides }; // Numeric dates additionally honour the regional `dateLocale` override // (defaults to `auto` = the UI language, preserving prior behaviour). (#456) const numericLocale = resolveDateLocale(dateLocale, uiLocale); const hour12 = timeFormat === "12h"; const timeZone = getEffectiveTimeZone(); if (dateFormat === "relative") { const diff = now.getTime() - d.getTime(); const minutes = Math.floor(diff / 60000); const hours = Math.floor(diff / 3600000); const days = Math.floor(diff / 86400000); // Natural, fully localized relative time ("an hour ago" / "לפני שעה", // "2 days ago" / "לפני יומיים") with correct singular/dual/plural per locale. const rtf = new Intl.RelativeTimeFormat(uiLocale, { numeric: "auto" }); if (minutes < 1) return rtf.format(0, "second"); if (minutes < 60) return rtf.format(-minutes, "minute"); if (hours < 24) return rtf.format(-hours, "hour"); if (days < 7) return rtf.format(-days, "day"); return cachedDateFormat(uiLocale, { timeZone, month: "short", day: "numeric", year: getWallClock(d, timeZone).year !== getWallClock(now, timeZone).year ? "numeric" : undefined, }).format(d); } if (dateFormat === "full") { return cachedDateFormat(numericLocale, { timeZone, year: "numeric", month: "2-digit", day: "2-digit", hour: "2-digit", minute: "2-digit", hour12, }).format(d); } // 'smart' (default) const timeStr = cachedDateFormat(uiLocale, { timeZone, hour: "2-digit", minute: "2-digit", hour12, }).format(d); // Calendar-day comparison in the display zone, not the browser zone. const dWall = getWallClock(d, timeZone); const nowWall = getWallClock(now, timeZone); const isSameDay = dWall.year === nowWall.year && dWall.month === nowWall.month && dWall.day === nowWall.day; if (isSameDay) return timeStr; if (overrides?.listStyle === 'mountain-view') { if (dWall.year === nowWall.year) { return cachedDateFormat(uiLocale, { timeZone, month: "short", day: "numeric" }).format(d); } return cachedDateFormat(numericLocale, { timeZone, year: "2-digit", month: "numeric", day: "numeric" }).format(d); } const daysAgo = Math.floor((now.getTime() - d.getTime()) / 86400000); if (daysAgo < 7) { // German Intl outputs "Fr." with a trailing dot for `weekday: 'short'`; // strip it so the result reads cleanly next to the time. const weekday = cachedDateFormat(uiLocale, { timeZone, weekday: "short" }) .format(d) .replace(/\.$/, ""); return `${weekday} ${timeStr}`; } return cachedDateFormat(numericLocale, { timeZone, year: "numeric", month: "2-digit", day: "2-digit", }).format(d); } /** * Format a date/time string respecting the user's 12h/24h time format preference. * Rendered in the effective time zone (user override, else browser) - #755. */ export function formatDateTime( date: Date | string, timeFormat: '12h' | '24h', options?: { weekday?: 'short' | 'long'; year?: 'numeric'; month?: 'short' | 'long'; day?: 'numeric'; second?: '2-digit'; timeZoneName?: 'short'; dateOnly?: boolean; } ): string { const d = typeof date === 'string' ? new Date(date) : date; if (isNaN(d.getTime())) return typeof date === 'string' ? date : ''; // Honour the regional `dateLocale` override; `auto` keeps the previous // `undefined` (runtime default) locale so existing behaviour is unchanged. (#456) const { dateLocale } = useSettingsStore.getState(); const effectiveLocale = resolveDateLocale(dateLocale, undefined); const localeOptions: Intl.DateTimeFormatOptions = { timeZone: getEffectiveTimeZone() }; if (options?.weekday) localeOptions.weekday = options.weekday; if (options?.year) localeOptions.year = options.year; if (options?.month) localeOptions.month = options.month; if (options?.day) localeOptions.day = options.day; if (!options?.dateOnly) { localeOptions.hour = '2-digit'; localeOptions.minute = '2-digit'; localeOptions.hour12 = timeFormat === '12h'; if (options?.second) localeOptions.second = options.second; if (options?.timeZoneName) localeOptions.timeZoneName = options.timeZoneName; } return d.toLocaleString(effectiveLocale, localeOptions); } /** * How long ago `date` was, in words ("3 minutes ago", "2 days ago"), for the * parenthesis Gmail puts after a message's time. Empty once it is a month * old, where the date alone says enough. * * Days are calendar days in the user's effective time zone, so a mail from * the day before yesterday is never called "yesterday" just because fewer * than 48 hours have passed. (#1259) */ export function formatRelativeAgo(date: Date | string, now: Date = new Date()): string { const d = typeof date === 'string' ? new Date(date) : date; if (!d || Number.isNaN(d.getTime())) return ''; const diff = Math.max(0, now.getTime() - d.getTime()); const minutes = Math.floor(diff / 60000); const hours = Math.floor(diff / 3600000); const timeZone = getEffectiveTimeZone(); const dayNumber = (x: Date) => { const w = getWallClock(x, timeZone); return Date.UTC(w.year, w.month - 1, w.day) / 86400000; }; const days = dayNumber(now) - dayNumber(d); if (days >= 30) return ''; const locale = getEffectiveLocale(); const rtf = new Intl.RelativeTimeFormat(locale === 'en' ? 'en-US' : locale, { numeric: 'auto' }); if (minutes < 1) return rtf.format(0, 'second'); if (minutes < 60) return rtf.format(-minutes, 'minute'); // The day the clocks go back has 25 hours: 24 of them can still be today. if (hours < 24 || days < 1) return rtf.format(-hours, 'hour'); return rtf.format(-days, 'day'); } // Marketing emails pad the preheader with whitespace, format chars (soft // hyphens, zero-width chars, BOM, directional marks) and combining marks // (e.g. U+034F) to push real content past the preview window. Strip them all. // \p{Cf} = Format, \p{Mn} = combining marks; \s covers figure space, NBSP, etc. const LEADING_INVISIBLE_RE = /^[\s\p{Cf}\p{Mn}]+/u; // After stripping, a server-side truncation indicator like "..." may be all // that's left. Treat that as no preview so callers can fall back. const ONLY_PUNCTUATION_RE = /^[.\u2026\s]+$/; export function stripInvisibleLeading(text: string): string { const stripped = text.replace(LEADING_INVISIBLE_RE, ''); if (ONLY_PUNCTUATION_RE.test(stripped)) return ''; return stripped; } // A style sheet at the start of the text: an at-rule, or a selector opening a // rule. Plain prose does not start like this - "@" or "#" followed by a word // and then, before any sentence ends, a "{". const DECLARATIONS_RE = /^\s*(?:[-\w]+\s*:|[.#@a-z*][^{}]*\{)/i; // At-rule names are matched case-sensitively: style sheets write them in lower // case, while "@Page …" at the start of a preview is a mention. A selector's // colon is a pseudo-class ("a:hover"), never followed by a space as in // "Reminder: your appointment {date: …}". const AT_RULE_RE = /^@(?:media|font-face|import|supports|keyframes|charset|page)\b/; const SELECTOR_RE = /^[.#]?[a-z*](?:[\w\-.#,>~+*\s[\]="']|:(?!\s))*\{/i; /** * Drops a style sheet from the start of a text preview. * * Some servers build the preview from an HTML body without skipping its *