diff --git a/CHANGELOG.md b/CHANGELOG.md index 5cbaec13d..eb935f21a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,28 @@ # Changelog +## 1.7.6 (2026-06-28) + +### Breaking Changes + +- **S/MIME**: The built-in S/MIME implementation has been removed from core and re-delivered through the new generic crypto plugin hooks (privileged same-origin plugin tier). S/MIME signing, encryption, decryption, certificate management, and the related settings UI now live in a plugin rather than the main app. Deployments that relied on built-in S/MIME must install the S/MIME crypto plugin to retain those features. + +### Features + +- **Plugins**: Privileged same-origin plugin tier with a crypto API surface +- **Plugins**: Plugin hooks for email details, headers, and source +- **Mail**: Option to hide the total message count on folders (#498) + +### Fixes + +- **Mail**: Hide the server scheduled folder when the virtual one is shown (#495) +- **Mail**: Stop the unified mailbox from mutating client-returned email objects +- **Composer**: HTML-escape sender and subject in the reply/forward quote header (#482) +- **Calendar**: Send calendar invites by setting `organizerCalendarAddress` +- **Identity**: Sync the default identity (`preferredPrimaryId`) to server settings (#507) +- **Auth**: Support MFA login via the structured auth endpoint +- **Admin**: Show all built-in themes in the admin theme controls (#496) +- **i18n**: Add missing translation keys across 19 locales + ## 1.7.5 (2026-06-24) ### Features diff --git a/README.md b/README.md index f47abf3c8..cc14a8887 100644 --- a/README.md +++ b/README.md @@ -12,10 +12,8 @@ A modern, self-hosted webmail client for [Stalwart Mail Server](https://stalw.ar [![License: AGPL v3](https://img.shields.io/badge/license-AGPL%20v3-blue.svg?logo=gnu&logoColor=white)](LICENSE) [![Discord](https://img.shields.io/discord/1482128142939455674?color=7289da&label=discord&logo=discord&logoColor=white)](https://discord.gg/tYCujymGrT) -[![Version](https://img.shields.io/badge/version-1.7.5-green.svg?logo=git&logoColor=white)](CHANGELOG.md) +[![Version](https://img.shields.io/badge/version-1.7.6-green.svg?logo=git&logoColor=white)](CHANGELOG.md) [![Docker](https://img.shields.io/badge/docker-ghcr.io%2Fbulwarkmail%2Fwebmail-blue?logo=docker&logoColor=white)](https://ghcr.io/bulwarkmail/webmail) -[![Grafana](https://img.shields.io/badge/grafana-dashboard-orange?logo=grafana&logoColor=white)](https://grafana.external.bulwarkmail.org/) - --- diff --git a/VERSION b/VERSION index 6a126f402..de28578af 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.7.5 +1.7.6 diff --git a/app/(main)/[locale]/calendar/page.tsx b/app/(main)/[locale]/calendar/page.tsx index 07a2e98d5..0c9ffefda 100644 --- a/app/(main)/[locale]/calendar/page.tsx +++ b/app/(main)/[locale]/calendar/page.tsx @@ -1149,13 +1149,13 @@ export default function CalendarPage() {
diff --git a/app/(main)/[locale]/error.tsx b/app/(main)/[locale]/error.tsx index 50706cffc..7ddd9107e 100644 --- a/app/(main)/[locale]/error.tsx +++ b/app/(main)/[locale]/error.tsx @@ -38,11 +38,11 @@ export default function LocaleError({

diff --git a/app/(main)/[locale]/files/page.tsx b/app/(main)/[locale]/files/page.tsx index 4c3a5fad6..88f24225a 100644 --- a/app/(main)/[locale]/files/page.tsx +++ b/app/(main)/[locale]/files/page.tsx @@ -477,7 +477,7 @@ export default function FilesPage() { onClick={() => router.push("/")} className="justify-start" > - + {t("title")} @@ -611,7 +611,7 @@ export default function FilesPage() { : '0%' }} /> -

+

{migrationProgress.current} / {migrationProgress.total}

diff --git a/app/(main)/[locale]/login/page.tsx b/app/(main)/[locale]/login/page.tsx index 6cdc3838f..77f3cd78b 100644 --- a/app/(main)/[locale]/login/page.tsx +++ b/app/(main)/[locale]/login/page.tsx @@ -133,9 +133,16 @@ export default function LoginPage() { const isMobileHandoff = Boolean(mobileRedirectUri); const { login, loginDemo, isLoading, error, clearError, isAuthenticated } = useAuthStore(); const { theme, setTheme, initializeTheme } = useThemeStore(useShallow((s) => ({ theme: s.theme, setTheme: s.setTheme, initializeTheme: s.initializeTheme }))); - const { appName, jmapServerUrl: configuredServerUrl, oauthEnabled, oauthOnly, oauthClientId: globalOauthClientId, oauthIssuerUrl: globalOauthIssuerUrl, oauthScopes, rememberMeEnabled, devMode, demoMode, loginLogoLightUrl, loginLogoDarkUrl, loginCompanyName, loginImprintUrl, loginPrivacyPolicyUrl, loginWebsiteUrl, isLoading: configLoading, error: configError, autoSsoEnabled, embeddedMode: _embeddedMode, allowCustomJmapEndpoint, jmapServers, jmapServerAutoPickByDomain } = useConfig(); + const { appName, jmapServerUrl: configuredServerUrl, oauthEnabled, oauthOnly, oauthClientId: globalOauthClientId, oauthIssuerUrl: globalOauthIssuerUrl, oauthScopes, rememberMeEnabled, devMode, demoMode, loginLogoLightUrl, loginLogoDarkUrl, loginCompanyName, loginImprintUrl, loginPrivacyPolicyUrl, loginWebsiteUrl, loginLogoMaxHeight, loginLogoMaxWidth, loginShowHeading, loginShowSubtitle, isLoading: configLoading, error: configError, autoSsoEnabled, embeddedMode: _embeddedMode, allowCustomJmapEndpoint, jmapServers, jmapServerAutoPickByDomain } = useConfig(); const resolvedTheme = useThemeStore((s) => s.resolvedTheme); + // Login logo sizing: when a max height/width is configured, drop the fixed + // 64×64 box so the logo (e.g. a wide wordmark) can render at its true size. + const hasLogoSize = Boolean(loginLogoMaxHeight || loginLogoMaxWidth); + const loginLogoStyle = hasLogoSize + ? { maxHeight: loginLogoMaxHeight || undefined, maxWidth: loginLogoMaxWidth || undefined } + : undefined; + const [formData, setFormData] = useState({ username: "", password: "", @@ -718,7 +725,7 @@ export default function LoginPage() { )} > - {option.label} + {option.label} {isActive && } ); @@ -867,7 +874,7 @@ export default function LoginPage() { )} > - {option.label} + {option.label} {isActive && } ); @@ -881,19 +888,24 @@ export default function LoginPage() {
{/* Header section with logo */}
-
+
{appName}
-

- {isAddAccountMode ? t("add_account_title") : appName} -

-

- {isAddAccountMode ? t("add_account_subtitle") : (t("title") !== appName ? t("title") : "Sign in to your account")} -

+ {loginShowHeading && ( +

+ {isAddAccountMode ? t("add_account_title") : appName} +

+ )} + {loginShowSubtitle && ( +

+ {isAddAccountMode ? t("add_account_subtitle") : (t("title") !== appName ? t("title") : "Sign in to your account")} +

+ )}
{/* Form section */} @@ -1121,7 +1133,7 @@ export default function LoginPage() { type={showPassword ? "text" : "password"} value={formData.password} onChange={(e) => setFormData({ ...formData, password: e.target.value })} - className="h-11 px-3.5 pr-11 bg-muted/40 border-border/60 rounded-xl focus:bg-background focus:border-primary/50 transition-all duration-200" + className="h-11 px-3.5 pe-11 bg-muted/40 border-border/60 rounded-xl focus:bg-background focus:border-primary/50 transition-all duration-200" placeholder={t("password_placeholder")} required autoComplete="current-password" @@ -1241,9 +1253,9 @@ export default function LoginPage() { disabled={oauthLoading || isLoading} > {oauthLoading ? ( - + ) : ( - + )} {t("sign_in_sso")} diff --git a/app/(main)/[locale]/page.tsx b/app/(main)/[locale]/page.tsx index 589d53159..5d98c72e6 100644 --- a/app/(main)/[locale]/page.tsx +++ b/app/(main)/[locale]/page.tsx @@ -75,7 +75,7 @@ import { appLifecycleHooks, uiHooks, routerHooks, toastHooks, emailHooks } from import { emailToReadView } from "@/lib/plugin-projection"; import { buildQuoteHeader } from "@/lib/quote-header"; import { buildReplySubject, buildForwardSubject } from "@/lib/subject-prefix"; -import { useLocaleStore } from "@/stores/locale-store"; +import { getEffectiveLocale } from '@/i18n/detect-locale'; import type { QuoteHeader } from "@/lib/plugin-types"; const SCHEDULED_MAILBOX_ID = '__scheduled__'; @@ -121,7 +121,7 @@ export default function Home() { useIdentitySync(); const trustedSendersAddressBook = useSettingsStore((state) => state.trustedSendersAddressBook); const sendDelaySeconds = useSettingsStore((state) => state.sendDelaySeconds); - const { loadTrustedSendersBook, trustedSendersLoaded } = useContactStore(); + const { loadTrustedSendersBook, trustedSendersLoaded, loadRecentRecipients } = useContactStore(); const promptForRescheduleDelayedUntil = useCallback((): string | null => { const value = window.prompt(t('email_viewer.reschedule_prompt')); @@ -342,6 +342,15 @@ export default function Home() { refreshCurrentMailbox, } = useEmailStore(); + // Load recent recipients (from the Sent folder) for compose autocomplete. + // Runs once when the Sent mailbox is known; the store guards against reloads. + useEffect(() => { + const sent = mailboxes.find((m) => m.role === 'sent'); + if (client && sent) { + loadRecentRecipients(client, sent.originalId || sent.id); + } + }, [client, mailboxes, loadRecentRecipients]); + // Pro shell: populate per-account mailbox cache so the sidebar can render // every connected account Thunderbird-style. useProMultiAccountMailboxes(); @@ -612,6 +621,10 @@ export default function Home() { onToggleSpam: async () => { if (isScheduledView) return; const currentMailbox = mailboxes.find(m => m.id === selectedMailbox); + // Marking your own outgoing mail as spam makes no sense - the toolbar + // and menus hide the action in Sent/Drafts/Scheduled, so the shortcut + // is a no-op there too. + if (['sent', 'drafts', 'scheduled'].includes(currentMailbox?.role || '')) return; const isInJunk = currentMailbox?.role === 'junk'; if (selectedEmailIds.size > 0 && client) { const ids = Array.from(selectedEmailIds); @@ -1172,18 +1185,22 @@ export default function Home() { return; } - // Mark the original email with $answered or $forwarded keyword - if (originalEmailId && (effectiveMode === 'reply' || effectiveMode === 'replyAll')) { + // Mark the original email with $answered or $forwarded keyword. Route the + // write to the email's own account so the flag lands on shared/group-mailbox + // messages instead of being dropped against the reaching account. (#281) + if (originalEmailId && (effectiveMode === 'reply' || effectiveMode === 'replyAll' || effectiveMode === 'forward')) { + const s = useEmailStore.getState(); + const orig = s.emails.find(e => e.id === originalEmailId); + const kwClientId = s.isUnifiedView ? orig?.sourceClientAccountId : undefined; + const kwAccountId = s.isUnifiedView ? orig?.sourceAccountId : undefined; + const kwClient = kwClientId + ? (useAuthStore.getState().getClientForAccount(kwClientId) ?? client) + : client; + const keyword = effectiveMode === 'forward' ? '$forwarded' : '$answered'; try { - await client.setKeyword(originalEmailId, '$answered'); + await kwClient.setKeyword(originalEmailId, keyword, kwAccountId); } catch (e) { - debug.error('Failed to set $answered keyword:', e); - } - } else if (originalEmailId && effectiveMode === 'forward') { - try { - await client.setKeyword(originalEmailId, '$forwarded'); - } catch (e) { - debug.error('Failed to set $forwarded keyword:', e); + debug.error(`Failed to set ${keyword} keyword:`, e); } } @@ -1263,7 +1280,7 @@ export default function Home() { }, newTo, newCc, - locale: useLocaleStore.getState().locale, + locale: getEffectiveLocale(), timeFormat: useSettingsStore.getState().timeFormat, unknownLabel: tCommon('unknown'), labels: { @@ -1635,6 +1652,45 @@ export default function Home() { } }; + const handleTogglePinned = async (emailToPin: Email) => { + if (!client) return; + + try { + const email = emails.find(e => e.id === emailToPin.id) ?? emailToPin; + const isPinned = email.keywords?.['$pinned'] === true; + // JMAP keywords are a set of present keys - drop the key to unpin + // rather than writing a false value. + const keywords = { ...email.keywords }; + if (isPinned) { + delete keywords['$pinned']; + } else { + keywords['$pinned'] = true; + } + + // Same unified-view routing as color tags: write to the email's own + // account via the login it is reachable through. (#281) + const pinClientId = isUnifiedView ? email.sourceClientAccountId : undefined; + const pinAccountId = isUnifiedView ? email.sourceAccountId : undefined; + const pinClient = pinClientId + ? (useAuthStore.getState().getClientForAccount(pinClientId) ?? client) + : client; + + await pinClient.updateEmailKeywords(email.id, keywords, pinAccountId); + + // Patch in place so the icon flips immediately, then refetch the first + // page so the mail floats/sinks per the server's pinned-first sort. + // Skip the refetch where that sort does not apply (unified views) or + // where it would replace a tag-filtered list (refreshCurrentMailbox + // fetches by folder only). + setEmailKeywordsLocal(email.id, keywords); + if (!isUnifiedView && !useEmailStore.getState().selectedKeyword) { + void refreshCurrentMailbox(client); + } + } catch (error) { + console.error("Failed to toggle pin:", error); + } + }; + const handleSetColorTag = async (emailId: string, color: string | null) => { if (!client) return; @@ -1663,8 +1719,20 @@ export default function Home() { } } + // In unified view route the write to the email's own account, reached + // through the login it is reachable via (`sourceClientAccountId`) and + // applied to its owning JMAP account (`sourceAccountId`). For personal + // sources these resolve to the account itself, so behavior is unchanged. + // Without this, tags on shared/group-mailbox messages are written to the + // reaching account and silently dropped by the server. (#281) + const tagClientId = isUnifiedView ? email.sourceClientAccountId : undefined; + const tagAccountId = isUnifiedView ? email.sourceAccountId : undefined; + const tagClient = tagClientId + ? (useAuthStore.getState().getClientForAccount(tagClientId) ?? client) + : client; + // Update email keywords via JMAP - await client.updateEmailKeywords(emailId, keywords); + await tagClient.updateEmailKeywords(emailId, keywords, tagAccountId); // Patch the email in place so the list keeps its scroll/pagination state // instead of being reset to the first page by a full refetch. @@ -1702,7 +1770,15 @@ export default function Home() { setTabletListVisible(true); } if (viewingClient) { - await fetchEmails(viewingClient, mailboxId); + // Keep an active search applied when switching folders (#553); the + // store actions resolve the viewing account's client internally. + if (!isFilterEmpty(searchFilters)) { + await advancedSearch(viewingClient); + } else if (searchQuery) { + await searchEmails(viewingClient, searchQuery); + } else { + await fetchEmails(viewingClient, mailboxId); + } } }; @@ -1792,8 +1868,13 @@ export default function Home() { } if (client) { - // If there's an active search, re-run it in the new mailbox - if (searchQuery) { + // If there's an active search, re-run it in the new mailbox. Advanced + // filters must go through advancedSearch (which also includes the text + // query) — falling back to fetchEmails would silently drop them while + // the UI still shows them as active (#553). + if (!isFilterEmpty(searchFilters)) { + await advancedSearch(client); + } else if (searchQuery) { await searchEmails(client, searchQuery); } else { await fetchEmails(client, mailboxId); @@ -2271,11 +2352,22 @@ export default function Home() { return; } - // Mark the original email as answered - try { - await client.setKeyword(originalEmailId, '$answered'); - } catch (e) { - debug.error('Failed to set $answered keyword:', e); + // Mark the original email as answered. Route the write to the email's own + // account so the flag lands on shared/group-mailbox messages instead of + // being dropped against the reaching account. (#281) + { + const s = useEmailStore.getState(); + const orig = s.emails.find(e => e.id === originalEmailId); + const kwClientId = s.isUnifiedView ? orig?.sourceClientAccountId : undefined; + const kwAccountId = s.isUnifiedView ? orig?.sourceAccountId : undefined; + const kwClient = kwClientId + ? (useAuthStore.getState().getClientForAccount(kwClientId) ?? client) + : client; + try { + await kwClient.setKeyword(originalEmailId, '$answered', kwAccountId); + } catch (e) { + debug.error('Failed to set $answered keyword:', e); + } } // Refresh emails to show the sent reply @@ -2667,17 +2759,19 @@ export default function Home() {
@@ -2987,6 +3081,9 @@ export default function Home() { await toggleStar(client, email.id); } }} + onTogglePinned={async (email) => { + await handleTogglePinned(email); + }} onDelete={async (email) => { await handleDelete(email); }} @@ -3025,7 +3122,7 @@ export default function Home() { }} className={cn( "absolute z-40 rounded-full shadow-lg", - isMobile ? "bottom-4 right-4 h-14 w-14" : "bottom-4 right-4 h-12 w-12" + isMobile ? "bottom-4 end-4 h-14 w-14" : "bottom-4 end-4 h-12 w-12" )} aria-label={t('sidebar.compose')} title={t('sidebar.compose_hint')} @@ -3084,6 +3181,11 @@ export default function Home() {
{t('email_composer.continue_draft')} {pendingDraft.subject && ( - {pendingDraft.subject} + {pendingDraft.subject} )}
s.focusedPaneId); const loadedTabIds = useProTabStore((s) => s.loadedTabIds); const openTab = useProTabStore((s) => s.openTab); - const closeTab = useProTabStore((s) => s.closeTab); + const requestCloseTab = useProTabStore((s) => s.requestCloseTab); const setActiveTab = useProTabStore((s) => s.setActiveTab); const setFocusedPane = useProTabStore((s) => s.setFocusedPane); const moveTabToPane = useProTabStore((s) => s.moveTabToPane); @@ -354,7 +354,7 @@ export default function ProHome() { activeMainTabId={activeMainTabId} activeSplitTabId={activeSplitTabId} onActivate={setActiveTab} - onClose={closeTab} + onClose={requestCloseTab} onDragStateChange={setIsTabDragging} /> diff --git a/app/(main)/[locale]/settings/page.tsx b/app/(main)/[locale]/settings/page.tsx index f2137685d..55d748eaf 100644 --- a/app/(main)/[locale]/settings/page.tsx +++ b/app/(main)/[locale]/settings/page.tsx @@ -21,7 +21,6 @@ import { Tags, HardDrive, BookUser, - KeyRound, PanelLeftClose, Bell, Puzzle, @@ -63,7 +62,6 @@ import { AccountSecuritySettings } from '@/components/settings/account-security- import { FilesSettingsComponent } from '@/components/settings/files-settings'; import { DownloadsSettings } from '@/components/settings/downloads-settings'; import { ContactsSettings } from '@/components/settings/contacts-settings'; -import { SmimeSettings } from '@/components/settings/smime-settings'; import { SidebarAppsSettings } from '@/components/settings/sidebar-apps-settings'; import { NotificationSettings } from '@/components/settings/notification-settings'; import { ThemesSettings } from '@/components/settings/themes-settings'; @@ -102,7 +100,6 @@ type Tab = | 'folders' | 'keywords' | 'security' - | 'encryption' | 'content_senders' | 'calendar' | 'contacts' @@ -139,7 +136,6 @@ const tabIcons: Record = { folders: FolderOpen, keywords: Tags, security: Shield, - encryption: KeyRound, content_senders: EyeOff, calendar: Calendar, contacts: BookUser, @@ -217,7 +213,6 @@ const tabSearchPaths: Record = { folders: ['settings.folders'], keywords: ['settings.keywords'], security: ['settings.security'], - encryption: ['smime'], content_senders: [ 'settings.email_behavior.always_light_mode', 'settings.email_behavior.external_content', @@ -252,7 +247,6 @@ const tabKeywords: Record = { folders: 'mailbox subscribe', keywords: 'tags labels colors', security: 'password 2fa two-factor passkey app password mfa', - encryption: 's/mime smime certificate pgp gpg', content_senders: 'block sender remote images privacy tracking', calendar: 'event schedule appointment meeting timezone', contacts: 'address book contact', @@ -623,7 +617,6 @@ export default function SettingsPage() { // Privacy & Security ...(stalwartFeaturesEnabled ? [{ id: 'security' as Tab, label: t('tabs.security'), icon: tabIcons.security, group: 'privacy' as TabGroup }] : []), - ...(isFeatureEnabled('smimeEnabled') ? [{ id: 'encryption' as Tab, label: t('tabs.encryption'), icon: tabIcons.encryption, group: 'privacy' as TabGroup }] : []), { id: 'content_senders', label: t('tabs.content_senders'), icon: tabIcons.content_senders, group: 'privacy' }, // Apps @@ -719,11 +712,11 @@ export default function SettingsPage() { clearManagedAccount(); handleTabSelect('account'); }} - className="flex items-center gap-2 w-full mb-4 px-3 py-2 rounded-md border border-border bg-muted/40 hover:bg-muted text-left transition-colors" + className="flex items-center gap-2 w-full mb-4 px-3 py-2 rounded-md border border-border bg-muted/40 hover:bg-muted text-start transition-colors" > {t('scoped.back')} - + {t('scoped.managing', { name: managedAccount.name })} @@ -743,7 +736,6 @@ export default function SettingsPage() { {effectiveActiveTab === 'folders' && } {effectiveActiveTab === 'keywords' && } {effectiveActiveTab === 'security' && } - {effectiveActiveTab === 'encryption' && } {effectiveActiveTab === 'content_senders' && } {effectiveActiveTab === 'calendar' && ( managedAccountId @@ -828,7 +820,7 @@ export default function SettingsPage() { value={searchQuery} onChange={(e) => setSearchQuery(e.target.value)} placeholder={t('search_placeholder')} - className="pl-9 pr-9 h-10" + className="ps-9 pe-9 h-10" aria-label={t('search_placeholder')} /> {searchQuery && ( @@ -876,7 +868,7 @@ export default function SettingsPage() { @@ -940,7 +932,7 @@ export default function SettingsPage() { <>
router.push('/')} className="w-full justify-start" > - + {t('back_to_mail')}
@@ -968,7 +960,7 @@ export default function SettingsPage() { value={searchQuery} onChange={(e) => setSearchQuery(e.target.value)} placeholder={t('search_placeholder')} - className="pl-8 pr-8 h-9 text-sm" + className="ps-8 pe-8 h-9 text-sm" aria-label={t('search_placeholder')} /> {searchQuery && ( @@ -1005,7 +997,7 @@ export default function SettingsPage() { diff --git a/app/(main)/admin/_tabs/_jmap-servers-section.tsx b/app/(main)/admin/_tabs/_jmap-servers-section.tsx index 638e9feee..ef4e00ac2 100644 --- a/app/(main)/admin/_tabs/_jmap-servers-section.tsx +++ b/app/(main)/admin/_tabs/_jmap-servers-section.tsx @@ -220,7 +220,7 @@ export function JmapServersSection({ value, source, onChange, onRevert }: Props) Per-server OAuth (optional, overrides global) {d.oauthExpanded && ( -
+
- Time - Action - Details - IP + Time + Action + Details + IP diff --git a/app/(main)/admin/_tabs/marketplace.tsx b/app/(main)/admin/_tabs/marketplace.tsx index 64fda7631..6bf82c389 100644 --- a/app/(main)/admin/_tabs/marketplace.tsx +++ b/app/(main)/admin/_tabs/marketplace.tsx @@ -171,7 +171,7 @@ export function MarketplaceTab() { placeholder="Search extensions..." value={searchInput} onChange={(e) => setSearchInput(e.target.value)} - className="w-full h-9 pl-9 pr-3 rounded-md border border-input bg-background text-sm text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring/20 focus:border-ring" + className="w-full h-9 ps-9 pe-3 rounded-md border border-input bg-background text-sm text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring/20 focus:border-ring" />
@@ -210,7 +210,7 @@ export function MarketplaceTab() { {loading && !error && (
- Searching extensions... + Searching extensions...
)} diff --git a/app/(main)/admin/_tabs/plugin-config-panel.tsx b/app/(main)/admin/_tabs/plugin-config-panel.tsx index 70e83cb74..2cc5fe7fd 100644 --- a/app/(main)/admin/_tabs/plugin-config-panel.tsx +++ b/app/(main)/admin/_tabs/plugin-config-panel.tsx @@ -155,7 +155,7 @@ export function PluginConfigPanel({ pluginId, onBack }: Props) { if (loading) { return (
- + Loading...
); @@ -217,7 +217,7 @@ export function PluginConfigPanel({ pluginId, onBack }: Props) {
{field.description && (

{field.description}

@@ -250,7 +250,7 @@ export function PluginConfigPanel({ pluginId, onBack }: Props) { value={formValues[key] ?? ''} onChange={(e) => setFormValues(prev => ({ ...prev, [key]: e.target.value }))} placeholder={config[key] ? '•••••••• (unchanged)' : (field.placeholder || '')} - className="w-full h-9 px-3 pr-10 rounded-md border border-input bg-background text-sm focus:outline-none focus:ring-2 focus:ring-ring font-mono" + className="w-full h-9 px-3 pe-10 rounded-md border border-input bg-background text-sm focus:outline-none focus:ring-2 focus:ring-ring font-mono" />
{logoUrl ? ( - + ) : ( - + )} Admin Panel
diff --git a/app/(main)/admin/marketplace/[slug]/page.tsx b/app/(main)/admin/marketplace/[slug]/page.tsx index e8095b05a..d601e66c1 100644 --- a/app/(main)/admin/marketplace/[slug]/page.tsx +++ b/app/(main)/admin/marketplace/[slug]/page.tsx @@ -186,7 +186,7 @@ export default function MarketplacePreviewPage() { if (loading) { return (
- + Loading...
); @@ -512,7 +512,7 @@ export default function MarketplacePreviewPage() {
diff --git a/app/(main)/layout.tsx b/app/(main)/layout.tsx index 65d4bba17..04a869633 100644 --- a/app/(main)/layout.tsx +++ b/app/(main)/layout.tsx @@ -1,4 +1,5 @@ import type { Metadata, Viewport } from "next"; +import { getLocaleDirection } from "@/i18n/direction"; import { Geist, Geist_Mono } from "next/font/google"; import { headers } from "next/headers"; import { getLocale, getTranslations } from "next-intl/server"; @@ -76,7 +77,7 @@ export default async function RootLayout({ const parentOrigin = process.env.NEXT_PUBLIC_PARENT_ORIGIN || ""; return ( - + diff --git a/app/(main)/setup/page.tsx b/app/(main)/setup/page.tsx index 6f3fd608e..299715c5e 100644 --- a/app/(main)/setup/page.tsx +++ b/app/(main)/setup/page.tsx @@ -780,7 +780,7 @@ function ServerStep({ config, setConfig, onNext }: Pick
-
{showUrlField && ( -
+
{uploadError}

+

{uploadError}

)}
); @@ -1601,7 +1601,7 @@ function SummaryRow({ label, value, mono }: { label: string; value: string; mono return (
{label} - + {value || -}
diff --git a/app/(sandbox)/plugin-sandbox-privileged/page.tsx b/app/(sandbox)/plugin-sandbox-privileged/page.tsx new file mode 100644 index 000000000..0f3d431d3 --- /dev/null +++ b/app/(sandbox)/plugin-sandbox-privileged/page.tsx @@ -0,0 +1,15 @@ +import { SandboxRuntime } from '@/lib/plugin-sandbox/runtime'; + +// Privileged-tier sandbox route. Identical runtime to /plugin-sandbox, but the +// host loads it into a same-origin (`allow-same-origin`) iframe so the bundle +// gets real `crypto.subtle` + IndexedDB. The trust gate (signature + admin +// approval) is enforced host-side before this route is ever framed; the page +// itself carries no extra privilege. +// +// Must be dynamic so the per-request CSP nonce from proxy.ts is embedded in +// Next's injected hydration/chunk scripts. +export const dynamic = 'force-dynamic'; + +export default function PrivilegedPluginSandboxPage() { + return ; +} diff --git a/app/api/admin/marketplace/route.ts b/app/api/admin/marketplace/route.ts index d44887ce4..b60dd6a66 100644 --- a/app/api/admin/marketplace/route.ts +++ b/app/api/admin/marketplace/route.ts @@ -341,6 +341,7 @@ export async function POST(request: NextRequest) { author: (manifest.author as string) || 'Unknown', description: (manifest.description as string) || '', type: (manifest.type as string) || 'hook', + ...(manifest.tier === 'privileged' ? { tier: 'privileged' } : {}), permissions, entrypoint, enabled: existingPlugin?.enabled ?? true, diff --git a/app/api/admin/plugins/route.ts b/app/api/admin/plugins/route.ts index 065627019..a62c4bf81 100644 --- a/app/api/admin/plugins/route.ts +++ b/app/api/admin/plugins/route.ts @@ -183,6 +183,7 @@ export async function POST(request: NextRequest) { author: manifest.author as string, description: (manifest.description as string) || '', type: manifest.type as string, + ...(manifest.tier === 'privileged' ? { tier: 'privileged' } : {}), permissions: (manifest.permissions as string[]) || [], entrypoint: manifest.entrypoint as string, enabled: true, diff --git a/app/api/auth/token/route.ts b/app/api/auth/token/route.ts index e734ba714..167689560 100644 --- a/app/api/auth/token/route.ts +++ b/app/api/auth/token/route.ts @@ -82,9 +82,16 @@ export async function PUT(request: NextRequest) { if (!tokenResponse.ok) { const errorText = await tokenResponse.text(); logger.error('Token refresh failed', { status: tokenResponse.status, error: errorText }); - cookieStore.delete(cookieName); - cookieStore.delete(refreshTokenServerCookieName(slot)); - return NextResponse.json({ error: 'Refresh failed' }, { status: 401 }); + // Drop the refresh token only when the server definitively rejected it + // (invalid/expired/revoked grant). A 5xx or 429 is an outage - keeping + // the cookie lets the session resume once the server is back. + const status = tokenResponse.status; + if (status === 400 || status === 401 || status === 403) { + cookieStore.delete(cookieName); + cookieStore.delete(refreshTokenServerCookieName(slot)); + return NextResponse.json({ error: 'Refresh failed' }, { status: 401 }); + } + return NextResponse.json({ error: 'Token endpoint unavailable' }, { status: 503 }); } const tokens = await tokenResponse.json(); diff --git a/app/api/auth/totp-token-exchange/route.ts b/app/api/auth/totp-token-exchange/route.ts index 11fa1a6b0..6e5b87c41 100644 --- a/app/api/auth/totp-token-exchange/route.ts +++ b/app/api/auth/totp-token-exchange/route.ts @@ -1,8 +1,6 @@ import { NextRequest, NextResponse } from 'next/server'; import { cookies } from 'next/headers'; import { logger } from '@/lib/logger'; -import { discoverOAuth } from '@/lib/oauth/discovery'; -import { getDiscoveryValidator } from '@/lib/oauth/token-exchange'; import { refreshTokenCookieName, refreshTokenServerCookieName } from '@/lib/oauth/tokens'; import { getCookieOptions } from '@/lib/oauth/cookie-config'; import { readFileEnv } from '@/lib/read-file-env'; @@ -11,81 +9,173 @@ import { isPublicHttpUrl } from '@/lib/security/url-guard'; import { recordLogin } from '@/lib/telemetry/login-tracker'; import { parseJmapServers, findServerByUrl, findServerById } from '@/lib/admin/jmap-servers'; import { MAX_ACCOUNT_SLOTS } from '@/lib/account-utils'; +import { generateCodeVerifier, generateCodeChallenge } from '@/lib/oauth/pkce'; /** - * Exchange basic auth credentials (with TOTP appended) for OAuth tokens. + * Exchange a password + (optional) TOTP code for OAuth tokens. * - * This allows 2FA users who log in with basic auth + TOTP to upgrade - * to token-based auth, avoiding session expiry when the TOTP rotates. + * Stalwart 0.16+ no longer accepts the legacy `password$totp` convention over + * HTTP Basic auth: its Basic decoder hardcodes `mfa_token: None` and never + * splits the secret on `$`, so any TOTP appended to the password is verified + * verbatim against the password hash and fails. The MFA token must instead be + * supplied as a distinct field through the structured login endpoint. * - * Tries three strategies: - * 1. ROPC grant with client_id (if OAUTH_CLIENT_ID is set) - * 2. ROPC grant without client_id - * 3. ROPC grant authenticated via Basic Auth header (Stalwart-style) + * This route drives that flow server-side (avoiding browser CORS against the + * mail server, same as OAuth discovery): + * 1. POST {serverUrl}/api/auth -> authenticate with a separate `mfaToken`, + * receiving a short-lived authorization `clientCode`. + * 2. POST {serverUrl}/auth/token (grant_type=authorization_code) -> exchange + * the code (with PKCE) for access/refresh tokens. + * + * Token-based auth also survives TOTP rotation, unlike basic auth which embeds + * the (≈30s) code in every request. */ -async function tryTokenRequest( - tokenEndpoint: string, - params: URLSearchParams, - extraHeaders?: Record, -): Promise<{ ok: true; tokens: { access_token: string; expires_in?: number; refresh_token?: string } } | { ok: false; status: number; error: string }> { +// Fallback OAuth client id used when no client is configured. Stalwart accepts +// any client id unless `require_client_registration` is enabled (default off); +// when it is enabled the admin must configure `oauthClientId` with this +// redirect URI registered. +const DEFAULT_CLIENT_ID = 'bulwark-webmail'; + +interface LoginResult { + type?: string; + // The response keeps snake_case: only the LoginResponse variant *tags* are + // camelCased server-side, not the struct fields (the request fields are). + client_code?: string; +} + +function trimUrl(url: string): string { + return url.replace(/\/+$/, ''); +} + +async function attemptLogin( + upstreamUrl: string, + username: string, + password: string, + totp: string | undefined, + redirectUri: string, + slot: number, + serverId: string | null, +): Promise { + const base = trimUrl(upstreamUrl); + + // Per-server OAuth credentials override the global ones when the requested + // server entry has its own oauth block configured. + const serverList = parseJmapServers(configManager.get('jmapServers', [])); + const entry = findServerById(serverList, serverId); + const clientId = entry?.oauth?.clientId + || configManager.get('oauthClientId', '') + || process.env.OAUTH_CLIENT_ID + || DEFAULT_CLIENT_ID; + const clientSecret = entry?.oauth?.clientSecret + || configManager.get('oauthClientSecret', '') + || process.env.OAUTH_CLIENT_SECRET + || readFileEnv(process.env.OAUTH_CLIENT_SECRET_FILE) + || ''; + + // PKCE proves the token exchange originates from the same client that + // initiated the login, so no client secret is required for public clients. + const verifier = generateCodeVerifier(); + const challenge = await generateCodeChallenge(verifier); + + // Step 1: structured login with a separate MFA token. + let login: LoginResult; try { - const headers: Record = { 'Content-Type': 'application/x-www-form-urlencoded', ...extraHeaders }; - const response = await fetch(tokenEndpoint, { + const loginResponse = await fetch(`${base}/api/auth`, { method: 'POST', - headers, - body: params.toString(), + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + type: 'authCode', + accountName: username, + accountSecret: password, + ...(totp ? { mfaToken: totp } : {}), + clientId, + redirectUri, + codeChallenge: challenge, + codeChallengeMethod: 'S256', + }), }); - if (!response.ok) { - const errorText = await response.text(); - return { ok: false, status: response.status, error: errorText.substring(0, 500) }; + if (!loginResponse.ok) { + const detail = (await loginResponse.text()).substring(0, 500); + logger.warn('TOTP login: /api/auth rejected request', { status: loginResponse.status }); + // A 404 means the server predates the structured login endpoint; let the + // caller fall back to the legacy basic-auth path. + return NextResponse.json( + { error: loginResponse.status === 404 ? 'login_endpoint_missing' : 'login_failed', detail }, + { status: loginResponse.status === 404 ? 404 : 502 }, + ); } - const tokens = await response.json(); - if (!tokens.access_token) { - return { ok: false, status: 502, error: 'Response missing access_token' }; + login = await loginResponse.json(); + } catch (err) { + logger.warn('TOTP login: /api/auth request failed', { error: err instanceof Error ? err.message : String(err) }); + return NextResponse.json({ error: 'login_unreachable' }, { status: 502 }); + } + + switch (login.type) { + case 'authenticated': + break; + case 'mfaRequired': + return NextResponse.json({ error: 'totp_required' }, { status: 401 }); + case 'failure': + default: + return NextResponse.json({ error: 'invalid_credentials' }, { status: 401 }); + } + + if (!login.client_code) { + logger.warn('TOTP login: authenticated response missing client_code'); + return NextResponse.json({ error: 'login_failed' }, { status: 502 }); + } + + // Step 2: exchange the authorization code for tokens. + const tokenParams = new URLSearchParams({ + grant_type: 'authorization_code', + code: login.client_code, + client_id: clientId, + redirect_uri: redirectUri, + code_verifier: verifier, + }); + // Confidential clients still send their secret; harmless for public clients. + if (clientSecret) tokenParams.set('client_secret', clientSecret); + + let tokens: { access_token?: string; expires_in?: number; refresh_token?: string }; + try { + const tokenResponse = await fetch(`${base}/auth/token`, { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: tokenParams.toString(), + }); + + if (!tokenResponse.ok) { + const detail = (await tokenResponse.text()).substring(0, 500); + logger.warn('TOTP login: token exchange failed', { status: tokenResponse.status, detail }); + return NextResponse.json({ error: 'token_exchange_failed', detail }, { status: 502 }); } - return { ok: true, tokens }; + tokens = await tokenResponse.json(); } catch (err) { - return { ok: false, status: 0, error: err instanceof Error ? err.message : String(err) }; + logger.warn('TOTP login: token endpoint failed', { error: err instanceof Error ? err.message : String(err) }); + return NextResponse.json({ error: 'token_exchange_failed' }, { status: 502 }); } -} -async function findTokenEndpoint(serverUrl: string, adminTrusted: boolean): Promise { - // Admin-trusted callers (matched server entry or configured JMAP server URL) - // honor the `oauthAllowPrivateEndpoints` opt-in. User-supplied URLs always - // go through the SSRF validator regardless of the setting. - const validateEndpoint = adminTrusted ? getDiscoveryValidator() : isPublicHttpUrl; - // 1. Try OAuth discovery - const metadata = await discoverOAuth(serverUrl, { validateEndpoint }); - if (metadata?.token_endpoint) return metadata.token_endpoint; - - // 2. Try common Stalwart token endpoint paths directly - const candidates = [ - `${serverUrl}/auth/token`, - `${serverUrl}/api/oauth/token`, - ]; - - for (const url of candidates) { - try { - // A POST with no body should return 400 (bad request) rather than 404 if the endpoint exists - const probe = await fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body: 'grant_type=probe' }); - if (probe.status !== 404 && probe.status !== 405) { - return url; - } - } catch { - // Network error - endpoint not reachable - } + if (!tokens.access_token) { + return NextResponse.json({ error: 'token_exchange_failed', detail: 'Response missing access_token' }, { status: 502 }); } - return null; + logger.info('TOTP login succeeded'); + void recordLogin(username, base); + return await storeAndRespond( + { access_token: tokens.access_token, expires_in: tokens.expires_in, refresh_token: tokens.refresh_token }, + slot, + serverId, + ); } export async function POST(request: NextRequest) { try { - const { serverUrl, username, password, slot: bodySlot, server_id: bodyServerId } = await request.json(); + const { serverUrl, username, password, totp, slot: bodySlot, server_id: bodyServerId, redirectUri: bodyRedirectUri } = + await request.json(); if (!serverUrl || !username || !password) { return NextResponse.json({ error: 'Missing required parameters' }, { status: 400 }); @@ -93,6 +183,7 @@ export async function POST(request: NextRequest) { const slot = typeof bodySlot === 'number' && bodySlot >= 0 && bodySlot < MAX_ACCOUNT_SLOTS ? bodySlot : 0; const requestedServerId = typeof bodyServerId === 'string' && bodyServerId ? bodyServerId : null; + const totpCode = typeof totp === 'string' && totp ? totp : undefined; // Pin the upstream URL to a configured JMAP server. The list of allowed // servers is `jmapServerUrl` plus any entry from `jmapServers`. Only when @@ -110,20 +201,17 @@ export async function POST(request: NextRequest) { let upstreamUrl: string; let resolvedServerId: string | null = null; - let adminTrusted = false; const requestedEntry = findServerById(serverList, requestedServerId); const matchedEntry = requestedEntry || findServerByUrl(serverList, serverUrl); if (matchedEntry) { upstreamUrl = matchedEntry.url; resolvedServerId = matchedEntry.id; - adminTrusted = true; } else if (configuredServerUrl) { upstreamUrl = configuredServerUrl; - adminTrusted = true; } else if (allowCustomEndpoint) { if (!(await isPublicHttpUrl(serverUrl))) { - logger.warn('TOTP token exchange: rejected non-public server URL'); + logger.warn('TOTP login: rejected non-public server URL'); return NextResponse.json({ error: 'invalid_server_url' }, { status: 400 }); } upstreamUrl = serverUrl; @@ -131,100 +219,22 @@ export async function POST(request: NextRequest) { return NextResponse.json({ error: 'jmap_server_not_configured' }, { status: 500 }); } - const tokenEndpoint = await findTokenEndpoint(upstreamUrl, adminTrusted); - if (!tokenEndpoint) { - logger.warn('TOTP token exchange: no token endpoint found'); - return NextResponse.json({ error: 'no_token_endpoint', detail: 'Could not discover OAuth token endpoint on the mail server' }, { status: 404 }); - } + // The redirect URI must be identical in the login and token-exchange steps, + // and (when require_client_registration is on) registered for the client. + // Prefer the browser-supplied callback URL the OAuth client already uses; + // fall back to the upstream URL so the two steps still agree. + const redirectUri = + typeof bodyRedirectUri === 'string' && /^https?:\/\//.test(bodyRedirectUri) + ? bodyRedirectUri + : trimUrl(upstreamUrl); - return await attemptAllStrategies(tokenEndpoint, upstreamUrl, username, password, slot, resolvedServerId); + return await attemptLogin(upstreamUrl, username, password, totpCode, redirectUri, slot, resolvedServerId); } catch (error) { - logger.error('TOTP token exchange error', { error: error instanceof Error ? error.message : 'Unknown error' }); + logger.error('TOTP login error', { error: error instanceof Error ? error.message : 'Unknown error' }); return NextResponse.json({ error: 'Internal server error' }, { status: 500 }); } } -async function attemptAllStrategies( - tokenEndpoint: string, - serverUrl: string, - username: string, - password: string, - slot: number, - serverId: string | null, -): Promise { - logger.info('TOTP token exchange: found token endpoint', { tokenEndpoint }); - - // Per-server OAuth credentials override the global ones when the requested - // server entry has its own oauth block configured. - const serverList = parseJmapServers(configManager.get('jmapServers', [])); - const entry = findServerById(serverList, serverId); - const clientId = entry?.oauth?.clientId - || configManager.get('oauthClientId', '') - || process.env.OAUTH_CLIENT_ID; - const clientSecret = entry?.oauth?.clientSecret - || configManager.get('oauthClientSecret', '') - || process.env.OAUTH_CLIENT_SECRET - || readFileEnv(process.env.OAUTH_CLIENT_SECRET_FILE); - const basicAuth = `Basic ${Buffer.from(`${username}:${password}`).toString('base64')}`; - const attempts: Array<{ strategy: string; error: string }> = []; - - // Strategy 1: ROPC with client_id (if configured) - if (clientId) { - const params = new URLSearchParams({ grant_type: 'password', username, password, client_id: clientId }); - if (clientSecret) params.set('client_secret', clientSecret); - const result = await tryTokenRequest(tokenEndpoint, params); - if (result.ok) { - logger.info('TOTP token exchange succeeded (ROPC with client_id)'); - void recordLogin(username, serverUrl); - return await storeAndRespond(result.tokens, slot, serverId); - } - attempts.push({ strategy: 'ROPC with client_id', error: result.error }); - } - - // Strategy 2: ROPC without client_id - { - const params = new URLSearchParams({ grant_type: 'password', username, password }); - const result = await tryTokenRequest(tokenEndpoint, params); - if (result.ok) { - logger.info('TOTP token exchange succeeded (ROPC without client_id)'); - void recordLogin(username, serverUrl); - return await storeAndRespond(result.tokens, slot, serverId); - } - attempts.push({ strategy: 'ROPC without client_id', error: result.error }); - } - - // Strategy 3: Basic Auth header on token endpoint (some servers accept this) - { - const params = new URLSearchParams({ grant_type: 'password' }); - const result = await tryTokenRequest(tokenEndpoint, params, { 'Authorization': basicAuth }); - if (result.ok) { - logger.info('TOTP token exchange succeeded (Basic Auth header)'); - void recordLogin(username, serverUrl); - return await storeAndRespond(result.tokens, slot, serverId); - } - attempts.push({ strategy: 'Basic Auth header', error: result.error }); - } - - // Strategy 4: client_credentials with Basic Auth (last resort) - { - const params = new URLSearchParams({ grant_type: 'client_credentials' }); - const result = await tryTokenRequest(tokenEndpoint, params, { 'Authorization': basicAuth }); - if (result.ok) { - logger.info('TOTP token exchange succeeded (client_credentials + Basic Auth)'); - void recordLogin(username, serverUrl); - return await storeAndRespond(result.tokens, slot, serverId); - } - attempts.push({ strategy: 'client_credentials + Basic Auth', error: result.error }); - } - - logger.warn('TOTP token exchange: all strategies failed', { attempts }); - return NextResponse.json({ - error: 'token_exchange_failed', - detail: 'All token exchange strategies failed', - attempts, - }, { status: 502 }); -} - async function storeAndRespond( tokens: { access_token: string; expires_in?: number; refresh_token?: string }, slot: number, diff --git a/app/api/config/route.ts b/app/api/config/route.ts index d0c792557..d9a7efd7c 100644 --- a/app/api/config/route.ts +++ b/app/api/config/route.ts @@ -74,6 +74,10 @@ export async function GET(request: NextRequest) { loginImprintUrl: branded('loginImprintUrl', ''), loginPrivacyPolicyUrl: branded('loginPrivacyPolicyUrl', ''), loginWebsiteUrl: branded('loginWebsiteUrl', ''), + loginLogoMaxHeight: configManager.get('loginLogoMaxHeight', ''), + loginLogoMaxWidth: configManager.get('loginLogoMaxWidth', ''), + loginShowHeading: configManager.get('loginShowHeading', true), + loginShowSubtitle: configManager.get('loginShowSubtitle', true), demoMode: configManager.get('demoMode', false), allowCustomJmapEndpoint: configManager.get('allowCustomJmapEndpoint', false), jmapServers: redactJmapServers(parseJmapServers(configManager.get('jmapServers', []))), diff --git a/app/api/dev-jmap/[...path]/route.ts b/app/api/dev-jmap/[...path]/route.ts index 351c3c721..faec69686 100644 --- a/app/api/dev-jmap/[...path]/route.ts +++ b/app/api/dev-jmap/[...path]/route.ts @@ -1679,7 +1679,8 @@ function handleEmailSubmissionGet(args: MethodArgs, callId: string): MethodResul } function handleQuotaGet(_args: MethodArgs, callId: string): MethodResult { - return ['Quota/get', { accountId: ACCOUNT_ID, state: nextState(), list: [{ resourceType: 'mail', scope: 'mail', used: 52428800, hardLimit: 1073741824 }], notFound: [] }, callId]; + // mirroring Stalwart: resourceType "octets", scope "account" + return ['Quota/get', { accountId: ACCOUNT_ID, state: nextState(), list: [{ id: 'quota-1', resourceType: 'octets', scope: 'account', types: ['Email', 'SieveScript'], used: 52428800, hardLimit: 1073741824 }], notFound: [] }, callId]; } function handleVacationResponseGet(_args: MethodArgs, callId: string): MethodResult { diff --git a/app/api/plugins/route.ts b/app/api/plugins/route.ts index c6789a169..3cb3a63fc 100644 --- a/app/api/plugins/route.ts +++ b/app/api/plugins/route.ts @@ -37,6 +37,9 @@ export async function GET() { author: p.author, description: p.description, type: p.type, + // Requested execution tier; clients gate the same-origin privileged + // sandbox on this (plus signature + approval + consent). + tier: p.tier, permissions: p.permissions, entrypoint: p.entrypoint, // Policy is the canonical source for force-enable. The per-plugin field diff --git a/app/api/pwa-icon/[size]/route.ts b/app/api/pwa-icon/[size]/route.ts index 52870b185..93a538c59 100644 --- a/app/api/pwa-icon/[size]/route.ts +++ b/app/api/pwa-icon/[size]/route.ts @@ -59,10 +59,12 @@ export async function GET( domainOverrides.pwaIconUrl || domainOverrides.faviconUrl || (sources.pwaIconUrl?.source !== 'default' ? (sources.pwaIconUrl?.value as string) : '') || - (sources.faviconUrl?.source !== 'default' ? (sources.faviconUrl?.value as string) : ''); - if (!iconUrl) { - return new NextResponse('No PWA icon configured', { status: 404 }); - } + (sources.faviconUrl?.source !== 'default' ? (sources.faviconUrl?.value as string) : '') || + // Fall back to the built-in default so this endpoint ALWAYS returns an app + // icon (custom if configured, else the bundled default). This lets callers + // that can't run the custom-vs-default check themselves - notably the + // service worker's notifications - use a single stable URL. + `/icon-${size}x${size}.png`; const pngHeaders = { 'Content-Type': 'image/png', diff --git a/app/globals.css b/app/globals.css index ce708b751..bc55d5549 100644 --- a/app/globals.css +++ b/app/globals.css @@ -660,13 +660,13 @@ body { .tiptap ul { list-style-type: disc; - padding-left: 1.5rem; + padding-inline-start: 1.5rem; margin: 0.25rem 0; } .tiptap ol { list-style-type: decimal; - padding-left: 1.5rem; + padding-inline-start: 1.5rem; margin: 0.25rem 0; } @@ -675,8 +675,8 @@ body { } .tiptap blockquote { - border-left: 3px solid var(--color-border); - padding-left: 1rem; + border-inline-start: 3px solid var(--color-border); + padding-inline-start: 1rem; margin: 0.5rem 0; color: var(--color-muted-foreground); } @@ -719,7 +719,7 @@ body { .tiptap p.is-editor-empty:first-child::before { content: attr(data-placeholder); - float: left; + float: inline-start; color: var(--color-muted-foreground); pointer-events: none; height: 0; @@ -750,7 +750,7 @@ body { background-color: var(--color-muted) !important; color: var(--color-foreground) !important; font-weight: 600; - text-align: left; + text-align: start; } .tiptap table p { @@ -771,7 +771,7 @@ body { bottom: -2px; pointer-events: none; position: absolute; - right: -2px; + inset-inline-end: -2px; top: 0; width: 4px; } @@ -780,6 +780,24 @@ body { cursor: col-resize; } +/* RTL direction overrides — when a paragraph or heading has dir="rtl", + ensure block-level visual cues also flip to the opposite side. */ +.tiptap [dir="rtl"] blockquote, +.tiptap blockquote[dir="rtl"] { + border-inline-start: none; + border-inline-end: 3px solid var(--color-border); + padding-inline-start: 0; + padding-inline-end: 1rem; +} + +.tiptap [dir="rtl"] ul, +.tiptap ul[dir="rtl"], +.tiptap [dir="rtl"] ol, +.tiptap ol[dir="rtl"] { + padding-inline-start: 0; + padding-inline-end: 1.5rem; +} + /* Smooth halo applied to a setting row when the user clicks a sub-result in the settings search. The element bg + a 10px box-shadow fill the row plus a 10px ring of the same tint, then a 2px outline sits exactly at the outer @@ -808,3 +826,20 @@ body { border-radius: 8px; animation: settings-search-pulse 1.6s ease-in-out forwards; } + +/* RTL: mirror directional icons (chevrons/arrows) so prev/next, back/forward, + and panel-collapse affordances point the correct way in right-to-left layouts. + lucide-react emits a `lucide-` class per icon, so we target the + directional ones only — vertical chevrons (up/down) are intentionally left. */ +[dir="rtl"] .lucide-chevron-left, +[dir="rtl"] .lucide-chevron-right, +[dir="rtl"] .lucide-chevrons-left, +[dir="rtl"] .lucide-chevrons-right, +[dir="rtl"] .lucide-arrow-left, +[dir="rtl"] .lucide-arrow-right, +[dir="rtl"] .lucide-arrow-big-left, +[dir="rtl"] .lucide-arrow-big-right, +[dir="rtl"] .lucide-panel-left, +[dir="rtl"] .lucide-panel-right { + transform: scaleX(-1); +} diff --git a/components/calendar/calendar-agenda-view.tsx b/components/calendar/calendar-agenda-view.tsx index 1aa0513ce..d757305c7 100644 --- a/components/calendar/calendar-agenda-view.tsx +++ b/components/calendar/calendar-agenda-view.tsx @@ -131,7 +131,7 @@ export function CalendarAgendaView({ )}> {formatDateHeader(group.date)} - + {intlFormatter.dateTime(group.date, { month: "short", day: "numeric", year: "numeric" })}
@@ -148,6 +148,9 @@ export function CalendarAgendaView({ const color = getEventColor(ev, calendar); const start = getEventStartDate(ev); const end = getEventEndDate(ev); + // iTIP CANCEL marks the attendee's copy with status "cancelled" + // instead of deleting it (#572). + const isCancelled = ev.status === "cancelled"; const locationName = ev.locations ? Object.values(ev.locations)[0]?.name : null; @@ -159,7 +162,10 @@ export function CalendarAgendaView({ onMouseEnter={(e) => onHoverEvent?.(ev, e.currentTarget.getBoundingClientRect())} onMouseLeave={() => onHoverLeave?.()} onContextMenu={onContextMenuEvent ? (e) => onContextMenuEvent(e, ev) : undefined} - className="w-full flex items-start px-4 hover:bg-muted/50 transition-colors text-left" + className={cn( + "w-full flex items-start px-4 hover:bg-muted/50 transition-colors text-start", + isCancelled && "opacity-60" + )} style={{ gap: 'var(--density-item-gap)', paddingBlock: 'var(--density-item-py)' }} >
@@ -181,7 +187,7 @@ export function CalendarAgendaView({ />
-
+
{ev.title || t("events.no_title")}
{locationName && ( diff --git a/components/calendar/calendar-day-view.tsx b/components/calendar/calendar-day-view.tsx index b06528fc9..943480b3d 100644 --- a/components/calendar/calendar-day-view.tsx +++ b/components/calendar/calendar-day-view.tsx @@ -218,7 +218,7 @@ export function CalendarDayView({ {HOURS.map((h) => (
{h > 0 && ( @@ -231,7 +231,7 @@ export function CalendarDayView({
handleGridPointerDown(e, dayKey, selectedDate)} @@ -312,7 +312,7 @@ export function CalendarDayView({ style={{ top: (nowMinutes / 60) * HOUR_HEIGHT }} >
-
+
@@ -346,7 +346,7 @@ export function CalendarDayView({ style={{ top: (dropTarget.minutes / 60) * HOUR_HEIGHT }} >
-
+
diff --git a/components/calendar/calendar-month-view.tsx b/components/calendar/calendar-month-view.tsx index c6b26e49c..0b2305127 100644 --- a/components/calendar/calendar-month-view.tsx +++ b/components/calendar/calendar-month-view.tsx @@ -145,7 +145,7 @@ export function CalendarMonthView({
{dayHeaders.map((d) => (
{isMobile ? t(`days.${d}`).slice(0, 2) : t(`days.${d}`)} @@ -181,7 +181,7 @@ export function CalendarMonthView({ onDragLeave={handleCellDragLeave} onDrop={(e) => handleCellDrop(e, day)} className={cn( - "border-r border-border last:border-r-0 p-1 cursor-pointer transition-colors touch-manipulation", + "border-e border-border last:border-e-0 p-1 cursor-pointer transition-colors touch-manipulation", !inMonth && "bg-muted/30", "hover:bg-muted/50", selected && isMobile && "bg-primary/10", diff --git a/components/calendar/calendar-sidebar-panel.tsx b/components/calendar/calendar-sidebar-panel.tsx index 2fc8dc110..38dc2dccd 100644 --- a/components/calendar/calendar-sidebar-panel.tsx +++ b/components/calendar/calendar-sidebar-panel.tsx @@ -397,7 +397,7 @@ export function CalendarSidebarPanel({ {t('tasks.label')} {pendingTaskCount > 0 && ( - {pendingTaskCount} + {pendingTaskCount} )} {overdueTaskCount > 0 && ( {overdueTaskCount} {t('tasks.filter_overdue').toLowerCase()} @@ -437,7 +437,7 @@ export function CalendarSidebarPanel({ onCreateCalendar(); } }} - className="ml-auto p-0.5 rounded text-muted-foreground/70 opacity-0 group-hover:opacity-100 hover:text-foreground hover:bg-muted transition-colors cursor-pointer" + className="ms-auto p-0.5 rounded text-muted-foreground/70 opacity-0 group-hover:opacity-100 hover:text-foreground hover:bg-muted transition-colors cursor-pointer" title={tMgmt('add_calendar')} > @@ -445,7 +445,7 @@ export function CalendarSidebarPanel({ )} {expanded && ( -
+
{owned.length > 0 && (
diff --git a/components/calendar/calendar-toolbar.tsx b/components/calendar/calendar-toolbar.tsx index c642bd79f..ec9480dd6 100644 --- a/components/calendar/calendar-toolbar.tsx +++ b/components/calendar/calendar-toolbar.tsx @@ -124,7 +124,7 @@ export function CalendarToolbar({ variant="ghost" size="icon" onClick={onMenuClick} - className="h-8 w-8 -ml-1 mr-1" + className="h-8 w-8 -ms-1 me-1" aria-label={t("nav_open_menu")} > @@ -138,7 +138,7 @@ export function CalendarToolbar({ {onMenuClick && ( -
@@ -284,7 +284,7 @@ export function CalendarToolbar({ {/* ── DESKTOP TOOLBAR ── */} {!isMobile && (
- - + {getDateLabel()}
@@ -328,9 +328,9 @@ export function CalendarToolbar({ {(onImport || onSubscribe) && !isMobile && (
{showImportDropdown && (
@@ -359,7 +359,7 @@ export function CalendarToolbar({ {!isMobile && ( )} diff --git a/components/calendar/calendar-week-view.tsx b/components/calendar/calendar-week-view.tsx index 16e822aa3..4d4a40520 100644 --- a/components/calendar/calendar-week-view.tsx +++ b/components/calendar/calendar-week-view.tsx @@ -211,7 +211,7 @@ export function CalendarWeekView({
{hasAllDay && (
{t("events.all_day")} @@ -306,7 +306,7 @@ export function CalendarWeekView({
-
+
{weekDays.map((day) => { const todayCol = isToday(day); const selected = isSameDay(day, selectedDate); @@ -318,7 +318,7 @@ export function CalendarWeekView({ role="columnheader" aria-label={fullLabel} className={cn( - "text-center py-2 text-sm border-r border-border last:border-r-0 transition-colors touch-manipulation", + "text-center py-2 text-sm border-e border-border last:border-e-0 transition-colors touch-manipulation", "hover:bg-muted/50", todayCol && "font-bold", )} @@ -345,7 +345,7 @@ export function CalendarWeekView({ {HOURS.map((h) => (
{h > 0 && ( @@ -357,7 +357,7 @@ export function CalendarWeekView({ ))}
-
+
{weekDays.map((day) => { const key = format(day, "yyyy-MM-dd"); const dayEvents = timedEvents.get(key) || []; @@ -367,7 +367,7 @@ export function CalendarWeekView({ return (
handleGridPointerDown(e, key, day)} @@ -448,7 +448,7 @@ export function CalendarWeekView({ style={{ top: (nowMinutes / 60) * HOUR_HEIGHT }} >
-
+
@@ -482,7 +482,7 @@ export function CalendarWeekView({ style={{ top: (dropTarget.minutes / 60) * HOUR_HEIGHT }} >
-
+
diff --git a/components/calendar/create-calendar-modal.tsx b/components/calendar/create-calendar-modal.tsx index e5b675e76..656dc8d91 100644 --- a/components/calendar/create-calendar-modal.tsx +++ b/components/calendar/create-calendar-modal.tsx @@ -137,7 +137,7 @@ export function CreateCalendarModal({ client, onClose }: CreateCalendarModalProp ); } @@ -159,12 +163,13 @@ export function EventCard({ event, calendar, variant, onClick, onMouseEnter, onM aria-label={ariaLabel} {...dragProps} className={cn( - "w-full h-full text-left rounded-r px-1.5 py-0.5 text-xs overflow-hidden", + "w-full h-full text-start rounded-r px-1.5 py-0.5 text-xs overflow-hidden", "hover:opacity-90 transition-opacity cursor-pointer", continuesAfter && "rounded-r-sm", - continuesAfter && "pr-2", + continuesAfter && "pe-2", isSelected && "ring-2 ring-primary", isBeingDragged && "opacity-50", + isCancelled && !isBeingDragged && "opacity-60", className )} style={{ backgroundColor: `${color}24`, borderLeft: `3px solid ${color}`, color, ...style }} @@ -173,7 +178,7 @@ export function EventCard({ event, calendar, variant, onClick, onMouseEnter, onM {showTimeInMonthView && !event.showWithoutTime && ( {format(startDate, timeFmt)} )} - {event.title || t("events.no_title")} + {event.title || t("events.no_title")}
); @@ -189,15 +194,16 @@ export function EventCard({ event, calendar, variant, onClick, onMouseEnter, onM {...dragProps} data-calendar-event className={cn( - "w-full h-full text-left rounded-r px-1.5 py-0.5 text-xs overflow-hidden", + "w-full h-full text-start rounded-r px-1.5 py-0.5 text-xs overflow-hidden", "hover:opacity-90 transition-opacity cursor-pointer", isSelected && "ring-2 ring-primary", isBeingDragged && "opacity-50", + isCancelled && !isBeingDragged && "opacity-60", className )} style={{ backgroundColor: `${color}30`, borderLeft: `3px solid ${color}`, color, ...style }} > -
{event.title || t("events.no_title")}
+
{event.title || t("events.no_title")}
{!event.showWithoutTime && (
{timeString} diff --git a/components/calendar/event-detail-popover.tsx b/components/calendar/event-detail-popover.tsx index 79af4181d..bfc613bd4 100644 --- a/components/calendar/event-detail-popover.tsx +++ b/components/calendar/event-detail-popover.tsx @@ -290,20 +290,23 @@ export function EventDetailPopover({ className="w-2.5 h-2.5 rounded-full flex-shrink-0" style={{ backgroundColor: color }} /> -

+

{event.title || t("events.no_title")}

{calendar && ( -

+

{calendar.name} {event.status === "tentative" && ( - + {t("detail.tentative")} )} {event.status === "cancelled" && ( - + {t("detail.cancelled")} )} @@ -343,13 +346,13 @@ export function EventDetailPopover({ <>

{formatEventDate(startDate)} - + {formatTime(startDate)}
{formatEventDate(endDate)} - + {formatTime(endDate)}
@@ -364,11 +367,11 @@ export function EventDetailPopover({ {formatEventDate(startDate)} {event.showWithoutTime ? ( - {t("events.all_day")} + {t("events.all_day")} ) : (
{formatTime(startDate)} – {formatTime(endDate)} - ({formatDurationDisplay(durationMinutes)}) + ({formatDurationDisplay(durationMinutes)})
)} @@ -434,7 +437,7 @@ export function EventDetailPopover({ {p.name || p.email} {p.isOrganizer && ( - + ({t("participants.organizer").toLowerCase()}) )} @@ -512,7 +515,7 @@ export function EventDetailPopover({ disabled={!noteText.trim() || isSavingNote} className="h-7 text-xs" > - + {t("detail.save_note")}
@@ -546,7 +549,7 @@ export function EventDetailPopover({ : "text-success border-success/30 hover:bg-success/10" } > - {userCurrentStatus === "accepted" && } + {userCurrentStatus === "accepted" && } {t("participants.accepted")}
@@ -606,7 +609,7 @@ export function EventDetailPopover({ ) : ( <>
diff --git a/components/calendar/event-modal.tsx b/components/calendar/event-modal.tsx index 609613f1f..cd65425d6 100644 --- a/components/calendar/event-modal.tsx +++ b/components/calendar/event-modal.tsx @@ -522,10 +522,16 @@ export function EventModal({ { name: organizerName, email: organizerEmail }, effectiveAttendees ) as Record; - data.replyTo = { imip: `mailto:${organizerEmail}` }; + // Stalwart (calcard) derives the iCalendar ORGANIZER property solely from + // organizerCalendarAddress; without it no ORGANIZER is emitted and iTIP + // scheduling is silently skipped (NoSchedulingInfo), so no invites are sent. + // The RFC 8984 replyTo property is retired in jscalendarbis and ignored. + data.organizerCalendarAddress = `mailto:${organizerEmail}`; } else if (effectiveAttendees.length === 0 && event?.participants) { data.participants = null; + // Also clear the retired replyTo that older releases (<= 1.7.6) wrote. data.replyTo = null; + data.organizerCalendarAddress = null; } const shouldSendScheduling = effectiveAttendees.length > 0 && sendInvitations; @@ -660,11 +666,11 @@ export function EventModal({
{formatEventDate(startD)} - {format(startD, timeDisplayFmt)} + {format(startD, timeDisplayFmt)}
{formatEventDate(endD)} - {format(endD, timeDisplayFmt)} + {format(endD, timeDisplayFmt)}
); @@ -673,7 +679,7 @@ export function EventModal({
{formatEventDate(startD)} {!event.showWithoutTime && ( - + {format(startD, timeDisplayFmt)} – {format(endD, timeDisplayFmt)} )} @@ -695,7 +701,7 @@ export function EventModal({ {t("participants.title")}
-
+
{participants.map(p => (
{p.name || p.email} @@ -720,7 +726,7 @@ export function EventModal({ ? "bg-success hover:bg-success/80 text-success-foreground" : "text-success border-success/30 hover:bg-success/10"} > - {userCurrentStatus === "accepted" && } + {userCurrentStatus === "accepted" && } {t("participants.accepted")}
@@ -778,7 +784,7 @@ export function EventModal({

{event.title || t("events.no_title")}

{eventCalendar && ( -

{eventCalendar.name}

+

{eventCalendar.name}

)}
) : ( ) )} {onDuplicate && !showDeleteConfirm && ( )}
{!showDeleteConfirm && ( )} @@ -1314,7 +1320,7 @@ export function EventModal({ onClick={() => setShowDeleteConfirm(true)} className="text-red-600 dark:text-red-400" > - + {t("events.delete")} ) @@ -1326,7 +1332,7 @@ export function EventModal({ onClick={handleDuplicate} aria-label={t("events.duplicate")} > - + {t("events.duplicate")} )} diff --git a/components/calendar/ical-import-modal.tsx b/components/calendar/ical-import-modal.tsx index 362158291..b08f09137 100644 --- a/components/calendar/ical-import-modal.tsx +++ b/components/calendar/ical-import-modal.tsx @@ -444,7 +444,7 @@ export function ICalImportModal({ calendars, client, onClose, initialUrl }: ICal onClick={handleImport} disabled={selectedIndices.size === 0} > - + {t("import_button")} ({selectedIndices.size}) )} diff --git a/components/calendar/ical-subscription-modal.tsx b/components/calendar/ical-subscription-modal.tsx index 7583ad12e..05001fa2f 100644 --- a/components/calendar/ical-subscription-modal.tsx +++ b/components/calendar/ical-subscription-modal.tsx @@ -208,7 +208,7 @@ export function ICalSubscriptionModal({ client, onClose, editSubscription, initi )} diff --git a/components/calendar/task-toolbar.tsx b/components/calendar/task-toolbar.tsx index 5111f69a2..cdda536ef 100644 --- a/components/calendar/task-toolbar.tsx +++ b/components/calendar/task-toolbar.tsx @@ -44,7 +44,7 @@ export function TaskToolbar({ ))}
-
diff --git a/components/contacts/__tests__/contact-form.test.tsx b/components/contacts/__tests__/contact-form.test.tsx index 530c086a4..75f4375b8 100644 --- a/components/contacts/__tests__/contact-form.test.tsx +++ b/components/contacts/__tests__/contact-form.test.tsx @@ -62,6 +62,27 @@ describe('ContactForm', () => { expect(phoneAfter.length).toBe(phoneBefore.length + 1); }); + it('sends media: null when an existing photo is removed', async () => { + const onSave = vi.fn().mockResolvedValue(undefined); + const contactWithPhoto: ContactCard = { + ...existingContact, + media: { + photo: { kind: 'photo', uri: 'data:image/png;base64,AAAA', mediaType: 'image/png' }, + }, + }; + render(); + + fireEvent.click(screen.getByText('remove_photo')); + fireEvent.submit(screen.getByText('save').closest('form')!); + + await waitFor(() => { + expect(onSave).toHaveBeenCalledOnce(); + }); + + const savedData = onSave.mock.calls[0][0]; + expect(savedData.media).toBeNull(); + }); + it('submits form data correctly', async () => { const onSave = vi.fn().mockResolvedValue(undefined); render(); diff --git a/components/contacts/contact-activity.tsx b/components/contacts/contact-activity.tsx index 86b50b982..d5d23e166 100644 --- a/components/contacts/contact-activity.tsx +++ b/components/contacts/contact-activity.tsx @@ -229,7 +229,7 @@ export function ContactActivity({ contact }: ContactActivityProps) { key={email.id} type="button" onClick={() => handleOpenEmail(email)} - className="w-full text-left flex items-start gap-3 px-2 py-2 rounded-md hover:bg-muted/60 transition-colors touch-manipulation" + className="w-full text-start flex items-start gap-3 px-2 py-2 rounded-md hover:bg-muted/60 transition-colors touch-manipulation" >
@@ -277,7 +277,7 @@ export function ContactActivity({ contact }: ContactActivityProps) { key={event.id} type="button" onClick={() => handleOpenEvent(event)} - className="w-full text-left flex items-baseline gap-3 px-2 py-2 rounded-md hover:bg-muted/60 transition-colors touch-manipulation" + className="w-full text-start flex items-baseline gap-3 px-2 py-2 rounded-md hover:bg-muted/60 transition-colors touch-manipulation" > {formatEventTime(event)} diff --git a/components/contacts/contact-detail.tsx b/components/contacts/contact-detail.tsx index 2dc70b103..9cf0d4d53 100644 --- a/components/contacts/contact-detail.tsx +++ b/components/contacts/contact-detail.tsx @@ -2,16 +2,13 @@ import { useState, useEffect, useRef } from "react"; import { useTranslations } from "next-intl"; -import { Mail, Phone, Building, MapPin, StickyNote, Pencil, Trash2, BookUser, Copy, Send, Globe, Cake, KeyRound, Users, Briefcase, Heart, Languages, Calendar, UserCircle, ShieldCheck, ShieldAlert, Download, MoreHorizontal, Printer } from "lucide-react"; +import { Mail, Phone, Building, MapPin, StickyNote, Pencil, Trash2, BookUser, Copy, Send, Globe, Cake, KeyRound, Users, Briefcase, Heart, Languages, Calendar, UserCircle, Download, MoreHorizontal, Printer } from "lucide-react"; import { Avatar } from "@/components/ui/avatar"; import { Button } from "@/components/ui/button"; import { cn } from "@/lib/utils"; import type { ContactCard, AnniversaryDate, PartialDate } from "@/lib/jmap/types"; import { getContactDisplayName, getContactPrimaryEmail, getContactPhotoUri } from "@/stores/contact-store"; import { ContactActivity } from "./contact-activity"; -import { useSmimeStore } from "@/stores/smime-store"; -import { parseCertificatePemOrDer, extractCertificateInfo } from "@/lib/smime/certificate-utils"; -import type { CertificateInfo } from "@/lib/smime/types"; import { toast } from "@/stores/toast-store"; import { exportContact } from "./contact-export"; import { printContact } from "./contact-print"; @@ -119,49 +116,9 @@ function formatDate(dateInput: AnniversaryDate): string { export function ContactDetail({ contact, onEdit, onDelete, onAddToGroup, onDuplicate, onCompose, isMobile, className }: ContactDetailProps) { const t = useTranslations("contacts"); - const smimeStore = useSmimeStore(); - const [parsedCerts, setParsedCerts] = useState>(new Map()); const cryptoKeys = contact?.cryptoKeys ? Object.values(contact.cryptoKeys) : []; - useEffect(() => { - if (!contact) return; - let cancelled = false; - const parseCerts = async () => { - const results = new Map(); - for (let i = 0; i < cryptoKeys.length; i++) { - const key = cryptoKeys[i]; - if (typeof key.uri !== 'string') continue; - try { - let derBytes: ArrayBuffer | string | null = null; - if (key.uri.startsWith('data:')) { - const commaIdx = key.uri.indexOf(','); - if (commaIdx === -1) continue; - const b64 = key.uri.substring(commaIdx + 1); - const binary = atob(b64); - const bytes = new Uint8Array(binary.length); - for (let j = 0; j < binary.length; j++) bytes[j] = binary.charCodeAt(j); - derBytes = bytes.buffer; - } else if (key.uri.startsWith('-----BEGIN')) { - derBytes = key.uri; - } - if (!derBytes) continue; - const cert = parseCertificatePemOrDer(derBytes); - const der = typeof derBytes === 'string' ? cert.toSchema(true).toBER(false) : derBytes; - const info = await extractCertificateInfo(cert, der); - if (!cancelled) results.set(i, info); - } catch { /* skip unparseable keys */ } - } - if (!cancelled) setParsedCerts(results); - }; - if (cryptoKeys.length > 0) { - parseCerts(); - } else { - setParsedCerts(new Map()); - } - return () => { cancelled = true; }; - }, [contact?.id]); // eslint-disable-line react-hooks/exhaustive-deps - if (!contact) { return (
@@ -207,30 +164,6 @@ export function ContactDetail({ contact, onEdit, onDelete, onAddToGroup, onDupli const anniversaries = contact.anniversaries ? Object.values(contact.anniversaries) : []; const keywords = contact.keywords ? Object.keys(contact.keywords).filter(k => contact.keywords![k]) : []; - const handleImportContactCert = async (keyIndex: number) => { - const key = cryptoKeys[keyIndex]; - if (!key?.uri || typeof key.uri !== 'string') return; - try { - let derBytes: ArrayBuffer | string; - if (key.uri.startsWith('data:')) { - const commaIdx = key.uri.indexOf(','); - if (commaIdx === -1) return; - const b64 = key.uri.substring(commaIdx + 1); - const binary = atob(b64); - const bytes = new Uint8Array(binary.length); - for (let j = 0; j < binary.length; j++) bytes[j] = binary.charCodeAt(j); - derBytes = bytes.buffer; - } else if (key.uri.startsWith('-----BEGIN')) { - derBytes = key.uri; - } else { - return; - } - await smimeStore.importPublicCert(derBytes, 'contact', contact.id); - toast.success(t("detail.cert_imported")); - } catch (err) { - toast.error(err instanceof Error ? err.message : t("detail.cert_import_failed")); - } - }; const relatedTo = contact.relatedTo ? Object.entries(contact.relatedTo) : []; const preferredLanguages = contact.preferredLanguages ? Object.values(contact.preferredLanguages) : []; const personalInfo = contact.personalInfo ? Object.values(contact.personalInfo) : []; @@ -268,7 +201,7 @@ export function ContactDetail({ contact, onEdit, onDelete, onAddToGroup, onDupli onClick={onCompose} className="touch-manipulation" > - + {t("detail.compose_email")} )} @@ -277,12 +210,12 @@ export function ContactDetail({ contact, onEdit, onDelete, onAddToGroup, onDupli href={`tel:${phone}`} className="inline-flex items-center justify-center rounded-md font-medium h-9 px-3 text-sm border border-input bg-background hover:bg-accent hover:text-accent-foreground transition-colors touch-manipulation" > - + {t("context_menu.call")} )} @@ -493,59 +426,20 @@ export function ContactDetail({ contact, onEdit, onDelete, onAddToGroup, onDupli {cryptoKeys.length > 0 && (
- {cryptoKeys.map((key, i) => { - const certInfo = parsedCerts.get(i); - const isExpired = certInfo ? new Date(certInfo.notAfter) < new Date() : false; - const alreadyImported = certInfo?.emailAddresses?.[0] - ? !!smimeStore.getPublicCertForEmail(certInfo.emailAddresses[0]) - : false; - - return ( -
- {certInfo ? ( - <> -
- {isExpired ? ( - - ) : ( - - )} - {certInfo.subject} -
-
-

{t("detail.cert_issuer")}: {certInfo.issuer}

-

- {t("detail.cert_expires")}: {new Date(certInfo.notAfter).toLocaleDateString()} - {isExpired && ({t("detail.cert_expired")})} -

-

{t("detail.cert_fingerprint")}: {certInfo.fingerprint.substring(0, 20)}...

- {certInfo.algorithm &&

{t("detail.cert_algorithm")}: {certInfo.algorithm}

} -
- {!alreadyImported && ( - - )} - {alreadyImported && ( -

{t("detail.cert_already_imported")}

- )} - + {cryptoKeys.map((key, i) => ( +
+
+ + {typeof key.uri === 'string' && key.uri.startsWith("http") ? ( + + {key.uri} + ) : ( -
- - {typeof key.uri === 'string' && key.uri.startsWith("http") ? ( - - {key.uri} - - ) : ( - {typeof key.uri === 'string' ? `${key.uri.substring(0, 80)}${key.uri.length > 80 ? "…" : ""}` : String(key.uri ?? '')} - )} -
+ {typeof key.uri === 'string' ? `${key.uri.substring(0, 80)}${key.uri.length > 80 ? "…" : ""}` : String(key.uri ?? '')} )}
- ); - })} +
+ ))}
)} @@ -693,7 +587,7 @@ function MoreActionsMenu({ items, label }: { items: MoreItem[]; label: string }) setOpen(false); }} className={cn( - "w-full flex items-center gap-2 px-3 py-1.5 text-sm text-left hover:bg-muted focus:bg-muted focus:outline-none transition-colors", + "w-full flex items-center gap-2 px-3 py-1.5 text-sm text-start hover:bg-muted focus:bg-muted focus:outline-none transition-colors", item.destructive && "text-red-600 dark:text-red-400 hover:bg-red-50 dark:hover:bg-red-950 focus:bg-red-50 dark:focus:bg-red-950", )} > diff --git a/components/contacts/contact-form.tsx b/components/contacts/contact-form.tsx index ebf6fe1c2..a2e898ebd 100644 --- a/components/contacts/contact-form.tsx +++ b/components/contacts/contact-form.tsx @@ -72,7 +72,7 @@ function FormSection({ icon: Icon, title, children, collapsible, defaultOpen = t
{emailErrors[i] && ( -

{emailErrors[i]}

+

{emailErrors[i]}

)}
))}
@@ -794,7 +799,7 @@ export function ContactForm({ contact, addressBooks, allKeywords, defaultAddress
))}
@@ -850,7 +855,7 @@ export function ContactForm({ contact, addressBooks, allKeywords, defaultAddress
))}
@@ -879,7 +884,7 @@ export function ContactForm({ contact, addressBooks, allKeywords, defaultAddress
))}
@@ -911,7 +916,7 @@ export function ContactForm({ contact, addressBooks, allKeywords, defaultAddress
))}
@@ -952,7 +957,7 @@ export function ContactForm({ contact, addressBooks, allKeywords, defaultAddress
))}
@@ -1140,7 +1145,7 @@ function CategoryComboBox({
@@ -134,7 +134,7 @@ export function ContactGroupForm({ type="button" onClick={() => toggleMember(contact.id)} className={cn( - "w-full flex items-center gap-3 px-3 py-2.5 text-left transition-colors", + "w-full flex items-center gap-3 px-3 py-2.5 text-start transition-colors", "hover:bg-muted", isSelected && "bg-primary/5" )} diff --git a/components/contacts/contact-group-list.tsx b/components/contacts/contact-group-list.tsx index 0c3127b1f..e5193e9d0 100644 --- a/components/contacts/contact-group-list.tsx +++ b/components/contacts/contact-group-list.tsx @@ -45,7 +45,7 @@ export function ContactGroupList({
@@ -69,7 +69,7 @@ export function ContactGroupList({ key={group.id} onClick={() => onSelectGroup(group.id)} className={cn( - "w-full flex items-center px-4 text-left transition-colors", + "w-full flex items-center px-4 text-start transition-colors", "hover:bg-muted", group.id === selectedGroupId && "bg-accent text-accent-foreground" )} diff --git a/components/contacts/contact-import-dialog.tsx b/components/contacts/contact-import-dialog.tsx index d62e6dcfa..8e8331a11 100644 --- a/components/contacts/contact-import-dialog.tsx +++ b/components/contacts/contact-import-dialog.tsx @@ -187,7 +187,7 @@ export function ContactImportDialog({ type="button" onClick={() => toggleSelect(idx)} className={cn( - "w-full flex items-center gap-3 px-3 py-2.5 text-left transition-colors hover:bg-muted", + "w-full flex items-center gap-3 px-3 py-2.5 text-start transition-colors hover:bg-muted", isSelected && "bg-primary/5" )} > diff --git a/components/contacts/contact-list.tsx b/components/contacts/contact-list.tsx index 04eac01bb..0d9f23c77 100644 --- a/components/contacts/contact-list.tsx +++ b/components/contacts/contact-list.tsx @@ -310,7 +310,7 @@ export function ContactList({ placeholder={t("search_placeholder")} value={searchQuery} onChange={(e) => onSearchChange(e.target.value)} - className={cn("pl-9 h-9", searchQuery && "pr-8")} + className={cn("ps-9 h-9", searchQuery && "pe-8")} /> {searchQuery && ( )} @@ -538,7 +538,7 @@ export function ContactList({

{t("empty_state_title")}

{t("empty_state_subtitle")}

diff --git a/components/contacts/contacts-sidebar.tsx b/components/contacts/contacts-sidebar.tsx index 1fd36bb82..80fc4b401 100644 --- a/components/contacts/contacts-sidebar.tsx +++ b/components/contacts/contacts-sidebar.tsx @@ -287,14 +287,14 @@ export function ContactsSidebar({ className="absolute right-0 top-full mt-1 w-44 rounded-md border border-border bg-background text-foreground shadow-md z-50 py-1" > {onCreateAddressBook && ( @@ -355,7 +355,7 @@ export function ContactsSidebar({
{expanded && ( -
+
{owned.length > 0 && (
@@ -418,7 +418,7 @@ export function ContactsSidebar({
@@ -505,7 +505,7 @@ export function ContactsSidebar({
@@ -561,7 +561,7 @@ export function ContactsSidebar({
@@ -847,7 +847,7 @@ function AddressBookItem({ onDragLeave={handleDragLeave} onDrop={handleDrop} className={cn( - "w-full flex items-center gap-2 pl-5 pr-3 text-sm transition-colors", + "w-full flex items-center gap-2 ps-5 pe-3 text-sm transition-colors", isActive ? "bg-accent text-accent-foreground font-medium" : "text-foreground/80 hover:bg-muted", @@ -858,11 +858,11 @@ function AddressBookItem({ {book.name} {!book.isShared && Object.keys(book.shareWith || {}).length > 0 && ( - + )} 0) && "ml-auto" + !(!book.isShared && Object.keys(book.shareWith || {}).length > 0) && "ms-auto" )}> {contactCount} diff --git a/components/email/__tests__/calendar-invitation-banner.test.tsx b/components/email/__tests__/calendar-invitation-banner.test.tsx index fb08be4a3..6e2c017a4 100644 --- a/components/email/__tests__/calendar-invitation-banner.test.tsx +++ b/components/email/__tests__/calendar-invitation-banner.test.tsx @@ -540,7 +540,9 @@ describe('CalendarInvitationBanner', () => { mocks.clientMock, 'event-8', expect.objectContaining({ - replyTo: { imip: 'mailto:organizer@example.com' }, + // The stored event lacks an ORGANIZER, so the RSVP repair writes + // organizerCalendarAddress (replyTo is retired in jscalendarbis). + organizerCalendarAddress: 'mailto:organizer@example.com', participants: expect.objectContaining({ attendee: expect.objectContaining({ participationStatus: 'accepted', diff --git a/components/email/__tests__/email-list-item.test.tsx b/components/email/__tests__/email-list-item.test.tsx index 2a6101fd3..72d41e357 100644 --- a/components/email/__tests__/email-list-item.test.tsx +++ b/components/email/__tests__/email-list-item.test.tsx @@ -121,3 +121,34 @@ describe('EmailListItem tag badge', () => { expect(container.querySelector('p')).toBeNull(); }); }); + +describe('EmailListItem shift-range checkbox', () => { + beforeEach(() => { + useSettingsStore.setState({ emailKeywords: [...DEFAULT_KEYWORDS], showPreview: false, mailLayout: 'split' }); + }); + + it('shift-clicking the checkbox extends the selection from the anchor', () => { + const e1 = makeEmail({ id: 'e1', threadId: 't1' }); + const e2 = makeEmail({ id: 'e2', threadId: 't2' }); + const e3 = makeEmail({ id: 'e3', threadId: 't3' }); + // selection mode active (so the checkbox renders), anchor on e1 + useEmailStore.setState({ + emails: [e1, e2, e3], + selectedEmailIds: new Set(['e1']), + lastSelectedEmailId: 'e1', + selectedMailbox: 'inbox', + }); + + render(); + // the checkbox is the first button in the row (shown in selection mode) + const checkbox = screen.getAllByRole('button')[0]; + act(() => { + checkbox.dispatchEvent(new MouseEvent('click', { bubbles: true, shiftKey: true })); + }); + + const sel = useEmailStore.getState().selectedEmailIds; + expect(sel.has('e1')).toBe(true); + expect(sel.has('e2')).toBe(true); // the in-between row got filled in + expect(sel.has('e3')).toBe(true); + }); +}); diff --git a/components/email/__tests__/recipient-chip-drag.test.tsx b/components/email/__tests__/recipient-chip-drag.test.tsx index 10ff1f331..461b4452b 100644 --- a/components/email/__tests__/recipient-chip-drag.test.tsx +++ b/components/email/__tests__/recipient-chip-drag.test.tsx @@ -67,21 +67,6 @@ vi.mock('@/stores/account-store', () => { return { useAccountStore: hook }; }); -vi.mock('@/stores/smime-store', () => { - const state = { - certs: [], - signingEnabled: false, - encryptionEnabled: false, - defaultSigningCertId: null, - defaultEncryptionCertId: null, - }; - const hook = (sel?: (s: typeof state) => unknown) => - typeof sel === 'function' ? sel(state) : state; - hook.getState = () => state; - hook.setState = (p: Partial) => Object.assign(state, p); - return { useSmimeStore: hook }; -}); - vi.mock('@/stores/email-store', () => { const state = { draftSaveEnabled: false, @@ -172,12 +157,6 @@ vi.mock('@/lib/signature-utils', () => ({ getPlainTextSignature: () => '', })); vi.mock('@/lib/sub-addressing', () => ({ generateSubAddress: () => '' })); -vi.mock('@/lib/smime/smime-sign', () => ({ smimeSign: async () => null })); -vi.mock('@/lib/smime/smime-encrypt', () => ({ smimeEncrypt: async () => null })); -vi.mock('@/lib/smime/mime-builder', () => ({ - buildMimeMessage: () => null, - wrapCmsAsSmimeMessage: () => null, -})); vi.mock('@/lib/debug', () => ({ debug: () => {} })); vi.mock('@/components/email/quoted-html', () => ({ buildQuotedHtmlBlock: () => '', diff --git a/components/email/__tests__/recipient-paste.test.tsx b/components/email/__tests__/recipient-paste.test.tsx index aa07bf787..1a3ccef6a 100644 --- a/components/email/__tests__/recipient-paste.test.tsx +++ b/components/email/__tests__/recipient-paste.test.tsx @@ -66,21 +66,6 @@ vi.mock('@/stores/account-store', () => { return { useAccountStore: hook }; }); -vi.mock('@/stores/smime-store', () => { - const state = { - certs: [], - signingEnabled: false, - encryptionEnabled: false, - defaultSigningCertId: null, - defaultEncryptionCertId: null, - }; - const hook = (sel?: (s: typeof state) => unknown) => - typeof sel === 'function' ? sel(state) : state; - hook.getState = () => state; - hook.setState = (p: Partial) => Object.assign(state, p); - return { useSmimeStore: hook }; -}); - vi.mock('@/stores/email-store', () => { const state = { draftSaveEnabled: false, @@ -171,12 +156,6 @@ vi.mock('@/lib/signature-utils', () => ({ getPlainTextSignature: () => '', })); vi.mock('@/lib/sub-addressing', () => ({ generateSubAddress: () => '' })); -vi.mock('@/lib/smime/smime-sign', () => ({ smimeSign: async () => null })); -vi.mock('@/lib/smime/smime-encrypt', () => ({ smimeEncrypt: async () => null })); -vi.mock('@/lib/smime/mime-builder', () => ({ - buildMimeMessage: () => null, - wrapCmsAsSmimeMessage: () => null, -})); vi.mock('@/lib/debug', () => ({ debug: () => {} })); vi.mock('@/components/email/quoted-html', () => ({ buildQuotedHtmlBlock: () => '', diff --git a/components/email/__tests__/selectable-avatar.test.tsx b/components/email/__tests__/selectable-avatar.test.tsx new file mode 100644 index 000000000..51ac9269c --- /dev/null +++ b/components/email/__tests__/selectable-avatar.test.tsx @@ -0,0 +1,39 @@ +import { describe, it, expect, vi } from 'vitest'; +import { render, screen, fireEvent } from '@testing-library/react'; +import { SelectableAvatar } from '../selectable-avatar'; + +// Isolate from the real Avatar (image fetching, libravatar hashing) — we only +// care about the selection wrapper behaviour here. +vi.mock('@/components/ui/avatar', () => ({ + Avatar: (props: { name?: string }) => {props.name}, +})); + +describe('SelectableAvatar', () => { + it('renders the wrapped avatar', () => { + render( {}} selectLabel="Select" />); + expect(screen.getByTestId('avatar')).toHaveTextContent('Marta'); + }); + + it('fires onToggle and stops propagation when the avatar is clicked', () => { + const onToggle = vi.fn(); + const onRowClick = vi.fn(); + render( +
+ +
, + ); + fireEvent.click(screen.getByRole('checkbox')); + expect(onToggle).toHaveBeenCalledTimes(1); + // Clicking the avatar must not bubble up to open/select the row. + expect(onRowClick).not.toHaveBeenCalled(); + }); + + it('reflects the checked state via aria-checked', () => { + const { rerender } = render( + {}} selectLabel="Select" />, + ); + expect(screen.getByRole('checkbox')).toHaveAttribute('aria-checked', 'false'); + rerender( {}} selectLabel="Select" />); + expect(screen.getByRole('checkbox')).toHaveAttribute('aria-checked', 'true'); + }); +}); diff --git a/components/email/calendar-invitation-banner.tsx b/components/email/calendar-invitation-banner.tsx index bd21fc54a..e3293e2c4 100644 --- a/components/email/calendar-invitation-banner.tsx +++ b/components/email/calendar-invitation-banner.tsx @@ -582,7 +582,12 @@ export function CalendarInvitationBanner({ email }: CalendarInvitationBannerProp } else { await updateEvent(client, eventForRsvp.id, { participants: repairedParticipants, - replyTo: replyToForRsvp ?? undefined, + // Stalwart routes the iTIP REPLY via the stored ORGANIZER + // (organizerCalendarAddress; the RFC 8984 replyTo is retired). + // Only repair a missing organizer - attendees may not modify it. + ...(replyToForRsvp?.imip && !eventForRsvp.organizerCalendarAddress + ? { organizerCalendarAddress: replyToForRsvp.imip } + : {}), }, true); setRsvpStatus(status); setActionNotice(t('rsvp_sent')); @@ -1048,7 +1053,7 @@ export function CalendarInvitationBanner({ email }: CalendarInvitationBannerProp setShowCalendarPicker(false); handleImport(cal.id); }} - className="w-full px-3 py-1.5 text-sm text-left hover:bg-muted flex items-center gap-2" + className="w-full px-3 py-1.5 text-sm text-start hover:bg-muted flex items-center gap-2" > + )}
)} diff --git a/components/email/email-composer.tsx b/components/email/email-composer.tsx index be2d23e2c..5a62c335e 100644 --- a/components/email/email-composer.tsx +++ b/components/email/email-composer.tsx @@ -5,13 +5,13 @@ import { useFocusTrap } from "@/hooks/use-focus-trap"; import { useTranslations } from "next-intl"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; -import { X, Paperclip, Send, Save, Check, Loader2, AlertCircle, FileText, BookmarkPlus, ShieldCheck, Lock, CalendarClock, ChevronDown, MailCheck } from "lucide-react"; +import { X, Paperclip, Send, Save, Check, Loader2, AlertCircle, FileText, BookmarkPlus, CalendarClock, ChevronDown, MailCheck, Search } from "lucide-react"; import { cn, formatFileSize, formatDateTime, generateUUID } from "@/lib/utils"; import { debug } from "@/lib/debug"; import { toast } from "@/stores/toast-store"; import { useContextMenu } from "@/hooks/use-context-menu"; import { ContextMenu, ContextMenuItem, ContextMenuSeparator } from "@/components/ui/context-menu"; -import { sanitizeSignatureHtml, sanitizeEmailHtml } from "@/lib/email-sanitization"; +import { sanitizeSignatureHtml, sanitizeEmailHtml, escapeHtml } from "@/lib/email-sanitization"; import { buildReplySubject, buildForwardSubject } from "@/lib/subject-prefix"; import { isFilePreviewable } from "@/lib/file-preview"; import { buildQuotedHtmlBlock, serializeEditorContent } from "@/components/email/quoted-html"; @@ -22,16 +22,10 @@ import { useAuthStore } from "@/stores/auth-store"; import { useIdentityStore } from "@/stores/identity-store"; import { useProMultiAccountIdentities, stripCrossAccountIdentityPrefix } from "@/hooks/use-pro-multi-account-identities"; import { useAccountStore } from "@/stores/account-store"; -import { useSmimeStore } from "@/stores/smime-store"; -import { useEmailStore } from "@/stores/email-store"; import { useSettingsStore } from "@/stores/settings-store"; -import { buildMimeMessage, wrapCmsAsSmimeMessage } from "@/lib/smime/mime-builder"; -import type { MimeAttachment } from "@/lib/smime/mime-builder"; -import { smimeSign } from "@/lib/smime/smime-sign"; import { PluginSlot } from "@/components/plugins/plugin-slot"; import { Avatar } from "@/components/ui/avatar"; import { FilePreviewModal } from "@/components/files/file-preview-modal"; -import { smimeEncrypt } from "@/lib/smime/smime-encrypt"; import { useContactStore } from "@/stores/contact-store"; import { useTemplateStore } from "@/stores/template-store"; import { SubAddressHelper } from "@/components/identity/sub-address-helper"; @@ -41,7 +35,7 @@ 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 { resolveReplyFrom } from "@/lib/reply-identity"; +import { findComposeIdentityId, resolveReplyFrom } from "@/lib/reply-identity"; import { computeReplyThreadingHeaders } from "@/lib/email-threading"; import { rewriteCidImagesForEditor, @@ -51,8 +45,11 @@ import { parseRecipientList, formatRecipientList, splitPastedRecipients, + waitForPendingUploads, + extractUserAuthoredText, type Recipient, } from "@/lib/email-composer-utils"; +import { isValidEmail } from "@/lib/validation"; import { RichTextEditor } from "@/components/email/rich-text-editor"; import type { Editor } from "@tiptap/react"; import { htmlToPlainText as htmlToPlainTextShared } from "@/lib/html-to-text"; @@ -139,12 +136,28 @@ interface EmailComposerProps { }) => void | Promise; onScheduledSendCreated?: () => void | Promise; onClose?: () => void; + /** + * When provided, the composer assigns its close handler to `current`. The + * handler shows the unsaved-changes dialog when the draft is dirty, so a + * host (e.g. the Pro tab bar's close button) can route an external close + * request through the same guard instead of discarding silently. + */ + requestCloseRef?: React.MutableRefObject<(() => void) | null>; onDiscardDraft?: (draftId: string) => void; onSaveState?: (data: ComposerDraftData) => void; className?: string; initialDraftText?: string; initialData?: ComposerDraftData | null; mode?: 'compose' | 'reply' | 'replyAll' | 'forward'; + /** + * Email of the mailbox/account the user is viewing when they start a new + * message. When set (and `autoSelectReplyIdentity` is on), a fresh compose + * preselects the identity matching this address instead of the primary + * identity, so "New message" from info@ defaults its From to info@. Mirrors + * the reply-time identity match; ignored for reply/replyAll/forward (those + * resolve from the original recipients). + */ + composeFromAccountEmail?: string; replyTo?: { from?: { email?: string; name?: string }[]; replyToAddresses?: { email?: string; name?: string }[]; @@ -235,12 +248,14 @@ export function EmailComposer({ onSend, onScheduledSendCreated, onClose, + requestCloseRef, onDiscardDraft, onSaveState, className, initialDraftText, initialData, mode = 'compose', + composeFromAccountEmail, replyTo }: EmailComposerProps) { const t = useTranslations('email_composer'); @@ -347,9 +362,8 @@ export function EmailComposer({ const date = replyTo.receivedAt ? formatDateTime(replyTo.receivedAt, timeFormat, { weekday: 'short', year: 'numeric', month: 'short', day: 'numeric' }) : ""; const from = replyTo.from?.[0]; - const fromStr = from ? `${from.name || from.email}` : tCommon('unknown'); - // Forward "From:" shows the full sender incl. address; reply line keeps - // the bare name (reads naturally in the localized "On … wrote:" line). + // Forward "From:" and the reply "On … wrote:" line both show the full + // sender incl. address ("Name "), like Gmail/Outlook (#482). const fromStrFull = from ? (from.name && from.email && from.name !== from.email ? `${from.name} <${from.email}>` @@ -377,7 +391,7 @@ export function EmailComposer({ if (mode === 'forward') { return `${prefix}${signatureBlock}\n\n${tQuote('forwarded_separator')}\n${tQuote('from_label')}: ${fromStrFull}\n${tQuote('date_label')}: ${date}\n${tQuote('subject_label')}: ${replyTo.subject || ''}\n\n${originalText}`; } else if (mode === 'reply' || mode === 'replyAll') { - return `${prefix}${signatureBlock}\n\n${tQuote('reply_line', { date, from: fromStr })}\n${quotedText}`; + return `${prefix}${signatureBlock}\n\n${tQuote('reply_line', { date, from: fromStrFull })}\n${quotedText}`; } return prefix; } @@ -399,7 +413,7 @@ export function EmailComposer({ const date = replyTo.receivedAt ? formatDateTime(replyTo.receivedAt, timeFormat, { weekday: 'short', year: 'numeric', month: 'short', day: 'numeric' }) : ""; const from = replyTo.from?.[0]; - const fromStr = from ? `${from.name || from.email}` : tCommon('unknown'); + // Forward and reply quote lines both show the full "Name " sender (#482). const fromStrFull = from ? (from.name && from.email && from.name !== from.email ? `${from.name} <${from.email}>` @@ -434,9 +448,11 @@ export function EmailComposer({ // Build quoted content as HTML if (replyTo.htmlBody && (mode === 'reply' || mode === 'replyAll' || mode === 'forward')) { + // HTML-escape user-controlled values: an unescaped sender "Name " + // has its "" eaten as a bogus HTML tag by the rich-text editor (#482). const quoteHeader = mode === 'forward' - ? `${tQuote('forwarded_separator')}
${tQuote('from_label')}: ${fromStrFull}
${tQuote('date_label')}: ${date}
${tQuote('subject_label')}: ${replyTo.subject || ''}

` - : `${tQuote('reply_line', { date, from: fromStr })}
`; + ? `${tQuote('forwarded_separator')}
${tQuote('from_label')}: ${escapeHtml(fromStrFull)}
${tQuote('date_label')}: ${escapeHtml(date)}
${tQuote('subject_label')}: ${escapeHtml(replyTo.subject || '')}

` + : `${tQuote('reply_line', { date: escapeHtml(date), from: escapeHtml(fromStrFull) })}
`; // Embed the original as a QuotedHtml island (verbatim, schema-free) so // its layout survives the editor round-trip. Sanitize first to strip // scripts/styles/head; cid rewrite afterwards so data-cid markers @@ -450,9 +466,9 @@ export function EmailComposer({ if (replyTo.body) { const escapedOriginal = replyTo.body.replace(/&/g, '&').replace(//g, '>').replace(/\n/g, '
'); if (mode === 'forward') { - return `${prefix}${signatureBlock}

${tQuote('forwarded_separator')}
${tQuote('from_label')}: ${fromStrFull}
${tQuote('date_label')}: ${date}
${tQuote('subject_label')}: ${replyTo.subject || ''}

${escapedOriginal}`; + return `${prefix}${signatureBlock}

${tQuote('forwarded_separator')}
${tQuote('from_label')}: ${escapeHtml(fromStrFull)}
${tQuote('date_label')}: ${escapeHtml(date)}
${tQuote('subject_label')}: ${escapeHtml(replyTo.subject || '')}

${escapedOriginal}`; } else if (mode === 'reply' || mode === 'replyAll') { - return `${prefix}${signatureBlock}

${tQuote('reply_line', { date, from: fromStr })}
${escapedOriginal}
`; + return `${prefix}${signatureBlock}

${tQuote('reply_line', { date: escapeHtml(date), from: escapeHtml(fromStrFull) })}
${escapedOriginal}
`; } } return prefix; @@ -521,11 +537,6 @@ export function EmailComposer({ const [showCloseDialog, setShowCloseDialog] = useState(false); const [showAllAttachments, setShowAllAttachments] = useState(false); const [previewAttachment, setPreviewAttachment] = useState(null); - const [smimeSign_, setSmimeSign] = useState(false); - const [smimeEncrypt_, setSmimeEncrypt] = useState(false); - const [smimePassphrasePrompt, setSmimePassphrasePrompt] = useState<{ keyId: string; resolve: (passphrase: string) => void; reject: () => void } | null>(null); - const [smimePassphraseInput, setSmimePassphraseInput] = useState(''); - const [smimePassphraseError, setSmimePassphraseError] = useState(''); const [showAttachmentWarning, setShowAttachmentWarning] = useState(false); const [attachmentWarningKeyword, setAttachmentWarningKeyword] = useState(''); const [attachmentWarningDelayedUntil, setAttachmentWarningDelayedUntil] = useState(); @@ -677,6 +688,19 @@ export function EmailComposer({ useEffect(() => { if (!autoSelectReplyIdentity) return; if (selectedIdentityId || initialData?.selectedIdentityId) return; + + // New message started from a specific mailbox/account: default the From to + // that mailbox's identity instead of the primary one, so composing while + // viewing info@ sends as info@. Reply/forward fall through to the + // recipient-based resolution below. + if (mode === 'compose') { + const composeIdentityId = findComposeIdentityId(identities, composeFromAccountEmail); + if (composeIdentityId) { + setSelectedIdentityId(composeIdentityId); + } + return; + } + if (mode !== 'reply' && mode !== 'replyAll') return; const resolved = resolveReplyFrom(identities, { @@ -710,6 +734,7 @@ export function EmailComposer({ } }, [ autoSelectReplyIdentity, + composeFromAccountEmail, fromOverrideEnabled, identities, initialData?.selectedIdentityId, @@ -797,38 +822,17 @@ export function EmailComposer({ ? `
${getPlainTextSignature(signatureIdentity).replace(/&/g, '&').replace(//g, '>').replace(/\n/g, '
')}
` : ''; const getAutocomplete = useContactStore((s) => s.getAutocomplete); + const searchRecipients = useContactStore((s) => s.searchRecipients); + // Whether a Sent mailbox is known so the on-demand server search is worth + // offering (falls back to hiding the "search the server" row otherwise). + const canSearchServer = useContactStore((s) => s.sentMailboxId != null); const addToTrustedSendersBook = useContactStore((s) => s.addToTrustedSendersBook); const addTrustedSender = useSettingsStore((s) => s.addTrustedSender); const trustedSendersAddressBook = useSettingsStore((s) => s.trustedSendersAddressBook); const addTemplate = useTemplateStore((s) => s.addTemplate); - const sendRawEmail = useEmailStore((s) => s.sendRawEmail); - const smimeStore = useSmimeStore(); - - // Determine S/MIME availability for the selected identity - const currentSmimeIdentityId = selectedIdentityId || primaryIdentity?.id; - const smimeKeyRecord = currentSmimeIdentityId ? smimeStore.getKeyRecordForIdentity(currentSmimeIdentityId) : undefined; - const canSmimeSign = !!smimeKeyRecord; - const canSmimeEncrypt = (() => { - if (!smimeKeyRecord) return false; - const allRecipients = [ - ...withInput(to, toInput), - ...withInput(cc, ccInput), - ...withInput(bcc, bccInput), - ].map(r => r.email); - if (allRecipients.length === 0) return false; - const { missing } = smimeStore.getRecipientCerts(allRecipients); - return missing.length === 0; - })(); - - // Initialize S/MIME defaults from store when identity changes - useEffect(() => { - if (currentSmimeIdentityId) { - setSmimeSign(!!smimeStore.defaultSignIdentity[currentSmimeIdentityId] && canSmimeSign); - } - setSmimeEncrypt(smimeStore.defaultEncrypt && canSmimeEncrypt); - // Only run when identity changes, not on every recipient edit - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [currentSmimeIdentityId]); + // Sign/encrypt is provided by crypto plugins (S/MIME, PGP) via the + // composer-toolbar slot + the onComposeSend hook — the host stays + // crypto-agnostic. // Serialized recipient strings for ComposerDraftData (string-shaped) and for // by-value dirty comparison. Folds in any uncommitted typed text. @@ -897,6 +901,10 @@ export function EmailComposer({ const [autocompleteResults, setAutocompleteResults] = useState>([]); const [activeAutoField, setActiveAutoField] = useState<'to' | 'cc' | 'bcc' | null>(null); const [autoSelectedIndex, setAutoSelectedIndex] = useState(-1); + // Current trimmed query behind the open dropdown, plus the in-flight flag for + // the on-demand Sent-folder lookup ("search the server" row). + const [autoQuery, setAutoQuery] = useState(''); + const [isSearchingServer, setIsSearchingServer] = useState(false); const autocompleteTimeoutRef = useRef(null); const toInputRef = useRef(null); const ccInputRef = useRef(null); @@ -944,8 +952,10 @@ export function EmailComposer({ setAutocompleteResults([]); setActiveAutoField(null); setAutoSelectedIndex(-1); + setAutoQuery(''); return; } + setAutoQuery(query); autocompleteTimeoutRef.current = setTimeout(async () => { const localResults = getAutocomplete(query); @@ -953,10 +963,39 @@ export function EmailComposer({ const initial: RecipientSuggestion[] = localResults.map(r => ({ name: r.name, email: r.email })); const merged = await contactHooks.onProvideRecipientSuggestions.transform(initial, { query }); setAutocompleteResults(merged.map(s => ({ name: s.name, email: s.email }))); - setActiveAutoField(merged.length > 0 ? field : null); + // Keep the dropdown open even without local hits when a server search is + // available, so the "search the server" row stays reachable (OWA-style). + setActiveAutoField(merged.length > 0 || canSearchServer ? field : null); setAutoSelectedIndex(-1); }, 200); - }, [getAutocomplete]); + }, [getAutocomplete, canSearchServer]); + + // On-demand: search the Sent folder server-side for recipients matching the + // current query and merge fresh hits into the open dropdown (deduped by email). + const handleServerSearch = useCallback(async () => { + const query = autoQuery.trim(); + if (!composerClient || !query || isSearchingServer) return; + setIsSearchingServer(true); + try { + const serverResults = await searchRecipients(composerClient, query); + setAutocompleteResults((prev) => { + const seen = new Set(prev.map((r) => r.email.toLowerCase())); + const merged = [...prev]; + for (const r of serverResults) { + const key = r.email.toLowerCase(); + if (!seen.has(key)) { + seen.add(key); + merged.push(r); + } + } + return merged; + }); + } catch { + // Best-effort: a failed lookup just leaves the local suggestions in place. + } finally { + setIsSearchingServer(false); + } + }, [autoQuery, composerClient, isSearchingServer, searchRecipients]); const insertAutocomplete = (suggestion: { name: string; email: string }, field: 'to' | 'cc' | 'bcc') => { const setter = field === 'to' ? setTo : field === 'cc' ? setCc : setBcc; @@ -1416,6 +1455,7 @@ export function EmailComposer({ const canSend = toAddresses.length > 0 && !!subject && hasContent; const getSendTooltip = (): string | undefined => { + if (isWaitingForUploads) return t('validation.attachments_uploading'); if (canSend) return undefined; if (toAddresses.length === 0) return t('validation.recipient_required'); if (!subject) return t('validation.subject_required'); @@ -1501,8 +1541,43 @@ export function EmailComposer({ const [isSending, setIsSending] = useState(false); const isSendingRef = useRef(false); + // Attachments still uploading when Send is clicked used to be silently + // dropped from the outgoing message (the filters below exclude anything + // with uploading:true). attachmentsRef gives handleSend a way to read the + // freshest attachment state after waiting on in-flight uploads, since the + // `attachments` closure captured at click time won't reflect uploads that + // finish during that wait. + const attachmentsRef = useRef(attachments); + useEffect(() => { + attachmentsRef.current = attachments; + }, [attachments]); + const [isWaitingForUploads, setIsWaitingForUploads] = useState(false); + const sendCancelledRef = useRef(false); + const handleSend = async (skipAttachmentCheck = false, delayedUntil?: string) => { if (isSendingRef.current) return; + + if (attachmentsRef.current.some(att => att.uploading)) { + isSendingRef.current = true; + setIsSending(true); + setIsWaitingForUploads(true); + const uploadResult = await waitForPendingUploads( + () => attachmentsRef.current, + () => sendCancelledRef.current + ); + setIsWaitingForUploads(false); + isSendingRef.current = false; + setIsSending(false); + if (uploadResult === 'cancelled') return; + if (uploadResult === 'failed') { + // An upload broke while we were waiting - the user may not be + // looking at the composer, so auto-sending would silently drop + // the failed attachment. Abort and let them decide. + toast.error(t('validation.attachment_upload_failed')); + return; + } + } + const ccAddresses = withInput(cc, ccInput); const bccAddresses = withInput(bcc, bccInput); @@ -1523,9 +1598,15 @@ export function EmailComposer({ // Attachment reminder check if (!skipAttachmentCheck && attachmentReminderEnabled) { - const hasAttachments = attachments.some(att => att.blobId && !att.uploading && !att.error); + const hasAttachments = attachmentsRef.current.some(att => att.blobId && !att.uploading && !att.error); if (!hasAttachments) { - const bodyText = htmlToPlainText(body); + // Scan only the user-authored text: the quoted original of a + // reply/forward often mentions an attachment itself, which used to fire + // the reminder even when the user typed no keyword and added nothing (#570). + const bodyText = extractUserAuthoredText(body, { + plainTextMode, + forwardedSeparator: tQuote('forwarded_separator'), + }); const searchText = `${subject} ${bodyText}`.toLowerCase(); const matched = attachmentReminderKeywords.find(kw => searchText.includes(kw.toLowerCase())); if (matched) { @@ -1639,7 +1720,7 @@ export function EmailComposer({ textBody: finalBody, identityId: currentIdentity?.id || '', fromEmail, - attachments: attachments + attachments: attachmentsRef.current .filter(att => att.blobId && !att.uploading && !att.error) .map(a => ({ name: a.name, type: a.type || 'application/octet-stream', size: a.size })), inReplyTo: threadingHeaders?.inReplyTo?.[0], @@ -1647,149 +1728,43 @@ export function EmailComposer({ const sendAllowed = await emailHooks.onBeforeEmailSend.intercept(sendablePreview); if (!sendAllowed) return; - // S/MIME send pipeline: build raw MIME → sign → encrypt → sendRawEmail - if ((smimeSign_ || smimeEncrypt_) && client && currentIdentity?.id) { - // S/MIME keys are scoped to one JMAP account's identity - sending - // from a cross-account identity via S/MIME would mix accounts' - // certs/clients. Refuse upfront and tell the user to switch. - const crossAccount = stripCrossAccountIdentityPrefix(currentIdentity.id); - if (crossAccount.localAccountId) { - throw new Error('S/MIME sending from another account’s identity is not supported. Switch to that account first.'); - } - // 1. Resolve S/MIME key - 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)) { - const passphrase = await new Promise((resolve, reject) => { - setSmimePassphrasePrompt({ keyId: smimeKeyRecord.id, resolve, reject }); - }); - try { - await smimeStore.unlockKey(smimeKeyRecord.id, passphrase); - } finally { - setSmimePassphrasePrompt(null); - setSmimePassphraseInput(''); - setSmimePassphraseError(''); - } - } - - // 3. Resolve attachments as ArrayBuffers - const mimeAttachments: MimeAttachment[] = []; - for (const att of attachments) { - if (att.error || att.uploading) continue; - let content: ArrayBuffer; - if (att.file && att.file.size > 0) { - content = await att.file.arrayBuffer(); - } else if (att.blobId && client) { - content = await client.fetchBlobArrayBuffer(att.blobId, att.name, att.type); - } else { - continue; - } - mimeAttachments.push({ - filename: att.name, - contentType: att.type || 'application/octet-stream', - content, - }); - } - for (const inline of inlineAttachments) { - if (!client) break; - const content = await client.fetchBlobArrayBuffer(inline.blobId, inline.name, inline.type); - mimeAttachments.push({ - filename: inline.name, - contentType: inline.type, - content, - cid: inline.cid, - }); - } - - // 4. Build canonical MIME - // mime-builder takes inReplyTo as a single ref-form msg-id (with brackets); - // references stays an array. threadingHeaders contains bare msg-ids. - const mimeInReplyTo = threadingHeaders?.inReplyTo[0] - ? `<${threadingHeaders.inReplyTo[0]}>` - : undefined; - const mimeReferences = threadingHeaders?.references.length - ? threadingHeaders.references.map(id => `<${id}>`) - : undefined; - const mimeBytes = buildMimeMessage({ - from: { name: currentIdentity.name || undefined, email: fromEmail || currentIdentity.email }, - to: toAddresses, - cc: ccAddresses.length > 0 ? ccAddresses : undefined, - bcc: bccAddresses.length > 0 ? bccAddresses : undefined, - subject, - inReplyTo: mimeInReplyTo, - references: mimeReferences, - textBody: finalBody, - htmlBody: finalHtmlBody, - attachments: mimeAttachments.length > 0 ? mimeAttachments : undefined, - }); - - let payload: Blob = new Blob([mimeBytes.buffer as ArrayBuffer], { type: 'message/rfc822' }); - - const smimeHeaders = { - from: { name: currentIdentity.name || undefined, email: fromEmail || currentIdentity.email }, - to: toAddresses, - cc: ccAddresses.length > 0 ? ccAddresses : undefined, - subject, - inReplyTo: mimeInReplyTo, - references: mimeReferences, - }; - - // 5. Sign if enabled - if (smimeSign_ && smimeKeyRecord) { - const privateKey = smimeStore.getUnlockedKey(smimeKeyRecord.id); - if (!privateKey) throw new Error('S/MIME key is not unlocked'); - const cmsBlob = await smimeSign( - mimeBytes, - privateKey, - smimeKeyRecord.certificate, - smimeKeyRecord.certificateChain || [], - ); - const cmsBytes = new Uint8Array(await cmsBlob.arrayBuffer()); - payload = wrapCmsAsSmimeMessage(cmsBytes, { ...smimeHeaders, smimeType: 'signed-data' }); - } - - // 6. Encrypt if enabled - if (smimeEncrypt_ && smimeKeyRecord) { - const allRecipients = [...toAddresses, ...ccAddresses, ...bccAddresses].map(r => r.email); - const { found, missing } = smimeStore.getRecipientCerts(allRecipients); - if (missing.length > 0) { - throw new Error(`Missing certificates for: ${missing.join(', ')}`); - } - const recipientCertsDer = found.map(c => c.certificate instanceof ArrayBuffer ? c.certificate : new Uint8Array(c.certificate as ArrayBuffer).buffer); - const payloadBytes = new Uint8Array(await payload.arrayBuffer()); - const cmsBlob = await smimeEncrypt( - payloadBytes, - recipientCertsDer, - smimeKeyRecord.certificate, - ); - const cmsBytes = new Uint8Array(await cmsBlob.arrayBuffer()); - payload = wrapCmsAsSmimeMessage(cmsBytes, { ...smimeHeaders, smimeType: 'enveloped-data' }); - } - - // 7. Send via raw email path - const result = await sendRawEmail(client, payload, currentIdentity.id, effectiveDelayedUntil, [...toAddresses, ...ccAddresses, ...bccAddresses].map(r => r.email)); - if (effectiveDelayedUntil && finalDraftId) { - client.deleteEmail(finalDraftId).catch(err => { - debug.warn('email', 'Scheduled S/MIME send created, but plaintext draft cleanup failed:', err); - toast.warning(t('schedule_send_cleanup_warning')); + // Hand off to a crypto plugin (S/MIME, PGP, …) if one wants to take over + // the send: it builds raw MIME, signs/encrypts, and submits via + // api.jmap.sendRaw. A handler returning false means "I sent it" — the + // host then skips its own plaintext submission but still cleans up the + // draft (and fires the scheduled-send callback for a delayed send). + const composeSendRequest = { + to: toAddresses.map(r => formatRecipient(r.name, r.email)), + cc: ccAddresses.map(r => formatRecipient(r.name, r.email)), + bcc: bccAddresses.map(r => formatRecipient(r.name, r.email)), + subject, + htmlBody: finalHtmlBody || '', + textBody: finalBody, + identityId: currentIdentity?.id || '', + fromEmail, + fromName, + inReplyTo: threadingHeaders?.inReplyTo?.[0], + references: threadingHeaders?.references, + delayedUntil: effectiveDelayedUntil, + attachments: [ + ...attachmentsRef.current + .filter(att => att.blobId && !att.uploading && !att.error) + .map(a => ({ name: a.name, type: a.type || 'application/octet-stream', size: a.size, blobId: a.blobId })), + ...inlineAttachments.map(a => ({ name: a.name, type: a.type, size: a.size, blobId: a.blobId, cid: a.cid })), + ], + }; + const sendHandledByPlugin = (await emailHooks.onComposeSend.intercept(composeSendRequest)) === false; + if (sendHandledByPlugin) { + if (finalDraftId) { + client?.deleteEmail(finalDraftId).catch((err) => { + debug.warn('email', 'Plugin handled the send, but draft cleanup failed:', err); }); } - if (result.scheduled) { - await onScheduledSendCreated?.(); - } + if (effectiveDelayedUntil) await onScheduledSendCreated?.(); } else { // Standard JMAP send path // Collect uploaded attachment blobIds for the send request - const uploadedAttachments: Array<{ blobId: string; name: string; type: string; size: number; disposition?: 'attachment' | 'inline'; cid?: string }> = attachments + const uploadedAttachments: Array<{ blobId: string; name: string; type: string; size: number; disposition?: 'attachment' | 'inline'; cid?: string }> = attachmentsRef.current .filter(att => att.blobId && !att.uploading && !att.error) .map(att => ({ blobId: att.blobId!, name: att.name, type: att.type || 'application/octet-stream', size: att.size })); uploadedAttachments.push(...inlineAttachments); @@ -1916,6 +1891,7 @@ export function EmailComposer({ }, []); const cleanClose = () => { + sendCancelledRef.current = true; explicitCloseRef.current = true; if (saveTimeoutRef.current) { clearTimeout(saveTimeoutRef.current); @@ -1925,6 +1901,7 @@ export function EmailComposer({ }; const handleSaveDraftAndClose = async () => { + sendCancelledRef.current = true; explicitCloseRef.current = true; setShowCloseDialog(false); if (saveTimeoutRef.current) { @@ -1936,6 +1913,7 @@ export function EmailComposer({ }; const handleDiscardAndClose = () => { + sendCancelledRef.current = true; explicitCloseRef.current = true; setShowCloseDialog(false); if (saveTimeoutRef.current) { @@ -1956,6 +1934,17 @@ export function EmailComposer({ } }; + // Expose the dirty-aware close handler so external hosts (e.g. the Pro tab + // bar) can trigger the same "Save or discard draft?" guard. Re-assigned on + // every render to capture the latest closure over the live refs/state. + useEffect(() => { + if (!requestCloseRef) return; + requestCloseRef.current = handleClose; + return () => { + requestCloseRef.current = null; + }; + }); + const handleComposerKeyDown = (e: React.KeyboardEvent) => { if (e.defaultPrevented) return; @@ -1969,7 +1958,6 @@ export function EmailComposer({ showTemplatePicker || showSaveAsTemplate || showScheduleDialog || - smimePassphrasePrompt || showAttachmentWarning || showCloseDialog ) return; @@ -2002,7 +1990,7 @@ export function EmailComposer({
{/* Right-side composer sidebar slot is rendered after the main content div below. */}
- + {t('send')}
@@ -2206,6 +2194,10 @@ export function EmailComposer({ autoSelectedIndex={autoSelectedIndex} dropdownRef={toDropdownRef} onInsertAutocomplete={insertAutocomplete} + canSearchServer={canSearchServer} + onServerSearch={handleServerSearch} + isSearchingServer={isSearchingServer} + serverSearchQuery={autoQuery} validationError={validationErrors.to} validationMessage={t('validation.recipient_required')} onTab={focusSubject} @@ -2283,6 +2275,10 @@ export function EmailComposer({ autoSelectedIndex={autoSelectedIndex} dropdownRef={ccDropdownRef} onInsertAutocomplete={insertAutocomplete} + canSearchServer={canSearchServer} + onServerSearch={handleServerSearch} + isSearchingServer={isSearchingServer} + serverSearchQuery={autoQuery} onMoveChip={handleMoveChip} />
@@ -2308,6 +2304,10 @@ export function EmailComposer({ autoSelectedIndex={autoSelectedIndex} dropdownRef={bccDropdownRef} onInsertAutocomplete={insertAutocomplete} + canSearchServer={canSearchServer} + onServerSearch={handleServerSearch} + isSearchingServer={isSearchingServer} + serverSearchQuery={autoQuery} onMoveChip={handleMoveChip} />
@@ -2445,7 +2445,7 @@ export function EmailComposer({ )} - {/* S/MIME toggles */} - {canSmimeSign && ( - <> -
- - - - )} + {/* Sign/encrypt controls are contributed by crypto plugins via the + composer-toolbar slot (rendered below). */} {/* Read-receipt request toggle */} )} @@ -2668,60 +2645,6 @@ export function EmailComposer({
)} - {/* S/MIME passphrase prompt */} - {smimePassphrasePrompt && ( -
-
e.stopPropagation()} - className="bg-background border border-border rounded-lg shadow-xl w-full max-w-sm animate-in zoom-in-95 duration-200" - > -
-

{t('smime_unlock_title')}

-

{t('smime_unlock_message')}

- { - setSmimePassphraseInput(e.target.value); - setSmimePassphraseError(''); - }} - onKeyDown={(e) => { - if (e.key === 'Enter' && smimePassphraseInput) { - smimePassphrasePrompt.resolve(smimePassphraseInput); - } - }} - placeholder={t('smime_passphrase_placeholder')} - className="mt-3 w-full px-3 py-2 border border-border rounded-md text-sm bg-background text-foreground outline-none focus:ring-2 focus:ring-primary" - /> - {smimePassphraseError && ( -

{smimePassphraseError}

- )} -
-
- - -
-
-
- )} - {showAttachmentWarning && (
@@ -2795,7 +2718,7 @@ export function EmailComposer({
); @@ -2806,7 +2729,10 @@ const AutocompleteDropdown = React.forwardRef; selectedIndex: number; onSelect: (suggestion: { name: string; email: string }) => void; -}>(function AutocompleteDropdown({ id, results, selectedIndex, onSelect }, ref) { + onSearchServer?: () => void; + isSearchingServer?: boolean; +}>(function AutocompleteDropdown({ id, results, selectedIndex, onSelect, onSearchServer, isSearchingServer }, ref) { + const t = useTranslations('email_composer'); return (
{results.map((r, i) => ( @@ -2817,7 +2743,7 @@ const AutocompleteDropdown = React.forwardRef { @@ -2832,6 +2758,27 @@ const AutocompleteDropdown = React.forwardRef ))} + {onSearchServer && ( + + )}
); }); @@ -2852,6 +2799,10 @@ function RecipientChipInput({ autoSelectedIndex, dropdownRef, onInsertAutocomplete, + canSearchServer, + onServerSearch, + isSearchingServer, + serverSearchQuery, validationError, validationMessage, onTab, @@ -2872,6 +2823,10 @@ function RecipientChipInput({ autoSelectedIndex: number; dropdownRef: React.RefObject; onInsertAutocomplete: (suggestion: { name: string; email: string }, field: 'to' | 'cc' | 'bcc') => void; + canSearchServer: boolean; + onServerSearch: () => void; + isSearchingServer: boolean; + serverSearchQuery: string; validationError?: boolean; validationMessage?: string; onTab?: () => void; @@ -2968,7 +2923,15 @@ function RecipientChipInput({ } } - if ((e.key === ' ' || e.key === 'Enter' || e.key === 'Tab') && inputText.trim()) { + // Enter / Tab commit whatever is typed. Space only commits when the input + // is already a complete email address; otherwise Space is a normal + // character so a name search like "John Doe" can continue past the space + // instead of committing "John" as a bogus recipient (#571). + const trimmedInput = inputText.trim(); + const commitOnKey = + ((e.key === 'Enter' || e.key === 'Tab') && trimmedInput) || + (e.key === ' ' && isValidEmail(trimmedInput)); + if (commitOnKey) { if (e.key !== 'Tab') e.preventDefault(); commitCurrentInput(); if (e.key === 'Tab' && onTab) { @@ -3173,13 +3136,16 @@ function RecipientChipInput({ {validationError && validationMessage && (

{validationMessage}

)} - {activeAutoField === field && autocompleteResults.length > 0 && ( + {activeAutoField === field && + (autocompleteResults.length > 0 || (canSearchServer && serverSearchQuery.length > 0)) && ( onInsertAutocomplete(suggestion, field)} + onSearchServer={canSearchServer && serverSearchQuery.length > 0 ? onServerSearch : undefined} + isSearchingServer={isSearchingServer} /> )} void; onMarkAsRead?: (read: boolean) => void; onToggleStar?: () => void; + onTogglePinned?: () => void; onDelete?: () => void; onArchive?: () => void; onSetColorTag?: (color: string | null) => void; @@ -126,6 +129,7 @@ export function EmailContextMenu({ onForward, onMarkAsRead, onToggleStar, + onTogglePinned, onDelete, onArchive, onSetColorTag, @@ -149,10 +153,14 @@ export function EmailContextMenu({ const emailKeywords = useSettingsStore((state) => state.emailKeywords); const isUnread = !email.keywords?.$seen; const isStarred = email.keywords?.$flagged; + const isPinned = email.keywords?.['$pinned'] === true; const isDraft = email.keywords?.['$draft'] === true; const currentColors = getCurrentColors(email.keywords); const showBatchActions = isMultiSelect && selectedCount > 1; const isInJunkFolder = currentMailboxRole === 'junk'; + // Marking your own outgoing mail as spam makes no sense - hide the action + // in Sent, Drafts and Scheduled. + const spamApplicable = !['sent', 'drafts', 'scheduled'].includes(currentMailboxRole || ''); const isScheduled = email.isScheduled === true; const canCancelScheduled = isScheduled && email.scheduledUndoStatus === 'pending'; @@ -326,7 +334,7 @@ export function EmailContextMenu({
)} {node.children.length > 0 && ( -
+
{renderNodes(node.children)}
)} @@ -349,6 +357,15 @@ export function EmailContextMenu({ /> )} + {/* Pin/Unpin - only for single email; pinned mails float to the top of the list */} + {!showBatchActions && onTogglePinned && ( + handleAction(onTogglePinned)} + /> + )} + {/* Set tag submenu - only for single email */} {!showBatchActions && ( @@ -360,7 +377,7 @@ export function EmailContextMenu({ role="menuitem" onClick={() => handleAction(() => onSetColorTag?.(option.value))} className={cn( - "w-full px-3 py-1.5 text-sm text-left flex items-center gap-2 hover:bg-muted cursor-pointer", + "w-full px-3 py-1.5 text-sm text-start flex items-center gap-2 hover:bg-muted cursor-pointer", isActive && "bg-accent font-medium" )} > @@ -385,22 +402,26 @@ export function EmailContextMenu({ )} - + {/* Spam - contextual based on folder; pointless on own outgoing mail */} + {spamApplicable && ( + <> + - {/* Spam - contextual based on folder */} - - handleAction( - showBatchActions - ? (isInJunkFolder ? onBatchUndoSpam! : onBatchMarkAsSpam!) - : (isInJunkFolder ? onUndoSpam! : onMarkAsSpam!) - ) - } - disabled={showBatchActions ? (isInJunkFolder ? !onBatchUndoSpam : !onBatchMarkAsSpam) : (isInJunkFolder ? !onUndoSpam : !onMarkAsSpam)} - destructive={!isInJunkFolder} - /> + + handleAction( + showBatchActions + ? (isInJunkFolder ? onBatchUndoSpam! : onBatchMarkAsSpam!) + : (isInJunkFolder ? onUndoSpam! : onMarkAsSpam!) + ) + } + disabled={showBatchActions ? (isInJunkFolder ? !onBatchUndoSpam : !onBatchMarkAsSpam) : (isInJunkFolder ? !onUndoSpam : !onMarkAsSpam)} + destructive={!isInJunkFolder} + /> + + )} diff --git a/components/email/email-hover-actions.tsx b/components/email/email-hover-actions.tsx index 4e465fe76..a2691da74 100644 --- a/components/email/email-hover-actions.tsx +++ b/components/email/email-hover-actions.tsx @@ -21,6 +21,8 @@ interface EmailHoverActionsProps { // the spam quick-action flips to "not spam". isInJunk?: boolean; onUndoSpam?: () => void; + // Hidden where marking spam is meaningless for self-authored mail (Drafts, Sent). + spamApplicable?: boolean; } const ACTION_CONFIG: Record state.hoverActions); const hoverActionsMode = useSettingsStore((state) => state.hoverActionsMode); @@ -121,6 +124,7 @@ export function EmailHoverActions({ const actionButtons = hoverActions.map((actionId) => { const config = ACTION_CONFIG[actionId]; if (!config) return null; + if (actionId === "spam" && !spamApplicable) return null; const Icon = config.icon; // In a junk context the spam action becomes "not spam". @@ -177,16 +181,17 @@ export function EmailHoverActions({ return (
-
+
{actionButtons}
diff --git a/components/email/email-list-item.tsx b/components/email/email-list-item.tsx index b6549346c..44c7e9154 100644 --- a/components/email/email-list-item.tsx +++ b/components/email/email-list-item.tsx @@ -5,8 +5,8 @@ import { useCallback } from "react"; import { formatDate, stripInvisibleLeading } from "@/lib/utils"; import { Email } from "@/lib/jmap/types"; import { cn } from "@/lib/utils"; -import { Avatar } from "@/components/ui/avatar"; -import { Paperclip, Star, Circle, CheckSquare, Square, Reply, Forward } from "lucide-react"; +import { SelectableAvatar } from "@/components/email/selectable-avatar"; +import { Paperclip, Star, Pin, Circle, CheckSquare, Square, Reply, Forward } from "lucide-react"; import { useEmailStore } from "@/stores/email-store"; import { useSettingsStore, KEYWORD_PALETTE } from "@/stores/settings-store"; import { useAuthStore } from "@/stores/auth-store"; @@ -34,16 +34,19 @@ interface EmailListItemProps { export function EmailListItem({ email, selected, onClick, onDoubleClick, onContextMenu, onToggleStar, onMarkAsRead, onDelete, onArchive, onSetColorTag, onMarkAsSpam, onUndoSpam }: EmailListItemProps) { const t = useTranslations('email_viewer'); + const tBatch = useTranslations('email_list.batch_actions'); const { selectedEmailIds, toggleEmailSelection, selectRangeEmails, selectedMailbox, mailboxes, clearSelection, isUnifiedView, unifiedRole } = useEmailStore(); const showPreview = useSettingsStore((state) => state.showPreview); const density = useSettingsStore((state) => state.density); const mailLayout = useSettingsStore((state) => state.mailLayout); const emailKeywords = useSettingsStore((state) => state.emailKeywords); + const tintListRowsByTag = useSettingsStore((state) => state.tintListRowsByTag); const showAvatarsInJunk = useSettingsStore((state) => state.showAvatarsInJunk); const { identities } = useAuthStore(); const isChecked = selectedEmailIds.has(email.id); const isUnread = !email.keywords?.$seen; const isStarred = email.keywords?.$flagged; + const isPinned = email.keywords?.['$pinned'] === true; const isImportant = email.keywords?.["$important"]; const isAnswered = email.keywords?.$answered; const isForwarded = email.keywords?.$forwarded; @@ -66,7 +69,7 @@ export function EmailListItem({ email, selected, onClick, onDoubleClick, onConte const keywordDefs = colorTagIds.map(id => emailKeywords.find(k => k.id === id) ?? { id, label: id, color: 'gray' }); // Use first tag for background coloring const keywordDef = keywordDefs[0] ?? null; - const colorTag = keywordDef ? KEYWORD_PALETTE[keywordDef.color]?.bg ?? null : null; + const colorTag = (tintListRowsByTag && keywordDef) ? KEYWORD_PALETTE[keywordDef.color]?.bg ?? null : null; // Drag and drop functionality const { dragHandlers, isDragging } = useEmailDrag({ @@ -87,7 +90,14 @@ export function EmailListItem({ email, selected, onClick, onDoubleClick, onConte const handleCheckboxClick = (e: React.MouseEvent) => { e.stopPropagation(); - toggleEmailSelection(email.id); + if (e.shiftKey) { + // Shift-click extends the selection from the anchor to here, like + // shift-clicking the row (the checkbox stops propagation, so the + // row's shift handler never runs — replicate it here). + selectRangeEmails(email.id); + } else { + toggleEmailSelection(email.id); + } }; const handleContextMenu = (e: React.MouseEvent) => { @@ -166,19 +176,22 @@ export function EmailListItem({ email, selected, onClick, onDoubleClick, onConte {/* Unread indicator */} {isUnread && ( -
+
)} {/* Avatar */} {density !== 'extra-compact' && ( - toggleEmailSelection(email.id)} + selectLabel={tBatch('select')} /> )} @@ -206,6 +219,7 @@ export function EmailListItem({ email, selected, onClick, onDoubleClick, onConte
+ {isPinned && } {isStarred && } {isImportant && } {isAnswered && !isForwarded && } @@ -242,6 +256,9 @@ export function EmailListItem({ email, selected, onClick, onDoubleClick, onConte {sender?.name || sender?.email || "Unknown"}
+ {isPinned && ( + + )} {isStarred && ( )} @@ -327,6 +344,7 @@ export function EmailListItem({ email, selected, onClick, onDoubleClick, onConte onMarkAsSpam={onMarkAsSpam} onUndoSpam={onUndoSpam} isInJunk={currentMailboxRole === 'junk'} + spamApplicable={!['sent', 'drafts', 'scheduled'].includes(currentMailboxRole || '')} />
); diff --git a/components/email/email-list.tsx b/components/email/email-list.tsx index 9c3ec0c71..3df7753d4 100644 --- a/components/email/email-list.tsx +++ b/components/email/email-list.tsx @@ -35,6 +35,7 @@ interface EmailListProps { onForward?: (email: Email) => void; onMarkAsRead?: (email: Email, read: boolean) => void; onToggleStar?: (email: Email) => void; + onTogglePinned?: (email: Email) => void; onDelete?: (email: Email) => void; onArchive?: (email: Email) => void; onSetColorTag?: (emailId: string, color: string | null) => void; @@ -64,6 +65,7 @@ export function EmailList({ onForward, onMarkAsRead, onToggleStar, + onTogglePinned, onDelete, onArchive, onSetColorTag, @@ -415,9 +417,9 @@ export function EmailList({ className="text-destructive border-destructive/30 hover:bg-destructive/10 text-xs" > {isProcessing ? ( - + ) : ( - + )} {t('empty_folder.button')} @@ -551,6 +553,7 @@ export function EmailList({ onForward={() => onForward?.(contextMenu.data!)} onMarkAsRead={(read) => onMarkAsRead?.(contextMenu.data!, read)} onToggleStar={() => onToggleStar?.(contextMenu.data!)} + onTogglePinned={onTogglePinned ? () => onTogglePinned(contextMenu.data!) : undefined} onDelete={() => onDelete?.(contextMenu.data!)} onArchive={() => onArchive?.(contextMenu.data!)} onSetColorTag={(color) => onSetColorTag?.(contextMenu.data!.id, color)} diff --git a/components/email/email-viewer.tsx b/components/email/email-viewer.tsx index a76c8fdda..7055e9288 100644 --- a/components/email/email-viewer.tsx +++ b/components/email/email-viewer.tsx @@ -13,6 +13,7 @@ import { Avatar } from "@/components/ui/avatar"; import { formatFileSize, cn, buildMailboxTree, MailboxNode, formatDateTime, generateUUID } from "@/lib/utils"; import { getSecurityStatus, extractListHeaders } from "@/lib/email-headers"; import { emailToReadView } from "@/lib/plugin-projection"; +import { generateEmailSource } from "@/lib/email-source"; import { Reply, ReplyAll, @@ -87,23 +88,16 @@ import { ReadReceiptBanner } from "./read-receipt-banner"; import { stripCrossAccountIdentityPrefix } from "@/hooks/use-pro-multi-account-identities"; import { useTour } from "@/components/tour/tour-provider"; import { useIsEmbedded } from "@/hooks/use-is-embedded"; -import { SmimePassphraseDialog } from "@/components/settings/smime-passphrase-dialog"; import { findCalendarAttachment, isCalendarMimeType } from "@/lib/calendar-invitation"; import { RecipientPopover } from "./recipient-popover"; import { isFilePreviewable, isMimeTypeSafeForInlinePreview } from "@/lib/file-preview"; -import { SmimeStatusBanner } from "./smime-status-banner"; -import { detectSmime } from "@/lib/smime/smime-detect"; -import { smimeDecrypt, SmimeKeyLockedError, normalizeCmsBytes } from "@/lib/smime/smime-decrypt"; -import { smimeVerify } from "@/lib/smime/smime-verify"; -import { useSmimeStore } from "@/stores/smime-store"; -import type { SmimeStatus } from "@/lib/smime/types"; import { parseTnef, isTnefAttachment } from "@/lib/tnef"; import { debug } from "@/lib/debug"; import type { TnefAttachment } from "@/lib/tnef"; import { PluginSlot } from "@/components/plugins/plugin-slot"; import { usePluginSlotOffers } from "@/hooks/use-plugin-slot-offers"; import { ResizeHandle } from "@/components/layout/resize-handle"; -import { emailHooks, uiHooks } from "@/lib/plugin-hooks"; +import { emailHooks, uiHooks, renderHooks } from "@/lib/plugin-hooks"; import type { AttachmentInfo, AttachmentPreview } from "@/lib/plugin-types"; import { useAttachmentDrag, isDragOutSupported, type AttachmentDragSource } from "@/hooks/use-attachment-drag"; import type { IJMAPClient } from "@/lib/jmap/client-interface"; @@ -252,64 +246,6 @@ const _formatRecipients = ( return displayRecipients.join(', '); }; -function parseMimeHeaders(headerText: string): Map { - const headers = new Map(); - const lines = headerText.split(/\r?\n/); - let currentKey: string | null = null; - - for (const line of lines) { - if (!line) continue; - if (/^[ \t]/.test(line) && currentKey) { - headers.set(currentKey, `${headers.get(currentKey) || ''} ${line.trim()}`.trim()); - continue; - } - - const separatorIndex = line.indexOf(':'); - if (separatorIndex <= 0) continue; - - currentKey = line.slice(0, separatorIndex).trim().toLowerCase(); - headers.set(currentKey, line.slice(separatorIndex + 1).trim()); - } - - return headers; -} - -function getMimeBoundary(contentType: string): string | null { - const match = contentType.match(/boundary=(?:"([^"]+)"|([^;\s]+))/i); - return match?.[1] || match?.[2] || null; -} - -function decodeQuotedPrintableUtf8(input: string): string { - const normalized = input.replace(/=(\r?\n)/g, ''); - const bytes: number[] = []; - - for (let index = 0; index < normalized.length; index++) { - if (normalized[index] === '=' && /^[0-9A-Fa-f]{2}$/.test(normalized.slice(index + 1, index + 3))) { - bytes.push(parseInt(normalized.slice(index + 1, index + 3), 16)); - index += 2; - continue; - } - bytes.push(normalized.charCodeAt(index) & 0xff); - } - - return new TextDecoder().decode(new Uint8Array(bytes)); -} - -function decodeBase64Utf8(input: string): string { - const cleaned = input.replace(/\s/g, ''); - if (!cleaned) return ''; - try { - const binary = atob(cleaned); - const bytes = new Uint8Array(binary.length); - for (let index = 0; index < binary.length; index++) { - bytes[index] = binary.charCodeAt(index); - } - return new TextDecoder().decode(bytes); - } catch { - return input; - } -} - function decodeBase64Bytes(input: string): Uint8Array | null { const cleaned = input.replace(/\s/g, ''); if (!cleaned) return null; @@ -326,21 +262,6 @@ function decodeBase64Bytes(input: string): Uint8Array | null { } } -function splitMimeHeadersAndBody(rawText: string): { headerText: string; bodyText: string } { - const separatorMatch = rawText.match(/\r?\n\r?\n/); - const separatorIndex = separatorMatch?.index ?? -1; - const separator = separatorMatch?.[0] ?? ''; - - if (separatorIndex < 0) { - return { headerText: '', bodyText: rawText }; - } - - return { - headerText: rawText.slice(0, separatorIndex), - bodyText: rawText.slice(separatorIndex + separator.length), - }; -} - function getAttachmentContentBytes(attachment: { content?: ArrayBuffer | Uint8Array | string; encoding?: 'base64' | 'utf8'; @@ -365,89 +286,6 @@ function getAttachmentContentBytes(attachment: { return null; } -function extractNestedSignedDataCandidate( - parsed: { attachments?: Array; headers?: Array<{ key: string; value: string }> }, - rawBytes: Uint8Array, -): { source: string; bytes: ArrayBuffer } | null { - const topLevelContentType = (parsed.headers?.find(h => h.key === 'content-type')?.value || '').toLowerCase(); - if (topLevelContentType.includes('application/pkcs7-mime') && topLevelContentType.includes('signed-data')) { - const rawText = new TextDecoder().decode(rawBytes); - const { bodyText } = splitMimeHeadersAndBody(rawText); - const topLevelTransferEncoding = ( - parsed.headers?.find(h => h.key === 'content-transfer-encoding')?.value || '' - ).toLowerCase(); - - if (topLevelTransferEncoding.includes('base64')) { - const decoded = decodeBase64Bytes(bodyText); - if (decoded) { - return { - source: 'top-level-content-type-body', - bytes: decoded.buffer.slice(decoded.byteOffset, decoded.byteOffset + decoded.byteLength) as ArrayBuffer, - }; - } - } - - const bodyBytes = new TextEncoder().encode(bodyText); - return { - source: 'top-level-content-type-body-text', - bytes: bodyBytes.buffer.slice(bodyBytes.byteOffset, bodyBytes.byteOffset + bodyBytes.byteLength) as ArrayBuffer, - }; - } - - const rawText = new TextDecoder().decode(rawBytes); - const messageContent = splitMimeHeadersAndBody(rawText).bodyText; - const { headerText, bodyText } = splitMimeHeadersAndBody(messageContent); - const bodyHeaders = parseMimeHeaders(headerText); - const bodyContentType = (bodyHeaders.get('content-type') || '').toLowerCase(); - const bodyTransferEncoding = (bodyHeaders.get('content-transfer-encoding') || '').toLowerCase(); - - if (bodyContentType.includes('application/pkcs7-mime') && bodyContentType.includes('signed-data')) { - if (bodyTransferEncoding.includes('base64')) { - const decoded = decodeBase64Bytes(bodyText); - if (decoded) { - return { - source: 'message-body-signed-data', - bytes: decoded.buffer.slice(decoded.byteOffset, decoded.byteOffset + decoded.byteLength) as ArrayBuffer, - }; - } - } - - const bodyBytes = new TextEncoder().encode(bodyText); - return { - source: 'message-body-signed-data-text', - bytes: bodyBytes.buffer.slice(bodyBytes.byteOffset, bodyBytes.byteOffset + bodyBytes.byteLength) as ArrayBuffer, - }; - } - - const nestedAttachment = parsed.attachments?.find(attachment => { - const mimeType = ((attachment as { mimeType?: string }).mimeType || '').toLowerCase(); - const filename = ((attachment as { filename?: string | null }).filename || '').toLowerCase(); - return mimeType.includes('application/pkcs7-mime') || filename.endsWith('.p7m'); - }) as { - filename?: string | null; - mimeType?: string; - encoding?: 'base64' | 'utf8'; - content?: ArrayBuffer | Uint8Array | string; - } | undefined; - - if (!nestedAttachment) { - return null; - } - - const attachmentBytes = getAttachmentContentBytes(nestedAttachment); - if (!attachmentBytes) { - return null; - } - - return { - source: nestedAttachment.mimeType || nestedAttachment.filename || 'attachment-signed-data', - bytes: attachmentBytes.buffer.slice( - attachmentBytes.byteOffset, - attachmentBytes.byteOffset + attachmentBytes.byteLength, - ) as ArrayBuffer, - }; -} - /** * Check if an HTML body string is effectively empty (just boilerplate/whitespace). * Outlook often generates HTML bodies with Word CSS +   but no real text. @@ -463,106 +301,6 @@ function isHtmlBodyEffectivelyEmpty(html: string): boolean { return textContent.length === 0; } -function extractMimePartContent(rawText: string, depth = 0): { html: string | null; text: string | null } { - if (depth > 6) { - const trimmed = rawText.trim(); - return { html: null, text: trimmed || null }; - } - - const separatorMatch = rawText.match(/\r?\n\r?\n/); - const separatorIndex = separatorMatch?.index ?? -1; - const separator = separatorMatch?.[0] ?? ''; - - const headerText = separatorIndex >= 0 ? rawText.slice(0, separatorIndex) : ''; - const bodyText = separatorIndex >= 0 ? rawText.slice(separatorIndex + separator.length) : rawText; - const headers = parseMimeHeaders(headerText); - const contentType = (headers.get('content-type') || '').toLowerCase(); - const transferEncoding = (headers.get('content-transfer-encoding') || '').toLowerCase(); - - if (contentType.includes('multipart/')) { - const boundary = getMimeBoundary(contentType); - if (boundary) { - const boundaryMarker = `--${boundary}`; - const sections = bodyText.split(boundaryMarker); - let bestHtml: string | null = null; - let bestText: string | null = null; - - for (const section of sections) { - const trimmedSection = section.trim(); - if (!trimmedSection || trimmedSection === '--') continue; - const normalizedSection = trimmedSection.endsWith('--') - ? trimmedSection.slice(0, -2).trim() - : trimmedSection; - const extracted = extractMimePartContent(normalizedSection, depth + 1); - if (extracted.html && !bestHtml) { - bestHtml = extracted.html; - } - if (extracted.text && !bestText) { - bestText = extracted.text; - } - if (bestHtml && bestText) break; - } - - return { html: bestHtml, text: bestText }; - } - } - - if (contentType.includes('message/rfc822')) { - return extractMimePartContent(bodyText, depth + 1); - } - - let decodedBody = bodyText; - if (transferEncoding.includes('quoted-printable')) { - decodedBody = decodeQuotedPrintableUtf8(bodyText); - } else if (transferEncoding.includes('base64')) { - decodedBody = decodeBase64Utf8(bodyText); - } - - const trimmedBody = decodedBody.trim(); - if (!trimmedBody) { - return { html: null, text: null }; - } - - if (contentType.includes('text/html')) { - return { html: decodedBody, text: null }; - } - - if (contentType.includes('text/plain')) { - return { html: null, text: decodedBody }; - } - - if (/^\s* }, - rawBytes: Uint8Array, -): { html: string | null; text: string | null; fallbackUsed: boolean } { - const parsedHtml = parsed.html?.trim() ? parsed.html : null; - const parsedText = parsed.text?.trim() ? parsed.text : null; - - if (parsedHtml || parsedText) { - return { html: parsedHtml, text: parsedText, fallbackUsed: false }; - } - - const rawText = new TextDecoder().decode(rawBytes); - const fallback = extractMimePartContent(rawText); - if (fallback.html || fallback.text) { - return { html: fallback.html, text: fallback.text, fallbackUsed: true }; - } - - const trimmed = rawText.trim(); - return { - html: null, - text: trimmed || null, - fallbackUsed: !!trimmed, - }; -} - interface EffectiveAttachment { id: string; name: string | null; @@ -595,7 +333,7 @@ function renderClickableRecipients( return ( - {index > 0 && ,} + {index > 0 && ,} +
{/* Header */}

{t('contact_sidebar.title')}

@@ -869,7 +607,7 @@ function SidebarSection({ icon: Icon, title, children }: { icon: React.Component

{title}

-
{children}
+
{children}
); } @@ -910,7 +648,6 @@ export function EmailViewer({ const tComposer = useTranslations('email_composer'); const tNotifications = useTranslations('notifications'); const tCommon = useTranslations('common'); - const tSmime = useTranslations('smime'); const tFiles = useTranslations('files'); const tDemoWelcome = useTranslations('demo_welcome'); const tWelcome = useTranslations('welcome'); @@ -956,6 +693,9 @@ export function EmailViewer({ // Detect if current mailbox is Junk folder const isInJunkFolder = currentMailboxRole === 'junk'; + // Marking your own outgoing mail as spam makes no sense - hide the action + // in Sent, Drafts and Scheduled. + const spamApplicable = !['sent', 'drafts', 'scheduled'].includes(currentMailboxRole || ''); // Detect if the email is a draft const isDraft = email?.keywords?.['$draft'] === true; @@ -1015,6 +755,19 @@ export function EmailViewer({ const [quickReplyText, setQuickReplyText] = useState(""); const [isQuickReplyFocused, setIsQuickReplyFocused] = useState(false); const [isSendingQuickReply, setIsSendingQuickReply] = useState(false); + const handleSendQuickReply = async () => { + if (!quickReplyText.trim() || !onQuickReply || isSendingQuickReply) return; + setIsSendingQuickReply(true); + try { + await onQuickReply(quickReplyText); + setQuickReplyText(""); + setIsQuickReplyFocused(false); + } catch (error) { + console.error("Failed to send quick reply:", error); + } finally { + setIsSendingQuickReply(false); + } + }; const [showSourceModal, setShowSourceModal] = useState(false); const [moreMenuOpen, setMoreMenuOpen] = useState(false); const [moreMenuSub, setMoreMenuSub] = useState<'move' | 'tag' | null>(null); @@ -1028,15 +781,12 @@ export function EmailViewer({ const currentColors = getCurrentColors(email?.keywords); const currentColor = currentColors[0] ?? null; - // S/MIME state - const [smimeStatus, setSmimeStatus] = useState(null); - const [smimeDecryptedHtml, setSmimeDecryptedHtml] = useState(null); - const [smimeDecryptedText, setSmimeDecryptedText] = useState(null); - const [smimeDecryptedAttachments, setSmimeDecryptedAttachments] = useState([]); - const [smimeUnlockDialogOpen, setSmimeUnlockDialogOpen] = useState(false); - const [smimeUnlockTargetId, setSmimeUnlockTargetId] = useState(null); - const [smimeUnlockError, setSmimeUnlockError] = useState(null); - const smimeStore = useSmimeStore(); + // Crypto-plugin rendered body (S/MIME, PGP, …) — populated by the generic + // onRenderEmailBody hook. Verification/decryption status UI is provided by the + // crypto plugin's own email-banner slot, so the host keeps no S/MIME state. + const [pluginRenderedHtml, setPluginRenderedHtml] = useState(null); + const [pluginRenderedText, setPluginRenderedText] = useState(null); + const [pluginRenderedAttachments, setPluginRenderedAttachments] = useState([]); // TNEF (winmail.dat) support const [tnefHtml, setTnefHtml] = useState(null); @@ -1053,6 +803,9 @@ export function EmailViewer({ // sessions so the panel reopens the way the user last left it. const detailSlots = usePluginSlotOffers('email-detail-sidebar'); const hasDetailSidebar = detailSlots.length > 0; + // Whether any plugin offers a "more details" section, so we only render the + // bottom plugin category wrapper when something will fill it. + const hasDetailsSlotOffers = usePluginSlotOffers('email-details-section').length > 0; const [detailSidebarCollapsed, setDetailSidebarCollapsed] = useState(() => { if (typeof window === 'undefined') return false; try { return localStorage.getItem('emailDetailSidebarCollapsed') === '1'; } catch { return false; } @@ -1073,11 +826,6 @@ export function EmailViewer({ try { localStorage.setItem('emailDetailSidebarWidth', String(detailSidebarWidth)); } catch { /* ignore */ } }, [detailSidebarWidth]); - // Ensure S/MIME key records are loaded from IndexedDB - useLayoutEffect(() => { - smimeStore.load(activeAccountId ?? undefined); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [activeAccountId]); // Build mailbox tree for move-to dropdown const moveTargetIds = useMemo(() => new Set( @@ -1348,13 +1096,9 @@ export function EmailViewer({ setIsQuickReplyFocused(false); setShowSourceModal(false); setEmailViewDarkOverride(null); - setSmimeStatus(null); - setSmimeDecryptedHtml(null); - setSmimeDecryptedText(null); - setSmimeDecryptedAttachments([]); - setSmimeUnlockDialogOpen(false); - setSmimeUnlockTargetId(null); - setSmimeUnlockError(null); + setPluginRenderedHtml(null); + setPluginRenderedText(null); + setPluginRenderedAttachments([]); setTnefHtml(null); setTnefText(null); setTnefAttachments([]); @@ -1364,613 +1108,82 @@ export function EmailViewer({ setEmbeddedEmailUnwrapped(false); }, [email?.id, externalContentPolicy]); - const prepareSmimeUnlock = useCallback((keyRecordId: string) => { - setSmimeUnlockTargetId(keyRecordId); - setSmimeUnlockError(null); - }, []); - - const openSmimeUnlockDialog = useCallback(() => { - if (!smimeUnlockTargetId) { - return; - } - - setSmimeUnlockDialogOpen(true); - }, [smimeUnlockTargetId]); - - const handleSmimeUnlockSubmit = useCallback(async (passphrase: string) => { - if (!smimeUnlockTargetId) { - return; - } - - try { - await smimeStore.unlockKey(smimeUnlockTargetId, passphrase); - setSmimeUnlockDialogOpen(false); - setSmimeUnlockTargetId(null); - setSmimeUnlockError(null); - } catch (error) { - setSmimeUnlockError(error instanceof Error ? error.message : 'Unlock failed'); - } - }, [smimeStore, smimeUnlockTargetId]); - - // S/MIME detection and processing + // Crypto-plugin body takeover (S/MIME, PGP, …). A privileged crypto plugin + // can fetch the raw message via api.jmap.fetchBlob, decrypt/verify it, and + // return a replaced body through the onRenderEmailBody hook. The host stays + // crypto-agnostic; the plugin renders its own verification/encryption status + // via its email-banner slot. Falls through to normal rendering otherwise. useEffect(() => { - if (!email || !client) return; - - const smimeDebug = (...args: unknown[]) => { - if (useSettingsStore.getState().debugMode) { - console.debug(...args); - } - }; + if (!email) return; + let cancelled = false; - const smimeWarn = (...args: unknown[]) => { - if (useSettingsStore.getState().debugMode) { - console.warn(...args); + const dataUrlToBytes = (dataUrl: string): Uint8Array | null => { + try { + const comma = dataUrl.indexOf(','); + if (comma < 0) return null; + const meta = dataUrl.slice(0, comma); + const data = dataUrl.slice(comma + 1); + if (meta.includes(';base64')) { + const bin = atob(data); + const u8 = new Uint8Array(bin.length); + for (let i = 0; i < bin.length; i++) u8[i] = bin.charCodeAt(i); + return u8; + } + return new TextEncoder().encode(decodeURIComponent(data)); + } catch { + return null; } }; - const smimeError = (...args: unknown[]) => { - console.error(...args); - }; - - const rawContentType = email.headers?.['content-type'] || email.headers?.['Content-Type']; - const contentType = Array.isArray(rawContentType) ? rawContentType[0] : rawContentType; - const detection = detectSmime( - contentType, - email.bodyStructure as Parameters[1], - email.attachments as Parameters[2], - ); - - smimeDebug('[S/MIME] detection:', { contentType, bodyStructure: email.bodyStructure, attachments: email.attachments, detection }); - - if (!detection.type) return; - - // Unsupported type (e.g., detached signature) - if (!detection.supported) { - setSmimeStatus({ - isSigned: detection.type === 'detached-sig', - isEncrypted: false, - unsupportedReason: 'Detached S/MIME signatures are not yet supported', - }); - return; - } - - if (!detection.blobId) return; - - let cancelled = false; - - async function processSmime() { + (async () => { try { - const toHex = (bytes: Uint8Array, count: number) => - Array.from(bytes.slice(0, count)).map(b => b.toString(16).padStart(2, '0')).join(' '); - - const toAsciiPreview = (bytes: Uint8Array, count: number) => { - try { - return new TextDecoder().decode(bytes.slice(0, count)); - } catch { - return ''; - } - }; - - const toExactArrayBuffer = (view: Uint8Array): ArrayBuffer => - view.buffer.slice(view.byteOffset, view.byteOffset + view.byteLength) as ArrayBuffer; - - const cmsCandidates: Array<{ source: string; raw: ArrayBuffer }> = []; - - const findPartById = ( - part: Parameters[1], - targetPartId: string, - ): Parameters[1] | undefined => { - if (!part) return undefined; - if (part.partId === targetPartId) return part; - if (part.subParts) { - for (const sub of part.subParts) { - const found = findPartById(sub as Parameters[1], targetPartId); - if (found) return found; - } - } - return undefined; + const rawContentType = email.headers?.['content-type'] || email.headers?.['Content-Type']; + const contentType = Array.isArray(rawContentType) ? rawContentType[0] : rawContentType; + const initialBody = { html: '', text: '', attachments: [] as unknown[] }; + const ctx = { + id: email.id, + contentType, + bodyStructure: email.bodyStructure, + attachments: email.attachments, + blobId: email.blobId, + from: email.from, }; - - const detectedPart = detection.partId - ? findPartById(email!.bodyStructure as Parameters[1], detection.partId) - : undefined; - const detectedPartName = detectedPart?.name || 'smime.p7m'; - const detectedPartType = detectedPart?.type || 'application/pkcs7-mime'; - - const detectedPartSize = (detectedPart as { size?: number } | undefined)?.size; - if (detectedPartSize === 0) { - smimeWarn('[S/MIME] detected part has size=0; trying multiple blob fetch variants', { - partId: detection.partId, - blobId: detection.blobId, - name: detectedPartName, - type: detectedPartType, - }); - } - - // Primary source: Blob/download endpoint - try { - const blobBytes = await client!.fetchBlobArrayBuffer(detection.blobId!); - if (blobBytes.byteLength > 0) { - cmsCandidates.push({ source: 'blob-default', raw: blobBytes }); - } - smimeWarn('[S/MIME] blob-default fetch result:', { - byteLength: blobBytes.byteLength, - }); - } catch (error) { - smimeWarn('[S/MIME] blob fetch failed:', error); - // Fallback sources below may still work - } - - // Variant source: same blob with explicit part name/type in URL template - try { - const typedBlobBytes = await client!.fetchBlobArrayBuffer( - detection.blobId!, - detectedPartName, - detectedPartType, - ); - if (typedBlobBytes.byteLength > 0) { - cmsCandidates.push({ source: 'blob-typed', raw: typedBlobBytes }); - } - smimeWarn('[S/MIME] blob-typed fetch result:', { - byteLength: typedBlobBytes.byteLength, - name: detectedPartName, - type: detectedPartType, - }); - } catch (error) { - smimeWarn('[S/MIME] typed blob fetch failed:', error); - } - - // Fallback source: bodyValues entry for the detected S/MIME part - const bodyValue = detection.partId ? email!.bodyValues?.[detection.partId]?.value : undefined; - const bodyValueMeta = detection.partId ? email!.bodyValues?.[detection.partId] : undefined; - smimeWarn('[S/MIME] bodyValues candidate:', { - partId: detection.partId, - exists: !!bodyValueMeta, - valueLength: bodyValue?.length ?? 0, - isTruncated: bodyValueMeta?.isTruncated ?? false, - isEncodingProblem: bodyValueMeta?.isEncodingProblem ?? false, - }); - if (bodyValue) { - const bodyValueBytes = new TextEncoder().encode(bodyValue); - cmsCandidates.push({ source: 'bodyValues', raw: toExactArrayBuffer(bodyValueBytes) }); - } - - // Fallback source: fetch full RFC822 blob and extract CMS bytes from message body - // Some servers return empty bytes for part blobId=0 while Email.blobId still has full content. - if (email!.blobId) { - try { - const fullMessageBytes = await client!.fetchBlobArrayBuffer( - email!.blobId, - 'message.eml', - 'message/rfc822', - ); - if (fullMessageBytes.byteLength > 0) { - cmsCandidates.push({ source: 'email-blob', raw: fullMessageBytes }); - } - smimeWarn('[S/MIME] email-blob fetch result:', { - blobId: email!.blobId, - byteLength: fullMessageBytes.byteLength, - }); - } catch (error) { - smimeWarn('[S/MIME] email-blob fetch failed:', error); - } - } else { - smimeWarn('[S/MIME] email-blob unavailable: Email.blobId not present'); - } - - if (cmsCandidates.length === 0) { - throw new Error('No usable CMS bytes found (blob-default/blob-typed/bodyValues/email-blob all empty)'); - } - - const expandedCandidates: Array<{ source: string; raw: ArrayBuffer }> = []; - - for (const candidate of cmsCandidates) { - expandedCandidates.push(candidate); - - if (candidate.source === 'email-blob') { - // Candidate 1: raw message body (strip RFC822 headers) - try { - const fullText = new TextDecoder().decode(candidate.raw); - const headerEnd = fullText.search(/\r?\n\r?\n/); - if (headerEnd >= 0) { - const headerSep = fullText.slice(headerEnd).match(/^\r?\n\r?\n/)?.[0] ?? '\r\n\r\n'; - const bodyText = fullText.slice(headerEnd + headerSep.length); - if (bodyText.trim().length > 0) { - const bodyBytes = new TextEncoder().encode(bodyText); - expandedCandidates.push({ - source: 'email-blob-body', - raw: toExactArrayBuffer(bodyBytes), - }); - } - } - } catch { - // ignore extraction failures - } - - // Candidate 2: parse MIME and extract pkcs7 attachment content - try { - const { default: PostalMime } = await import('postal-mime'); - const parser = new PostalMime(); - const parsedFull = await parser.parse(candidate.raw); - const smimeAttachment = parsedFull.attachments?.find(att => { - const mimeType = ((att as { mimeType?: string }).mimeType || '').toLowerCase(); - const filename = ((att as { filename?: string }).filename || '').toLowerCase(); - return mimeType.includes('application/pkcs7-mime') || filename.endsWith('.p7m'); - }); - - if (smimeAttachment) { - const content = (smimeAttachment as { content?: unknown }).content; - if (content instanceof Uint8Array) { - expandedCandidates.push({ - source: 'email-blob-attachment', - raw: toExactArrayBuffer(content), - }); - } else if (content instanceof ArrayBuffer) { - expandedCandidates.push({ - source: 'email-blob-attachment', - raw: content, - }); - } else if (typeof content === 'string') { - const contentBytes = new TextEncoder().encode(content); - expandedCandidates.push({ - source: 'email-blob-attachment', - raw: toExactArrayBuffer(contentBytes), - }); - } - } - } catch (error) { - smimeWarn('[S/MIME] email-blob MIME parse/extract failed:', error); - } - } - } - - const normalizedCandidates = expandedCandidates.map(candidate => ({ - source: candidate.source, - raw: candidate.raw, - normalized: normalizeCmsBytes(candidate.raw), - })); - - const candidateSummaries = normalizedCandidates.map((candidate, index) => { - const rawBytes = new Uint8Array(candidate.raw); - const normalizedBytes = new Uint8Array(candidate.normalized); - return { - index, - source: candidate.source, - rawLength: candidate.raw.byteLength, - normalizedLength: candidate.normalized.byteLength, - rawFirstBytesHex: toHex(rawBytes, 24), - normalizedFirstBytesHex: toHex(normalizedBytes, 24), - rawAsciiPreview: toAsciiPreview(rawBytes, 180), - }; - }); - - smimeWarn('[S/MIME] CMS candidates:', { - detection, - candidateCount: candidateSummaries.length, - candidates: candidateSummaries, - }); - - if (useSettingsStore.getState().debugMode && typeof window !== 'undefined') { - const debugPayload = { - emailId: email!.id, - detection, - generatedAt: new Date().toISOString(), - candidates: candidateSummaries, - }; - - const exportCandidate = (index = 0, normalized = true) => { - const candidate = normalizedCandidates[index]; - if (!candidate) { - throw new Error(`Invalid candidate index: ${index}`); - } - const bytes = normalized ? candidate.normalized : candidate.raw; - const mode = normalized ? 'normalized' : 'raw'; - const filename = `smime-${email!.id}-${candidate.source}-${index}-${mode}.p7m`; - const blob = new Blob([bytes], { type: 'application/pkcs7-mime' }); - const url = URL.createObjectURL(blob); - const anchor = document.createElement('a'); - anchor.href = url; - anchor.download = filename; - document.body.appendChild(anchor); - anchor.click(); - anchor.remove(); - setTimeout(() => URL.revokeObjectURL(url), 1000); - return { filename, byteLength: bytes.byteLength, source: candidate.source, mode }; - }; - - (window as unknown as { - __smimeDebugLast?: unknown; - __smimeDebugExport?: (index?: number, normalized?: boolean) => unknown; - }).__smimeDebugLast = debugPayload; - (window as unknown as { - __smimeDebugLast?: unknown; - __smimeDebugExport?: (index?: number, normalized?: boolean) => unknown; - }).__smimeDebugExport = exportCandidate; - - smimeWarn('[S/MIME] debug helpers ready: window.__smimeDebugLast, window.__smimeDebugExport(index, normalized=true)'); - } - - const isCmsParseError = (error: unknown) => { - if (!(error instanceof Error)) return false; - return ( - error.message.includes('Invalid ASN.1 data') || - error.message.includes('Unexpected CMS content type') || - error.message.includes('Object\'s schema was not verified against input data for ContentInfo') - ); + const result = await renderHooks.onRenderEmailBody.transform(initialBody, ctx) as { + html?: string; + text?: string; + attachments?: Array<{ name?: string; type?: string; size?: number; dataUrl?: string; cid?: string }>; + handledBy?: string; }; - - const fromEmail = email!.from?.[0]?.email; - - if (detection.type === 'enveloped-data') { - // Encrypted message - const { keyRecords, unlockedDecryptionKeys, unlockedLegacyDecryptionKeys } = smimeStore; - smimeDebug('[S/MIME] decrypt attempt:', { - keyRecordCount: keyRecords.length, - unlockedKeyCount: unlockedDecryptionKeys.size, - legacyKeyCount: unlockedLegacyDecryptionKeys.size, - keyRecordIds: keyRecords.map(k => k.id), - }); - - // Short-circuit: no keys imported at all - if (keyRecords.length === 0) { - smimeDebug('[S/MIME] no key records available, skipping decrypt'); - setSmimeStatus({ - isSigned: false, - isEncrypted: true, - decryptionError: 'no-key', - }); - return; - } - - try { - let result: Awaited> | null = null; - let lastError: unknown = null; - - for (const candidate of normalizedCandidates) { - try { - result = await smimeDecrypt({ - cmsBytes: candidate.normalized, - keyRecords, - unlockedKeys: unlockedDecryptionKeys, - legacyUnlockedKeys: unlockedLegacyDecryptionKeys, - }); - smimeDebug('[S/MIME] decrypt success with candidate:', { - source: candidate.source, - byteLength: candidate.normalized.byteLength, - }); - break; - } catch (error) { - lastError = error; - smimeWarn('[S/MIME] decrypt candidate failed:', { - source: candidate.source, - error: error instanceof Error ? error.message : String(error), - }); - // SmimeKeyLockedError should bubble up immediately so the UI can prompt for passphrase - if (error instanceof SmimeKeyLockedError) { - throw error; - } - // For other errors (CMS parse, decrypt failure), try the next candidate - } - } - - if (!result) { - throw lastError instanceof Error ? lastError : new Error('Decryption failed'); - } - - if (cancelled) return; - - // Parse inner MIME - const { default: PostalMime } = await import('postal-mime'); - const parser = new PostalMime(); - const parsed = await parser.parse(result.mimeBytes); - if (cancelled) return; - const parsedContent = getRenderableSmimeContent(parsed, result.mimeBytes); - smimeDebug('[S/MIME] decrypted MIME parsed:', { - subject: parsed.subject, - htmlLength: parsed.html?.length ?? 0, - textLength: parsed.text?.length ?? 0, - attachmentCount: parsed.attachments?.length ?? 0, - fallbackUsed: parsedContent.fallbackUsed, - renderHtmlLength: parsedContent.html?.length ?? 0, - renderTextLength: parsedContent.text?.length ?? 0, - }); - - // Check if inner content is also signed - const nestedSignedData = extractNestedSignedDataCandidate(parsed, result.mimeBytes); - if (nestedSignedData) { - // Nested sign-then-encrypt - verify inner signature - const innerBytes = normalizeCmsBytes(nestedSignedData.bytes); - smimeDebug('[S/MIME] nested signed-data candidate:', { - source: nestedSignedData.source, - byteLength: innerBytes.byteLength, - }); - try { - const verifyResult = await smimeVerify(innerBytes, fromEmail); - if (cancelled) return; - // Parse the verified inner content - const innerParsed = await new PostalMime().parse(verifyResult.mimeBytes); - if (cancelled) return; - const innerParsedContent = getRenderableSmimeContent(innerParsed, verifyResult.mimeBytes); - smimeDebug('[S/MIME] verified inner MIME parsed:', { - subject: innerParsed.subject, - htmlLength: innerParsed.html?.length ?? 0, - textLength: innerParsed.text?.length ?? 0, - attachmentCount: innerParsed.attachments?.length ?? 0, - fallbackUsed: innerParsedContent.fallbackUsed, - renderHtmlLength: innerParsedContent.html?.length ?? 0, - renderTextLength: innerParsedContent.text?.length ?? 0, - }); - setSmimeDecryptedHtml(innerParsedContent.html); - setSmimeDecryptedText(innerParsedContent.text); - setSmimeDecryptedAttachments(innerParsed.attachments ?? []); - setSmimeStatus({ - ...verifyResult.status, - isEncrypted: true, - decryptionSuccess: true, - }); - // Auto-import signer cert if enabled - if (smimeStore.autoImportSignerCerts && verifyResult.status.signatureValid && verifyResult.status.signerCert) { - const existing = smimeStore.getPublicCertForEmail(verifyResult.status.signerCert.email); - if (!existing) { - try { - await smimeStore.importPublicCert(verifyResult.status.signerCert.certificate, 'signed-email'); - smimeDebug('[S/MIME] auto-imported signer cert:', { email: verifyResult.status.signerCert.email, fingerprint: verifyResult.status.signerCert.fingerprint }); - } catch (importErr) { - smimeError('[S/MIME] auto-import signer cert failed:', importErr); - } - } else { - smimeDebug('[S/MIME] signer cert already imported:', { email: existing.email, fingerprint: existing.fingerprint }); - } - } else if (verifyResult.status.signatureValid && verifyResult.status.signerCert) { - smimeDebug('[S/MIME] auto-import disabled, skipping signer cert:', { email: verifyResult.status.signerCert.email }); - } - } catch (error) { - smimeError('[S/MIME] nested signature verify failed:', { - source: nestedSignedData.source, - error: error instanceof Error ? error.message : String(error), - }); - // Verification failed but decryption worked - setSmimeDecryptedHtml(parsedContent.html); - setSmimeDecryptedText(parsedContent.text); - setSmimeDecryptedAttachments((parsed.attachments ?? []) as PostalMimeAttachment[]); - setSmimeStatus({ - isSigned: false, - isEncrypted: true, - decryptionSuccess: true, - }); - } - } else { - setSmimeDecryptedHtml(parsedContent.html); - setSmimeDecryptedText(parsedContent.text); - setSmimeDecryptedAttachments((parsed.attachments ?? []) as PostalMimeAttachment[]); - setSmimeStatus({ - isSigned: false, - isEncrypted: true, - decryptionSuccess: true, - }); - } - } catch (err) { - if (cancelled) return; - smimeError('[S/MIME] decrypt error:', err); - if (err instanceof SmimeKeyLockedError) { - prepareSmimeUnlock(err.keyRecordId); - setSmimeStatus({ - isSigned: false, - isEncrypted: true, - decryptionError: 'locked', - }); - } else { - const errMsg = err instanceof Error ? err.message : 'Decryption failed'; - const isNoKeyError = errMsg.includes('No imported S/MIME key matches'); - setSmimeStatus({ - isSigned: false, - isEncrypted: true, - decryptionError: isNoKeyError ? 'no-key' : errMsg, - }); - } - } - } else if (detection.type === 'signed-data') { - // Signed message - try { - let result: Awaited> | null = null; - let lastError: unknown = null; - - for (const candidate of normalizedCandidates) { - try { - result = await smimeVerify(candidate.normalized, fromEmail); - smimeDebug('[S/MIME] verify success with candidate:', { - source: candidate.source, - byteLength: candidate.normalized.byteLength, - }); - break; - } catch (error) { - lastError = error; - smimeWarn('[S/MIME] verify candidate failed:', { - source: candidate.source, - error: error instanceof Error ? error.message : String(error), - }); - if (!isCmsParseError(error)) { - throw error; - } - } - } - - if (!result) { - throw lastError instanceof Error ? lastError : new Error('Verification failed'); - } - - if (cancelled) return; - - // Parse inner MIME - const { default: PostalMime } = await import('postal-mime'); - const parser = new PostalMime(); - const parsed = await parser.parse(result.mimeBytes); - if (cancelled) return; - const parsedContent = getRenderableSmimeContent(parsed, result.mimeBytes); - smimeDebug('[S/MIME] verified MIME parsed:', { - subject: parsed.subject, - htmlLength: parsed.html?.length ?? 0, - textLength: parsed.text?.length ?? 0, - attachmentCount: parsed.attachments?.length ?? 0, - fallbackUsed: parsedContent.fallbackUsed, - renderHtmlLength: parsedContent.html?.length ?? 0, - renderTextLength: parsedContent.text?.length ?? 0, - }); - - setSmimeDecryptedHtml(parsedContent.html); - setSmimeDecryptedText(parsedContent.text); - setSmimeDecryptedAttachments((parsed.attachments ?? []) as PostalMimeAttachment[]); - setSmimeStatus(result.status); - // Auto-import signer cert if enabled - if (smimeStore.autoImportSignerCerts && result.status.signatureValid && result.status.signerCert) { - const existing = smimeStore.getPublicCertForEmail(result.status.signerCert.email); - if (!existing) { - try { - await smimeStore.importPublicCert(result.status.signerCert.certificate, 'signed-email'); - smimeDebug('[S/MIME] auto-imported signer cert:', { email: result.status.signerCert.email, fingerprint: result.status.signerCert.fingerprint }); - } catch (importErr) { - smimeError('[S/MIME] auto-import signer cert failed:', importErr); - } - } else { - smimeDebug('[S/MIME] signer cert already imported:', { email: existing.email, fingerprint: existing.fingerprint }); - } - } else if (result.status.signatureValid && result.status.signerCert) { - smimeDebug('[S/MIME] auto-import disabled, skipping signer cert:', { email: result.status.signerCert.email }); - } - } catch (err) { - if (cancelled) return; - setSmimeStatus({ - isSigned: true, - isEncrypted: false, - signatureValid: false, - signatureError: err instanceof Error ? err.message : 'Verification failed', - }); - } + if (cancelled) return; + if (!result || !result.handledBy) { + setPluginRenderedHtml(null); + setPluginRenderedText(null); + setPluginRenderedAttachments([]); + return; } + setPluginRenderedHtml(typeof result.html === 'string' && result.html ? result.html : null); + setPluginRenderedText(typeof result.text === 'string' && result.text ? result.text : null); + // Normalise the plugin's attachment shape into the PostalMime-like shape + // the viewer's download / inline-image machinery already understands. + const atts = Array.isArray(result.attachments) ? result.attachments : []; + const decoded = atts.map((a) => ({ + filename: a.name ?? null, + mimeType: a.type || 'application/octet-stream', + contentId: a.cid, + content: (a.dataUrl ? dataUrlToBytes(a.dataUrl) : null) ?? new Uint8Array(0), + } as unknown as PostalMimeAttachment)); + setPluginRenderedAttachments(decoded); } catch (err) { if (cancelled) return; - smimeError('[S/MIME] processing failed before decrypt/verify:', err); - // Failed to fetch CMS blob - setSmimeStatus({ - isSigned: false, - isEncrypted: detection.type === 'enveloped-data', - decryptionError: err instanceof Error ? err.message : 'Failed to fetch encrypted content', - }); + debug.error('onRenderEmailBody hook failed:', err); + setPluginRenderedHtml(null); + setPluginRenderedText(null); + setPluginRenderedAttachments([]); } - } + })(); - processSmime(); return () => { cancelled = true; }; - }, [ - email, - client, - prepareSmimeUnlock, - smimeStore.autoImportSignerCerts, - smimeStore.keyRecords, - smimeStore.unlockedDecryptionKeys, - smimeStore.unlockedLegacyDecryptionKeys, - smimeStore, - ]); + }, [email]); // TNEF (winmail.dat) detection and processing useEffect(() => { @@ -2143,7 +1356,7 @@ export function EmailViewer({ let cancelled = false; const objectUrls: string[] = []; - const decryptedCidAttachments = smimeDecryptedAttachments.filter(att => att.contentId); + const decryptedCidAttachments = pluginRenderedAttachments.filter(att => att.contentId); if (decryptedCidAttachments.length > 0) { const urls: Record = {}; @@ -2204,11 +1417,11 @@ export function EmailViewer({ cancelled = true; objectUrls.forEach(url => URL.revokeObjectURL(url)); }; - }, [client, email?.id, smimeDecryptedAttachments, email?.attachments]); + }, [client, email?.id, pluginRenderedAttachments, email?.attachments]); const effectiveAttachments = useMemo(() => { - if (smimeDecryptedAttachments.length > 0) { - return smimeDecryptedAttachments + if (pluginRenderedAttachments.length > 0) { + return pluginRenderedAttachments .filter(att => !(hideInlineImageAttachments && att.contentId && (att.mimeType || '').startsWith('image/'))) .map((attachment, index) => ({ id: `smime-${index}-${attachment.filename || attachment.mimeType}`, @@ -2270,7 +1483,7 @@ export function EmailViewer({ // attachment list — and its downstream layout measurement — on every email // field change. // eslint-disable-next-line react-hooks/exhaustive-deps - }, [email?.attachments, smimeDecryptedAttachments, tnefHtml, tnefText, tnefAttachments, embeddedEmailUnwrapped, embeddedEmailAttachments, calendarInvitationParsingEnabled, hideInlineImageAttachments]); + }, [email?.attachments, pluginRenderedAttachments, tnefHtml, tnefText, tnefAttachments, embeddedEmailUnwrapped, embeddedEmailAttachments, calendarInvitationParsingEnabled, hideInlineImageAttachments]); // Measure attachment chips in the below-header row to determine how many fit // on a single line; the rest collapse into a "+N attachments" overflow pill. @@ -2310,144 +1523,6 @@ export function EmailViewer({ }, [effectiveAttachments, attachmentPosition, imageThumbUrls]); // Generate email source for viewing - const generateEmailSource = (email: Email): string => { - let source = ''; - - // Headers - source += '=== EMAIL HEADERS ===\n\n'; - if (email.messageId) source += `Message-ID: ${email.messageId}\n`; - if (email.from) source += `From: ${email.from.map(a => a.name ? `${a.name} <${a.email}>` : a.email).join(', ')}\n`; - if (email.to) source += `To: ${email.to.map(a => a.name ? `${a.name} <${a.email}>` : a.email).join(', ')}\n`; - if (email.cc) source += `Cc: ${email.cc.map(a => a.name ? `${a.name} <${a.email}>` : a.email).join(', ')}\n`; - if (email.bcc) source += `Bcc: ${email.bcc.map(a => a.name ? `${a.name} <${a.email}>` : a.email).join(', ')}\n`; - if (email.replyTo) source += `Reply-To: ${email.replyTo.map(a => a.name ? `${a.name} <${a.email}>` : a.email).join(', ')}\n`; - if (email.subject) source += `Subject: ${email.subject}\n`; - if (email.sentAt) source += `Date: ${new Date(email.sentAt).toUTCString()}\n`; - if (email.receivedAt) source += `Received-At: ${new Date(email.receivedAt).toUTCString()}\n`; - if (email.inReplyTo) source += `In-Reply-To: ${email.inReplyTo.join(', ')}\n`; - if (email.references) source += `References: ${email.references.join(', ')}\n`; - - // Additional headers - if (email.headers) { - source += '\n--- Additional Headers ---\n'; - // Headers should now always be a Record after client processing - Object.entries(email.headers).forEach(([key, value]) => { - const val = Array.isArray(value) ? value.join('\n ') : String(value); - source += `${key}: ${val}\n`; - }); - } - - // Authentication results - if (email.authenticationResults) { - source += '\n--- Authentication Results ---\n'; - if (email.authenticationResults.spf) { - source += `SPF: ${email.authenticationResults.spf.result}`; - if (email.authenticationResults.spf.domain) source += ` (${email.authenticationResults.spf.domain})`; - source += '\n'; - } - if (email.authenticationResults.dkim) { - source += `DKIM: ${email.authenticationResults.dkim.result}`; - if (email.authenticationResults.dkim.domain) source += ` (${email.authenticationResults.dkim.domain})`; - source += '\n'; - } - if (email.authenticationResults.dmarc) { - source += `DMARC: ${email.authenticationResults.dmarc.result}`; - if (email.authenticationResults.dmarc.policy) source += ` policy=${email.authenticationResults.dmarc.policy}`; - source += '\n'; - } - } - - if (email.spamScore !== undefined) { - source += `Spam Score: ${email.spamScore}`; - if (email.spamStatus) source += ` (${email.spamStatus})`; - source += '\n'; - } - - // Metadata - source += '\n=== EMAIL METADATA ===\n\n'; - source += `Email ID: ${email.id}\n`; - source += `Thread ID: ${email.threadId}\n`; - source += `Size: ${formatFileSize(email.size)}\n`; - source += `Has Attachment: ${email.hasAttachment ? 'Yes' : 'No'}\n`; - if (email.keywords) { - const keywords = Object.entries(email.keywords) - .filter(([_, v]) => v) - .map(([k]) => k) - .join(', '); - if (keywords) source += `Keywords: ${keywords}\n`; - } - - // Attachments - if (email.attachments && email.attachments.length > 0) { - source += '\n=== ATTACHMENTS ===\n\n'; - email.attachments.forEach((att, i) => { - source += `[${i + 1}] ${att.name || 'Unnamed'}\n`; - source += ` Type: ${att.type}\n`; - source += ` Size: ${formatFileSize(att.size)}\n`; - source += ` Blob ID: ${att.blobId}\n`; - if (att.cid) source += ` Content-ID: ${att.cid}\n`; - source += '\n'; - }); - } - - // Body content - source += '\n=== EMAIL BODY ===\n\n'; - - let hasBodyContent = false; - - // Text version - if (email.textBody?.[0]?.partId && email.bodyValues?.[email.textBody[0].partId]) { - const textValue = email.bodyValues[email.textBody[0].partId].value; - if (textValue && textValue.trim()) { - source += '--- Plain Text Version ---\n\n'; - source += textValue; - source += '\n\n'; - hasBodyContent = true; - } - } - - // HTML version - if (email.htmlBody?.[0]?.partId && email.bodyValues?.[email.htmlBody[0].partId]) { - const htmlValue = email.bodyValues[email.htmlBody[0].partId].value; - if (htmlValue && htmlValue.trim()) { - source += '--- HTML Version ---\n\n'; - source += htmlValue; - source += '\n\n'; - hasBodyContent = true; - } - } - - // All body values if we haven't found content yet - if (!hasBodyContent && email.bodyValues) { - const bodyKeys = Object.keys(email.bodyValues); - if (bodyKeys.length > 0) { - source += '--- Body Parts ---\n\n'; - bodyKeys.forEach((key, index) => { - const bodyValue = email.bodyValues![key].value; - if (bodyValue && bodyValue.trim()) { - source += `Part ${index + 1} (${key}):\n`; - source += bodyValue; - source += '\n\n'; - hasBodyContent = true; - } - }); - } - } - - // Preview if no body - if (!hasBodyContent && email.preview) { - source += '--- Preview Only ---\n\n'; - source += email.preview; - source += '\n'; - } - - if (!hasBodyContent && !email.preview) { - source += '(No body content available)\n'; - } - - return source; - }; - const copySourceToClipboard = async () => { if (!email) return; @@ -2622,18 +1697,18 @@ export function EmailViewer({ // Override email content with S/MIME decrypted content when available const effectiveEmailContent = useMemo(() => { - if (smimeDecryptedHtml) { - const htmlWithCidUrls = smimeDecryptedHtml.replace( + if (pluginRenderedHtml) { + const htmlWithCidUrls = pluginRenderedHtml.replace( /\bcid:([^"'\s)]+)/gi, (_match, cidRef) => { return cidBlobUrls[cidRef] || 'data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7'; } ); const cleanHtml = DOMPurify.sanitize(htmlWithCidUrls, EMAIL_IFRAME_SANITIZE_CONFIG); - return { html: cleanHtml, isHtml: true, hasStyleTag: /]/i.test(smimeDecryptedHtml), externalBlocked: false }; + return { html: cleanHtml, isHtml: true, hasStyleTag: /]/i.test(pluginRenderedHtml), externalBlocked: false }; } - if (smimeDecryptedText) { - return { html: plainTextToSafeHtml(smimeDecryptedText), isHtml: false, hasStyleTag: false, externalBlocked: false }; + if (pluginRenderedText) { + return { html: plainTextToSafeHtml(pluginRenderedText), isHtml: false, hasStyleTag: false, externalBlocked: false }; } // TNEF (winmail.dat) extracted content if (tnefHtml) { @@ -2652,7 +1727,7 @@ export function EmailViewer({ return { html: plainTextToSafeHtml(embeddedEmailText), isHtml: false, hasStyleTag: false, externalBlocked: false }; } return emailContent; - }, [cidBlobUrls, emailContent, smimeDecryptedHtml, smimeDecryptedText, tnefHtml, tnefText, embeddedEmailHtml, embeddedEmailText]); + }, [cidBlobUrls, emailContent, pluginRenderedHtml, pluginRenderedText, tnefHtml, tnefText, embeddedEmailHtml, embeddedEmailText]); const resolveAttachmentName = useCallback( (attachment: EffectiveAttachment) => { @@ -3042,6 +2117,12 @@ export function EmailViewer({ fit - the latter wraps header text to one character per line, which reads as 90deg-rotated vertical headers (issue #409). */ html { overflow: hidden; height: auto !important; } + /* Some emails put height:100% on a full-bleed wrapper table/div (not html/body), + which - with body's overflow:hidden - clips the content to a sliver, and the + scrollHeight-based auto-resize then locks the iframe short (a Box.co.il + verification email rendered as a logo-only 150px strip). Neutralise the + full-height trick on any element so the body grows to its content. */ + [style*="height:100%"], [style*="height: 100%"] { height: auto !important; } body { margin: 0; padding: ${bodyPadding}; overflow-x: auto; overflow-y: hidden; height: auto !important; 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; } @media (max-width: 640px) { body { padding-left: ${mobileBodyPaddingX}; padding-right: ${mobileBodyPaddingX}; } } img { max-width: 100% !important; height: auto !important; } @@ -3089,17 +2170,52 @@ export function EmailViewer({ const doc = iframe.contentDocument; if (doc?.body) { // Auto-resize iframe to fit content - const resizeObserver = new ResizeObserver(() => { - const height = doc.documentElement.scrollHeight; + // Measure max(documentElement, body): a height:100% wrapper can leave + // documentElement.scrollHeight short while the real content lives in body. + const applyHeight = () => { + if (iframe.contentDocument !== doc) return; // navigated away; stale + const height = Math.max(doc.documentElement.scrollHeight, doc.body.scrollHeight); iframe.style.height = height + 'px'; lastBodyHeightRef.current = height; - }); + }; + const resizeObserver = new ResizeObserver(applyHeight); resizeObserver.observe(doc.body); - const initialHeight = doc.documentElement.scrollHeight; - iframe.style.height = initialHeight + 'px'; - lastBodyHeightRef.current = initialHeight; + applyHeight(); + // The ResizeObserver only fires on body's border box; a content overflow + // that grows scrollHeight without resizing that box (e.g. a height:100% + // wrapper, or images that reflow the layout after onload) is otherwise + // missed and the iframe stays short. Re-measure on a fixed cadence over a + // short settle window, then stop — a self-clearing catch-all that does + // not depend on image load/error events firing (blocked images may fire + // neither). Cheap: ~12 scrollHeight reads, no early-stop heuristic to + // mis-trigger on a brief-stable-then-grow reflow. + const poll = window.setInterval(() => { + if (iframe.contentDocument !== doc) { window.clearInterval(poll); return; } + applyHeight(); + }, 200); + window.setTimeout(() => window.clearInterval(poll), 2400); setIframeReady(true); + // Hide images that fail to load (dead/mixed-content/unreachable external + // URLs) rather than leaving the browser's broken-image placeholder and + // alt text, which read as stray label text in an otherwise image-only + // email (e.g. a blocked "logo" alt). Blocked images already carry a 1x1 + // transparent pixel (naturalWidth 1) and display:none, so they're skipped. + const hideIfBroken = (img: HTMLImageElement) => { + if (img.complete && img.naturalWidth === 0 && img.getAttribute('src')) { + img.style.display = 'none'; + } + }; + doc.querySelectorAll('img').forEach((el) => { + const img = el as HTMLImageElement; + if (img.complete) { + hideIfBroken(img); + } else { + img.addEventListener('error', () => { img.style.display = 'none'; }, { once: true }); + img.addEventListener('load', () => hideIfBroken(img), { once: true }); + } + }); + // Make links open in new tab doc.querySelectorAll('a').forEach(a => { a.setAttribute('target', '_blank'); @@ -3521,7 +2637,7 @@ export function EmailViewer({

{tDemoWelcome('title')}

{tDemoWelcome('description')}

-
+
{tDemoWelcome('feature_email')} @@ -3563,7 +2679,7 @@ export function EmailViewer({

{t('no_conversation_description')}

{onCompose && ( )} @@ -3588,7 +2704,7 @@ export function EmailViewer({ variant="ghost" size="icon" onClick={onBack} - className="h-9 w-9 flex-shrink-0 -ml-1" + className="h-9 w-9 flex-shrink-0 -ms-1" aria-label={t('back_to_list')} > @@ -3723,7 +2839,7 @@ export function EmailViewer({ {showToolbarLabels && {t('move')}} {moveMenuOpen && ( -
+
{(() => { const renderNodes = (nodes: MailboxNode[], depth = 0) => { return nodes.map((node) => { @@ -3734,7 +2850,7 @@ export function EmailViewer({ {isTarget ? ( {tagMenuOpen && ( -
+
{colorOptions.map((option) => { const isActive = currentColors.includes(option.value); return ( @@ -3802,13 +2918,13 @@ export function EmailViewer({ key={option.value} onClick={() => { if (email) onSetColorTag?.(email.id, option.value); setTagMenuOpen(false); }} className={cn( - "w-full px-3 py-1.5 text-sm text-left hover:bg-muted flex items-center gap-2", + "w-full px-3 py-1.5 text-sm text-start hover:bg-muted flex items-center gap-2", isActive && "bg-accent font-medium" )} > {option.name} - {isActive && } + {isActive && } ); })} @@ -3817,7 +2933,7 @@ export function EmailViewer({
{/* Spam */} - {(onMarkAsSpam || onUndoSpam) && ( + {spamApplicable && (onMarkAsSpam || onUndoSpam) && ( {moreMenuOpen && !isMobile && ( -
+
{/* Star toggle */} {moreMenuSub === 'move' && ( -
+
{(() => { const renderMobileNodes = (nodes: MailboxNode[], depth = 0) => { return nodes.map((node) => { @@ -3988,7 +3104,7 @@ export function EmailViewer({ {isTarget ? ( {moreMenuSub === 'tag' && ( -
+
{colorOptions.map((option) => { const isActive = currentColors.includes(option.value); return ( @@ -4037,13 +3153,13 @@ export function EmailViewer({ key={option.value} onClick={() => { if (email) onSetColorTag?.(email.id, option.value); setMoreMenuOpen(false); setMoreMenuSub(null); }} className={cn( - "w-full px-3 py-1.5 text-sm text-left hover:bg-muted flex items-center gap-2", + "w-full px-3 py-1.5 text-sm text-start hover:bg-muted flex items-center gap-2", isActive && "bg-accent font-medium" )} > {option.name} - {isActive && } + {isActive && } ); })} @@ -4052,7 +3168,7 @@ export function EmailViewer({
)} {/* Overflow: spam */} - {(onMarkAsSpam || onUndoSpam) && ( + {spamApplicable && (onMarkAsSpam || onUndoSpam) && ( ); })} {currentColors.length > 0 && (
{/* Date/time on the right of subject row - hidden on mobile, shown next to sender */} -
+
{formatDateTime(email.receivedAt, timeFormat, { weekday: 'short', year: 'numeric', month: 'short', day: 'numeric' })} @@ -4453,7 +3569,7 @@ export function EmailViewer({ name={sender?.name} email={sender.email} onViewContact={handleViewContactSidebar} - className="font-semibold text-left" + className="font-semibold text-start" /> ) : ( {t('unknown_sender')} @@ -4517,7 +3633,7 @@ export function EmailViewer({ )}
{/* Date/time + size on the right (mobile) */} -
+
{formatDateTime(email.receivedAt, timeFormat, { weekday: 'short', year: 'numeric', month: 'short', day: 'numeric' })} @@ -4861,6 +3977,22 @@ export function EmailViewer({ const hasListInfo = !!(listHeaders?.listId || listHeaders?.listUnsubscribe || listHeaders?.listHelp || listHeaders?.listPost); const hasAuthSection = !!(auth?.spf || auth?.dkim || auth?.dmarc || auth?.iprev || email.spamScore !== undefined || email.spamLLM); + // Projected, read-only view handed to plugins that render in the + // "more details" panel. Includes the parsed `headers` map and full + // `source` so plugins can inspect raw headers / message source. + // Built lazily here - only when the details panel is expanded. + const detailsView = emailToReadView(email); + // Lets a plugin add rows under an existing category. The plugin's + // own `shouldShow({ email, category })` decides which category it + // appears under (or `category === null` for the new bottom section). + const CategorySlot = ({ category }: { category: string }) => ( + + ); + return (
@@ -4874,7 +4006,7 @@ export function EmailViewer({ email={sender?.email || ''} displayLabel={sender?.name && sender?.email ? `${sender.name} <${sender.email}>` : undefined} onViewContact={handleViewContactSidebar} - className="text-sm text-left" + className="text-sm text-start" />
@@ -4918,6 +4050,7 @@ export function EmailViewer({ )} + {hasAuthSection && ( @@ -5003,6 +4136,7 @@ export function EmailViewer({
)} + )} @@ -5037,6 +4171,7 @@ export function EmailViewer({ {email.threadId} )} + )} @@ -5064,6 +4199,7 @@ export function EmailViewer({ {email.accountLabel} )} + {hasListInfo && ( @@ -5089,6 +4225,18 @@ export function EmailViewer({ {listHeaders.listPost} )} + + + )} + + {/* Plugin-supplied category. Plugins whose shouldShow accepts + `category === null` render their own titled section here. */} + {hasDetailsSlotOffers && ( +
+
)}
@@ -5096,18 +4244,6 @@ export function EmailViewer({ ); })()} - {/* S/MIME Status Banner */} - {smimeStatus && ( -
-
- -
-
- )} - {/* Scheduled Banner */} {isScheduled && (
@@ -5166,18 +4302,6 @@ export function EmailViewer({
)} - { - setSmimeUnlockDialogOpen(false); - setSmimeUnlockError(null); - }} - onSubmit={handleSmimeUnlockSubmit} - title={tSmime('unlock_key')} - description={tSmime('unlock_key_desc')} - error={smimeUnlockError} - /> - {/* Unified Notification Banner - External Content + Calendar Invitation + Read Receipt */} {((hasBlockedContent && !allowExternalContent && externalContentPolicy !== 'allow') || hasCalendarInvitation || @@ -5410,7 +4534,7 @@ export function EmailViewer({ {getAttachmentDisplayName(attachment.name, attachment.type)} - + {formatFileSize(attachment.size)}
@@ -5548,7 +4672,7 @@ export function EmailViewer({ {getAttachmentDisplayName(attachment.name, attachment.type)} - + {formatFileSize(attachment.size)}
@@ -5646,6 +4770,12 @@ export function EmailViewer({ value={quickReplyText} onChange={(e) => setQuickReplyText(e.target.value)} onFocus={() => setIsQuickReplyFocused(true)} + onKeyDown={(e) => { + if ((e.metaKey || e.ctrlKey) && e.key === "Enter") { + e.preventDefault(); + void handleSendQuickReply(); + } + }} placeholder={t('quick_reply_placeholder')} className={cn( "w-full px-3 py-2 text-sm border border-border bg-background text-foreground rounded-lg", @@ -5685,35 +4815,22 @@ export function EmailViewer({ disabled={isSendingQuickReply} className="text-muted-foreground" > - + {t('more_options')} + ); +} diff --git a/components/email/smime-status-banner.tsx b/components/email/smime-status-banner.tsx deleted file mode 100644 index b17b0fd4a..000000000 --- a/components/email/smime-status-banner.tsx +++ /dev/null @@ -1,138 +0,0 @@ -"use client"; - -import React from "react"; -import { ShieldCheck, ShieldAlert, ShieldX, Lock, LockOpen, AlertTriangle, Info } from "lucide-react"; -import { cn } from "@/lib/utils"; -import { useTranslations } from "next-intl"; -import type { SmimeStatus } from "@/lib/smime/types"; - -interface SmimeStatusBannerProps { - status: SmimeStatus; - onUnlockKey?: () => void; - className?: string; -} - -type SmimeVariant = 'success' | 'warning' | 'error' | 'info'; - -const variantTone: Record = { - success: 'bg-success/15 text-success', - warning: 'bg-warning/15 text-warning', - error: 'bg-destructive/15 text-destructive', - info: 'bg-info/15 text-info', -}; - -export function SmimeStatusBanner({ status, onUnlockKey, className }: SmimeStatusBannerProps) { - const t = useTranslations('smime'); - - const items: Array<{ - icon: React.ReactNode; - text: string; - variant: SmimeVariant; - }> = []; - - // Encryption status - if (status.isEncrypted) { - if (status.decryptionError) { - if (status.decryptionError === 'locked') { - items.push({ - icon: , - text: t('unlock_key_desc'), - variant: 'warning', - }); - } else if (status.decryptionError === 'no-key') { - items.push({ - icon: , - text: t('status_encrypted_no_key'), - variant: 'warning', - }); - } else { - items.push({ - icon: , - text: t('status_encrypted_failed'), - variant: 'error', - }); - } - } else { - items.push({ - icon: , - text: t('status_encrypted_ok'), - variant: 'success', - }); - } - } - - // Signature status - if (status.isSigned) { - if (status.signatureValid === true) { - if (status.selfSigned) { - items.push({ - icon: , - text: t('status_signed_self_signed'), - variant: 'warning', - }); - } else if (status.signerEmailMatch === false) { - items.push({ - icon: , - text: t('status_signed_mismatch'), - variant: 'warning', - }); - } else { - items.push({ - icon: , - text: t('status_signed_valid'), - variant: 'success', - }); - } - } else if (status.signatureValid === false) { - items.push({ - icon: , - text: status.signatureError || t('status_signed_invalid'), - variant: 'error', - }); - } - } - - // Unsupported S/MIME - if (status.unsupportedReason) { - items.push({ - icon: , - text: t('status_unsupported'), - variant: 'info', - }); - } - - if (items.length === 0) return null; - - return ( -
- {items.map((item, i) => ( -
-
- {item.icon} -
-
-
-
- S/MIME -
-
- {item.text} -
-
- {item.variant === 'warning' && status.decryptionError === 'locked' && onUnlockKey && ( - - )} -
-
- ))} -
- ); -} diff --git a/components/email/text-direction-extension.ts b/components/email/text-direction-extension.ts new file mode 100644 index 000000000..8ba3922b1 --- /dev/null +++ b/components/email/text-direction-extension.ts @@ -0,0 +1,116 @@ +/** + * Tiptap extension for text direction (LTR / RTL). + * + * Adds a `dir` attribute to heading and paragraph nodes and provides + * `setTextDirection` and `toggleTextDirection` chain commands so the + * toolbar button can switch directions per block. + */ +import { Extension } from "@tiptap/core"; + +export type TextDirectionValue = "ltr" | "rtl" | null; + +declare module "@tiptap/core" { + interface Commands { + textDirection: { + /** Set explicit direction on the current block(s). */ + setTextDirection: (direction: TextDirectionValue) => ReturnType; + /** Toggle between "rtl" and "ltr" on the current block(s). */ + toggleTextDirection: () => ReturnType; + }; + } +} + +/** + * Unicode ranges used for auto-detection of the first strong character. + * Ranges cover Arabic, Hebrew, Syriac, Thaana, N'Ko, Samaritan, Mandaic, + * and supplemental Arabic blocks — essentially every RTL script in active use. + */ +const RTL_RANGES = [ + [0x0590, 0x08ff], // Hebrew, Arabic, Syriac, Thaana, NKo, Samaritan, Mandaic + [0xfb1d, 0xfdff], // Hebrew presentation forms, Arabic presentation forms-A + [0xfe70, 0xfefc], // Arabic presentation forms-B + [0x10800, 0x10fff], // Cypriot, Imperial Aramaic, Palmyrene, etc. + [0x1e800, 0x1edff], // Mende Kikakui, Adlam + [0x1ee00, 0x1eeff], // Arabic mathematical alphabetic symbols +]; + +function isRtlChar(codePoint: number): boolean { + return RTL_RANGES.some(([lo, hi]) => codePoint >= lo && codePoint <= hi); +} + +/** + * Detect the natural direction of text by scanning for the first strong + * character. Returns "rtl" if the first strong character is RTL, "ltr" + * otherwise. + */ +export function detectTextDirection(text: string): "ltr" | "rtl" { + for (const char of text) { + const cp = char.codePointAt(0); + if (cp === undefined) continue; + if (isRtlChar(cp)) return "rtl"; + // Any Latin, Cyrillic, Greek, or other LTR letter is a strong LTR signal + if ( + (cp >= 0x0041 && cp <= 0x005a) || // A-Z + (cp >= 0x0061 && cp <= 0x007a) || // a-z + (cp >= 0x0400 && cp <= 0x04ff) || // Cyrillic + (cp >= 0x0370 && cp <= 0x03ff) || // Greek + (cp >= 0x0900 && cp <= 0x097f) || // Devanagari + (cp >= 0x4e00 && cp <= 0x9fff) // CJK + ) { + return "ltr"; + } + } + return "ltr"; +} + +export const TextDirection = Extension.create({ + name: "textDirection", + + addGlobalAttributes() { + return [ + { + types: ["heading", "paragraph"], + attributes: { + dir: { + default: null as string | null, + parseHTML: (element: HTMLElement) => { + const dir = element.getAttribute("dir"); + if (dir === "rtl") return "rtl"; + if (dir === "ltr") return "ltr"; + return null; + }, + renderHTML: (attributes: Record) => { + if (attributes.dir === "rtl") return { dir: "rtl" }; + if (attributes.dir === "ltr") return { dir: "ltr" }; + return {}; + }, + }, + }, + }, + ]; + }, + + addCommands() { + return { + setTextDirection: + (direction: TextDirectionValue) => + ({ commands }) => { + if (direction === null) { + // Unset direction on current blocks + return commands.updateAttributes("heading", { dir: null }) && + commands.updateAttributes("paragraph", { dir: null }); + } + return commands.updateAttributes("heading", { dir: direction }) && + commands.updateAttributes("paragraph", { dir: direction }); + }, + toggleTextDirection: + () => + ({ editor, commands }) => { + const currentDir = editor.getAttributes("paragraph").dir || + editor.getAttributes("heading").dir; + const next: TextDirectionValue = currentDir === "rtl" ? "ltr" : "rtl"; + return commands.setTextDirection(next); + }, + }; + }, +}); diff --git a/components/email/text-direction.ts b/components/email/text-direction.ts new file mode 100644 index 000000000..7edf34b57 --- /dev/null +++ b/components/email/text-direction.ts @@ -0,0 +1,58 @@ +import { Extension } from "@tiptap/core"; + +export type TextDir = "ltr" | "rtl"; + +declare module "@tiptap/core" { + interface Commands { + textDirection: { + setTextDirection: (dir: TextDir) => ReturnType; + unsetTextDirection: () => ReturnType; + }; + } +} + +/** + * Adds a `dir` attribute to block nodes so the composer can mark individual + * paragraphs/headings as LTR or RTL (Gmail-style right-to-left editing). The + * attribute round-trips to HTML so the direction is preserved in the sent mail. + */ +export const TextDirection = Extension.create({ + name: "textDirection", + + addOptions() { + return { types: ["paragraph", "heading", "blockquote", "listItem"] }; + }, + + addGlobalAttributes() { + return [ + { + types: this.options.types, + attributes: { + dir: { + default: null, + parseHTML: (element) => element.getAttribute("dir") || null, + renderHTML: (attributes) => + attributes.dir ? { dir: attributes.dir } : {}, + }, + }, + }, + ]; + }, + + addCommands() { + return { + setTextDirection: + (dir) => + ({ commands }) => + this.options.types.every((type: string) => + commands.updateAttributes(type, { dir }), + ), + unsetTextDirection: + () => + ({ commands }) => + this.options.types.every((type: string) => + commands.resetAttributes(type, "dir"), + ), + }; + }, +}); diff --git a/components/email/thread-conversation-view.tsx b/components/email/thread-conversation-view.tsx index 526599c2b..37552e93e 100644 --- a/components/email/thread-conversation-view.tsx +++ b/components/email/thread-conversation-view.tsx @@ -150,7 +150,7 @@ export function ThreadConversationView({
@@ -488,13 +488,13 @@ function EmailCard({
{/* Card Header - Always visible */} )} @@ -671,7 +671,7 @@ function EmailCard({ }} className="flex-1" > - + {t("email_viewer.reply_all")} )} @@ -685,7 +685,7 @@ function EmailCard({ }} className="flex-1" > - + {t("email_viewer.forward")} )} diff --git a/components/email/thread-email-item.tsx b/components/email/thread-email-item.tsx index 11cfd637e..bd35f4c59 100644 --- a/components/email/thread-email-item.tsx +++ b/components/email/thread-email-item.tsx @@ -86,8 +86,8 @@ export function ThreadEmailItem({ {...longPressHandlers} className={cn( "relative cursor-pointer select-none transition-all duration-150", - "pl-12 pr-4", - "border-l-2 border-l-transparent", + "ps-12 pe-4", + "border-s-2 border-l-transparent", selected ? "bg-selection border-l-primary" : "hover:bg-muted/50", @@ -130,7 +130,7 @@ export function ThreadEmailItem({ {/* Unread indicator */} {isUnread && ( -
+
)} diff --git a/components/email/thread-list-item.tsx b/components/email/thread-list-item.tsx index ebd8ceb0c..8e44f2a2e 100644 --- a/components/email/thread-list-item.tsx +++ b/components/email/thread-list-item.tsx @@ -4,8 +4,8 @@ import React, { useCallback } from "react"; import { formatDate, formatDateTime, stripInvisibleLeading } from "@/lib/utils"; import { Email, ThreadGroup, ALL_MAIL_MAILBOX_ID } from "@/lib/jmap/types"; import { cn } from "@/lib/utils"; -import { Avatar } from "@/components/ui/avatar"; -import { Paperclip, Star, Circle, ChevronRight, ChevronDown, Loader2, MessageSquare, CheckSquare, Square, Reply, Forward, CalendarClock, Folder } from "lucide-react"; +import { SelectableAvatar } from "@/components/email/selectable-avatar"; +import { Paperclip, Star, Pin, Circle, ChevronRight, ChevronDown, Loader2, MessageSquare, CheckSquare, Square, Reply, Forward, CalendarClock, Folder } from "lucide-react"; import { useSettingsStore, KEYWORD_PALETTE } from "@/stores/settings-store"; import { useUIStore } from "@/stores/ui-store"; import { useEmailStore } from "@/stores/email-store"; @@ -75,8 +75,10 @@ interface SingleEmailItemProps { const SingleEmailItem = React.forwardRef( function SingleEmailItem({ email, selected, onClick, onDoubleClick, onContextMenu, showPreview, colorTag, onToggleStar, onMarkAsRead, onDelete, onArchive, onSetColorTag, onMarkAsSpam, onUndoSpam }, ref) { const t = useTranslations('email_viewer'); + const tBatch = useTranslations('email_list.batch_actions'); const isUnread = !email.keywords?.$seen; const isStarred = email.keywords?.$flagged; + const isPinned = email.keywords?.['$pinned'] === true; const isAnswered = email.keywords?.$answered; const isForwarded = email.keywords?.$forwarded; const { selectedMailbox, mailboxes, selectedEmailIds, toggleEmailSelection, selectRangeEmails, clearSelection, isUnifiedView, unifiedRole } = useEmailStore(); @@ -88,6 +90,7 @@ const SingleEmailItem = React.forwardRef( const showRecipient = currentMailboxRole === 'sent' || currentMailboxRole === 'drafts'; const sender = showRecipient ? (email.to?.[0] ?? email.from?.[0]) : email.from?.[0]; const emailKeywords = useSettingsStore((state) => state.emailKeywords); + const tintListRowsByTag = useSettingsStore((state) => state.tintListRowsByTag); const density = useSettingsStore((state) => state.density); const mailLayout = useSettingsStore((state) => state.mailLayout); const timeFormat = useSettingsStore((state) => state.timeFormat); @@ -111,7 +114,7 @@ const SingleEmailItem = React.forwardRef( const tagIds = getEmailColorTags(email.keywords); const resolvedKeywordDefs = tagIds.map(id => emailKeywords.find(k => k.id === id) ?? { id, label: id, color: 'gray' }); const resolvedKeywordDef = resolvedKeywordDefs[0] ?? null; - const resolvedColorTag = (() => { + const resolvedColorTag = !tintListRowsByTag ? null : (() => { if (colorTag) return colorTag; return resolvedKeywordDef ? KEYWORD_PALETTE[resolvedKeywordDef.color]?.bg ?? null : null; })(); @@ -134,7 +137,11 @@ const SingleEmailItem = React.forwardRef( const handleCheckboxClick = (e: React.MouseEvent) => { e.stopPropagation(); - toggleEmailSelection(email.id); + if (e.shiftKey) { + selectRangeEmails(email.id); + } else { + toggleEmailSelection(email.id); + } }; const handleContextMenu = (e: React.MouseEvent) => { @@ -211,18 +218,21 @@ const SingleEmailItem = React.forwardRef( )} {isUnread && ( -
+
)} {density !== 'extra-compact' && ( - toggleEmailSelection(email.id)} + selectLabel={tBatch('select')} /> )} @@ -256,6 +266,7 @@ const SingleEmailItem = React.forwardRef(
+ {isPinned && } {isStarred && } {isAnswered && !isForwarded && } {isForwarded && !isAnswered && } @@ -308,6 +319,9 @@ const SingleEmailItem = React.forwardRef( {sender?.name || sender?.email || "Unknown"}
+ {isPinned && ( + + )} {isStarred && ( )} @@ -397,6 +411,7 @@ const SingleEmailItem = React.forwardRef( onMarkAsSpam={onMarkAsSpam} onUndoSpam={onUndoSpam} isInJunk={currentMailboxRole === 'junk'} + spamApplicable={!['sent', 'drafts', 'scheduled'].includes(currentMailboxRole || '')} /> )}
@@ -427,13 +442,14 @@ export const ThreadListItem = React.forwardRef state.showPreview); const density = useSettingsStore((state) => state.density); const mailLayout = useSettingsStore((state) => state.mailLayout); const timeFormat = useSettingsStore((state) => state.timeFormat); const showAvatarsInJunk = useSettingsStore((state) => state.showAvatarsInJunk); const isMobile = useUIStore((state) => state.isMobile); - const { latestEmail, participantNames, hasUnread, hasStarred, hasAttachment, hasAnswered, hasForwarded, emailCount } = thread; + const { latestEmail, participantNames, hasUnread, hasStarred, hasPinned, hasAttachment, hasAnswered, hasForwarded, emailCount } = thread; // The horizontal one-line "focus" layout doesn't fit on narrow screens; fall back to multi-line on mobile. const isFocusedMailLayout = mailLayout === 'focus' && !isMobile; const trimmedPreview = stripInvisibleLeading(latestEmail.preview ?? ''); @@ -479,8 +495,9 @@ export const ThreadListItem = React.forwardRef state.emailKeywords); + const tintListRowsByTag = useSettingsStore((state) => state.tintListRowsByTag); const keywordDef = threadColor ? (emailKeywordDefs.find(k => k.id === threadColor) ?? { id: threadColor, label: threadColor, color: 'gray' }) : null; - const colorTag = keywordDef ? KEYWORD_PALETTE[keywordDef.color]?.bg ?? null : null; + const colorTag = (tintListRowsByTag && keywordDef) ? KEYWORD_PALETTE[keywordDef.color]?.bg ?? null : null; const isSelected = selectedEmailId === latestEmail.id || thread.emails.some(e => e.id === selectedEmailId); @@ -511,9 +528,8 @@ export const ThreadListItem = React.forwardRef { - e.stopPropagation(); - // Toggle selection for all emails in this thread + // Toggle selection for all emails in this thread. + const toggleThreadSelection = () => { const allSelected = thread.emails.every(em => selectedEmailIds.has(em.id)); const newSelection = new Set(selectedEmailIds); thread.emails.forEach(em => { @@ -526,6 +542,15 @@ export const ThreadListItem = React.forwardRef { + e.stopPropagation(); + if (e.shiftKey) { + selectRangeEmails(latestEmail.id); + return; + } + toggleThreadSelection(); + }; + const handleHeaderClick = (e: React.MouseEvent) => { if (e.ctrlKey || e.metaKey) { e.preventDefault(); @@ -618,19 +643,22 @@ export const ThreadListItem = React.forwardRef +
)} {density !== 'extra-compact' && (
- {!isMobile && !isFocusedMailLayout && (
+ {hasPinned && } {hasStarred && } {hasAnswered && !hasForwarded && } {hasForwarded && !hasAnswered && } @@ -766,6 +795,9 @@ export const ThreadListItem = React.forwardRef
+ {hasPinned && ( + + )} {hasStarred && ( )} @@ -855,6 +887,7 @@ export const ThreadListItem = React.forwardRef onMarkAsSpam(latestEmail) : undefined} onUndoSpam={onUndoSpam ? () => onUndoSpam(latestEmail) : undefined} isInJunk={currentMailboxRole === 'junk'} + spamApplicable={!['sent', 'drafts', 'scheduled'].includes(currentMailboxRole || '')} /> )}
@@ -863,7 +896,7 @@ export const ThreadListItem = React.forwardRef {isLoading ? (
- + {t('loading')}
) : ( diff --git a/components/email/unsubscribe-banner.tsx b/components/email/unsubscribe-banner.tsx index 1f63a0e4d..00736e697 100644 --- a/components/email/unsubscribe-banner.tsx +++ b/components/email/unsubscribe-banner.tsx @@ -96,7 +96,7 @@ export function UnsubscribeBanner({ if (success) { return ( - + {t(unsubMethod === 'http' @@ -110,7 +110,7 @@ export function UnsubscribeBanner({ if (error) { return ( - +
@@ -34,13 +34,13 @@ export function PageErrorFallback({ error: _error, resetError, t }: FallbackProp */ export function SidebarErrorFallback({ resetError, t }: FallbackProps) { return ( -
+

{t("sidebar_error")}

@@ -58,7 +58,7 @@ export function EmailListErrorFallback({ resetError, t }: FallbackProps) { {t("email_list_error")}

@@ -81,7 +81,7 @@ export function EmailViewerErrorFallback({ resetError, t }: FallbackProps) { {t("viewer_error_description")}

@@ -119,7 +119,7 @@ export function SettingsErrorFallback({ resetError, t }: FallbackProps) { {t("settings_error_description")}

diff --git a/components/files/file-browser.tsx b/components/files/file-browser.tsx index b712c1588..10fbd3307 100644 --- a/components/files/file-browser.tsx +++ b/components/files/file-browser.tsx @@ -821,8 +821,8 @@ export function FileBrowser({ const SortIndicator = ({ column }: { column: SortKey }) => { if (sortKey !== column) return null; return sortDir === "asc" - ? - : ; + ? + : ; }; // Keyboard shortcuts @@ -942,7 +942,7 @@ export function FileBrowser({ @@ -1003,7 +1003,7 @@ export function FileBrowser({ className="h-8" onClick={onPaste} > - + {t("paste")} ({clipboard.names.length}) )} @@ -1157,7 +1157,7 @@ export function FileBrowser({ className="h-7 text-destructive hover:text-destructive shrink-0" onClick={onRefresh} > - + {t("retry")}
@@ -1181,12 +1181,12 @@ export function FileBrowser({ {t("uploading")} {uploadProgress.name} {uploadProgress.totalFiles > 1 && ( - + ({uploadProgress.current}/{uploadProgress.totalFiles}) )} - + {uploadProgress.total > 0 ? `${Math.round((uploadProgress.loaded / uploadProgress.total) * 100)}%` : "…"} @@ -1267,7 +1267,7 @@ export function FileBrowser({ )} {/* Favorites & Recent sidebar (when layout is inline) */} {folderLayout === "inline" && (favorites.length > 0 || recentFiles.length > 0) && ( -
+
{favorites.length > 0 && (

@@ -1280,7 +1280,7 @@ export function FileBrowser({ key={fav} onClick={() => onNavigate(fav)} className={cn( - "w-full flex items-center gap-2 px-2 py-1.5 rounded text-sm hover:bg-muted transition-colors text-left", + "w-full flex items-center gap-2 px-2 py-1.5 rounded text-sm hover:bg-muted transition-colors text-start", currentPath === fav && "bg-muted font-medium" )} > @@ -1301,7 +1301,7 @@ export function FileBrowser({ {recentFiles.slice(0, 10).map((recent) => (