Compare commits

...
61 Commits
Author SHA1 Message Date
Linus Rath 9db7b6b55f docs: clarify signature byte cap comment is Stalwart-specific 2026-06-04 10:40:42 +02:00
Linus Rath 1460706e60 docs: update README 2026-06-04 10:31:42 +02:00
Linus Rath 27d624758a chore: update version to 1.7.3 2026-06-04 10:15:50 +02:00
Linus Rath 5288821605 i18n: register Hungarian locale and backfill files.migration_* keys 2026-06-04 00:56:50 +02:00
Norbert Balák-HorváthandLinus Rath 58e3ecc97a fix struct 2026-06-04 00:48:18 +02:00
Norbert Balák-HorváthandLinus Rath 38c0694a08 Audit HU translation 2026-06-04 00:48:18 +02:00
Norbert Balák-HorváthandLinus Rath 71d25bb62f Add Hungarian lang support 2026-06-04 00:48:18 +02:00
Linus Rath ef825b801a fix: list Files via FileNode/get ids:null so folders are visible 2026-06-04 00:45:08 +02:00
Linus Rath 50cbd66bfd fix: treat blob-less FileNode as the only folder signal; migrate legacy dir-markers 2026-06-04 00:30:38 +02:00
Linus Rath d564c874a3 feat: migrate legacy flat-named Files into real hierarchy on load #379 2026-06-03 21:20:25 +02:00
Linus Rath 568abb0e33 fix: remove flatname workaround, store Files as real FileNode #379 2026-06-03 20:53:03 +02:00
Linus Rath 44781f002c fix: empty Trash for shared and group folders #387 2026-06-03 20:18:02 +02:00
dealerwebandLinus Rath 941fa15251 Fix: dark-mode borders invisible (border token collided with secondary)
In the dark theme --color-border was #262626, identical to --color-secondary/--color-muted. The global `* { border-color: var(--color-border) }` rule therefore rendered borders invisible on those surfaces - e.g. the folder sidebar's right border and the account header's bottom border vanished in dark mode.

Set --color-border to rgba(128, 128, 128, 0.3) (the same neutral the navigation rail already uses inline) so borders stay visible and consistent across all dark surfaces (background, secondary, card, popover).
2026-06-03 20:12:51 +02:00
dealerwebandLinus Rath 60c7bd713e Fix: remove the 16px empty strip beside the collapsed sidebar
The collapsed sidebar wrapper was hard-coded to 64px while the sidebar itself is w-12 (48px), leaving a 16px empty strip on its right edge. Match the wrapper to the sidebar's own width.
2026-06-03 20:12:51 +02:00
Linus Rath 6f193e0c24 fix: make clicking the active theme a no-op 2026-06-03 19:42:36 +02:00
Linus Rath 6bb85d746c fix: show light/dark variant chips on Default theme card 2026-06-03 19:42:01 +02:00
Linus Rath bbd43b5948 feat: render theme cards as a mini mailbox mockup from theme colors 2026-06-03 19:40:54 +02:00
Linus Rath 180331805f Merge branch 'main' of https://github.com/bulwarkmail/webmail 2026-06-03 19:34:20 +02:00
Linus Rath 9e96b1c24e fix: distinguish Themes tab icon from Appearance 2026-06-03 19:33:50 +02:00
Linus Rath 40b4b26074 fix: move Themes settings into Appearance category 2026-06-03 19:33:00 +02:00
Linus Rath 9863b9d88e feat: add Aurora Glass built-in theme 2026-06-03 19:30:51 +02:00
dealerwebandLinus Rath 75602b6a00 Fix: Settings section gears permanently hijacked the active tab
The folder and tag section gears in the sidebar deep-linked into Settings by writing the persisted `settings-active-tab` localStorage key, so the chosen section became the permanent default the main Settings button opened on - indefinitely.

Compounding it, the desktop Settings tab list called setActiveTab directly without persisting, so normal navigation never updated the default and the hijacked value could never self-correct.

Fix: section gears now write a one-shot sessionStorage key that is consumed on mount (transient deep-link, no persistence); desktop tab clicks go through handleTabSelect like the mobile list, so the last-used tab is saved consistently. Stale/removed tab IDs are still caught by the existing effectiveActiveTab fallback.
2026-06-03 15:01:13 +02:00
Pascal DietrichandLinus Rath 22da11514b feat: add passwordHashFile to admin.json 2026-06-02 23:48:03 +02:00
Linus Rath 9953557af0 fix: align account selector header height with search/reply toolbars 2026-06-02 01:19:37 +02:00
Linus Rath 8f7066d194 fix: close pane gaps by centering resize handle on the seam 2026-06-02 01:17:09 +02:00
Linus Rath 75c02f443f fix: align top bars to uniform h-14 height 2026-06-02 01:08:09 +02:00
Linus Rath 31100b8f87 fix: discover OIDC metadata server-side to avoid CORS failures #382 2026-06-02 00:15:50 +02:00
Linus Rath 152ec99262 feat: add Elastic built-in theme 2026-06-01 23:28:15 +02:00
Linus Rath ce401c0f59 test: cover withBasePath base-path fallback prefixing 2026-06-01 17:59:19 +02:00
Linus Rath 3035fb046f feat: surface most severe SPF result and hide "via" badge on spoofed mail 2026-06-01 17:46:57 +02:00
Linus Rath 4659b81538 Merge branch 'main' of https://github.com/bulwarkmail/webmail 2026-06-01 17:42:37 +02:00
Linus Rath c78dbee60b fix: move mail from shared group inbox to personal inbox #375 2026-06-01 17:39:28 +02:00
dealerwebandLinus Rath 4b4c801148 Feature: preview composer attachments inline (click to open)
Clicking an attachment chip in the composer now opens the same FilePreviewModal
the message viewer uses, instead of offering only download/remove. The chip
becomes clickable once the attachment has content (a local File, or an uploaded
blob for forwarded attachments) and the type is previewable.

- getFileContent prefers the in-memory File (no network round-trip) and falls
  back to composerClient.fetchBlob for forwarded attachments (blobId only).
- Previewability (isFilePreviewable) and the open-in-new-tab safety gate are
  handled inside the modal, so this adds no new egress/attack surface; the local
  download path uses an <a download> (forces a save, never executes).
- No new dependencies and no new locale keys.
2026-06-01 17:30:50 +02:00
dealerwebandLinus Rath 2e4d3d4bc6 Feature: preview .eml (message/rfc822) attachments like an email
Clicking an embedded email attachment (bounce/DSN, forward-as-attachment, ...)
opened only a download. Add an 'eml' preview kind: FilePreviewModal parses the
blob with postal-mime (dynamic-imported, off the bundle) and renders it via a
new EmlPreview component - header (from/to/subject/date) + body + the message's
own attachments.

The body is sanitized with DOMPurify (sanitizeEmailHtmlForIframe) AND rendered
in a fully-locked sandbox iframe (sandbox="" - no scripts, no same-origin), so a
script-bearing .eml can never execute in-origin. Reuses the email_viewer locale
namespace (no new keys).
2026-06-01 17:25:14 +02:00
dealerwebandLinus Rath 26ccf9e3b4 Fix: email body clipped under the fold when it sets html/body height:100%
Some emails (Outlook / templated HTML) set `html, body { height: 100% }` in
their own <style>. Combined with the viewer srcDoc's `overflow: hidden` and the
scrollHeight-based iframe auto-resize, the measured height collapses to the
iframe's initial size, so everything below the first screenful (often just the
header/logo) is clipped and the rest of the message is invisible.

Force `height: auto !important` on html/body in the rendered srcDoc so the
document grows to its real content height before scrollHeight is measured.
2026-06-01 17:24:56 +02:00
Linus Rath bc322a1e69 feat: add /api/translate proxy and expose email body to plugins 2026-05-31 18:01:00 +02:00
Linus Rath 6ee3849463 fix: theme plugin slot iframes with host font + color tokens 2026-05-31 16:28:22 +02:00
Linus RathandLinus Rath abb249e5df Fix: gate preview "open in new tab" on inline-safe MIME types
The header open-in-new-tab button opened the blob: URL as a top-level
navigation for any preview that produced an objectUrl, including HTML and
SVG attachments. Blob URLs inherit our origin, so a script-bearing
attachment (text/html, image/svg+xml, ...) would execute in-origin when
opened that way - the exact case isMimeTypeSafeForInlinePreview() already
guards. Gate the button on that helper so it only appears for inert types
(images except SVG, audio, video, PDF, text/plain).
2026-05-31 15:58:53 +02:00
dealerwebandLinus Rath 0352312f25 Feature: attachment preview - reliable MIME + inline PDF on desktop and mobile
- MIME: Stalwart's download endpoint often returns application/octet-stream, so
  blob: previews silently downloaded (UUID filename) instead of rendering.
  Resolve the most specific MIME (attachment type -> filename ext -> blob type)
  and re-wrap the blob; also fixes inline preview for images and video.
- Desktop PDF: render via <iframe> (reliable for blob: PDFs) instead of <object>.
- Mobile PDF: no usable inline viewer (Android shows a blank frame / silent
  download; iOS Safari renders only the first page of a PDF in an <iframe>), so
  render with pdf.js (canvas, dynamic-imported so it stays off the desktop
  bundle; iOS-safe canvas cap). Double-tap zoom (fit -> 2x -> 3x -> fit) and
  2-finger pinch zoom (to 4x), both centred on the gesture and pannable via
  native scrolling.
- Route to pdf.js when navigator.pdfViewerEnabled is false (Android) and on iOS
  (incl. iPadOS, which reports true yet shows only the first page in a frame).
- Modal header gains an open-in-new-tab icon (next to download/close); the
  Android/browser Back button closes the preview instead of navigating the page.
- On a pdf.js render failure, offer an open-in-new-tab action as fallback.
2026-05-31 15:58:53 +02:00
Linus Rath ae66f8d89d Feature: per-viewer colors for shared calendars (#345) 2026-05-31 15:52:27 +02:00
dealerwebandLinus Rath 55be19ede7 Feature: admin toggle for search-engine indexing (robots)
Add a 'Search Engine Indexing' toggle under Settings -> General. Off (the
default) emits robots noindex/nofollow in the document head - the safe default
for a private webmail; on lets an admin opt the deployment into indexing.
Backed by the existing admin config-manager (SEARCH_ENGINE_INDEXING env var /
admin override / revert), read server-side in the root generateMetadata().
2026-05-31 14:45:32 +02:00
Pascal DietrichandLinus Rath b508551d02 feat: add sessionSecretFile and oauthClientSecretFile for JSON config 2026-05-31 00:14:38 +02:00
dealerwebandLinus Rath 7ee329e046 Fix: no more 404 console spam for missing sender favicons
/api/favicon returned 404 in three paths (negative cache hit, non-200
upstream, sub-10-byte body), and since the avatar loads it as <img src>,
the browser logged a red 404 for every sender domain without a public
favicon - dozens per inbox view. Now it returns HTTP 200 with a 1x1
transparent PNG and an X-Bulwark-Favicon: missing header. Avatar.tsx detects
the sentinel via naturalWidth <= 1 in onLoad and falls back to initials, so
behaviour is visually identical without the console noise.
2026-05-30 16:58:46 +02:00
dealerwebandLinus Rath 1512b9afc0 Feature: editable layout-preserving quote island
Replying to / forwarding a layout-heavy HTML email (nested tables, MJML,
Outlook divs) destroyed its layout: ProseMirror re-parsed the quoted body
through its strict schema and discarded anything that didn't fit. The quoted
original is now held verbatim in a new atomic QuotedHtml node and never parsed
into the schema; its NodeView renders inside a shadow root so app CSS can't
cascade in and the in-editor view matches the sent mail 1:1.

- quoted-html.ts (new): QuotedHtml atom node + shadow-DOM NodeView (inner
  contentEditable for redaction), serializeEditorContent(), buildQuotedHtmlBlock().
- rich-text-editor: register the node; emit via serializeEditorContent (not
  getHTML) so the verbatim island survives.
- composer: both HTML reply/forward paths embed the original as an island
  (sanitize -> cid-rewrite -> buildQuotedHtmlBlock); the signature-swap effect
  serializes via serializeEditorContent and treats the island as a quote
  boundary so the splice never cuts into the quoted body.

atom:true means Backspace at the boundary / Ctrl+A+Delete removes the whole
quote in one go.
2026-05-30 16:43:50 +02:00
Linus Rath ad48f4394a Merge branch 'main' into HEAD
# Conflicts:
#	app/(main)/layout.tsx
#	locales/cs/common.json
#	locales/da/common.json
#	locales/de/common.json
#	locales/en/common.json
#	locales/es/common.json
#	locales/fr/common.json
#	locales/it/common.json
#	locales/ja/common.json
#	locales/ko/common.json
#	locales/lv/common.json
#	locales/nl/common.json
#	locales/pl/common.json
#	locales/pt/common.json
#	locales/ru/common.json
#	locales/tr/common.json
#	locales/uk/common.json
#	locales/zh/common.json
2026-05-30 16:23:37 +02:00
Linus Rath 8d79145dba Merge remote-tracking branch 'origin/main' into pr/quote-header-i18n 2026-05-30 16:15:18 +02:00
Linus Rath b821f8cc27 Fix: drop single-letter R:/I: subject prefix tokens 2026-05-30 16:04:44 +02:00
dealerwebandLinus Rath bebb394f54 Feature: read receipts (MDN, RFC 8098)
Bulwark had no read-receipt support (JMAP/Stalwart have no native MDN).
End-to-end, client-side, in three parts:

- Request (compose): a toolbar toggle (MailCheck, green when on) sets
  Disposition-Notification-To on the outgoing message via the JMAP
  "header:<name>:asText" create property. Threaded composer -> page ->
  email-store -> client.sendEmail. Default from requestReadReceiptDefault.

- Detect (viewer): reads Disposition-Notification-To case-insensitively from
  the parsed headers and shows a banner (green Send / red Ignore) in the
  unified notification bar. Hidden in Sent/Drafts/Trash/Junk and once handled.
  message/disposition-notification + message/delivery-status report parts are
  filtered out of the attachment list.

- Respond (MDN): lib/mdn.ts builds an RFC 8098 multipart/report (text/plain +
  message/disposition-notification, UTF-8/base64, localized subject + body).
  client.sendReadReceipt uploads the blob, imports it into Sent via
  Email/import, then submits with an explicit envelope. Both Send and Ignore
  set the $MDNSent keyword (RFC 3503) so no client re-prompts. Behaviour
  configurable: ask / always / never.

New: lib/mdn.ts, read-receipt-banner.tsx. Settings (requestReadReceiptDefault,
readReceiptResponse) + UI. All 17 locales.
2026-05-30 15:58:39 +02:00
dealerwebandLinus Rath 2ba0003e16 Feature: localizable sandboxed plugins (manifest locales + api.i18n.t)
The plugin runtime received the active locale (init payload + 'locale-change')
and plugins could declare a `locales` map, but none of it was usable: the
locales never reached the runtime, and buildPluginApi exposed no i18n. So
plugin code calling pluginApi.i18n.t(...) (as the External Link Warning plugin
does) always got undefined and fell back to English.

Thread plugin locales end to end and surface an i18n API:
- ServerPlugin gains `locales`; the upload route persists manifest.locales
  (alongside configSchema/settingsSchema), and /api/plugins surfaces it to the
  client so it flows registry -> client -> sandbox host-bridge -> runtime.
- runtime sets __PLUGIN_LOCALE__ at init (not only on later 'locale-change')
  and buildPluginApi exposes `i18n.locale` + `i18n.t(key, vars)` resolving
  against the plugin's declared locales (manifest.locales) with English/key
  fallback and {placeholder} interpolation.

Lets any sandboxed plugin localize its strings from its manifest.
2026-05-30 15:56:55 +02:00
dealerwebandLinus Rath 4c1d0931a1 Fix: deduplicate localized reply/forward subject prefixes
Replying to a reply produced "Re: Re: foo" (and German used the English
"Re:"/"Fwd:" instead of "AW:"/"WG:"). Four code paths built reply/forward
subjects and only one deduplicated - and only for the English prefix, so
cross-locale threads accumulated chains.

New lib/subject-prefix.ts strips any leading run of reply/forward markers
across ~35 tokens from all supported languages (plus Outlook Re[2]: and
Eudora Re*2: counters), then prepends the locale-appropriate prefix. All four
call sites (composer getInitialSubject, the two page.tsx sites, and the three
pro-tab handlers) now use buildReplySubject/buildForwardSubject. German prefix
corrected to AW:/WG:.
2026-05-30 15:47:53 +02:00
dealerwebandLinus Rath 5a70cf95e0 Fix: add missing settings.folders.role_memos translation
settings.folders.role_memos (Stalwart's "memos" mailbox role) was missing in
all 17 locales, so the folder list showed the raw key "role_memos" and logged
a MISSING_MESSAGE warning. Add the translation to every locale.
2026-05-30 15:46:13 +02:00
dealerwebandLinus Rath 66c5f0f52c Feature: configurable PWA install screenshots (per-domain)
Admins can upload custom mobile/desktop screenshots shown in the browser's
PWA install dialog, replacing the hardcoded Bulwark ones. Two new config keys
(pwaScreenshotMobileUrl/DesktopUrl), upload widgets in the admin Branding tab,
a sharp-based /api/pwa-screenshot/[variant] resize route, and manifest.ts picks
the custom screenshots when configured.

Like the other branding fields, screenshots are per-domain: they are
BRANDING_OVERRIDE_KEYS, the manifest and the /api/pwa-screenshot route resolve
them from the request host (domain override -> global -> Bulwark default), and
the admin Branding tab + upload/delete route handle them in a per-domain scope,
mirroring pwaIconUrl/faviconUrl.
2026-05-30 15:45:59 +02:00
dealerwebandLinus Rath 8353b28b33 Feature: extended filter rules — attachment field + multi-value conditions
Adds an "Attachment" condition field (is present / of type <ext>) backed by
the RFC 5703 Sieve mime extension, matching the filename in both
Content-Disposition and Content-Type headers so real-world senders that only
put the name in Content-Type (Microsoft SMTPSVC, etc.) are caught. Users type
extensions (pdf, doc) not MIME types.

Also makes each text condition accept comma-separated multiple values emitted
as a Sieve string list (OR within the condition), so "(domain1 OR domain2)
AND attachment pdf/xml" is expressible in one rule. value is now string |
string[] (single-value rules stay strings -> backward compatible). New filter
locale keys in all 17 locales.
2026-05-30 15:45:33 +02:00
dealerwebandLinus Rath 229992853b Fix: localize the PWA install prompt
The PWA install prompt was hardcoded English regardless of the selected UI
language (and the large English block tripped Chrome's translate popup on
Android). Add a pwa_install namespace to all 17 locales, switch the component
to useTranslations, and move <PWAInstallPrompt /> from (main)/layout into
(main)/[locale]/layout so it renders inside the IntlProvider. The title keeps
the dynamic {appName}, so per-domain branding still applies.
2026-05-30 15:35:05 +02:00
dealerwebandLinus Rath f0d87d594a Fix: honour basePath in plugin sandbox, http.post proxy, and branding
Upstream 1.7.2 prefixes most hand-written URLs with basePath via apiFetch /
withBasePath, but four subpath-relevant spots were missed:

- host-bridge: the sandbox iframe src was a bare "/plugin-sandbox" -> 404
  under NEXT_PUBLIC_BASE_PATH, breaking all plugins. Wrap in withBasePath.
- host-api doHttpPost: the same-origin /api/* plugin proxy used raw fetch on
  url.pathname -> 404 under a subpath. Route it through apiFetch.
- admin branding preview <img>: unprefixed src -> broken thumbnail.
- (sandbox) layout: drop the Geist font + globals.css imports. The sandbox
  runs with an opaque origin, so those assets are CORS-blocked; the plugin
  bundle and all host API calls travel over the postMessage bridge, so no
  same-origin asset fetch happens there.
2026-05-30 15:31:08 +02:00
196e51e91b fix: preserve HTML signature when sending a quick reply
The quick-reply box built its body with appendPlainTextSignature, which runs
the identity's HTML signature through htmlToPlainText, and sent a text-only
message (htmlBody was undefined). A formatted signature (e.g. <strong>…) was
therefore flattened to plain text in the sent mail, even though it previewed
correctly in the identity editor. The full composer already builds an HTML
signature block; quick reply did not.

Add an appendHtmlSignature helper (mirrors the composer's send-time block) and,
when the sending identity has an HTML signature, send a matching HTML body from
handleQuickReply so the markup is preserved. Text-only identities keep the
plain-text-only behavior.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-30 15:12:30 +02:00
0879030dc8 feat(dev-jmap): persist identity create/update/destroy in mock server
The dev mock's Identity/set discarded its payload and Identity/get always
returned a static list, so saved identities never round-tripped in local
development. Persist create (with mayDelete: true), update, and destroy in
place, mirroring handleMailboxSet, so signature edits stick when testing
without a real JMAP server.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-30 15:12:30 +02:00
dealerweb 05e2837f6b Fix: localize reply/forward quote header incl. sender address
The reply/forward quote header was always emitted in English ("On {date},
{from} wrote:", "---------- Forwarded message ----------", From/Date/Subject)
regardless of UI language, in both the main path (lib/quote-header.ts) and the
composer's inline fallback. quote-header.ts now takes an optional localized
QuoteHeaderLabels set (English defaults preserved for back-compat); page.tsx
builds it from a new quote_header message namespace, and the composer fallback
uses the same keys. Added the quote_header namespace to all 17 locales.

Also folds in the forward-sender-address fix: the forward "From:" line now
shows the full "Name <email>" like every mail client (the reply line keeps the
bare name, which reads naturally in "On … wrote:").
2026-05-30 14:09:23 +02:00
dealerweb 241544cd08 Fix: correct <html lang> and localize the <head> description per locale
The root (main)/layout renders <html> ABOVE the [locale] segment, so next-intl's
getLocale() returns the default locale there - emitting <html lang="en"> on every
page regardless of UI language (e.g. /de) and a hardcoded English <head>
description. Both are strong "translate this page" triggers in Chrome.

proxy.ts already exposes the nonce to server components via the
x-middleware-request-* mechanism; expose the request pathname the same way as
x-pathname, and have the root layout derive the locale from it (falling back to
getLocale() when the path has no locale segment) for both <html lang> and the
localized meta_description (new key in all 17 locales; the English value is
unchanged).
2026-05-30 13:51:21 +02:00
Roman OswaldandLinus Rath 7341e47a1b feat: route Sent copy to shared-mailbox account on per-identity send 2026-05-29 23:44:12 +02:00
Linus Rath f238ca5898 chore: update package-lock.json 2026-05-28 21:09:50 +02:00
119 changed files with 10013 additions and 822 deletions
+45
View File
@@ -1,5 +1,50 @@
# Changelog # Changelog
## 1.7.3 (2026-06-04)
### Features
- **Mail**: Inline attachment preview — reliable MIME detection with inline PDF on desktop and mobile
- **Mail**: Preview composer attachments inline (click to open)
- **Mail**: Preview `.eml` (`message/rfc822`) attachments like an email
- **Mail**: Read receipts (MDN, RFC 8098)
- **Mail**: Editable, layout-preserving quote island when replying
- **Mail**: Surface the most severe SPF result and hide the "via" badge on spoofed mail
- **Calendar**: Per-viewer colors for shared calendars (#345)
- **Filters**: Extended filter rules — attachment field and multi-value conditions
- **Settings**: New built-in themes — Aurora Glass and Elastic
- **Settings**: Theme cards render as a mini mailbox mockup from theme colors, with light/dark variant chips
- **Plugins**: Localizable sandboxed plugins (manifest locales + `api.i18n.t`)
- **Plugins**: `/api/translate` proxy and email body exposed to plugins
- **Admin**: Toggle for search-engine indexing (robots)
- **Admin**: `passwordHashFile` in `admin.json`
- **Admin**: `sessionSecretFile` and `oauthClientSecretFile` for file-based secrets in JSON config
- **PWA**: Configurable install screenshots (per-domain)
- **i18n**: Hungarian locale support
### Fixes
- **Files**: Store Files as real `FileNode` hierarchy, migrate legacy flat-named files on load, and list folders via `FileNode/get` so they are visible (#379)
- **Files**: Treat a blob-less `FileNode` as the only folder signal and migrate legacy dir-markers
- **Mail**: Empty Trash for shared and group folders (#387)
- **Mail**: Move mail from a shared group inbox to a personal inbox (#375)
- **Mail**: Preserve the HTML signature when sending a quick reply
- **Mail**: Stop body clipping under the fold when the email sets `html`/`body` `height: 100%`
- **Mail**: Drop single-letter `R:`/`I:` subject prefix tokens and deduplicate localized reply/forward prefixes
- **Mail**: No more 404 console spam for missing sender favicons
- **Auth**: Discover OIDC metadata server-side to avoid CORS failures (#382)
- **Send**: Route the Sent copy to the shared-mailbox account on per-identity send
- **Routing**: Honour `basePath` in the plugin sandbox, `http.post` proxy, and branding
- **i18n**: Localize the PWA install prompt, reply/forward quote header (incl. sender address), `<html lang>`, and per-locale `<head>` description; add missing `settings.folders.role_memos` key
- **Themes**: Plugin slot iframes inherit host font and color tokens
- **Theme**: Gate preview "open in new tab" on inline-safe MIME types
- **Appearance**: Move Themes settings into the Appearance category with a distinct tab icon; clicking the active theme is a no-op
- **UI**: Fix invisible dark-mode borders (border token collided with secondary)
- **UI**: Remove the 16px empty strip beside the collapsed sidebar
- **UI**: Align top bars to a uniform `h-14` height and the account selector header to the search/reply toolbars
- **UI**: Close pane gaps by centering the resize handle on the seam
- **Settings**: Fix section gears permanently hijacking the active tab
## 1.7.2 (2026-05-28) ## 1.7.2 (2026-05-28)
### Features ### Features
+14 -8
View File
@@ -7,7 +7,10 @@
- Unified mailbox view across all connected accounts - Unified mailbox view across all connected accounts
- Three selectable mail layouts: split (three-pane), focused list, and reading pane at bottom - Three selectable mail layouts: split (three-pane), focused list, and reading pane at bottom
- Draft auto-save with identity preservation, persisted HTML body, and proper `In-Reply-To` / `References` headers on replies - Draft auto-save with identity preservation, persisted HTML body, and proper `In-Reply-To` / `References` headers on replies
- Attachment upload, download, drag-out to local file system, and inline preview; image thumbnails and forgotten-attachment warning - Attachment upload, download, drag-out to local file system, and inline preview images, inline PDF on desktop and mobile, composer attachments (click to open), and `.eml` (`message/rfc822`) attachments rendered like an email; image thumbnails and forgotten-attachment warning
- Scheduled send and configurable send delay
- Read receipts (MDN, RFC 8098)
- Editable, layout-preserving quote island when replying
- Full-text search with JMAP filter panel, search chips, wildcards, OR conditions, and cross-mailbox queries - Full-text search with JMAP filter panel, search chips, wildcards, OR conditions, and cross-mailbox queries
- Batch operations multi-select, archive, delete, move, tag - Batch operations multi-select, archive, delete, move, tag
- Archive modes direct, by year, or by month - Archive modes direct, by year, or by month
@@ -36,7 +39,7 @@
- Auto-generated birthday calendar from contacts - Auto-generated birthday calendar from contacts
- Virtual locations (video conference URLs) as first-class event fields - Virtual locations (video conference URLs) as first-class event fields
- Task management with due dates, priority, and completion status - Task management with due dates, priority, and completion status
- Shared calendars with CalDAV discovery and multi-account home resolution - Shared calendars with CalDAV discovery, multi-account home resolution, and per-viewer colors
- Week numbers, event hover preview, notifications with sound picker - Week numbers, event hover preview, notifications with sound picker
- Real-time sync via JMAP push - Real-time sync via JMAP push
@@ -52,7 +55,7 @@
## Filters & Templates ## Filters & Templates
- Server-side filters via JMAP Sieve Scripts (RFC 9661) - Server-side filters via JMAP Sieve Scripts (RFC 9661)
- Visual rule builder with expanded view; conditions (From, To, Subject, Size, Body…) and actions (Move, Forward, Star, Discard…) - Visual rule builder with expanded view; conditions (From, To, Subject, Size, Body, Attachment…) with multi-value matching and actions (Move, Forward, Star, Discard…)
- Preserves rules authored in other clients - Preserves rules authored in other clients
- Raw Sieve editor with syntax validation - Raw Sieve editor with syntax validation
- Vacation responder with date range scheduling - Vacation responder with date range scheduling
@@ -60,7 +63,7 @@
## Files ## Files
- JMAP FileNode browser (Stalwart native cloud storage) - JMAP FileNode browser (Stalwart native cloud storage) with a real folder hierarchy; legacy flat-named files are migrated into nested `FileNode` folders automatically on load
- Streamed WebDAV PUT upload and folder upload with progress tracking - Streamed WebDAV PUT upload and folder upload with progress tracking
- Dynamic upload limits based on server configuration - Dynamic upload limits based on server configuration
- Grid and list views with sorting by name, size, or date - Grid and list views with sorting by name, size, or date
@@ -72,7 +75,7 @@
- External content blocked by default, with a trusted senders list - External content blocked by default, with a trusted senders list
- HTML sanitization via DOMPurify - HTML sanitization via DOMPurify
- S/MIME manage certificates, sign, encrypt, decrypt, and verify; legacy 3DES / PBE support; per-account key isolation - S/MIME manage certificates, sign, encrypt, decrypt, and verify; legacy 3DES / PBE support; per-account key isolation
- SPF / DKIM / DMARC status indicators - SPF / DKIM / DMARC status indicators surfaces the most severe SPF result and hides the "via" badge on spoofed mail
- OAuth2 / OIDC with PKCE (Keycloak, Authentik, or built-in), OAuth-only mode, OAuth app passwords, and non-interactive SSO for embedded deployments - OAuth2 / OIDC with PKCE (Keycloak, Authentik, or built-in), OAuth-only mode, OAuth app passwords, and non-interactive SSO for embedded deployments
- TOTP two-factor authentication - TOTP two-factor authentication
- Account security panel for password and 2FA management via the Stalwart admin API - Account security panel for password and 2FA management via the Stalwart admin API
@@ -85,6 +88,7 @@
- Selectable mail layouts (split three-pane, focused list, reading pane at bottom) with resizable columns - Selectable mail layouts (split three-pane, focused list, reading pane at bottom) with resizable columns
- Dark and light themes with intelligent email color transformation - Dark and light themes with intelligent email color transformation
- Bundled color themes including Aurora Glass and Elastic; theme cards render as a mini mailbox mockup built from the theme's own colors, with light/dark variant chips
- Responsive desktop, tablet, and mobile layouts - Responsive desktop, tablet, and mobile layouts
- Full keyboard navigation - Full keyboard navigation
- Drag-and-drop email organization and tag assignment - Drag-and-drop email organization and tag assignment
@@ -98,7 +102,7 @@
## Internationalization ## Internationalization
17 languages: Česky · Dansk · Deutsch · English · Español · Français · Italiano · Latviešu · Nederlands · Polski · Português · Türkçe · Русский · Українська · 한국어 · 日本語 · 简体中文 18 languages: Česky · Dansk · Deutsch · English · Español · Français · Italiano · Latviešu · Magyar · Nederlands · Polski · Português · Türkçe · Русский · Українська · 한국어 · 日本語 · 简体中文
Automatic browser detection with persistent preference. Configurable locale URL prefix via `NEXT_PUBLIC_LOCALE_PREFIX`. Automatic browser detection with persistent preference. Configurable locale URL prefix via `NEXT_PUBLIC_LOCALE_PREFIX`.
@@ -118,7 +122,9 @@ Automatic browser detection with persistent preference. Configurable locale URL
- Web setup wizard for first launch guides through JMAP server(s), OAuth/OIDC, session secret, logging, branding (with file upload), and admin password; persists to the admin config dir, no `.env.local` editing required - Web setup wizard for first launch guides through JMAP server(s), OAuth/OIDC, session secret, logging, branding (with file upload), and admin password; persists to the admin config dir, no `.env.local` editing required
- Stalwart admin dashboard with dedicated policy sections, collapsed into a single tabbed page - Stalwart admin dashboard with dedicated policy sections, collapsed into a single tabbed page
- Split admin storage: `ADMIN_CONFIG_DIR` (operator-authored, mountable read-only after setup) and `ADMIN_STATE_DIR` (runtime audit log and login timestamps) - Split admin storage: `ADMIN_CONFIG_DIR` (operator-authored, mountable read-only after setup) and `ADMIN_STATE_DIR` (runtime audit log and login timestamps)
- Plugin system schema-driven config UI, render and intercept hooks, `onAvatarResolve`, `onBeforeEmailSend`, composer-sidebar and email-banner slots, calendar event slots, i18n APIs, and managed policy enforcement - File-based secrets for JSON config: `passwordHashFile` (admin password), `sessionSecretFile`, and `oauthClientSecretFile` for Docker/Kubernetes secret mounts
- Admin toggle for search-engine indexing (`robots.txt` / `noindex`)
- Plugin system schema-driven config UI, render and intercept hooks, `onAvatarResolve`, `onBeforeEmailSend`, composer-sidebar and email-banner slots, calendar event slots, i18n APIs (localizable sandboxed plugins via manifest locales and `api.i18n.t`), an `/api/translate` proxy, email-body access, and managed policy enforcement
- Plugin hot-reload and dev-folder loading, on-demand `src/` bundling via esbuild, and `http:fetch` permission with `httpOrigins` - Plugin hot-reload and dev-folder loading, on-demand `src/` bundling via esbuild, and `http:fetch` permission with `httpOrigins`
- Themes upload, enforce, and manage admin-controlled themes as ZIP bundles - Themes upload, enforce, and manage admin-controlled themes as ZIP bundles
- Extension marketplace browse and install plugins and themes from a configurable directory (`EXTENSION_DIRECTORY_URL`); install/uninstall restricted to the admin dashboard - Extension marketplace browse and install plugins and themes from a configurable directory (`EXTENSION_DIRECTORY_URL`); install/uninstall restricted to the admin dashboard
@@ -126,7 +132,7 @@ Automatic browser detection with persistent preference. Configurable locale URL
## Operations ## Operations
- Progressive Web App with service worker, install prompt, web push notifications for inbox mail, and dynamic manifest - Progressive Web App with service worker, install prompt, web push notifications for inbox mail, dynamic manifest, and configurable (per-domain) install screenshots
- Automatic update check with server-side logging of new releases and a non-dismissible update notice - Automatic update check with server-side logging of new releases and a non-dismissible update notice
- Structured logging (`text` or `json`) with category-based levels - Structured logging (`text` or `json`) with category-based levels
- Anonymous instance telemetry (opt-out via admin UI or `BULWARK_TELEMETRY=off`) version, platform, bucketed account counts, feature toggles only - Anonymous instance telemetry (opt-out via admin UI or `BULWARK_TELEMETRY=off`) version, platform, bucketed account counts, feature toggles only
+2 -2
View File
@@ -12,7 +12,7 @@ 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) [![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) [![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.2-green.svg?logo=git&logoColor=white)](CHANGELOG.md) [![Version](https://img.shields.io/badge/version-1.7.3-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) [![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/) [![Grafana](https://img.shields.io/badge/grafana-dashboard-orange?logo=grafana&logoColor=white)](https://grafana.external.bulwarkmail.org/)
@@ -86,7 +86,7 @@ Bulwark is a full webmail suite, not just an inbox. It bundles the four apps mos
- **Contacts** multiple address books, groups, vCard import/export - **Contacts** multiple address books, groups, vCard import/export
- **Files** Stalwart's JMAP FileNode storage with previews and folder upload - **Files** Stalwart's JMAP FileNode storage with previews and folder upload
Plus the infrastructure around them: a web setup wizard, OAuth2 / OIDC SSO, TOTP 2FA, multi-account with HTTP/2 connection pooling, 15 languages, PWA install, dark/light themes, a plugin system with an extension marketplace, and an admin dashboard. Plus the infrastructure around them: a web setup wizard, OAuth2 / OIDC SSO, TOTP 2FA, multi-account with HTTP/2 connection pooling, 18 languages, PWA install, dark/light themes, a plugin system with an extension marketplace, and an admin dashboard.
Full feature list: **[FEATURES.md](FEATURES.md)**. Full feature list: **[FEATURES.md](FEATURES.md)**.
+1 -1
View File
@@ -1 +1 @@
1.7.2 1.7.3
+64 -10
View File
@@ -60,6 +60,7 @@ import { useConfirmDialog } from "@/hooks/use-confirm-dialog";
import { CreateCalendarModal } from "@/components/calendar/create-calendar-modal"; import { CreateCalendarModal } from "@/components/calendar/create-calendar-modal";
import { getUserParticipantId } from "@/lib/calendar-participants"; import { getUserParticipantId } from "@/lib/calendar-participants";
import { generateBirthdayEvents, createBirthdayCalendar, BIRTHDAY_CALENDAR_ID } from "@/lib/birthday-calendar"; import { generateBirthdayEvents, createBirthdayCalendar, BIRTHDAY_CALENDAR_ID } from "@/lib/birthday-calendar";
import { sharedCalendarColorKey, pickUnusedCalendarColor } from "@/lib/shared-calendar-colors";
import { debug } from "@/lib/debug"; import { debug } from "@/lib/debug";
import { consumePendingWebcal, hasPendingWebcal, subscribeToPendingWebcal } from "@/lib/protocol-handlers/session"; import { consumePendingWebcal, hasPendingWebcal, subscribeToPendingWebcal } from "@/lib/protocol-handlers/session";
import type { ParsedWebcal } from "@/lib/protocol-handlers/webcal"; import type { ParsedWebcal } from "@/lib/protocol-handlers/webcal";
@@ -97,6 +98,9 @@ export default function CalendarPage() {
refreshAllSubscriptions, icalSubscriptions, refreshAllSubscriptions, icalSubscriptions,
} = useCalendarStore(); } = useCalendarStore();
const { firstDayOfWeek, timeFormat, showWeekNumbers, enableCalendarTasks, showTasksOnCalendar, calendarHoverPreview, showBirthdayCalendar, birthdayCalendarColor, updateSetting } = useSettingsStore(); const { firstDayOfWeek, timeFormat, showWeekNumbers, enableCalendarTasks, showTasksOnCalendar, calendarHoverPreview, showBirthdayCalendar, birthdayCalendarColor, updateSetting } = useSettingsStore();
const sharedCalendarColors = useSettingsStore((s) => s.sharedCalendarColors);
const setSharedCalendarColor = useSettingsStore((s) => s.setSharedCalendarColor);
const removeSharedCalendarColor = useSettingsStore((s) => s.removeSharedCalendarColor);
const taskStore = useTaskStore(); const taskStore = useTaskStore();
const fetchTasksFn = useTaskStore(state => state.fetchTasks); const fetchTasksFn = useTaskStore(state => state.fetchTasks);
const { identities } = useIdentityStore(); const { identities } = useIdentityStore();
@@ -1030,10 +1034,47 @@ export default function CalendarPage() {
try { return t('birthday_calendar'); } catch { return 'Birthdays'; } try { return t('birthday_calendar'); } catch { return 'Birthdays'; }
})(); })();
// Apply each shared calendar's local color override (per-viewer recolor,
// #345). The override replaces the calendar's color and wins over per-event
// colors via the `colorIsLocalOverride` flag (see getEventColor). Personal
// calendars are passed through untouched.
const displayCalendars = useMemo(() => {
return calendars.map((cal) => {
if (!cal.isShared) return cal;
const override = sharedCalendarColors[sharedCalendarColorKey(cal)];
if (!override) return cal;
return { ...cal, color: override, colorIsLocalOverride: true };
});
}, [calendars, sharedCalendarColors]);
// Auto-assign a random, not-yet-used palette color to any freshly shared
// calendar so multiple shared calendars don't collide on one color. Runs
// once per calendar (guarded by the presence of an existing key), and the
// user can still overwrite it from the sidebar.
useEffect(() => {
const shared = calendars.filter((c) => c.isShared);
const missing = shared.filter((c) => !sharedCalendarColors[sharedCalendarColorKey(c)]);
if (missing.length === 0) return;
// Seed "used" with personal calendar colors plus already-assigned shared
// overrides so the picks stay distinct from what's already on screen.
const used = new Set<string>();
for (const c of calendars) {
if (!c.isShared && c.color) used.add(c.color.toLowerCase());
}
for (const color of Object.values(sharedCalendarColors)) {
if (color) used.add(color.toLowerCase());
}
for (const cal of missing) {
const color = pickUnusedCalendarColor(used);
used.add(color.toLowerCase());
setSharedCalendarColor(sharedCalendarColorKey(cal), color);
}
}, [calendars, sharedCalendarColors, setSharedCalendarColor]);
const allCalendars = useMemo(() => { const allCalendars = useMemo(() => {
if (!showBirthdayCalendar) return calendars; if (!showBirthdayCalendar) return displayCalendars;
return [...calendars, createBirthdayCalendar(birthdayCalendarName, birthdayCalendarColor)]; return [...displayCalendars, createBirthdayCalendar(birthdayCalendarName, birthdayCalendarColor)];
}, [calendars, showBirthdayCalendar, birthdayCalendarName, birthdayCalendarColor]); }, [displayCalendars, showBirthdayCalendar, birthdayCalendarName, birthdayCalendarColor]);
const visibleEvents = useMemo(() => { const visibleEvents = useMemo(() => {
const filtered = events.filter((e) => { const filtered = events.filter((e) => {
@@ -1219,7 +1260,7 @@ export default function CalendarPage() {
/> />
<TaskListView <TaskListView
tasks={taskStore.tasks} tasks={taskStore.tasks}
calendars={calendars} calendars={displayCalendars}
selectedCalendarIds={selectedCalendarIds} selectedCalendarIds={selectedCalendarIds}
filter={taskStore.filter} filter={taskStore.filter}
showCompleted={taskStore.showCompleted} showCompleted={taskStore.showCompleted}
@@ -1317,8 +1358,21 @@ export default function CalendarPage() {
updateSetting('birthdayCalendarColor', color); updateSetting('birthdayCalendarColor', color);
return; return;
} }
// Shared calendars: recolor locally only (the viewer usually
// can't write the owner's calendar, and it'd recolor it for
// everyone). Personal calendars write through to the server.
const cal = allCalendars.find((c) => c.id === calendarId);
if (cal?.isShared) {
setSharedCalendarColor(sharedCalendarColorKey(cal), color);
return;
}
updateCalendar(client, calendarId, { color }); updateCalendar(client, calendarId, { color });
} : undefined} } : undefined}
onResetColor={(cal) => {
// Drop the local override; the auto-assign effect picks a
// fresh unused color (so it never reverts to a collision).
removeSharedCalendarColor(sharedCalendarColorKey(cal));
}}
onShareCalendar={client ? (cal) => setSharingCalendarId(cal.id) : undefined} onShareCalendar={client ? (cal) => setSharingCalendarId(cal.id) : undefined}
onCreateEvent={(cal: Calendar) => { onCreateEvent={(cal: Calendar) => {
setDefaultCalendarIdForCreate(cal.id); setDefaultCalendarIdForCreate(cal.id);
@@ -1389,7 +1443,7 @@ export default function CalendarPage() {
onSubscribe={() => setShowSubscriptionModal(true)} onSubscribe={() => setShowSubscriptionModal(true)}
isMobile={isMobile} isMobile={isMobile}
onNavigateBack={isMobile && mobileReturnToMonth && normalizedViewMode === "day" ? navigateBackToMonth : undefined} onNavigateBack={isMobile && mobileReturnToMonth && normalizedViewMode === "day" ? navigateBackToMonth : undefined}
calendars={calendars} calendars={displayCalendars}
selectedCalendarIds={selectedCalendarIds} selectedCalendarIds={selectedCalendarIds}
onToggleVisibility={toggleCalendarVisibility} onToggleVisibility={toggleCalendarVisibility}
enableCalendarTasks={enableCalendarTasks} enableCalendarTasks={enableCalendarTasks}
@@ -1419,7 +1473,7 @@ export default function CalendarPage() {
<EventModal <EventModal
key={editEvent?.id ?? 'new'} key={editEvent?.id ?? 'new'}
event={editEvent} event={editEvent}
calendars={calendars} calendars={displayCalendars}
defaultDate={defaultModalDate} defaultDate={defaultModalDate}
defaultEndDate={defaultModalEndDate} defaultEndDate={defaultModalEndDate}
defaultAllDay={defaultModalAllDay} defaultAllDay={defaultModalAllDay}
@@ -1442,7 +1496,7 @@ export default function CalendarPage() {
<TaskModal <TaskModal
key={editTask?.id ?? 'new-task'} key={editTask?.id ?? 'new-task'}
task={editTask} task={editTask}
calendars={calendars} calendars={displayCalendars}
onSave={handleSaveTask} onSave={handleSaveTask}
onDelete={handleDeleteTask} onDelete={handleDeleteTask}
onClose={() => { setShowTaskModal(false); setEditTask(null); }} onClose={() => { setShowTaskModal(false); setEditTask(null); }}
@@ -1529,7 +1583,7 @@ export default function CalendarPage() {
{detailEvent && detailAnchorRect && ( {detailEvent && detailAnchorRect && (
<EventDetailPopover <EventDetailPopover
event={detailEvent} event={detailEvent}
calendar={calendars.find(c => detailEvent.calendarIds[c.id])} calendar={displayCalendars.find(c => detailEvent.calendarIds[c.id])}
anchorRect={detailAnchorRect} anchorRect={detailAnchorRect}
onEdit={handleEditFromDetail} onEdit={handleEditFromDetail}
onDelete={handleDeleteFromDetail} onDelete={handleDeleteFromDetail}
@@ -1549,7 +1603,7 @@ export default function CalendarPage() {
<EventModal <EventModal
key={editEvent?.id ?? 'new'} key={editEvent?.id ?? 'new'}
event={editEvent} event={editEvent}
calendars={calendars} calendars={displayCalendars}
defaultDate={defaultModalDate} defaultDate={defaultModalDate}
defaultEndDate={defaultModalEndDate} defaultEndDate={defaultModalEndDate}
defaultAllDay={defaultModalAllDay} defaultAllDay={defaultModalAllDay}
@@ -1566,7 +1620,7 @@ export default function CalendarPage() {
{showImportModal && client && ( {showImportModal && client && (
<ICalImportModal <ICalImportModal
calendars={calendars} calendars={displayCalendars}
client={client} client={client}
initialUrl={pendingSubscription?.url} initialUrl={pendingSubscription?.url}
onClose={() => { onClose={() => {
+34 -3
View File
@@ -27,7 +27,7 @@ import { FilePreviewModal } from "@/components/files/file-preview-modal";
import { loadFilesSettings } from "@/components/files/files-settings-dialog"; import { loadFilesSettings } from "@/components/files/files-settings-dialog";
import type { FolderLayout } from "@/components/files/files-settings-dialog"; import type { FolderLayout } from "@/components/files/files-settings-dialog";
import { AppTopBannerSlot } from "@/components/plugins/app-top-banner-slot"; import { AppTopBannerSlot } from "@/components/plugins/app-top-banner-slot";
import { AlertTriangle } from "lucide-react"; import { AlertTriangle, Loader2 } from "lucide-react";
export default function FilesPage() { export default function FilesPage() {
const router = useRouter(); const router = useRouter();
@@ -48,9 +48,11 @@ export default function FilesPage() {
supportsFiles, supportsFiles,
selectedResources, selectedResources,
uploadProgress, uploadProgress,
migrationProgress,
clipboard, clipboard,
initClient, initClient,
checkSupport, checkSupport,
migrateLegacyFlatNodes,
navigate, navigate,
navigateByPath, navigateByPath,
refresh, refresh,
@@ -160,13 +162,16 @@ export default function FilesPage() {
const storeClient = useFileStore(s => s.client); const storeClient = useFileStore(s => s.client);
useEffect(() => { useEffect(() => {
if (storeClient && supportsFiles === null) { if (storeClient && supportsFiles === null) {
checkSupport().then((supported) => { checkSupport().then(async (supported) => {
if (supported) { if (supported) {
// Upgrade any files created by older builds (flat path-encoded names)
// into the real FileNode hierarchy before the first listing.
await migrateLegacyFlatNodes();
navigate(null); navigate(null);
} }
}); });
} }
}, [storeClient, supportsFiles, checkSupport, navigate]); }, [storeClient, supportsFiles, checkSupport, migrateLegacyFlatNodes, navigate]);
const handleNavigate = useCallback((path: string, resourceId?: string | null) => { const handleNavigate = useCallback((path: string, resourceId?: string | null) => {
// Pro shell only: the Account breadcrumb segment signals "go to this // Pro shell only: the Account breadcrumb segment signals "go to this
@@ -573,6 +578,32 @@ export default function FilesPage() {
/> />
)} )}
{/* Legacy file migration progress (issue #379) */}
{migrationProgress && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 backdrop-blur-sm">
<div className="w-[22rem] max-w-[90vw] rounded-lg border border-border bg-background p-6 shadow-xl">
<div className="flex items-center gap-3">
<Loader2 className="w-5 h-5 text-primary animate-spin shrink-0" />
<div>
<p className="text-sm font-medium">{t("migration_title")}</p>
<p className="text-xs text-muted-foreground">{t("migration_description")}</p>
</div>
</div>
<div className="mt-4 h-1.5 bg-primary/20 rounded-full overflow-hidden">
<div
className="h-full bg-primary rounded-full transition-all duration-300"
style={{ width: migrationProgress.total > 0
? `${(migrationProgress.current / migrationProgress.total) * 100}%`
: '0%' }}
/>
</div>
<p className="mt-2 text-xs text-muted-foreground tabular-nums text-right">
{migrationProgress.current} / {migrationProgress.total}
</p>
</div>
</div>
)}
<SidebarAppsModal isOpen={showAppsModal} onClose={closeAppsModal} /> <SidebarAppsModal isOpen={showAppsModal} onClose={closeAppsModal} />
<ConfirmDialog {...confirmDialogProps} /> <ConfirmDialog {...confirmDialogProps} />
</div> </div>
+2
View File
@@ -9,6 +9,7 @@ import { ProtocolLaunchHandlerProvider } from "@/components/protocol/protocol-la
import { ProInterfaceRedirect } from "@/components/pro/pro-interface-redirect"; import { ProInterfaceRedirect } from "@/components/pro/pro-interface-redirect";
import { PluginDialogHost } from "@/components/plugins/plugin-dialog-host"; import { PluginDialogHost } from "@/components/plugins/plugin-dialog-host";
import { PluginConsentDialog } from "@/components/plugins/plugin-consent-dialog"; import { PluginConsentDialog } from "@/components/plugins/plugin-consent-dialog";
import { PWAInstallPrompt } from "@/components/pwa-install-prompt";
import { locales } from "@/i18n/routing"; import { locales } from "@/i18n/routing";
export default async function LocaleLayout({ export default async function LocaleLayout({
@@ -41,6 +42,7 @@ export default async function LocaleLayout({
{children} {children}
<PluginDialogHost /> <PluginDialogHost />
<PluginConsentDialog /> <PluginConsentDialog />
<PWAInstallPrompt />
</ProtocolLaunchHandlerProvider> </ProtocolLaunchHandlerProvider>
</TourProvider> </TourProvider>
</EmbeddedBridgeProvider> </EmbeddedBridgeProvider>
+15 -4
View File
@@ -14,7 +14,7 @@ import { useConfig } from "@/hooks/use-config";
import { apiFetch, getPathPrefix, withBasePath } from "@/lib/browser-navigation"; import { apiFetch, getPathPrefix, withBasePath } from "@/lib/browser-navigation";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { AlertCircle, Loader2, X, Info, Eye, EyeOff, LogIn, Sun, Moon, Monitor, Check, Shield, Play, Copy } from "lucide-react"; import { AlertCircle, Loader2, X, Info, Eye, EyeOff, LogIn, Sun, Moon, Monitor, Check, Shield, Play, Copy } from "lucide-react";
import { discoverOAuth, type OAuthMetadata } from "@/lib/oauth/discovery"; import { type OAuthMetadata } from "@/lib/oauth/discovery";
import { generateCodeVerifier, generateCodeChallenge, generateState } from "@/lib/oauth/pkce"; import { generateCodeVerifier, generateCodeChallenge, generateState } from "@/lib/oauth/pkce";
import { useUpdateStore, selectBanner } from "@/stores/update-store"; import { useUpdateStore, selectBanner } from "@/stores/update-store";
import type { PublicJmapServerEntry } from "@/lib/admin/jmap-servers"; import type { PublicJmapServerEntry } from "@/lib/admin/jmap-servers";
@@ -329,16 +329,27 @@ export default function LoginPage() {
if (!oauthEnabled || !serverUrl) return; if (!oauthEnabled || !serverUrl) return;
setOauthDiscoveryDone(false); setOauthDiscoveryDone(false);
setOauthMetadata(null); setOauthMetadata(null);
discoverOAuth(effectiveOauthIssuerUrl || serverUrl) const controller = new AbortController();
// Discover via our own origin rather than fetching the IdP's /.well-known/*
// documents directly from the browser. A direct cross-origin discovery
// fetch is subject to CORS, and providers like Authentik serve those
// documents without Access-Control-Allow-Origin, so the browser blocks the
// response and login breaks (issue #382). The proxy runs discovery server
// side where CORS does not apply.
const query = selectedServer?.id ? `?server_id=${encodeURIComponent(selectedServer.id)}` : "";
apiFetch(`/api/auth/oauth/metadata${query}`, { signal: controller.signal })
.then(async (res) => (res.ok ? ((await res.json()) as OAuthMetadata) : null))
.then((metadata) => { .then((metadata) => {
setOauthMetadata(metadata); setOauthMetadata(metadata);
setOauthDiscoveryDone(true); setOauthDiscoveryDone(true);
}) })
.catch(() => { .catch((err) => {
if (err?.name === "AbortError") return;
setOauthMetadata(null); setOauthMetadata(null);
setOauthDiscoveryDone(true); setOauthDiscoveryDone(true);
}); });
}, [oauthEnabled, serverUrl, effectiveOauthIssuerUrl]); return () => controller.abort();
}, [oauthEnabled, serverUrl, effectiveOauthIssuerUrl, selectedServer?.id]);
// Auto-SSO: when enabled with OAUTH_ONLY, skip the login page entirely // Auto-SSO: when enabled with OAUTH_ONLY, skip the login page entirely
const ssoError = searchParams.get("sso_error"); const ssoError = searchParams.get("sso_error");
+77 -25
View File
@@ -55,7 +55,7 @@ import { useProMultiAccountMailboxes } from "@/hooks/use-pro-multi-account-mailb
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { FilePreviewModal } from "@/components/files/file-preview-modal"; import { FilePreviewModal } from "@/components/files/file-preview-modal";
import { isFilePreviewable } from "@/lib/file-preview"; import { isFilePreviewable } from "@/lib/file-preview";
import { appendPlainTextSignature } from "@/lib/signature-utils"; import { appendHtmlSignature, appendPlainTextSignature } from "@/lib/signature-utils";
import { computeReplyThreadingHeaders } from "@/lib/email-threading"; import { computeReplyThreadingHeaders } from "@/lib/email-threading";
import { EML_IMPORT_ACCEPT, expandImportableEmails } from "@/lib/eml-import"; import { EML_IMPORT_ACCEPT, expandImportableEmails } from "@/lib/eml-import";
import { resolveReplyFrom } from "@/lib/reply-identity"; import { resolveReplyFrom } from "@/lib/reply-identity";
@@ -72,6 +72,7 @@ import { plainTextToComposerBody } from "@/lib/email-composer-utils";
import { appLifecycleHooks, uiHooks, routerHooks, toastHooks, emailHooks } from "@/lib/plugin-hooks"; import { appLifecycleHooks, uiHooks, routerHooks, toastHooks, emailHooks } from "@/lib/plugin-hooks";
import { emailToReadView } from "@/lib/plugin-projection"; import { emailToReadView } from "@/lib/plugin-projection";
import { buildQuoteHeader } from "@/lib/quote-header"; import { buildQuoteHeader } from "@/lib/quote-header";
import { buildReplySubject, buildForwardSubject } from "@/lib/subject-prefix";
import { useLocaleStore } from "@/stores/locale-store"; import { useLocaleStore } from "@/stores/locale-store";
import type { QuoteHeader } from "@/lib/plugin-types"; import type { QuoteHeader } from "@/lib/plugin-types";
@@ -81,6 +82,7 @@ const SCHEDULED_MAILBOX_ID = '__scheduled__';
export default function Home() { export default function Home() {
const t = useTranslations(); const t = useTranslations();
const tCommon = useTranslations('common'); const tCommon = useTranslations('common');
const tQuote = useTranslations('quote_header');
const { appName } = useConfig(); const { appName } = useConfig();
const mailLayout = useSettingsStore((state) => state.mailLayout); const mailLayout = useSettingsStore((state) => state.mailLayout);
const [showComposer, setShowComposer] = useState(false); const [showComposer, setShowComposer] = useState(false);
@@ -299,6 +301,7 @@ export default function Home() {
fetchTagCounts, fetchTagCounts,
fetchEmailContent, fetchEmailContent,
isUnifiedView, isUnifiedView,
unifiedRole,
scheduledEmails, scheduledEmails,
scheduledTotal, scheduledTotal,
scheduledHasMore, scheduledHasMore,
@@ -751,9 +754,9 @@ export default function Home() {
let title = t('email_composer.new_message'); let title = t('email_composer.new_message');
if (baseSubject) { if (baseSubject) {
if (effectiveMode === 'reply' || effectiveMode === 'replyAll') { if (effectiveMode === 'reply' || effectiveMode === 'replyAll') {
title = baseSubject.startsWith('Re:') ? baseSubject : `Re: ${baseSubject}`; title = buildReplySubject(baseSubject, t('email_composer.prefix.reply'));
} else if (effectiveMode === 'forward') { } else if (effectiveMode === 'forward') {
title = baseSubject.startsWith('Fwd:') ? baseSubject : `Fwd: ${baseSubject}`; title = buildForwardSubject(baseSubject, t('email_composer.prefix.forward'));
} else { } else {
title = baseSubject; title = baseSubject;
} }
@@ -1129,6 +1132,7 @@ export default function Home() {
inReplyTo?: string[]; inReplyTo?: string[];
references?: string[]; references?: string[];
delayedUntil?: string; delayedUntil?: string;
requestReadReceipt?: boolean;
}) => { }) => {
if (!client) return; if (!client) return;
@@ -1136,7 +1140,7 @@ export default function Home() {
const effectiveMode = pendingDraft?.mode ?? composerMode; const effectiveMode = pendingDraft?.mode ?? composerMode;
const originalEmailId = selectedEmail?.id; const originalEmailId = selectedEmail?.id;
const result = await sendEmail(client, data.to, data.subject, data.body, data.cc, data.bcc, data.identityId, data.fromEmail, data.draftId, data.fromName, data.htmlBody, data.attachments, data.inReplyTo, data.references, data.delayedUntil, data.envelopeMailFrom); const result = await sendEmail(client, data.to, data.subject, data.body, data.cc, data.bcc, data.identityId, data.fromEmail, data.draftId, data.fromName, data.htmlBody, data.attachments, data.inReplyTo, data.references, data.delayedUntil, data.envelopeMailFrom, { requestReadReceipt: data.requestReadReceipt });
setShowComposer(false); setShowComposer(false);
if (result.scheduled) { if (result.scheduled) {
await refreshScheduledMetadata(client); await refreshScheduledMetadata(client);
@@ -1212,13 +1216,20 @@ export default function Home() {
locale: useLocaleStore.getState().locale, locale: useLocaleStore.getState().locale,
timeFormat: useSettingsStore.getState().timeFormat, timeFormat: useSettingsStore.getState().timeFormat,
unknownLabel: tCommon('unknown'), unknownLabel: tCommon('unknown'),
labels: {
formatReplyLine: (vars) => tQuote('reply_line', vars),
forwardedSeparator: tQuote('forwarded_separator'),
fromLabel: tQuote('from_label'),
dateLabel: tQuote('date_label'),
subjectLabel: tQuote('subject_label'),
},
}); });
setComposerQuoteHeader(header); setComposerQuoteHeader(header);
} catch (err) { } catch (err) {
console.warn('[quote-header] plugin transform failed; using default', err); console.warn('[quote-header] plugin transform failed; using default', err);
setComposerQuoteHeader(null); setComposerQuoteHeader(null);
} }
}, [tCommon]); }, [tCommon, tQuote]);
// Force a clean composer remount on every fresh entry point so prior // Force a clean composer remount on every fresh entry point so prior
// compose state can't bleed into the new session (#329 C). The composer is // compose state can't bleed into the new session (#329 C). The composer is
@@ -1378,8 +1389,19 @@ export default function Home() {
const handleDelete = async (emailToDelete: Email | null = selectedEmail) => { const handleDelete = async (emailToDelete: Email | null = selectedEmail) => {
if (!client || !emailToDelete) return; if (!client || !emailToDelete) return;
// Check if we're currently in the trash or junk folder // In unified view the trash destination and current-folder check must come
const currentMailbox = mailboxes.find(m => m.id === selectedMailbox); // from the email's own account, not the active one. (#281)
const actionMailboxes =
isUnifiedView && emailToDelete.accountId
? (accountMailboxes[emailToDelete.accountId] ?? mailboxes)
: mailboxes;
// Check if we're currently in the trash or junk folder. In unified view the
// "current folder" is the unified role within the email's account.
const currentMailbox = isUnifiedView
? (actionMailboxes.find(m => m.role === unifiedRole && emailToDelete.mailboxIds?.[m.id])
?? actionMailboxes.find(m => m.role === unifiedRole))
: mailboxes.find(m => m.id === selectedMailbox);
const isInTrash = currentMailbox?.role === 'trash'; const isInTrash = currentMailbox?.role === 'trash';
const isInJunk = currentMailbox?.role === 'junk'; const isInJunk = currentMailbox?.role === 'junk';
const permanentlyDeleteJunk = useSettingsStore.getState().permanentlyDeleteJunk; const permanentlyDeleteJunk = useSettingsStore.getState().permanentlyDeleteJunk;
@@ -1400,10 +1422,10 @@ export default function Home() {
console.error("Failed to permanently delete email:", error); console.error("Failed to permanently delete email:", error);
} }
} else { } else {
// Not in trash: always move to trash // Not in trash: always move to trash (in the email's own account).
const trashMailbox = const trashMailbox =
mailboxes.find(m => m.role === 'trash' && !m.isShared) ?? actionMailboxes.find(m => m.role === 'trash' && !m.isShared) ??
mailboxes.find(m => { actionMailboxes.find(m => {
if (m.isShared) return false; if (m.isShared) return false;
const lower = m.name.toLowerCase(); const lower = m.name.toLowerCase();
return lower.includes('trash') || lower.includes('deleted'); return lower.includes('trash') || lower.includes('deleted');
@@ -1426,9 +1448,27 @@ export default function Home() {
const handleArchive = async (emailToArchive: Email | null = selectedEmail) => { const handleArchive = async (emailToArchive: Email | null = selectedEmail) => {
if (!client || !emailToArchive) return; if (!client || !emailToArchive) return;
// In unified view the archive folder (and any year/month subfolders we
// create) must live in the email's own account, reached through that
// account's client. (#281)
const archiveAccountId = isUnifiedView ? emailToArchive.accountId : undefined;
const archiveClient = archiveAccountId
? (useAuthStore.getState().getClientForAccount(archiveAccountId) ?? client)
: client;
// Read fresh mailboxes from the store batch archive calls this in a loop, // Read fresh mailboxes from the store batch archive calls this in a loop,
// and each iteration needs to see folders created by prior iterations. // and each iteration needs to see folders created by prior iterations.
const currentMailboxes = useEmailStore.getState().mailboxes; const readMailboxes = () => {
const s = useEmailStore.getState();
return archiveAccountId
? (s.accountMailboxes[archiveAccountId] ?? s.mailboxes)
: s.mailboxes;
};
const refreshMailboxes = () =>
archiveAccountId
? useEmailStore.getState().fetchAccountMailboxes(archiveClient, archiveAccountId)
: fetchMailboxes(client);
const currentMailboxes = readMailboxes();
const archiveMailbox = currentMailboxes.find(m => m.role === "archive" || m.name.toLowerCase() === "archive"); const archiveMailbox = currentMailboxes.find(m => m.role === "archive" || m.name.toLowerCase() === "archive");
if (!archiveMailbox) return; if (!archiveMailbox) return;
@@ -1448,21 +1488,21 @@ export default function Home() {
m => m.name === year && m.parentId === archiveId m => m.name === year && m.parentId === archiveId
); );
if (!yearMailbox) { if (!yearMailbox) {
yearMailbox = await client.createMailbox(year, archiveId); yearMailbox = await archiveClient.createMailbox(year, archiveId);
await fetchMailboxes(client); await refreshMailboxes();
} }
if (archiveMode === 'year') { if (archiveMode === 'year') {
await moveThreadToMailbox(client, emailToArchive.id, yearMailbox.id); await moveThreadToMailbox(client, emailToArchive.id, yearMailbox.id);
} else { } else {
const yearId = yearMailbox.originalId || yearMailbox.id; const yearId = yearMailbox.originalId || yearMailbox.id;
const afterYear = useEmailStore.getState().mailboxes; const afterYear = readMailboxes();
let monthMailbox = afterYear.find( let monthMailbox = afterYear.find(
m => m.name === month && m.parentId === yearId m => m.name === month && m.parentId === yearId
); );
if (!monthMailbox) { if (!monthMailbox) {
monthMailbox = await client.createMailbox(month, yearId); monthMailbox = await archiveClient.createMailbox(month, yearId);
await fetchMailboxes(client); await refreshMailboxes();
} }
await moveThreadToMailbox(client, emailToArchive.id, monthMailbox.id); await moveThreadToMailbox(client, emailToArchive.id, monthMailbox.id);
} }
@@ -1473,7 +1513,7 @@ export default function Home() {
setConversationEmails([]); setConversationEmails([]);
} }
void fetchMailboxes(client); void refreshMailboxes();
} catch (error) { } catch (error) {
console.error("Failed to archive email:", error); console.error("Failed to archive email:", error);
} }
@@ -2090,9 +2130,21 @@ export default function Home() {
// Append signature from the sending identity (fall back to primary // Append signature from the sending identity (fall back to primary
// when the reply-from lives on the same identity but a different alias). // when the reply-from lives on the same identity but a different alias).
const finalBody = appendPlainTextSignature(body, sendingIdentity, { const separator = useSettingsStore.getState().signatureSeparatorEnabled;
separator: useSettingsStore.getState().signatureSeparatorEnabled, const finalBody = appendPlainTextSignature(body, sendingIdentity, { separator });
});
// When the identity has an HTML signature, send a matching HTML body so the
// signature keeps its formatting; appendPlainTextSignature would otherwise
// flatten it to plain text. Text-only identities keep the plain-text-only
// behavior (htmlBody stays undefined).
const escapedBody = body
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/\n/g, '<br>');
const finalHtmlBody = sendingIdentity?.htmlSignature?.trim()
? appendHtmlSignature(`<div>${escapedBody}</div>`, sendingIdentity, { separator })
: undefined;
const originalEmailId = selectedEmail.id; const originalEmailId = selectedEmail.id;
const sendDelaySeconds = useSettingsStore.getState().sendDelaySeconds; const sendDelaySeconds = useSettingsStore.getState().sendDelaySeconds;
@@ -2116,7 +2168,7 @@ export default function Home() {
const result = await sendEmail( const result = await sendEmail(
client, client,
[sender.email], [sender.email],
`Re: ${selectedEmail.subject || "(no subject)"}`, buildReplySubject(selectedEmail.subject || "(no subject)", t('email_composer.prefix.reply')),
finalBody, finalBody,
undefined, undefined,
undefined, undefined,
@@ -2124,7 +2176,7 @@ export default function Home() {
headerFromEmail, headerFromEmail,
undefined, undefined,
headerFromName, headerFromName,
undefined, finalHtmlBody,
undefined, undefined,
threading?.inReplyTo, threading?.inReplyTo,
threading?.references, threading?.references,
@@ -2414,7 +2466,7 @@ export default function Home() {
), ),
inlineApp && "hidden" inlineApp && "hidden"
)} )}
style={!isMobile && !isTablet ? { width: sidebarCollapsed ? 64 : sidebarWidth } : undefined} style={!isMobile && !isTablet ? { width: sidebarCollapsed ? 48 : sidebarWidth } : undefined}
> >
<ErrorBoundary fallback={SidebarErrorFallback}> <ErrorBoundary fallback={SidebarErrorFallback}>
<Sidebar <Sidebar
@@ -2498,8 +2550,8 @@ export default function Home() {
{/* Search Bar + Inline Advanced Filters */} {/* Search Bar + Inline Advanced Filters */}
<div className="border-b border-border bg-background"> <div className="border-b border-border bg-background">
<div className="px-3 py-3"> <div className="px-3 h-14 flex items-center">
<div className="flex items-center gap-1.5"> <div className="flex items-center gap-1.5 w-full">
{/* Select / Select All toggle */} {/* Select / Select All toggle */}
<button <button
type="button" type="button"
+17 -3
View File
@@ -33,6 +33,7 @@ import {
Languages, Languages,
Info, Info,
Bug, Bug,
SwatchBook,
Download, Download,
X, X,
type LucideIcon, type LucideIcon,
@@ -145,7 +146,7 @@ const tabIcons: Record<Tab, LucideIcon> = {
protocol_handlers: LinkIcon, protocol_handlers: LinkIcon,
sidebar_apps: PanelLeftClose, sidebar_apps: PanelLeftClose,
about_data: Info, about_data: Info,
themes: Palette, themes: SwatchBook,
plugins: Puzzle, plugins: Puzzle,
debug: Bug, debug: Bug,
}; };
@@ -335,6 +336,14 @@ const LEGACY_TAB_MAP: Record<string, Tab> = {
function readPersistedTab(): Tab { function readPersistedTab(): Tab {
try { try {
// One-shot deep link from the sidebar section gears (Folders / Tags).
// Used only as the initial tab and intentionally NOT written to
// 'settings-active-tab', so a gear click never becomes the persisted
// default that the regular Settings button lands on. Cleared on mount.
const deepLink = sessionStorage.getItem('settings-deep-link-tab');
if (deepLink) {
return (deepLink in LEGACY_TAB_MAP ? LEGACY_TAB_MAP[deepLink] : deepLink) as Tab;
}
const saved = localStorage.getItem('settings-active-tab'); const saved = localStorage.getItem('settings-active-tab');
if (!saved) return 'appearance'; if (!saved) return 'appearance';
if (saved in LEGACY_TAB_MAP) { if (saved in LEGACY_TAB_MAP) {
@@ -360,6 +369,11 @@ export default function SettingsPage() {
const { stalwartFeaturesEnabled } = useConfig(); const { stalwartFeaturesEnabled } = useConfig();
const { isFeatureEnabled } = usePolicyStore(); const { isFeatureEnabled } = usePolicyStore();
const [activeTab, setActiveTab] = useState<Tab>(readPersistedTab); const [activeTab, setActiveTab] = useState<Tab>(readPersistedTab);
// Consume the one-shot deep-link key so a section gear only steers this one
// open, never the persisted default for future Settings-button clicks.
useEffect(() => {
try { sessionStorage.removeItem('settings-deep-link-tab'); } catch { /* ignore */ }
}, []);
const [mobileShowContent, setMobileShowContent] = useState(false); const [mobileShowContent, setMobileShowContent] = useState(false);
const [searchQuery, setSearchQuery] = useState(''); const [searchQuery, setSearchQuery] = useState('');
const [pendingHighlight, setPendingHighlight] = useState<{ tab: Tab; label: string; pluginId?: string } | null>(null); const [pendingHighlight, setPendingHighlight] = useState<{ tab: Tab; label: string; pluginId?: string } | null>(null);
@@ -582,6 +596,7 @@ export default function SettingsPage() {
// Appearance // Appearance
{ id: 'appearance', label: t('tabs.appearance'), icon: tabIcons.appearance, group: 'appearance' }, { id: 'appearance', label: t('tabs.appearance'), icon: tabIcons.appearance, group: 'appearance' },
{ id: 'layout', label: t('tabs.layout'), icon: tabIcons.layout, group: 'appearance' }, { id: 'layout', label: t('tabs.layout'), icon: tabIcons.layout, group: 'appearance' },
...(isFeatureEnabled('themesEnabled') ? [{ id: 'themes' as Tab, label: 'Themes', icon: tabIcons.themes, group: 'appearance' as TabGroup }] : []),
// Mail // Mail
{ id: 'reading', label: t('tabs.reading'), icon: tabIcons.reading, group: 'mail' }, { id: 'reading', label: t('tabs.reading'), icon: tabIcons.reading, group: 'mail' },
@@ -607,7 +622,6 @@ export default function SettingsPage() {
// Advanced // Advanced
{ id: 'about_data', label: t('tabs.about_data'), icon: tabIcons.about_data, group: 'advanced' }, { id: 'about_data', label: t('tabs.about_data'), icon: tabIcons.about_data, group: 'advanced' },
...(isFeatureEnabled('themesEnabled') ? [{ id: 'themes' as Tab, label: 'Themes', icon: tabIcons.themes, group: 'advanced' as TabGroup }] : []),
...(isFeatureEnabled('pluginsEnabled') ? [{ id: 'plugins' as Tab, label: 'Plugins', icon: tabIcons.plugins, group: 'advanced' as TabGroup }] : []), ...(isFeatureEnabled('pluginsEnabled') ? [{ id: 'plugins' as Tab, label: 'Plugins', icon: tabIcons.plugins, group: 'advanced' as TabGroup }] : []),
...(isFeatureEnabled('debugModeEnabled') ? [{ id: 'debug' as Tab, label: t('tabs.debug'), icon: tabIcons.debug, group: 'advanced' as TabGroup }] : []), ...(isFeatureEnabled('debugModeEnabled') ? [{ id: 'debug' as Tab, label: t('tabs.debug'), icon: tabIcons.debug, group: 'advanced' as TabGroup }] : []),
]; ];
@@ -933,7 +947,7 @@ export default function SettingsPage() {
return ( return (
<div key={tab.id}> <div key={tab.id}>
<button <button
onClick={() => setActiveTab(tab.id)} onClick={() => handleTabSelect(tab.id)}
className={cn( className={cn(
'w-full text-left px-3 py-2 rounded-md text-sm transition-colors duration-150 flex items-center gap-2.5', 'w-full text-left px-3 py-2 rounded-md text-sm transition-colors duration-150 flex items-center gap-2.5',
effectiveActiveTab === tab.id effectiveActiveTab === tab.id
+5 -3
View File
@@ -2,7 +2,7 @@
import { useEffect, useMemo, useRef, useState } from 'react'; import { useEffect, useMemo, useRef, useState } from 'react';
import { Save, Loader2, RotateCcw, ImageIcon, Upload, Trash2, Globe, Plus, X } from 'lucide-react'; import { Save, Loader2, RotateCcw, ImageIcon, Upload, Trash2, Globe, Plus, X } from 'lucide-react';
import { apiFetch } from '@/lib/browser-navigation'; import { apiFetch, withBasePath } from '@/lib/browser-navigation';
import { import {
BRANDING_OVERRIDE_KEYS, BRANDING_OVERRIDE_KEYS,
parseDomainBranding, parseDomainBranding,
@@ -33,6 +33,8 @@ const TEXT_FIELDS = [
const PWA_IMAGE_FIELDS = [ const PWA_IMAGE_FIELDS = [
{ key: 'pwaIconUrl', label: 'PWA Icon', accept: '.svg,.png,.jpg,.webp' }, { key: 'pwaIconUrl', label: 'PWA Icon', accept: '.svg,.png,.jpg,.webp' },
{ key: 'pwaScreenshotMobileUrl', label: 'PWA Screenshot (Mobile)', accept: '.png,.jpg,.webp' },
{ key: 'pwaScreenshotDesktopUrl', label: 'PWA Screenshot (Desktop)', accept: '.png,.jpg,.webp' },
] as const; ] as const;
const PWA_TEXT_FIELDS = [ const PWA_TEXT_FIELDS = [
@@ -528,7 +530,7 @@ export function BrandingTab() {
<ImageIcon className="w-3.5 h-3.5 text-muted-foreground" /> <ImageIcon className="w-3.5 h-3.5 text-muted-foreground" />
<div className="h-8 w-auto bg-muted rounded flex items-center justify-center px-2"> <div className="h-8 w-auto bg-muted rounded flex items-center justify-center px-2">
<img <img
src={currentValue(field.key)} src={withBasePath(currentValue(field.key))}
alt={field.label} alt={field.label}
className="max-h-6 max-w-[200px] object-contain" className="max-h-6 max-w-[200px] object-contain"
onError={(e) => { (e.target as HTMLImageElement).style.display = 'none'; }} onError={(e) => { (e.target as HTMLImageElement).style.display = 'none'; }}
@@ -606,7 +608,7 @@ export function BrandingTab() {
<ImageIcon className="w-3.5 h-3.5 text-muted-foreground" /> <ImageIcon className="w-3.5 h-3.5 text-muted-foreground" />
<div className="h-8 w-auto bg-muted rounded flex items-center justify-center px-2"> <div className="h-8 w-auto bg-muted rounded flex items-center justify-center px-2">
<img <img
src={currentValue(field.key)} src={withBasePath(currentValue(field.key))}
alt={field.label} alt={field.label}
className="max-h-6 max-w-[200px] object-contain" className="max-h-6 max-w-[200px] object-contain"
onError={(e) => { (e.target as HTMLImageElement).style.display = 'none'; }} onError={(e) => { (e.target as HTMLImageElement).style.display = 'none'; }}
+1
View File
@@ -125,6 +125,7 @@ export function SettingsTab() {
)} )}
<ToggleSetting label="Stalwart Features" description="Enable Stalwart Mail Server-specific features" configKey="stalwartFeaturesEnabled" value={currentValue('stalwartFeaturesEnabled') as boolean} source={config.stalwartFeaturesEnabled?.source} onChange={handleChange} onRevert={handleRevert} /> <ToggleSetting label="Stalwart Features" description="Enable Stalwart Mail Server-specific features" configKey="stalwartFeaturesEnabled" value={currentValue('stalwartFeaturesEnabled') as boolean} source={config.stalwartFeaturesEnabled?.source} onChange={handleChange} onRevert={handleRevert} />
<ToggleSetting label="Demo Mode" description="Enable demo mode with sample data" configKey="demoMode" value={currentValue('demoMode') as boolean} source={config.demoMode?.source} onChange={handleChange} onRevert={handleRevert} /> <ToggleSetting label="Demo Mode" description="Enable demo mode with sample data" configKey="demoMode" value={currentValue('demoMode') as boolean} source={config.demoMode?.source} onChange={handleChange} onRevert={handleRevert} />
<ToggleSetting label="Search Engine Indexing" description="Allow search engines to index this webmail. Off (the default) sends noindex/nofollow in the page head, recommended for private deployments." configKey="searchEngineIndexing" value={currentValue('searchEngineIndexing') as boolean} source={config.searchEngineIndexing?.source} onChange={handleChange} onRevert={handleRevert} />
</SettingsSection> </SettingsSection>
<SettingsSection title="JMAP Servers (multi-server)"> <SettingsSection title="JMAP Servers (multi-server)">
+27 -5
View File
@@ -1,13 +1,25 @@
import type { Metadata, Viewport } from "next"; import type { Metadata, Viewport } from "next";
import { Geist, Geist_Mono } from "next/font/google"; import { Geist, Geist_Mono } from "next/font/google";
import { headers } from "next/headers"; import { headers } from "next/headers";
import { getLocale } from "next-intl/server"; import { getLocale, getTranslations } from "next-intl/server";
import { PWAInstallPrompt } from "@/components/pwa-install-prompt";
import { ServiceWorkerRegistration } from "@/components/service-worker-registration"; import { ServiceWorkerRegistration } from "@/components/service-worker-registration";
import { configManager } from "@/lib/admin/config-manager"; import { configManager } from "@/lib/admin/config-manager";
import { withBasePath } from "@/lib/browser-navigation"; import { withBasePath } from "@/lib/browser-navigation";
import { locales } from "@/i18n/routing";
import "../globals.css"; import "../globals.css";
// This layout renders <html> and sits ABOVE the [locale] segment, so
// next-intl's getLocale() returns the default locale here - emitting
// <html lang="en"> on e.g. /de pages, which makes browsers offer to
// "translate this page". Recover the active locale from the request pathname
// (exposed by proxy.ts as x-pathname), falling back to getLocale() (cookie /
// Accept-Language) when the path carries no locale segment.
async function resolveRequestLocale(): Promise<string> {
const pathname = (await headers()).get("x-pathname") || "";
const seg = pathname.split("/").find((s) => (locales as readonly string[]).includes(s));
return seg ?? (await getLocale());
}
const geistSans = Geist({ const geistSans = Geist({
variable: "--font-geist-sans", variable: "--font-geist-sans",
subsets: ["latin"], subsets: ["latin"],
@@ -27,10 +39,21 @@ export const viewport: Viewport = {
export async function generateMetadata(): Promise<Metadata> { export async function generateMetadata(): Promise<Metadata> {
await configManager.ensureLoaded(); await configManager.ensureLoaded();
const faviconUrl = configManager.get<string>("faviconUrl", "/branding/Bulwark_Favicon.svg"); const faviconUrl = configManager.get<string>("faviconUrl", "/branding/Bulwark_Favicon.svg");
// Localize the <head> description to match the UI language; a hardcoded
// English description is another signal that makes Chrome offer to
// "translate this page". Resolve the locale from the request path, since this
// layout is above the [locale] segment (see resolveRequestLocale).
const locale = await resolveRequestLocale();
const t = await getTranslations({ locale });
return { return {
title: process.env.APP_NAME || process.env.NEXT_PUBLIC_APP_NAME || "Webmail", title: process.env.APP_NAME || process.env.NEXT_PUBLIC_APP_NAME || "Webmail",
description: "Minimalist webmail client using JMAP protocol", description: t("meta_description"),
// A private webmail should not be indexed by search engines. This is opt-in
// via Settings -> General; the default (false) emits noindex/nofollow.
robots: configManager.get<boolean>("searchEngineIndexing", false)
? { index: true, follow: true }
: { index: false, follow: false },
appleWebApp: { appleWebApp: {
capable: true, capable: true,
statusBarStyle: "black-translucent", statusBarStyle: "black-translucent",
@@ -48,7 +71,7 @@ export default async function RootLayout({
}: { }: {
children: React.ReactNode; children: React.ReactNode;
}) { }) {
const locale = await getLocale(); const locale = await resolveRequestLocale();
const nonce = (await headers()).get("x-nonce") ?? ""; const nonce = (await headers()).get("x-nonce") ?? "";
const parentOrigin = process.env.NEXT_PUBLIC_PARENT_ORIGIN || ""; const parentOrigin = process.env.NEXT_PUBLIC_PARENT_ORIGIN || "";
@@ -92,7 +115,6 @@ export default async function RootLayout({
> >
<ServiceWorkerRegistration /> <ServiceWorkerRegistration />
{children} {children}
<PWAInstallPrompt />
</body> </body>
</html> </html>
); );
+9 -15
View File
@@ -1,17 +1,14 @@
import type { Metadata } from 'next'; import type { Metadata } from 'next';
import type { ReactNode } from 'react'; import type { ReactNode } from 'react';
import { Geist, Geist_Mono } from 'next/font/google';
import '../globals.css';
const geistSans = Geist({ // The plugin sandbox iframe runs with an opaque origin (the `sandbox`
variable: '--font-geist-sans', // attribute in production excludes `allow-same-origin` for isolation). Any
subsets: ['latin'], // asset request from this layout - bundled fonts, globals.css, etc. - is then
}); // cross-origin from the "null" origin to the host origin and gets blocked
// (fonts in particular require CORS). So this layout is intentionally minimal:
const geistMono = Geist_Mono({ // no font imports, no CSS imports. Plugins ship their own styles, and both the
variable: '--font-geist-mono', // plugin bundle and all host API calls travel over the postMessage RPC bridge,
subsets: ['latin'], // so the sandbox never fetches same-origin assets itself.
});
export const metadata: Metadata = { export const metadata: Metadata = {
title: 'Plugin sandbox', title: 'Plugin sandbox',
@@ -21,10 +18,7 @@ export const metadata: Metadata = {
export default function PluginSandboxLayout({ children }: { children: ReactNode }) { export default function PluginSandboxLayout({ children }: { children: ReactNode }) {
return ( return (
<html lang="en"> <html lang="en">
<body <body style={{ margin: 0, padding: 0, background: 'transparent' }}>
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
style={{ margin: 0, padding: 0, background: 'transparent' }}
>
{children} {children}
</body> </body>
</html> </html>
+7 -3
View File
@@ -26,14 +26,18 @@ const ALLOWED_MIME_TYPES = new Set([
'image/vnd.microsoft.icon', 'image/vnd.microsoft.icon',
]); ]);
type UploadSlot = BrandingOverrideKey;
/** Slots that correspond to branding config keys */ /** Slots that correspond to branding config keys */
const VALID_SLOTS = new Set<BrandingOverrideKey>([ const VALID_SLOTS = new Set<UploadSlot>([
'faviconUrl', 'faviconUrl',
'pwaIconUrl', 'pwaIconUrl',
'appLogoLightUrl', 'appLogoLightUrl',
'appLogoDarkUrl', 'appLogoDarkUrl',
'loginLogoLightUrl', 'loginLogoLightUrl',
'loginLogoDarkUrl', 'loginLogoDarkUrl',
'pwaScreenshotMobileUrl',
'pwaScreenshotDesktopUrl',
]); ]);
const EXT_BY_MIME: Record<string, string> = { const EXT_BY_MIME: Record<string, string> = {
@@ -131,7 +135,7 @@ export async function POST(request: NextRequest) {
return NextResponse.json({ error: 'Missing file or slot' }, { status: 400 }); return NextResponse.json({ error: 'Missing file or slot' }, { status: 400 });
} }
if (!VALID_SLOTS.has(slot as BrandingOverrideKey)) { if (!VALID_SLOTS.has(slot as UploadSlot)) {
return NextResponse.json({ error: `Invalid slot: ${slot}` }, { status: 400 }); return NextResponse.json({ error: `Invalid slot: ${slot}` }, { status: 400 });
} }
@@ -226,7 +230,7 @@ export async function DELETE(request: NextRequest) {
const slot = body.slot; const slot = body.slot;
const rawHost = body.host ?? ''; const rawHost = body.host ?? '';
if (!slot || !VALID_SLOTS.has(slot as BrandingOverrideKey)) { if (!slot || !VALID_SLOTS.has(slot as UploadSlot)) {
return NextResponse.json({ error: 'Invalid or missing slot' }, { status: 400 }); return NextResponse.json({ error: 'Invalid or missing slot' }, { status: 400 });
} }
+3
View File
@@ -192,6 +192,9 @@ export async function POST(request: NextRequest) {
...(manifest.settingsSchema && typeof manifest.settingsSchema === 'object' ...(manifest.settingsSchema && typeof manifest.settingsSchema === 'object'
? { settingsSchema: manifest.settingsSchema as ServerPlugin['settingsSchema'] } ? { settingsSchema: manifest.settingsSchema as ServerPlugin['settingsSchema'] }
: {}), : {}),
...(manifest.locales && typeof manifest.locales === 'object'
? { locales: manifest.locales as ServerPlugin['locales'] }
: {}),
...(declaredFrameOrigins.length > 0 ...(declaredFrameOrigins.length > 0
? { frameOrigins: declaredFrameOrigins } ? { frameOrigins: declaredFrameOrigins }
: {}), : {}),
+53
View File
@@ -0,0 +1,53 @@
import { NextRequest, NextResponse } from 'next/server';
import { logger } from '@/lib/logger';
import { configManager } from '@/lib/admin/config-manager';
import { getMetadata, getRequiredConfig } from '@/lib/oauth/token-exchange';
/**
* Same-origin OAuth metadata (discovery) proxy.
*
* The login page needs the authorization_endpoint to build the PKCE authorize
* URL in the browser. Discovering it directly from the browser means a
* cross-origin fetch to the IdP's /.well-known/* documents, which is subject
* to CORS: providers like Authentik serve those documents without an
* Access-Control-Allow-Origin header, so the browser blocks the response and
* discovery fails (issue #382). Performing discovery here - server to server,
* where CORS does not apply - and handing the result back as a same-origin
* response sidesteps the problem entirely.
*
* The discovery URL is resolved from admin config (via server_id), never from
* client input, so this cannot be abused as an open SSRF proxy. Endpoint URLs
* in the discovered document are still gated by the SSRF validator inside
* discoverOAuth. The returned fields are public well-known metadata.
*/
export async function GET(request: NextRequest) {
await configManager.ensureLoaded();
const serverId = request.nextUrl.searchParams.get('server_id');
let discoveryUrl: string;
try {
({ discoveryUrl } = getRequiredConfig(serverId));
} catch {
// OAuth not configured for this server - surface as "no metadata" rather
// than a 500 so the login page just hides the SSO button.
return NextResponse.json({ error: 'OAuth not configured' }, { status: 404 });
}
try {
const metadata = await getMetadata(serverId);
if (!metadata?.authorization_endpoint || !metadata.token_endpoint) {
logger.warn('OAuth metadata discovery returned no usable endpoints', { discoveryUrl });
return NextResponse.json({ error: 'OAuth discovery failed' }, { status: 502 });
}
return NextResponse.json(metadata, {
// Mirror the in-process discovery cache TTL so repeated login-page loads
// hit the CDN/browser cache instead of re-running discovery.
headers: { 'Cache-Control': 'private, max-age=600' },
});
} catch (error) {
logger.error('OAuth metadata discovery error', {
error: error instanceof Error ? error.message : 'Unknown error',
});
return NextResponse.json({ error: 'OAuth discovery failed' }, { status: 502 });
}
}
+58 -5
View File
@@ -723,7 +723,18 @@ const emails: MockEmail[] = [
// Identities // Identities
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
const IDENTITIES = [ type MockIdentity = {
id: string;
name: string;
email: string;
replyTo: Array<{ name?: string; email: string }> | null;
bcc: Array<{ name?: string; email: string }> | null;
textSignature: string | null;
htmlSignature: string | null;
mayDelete: boolean;
};
const IDENTITIES: MockIdentity[] = [
{ {
id: 'identity-001', id: 'identity-001',
name: 'Dev User', name: 'Dev User',
@@ -1562,13 +1573,55 @@ function handleIdentityGet(_args: MethodArgs, callId: string): MethodResult {
function handleIdentitySet(args: MethodArgs, callId: string): MethodResult { function handleIdentitySet(args: MethodArgs, callId: string): MethodResult {
const created: Record<string, { id: string }> = {}; const created: Record<string, { id: string }> = {};
const create = args.create as Record<string, unknown> | undefined; const updated: Record<string, null> = {};
const destroyed: string[] = [];
const create = args.create as Record<string, Record<string, unknown>> | undefined;
if (create) { if (create) {
for (const key of Object.keys(create)) { for (const [key, data] of Object.entries(create)) {
created[key] = { id: `identity-new-${Date.now()}-${key}` }; const newId = `identity-${Date.now()}-${key}`;
IDENTITIES.push({
id: newId,
name: (data.name as string) || '',
email: (data.email as string) || '',
replyTo: (data.replyTo as MockIdentity['replyTo']) ?? null,
bcc: (data.bcc as MockIdentity['bcc']) ?? null,
textSignature: (data.textSignature as string | null) ?? null,
htmlSignature: (data.htmlSignature as string | null) ?? null,
mayDelete: true,
});
created[key] = { id: newId };
} }
} }
return ['Identity/set', { accountId: ACCOUNT_ID, oldState: nextState(), newState: nextState(), created, updated: null, destroyed: null }, callId];
const update = args.update as Record<string, Record<string, unknown>> | undefined;
if (update) {
for (const [id, changes] of Object.entries(update)) {
const identity = IDENTITIES.find((i) => i.id === id);
if (identity) {
// Email is immutable per the identity form, so it's never in `changes`.
if (changes.name !== undefined) identity.name = changes.name as string;
if (changes.replyTo !== undefined) identity.replyTo = changes.replyTo as MockIdentity['replyTo'];
if (changes.bcc !== undefined) identity.bcc = changes.bcc as MockIdentity['bcc'];
if (changes.textSignature !== undefined) identity.textSignature = changes.textSignature as string | null;
if (changes.htmlSignature !== undefined) identity.htmlSignature = changes.htmlSignature as string | null;
updated[id] = null;
}
}
}
const destroy = args.destroy as string[] | undefined;
if (destroy) {
for (const id of destroy) {
const idx = IDENTITIES.findIndex((i) => i.id === id);
if (idx !== -1) {
IDENTITIES.splice(idx, 1);
destroyed.push(id);
}
}
}
return ['Identity/set', { accountId: ACCOUNT_ID, oldState: nextState(), newState: nextState(), created, updated, destroyed, notCreated: null, notUpdated: null, notDestroyed: null }, callId];
} }
function handleThreadGet(args: MethodArgs, callId: string): MethodResult { function handleThreadGet(args: MethodArgs, callId: string): MethodResult {
+17 -12
View File
@@ -19,6 +19,20 @@ const negativeCache = new Map<string, NegativeCacheEntry>();
const NEGATIVE_CACHE_TTL_MS = 24 * 60 * 60 * 1000; // 1 day const NEGATIVE_CACHE_TTL_MS = 24 * 60 * 60 * 1000; // 1 day
const NEGATIVE_CACHE_MAX_SIZE = 2000; const NEGATIVE_CACHE_MAX_SIZE = 2000;
// 1x1 transparent PNG. Returned with HTTP 200 (instead of 404) when no
// favicon exists for a domain, so the browser's <img> tag loads it cleanly
// without spamming the DevTools console with red 404 errors. Avatar.tsx
// checks `naturalWidth <= 1` in onLoad and falls back to initials.
const TRANSPARENT_PNG = Buffer.from(
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNgAAIAAAUAAen63NgAAAAASUVORK5CYII=',
'base64',
);
const MISSING_FAVICON_HEADERS = {
'Content-Type': 'image/png',
'Cache-Control': 'public, max-age=86400', // 1 day
'X-Bulwark-Favicon': 'missing',
};
// Strict domain validation to prevent SSRF // Strict domain validation to prevent SSRF
const DOMAIN_RE = /^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)+$/i; const DOMAIN_RE = /^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)+$/i;
@@ -434,10 +448,7 @@ export async function GET(request: NextRequest) {
// Check negative cache (domains known to have no favicon) // Check negative cache (domains known to have no favicon)
const neg = negativeCache.get(normalizedDomain); const neg = negativeCache.get(normalizedDomain);
if (neg && Date.now() - neg.fetchedAt < NEGATIVE_CACHE_TTL_MS) { if (neg && Date.now() - neg.fetchedAt < NEGATIVE_CACHE_TTL_MS) {
return new NextResponse(null, { return new NextResponse(TRANSPARENT_PNG, { headers: MISSING_FAVICON_HEADERS });
status: 404,
headers: { 'Cache-Control': 'public, max-age=86400' }, // 1 day
});
} }
// Check cache // Check cache
@@ -460,10 +471,7 @@ export async function GET(request: NextRequest) {
if (!upstream.ok) { if (!upstream.ok) {
evictNegativeOldest(); evictNegativeOldest();
negativeCache.set(normalizedDomain, { fetchedAt: Date.now() }); negativeCache.set(normalizedDomain, { fetchedAt: Date.now() });
return new NextResponse(null, { return new NextResponse(TRANSPARENT_PNG, { headers: MISSING_FAVICON_HEADERS });
status: 404,
headers: { 'Cache-Control': 'public, max-age=86400' },
});
} }
const contentType = upstream.headers.get('content-type') || 'image/x-icon'; const contentType = upstream.headers.get('content-type') || 'image/x-icon';
@@ -473,10 +481,7 @@ export async function GET(request: NextRequest) {
if (data.byteLength < 10) { if (data.byteLength < 10) {
evictNegativeOldest(); evictNegativeOldest();
negativeCache.set(normalizedDomain, { fetchedAt: Date.now() }); negativeCache.set(normalizedDomain, { fetchedAt: Date.now() });
return new NextResponse(null, { return new NextResponse(TRANSPARENT_PNG, { headers: MISSING_FAVICON_HEADERS });
status: 404,
headers: { 'Cache-Control': 'public, max-age=86400' },
});
} }
// Cache the result // Cache the result
+3
View File
@@ -57,6 +57,9 @@ export async function GET() {
// Per-user settings schema, captured from the manifest at upload/load // Per-user settings schema, captured from the manifest at upload/load
// time so the client can render the settings UI without re-parsing. // time so the client can render the settings UI without re-parsing.
settingsSchema: p.settingsSchema, settingsSchema: p.settingsSchema,
// Plugin-declared i18n tables, so the sandbox can localize plugin
// strings via api.i18n.t().
locales: p.locales,
})); }));
// Only serve enabled themes // Only serve enabled themes
+104
View File
@@ -0,0 +1,104 @@
import { NextRequest, NextResponse } from 'next/server';
import sharp from 'sharp';
import path from 'node:path';
import { readFile } from 'node:fs/promises';
import { configManager } from '@/lib/admin/config-manager';
import { getConfigDir } from '@/lib/admin/paths';
import {
matchDomainBranding,
parseDomainBranding,
pickRequestHost,
} from '@/lib/admin/domain-branding';
/**
* Variant target output size + admin config key.
* Matches the sizes declared in app/manifest.ts so the rendered PNG fits
* the slot the manifest tells the browser about.
*/
const VARIANTS = {
mobile: { width: 540, height: 720, configKey: 'pwaScreenshotMobileUrl' as const },
desktop: { width: 1280, height: 720, configKey: 'pwaScreenshotDesktopUrl' as const },
} as const;
type Variant = keyof typeof VARIANTS;
// Cache resized images keyed by (variant, source URL).
const cache = new Map<string, Blob>();
async function fetchSourceImage(iconUrl: string): Promise<Buffer> {
if (iconUrl.startsWith('http://') || iconUrl.startsWith('https://')) {
const res = await fetch(iconUrl);
if (!res.ok) throw new Error(`Failed to fetch PWA screenshot: ${res.status}`);
return Buffer.from(await res.arrayBuffer());
}
// Admin-uploaded branding asset: served from /api/admin/branding/<file>
// but stored on disk under getConfigDir()/branding/.
const ADMIN_BRANDING_PREFIX = '/api/admin/branding/';
if (iconUrl.startsWith(ADMIN_BRANDING_PREFIX)) {
const filename = path.basename(iconUrl.slice(ADMIN_BRANDING_PREFIX.length));
return readFile(path.join(getConfigDir(), 'branding', filename));
}
// Path relative to public/ directory
const publicPath = path.join(process.cwd(), 'public', iconUrl.replace(/^\//, ''));
return readFile(publicPath);
}
export async function GET(
req: NextRequest,
{ params }: { params: Promise<{ variant: string }> },
) {
const { variant: variantParam } = await params;
if (!(variantParam in VARIANTS)) {
return new NextResponse('Invalid variant. Allowed: mobile, desktop', { status: 400 });
}
const { width, height, configKey } = VARIANTS[variantParam as Variant];
await configManager.ensureLoaded();
const host = pickRequestHost(req);
const domainOverrides = matchDomainBranding(
host,
parseDomainBranding(configManager.get<unknown>('domainBranding', [])),
);
const sources = configManager.getAllWithSources();
const sourceEntry = sources[configKey];
const screenshotUrl =
domainOverrides[configKey] ||
(sourceEntry?.source !== 'default' ? (sourceEntry?.value as string | undefined) : undefined);
if (!screenshotUrl) {
return new NextResponse('No PWA screenshot configured', { status: 404 });
}
const pngHeaders = {
'Content-Type': 'image/png',
'Cache-Control': 'public, max-age=86400',
Vary: 'Host, X-Forwarded-Host',
};
const cacheKey = `${variantParam}|${screenshotUrl}`;
try {
if (cache.has(cacheKey)) {
return new NextResponse(cache.get(cacheKey)!, { headers: pngHeaders });
}
const sourceBuffer = await fetchSourceImage(screenshotUrl);
// 'cover' fills the target box without letterboxing - screenshots benefit
// more from cropping than from a transparent frame around them. Users get
// a hint about the recommended aspect ratio in the admin UI.
const resized = await sharp(sourceBuffer)
.resize(width, height, { fit: 'cover', position: 'center' })
.png()
.toBuffer();
const ab = new ArrayBuffer(resized.byteLength);
new Uint8Array(ab).set(resized);
const blob = new Blob([ab], { type: 'image/png' });
cache.set(cacheKey, blob);
return new NextResponse(blob, { headers: pngHeaders });
} catch (err) {
console.error('Failed to generate PWA screenshot:', err);
return new NextResponse('Failed to generate screenshot', { status: 500 });
}
}
+3
View File
@@ -23,10 +23,13 @@ const ALLOWED_MIME_TYPES = new Set([
const VALID_SLOTS = new Set([ const VALID_SLOTS = new Set([
'faviconUrl', 'faviconUrl',
'pwaIconUrl',
'appLogoLightUrl', 'appLogoLightUrl',
'appLogoDarkUrl', 'appLogoDarkUrl',
'loginLogoLightUrl', 'loginLogoLightUrl',
'loginLogoDarkUrl', 'loginLogoDarkUrl',
'pwaScreenshotMobileUrl',
'pwaScreenshotDesktopUrl',
]); ]);
const EXT_BY_MIME: Record<string, string> = { const EXT_BY_MIME: Record<string, string> = {
+298
View File
@@ -0,0 +1,298 @@
import { NextRequest, NextResponse } from 'next/server';
// Host-side proxy backing the "Translate" plugin (manifest apiPostPaths:
// ["/api/translate"]). The plugin slot iframe POSTs { text, target, source,
// provider } here via api.http.post; we forward to a free translation backend
// and return { translatedText, detectedSource } in a stable shape.
//
// Two providers:
// - "mymemory" — public MyMemory API, no configuration required. Its
// langpair needs an explicit source language, so when the
// plugin asks for "auto" we detect it locally first.
// - "libretranslate" — only available when the host sets LIBRETRANSLATE_URL
// (and optionally LIBRETRANSLATE_API_KEY). Supports native
// source auto-detection.
export const runtime = 'nodejs';
const MAX_CHARS = 5000;
// Long bodies are split into ~480-char chunks for MyMemory and translated
// sequentially, so allow enough headroom for ~10 round-trips.
const TIMEOUT_MS = 25000;
type Provider = 'mymemory' | 'libretranslate';
interface TranslateBody {
text?: unknown;
target?: unknown;
source?: unknown;
provider?: unknown;
}
interface TranslateResult {
translatedText: string;
detectedSource?: string;
}
// ─── Lightweight language detection ───────────────────────────
//
// MyMemory has no auto-detect, so we infer a source language from the text.
// Non-Latin scripts are decided by Unicode range; Latin-script European
// languages are scored by stop-word frequency. Detection only needs to be good
// enough to (a) pick a sensible langpair and (b) let the plugin skip messages
// already in the target language.
const SCRIPT_RANGES: ReadonlyArray<[RegExp, string]> = [
[/[぀-ヿ]/, 'ja'], // Hiragana / Katakana
[/[가-힯]/, 'ko'], // Hangul
[/[一-鿿]/, 'zh'], // CJK ideographs (after JP/KR checks)
[/[Ѐ-ӿ]/, 'ru'], // Cyrillic (ru vs uk refined below)
[/[Ͱ-Ͽ]/, 'el'], // Greek
[/[؀-ۿ]/, 'ar'], // Arabic
[/[֐-׿]/, 'he'], // Hebrew
[/[ऀ-ॿ]/, 'hi'], // Devanagari
];
// Distinctive stop words per Latin-script language from the manifest's option
// list. Kept small and high-signal to avoid cross-language collisions.
const LATIN_STOPWORDS: Record<string, readonly string[]> = {
en: ['the', 'and', 'you', 'that', 'with', 'for', 'this', 'have', 'are'],
de: ['der', 'die', 'und', 'das', 'ist', 'nicht', 'mit', 'sie', 'ein', 'auch'],
fr: ['les', 'des', 'une', 'est', 'pour', 'que', 'vous', 'dans', 'avec', 'pas'],
es: ['que', 'los', 'una', 'por', 'con', 'para', 'como', 'pero', 'más', 'esta'],
it: ['che', 'non', 'per', 'una', 'sono', 'con', 'come', 'questo', 'anche', 'della'],
pt: ['que', 'não', 'uma', 'com', 'para', 'como', 'mais', 'você', 'está', 'isso'],
nl: ['het', 'een', 'van', 'dat', 'niet', 'met', 'voor', 'aan', 'zijn', 'maar'],
pl: ['nie', 'jest', 'się', 'ale', 'oraz', 'tego', 'jak', 'tym', 'przez', 'dla'],
sv: ['och', 'att', 'det', 'som', 'för', 'med', 'inte', 'den', 'till', 'har'],
no: ['og', 'det', 'som', 'for', 'med', 'ikke', 'har', 'til', 'denne', 'jeg'],
da: ['og', 'det', 'som', 'for', 'med', 'ikke', 'har', 'til', 'denne', 'jeg'],
fi: ['että', 'olen', 'tämä', 'kanssa', 'mutta', 'sekä', 'ei', 'on', 'ja', 'jotta'],
cs: ['není', 'jsem', 'pro', 'ale', 'jako', 'tento', 'také', 'přes', 'jsou', 'své'],
ro: ['este', 'pentru', 'care', 'dar', 'sunt', 'această', 'mai', 'din', 'sau', 'nu'],
hu: ['hogy', 'nem', 'egy', 'van', 'ezt', 'vagy', 'mint', 'csak', 'ezzel', 'így'],
tr: ['bir', 'için', 'değil', 'bu', 'çok', 'daha', 'ama', 'gibi', 've', 'ile'],
};
function detectLanguage(text: string): string {
const sample = text.slice(0, 1000);
for (const [range, lang] of SCRIPT_RANGES) {
if (range.test(sample)) {
// Ukrainian shares Cyrillic with Russian; its unique glyphs decide it.
if (lang === 'ru' && /[єіїґ]/.test(sample)) return 'uk';
return lang;
}
}
const words = sample.toLowerCase().match(/[a-zà-ÿčśžłńęąółżźć]+/gi) || [];
if (words.length === 0) return 'en';
const counts: Record<string, number> = {};
const wordSet = new Set(words);
for (const [lang, stops] of Object.entries(LATIN_STOPWORDS)) {
let score = 0;
for (const stop of stops) if (wordSet.has(stop)) score += 1;
counts[lang] = score;
}
let best = 'en';
let bestScore = -1;
for (const [lang, score] of Object.entries(counts)) {
if (score > bestScore) {
best = lang;
bestScore = score;
}
}
return bestScore > 0 ? best : 'en';
}
function baseLang(code: string): string {
return String(code || '').toLowerCase().split('-')[0];
}
// ─── Chunking ─────────────────────────────────────────────────
//
// MyMemory's free endpoint caps each request's `q` at 500 characters and
// silently returns only the translated prefix beyond that — which is why long
// emails came back truncated ("…about Sel…"). We split the text into
// line-aware chunks under the limit, translate each, then rejoin so the whole
// body is covered.
const MYMEMORY_CHUNK = 480;
function chunkText(text: string, max: number): string[] {
const chunks: string[] = [];
let cur = '';
const flush = () => {
if (cur) {
chunks.push(cur);
cur = '';
}
};
for (const line of text.split('\n')) {
if (line.length > max) {
flush();
// A single over-long line (e.g. a long URL list): split on words, and
// hard-cut any word that is itself longer than the limit.
let seg = '';
for (const word of line.split(' ')) {
const piece = seg ? seg + ' ' + word : word;
if (piece.length > max) {
if (seg) {
chunks.push(seg);
seg = '';
}
if (word.length > max) {
for (let i = 0; i < word.length; i += max) chunks.push(word.slice(i, i + max));
} else {
seg = word;
}
} else {
seg = piece;
}
}
if (seg) chunks.push(seg);
continue;
}
if (cur && cur.length + 1 + line.length > max) flush();
cur = cur ? cur + '\n' + line : line;
}
flush();
return chunks;
}
// ─── Providers ────────────────────────────────────────────────
async function mymemoryRequest(
q: string,
langpair: string,
signal: AbortSignal,
): Promise<string> {
const url = new URL('https://api.mymemory.translated.net/get');
url.searchParams.set('q', q);
url.searchParams.set('langpair', langpair);
const res = await fetch(url.toString(), {
signal,
headers: { 'User-Agent': 'JMAP-Webmail/1.0 Translate-Plugin' },
});
const data = (await res.json().catch(() => null)) as
| { responseStatus?: number | string; responseData?: { translatedText?: string }; responseDetails?: string }
| null;
if (!res.ok || !data) {
throw new Error(`MyMemory returned ${res.status}`);
}
const status = Number(data.responseStatus);
if (status && status !== 200) {
throw new Error(data.responseDetails || `MyMemory error ${status}`);
}
const translatedText = data.responseData?.translatedText || '';
if (!translatedText) {
throw new Error('MyMemory returned no translation');
}
return translatedText;
}
async function translateMyMemory(
text: string,
source: string,
target: string,
signal: AbortSignal,
): Promise<TranslateResult> {
const detected = source === 'auto' || !source ? detectLanguage(text) : baseLang(source);
const tgt = baseLang(target);
// Nothing to do if already in the target language; the plugin skips display.
if (detected === tgt) {
return { translatedText: text, detectedSource: detected };
}
const langpair = `${detected}|${tgt}`;
const chunks = chunkText(text, MYMEMORY_CHUNK);
// Sequential to stay friendly to MyMemory's free-tier rate limits; emails are
// usually one or two chunks.
const translated: string[] = [];
for (const chunk of chunks) {
translated.push(await mymemoryRequest(chunk, langpair, signal));
}
return { translatedText: translated.join('\n'), detectedSource: detected };
}
async function translateLibre(
text: string,
source: string,
target: string,
signal: AbortSignal,
): Promise<TranslateResult> {
const endpoint = process.env.LIBRETRANSLATE_URL;
if (!endpoint) {
throw new Error('LibreTranslate is not configured on this server');
}
const apiKey = process.env.LIBRETRANSLATE_API_KEY;
const url = endpoint.replace(/\/+$/, '') + '/translate';
const res = await fetch(url, {
method: 'POST',
signal,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
q: text,
source: source || 'auto',
target: baseLang(target),
format: 'text',
...(apiKey ? { api_key: apiKey } : {}),
}),
});
const data = (await res.json().catch(() => null)) as
| { translatedText?: string; detectedLanguage?: { language?: string }; error?: string }
| null;
if (!res.ok || !data) {
throw new Error(data?.error || `LibreTranslate returned ${res.status}`);
}
if (!data.translatedText) {
throw new Error(data.error || 'LibreTranslate returned no translation');
}
return {
translatedText: data.translatedText,
detectedSource: data.detectedLanguage?.language || (source !== 'auto' ? baseLang(source) : undefined),
};
}
// ─── Route ────────────────────────────────────────────────────
export async function POST(request: NextRequest) {
let body: TranslateBody;
try {
body = (await request.json()) as TranslateBody;
} catch {
return NextResponse.json({ error: 'Invalid request body' }, { status: 400 });
}
const text = typeof body.text === 'string' ? body.text.trim() : '';
const target = typeof body.target === 'string' && body.target.trim() ? body.target.trim() : 'en';
const source = typeof body.source === 'string' && body.source.trim() ? body.source.trim() : 'auto';
const provider: Provider = body.provider === 'libretranslate' ? 'libretranslate' : 'mymemory';
if (!text) {
return NextResponse.json({ error: 'No text to translate' }, { status: 400 });
}
if (text.length > MAX_CHARS) {
return NextResponse.json(
{ error: `Text exceeds the ${MAX_CHARS}-character limit` },
{ status: 413 },
);
}
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), TIMEOUT_MS);
try {
const result =
provider === 'libretranslate'
? await translateLibre(text, source, target, controller.signal)
: await translateMyMemory(text, source, target, controller.signal);
return NextResponse.json(result, { status: 200 });
} catch (error: unknown) {
if (error instanceof Error && error.name === 'AbortError') {
return NextResponse.json({ error: 'Translation timed out' }, { status: 504 });
}
const message = error instanceof Error ? error.message : 'Translation failed';
return NextResponse.json({ error: message }, { status: 502 });
} finally {
clearTimeout(timeout);
}
}
+1 -1
View File
@@ -70,7 +70,7 @@
} }
.dark { .dark {
--color-border: #262626; --color-border: rgba(128, 128, 128, 0.3);
--color-input: #262626; --color-input: #262626;
--color-ring: #d4d4d4; --color-ring: #d4d4d4;
--color-background: #0a0a0a; --color-background: #0a0a0a;
+19 -4
View File
@@ -95,10 +95,25 @@ export default async function manifest(): Promise<ExtendedManifest> {
background_color: backgroundColor, background_color: backgroundColor,
icons, icons,
categories: ["productivity"], categories: ["productivity"],
screenshots: [ // Use admin-uploaded screenshots when configured (per-domain override,
{ src: withBase("/screenshot-540x720.png"), sizes: "540x720", type: "image/png" }, // admin/env global; resized on the fly via /api/pwa-screenshot/[variant]);
{ src: withBase("/screenshot-1280x720.png"), sizes: "1280x720", type: "image/png" }, // otherwise fall back to the built-in Bulwark screenshots from public/.
], screenshots: (() => {
const hasMobile =
!!domainOverrides.pwaScreenshotMobileUrl ||
sources.pwaScreenshotMobileUrl?.source !== "default";
const hasDesktop =
!!domainOverrides.pwaScreenshotDesktopUrl ||
sources.pwaScreenshotDesktopUrl?.source !== "default";
return [
hasMobile
? { src: withBase("/api/pwa-screenshot/mobile"), sizes: "540x720", type: "image/png" }
: { src: withBase("/screenshot-540x720.png"), sizes: "540x720", type: "image/png" },
hasDesktop
? { src: withBase("/api/pwa-screenshot/desktop"), sizes: "1280x720", type: "image/png" }
: { src: withBase("/screenshot-1280x720.png"), sizes: "1280x720", type: "image/png" },
];
})(),
protocol_handlers: [ protocol_handlers: [
{ protocol: "mailto", url: withBase("/protocol/mailto?url=%s") }, { protocol: "mailto", url: withBase("/protocol/mailto?url=%s") },
{ protocol: "webcal", url: withBase("/protocol/webcal?url=%s") }, { protocol: "webcal", url: withBase("/protocol/webcal?url=%s") },
+15 -2
View File
@@ -2,7 +2,7 @@
import { useMemo, useState } from "react"; import { useMemo, useState } from "react";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import { ChevronDown, ChevronRight, Globe, ListTodo, Pencil, RefreshCw, Share2, Trash2, Cake, User, Users, Plus, Eraser, Palette } from "lucide-react"; import { ChevronDown, ChevronRight, Globe, ListTodo, Pencil, RefreshCw, Share2, Trash2, Cake, User, Users, Plus, Eraser, Palette, Shuffle } from "lucide-react";
import { cn, formatDateTime } from "@/lib/utils"; import { cn, formatDateTime } from "@/lib/utils";
import type { Calendar } from "@/lib/jmap/types"; import type { Calendar } from "@/lib/jmap/types";
import { CalendarColorPicker } from "@/components/settings/calendar-management-settings"; import { CalendarColorPicker } from "@/components/settings/calendar-management-settings";
@@ -11,6 +11,7 @@ import { useSettingsStore } from "@/stores/settings-store";
import { useTaskStore } from "@/stores/task-store"; import { useTaskStore } from "@/stores/task-store";
import { useAccountStore } from "@/stores/account-store"; import { useAccountStore } from "@/stores/account-store";
import { BIRTHDAY_CALENDAR_ID } from "@/lib/birthday-calendar"; import { BIRTHDAY_CALENDAR_ID } from "@/lib/birthday-calendar";
import { sharedCalendarColorKey } from "@/lib/shared-calendar-colors";
import { toast } from "@/stores/toast-store"; import { toast } from "@/stores/toast-store";
import { ContextMenu, ContextMenuItem, ContextMenuSeparator, ContextMenuSubMenu } from "@/components/ui/context-menu"; import { ContextMenu, ContextMenuItem, ContextMenuSeparator, ContextMenuSubMenu } from "@/components/ui/context-menu";
import { useContextMenu } from "@/hooks/use-context-menu"; import { useContextMenu } from "@/hooks/use-context-menu";
@@ -50,6 +51,7 @@ interface CalendarSidebarPanelProps {
selectedCalendarIds: string[]; selectedCalendarIds: string[];
onToggleVisibility: (id: string) => void; onToggleVisibility: (id: string) => void;
onColorChange?: (calendarId: string, color: string) => void; onColorChange?: (calendarId: string, color: string) => void;
onResetColor?: (calendar: Calendar) => void;
onShareCalendar?: (calendar: Calendar) => void; onShareCalendar?: (calendar: Calendar) => void;
onCreateEvent?: (calendar: Calendar) => void; onCreateEvent?: (calendar: Calendar) => void;
onClearCalendar?: (calendar: Calendar) => void; onClearCalendar?: (calendar: Calendar) => void;
@@ -71,6 +73,7 @@ export function CalendarSidebarPanel({
selectedCalendarIds, selectedCalendarIds,
onToggleVisibility, onToggleVisibility,
onColorChange, onColorChange,
onResetColor,
onShareCalendar, onShareCalendar,
onCreateEvent, onCreateEvent,
onClearCalendar, onClearCalendar,
@@ -94,6 +97,7 @@ export function CalendarSidebarPanel({
const refreshICalSubscription = useCalendarStore((s) => s.refreshICalSubscription); const refreshICalSubscription = useCalendarStore((s) => s.refreshICalSubscription);
const removeICalSubscription = useCalendarStore((s) => s.removeICalSubscription); const removeICalSubscription = useCalendarStore((s) => s.removeICalSubscription);
const timeFormat = useSettingsStore((s) => s.timeFormat); const timeFormat = useSettingsStore((s) => s.timeFormat);
const sharedCalendarColors = useSettingsStore((s) => s.sharedCalendarColors);
const enableCalendarTasks = useSettingsStore((s) => s.enableCalendarTasks); const enableCalendarTasks = useSettingsStore((s) => s.enableCalendarTasks);
const tasks = useTaskStore((s) => s.tasks); const tasks = useTaskStore((s) => s.tasks);
const setViewMode = useCalendarStore((s) => s.setViewMode); const setViewMode = useCalendarStore((s) => s.setViewMode);
@@ -303,9 +307,11 @@ export function CalendarSidebarPanel({
const canCreate = onCreateEvent && !isBirthday && cal.myRights?.mayWriteOwn !== false; const canCreate = onCreateEvent && !isBirthday && cal.myRights?.mayWriteOwn !== false;
const canShare = onShareCalendar && cal.myRights?.mayShare && !cal.isShared; const canShare = onShareCalendar && cal.myRights?.mayShare && !cal.isShared;
const canChangeColor = !!onColorChange; const canChangeColor = !!onColorChange;
const hasColorOverride = !!cal.isShared && !!sharedCalendarColors[sharedCalendarColorKey(cal)];
const canResetColor = !!onResetColor && hasColorOverride;
const canClear = onClearCalendar && !isBirthday && cal.myRights?.mayDelete !== false; const canClear = onClearCalendar && !isBirthday && cal.myRights?.mayDelete !== false;
const canDelete = onDeleteCalendar && !isBirthday && !cal.isDefault && !cal.isShared; const canDelete = onDeleteCalendar && !isBirthday && !cal.isDefault && !cal.isShared;
const showSeparator = (canCreate || canShare || canChangeColor) && (canClear || canDelete); const showSeparator = (canCreate || canShare || canChangeColor || canResetColor) && (canClear || canDelete);
const color = cal.color || "#3b82f6"; const color = cal.color || "#3b82f6";
return ( return (
@@ -335,6 +341,13 @@ export function CalendarSidebarPanel({
</div> </div>
</ContextMenuSubMenu> </ContextMenuSubMenu>
)} )}
{canResetColor && (
<ContextMenuItem
icon={Shuffle}
label={tMgmt('random_color')}
onClick={() => { closeContextMenu(); onResetColor!(cal); }}
/>
)}
{showSeparator && <ContextMenuSeparator />} {showSeparator && <ContextMenuSeparator />}
{canClear && ( {canClear && (
<ContextMenuItem <ContextMenuItem
+5
View File
@@ -34,6 +34,11 @@ function sanitizeColor(color: string | null | undefined, fallback = "#3b82f6"):
} }
function getEventColor(event: CalendarEvent, calendar?: Calendar): string { function getEventColor(event: CalendarEvent, calendar?: Calendar): string {
// A local color override on a shared calendar wins over per-event colors,
// so the whole shared calendar paints uniformly in the viewer's chosen hue.
if (calendar?.colorIsLocalOverride && calendar.color) {
return sanitizeColor(calendar.color);
}
return sanitizeColor(event.color, sanitizeColor(calendar?.color)); return sanitizeColor(event.color, sanitizeColor(calendar?.color));
} }
+158 -33
View File
@@ -5,11 +5,14 @@ import { useFocusTrap } from "@/hooks/use-focus-trap";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { X, Paperclip, Send, Save, Check, Loader2, AlertCircle, FileText, BookmarkPlus, ShieldCheck, Lock, CalendarClock, ChevronDown } from "lucide-react"; import { X, Paperclip, Send, Save, Check, Loader2, AlertCircle, FileText, BookmarkPlus, ShieldCheck, Lock, CalendarClock, ChevronDown, MailCheck } from "lucide-react";
import { cn, formatFileSize, formatDateTime, generateUUID } from "@/lib/utils"; import { cn, formatFileSize, formatDateTime, generateUUID } from "@/lib/utils";
import { debug } from "@/lib/debug"; import { debug } from "@/lib/debug";
import { toast } from "@/stores/toast-store"; import { toast } from "@/stores/toast-store";
import { sanitizeSignatureHtml } from "@/lib/email-sanitization"; import { sanitizeSignatureHtml, sanitizeEmailHtml } 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";
import { emailHooks, contactHooks } from "@/lib/plugin-hooks"; import { emailHooks, contactHooks } from "@/lib/plugin-hooks";
import type { OutgoingEmail, RecipientSuggestion } from "@/lib/plugin-types"; import type { OutgoingEmail, RecipientSuggestion } from "@/lib/plugin-types";
import { useAuthStore } from "@/stores/auth-store"; import { useAuthStore } from "@/stores/auth-store";
@@ -23,6 +26,7 @@ import { buildMimeMessage, wrapCmsAsSmimeMessage } from "@/lib/smime/mime-builde
import type { MimeAttachment } from "@/lib/smime/mime-builder"; import type { MimeAttachment } from "@/lib/smime/mime-builder";
import { smimeSign } from "@/lib/smime/smime-sign"; import { smimeSign } from "@/lib/smime/smime-sign";
import { PluginSlot } from "@/components/plugins/plugin-slot"; import { PluginSlot } from "@/components/plugins/plugin-slot";
import { FilePreviewModal } from "@/components/files/file-preview-modal";
import { smimeEncrypt } from "@/lib/smime/smime-encrypt"; import { smimeEncrypt } from "@/lib/smime/smime-encrypt";
import { useContactStore } from "@/stores/contact-store"; import { useContactStore } from "@/stores/contact-store";
import { useTemplateStore } from "@/stores/template-store"; import { useTemplateStore } from "@/stores/template-store";
@@ -89,6 +93,7 @@ interface EmailComposerProps {
inReplyTo?: string[]; inReplyTo?: string[];
references?: string[]; references?: string[];
delayedUntil?: string; delayedUntil?: string;
requestReadReceipt?: boolean;
}) => void | Promise<void>; }) => void | Promise<void>;
onScheduledSendCreated?: () => void | Promise<void>; onScheduledSendCreated?: () => void | Promise<void>;
onClose?: () => void; onClose?: () => void;
@@ -196,6 +201,7 @@ export function EmailComposer({
}: EmailComposerProps) { }: EmailComposerProps) {
const t = useTranslations('email_composer'); const t = useTranslations('email_composer');
const tCommon = useTranslations('common'); const tCommon = useTranslations('common');
const tQuote = useTranslations('quote_header');
const timeFormat = useSettingsStore((state) => state.timeFormat); const timeFormat = useSettingsStore((state) => state.timeFormat);
const plainTextMode = useSettingsStore((state) => state.plainTextMode); const plainTextMode = useSettingsStore((state) => state.plainTextMode);
const subAddressDelimiter = useSettingsStore((state) => state.subAddressDelimiter); const subAddressDelimiter = useSettingsStore((state) => state.subAddressDelimiter);
@@ -205,6 +211,7 @@ export function EmailComposer({
const sendDelaySeconds = useSettingsStore((state) => state.sendDelaySeconds); const sendDelaySeconds = useSettingsStore((state) => state.sendDelaySeconds);
const signaturePosition = useSettingsStore((state) => state.signaturePosition); const signaturePosition = useSettingsStore((state) => state.signaturePosition);
const signatureSeparatorEnabled = useSettingsStore((state) => state.signatureSeparatorEnabled); const signatureSeparatorEnabled = useSettingsStore((state) => state.signatureSeparatorEnabled);
const requestReadReceiptDefault = useSettingsStore((state) => state.requestReadReceiptDefault);
const activeIdentities = useIdentityStore((s) => s.identities); const activeIdentities = useIdentityStore((s) => s.identities);
// Pro shell: surface identities from every connected account, grouped // Pro shell: surface identities from every connected account, grouped
// for the From dropdown's <optgroup>s. Outside Pro this collapses to // for the From dropdown's <optgroup>s. Outside Pro this collapses to
@@ -265,11 +272,9 @@ export function EmailComposer({
const getInitialSubject = () => { const getInitialSubject = () => {
if (!replyTo?.subject) return ""; if (!replyTo?.subject) return "";
if (mode === 'forward') { if (mode === 'forward') {
const fwdPrefix = t('prefix.forward'); return buildForwardSubject(replyTo.subject, t('prefix.forward'));
return `${fwdPrefix} ${replyTo.subject.replace(/^(Fwd:\s*|Tr:\s*)+/i, '')}`;
} else if (mode === 'reply' || mode === 'replyAll') { } else if (mode === 'reply' || mode === 'replyAll') {
const rePrefix = t('prefix.reply'); return buildReplySubject(replyTo.subject, t('prefix.reply'));
return `${rePrefix} ${replyTo.subject.replace(/^(Re:\s*)+/i, '')}`;
} }
return ""; return "";
}; };
@@ -291,6 +296,13 @@ export function EmailComposer({
const date = replyTo.receivedAt ? formatDateTime(replyTo.receivedAt, timeFormat, { weekday: 'short', year: 'numeric', month: 'short', day: 'numeric' }) : ""; const date = replyTo.receivedAt ? formatDateTime(replyTo.receivedAt, timeFormat, { weekday: 'short', year: 'numeric', month: 'short', day: 'numeric' }) : "";
const from = replyTo.from?.[0]; const from = replyTo.from?.[0];
const fromStr = from ? `${from.name || from.email}` : tCommon('unknown'); const fromStr = from ? `${from.name || from.email}` : tCommon('unknown');
// Forward "From:" shows the full sender incl. address; reply line keeps
// the bare name (reads naturally in the localized "On … wrote:" line).
const fromStrFull = from
? (from.name && from.email && from.name !== from.email
? `${from.name} <${from.email}>`
: (from.email || from.name || tCommon('unknown')))
: tCommon('unknown');
const originalText = replyTo.body || (replyTo.htmlBody ? htmlToPlainText(replyTo.htmlBody) : ''); const originalText = replyTo.body || (replyTo.htmlBody ? htmlToPlainText(replyTo.htmlBody) : '');
const quotedText = originalText.split('\n').map(line => `> ${line}`).join('\n'); const quotedText = originalText.split('\n').map(line => `> ${line}`).join('\n');
@@ -311,9 +323,9 @@ export function EmailComposer({
} }
if (mode === 'forward') { if (mode === 'forward') {
return `${prefix}${signatureBlock}\n\n---------- Forwarded message ----------\nFrom: ${fromStr}\nDate: ${date}\nSubject: ${replyTo.subject || ''}\n\n${originalText}`; 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') { } else if (mode === 'reply' || mode === 'replyAll') {
return `${prefix}${signatureBlock}\n\nOn ${date}, ${fromStr} wrote:\n${quotedText}`; return `${prefix}${signatureBlock}\n\n${tQuote('reply_line', { date, from: fromStr })}\n${quotedText}`;
} }
return prefix; return prefix;
} }
@@ -336,6 +348,11 @@ export function EmailComposer({
const date = replyTo.receivedAt ? formatDateTime(replyTo.receivedAt, timeFormat, { weekday: 'short', year: 'numeric', month: 'short', day: 'numeric' }) : ""; const date = replyTo.receivedAt ? formatDateTime(replyTo.receivedAt, timeFormat, { weekday: 'short', year: 'numeric', month: 'short', day: 'numeric' }) : "";
const from = replyTo.from?.[0]; const from = replyTo.from?.[0];
const fromStr = from ? `${from.name || from.email}` : tCommon('unknown'); const fromStr = from ? `${from.name || from.email}` : tCommon('unknown');
const fromStrFull = from
? (from.name && from.email && from.name !== from.email
? `${from.name} <${from.email}>`
: (from.email || from.name || tCommon('unknown')))
: tCommon('unknown');
const signatureBlock = buildEmbeddedSignatureHtml(initialSignatureIdentity, { const signatureBlock = buildEmbeddedSignatureHtml(initialSignatureIdentity, {
embed: shouldEmbedSignatureAboveQuote, embed: shouldEmbedSignatureAboveQuote,
@@ -344,35 +361,46 @@ export function EmailComposer({
// Plugin override (resolved at composer open via onBuildQuoteHeader). // Plugin override (resolved at composer open via onBuildQuoteHeader).
if (replyTo.quoteHeaderHtml !== undefined && (mode === 'reply' || mode === 'replyAll' || mode === 'forward')) { if (replyTo.quoteHeaderHtml !== undefined && (mode === 'reply' || mode === 'replyAll' || mode === 'forward')) {
const wrap = replyTo.quoteWrapInBlockquote !== false; if (replyTo.htmlBody) {
const originalHtml = replyTo.htmlBody // Layout-heavy original: embed verbatim as a QuotedHtml island so
? rewriteCidImagesForEditor(replyTo.htmlBody) // nested tables / MJML survive 1:1 (sanitize strips scripts/styles
: (replyTo.body // first; cid rewrite runs after so its data-cid markers survive).
const island = buildQuotedHtmlBlock(
rewriteCidImagesForEditor(sanitizeEmailHtml(replyTo.htmlBody))
);
return `${prefix}${signatureBlock}<br>${replyTo.quoteHeaderHtml}${island}`;
}
const escaped = replyTo.body
? replyTo.body.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/\n/g, '<br>') ? replyTo.body.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/\n/g, '<br>')
: ''); : '';
const wrap = replyTo.quoteWrapInBlockquote !== false;
const bodyHtml = wrap const bodyHtml = wrap
? `<blockquote style="margin:0 0 0 0.8ex;border-left:2px solid #ccc;padding-left:1ex">${originalHtml}</blockquote>` ? `<blockquote style="margin:0 0 0 0.8ex;border-left:2px solid #ccc;padding-left:1ex">${escaped}</blockquote>`
: originalHtml; : escaped;
return `${prefix}${signatureBlock}<br>${replyTo.quoteHeaderHtml}${bodyHtml}`; return `${prefix}${signatureBlock}<br>${replyTo.quoteHeaderHtml}${bodyHtml}`;
} }
// Build quoted content as HTML // Build quoted content as HTML
if (replyTo.htmlBody && (mode === 'reply' || mode === 'replyAll' || mode === 'forward')) { if (replyTo.htmlBody && (mode === 'reply' || mode === 'replyAll' || mode === 'forward')) {
const quoteHeader = mode === 'forward' const quoteHeader = mode === 'forward'
? `---------- Forwarded message ----------<br>From: ${fromStr}<br>Date: ${date}<br>Subject: ${replyTo.subject || ''}<br><br>` ? `${tQuote('forwarded_separator')}<br>${tQuote('from_label')}: ${fromStrFull}<br>${tQuote('date_label')}: ${date}<br>${tQuote('subject_label')}: ${replyTo.subject || ''}<br><br>`
: `On ${date}, ${fromStr} wrote:<br>`; : `${tQuote('reply_line', { date, from: fromStr })}<br>`;
// cid: image refs are rewritten so they render in the editor (browsers // Embed the original as a QuotedHtml island (verbatim, schema-free) so
// can't fetch cid: URLs); see useEffect below for the data-URL backfill. // its layout survives the editor round-trip. Sanitize first to strip
const quotedHtml = rewriteCidImagesForEditor(replyTo.htmlBody); // scripts/styles/head; cid rewrite afterwards so data-cid markers
return `${prefix}${signatureBlock}<br><div>${quoteHeader}</div><blockquote style="margin:0 0 0 0.8ex;border-left:2px solid #ccc;padding-left:1ex">${quotedHtml}</blockquote>`; // aren't dropped by the sanitizer's ALLOW_DATA_ATTR:false.
const island = buildQuotedHtmlBlock(
rewriteCidImagesForEditor(sanitizeEmailHtml(replyTo.htmlBody))
);
return `${prefix}${signatureBlock}<br><div>${quoteHeader}</div>${island}`;
} }
if (replyTo.body) { if (replyTo.body) {
const escapedOriginal = replyTo.body.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/\n/g, '<br>'); const escapedOriginal = replyTo.body.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/\n/g, '<br>');
if (mode === 'forward') { if (mode === 'forward') {
return `${prefix}${signatureBlock}<br><br>---------- Forwarded message ----------<br>From: ${fromStr}<br>Date: ${date}<br>Subject: ${replyTo.subject || ''}<br><br>${escapedOriginal}`; return `${prefix}${signatureBlock}<br><br>${tQuote('forwarded_separator')}<br>${tQuote('from_label')}: ${fromStrFull}<br>${tQuote('date_label')}: ${date}<br>${tQuote('subject_label')}: ${replyTo.subject || ''}<br><br>${escapedOriginal}`;
} else if (mode === 'reply' || mode === 'replyAll') { } else if (mode === 'reply' || mode === 'replyAll') {
return `${prefix}${signatureBlock}<br><br>On ${date}, ${fromStr} wrote:<br><blockquote style="margin:0 0 0 0.8ex;border-left:2px solid #ccc;padding-left:1ex">${escapedOriginal}</blockquote>`; return `${prefix}${signatureBlock}<br><br>${tQuote('reply_line', { date, from: fromStr })}<br><blockquote style="margin:0 0 0 0.8ex;border-left:2px solid #ccc;padding-left:1ex">${escapedOriginal}</blockquote>`;
} }
} }
return prefix; return prefix;
@@ -385,6 +413,7 @@ export function EmailComposer({
const [body, setBody] = useState(initialData?.body ?? getInitialBody()); const [body, setBody] = useState(initialData?.body ?? getInitialBody());
const [showCc, setShowCc] = useState(initialData?.showCc ?? !!getInitialCc()); const [showCc, setShowCc] = useState(initialData?.showCc ?? !!getInitialCc());
const [showBcc, setShowBcc] = useState(initialData?.showBcc ?? false); const [showBcc, setShowBcc] = useState(initialData?.showBcc ?? false);
const [requestReadReceipt, setRequestReadReceipt] = useState(requestReadReceiptDefault);
const [draftId, setDraftId] = useState<string | null>(initialData?.draftId ?? null); const [draftId, setDraftId] = useState<string | null>(initialData?.draftId ?? null);
// Mirror of draftId for synchronous reads inside chained saves; React's // Mirror of draftId for synchronous reads inside chained saves; React's
// setDraftId is async, so a queued saveDraft would otherwise see the old // setDraftId is async, so a queued saveDraft would otherwise see the old
@@ -425,6 +454,7 @@ export function EmailComposer({
const [showSaveAsTemplate, setShowSaveAsTemplate] = useState(false); const [showSaveAsTemplate, setShowSaveAsTemplate] = useState(false);
const [showCloseDialog, setShowCloseDialog] = useState(false); const [showCloseDialog, setShowCloseDialog] = useState(false);
const [showAllAttachments, setShowAllAttachments] = useState(false); const [showAllAttachments, setShowAllAttachments] = useState(false);
const [previewAttachment, setPreviewAttachment] = useState<ComposerAttachment | null>(null);
const [smimeSign_, setSmimeSign] = useState(false); const [smimeSign_, setSmimeSign] = useState(false);
const [smimeEncrypt_, setSmimeEncrypt] = useState(false); const [smimeEncrypt_, setSmimeEncrypt] = useState(false);
const [smimePassphrasePrompt, setSmimePassphrasePrompt] = useState<{ keyId: string; resolve: (passphrase: string) => void; reject: () => void } | null>(null); const [smimePassphrasePrompt, setSmimePassphrasePrompt] = useState<{ keyId: string; resolve: (passphrase: string) => void; reject: () => void } | null>(null);
@@ -503,7 +533,9 @@ export function EmailComposer({
if (!isReplyLike && mode !== 'compose') return; if (!isReplyLike && mode !== 'compose') return;
if (isReplyLike && signaturePosition !== 'above_quote') return; if (isReplyLike && signaturePosition !== 'above_quote') return;
const currentHtml = editor.getHTML(); // serializeEditorContent (not getHTML) so a QuotedHtml island's verbatim
// body isn't lost during the signature splice + setContent round-trip.
const currentHtml = serializeEditorContent(editor);
const doc = new DOMParser().parseFromString(currentHtml, 'text/html'); const doc = new DOMParser().parseFromString(currentHtml, 'text/html');
const startEl = doc.querySelector('[data-signature-block="separator"], [data-signature-block="start"]'); const startEl = doc.querySelector('[data-signature-block="separator"], [data-signature-block="start"]');
if (!startEl) return; if (!startEl) return;
@@ -525,15 +557,23 @@ export function EmailComposer({
if (!parent) return; if (!parent) return;
// Remove the existing signature range [startEl … endEl] inclusive, or // Remove the existing signature range [startEl … endEl] inclusive, or
// from startEl to the next blockquote if no end marker is present. // from startEl to the next quote boundary if no end marker is present.
// The quote boundary is either a legacy <blockquote> or the QuotedHtml
// island wrapper (<div data-quoted-html>) - stop before either so the
// signature splice never eats into the quoted body.
const removeUntil = endEl && endEl.parentNode === parent ? endEl : null; const removeUntil = endEl && endEl.parentNode === parent ? endEl : null;
const isQuoteBoundary = (n: Node | null): boolean => {
if (!n || n.nodeType !== 1) return false;
const el = n as Element;
return el.tagName === 'BLOCKQUOTE' || el.hasAttribute('data-quoted-html');
};
const toRemove: Node[] = []; const toRemove: Node[] = [];
let cursor: Node | null = startEl; let cursor: Node | null = startEl;
while (cursor) { while (cursor) {
toRemove.push(cursor); toRemove.push(cursor);
if (cursor === removeUntil) break; if (cursor === removeUntil) break;
const next: Node | null = cursor.nextSibling; const next: Node | null = cursor.nextSibling;
if (!removeUntil && next && (next as Element).tagName === 'BLOCKQUOTE') break; if (!removeUntil && isQuoteBoundary(next)) break;
cursor = next; cursor = next;
} }
const insertBefore = toRemove[toRemove.length - 1]?.nextSibling ?? null; const insertBefore = toRemove[toRemove.length - 1]?.nextSibling ?? null;
@@ -1086,6 +1126,43 @@ export function EmailComposer({
setAttachments(prev => prev.filter((_, i) => i !== index)); setAttachments(prev => prev.filter((_, i) => i !== index));
}; };
// Inline preview for composer attachments, reusing the message viewer's
// FilePreviewModal (so previewability and the open-in-new-tab safety gate are
// handled there). Prefer the local File - no network - and fall back to the
// uploaded blob (forwarded attachments, which carry only a blobId).
const getPreviewAttachmentContent = useCallback(async () => {
if (!previewAttachment) throw new Error('No attachment selected');
if (previewAttachment.file) {
return {
blob: previewAttachment.file,
contentType: previewAttachment.type || previewAttachment.file.type || 'application/octet-stream',
};
}
if (composerClient && previewAttachment.blobId) {
const blob = await composerClient.fetchBlob(previewAttachment.blobId, previewAttachment.name, previewAttachment.type);
return { blob, contentType: previewAttachment.type || blob.type || 'application/octet-stream' };
}
throw new Error('No attachment content available');
}, [previewAttachment, composerClient]);
const handlePreviewAttachmentDownload = useCallback(async () => {
if (!previewAttachment) return;
if (previewAttachment.file) {
const url = URL.createObjectURL(previewAttachment.file);
const a = document.createElement('a');
a.href = url;
a.download = previewAttachment.name;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
return;
}
if (composerClient && previewAttachment.blobId) {
await composerClient.downloadBlob(previewAttachment.blobId, previewAttachment.name, previewAttachment.type);
}
}, [previewAttachment, composerClient]);
// Auto-save draft functionality // Auto-save draft functionality
const saveDraftOnce = async (): Promise<string | null> => { const saveDraftOnce = async (): Promise<string | null> => {
if (!client || !composerClient) return null; if (!client || !composerClient) return null;
@@ -1657,6 +1734,7 @@ export function EmailComposer({
attachments: uploadedAttachments.length > 0 ? uploadedAttachments : undefined, attachments: uploadedAttachments.length > 0 ? uploadedAttachments : undefined,
inReplyTo: threadingHeaders?.inReplyTo, inReplyTo: threadingHeaders?.inReplyTo,
references: threadingHeaders?.references, references: threadingHeaders?.references,
requestReadReceipt,
delayedUntil: effectiveDelayedUntil, delayedUntil: effectiveDelayedUntil,
}); });
@@ -2169,7 +2247,21 @@ export function EmailComposer({
{attachments.length > 0 && ( {attachments.length > 0 && (
<div className="px-4 py-2 border-t shrink-0"> <div className="px-4 py-2 border-t shrink-0">
<div className="flex flex-wrap gap-2"> <div className="flex flex-wrap gap-2">
{(showAllAttachments ? attachments : attachments.slice(0, 3)).map((att, index) => ( {(showAllAttachments ? attachments : attachments.slice(0, 3)).map((att, index) => {
// Clickable to preview only once it has content (local File or an
// uploaded blob) and the type is previewable; never mid-upload.
const canPreview = !att.uploading && !att.error
&& (!!att.file || !!att.blobId)
&& isFilePreviewable(att.name, att.type);
const label = (
<>
<span className="max-w-[150px] md:max-w-[200px] truncate">{att.name}</span>
<span className="text-xs text-muted-foreground whitespace-nowrap">
({formatFileSize(att.size)})
</span>
</>
);
return (
<div <div
key={index} key={index}
className={cn( className={cn(
@@ -2191,10 +2283,18 @@ export function EmailComposer({
) : ( ) : (
<Paperclip className="w-3 h-3 flex-shrink-0" /> <Paperclip className="w-3 h-3 flex-shrink-0" />
)} )}
<span className="max-w-[150px] md:max-w-[200px] truncate">{att.name}</span> {canPreview ? (
<span className="text-xs text-muted-foreground whitespace-nowrap"> <button
({formatFileSize(att.size)}) type="button"
</span> onClick={() => setPreviewAttachment(att)}
title={att.name}
className="flex items-center gap-2 min-w-0 hover:underline"
>
{label}
</button>
) : (
<div className="flex items-center gap-2 min-w-0">{label}</div>
)}
<button <button
onClick={() => removeAttachment(index)} onClick={() => removeAttachment(index)}
className="ml-1 hover:text-red-500 min-w-[20px] min-h-[20px] flex items-center justify-center" className="ml-1 hover:text-red-500 min-w-[20px] min-h-[20px] flex items-center justify-center"
@@ -2204,7 +2304,8 @@ export function EmailComposer({
</button> </button>
</div> </div>
</div> </div>
))} );
})}
{attachments.length > 3 && ( {attachments.length > 3 && (
<button <button
onClick={() => setShowAllAttachments(prev => !prev)} onClick={() => setShowAllAttachments(prev => !prev)}
@@ -2218,7 +2319,7 @@ export function EmailComposer({
)} )}
{/* Bottom toolbar */} {/* Bottom toolbar */}
<div className="flex items-center justify-between px-4 py-2.5 border-t bg-background shrink-0 pb-[calc(env(safe-area-inset-bottom)/2)]"> <div className="flex items-center justify-between px-4 py-2.5 border-t bg-background shrink-0 pb-[calc(0.625rem+env(safe-area-inset-bottom)/2)]">
{/* Left side actions */} {/* Left side actions */}
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
<input <input
@@ -2281,6 +2382,21 @@ export function EmailComposer({
</Button> </Button>
</> </>
)} )}
{/* Read-receipt request toggle */}
<Button
variant="ghost"
size="icon"
onClick={() => setRequestReadReceipt(v => !v)}
className={cn(
"h-9 w-9",
requestReadReceipt && "bg-green-600 text-white hover:bg-green-600 hover:text-white dark:bg-green-600 dark:hover:bg-green-600"
)}
title={requestReadReceipt ? t('read_receipt_on') : t('read_receipt_off')}
aria-pressed={requestReadReceipt}
>
<MailCheck className="w-4 h-4" />
</Button>
<PluginSlot name="composer-toolbar" /> <PluginSlot name="composer-toolbar" />
</div> </div>
@@ -2519,6 +2635,15 @@ export function EmailComposer({
</div> </div>
</div> </div>
)} )}
{previewAttachment && (
<FilePreviewModal
name={previewAttachment.name}
onClose={() => setPreviewAttachment(null)}
onDownload={handlePreviewAttachmentDownload}
getFileContent={getPreviewAttachmentContent}
/>
)}
</div> </div>
<PluginSlot <PluginSlot
name="composer-sidebar-right" name="composer-sidebar-right"
+10 -2
View File
@@ -5,6 +5,7 @@ import { Mail, Tag } from 'lucide-react';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import type { Email, Identity } from '@/lib/jmap/types'; import type { Email, Identity } from '@/lib/jmap/types';
import { parseSubAddress } from '@/lib/sub-addressing'; import { parseSubAddress } from '@/lib/sub-addressing';
import { isAuthenticationSpoofed } from '@/lib/email-headers';
import { useSettingsStore } from '@/stores/settings-store'; import { useSettingsStore } from '@/stores/settings-store';
interface EmailIdentityBadgeProps { interface EmailIdentityBadgeProps {
@@ -29,8 +30,15 @@ export function EmailIdentityBadge({
// Parse the from address to check for sub-addressing // Parse the from address to check for sub-addressing
const parsedFrom = parseSubAddress(fromAddress, subAddressDelimiter); const parsedFrom = parseSubAddress(fromAddress, subAddressDelimiter);
// Find matching identity (email sent BY the user) // Find matching identity (email sent BY the user). When the message is
const matchingIdentity = identities.find( // likely spoofed, the From address can't be trusted, so we ignore any
// identity match — otherwise a forged From matching one of the user's own
// addresses would render a misleading "via <identity>" badge that implies
// legitimacy.
const spoofed = isAuthenticationSpoofed(email.authenticationResults);
const matchingIdentity = spoofed
? undefined
: identities.find(
(identity) => identity.email === fromAddress || identity.email === `${parsedFrom.baseUser}@${parsedFrom.domain}` (identity) => identity.email === fromAddress || identity.email === `${parsedFrom.baseUser}@${parsedFrom.domain}`
); );
+144 -8
View File
@@ -83,6 +83,8 @@ import { useThemeStore } from "@/stores/theme-store";
import { EmailIdentityBadge } from "./email-identity-badge"; import { EmailIdentityBadge } from "./email-identity-badge";
import { UnsubscribeBanner } from "./unsubscribe-banner"; import { UnsubscribeBanner } from "./unsubscribe-banner";
import { CalendarInvitationBanner } from "./calendar-invitation-banner"; import { CalendarInvitationBanner } from "./calendar-invitation-banner";
import { ReadReceiptBanner } from "./read-receipt-banner";
import { stripCrossAccountIdentityPrefix } from "@/hooks/use-pro-multi-account-identities";
import { useTour } from "@/components/tour/tour-provider"; import { useTour } from "@/components/tour/tour-provider";
import { useIsEmbedded } from "@/hooks/use-is-embedded"; import { useIsEmbedded } from "@/hooks/use-is-embedded";
import { SmimePassphraseDialog } from "@/components/settings/smime-passphrase-dialog"; import { SmimePassphraseDialog } from "@/components/settings/smime-passphrase-dialog";
@@ -911,6 +913,7 @@ export function EmailViewer({
const showToolbarLabels = useSettingsStore((state) => state.showToolbarLabels); const showToolbarLabels = useSettingsStore((state) => state.showToolbarLabels);
const mailLayout = useSettingsStore((state) => state.mailLayout); const mailLayout = useSettingsStore((state) => state.mailLayout);
const calendarInvitationParsingEnabled = useSettingsStore((state) => state.calendarInvitationParsingEnabled); const calendarInvitationParsingEnabled = useSettingsStore((state) => state.calendarInvitationParsingEnabled);
const readReceiptResponse = useSettingsStore((state) => state.readReceiptResponse);
const hideInlineImageAttachments = useSettingsStore((state) => state.hideInlineImageAttachments); const hideInlineImageAttachments = useSettingsStore((state) => state.hideInlineImageAttachments);
const attachmentImagePreviewsEnabled = useSettingsStore((state) => state.attachmentImagePreviewsEnabled); const attachmentImagePreviewsEnabled = useSettingsStore((state) => state.attachmentImagePreviewsEnabled);
const dragOutActive = useMemo(() => isDragOutSupported(), []); const dragOutActive = useMemo(() => isDragOutSupported(), []);
@@ -2193,6 +2196,9 @@ export function EmailViewer({
// Hide inline cid-referenced images when the user has opted to keep them // Hide inline cid-referenced images when the user has opted to keep them
// out of the attachment list (default on): these are embedded in the body. // out of the attachment list (default on): these are embedded in the body.
.filter(att => !(hideInlineImageAttachments && att.cid && att.disposition === 'inline' && (att.type || '').startsWith('image/'))) .filter(att => !(hideInlineImageAttachments && att.cid && att.disposition === 'inline' && (att.type || '').startsWith('image/')))
// Hide machine-readable report parts (MDN read-receipts, DSN bounce
// reports). These are required MIME parts, not real user attachments.
.filter(att => att.type !== 'message/disposition-notification' && att.type !== 'message/delivery-status')
.map((attachment, index) => ({ .map((attachment, index) => ({
id: attachment.blobId || `${attachment.name || 'attachment'}-${index}`, id: attachment.blobId || `${attachment.name || 'attachment'}-${index}`,
name: attachment.name || null, name: attachment.name || null,
@@ -2893,7 +2899,10 @@ export function EmailViewer({
<html style="color-scheme: ${colorScheme};"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"> <html style="color-scheme: ${colorScheme};"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
<meta http-equiv="Content-Security-Policy" content="${iframeCsp}"> <meta http-equiv="Content-Security-Policy" content="${iframeCsp}">
<style> <style>
html, body { overflow: hidden; } /* Force content height: some emails set html/body { height: 100% }, which -
combined with overflow:hidden and our scrollHeight-based auto-resize -
collapses the measured height and clips everything below the fold. */
html, body { overflow: hidden; height: auto !important; }
body { margin: 0; padding: ${bodyPadding}; 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; } body { margin: 0; padding: ${bodyPadding}; 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}; } } @media (max-width: 640px) { body { padding-left: ${mobileBodyPaddingX}; padding-right: ${mobileBodyPaddingX}; } }
img { max-width: 100% !important; height: auto !important; } img { max-width: 100% !important; height: auto !important; }
@@ -3252,6 +3261,103 @@ export function EmailViewer({
? calendarInvitationParsingEnabled && !!findCalendarAttachment(email) ? calendarInvitationParsingEnabled && !!findCalendarAttachment(email)
: false; : false;
// ── Read receipt (MDN, RFC 8098) ──────────────────────────────
// Detect a Disposition-Notification-To request on the open message. The
// header is parsed into email.headers by the client; look it up
// case-insensitively and extract the bare address.
const readReceiptRequestedBy = useMemo(() => {
const headers = email?.headers as Record<string, string | string[]> | undefined;
if (!headers) return null;
let raw: string | undefined;
for (const key of Object.keys(headers)) {
if (key.toLowerCase() === 'disposition-notification-to') {
const v = headers[key];
raw = Array.isArray(v) ? v[0] : v;
break;
}
}
if (!raw) return null;
const m = raw.match(/<([^>]+)>/);
const addr = (m ? m[1] : raw).trim();
return addr || null;
}, [email?.headers]);
// The identity whose address received the original message — the MDN is sent
// "from" that address. Falls back to the primary identity.
const receiptIdentity = useMemo(() => {
if (!identities?.length) return null;
const recipients = [...(email?.to || []), ...(email?.cc || [])]
.map(r => r.email?.toLowerCase())
.filter(Boolean);
return identities.find(i => recipients.includes(i.email?.toLowerCase())) || identities[0];
}, [identities, email?.to, email?.cc]);
const mdnAlreadyHandled = email?.keywords?.['$mdnsent'] === true;
const [mdnHandledLocally, setMdnHandledLocally] = useState(false);
useEffect(() => { setMdnHandledLocally(false); }, [email?.id]);
// Only offer the receipt for mail you're actually reading in a "received"
// location. Suppress your own copies (sent/drafts), discarded mail (trash),
// and spam (junk) - never confirm your address to spammers. Inbox, Archive
// and user folders all qualify.
const inReceiptEligibleFolder = !['sent', 'drafts', 'trash', 'junk'].includes(currentMailboxRole || '');
const shouldOfferReadReceipt =
!!readReceiptRequestedBy &&
!mdnAlreadyHandled &&
!mdnHandledLocally &&
readReceiptResponse !== 'never' &&
inReceiptEligibleFolder &&
!isDraft &&
!!receiptIdentity;
const sendReadReceiptNow = useCallback(async (automatic: boolean) => {
if (!client || !email || !readReceiptRequestedBy || !receiptIdentity) return;
const { rawId } = stripCrossAccountIdentityPrefix(receiptIdentity.id);
try {
await client.sendReadReceipt({
to: readReceiptRequestedBy,
fromEmail: receiptIdentity.email,
fromName: receiptIdentity.name,
identityId: rawId ?? receiptIdentity.id,
originalMessageId: email.messageId,
originalSubject: email.subject,
originalRecipient: receiptIdentity.email,
automatic,
subject: t('read_receipt.mdn_subject', { subject: email.subject || '' }),
humanText: t('read_receipt.mdn_body', { recipient: receiptIdentity.email }),
});
await client.setKeyword(email.id, '$mdnsent');
} catch (err) {
// Surface the failure instead of silently resetting the banner so we can
// see which step (upload / import / submission) failed.
console.error('Read-receipt (MDN) send failed:', err);
toast.error(t('read_receipt.send_failed'), {
message: err instanceof Error ? err.message : String(err),
});
throw err;
}
}, [client, email, readReceiptRequestedBy, receiptIdentity, t]);
const ignoreReadReceipt = useCallback(async () => {
setMdnHandledLocally(true);
if (client && email) {
// $MDNSent is the RFC 3503 flag every IMAP/JMAP client honours, so the
// request is suppressed everywhere - not just locally.
try { await client.setKeyword(email.id, '$mdnsent'); } catch { /* best effort */ }
}
}, [client, email]);
// "always" mode: auto-send the MDN once when the message is opened.
const autoMdnRef = useRef<string | null>(null);
useEffect(() => {
if (readReceiptResponse !== 'always') return;
if (!shouldOfferReadReceipt || !email) return;
if (autoMdnRef.current === email.id) return;
autoMdnRef.current = email.id;
sendReadReceiptNow(true).catch(() => { autoMdnRef.current = null; });
}, [readReceiptResponse, shouldOfferReadReceipt, email?.id, sendReadReceiptNow]);
// Show loading skeleton while email is being fetched // Show loading skeleton while email is being fetched
if (isLoading && !email) { if (isLoading && !email) {
return ( return (
@@ -4120,8 +4226,8 @@ export function EmailViewer({
"bg-background border-b border-border", "bg-background border-b border-border",
"max-lg:sticky max-lg:top-0 max-lg:z-10" "max-lg:sticky max-lg:top-0 max-lg:z-10"
)}> )}>
<div className="px-2 sm:px-4 lg:px-6 py-1 sm:py-2"> <div className="px-2 sm:px-4 lg:px-6 h-14 flex items-center">
<div ref={toolbarRef} className="flex items-center justify-between gap-0.5 sm:gap-2"> <div ref={toolbarRef} className="flex items-center justify-between gap-0.5 sm:gap-2 w-full">
{renderToolbarItems(true)} {renderToolbarItems(true)}
</div> </div>
</div> </div>
@@ -4711,9 +4817,27 @@ export function EmailViewer({
<section className="min-w-0"> <section className="min-w-0">
<SectionHeader>{t('details.authentication_security')}</SectionHeader> <SectionHeader>{t('details.authentication_security')}</SectionHeader>
<div className="flex flex-wrap gap-1.5"> <div className="flex flex-wrap gap-1.5">
{auth?.spf && ( {auth?.spf && (() => {
<AuthChip name="SPF" result={auth.spf.result} extra={auth.spf.domain} tooltip={t('authentication.tooltip_spf')} /> // When multiple identities (HELO + MAIL FROM) were
)} // evaluated, list each result in the tooltip for full
// transparency; the chip itself shows the most severe.
const breakdown = auth.spf.all && auth.spf.all.length > 1
? auth.spf.all
.map((r) => {
const label = r.identity === 'mailfrom' ? 'MAIL FROM' : r.identity === 'helo' ? 'HELO' : 'SPF';
return `${label}: ${translateAuthResult(r.result)}${r.domain ? ` (${r.domain})` : ''}`;
})
.join('\n')
: null;
return (
<AuthChip
name="SPF"
result={auth.spf.result}
extra={auth.spf.domain}
tooltip={breakdown ? `${t('authentication.tooltip_spf')}\n\n${breakdown}` : t('authentication.tooltip_spf')}
/>
);
})()}
{auth?.dkim && ( {auth?.dkim && (
<AuthChip name="DKIM" result={auth.dkim.result} extra={auth.dkim.domain} tooltip={t('authentication.tooltip_dkim')} /> <AuthChip name="DKIM" result={auth.dkim.result} extra={auth.dkim.domain} tooltip={t('authentication.tooltip_dkim')} />
)} )}
@@ -4946,9 +5070,10 @@ export function EmailViewer({
error={smimeUnlockError} error={smimeUnlockError}
/> />
{/* Unified Notification Banner - External Content + Calendar Invitation */} {/* Unified Notification Banner - External Content + Calendar Invitation + Read Receipt */}
{((hasBlockedContent && !allowExternalContent && externalContentPolicy !== 'allow') || {((hasBlockedContent && !allowExternalContent && externalContentPolicy !== 'allow') ||
hasCalendarInvitation) && ( hasCalendarInvitation ||
(readReceiptResponse === 'ask' && shouldOfferReadReceipt)) && (
<div className="border-b border-border bg-muted/30 isolate"> <div className="border-b border-border bg-muted/30 isolate">
<div className="px-6 py-1.5"> <div className="px-6 py-1.5">
<div className="flex flex-col gap-3 isolate"> <div className="flex flex-col gap-3 isolate">
@@ -5003,6 +5128,17 @@ export function EmailViewer({
{/* Read-receipt (MDN) request banner — only in "ask" mode */}
{readReceiptResponse === 'ask' && shouldOfferReadReceipt && readReceiptRequestedBy && (
<div className="py-1">
<ReadReceiptBanner
requestedBy={readReceiptRequestedBy}
onSend={() => sendReadReceiptNow(false)}
onIgnore={ignoreReadReceipt}
/>
</div>
)}
{/* Calendar Invitation Banner */} {/* Calendar Invitation Banner */}
{hasCalendarInvitation && ( {hasCalendarInvitation && (
<div className="py-1"> <div className="py-1">
+187
View File
@@ -0,0 +1,187 @@
"use client";
import { Node as TiptapNode, mergeAttributes } from "@tiptap/core";
import { DOMSerializer } from "@tiptap/pm/model";
import type { Editor } from "@tiptap/react";
// Marker attribute that identifies the quoted-original wrapper in serialized
// HTML, so parseHTML can recognise it on the way back in.
export const QUOTED_HTML_MARKER = "data-quoted-html";
/**
* QuotedHtml an atomic block node that carries the *verbatim* HTML of a
* quoted/forwarded original email. The HTML is stored in the `html` attribute
* and is NEVER parsed into the ProseMirror schema, so layout-heavy emails
* (nested tables, MJML, Outlook divs) survive a reply/forward 1:1.
*
* Behaviour:
* - To ProseMirror it's a single atomic block: Backspace at the boundary or
* Ctrl+A + Delete removes the whole quote in one go ("wie es sich gehört").
* - Its NodeView renders an inner `contentEditable` region so the user can
* still redact text inside the quote. Inner edits are synced back into the
* `html` attribute (without polluting the undo history).
*
* Serialization: ProseMirror's DOM serializer can't emit an atom's inner raw
* HTML, so use `serializeEditorContent(editor)` (below) instead of
* `editor.getHTML()` to read the composer body for sending/draft-saving.
*/
export const QuotedHtml = TiptapNode.create({
name: "quotedHtml",
group: "block",
atom: true,
selectable: true,
draggable: false,
// Isolating keeps selection/gapcursor behaviour sane at the boundary.
isolating: true,
addAttributes() {
return {
html: {
default: "",
// Capture the verbatim inner HTML when parsing. Because the node is
// an atom, ProseMirror does NOT descend into the children, so they
// never hit the schema.
parseHTML: (el) => el.innerHTML,
// Not rendered as an attribute - the real content round-trips via the
// custom serializer. renderHTML below only needs the wrapper.
renderHTML: () => ({}),
},
};
},
parseHTML() {
return [{ tag: `div[${QUOTED_HTML_MARKER}]` }];
},
renderHTML({ HTMLAttributes }) {
// Only used for ProseMirror's internal/clipboard round-trip. The send /
// draft path uses serializeEditorContent() which inlines attrs.html.
return ["div", mergeAttributes(HTMLAttributes, { [QUOTED_HTML_MARKER]: "" })];
},
addNodeView() {
return ({ node, editor, getPos }) => {
// Host element ProseMirror manages. A subtle left border echoes the
// classic email quote bar without touching the quoted content's styling.
const dom = document.createElement("div");
dom.setAttribute(QUOTED_HTML_MARKER, "");
dom.className = "quoted-html-island";
dom.style.cssText =
"border-left:2px solid #c5c5c5;padding-left:12px;margin-top:8px;";
// CRITICAL: render the quoted email inside a Shadow Root. The app's
// global CSS (Tailwind preflight, .tiptap table/td rules, box-sizing
// resets) would otherwise cascade INTO the quote and destroy its layout
// - even though the verbatim HTML serializes/sends perfectly. Shadow DOM
// isolates both directions: only the browser's UA defaults + the email's
// own inline styles apply, so the in-editor rendering matches the sent
// mail 1:1.
const shadow = dom.attachShadow({ mode: "open" });
const inner = document.createElement("div");
inner.contentEditable = "true";
inner.style.cssText = "outline:none;";
inner.innerHTML = node.attrs.html || "";
shadow.appendChild(inner);
// Track focus via focusin/focusout: inside a shadow root,
// document.activeElement is retargeted to the host, so we can't rely on
// it to detect "is the user editing in here".
let focused = false;
inner.addEventListener("focusin", () => {
focused = true;
});
inner.addEventListener("focusout", () => {
focused = false;
});
// Sync inner edits back into the node attribute. Coalesced via rAF so a
// burst of keystrokes is one transaction; addToHistory:false keeps
// redaction edits out of the editor's undo stack. The `input` event is
// composed and crosses the shadow boundary, so this listener fires.
let frame = 0;
const syncBack = () => {
cancelAnimationFrame(frame);
frame = requestAnimationFrame(() => {
if (typeof getPos !== "function") return;
const pos = getPos();
if (pos == null) return;
const current = inner.innerHTML;
if (current === node.attrs.html) return;
editor.view.dispatch(
editor.view.state.tr
.setNodeAttribute(pos, "html", current)
.setMeta("addToHistory", false)
);
});
};
inner.addEventListener("input", syncBack);
return {
dom,
// ProseMirror must not try to reconcile the foreign shadow content.
ignoreMutation: () => true,
// Events originating inside the island are retargeted to the host
// (`dom`) once they cross the shadow boundary, so dom.contains(target)
// is true for them → let the native shadow contentEditable handle
// them. Events from the surrounding doc (boundary Backspace,
// Ctrl+A+Delete) target other elements → fall through to ProseMirror
// so whole-block deletion still works.
stopEvent: (event) => {
const target = event.target as Node | null;
return !!target && dom.contains(target);
},
update: (updatedNode) => {
if (updatedNode.type.name !== "quotedHtml") return false;
// Don't clobber the caret while the user is redacting inside.
if (!focused && inner.innerHTML !== updatedNode.attrs.html) {
inner.innerHTML = updatedNode.attrs.html || "";
}
return true;
},
destroy: () => {
cancelAnimationFrame(frame);
inner.removeEventListener("input", syncBack);
},
};
};
},
});
/**
* Serialize the composer document to HTML for sending / draft-saving.
*
* Use this INSTEAD of `editor.getHTML()`: ProseMirror's DOM serializer cannot
* emit the raw inner HTML of an atom node, so it would drop the quoted body.
* Here we walk the top-level nodes, inline the quote node's verbatim `html`,
* and serialize everything else normally.
*/
export function serializeEditorContent(editor: Editor): string {
const serializer = DOMSerializer.fromSchema(editor.schema);
const parts: string[] = [];
editor.state.doc.forEach((node) => {
if (node.type.name === "quotedHtml") {
// Emit the SAME wrapper buildQuotedHtmlBlock produces, so a saved draft
// round-trips: re-opening parses this back into a QuotedHtml node
// instead of letting the schema mangle the raw table layout again.
parts.push(buildQuotedHtmlBlock((node.attrs.html as string) || ""));
return;
}
const fragment = serializer.serializeNode(node);
const tmp = document.createElement("div");
tmp.appendChild(fragment);
parts.push(tmp.innerHTML);
});
return parts.join("");
}
/**
* Build the editor-content wrapper that the composer prepends/appends so the
* quoted original becomes a single QuotedHtml node. The inner HTML must be
* pre-sanitized (scripts/styles/head stripped, cid: images rewritten).
*
* The `data-quoted-html` marker is what parseHTML keys on, so this exact form
* must be what serializeEditorContent emits too (round-trip consistency).
*/
export function buildQuotedHtmlBlock(sanitizedInnerHtml: string): string {
return `<div ${QUOTED_HTML_MARKER}>${sanitizedInnerHtml}</div>`;
}
+60
View File
@@ -0,0 +1,60 @@
'use client';
import { useState } from 'react';
import { MailCheck, Loader2, CheckCircle } from 'lucide-react';
import { useTranslations } from 'next-intl';
interface ReadReceiptBannerProps {
/** Address that requested the receipt (Disposition-Notification-To). */
requestedBy: string;
/** Sends the MDN. Should resolve when the receipt has been submitted. */
onSend: () => Promise<void>;
/** Suppresses the request without sending (sets $MDNSent server-side). */
onIgnore: () => void;
}
export function ReadReceiptBanner({ requestedBy, onSend, onIgnore }: ReadReceiptBannerProps) {
const t = useTranslations('email_viewer.read_receipt');
const [state, setState] = useState<'idle' | 'sending' | 'sent'>('idle');
if (state === 'sent') {
return (
<div className="flex items-center gap-2 rounded-md border border-border bg-muted/40 px-3 py-2 text-sm text-muted-foreground">
<CheckCircle className="w-4 h-4 text-green-600 dark:text-green-400 shrink-0" />
<span>{t('sent')}</span>
</div>
);
}
return (
<div className="flex flex-wrap items-center gap-2 rounded-md border border-amber-300/60 bg-amber-50 px-3 py-2 text-sm dark:border-amber-700/50 dark:bg-amber-950/30">
<MailCheck className="w-4 h-4 shrink-0 text-amber-600 dark:text-amber-400" />
<span className="text-foreground">{t('prompt')}</span>
<span className="break-all text-muted-foreground">{requestedBy}</span>
<div className="ml-auto flex items-center gap-2">
<button
onClick={async () => {
setState('sending');
try {
await onSend();
setState('sent');
} catch {
setState('idle');
}
}}
disabled={state === 'sending'}
className="inline-flex items-center gap-1.5 rounded-md bg-green-600 px-3 py-1.5 text-xs font-medium text-white transition-colors hover:bg-green-700 disabled:cursor-not-allowed disabled:opacity-50"
>
{state === 'sending' && <Loader2 className="w-3 h-3 animate-spin" />}
{t('send')}
</button>
<button
onClick={onIgnore}
className="rounded-md bg-red-600 px-3 py-1.5 text-xs font-medium text-white transition-colors hover:bg-red-700"
>
{t('ignore')}
</button>
</div>
</div>
);
}
+11 -3
View File
@@ -16,6 +16,7 @@ import { Table } from "@tiptap/extension-table";
import { TableRow } from "@tiptap/extension-table-row"; import { TableRow } from "@tiptap/extension-table-row";
import { TableHeader } from "@tiptap/extension-table-header"; import { TableHeader } from "@tiptap/extension-table-header";
import { TableCell } from "@tiptap/extension-table-cell"; import { TableCell } from "@tiptap/extension-table-cell";
import { QuotedHtml, serializeEditorContent } from "@/components/email/quoted-html";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { import {
Bold, Bold,
@@ -231,6 +232,9 @@ export function RichTextEditor({
style: "padding:6px 8px;border:1px solid #ccc;vertical-align:top;", style: "padding:6px 8px;border:1px solid #ccc;vertical-align:top;",
}, },
}), }),
// Quoted/forwarded original email body - held verbatim as an atomic
// node so layout-heavy HTML survives 1:1 (see quoted-html.ts).
QuotedHtml,
], ],
content, content,
editorProps: { editorProps: {
@@ -281,14 +285,18 @@ export function RichTextEditor({
}, },
}, },
onUpdate: ({ editor }) => { onUpdate: ({ editor }) => {
onChange(editor.getHTML()); // serializeEditorContent (not getHTML) so the verbatim quoted-original
// HTML held in the QuotedHtml atom node is emitted intact.
onChange(serializeEditorContent(editor));
}, },
immediatelyRender: false, immediatelyRender: false,
}); });
// Sync external content changes (e.g. template application) // Sync external content changes (e.g. template application). Compare against
// the custom serialization so a doc that only differs inside a QuotedHtml
// island isn't needlessly re-parsed (which would reset the island DOM).
useEffect(() => { useEffect(() => {
if (editor && content !== editor.getHTML()) { if (editor && content !== serializeEditorContent(editor)) {
editor.commands.setContent(content, { emitUpdate: false }); editor.commands.setContent(content, { emitUpdate: false });
} }
}, [content, editor]); }, [content, editor]);
+110
View File
@@ -0,0 +1,110 @@
"use client";
import { useTranslations } from "next-intl";
import { Paperclip, Download } from "lucide-react";
import { sanitizeEmailHtmlForIframe } from "@/lib/email-sanitization";
// A parsed message/rfc822 (.eml), as produced by postal-mime. Only the fields
// this preview renders are typed.
export type ParsedEml = {
subject?: string;
from?: { name?: string; address?: string };
to?: Array<{ name?: string; address?: string }>;
date?: string;
html?: string;
text?: string;
attachments?: Array<{ filename?: string; mimeType?: string; content?: ArrayBuffer | Uint8Array }>;
};
function formatAddress(a?: { name?: string; address?: string }): string {
if (!a) return "";
if (a.name && a.address) return `${a.name} <${a.address}>`;
return a.address || a.name || "";
}
function escapeHtml(s: string): string {
return s
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#39;");
}
// Renders a .eml attachment like an email: header (from/to/subject/date) + the
// body, plus the message's own attachments. The body is sanitized with
// DOMPurify AND rendered in a fully-locked sandbox iframe (sandbox="" - no
// scripts, no same-origin), so a script-bearing .eml can never execute in our
// origin. Parsing happens in the caller (FilePreviewModal); this is pure
// presentation.
export function EmlPreview({ message }: { message: ParsedEml }) {
const t = useTranslations("email_viewer");
const bodyDoc = message.html
? sanitizeEmailHtmlForIframe(message.html)
: message.text
? `<pre style="white-space:pre-wrap;word-break:break-word;font-family:ui-monospace,monospace;margin:0;padding:8px">${escapeHtml(message.text)}</pre>`
: "";
const downloadAttachment = (att: NonNullable<ParsedEml["attachments"]>[number]) => {
if (!att.content) return;
// content is a real ArrayBuffer/Uint8Array at runtime; cast for the strict
// BlobPart lib type (Uint8Array<ArrayBufferLike> vs ArrayBuffer).
const url = URL.createObjectURL(new Blob([att.content as BlobPart], { type: att.mimeType || "application/octet-stream" }));
const a = document.createElement("a");
a.href = url;
a.download = att.filename || "attachment";
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
};
return (
<div
className="w-full max-w-3xl max-h-full self-start overflow-auto rounded-lg border border-border bg-background shadow-2xl p-4"
onClick={(e) => e.stopPropagation()}
>
<h2 className="text-lg font-semibold text-foreground break-words">{message.subject || ""}</h2>
<div className="mt-2 space-y-0.5 text-sm text-muted-foreground border-b border-border pb-3">
{message.from && (
<div><span className="font-medium text-foreground">{t("from")}: </span>{formatAddress(message.from)}</div>
)}
{message.to && message.to.length > 0 && (
<div><span className="font-medium text-foreground">{t("to")}: </span>{message.to.map(formatAddress).join(", ")}</div>
)}
{message.date && (
<div><span className="font-medium text-foreground">{t("date")}: </span>{new Date(message.date).toLocaleString()}</div>
)}
</div>
{bodyDoc && (
<iframe
title={message.subject || "email"}
sandbox=""
srcDoc={bodyDoc}
className="w-full min-h-[55vh] mt-3 rounded bg-white"
/>
)}
{message.attachments && message.attachments.length > 0 && (
<div className="mt-4 border-t border-border pt-3">
<div className="text-xs font-medium text-muted-foreground mb-2">{t("attachments")}</div>
<div className="flex flex-wrap gap-2">
{message.attachments.map((att, i) => (
<button
key={i}
type="button"
onClick={() => downloadAttachment(att)}
title={t("download")}
className="flex items-center gap-2 px-3 py-1.5 rounded-md text-sm bg-muted text-foreground hover:bg-muted/70"
>
<Paperclip className="w-3 h-3 flex-shrink-0" />
<span className="max-w-[200px] truncate">{att.filename || "attachment"}</span>
<Download className="w-3 h-3 flex-shrink-0 text-muted-foreground" />
</button>
))}
</div>
</div>
)}
</div>
);
}
+156 -15
View File
@@ -1,10 +1,48 @@
"use client"; "use client";
import { useState, useEffect } from "react"; import { useState, useEffect, useRef } from "react";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import { X, Download, Loader2 } from "lucide-react"; import { X, Download, Loader2, ExternalLink } from "lucide-react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { getFilePreviewKind } from "@/lib/file-preview"; import { getFilePreviewKind, isMimeTypeSafeForInlinePreview } from "@/lib/file-preview";
import dynamic from "next/dynamic";
import { EmlPreview, type ParsedEml } from "@/components/files/eml-preview";
// pdf.js-based inline viewer for mobile (no native inline PDF viewer). Loaded
// only on the mobile PDF path so pdfjs-dist + its worker never reach the
// desktop bundle.
const PdfMobileViewer = dynamic(
() => import("@/components/files/pdf-mobile-viewer").then((m) => m.PdfMobileViewer),
{ ssr: false },
);
// Map a few well-known extensions back to canonical MIME types. Used when the
// server returns application/octet-stream (or empty) for an attachment whose
// actual type is obvious from the filename. The blob.type drives how browsers
// render blob: URLs, so guessing wrong here means the inline preview silently
// downgrades to a download.
const EXT_TO_MIME: Record<string, string> = {
pdf: "application/pdf",
png: "image/png",
jpg: "image/jpeg",
jpeg: "image/jpeg",
gif: "image/gif",
webp: "image/webp",
avif: "image/avif",
bmp: "image/bmp",
mp3: "audio/mpeg",
wav: "audio/wav",
ogg: "audio/ogg",
m4a: "audio/mp4",
mp4: "video/mp4",
webm: "video/webm",
ogv: "video/ogg",
};
function inferMimeFromName(name: string): string | undefined {
const ext = name.toLowerCase().split(".").pop();
return ext ? EXT_TO_MIME[ext] : undefined;
}
interface FilePreviewModalProps { interface FilePreviewModalProps {
name: string; name: string;
@@ -111,6 +149,54 @@ export function FilePreviewModal({ name, onClose, onDownload, getFileContent }:
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [error, setError] = useState(false); const [error, setError] = useState(false);
const [resolvedFileType, setResolvedFileType] = useState(() => getFilePreviewKind(name)); const [resolvedFileType, setResolvedFileType] = useState(() => getFilePreviewKind(name));
const [pdfInlineSupported, setPdfInlineSupported] = useState(true);
// Whether the resolved blob MIME is inert enough to open as a top-level
// navigation. Blob URLs inherit our origin, so opening a script-bearing type
// (text/html, image/svg+xml, ...) in a new tab would execute it in-origin.
const [canOpenInNewTab, setCanOpenInNewTab] = useState(false);
const [emlContent, setEmlContent] = useState<ParsedEml | null>(null);
// Decide whether to render the PDF in a plain <iframe> (desktop) or with the
// pdf.js canvas viewer (mobile). navigator.pdfViewerEnabled is the standard
// signal and correctly reports false on Android Chrome (no inline viewer).
// iOS Safari is the exception: it reports true (it can show PDFs on top-frame
// navigation) yet renders only the FIRST page inside an <iframe> - a
// long-standing WebKit limitation - so it must use pdf.js too. Detect iOS
// (incl. iPadOS, which spoofs a "Macintosh" UA but exposes touch points).
useEffect(() => {
const nav = navigator as Navigator & { pdfViewerEnabled?: boolean };
const isIOS =
/iPad|iPhone|iPod/.test(nav.userAgent) ||
(nav.maxTouchPoints > 1 && /Macintosh/.test(nav.userAgent));
if (isIOS) {
setPdfInlineSupported(false);
} else if (typeof nav.pdfViewerEnabled === "boolean") {
setPdfInlineSupported(nav.pdfViewerEnabled);
}
}, []);
// Keep the latest onClose for the back-button handler without re-subscribing.
const onCloseRef = useRef(onClose);
onCloseRef.current = onClose;
// Make the Android/browser Back button close the preview instead of
// navigating the page underneath: push a throwaway history entry when the
// modal opens and close it on popstate. On a normal close (X / backdrop /
// Escape) the modal unmounts and we pop that entry ourselves, so the user's
// next Back isn't swallowed by it.
useEffect(() => {
window.history.pushState({ __filePreview: true }, "");
let poppedByBack = false;
const onPop = () => {
poppedByBack = true;
onCloseRef.current();
};
window.addEventListener("popstate", onPop);
return () => {
window.removeEventListener("popstate", onPop);
if (!poppedByBack) window.history.back();
};
}, []);
const fileType = resolvedFileType; const fileType = resolvedFileType;
@@ -120,6 +206,8 @@ export function FilePreviewModal({ name, onClose, onDownload, getFileContent }:
setContent(null); setContent(null);
setObjectUrl(null); setObjectUrl(null);
setCanOpenInNewTab(false);
setEmlContent(null);
setLoading(true); setLoading(true);
setError(false); setError(false);
setResolvedFileType(getFilePreviewKind(name)); setResolvedFileType(getFilePreviewKind(name));
@@ -136,9 +224,36 @@ export function FilePreviewModal({ name, onClose, onDownload, getFileContent }:
if (previewType === "text" || previewType === "markdown") { if (previewType === "text" || previewType === "markdown") {
const text = await blob.text(); const text = await blob.text();
if (!cancelled) setContent(text); if (!cancelled) setContent(text);
} else if (previewType === "eml") {
// Parse the embedded message (postal-mime, dynamic-imported so it
// stays off the bundle) and render it like an email via EmlPreview.
const { default: PostalMime } = await import("postal-mime");
const parsed = await new PostalMime().parse(await blob.arrayBuffer());
if (!cancelled) setEmlContent(parsed as ParsedEml);
} else { } else {
revokeUrl = URL.createObjectURL(blob); // Stalwart's download endpoint can return generic
if (!cancelled) setObjectUrl(revokeUrl); // application/octet-stream for attachments even when the email's
// MIME structure declared application/pdf (etc.). The blob inherits
// that, so a blob: URL plugged into <iframe> looks like a binary
// stream and Chrome/Edge silently download it (with the blob UUID
// as filename) instead of rendering inline. Re-wrap with the most
// specific MIME we can resolve - prefer explicit attachment type,
// then filename-derived MIME, then whatever the blob came with.
const inferredFromName = inferMimeFromName(name);
const isUseless = (ty?: string) =>
!ty || ty === "application/octet-stream" || ty === "binary/octet-stream";
const effectiveType =
(contentType && !isUseless(contentType) ? contentType : undefined)
?? inferredFromName
?? blob.type;
const typedBlob = blob.type !== effectiveType
? new Blob([blob], { type: effectiveType })
: blob;
revokeUrl = URL.createObjectURL(typedBlob);
if (!cancelled) {
setObjectUrl(revokeUrl);
setCanOpenInNewTab(isMimeTypeSafeForInlinePreview(effectiveType));
}
} }
} catch { } catch {
if (!cancelled) setError(true); if (!cancelled) setError(true);
@@ -173,6 +288,18 @@ export function FilePreviewModal({ name, onClose, onDownload, getFileContent }:
<div className="flex items-center justify-between px-4 py-3 bg-background/90 backdrop-blur border-b border-border" onClick={(e) => e.stopPropagation()}> <div className="flex items-center justify-between px-4 py-3 bg-background/90 backdrop-blur border-b border-border" onClick={(e) => e.stopPropagation()}>
<h3 className="text-sm font-medium truncate">{name}</h3> <h3 className="text-sm font-medium truncate">{name}</h3>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
{objectUrl && canOpenInNewTab && (
<Button
variant="ghost"
size="icon"
className="h-8 w-8"
title={t("open_in_new_tab")}
aria-label={t("open_in_new_tab")}
onClick={() => window.open(objectUrl, "_blank", "noopener,noreferrer")}
>
<ExternalLink className="w-4 h-4" />
</Button>
)}
<Button variant="ghost" size="icon" className="h-8 w-8" onClick={() => void onDownload()}> <Button variant="ghost" size="icon" className="h-8 w-8" onClick={() => void onDownload()}>
<Download className="w-4 h-4" /> <Download className="w-4 h-4" />
</Button> </Button>
@@ -211,6 +338,10 @@ export function FilePreviewModal({ name, onClose, onDownload, getFileContent }:
</div> </div>
)} )}
{!loading && !error && fileType === "eml" && emlContent && (
<EmlPreview message={emlContent} />
)}
{!loading && !error && fileType === "image" && objectUrl && ( {!loading && !error && fileType === "image" && objectUrl && (
<img <img
src={objectUrl} src={objectUrl}
@@ -231,19 +362,29 @@ export function FilePreviewModal({ name, onClose, onDownload, getFileContent }:
/> />
)} )}
{!loading && !error && fileType === "pdf" && objectUrl && ( {!loading && !error && fileType === "pdf" && objectUrl && pdfInlineSupported && (
<object // <iframe> renders PDFs reliably across desktop Chromium, Firefox,
data={objectUrl} // and Safari from a blob: URL. <object> was prone to falling back to
type="application/pdf" // a silent download when the blob's Content-Type wasn't recognised.
<iframe
src={objectUrl}
className="w-full max-w-5xl h-full rounded-lg bg-white" className="w-full max-w-5xl h-full rounded-lg bg-white"
aria-label={name} title={name}
onClick={(e) => e.stopPropagation()}
/>
)}
{!loading && !error && fileType === "pdf" && objectUrl && !pdfInlineSupported && (
// Mobile browsers can't show a PDF inline in an <iframe> (Android: a
// blank frame / silent download; iOS: only the first page), so render
// it with pdf.js (canvas) instead. The header's open-in-new-tab /
// download actions are the fallback if pdf.js can't render the doc.
<div
className="w-full max-w-3xl h-full overflow-auto rounded-lg bg-neutral-200 dark:bg-neutral-800 p-2"
onClick={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}
> >
<Button onClick={() => void onDownload()}> <PdfMobileViewer url={objectUrl} />
<Download className="w-4 h-4 mr-2" /> </div>
{t("download")}
</Button>
</object>
)} )}
{!loading && !error && fileType === "audio" && objectUrl && ( {!loading && !error && fileType === "audio" && objectUrl && (
+223
View File
@@ -0,0 +1,223 @@
"use client";
import { useEffect, useRef, useState } from "react";
import { useTranslations } from "next-intl";
import { Loader2, ExternalLink } from "lucide-react";
import { Button } from "@/components/ui/button";
// Real inline PDF preview for mobile browsers. Android Chrome / iOS WebKit have
// no native inline PDF viewer, so the desktop <iframe src=blob:> approach
// renders a blank frame. pdf.js rasterises each page to a <canvas> in pure JS,
// so it works everywhere regardless of native PDF support.
//
// This component is dynamic-imported (ssr:false) only on the mobile PDF path,
// and it dynamic-imports pdfjs-dist itself, so the (~hundreds of KB) library +
// worker never reach the desktop bundle.
// iOS WebKit caps total canvas area (~16 MP) and is memory-sensitive; keep each
// page's backing store well under that so large pages don't render blank. The
// backing store is rendered at ~2x the fit width (dpr), which also gives the
// double-tap zoom some headroom before CSS upscaling softens the text.
const MAX_CANVAS_AREA = 4_000_000; // ~4 MP per page
// Double-tap zoom steps: fit -> 2x -> 3x -> fit. Implemented as the page
// container's CSS width (so panning is native scrolling, not a transform).
const ZOOM_STEPS = [1, 2, 3];
const MAX_ZOOM = 4; // pinch can go a bit beyond the double-tap steps
const DOUBLE_TAP_MS = 300;
const DOUBLE_TAP_SLOP = 30; // px
export function PdfMobileViewer({ url }: { url: string }) {
const rootRef = useRef<HTMLDivElement>(null);
const pagesRef = useRef<HTMLDivElement>(null);
const zoom = useRef({ step: 0, scale: 1 });
const [status, setStatus] = useState<"loading" | "ready" | "error">("loading");
const t = useTranslations("files");
// Render the PDF pages to canvases.
useEffect(() => {
let cancelled = false;
let loadingTask: import("pdfjs-dist").PDFDocumentLoadingTask | null = null;
// Reset zoom for a freshly loaded document.
zoom.current = { step: 0, scale: 1 };
if (pagesRef.current) pagesRef.current.style.width = "100%";
(async () => {
try {
const pdfjs = await import("pdfjs-dist");
pdfjs.GlobalWorkerOptions.workerSrc = new URL(
"pdfjs-dist/build/pdf.worker.min.mjs",
import.meta.url,
).toString();
loadingTask = pdfjs.getDocument({ url });
const doc = await loadingTask.promise;
if (cancelled) return;
const pages = pagesRef.current;
if (!pages) return;
pages.replaceChildren();
const dpr = Math.min(window.devicePixelRatio || 1, 2);
const cssWidth = Math.min(pages.clientWidth || 320, 900);
for (let n = 1; n <= doc.numPages; n++) {
if (cancelled) return;
const page = await doc.getPage(n);
const baseVp = page.getViewport({ scale: 1 });
let scale = (cssWidth / baseVp.width) * dpr;
const area = baseVp.width * scale * (baseVp.height * scale);
if (area > MAX_CANVAS_AREA) scale *= Math.sqrt(MAX_CANVAS_AREA / area);
const viewport = page.getViewport({ scale });
const canvas = document.createElement("canvas");
canvas.width = Math.floor(viewport.width);
canvas.height = Math.floor(viewport.height);
canvas.style.width = "100%";
canvas.style.height = "auto";
canvas.style.display = "block";
canvas.style.margin = "0 auto 8px";
canvas.style.background = "#fff";
pages.appendChild(canvas);
await page.render({ canvas, viewport }).promise;
}
if (!cancelled) setStatus("ready");
} catch {
if (!cancelled) setStatus("error");
}
})();
return () => {
cancelled = true;
void loadingTask?.destroy().catch(() => {});
};
}, [url]);
// Gesture zoom. 1-finger double-tap cycles the steps (fit -> 2x -> 3x ->
// fit); 2-finger pinch zooms continuously up to MAX_ZOOM. Both drive a
// per-page CSS-width zoom that pans via native scrolling and re-centre on the
// gesture point. touch-action (pan-x pan-y) keeps 1-finger panning native
// while disabling the browser's own pinch/double-tap page zoom, which we
// replace here.
useEffect(() => {
const root = rootRef.current;
if (!root) return;
const distance = (a: Touch, b: Touch) =>
Math.hypot(a.clientX - b.clientX, a.clientY - b.clientY);
// Apply an absolute zoom scale, keeping (cx, cy) [relative to root] under
// the same content point.
const applyZoom = (target: number, cx: number, cy: number) => {
const pages = pagesRef.current;
if (!pages) return;
const next = Math.max(1, Math.min(MAX_ZOOM, target));
const ratio = next / zoom.current.scale;
if (ratio === 1) return;
pages.style.width = `${next * 100}%`;
void root.offsetWidth; // force reflow so the new scroll range is live
root.scrollLeft = (root.scrollLeft + cx) * ratio - cx;
root.scrollTop = (root.scrollTop + cy) * ratio - cy;
zoom.current.scale = next;
};
let lastTap = 0;
let lastX = 0;
let lastY = 0;
let pinching = false;
let pinchStartDist = 0;
let pinchStartScale = 1;
let didPinch = false;
const onStart = (e: TouchEvent) => {
if (e.touches.length === 2) {
pinching = true;
didPinch = true;
pinchStartDist = distance(e.touches[0], e.touches[1]) || 1;
pinchStartScale = zoom.current.scale;
}
};
const onMove = (e: TouchEvent) => {
if (!pinching || e.touches.length !== 2) return;
e.preventDefault(); // take over the 2-finger gesture from native pan
const rect = root.getBoundingClientRect();
const cx = (e.touches[0].clientX + e.touches[1].clientX) / 2 - rect.left;
const cy = (e.touches[0].clientY + e.touches[1].clientY) / 2 - rect.top;
applyZoom(pinchStartScale * (distance(e.touches[0], e.touches[1]) / pinchStartDist), cx, cy);
};
const onEnd = (e: TouchEvent) => {
if (pinching && e.touches.length < 2) {
pinching = false;
// Snap the step index to the current scale so double-tap stays sensible.
const s = zoom.current.scale;
zoom.current.step = s <= 1.01 ? 0 : s < 3 ? 1 : 2;
}
if (e.touches.length > 0) return; // fingers still down
if (didPinch) {
didPinch = false; // a pinch is not a tap
return;
}
if (e.changedTouches.length !== 1) return;
const touch = e.changedTouches[0];
const now = e.timeStamp;
const isDouble =
now - lastTap < DOUBLE_TAP_MS &&
Math.abs(touch.clientX - lastX) < DOUBLE_TAP_SLOP &&
Math.abs(touch.clientY - lastY) < DOUBLE_TAP_SLOP;
if (!isDouble) {
lastTap = now;
lastX = touch.clientX;
lastY = touch.clientY;
return;
}
lastTap = 0; // consume so a third tap starts fresh
e.preventDefault();
const rect = root.getBoundingClientRect();
zoom.current.step = (zoom.current.step + 1) % ZOOM_STEPS.length;
applyZoom(ZOOM_STEPS[zoom.current.step], touch.clientX - rect.left, touch.clientY - rect.top);
};
root.addEventListener("touchstart", onStart, { passive: true });
root.addEventListener("touchmove", onMove, { passive: false });
root.addEventListener("touchend", onEnd, { passive: false });
return () => {
root.removeEventListener("touchstart", onStart);
root.removeEventListener("touchmove", onMove);
root.removeEventListener("touchend", onEnd);
};
}, []);
return (
<div
ref={rootRef}
className="w-full h-full overflow-auto"
// Allow panning, but disable the browser's pinch/double-tap page zoom so
// our own double-tap zoom drives the PDF instead of the whole modal.
style={{ touchAction: "pan-x pan-y" }}
>
{status === "loading" && (
<div className="flex justify-center py-10">
<Loader2 className="w-6 h-6 animate-spin text-muted-foreground" />
</div>
)}
{status === "error" && (
// pdf.js couldn't render this document (corrupt/encrypted, worker load
// failure, ...). Offer the OS/native viewer instead of a stuck frame.
<div className="flex justify-center py-10">
<Button
variant="outline"
size="sm"
onClick={() => window.open(url, "_blank", "noopener,noreferrer")}
>
<ExternalLink className="w-4 h-4 mr-2" />
{t("open_in_new_tab")}
</Button>
</div>
)}
<div ref={pagesRef} className="w-full" />
</div>
);
}
+104 -15
View File
@@ -27,7 +27,7 @@ interface FilterRuleModalProps {
} }
const ALL_FIELDS: FilterConditionField[] = [ const ALL_FIELDS: FilterConditionField[] = [
"from", "to", "cc", "subject", "header", "size", "body", "from", "to", "cc", "subject", "header", "size", "body", "attachment",
]; ];
const TEXT_COMPARATORS: FilterComparator[] = [ const TEXT_COMPARATORS: FilterComparator[] = [
@@ -36,6 +36,14 @@ const TEXT_COMPARATORS: FilterComparator[] = [
const SIZE_COMPARATORS: FilterComparator[] = ["greater_than", "less_than"]; const SIZE_COMPARATORS: FilterComparator[] = ["greater_than", "less_than"];
const ATTACHMENT_COMPARATORS: FilterComparator[] = ["has_any", "has_type"];
function comparatorsFor(field: FilterConditionField): FilterComparator[] {
if (field === "size") return SIZE_COMPARATORS;
if (field === "attachment") return ATTACHMENT_COMPARATORS;
return TEXT_COMPARATORS;
}
const ALL_ACTION_TYPES: FilterActionType[] = [ const ALL_ACTION_TYPES: FilterActionType[] = [
"move", "copy", "forward", "mark_read", "star", "add_label", "discard", "reject", "keep", "stop", "move", "copy", "forward", "mark_read", "star", "add_label", "discard", "reject", "keep", "stop",
]; ];
@@ -47,6 +55,27 @@ function makeEmptyCondition(): FilterCondition {
return { field: "from", comparator: "contains", value: "" }; return { field: "from", comparator: "contains", value: "" };
} }
// Multi-value handling: conditions are stored as string | string[]. The UI
// presents them as a single comma-separated text input — the user types
// "a, b, c" and the saved value becomes ["a","b","c"]. Single entries stay
// strings so existing single-value rules don't change shape.
function valueToInputString(v: string | string[]): string {
if (Array.isArray(v)) return v.join(", ");
return v;
}
function inputStringToValue(s: string): string | string[] {
const parts = s.split(",").map((p) => p.trim()).filter((p) => p.length > 0);
if (parts.length === 0) return "";
if (parts.length === 1) return parts[0];
return parts;
}
function isConditionValueEmpty(v: string | string[]): boolean {
if (Array.isArray(v)) return v.length === 0 || v.every((x) => !x.trim());
return !v.trim();
}
function makeEmptyAction(): FilterAction { function makeEmptyAction(): FilterAction {
return { type: "move", value: "" }; return { type: "move", value: "" };
} }
@@ -97,9 +126,23 @@ export function FilterRuleModal({
return; return;
} }
const validConditions = conditions.filter( // While editing, condition.value is always the raw string typed into the
(c) => c.value.trim() // input (commas not yet split). Convert to array form here on save so a
); // user typing "a, b, c" actually persists as ["a","b","c"]. This is the
// moment we know editing is finished - splitting earlier would eat any
// comma the user just typed mid-edit.
const validConditions = conditions
.filter((c) => {
if (c.field === "attachment" && c.comparator === "has_any") return true;
return !isConditionValueEmpty(c.value);
})
.map((c) => {
if (c.field === "attachment" && c.comparator === "has_any") return c;
if (c.field === "size") return c; // numeric, single-value only
if (typeof c.value !== "string") return c; // already structured
const parsed = inputStringToValue(c.value);
return { ...c, value: parsed };
});
if (validConditions.length === 0) { if (validConditions.length === 0) {
toast.error(t("validation_empty_conditions")); toast.error(t("validation_empty_conditions"));
return; return;
@@ -129,15 +172,27 @@ export function FilterRuleModal({
prev.map((c, i) => { prev.map((c, i) => {
if (i !== index) return c; if (i !== index) return c;
const updated = { ...c, ...updates }; const updated = { ...c, ...updates };
if (updates.field === "size" && !SIZE_COMPARATORS.includes(c.comparator)) { // Reconcile the comparator when the field changes so we never end up
updated.comparator = "greater_than"; // with e.g. (field=attachment, comparator=contains) — invalid for the
// Sieve generator. Each field has its own valid comparator set.
if (updates.field && updates.field !== c.field) {
const allowed = comparatorsFor(updates.field);
if (!allowed.includes(c.comparator)) {
updated.comparator = allowed[0];
} }
if (updates.field && updates.field !== "size" && SIZE_COMPARATORS.includes(c.comparator)) {
updated.comparator = "contains";
} }
if (updates.field && updates.field !== "header") { if (updates.field && updates.field !== "header") {
delete updated.headerName; delete updated.headerName;
} }
// has_any takes no value; clear it so we don't leak old text into
// the generated Sieve.
if (updated.field === "attachment" && updated.comparator === "has_any") {
updated.value = "";
}
// Size is numeric, single value only - collapse any list to scalar.
if (updated.field === "size" && Array.isArray(updated.value)) {
updated.value = updated.value[0] ?? "";
}
return updated; return updated;
}) })
); );
@@ -281,24 +336,58 @@ export function FilterRuleModal({
className={selectClass} className={selectClass}
aria-label={t("comparators.contains")} aria-label={t("comparators.contains")}
> >
{(condition.field === "size" ? SIZE_COMPARATORS : TEXT_COMPARATORS).map( {comparatorsFor(condition.field).map((c) => (
(c) => (
<option key={c} value={c}> <option key={c} value={c}>
{t(`comparators.${c}`)} {t(`comparators.${c}`)}
</option> </option>
) ))}
)}
</select> </select>
{/* has_any takes no value; render a stub so the row layout
stays consistent but no input is editable. */}
{condition.field === "attachment" && condition.comparator === "has_any" ? (
<div className="flex-1 min-w-[120px]" />
) : (
<Input <Input
value={condition.value} value={valueToInputString(condition.value)}
onChange={(e) => updateCondition(index, { value: e.target.value })} onChange={(e) =>
// Store the raw input string while typing. Splitting
// commas into an array on every keystroke would eat
// the comma the moment it's typed.
updateCondition(index, { value: e.target.value })
}
onBlur={(e) => {
// On blur: normalise comma-separated input into an
// array (or single string when only one item). Size
// stays numeric/single-value; attachment-has_any has
// no value at all.
if (condition.field === "size") return;
if (
condition.field === "attachment" &&
condition.comparator === "has_any"
)
return;
const parsed = inputStringToValue(e.target.value);
// Only update if the normalised shape actually
// differs - avoids triggering a no-op re-render and
// resetting the user's cursor on every blur.
if (
JSON.stringify(parsed) !== JSON.stringify(condition.value)
) {
updateCondition(index, { value: parsed });
}
}}
placeholder={ placeholder={
condition.field === "size" ? t("size_placeholder") : t("header_placeholder") condition.field === "size"
? t("size_placeholder")
: condition.field === "attachment"
? t("attachment_type_placeholder")
: t("value_placeholder_multi")
} }
className="flex-1 min-w-[120px]" className="flex-1 min-w-[120px]"
type={condition.field === "size" ? "number" : "text"} type={condition.field === "size" ? "number" : "text"}
/> />
)}
<button <button
type="button" type="button"
+1 -1
View File
@@ -8,7 +8,7 @@ import type { Identity, EmailAddress } from '@/lib/jmap/types';
import { sanitizeSignatureHtml } from '@/lib/email-sanitization'; import { sanitizeSignatureHtml } from '@/lib/email-sanitization';
import { getEmailValidationError, validateEmailList } from '@/lib/validation'; import { getEmailValidationError, validateEmailList } from '@/lib/validation';
// JMAP Identity/set caps signature fields at 2047 UTF-8 bytes per RFC 8621 §6.1. // Stalwarts JMAP Identity/set caps signature fields at 2047 UTF-8 bytes
const SIGNATURE_MAX_BYTES = 2047; const SIGNATURE_MAX_BYTES = 2047;
const utf8Encoder = new TextEncoder(); const utf8Encoder = new TextEncoder();
+4 -2
View File
@@ -77,9 +77,11 @@ export function ResizeHandle({ onResizeStart, onResize, onResizeEnd, onDoubleCli
onKeyDown={handleKeyDown} onKeyDown={handleKeyDown}
onDoubleClick={onDoubleClick} onDoubleClick={onDoubleClick}
className={cn( className={cn(
"flex-shrink-0 hover:bg-primary/30 active:bg-primary/50 transition-colors relative group", // Centered over the seam with negative margins so the handle adds no
// net layout space (panes sit flush) while staying draggable.
"flex-shrink-0 hover:bg-primary/30 active:bg-primary/50 transition-colors relative group z-10",
"focus-visible:outline-none focus-visible:bg-primary/40 focus-visible:ring-2 focus-visible:ring-primary/50", "focus-visible:outline-none focus-visible:bg-primary/40 focus-visible:ring-2 focus-visible:ring-primary/50",
isHorizontal ? "h-1 cursor-row-resize bg-border" : "w-1 cursor-col-resize", isHorizontal ? "h-1 -my-0.5 cursor-row-resize bg-border" : "w-1 -mx-0.5 cursor-col-resize",
className className
)} )}
> >
+7 -3
View File
@@ -904,11 +904,11 @@ export function Sidebar({
}; };
const openFolderSettings = () => { const openFolderSettings = () => {
try { localStorage.setItem('settings-active-tab', 'folders'); } catch { /* */ } try { sessionStorage.setItem('settings-deep-link-tab', 'folders'); } catch { /* */ }
router.push('/settings'); router.push('/settings');
}; };
const openKeywordSettings = () => { const openKeywordSettings = () => {
try { localStorage.setItem('settings-active-tab', 'keywords'); } catch { /* */ } try { sessionStorage.setItem('settings-deep-link-tab', 'keywords'); } catch { /* */ }
router.push('/settings'); router.push('/settings');
}; };
@@ -942,7 +942,10 @@ export function Sidebar({
{/* Header - hidden in the Pro shell, which owns its own chrome and {/* Header - hidden in the Pro shell, which owns its own chrome and
would otherwise render an empty strip (no collapse, no switcher). */} would otherwise render an empty strip (no collapse, no switcher). */}
{!isEmbedded && ( {!isEmbedded && (
<div className={cn("flex items-center border-b border-border", isCollapsed ? "justify-center px-2 py-2" : "gap-1 px-2 py-2")}> // Border lives on the wrapper (outside the h-14 box) so the bar's total
// height matches the search/reply toolbars, which border-b their wrapper too.
<div className="border-b border-border">
<div className={cn("flex items-center h-14", isCollapsed ? "justify-center px-2" : "gap-1 px-2")}>
<Button <Button
variant="ghost" variant="ghost"
size="icon" size="icon"
@@ -967,6 +970,7 @@ export function Sidebar({
<AccountSwitcher variant="expanded" className="flex-1" /> <AccountSwitcher variant="expanded" className="flex-1" />
)} )}
</div> </div>
</div>
)} )}
{!isCollapsed && <DemoBanner />} {!isCollapsed && <DemoBanner />}
+22 -9
View File
@@ -7,6 +7,19 @@
import React, { useEffect, useSyncExternalStore } from 'react'; import React, { useEffect, useSyncExternalStore } from 'react';
import { head, resolveHead, subscribe } from '@/lib/plugin-sandbox/host-dialog'; import { head, resolveHead, subscribe } from '@/lib/plugin-sandbox/host-dialog';
// Lightweight **bold** support in plugin dialog messages. Everything else is
// rendered literally (newlines come from the parent's white-space: pre-wrap).
// Splitting on the ** delimiter yields alternating plain/bold segments (odd
// indices are bold). Plugins control these strings, so the delimiters balance.
function renderMessage(message?: string): React.ReactNode {
if (!message) return null;
return message.split('**').map((seg, i) =>
i % 2 === 1
? <strong key={i}>{seg}</strong>
: <React.Fragment key={i}>{seg}</React.Fragment>,
);
}
export function PluginDialogHost(): React.JSX.Element | null { export function PluginDialogHost(): React.JSX.Element | null {
const current = useSyncExternalStore(subscribe, head, () => null); const current = useSyncExternalStore(subscribe, head, () => null);
@@ -50,9 +63,9 @@ export function PluginDialogHost(): React.JSX.Element | null {
> >
<div <div
style={{ style={{
background: 'var(--background, #fff)', background: 'var(--color-popover, #fff)',
color: 'var(--foreground, #0f172a)', color: 'var(--color-popover-foreground, #0f172a)',
border: '1px solid var(--border, #e2e8f0)', border: '1px solid var(--color-border, #e2e8f0)',
borderRadius: 12, borderRadius: 12,
padding: 20, padding: 20,
maxWidth: 480, maxWidth: 480,
@@ -63,8 +76,8 @@ export function PluginDialogHost(): React.JSX.Element | null {
<h2 id="plugin-dialog-title" style={{ fontSize: 16, fontWeight: 600, margin: '0 0 10px 0' }}> <h2 id="plugin-dialog-title" style={{ fontSize: 16, fontWeight: 600, margin: '0 0 10px 0' }}>
{current.title} {current.title}
</h2> </h2>
<p style={{ fontSize: 13, lineHeight: 1.5, margin: '0 0 16px 0', color: 'var(--muted-foreground, #64748b)', whiteSpace: 'pre-wrap' }}> <p style={{ fontSize: 13, lineHeight: 1.5, margin: '0 0 16px 0', color: 'var(--color-muted-foreground, #64748b)', whiteSpace: 'pre-wrap', overflowWrap: 'anywhere' }}>
{current.message} {renderMessage(current.message)}
</p> </p>
<div style={{ display: 'flex', gap: 8, justifyContent: 'flex-end' }}> <div style={{ display: 'flex', gap: 8, justifyContent: 'flex-end' }}>
{current.kind === 'confirm' && ( {current.kind === 'confirm' && (
@@ -78,7 +91,7 @@ export function PluginDialogHost(): React.JSX.Element | null {
fontSize: 13, fontSize: 13,
fontWeight: 500, fontWeight: 500,
cursor: 'pointer', cursor: 'pointer',
border: '1px solid var(--border, #e2e8f0)', border: '1px solid var(--color-border, #e2e8f0)',
background: 'transparent', background: 'transparent',
color: 'inherit', color: 'inherit',
}} }}
@@ -97,14 +110,14 @@ export function PluginDialogHost(): React.JSX.Element | null {
fontWeight: 500, fontWeight: 500,
cursor: 'pointer', cursor: 'pointer',
border: '1px solid transparent', border: '1px solid transparent',
background: current.danger ? '#dc2626' : '#3b82f6', background: current.danger ? 'var(--color-destructive, #dc2626)' : 'var(--color-primary, #3b82f6)',
color: '#fff', color: current.danger ? 'var(--color-destructive-foreground, #fff)' : 'var(--color-primary-foreground, #fff)',
}} }}
> >
{confirmLabel} {confirmLabel}
</button> </button>
</div> </div>
<div style={{ marginTop: 12, fontSize: 11, color: 'var(--muted-foreground, #94a3b8)', textAlign: 'right' }}> <div style={{ marginTop: 12, fontSize: 11, color: 'var(--color-muted-foreground, #94a3b8)', textAlign: 'right' }}>
From plugin: {current.pluginId} From plugin: {current.pluginId}
</div> </div>
</div> </div>
+12
View File
@@ -10,6 +10,8 @@ import React, { useEffect, useRef, useState } from 'react';
import type { SlotName } from '@/lib/plugin-types'; import type { SlotName } from '@/lib/plugin-types';
import { get as getActivePlugin } from '@/lib/plugin-sandbox/registry'; import { get as getActivePlugin } from '@/lib/plugin-sandbox/registry';
import { createSlotInstance, type SandboxInstance } from '@/lib/plugin-sandbox/host-bridge'; import { createSlotInstance, type SandboxInstance } from '@/lib/plugin-sandbox/host-bridge';
import { snapshotHostTheme } from '@/lib/plugin-sandbox/host-theme';
import { useThemeStore } from '@/stores/theme-store';
interface Props { interface Props {
pluginId: string; pluginId: string;
@@ -69,6 +71,16 @@ export function PluginIframeSlot({ pluginId, slot, extraProps }: Props) {
instanceRef.current?.updateProps(extraProps ?? {}); instanceRef.current?.updateProps(extraProps ?? {});
}, [extraProps]); }, [extraProps]);
// Re-theme the live slot iframe when the host theme changes (dark/light
// toggle or custom theme switch), without tearing down the iframe. Reacting
// to resolvedTheme + activeThemeId covers both; the snapshot reads the
// resolved DOM values so it picks up whichever is active.
const resolvedTheme = useThemeStore((s) => s.resolvedTheme);
const activeThemeId = useThemeStore((s) => s.activeThemeId);
useEffect(() => {
instanceRef.current?.setTheme(snapshotHostTheme());
}, [resolvedTheme, activeThemeId]);
if (show !== true) return null; if (show !== true) return null;
return <div ref={wrapperRef} style={{ height, minHeight: height }} data-plugin-iframe-slot={`${pluginId}:${slot}`} />; return <div ref={wrapperRef} style={{ height, minHeight: height }} data-plugin-iframe-slot={`${pluginId}:${slot}`} />;
} }
+4 -3
View File
@@ -10,6 +10,7 @@ import { useSettingsStore } from "@/stores/settings-store";
import { toast } from "@/stores/toast-store"; import { toast } from "@/stores/toast-store";
import { useProTabStore, type ProEmailTabData, type ProReplyContext } from "@/stores/pro-tab-store"; import { useProTabStore, type ProEmailTabData, type ProReplyContext } from "@/stores/pro-tab-store";
import type { Email } from "@/lib/jmap/types"; import type { Email } from "@/lib/jmap/types";
import { buildReplySubject, buildForwardSubject } from "@/lib/subject-prefix";
interface ProEmailTabBodyProps { interface ProEmailTabBodyProps {
tabId: string; tabId: string;
@@ -104,7 +105,7 @@ export function ProEmailTabBody({ tabId, data }: ProEmailTabBodyProps) {
replyTo: buildReplyContext(email), replyTo: buildReplyContext(email),
sourceEmailId: email.id, sourceEmailId: email.id,
initialDraftText: draftText, initialDraftText: draftText,
title: `Re: ${email.subject || t('email_composer.new_message')}`, title: buildReplySubject(email.subject || t('email_composer.new_message'), t('email_composer.prefix.reply')),
}); });
}, [email, openComposeTab, t]); }, [email, openComposeTab, t]);
@@ -116,7 +117,7 @@ export function ProEmailTabBody({ tabId, data }: ProEmailTabBodyProps) {
mode: 'replyAll', mode: 'replyAll',
replyTo: buildReplyContext(email), replyTo: buildReplyContext(email),
sourceEmailId: email.id, sourceEmailId: email.id,
title: `Re: ${email.subject || t('email_composer.new_message')}`, title: buildReplySubject(email.subject || t('email_composer.new_message'), t('email_composer.prefix.reply')),
}); });
}, [email, openComposeTab, t]); }, [email, openComposeTab, t]);
@@ -128,7 +129,7 @@ export function ProEmailTabBody({ tabId, data }: ProEmailTabBodyProps) {
mode: 'forward', mode: 'forward',
replyTo: buildReplyContext(email), replyTo: buildReplyContext(email),
sourceEmailId: email.id, sourceEmailId: email.id,
title: `Fwd: ${email.subject || t('email_composer.new_message')}`, title: buildForwardSubject(email.subject || t('email_composer.new_message'), t('email_composer.prefix.forward')),
}); });
}, [email, openComposeTab, t]); }, [email, openComposeTab, t]);
+8 -6
View File
@@ -1,6 +1,7 @@
"use client"; "use client";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { useTranslations } from "next-intl";
import { X, Download } from "lucide-react"; import { X, Download } from "lucide-react";
import { useConfig } from "@/hooks/use-config"; import { useConfig } from "@/hooks/use-config";
import { withBasePath } from "@/lib/browser-navigation"; import { withBasePath } from "@/lib/browser-navigation";
@@ -17,6 +18,7 @@ export function PWAInstallPrompt() {
useState<BeforeInstallPromptEvent | null>(null); useState<BeforeInstallPromptEvent | null>(null);
const [showPrompt, setShowPrompt] = useState(false); const [showPrompt, setShowPrompt] = useState(false);
const { appName, faviconUrl, appLogoLightUrl, appLogoDarkUrl } = useConfig(); const { appName, faviconUrl, appLogoLightUrl, appLogoDarkUrl } = useConfig();
const t = useTranslations("pwa_install");
useEffect(() => { useEffect(() => {
if (localStorage.getItem(DISMISSED_KEY)) return; if (localStorage.getItem(DISMISSED_KEY)) return;
@@ -84,17 +86,17 @@ export function PWAInstallPrompt() {
)} )}
<div> <div>
<h3 className="font-semibold text-sm text-neutral-900 dark:text-white"> <h3 className="font-semibold text-sm text-neutral-900 dark:text-white">
Install {appName} {t("title", { appName })}
</h3> </h3>
<p className="text-xs text-neutral-600 dark:text-neutral-400 mt-1"> <p className="text-xs text-neutral-600 dark:text-neutral-400 mt-1">
Install our app for quick access and offline support. {t("description")}
</p> </p>
</div> </div>
</div> </div>
<button <button
onClick={handleDismiss} onClick={handleDismiss}
className="text-neutral-400 hover:text-neutral-600 dark:hover:text-neutral-300 transition-colors" className="text-neutral-400 hover:text-neutral-600 dark:hover:text-neutral-300 transition-colors"
aria-label="Dismiss install prompt" aria-label={t("dismiss_aria")}
> >
<X className="w-4 h-4" /> <X className="w-4 h-4" />
</button> </button>
@@ -105,20 +107,20 @@ export function PWAInstallPrompt() {
onClick={handleDismiss} onClick={handleDismiss}
className="flex-1 px-3 py-2 text-sm font-medium text-neutral-700 dark:text-neutral-300 bg-neutral-100 dark:bg-neutral-800 rounded hover:bg-neutral-200 dark:hover:bg-neutral-700 transition-colors" className="flex-1 px-3 py-2 text-sm font-medium text-neutral-700 dark:text-neutral-300 bg-neutral-100 dark:bg-neutral-800 rounded hover:bg-neutral-200 dark:hover:bg-neutral-700 transition-colors"
> >
Not now {t("not_now")}
</button> </button>
<button <button
onClick={handleInstall} onClick={handleInstall}
className="flex-1 px-3 py-2 text-sm font-medium text-white bg-blue-600 rounded hover:bg-blue-700 transition-colors" className="flex-1 px-3 py-2 text-sm font-medium text-white bg-blue-600 rounded hover:bg-blue-700 transition-colors"
> >
Install {t("install")}
</button> </button>
</div> </div>
<button <button
onClick={handleDismissForever} onClick={handleDismissForever}
className="w-full text-xs text-neutral-400 hover:text-neutral-600 dark:hover:text-neutral-300 transition-colors text-center" className="w-full text-xs text-neutral-400 hover:text-neutral-600 dark:hover:text-neutral-300 transition-colors text-center"
> >
Don&apos;t remind me again {t("dont_remind")}
</button> </button>
</div> </div>
</div> </div>
@@ -15,25 +15,7 @@ import { ICalImportModal } from '@/components/calendar/ical-import-modal';
import { ICalSubscriptionModal } from '@/components/calendar/ical-subscription-modal'; import { ICalSubscriptionModal } from '@/components/calendar/ical-subscription-modal';
import { useSettingsStore } from '@/stores/settings-store'; import { useSettingsStore } from '@/stores/settings-store';
import { apiFetch } from '@/lib/browser-navigation'; import { apiFetch } from '@/lib/browser-navigation';
import { CALENDAR_COLORS, sharedCalendarColorKey } from '@/lib/shared-calendar-colors';
const CALENDAR_COLORS = [
"#3b82f6", // blue
"#ef4444", // red
"#22c55e", // green
"#f59e0b", // amber
"#8b5cf6", // violet
"#ec4899", // pink
"#14b8a6", // teal
"#f97316", // orange
"#06b6d4", // cyan
"#84cc16", // lime
"#6366f1", // indigo
"#a855f7", // purple
"#e11d48", // rose
"#0ea5e9", // sky
"#10b981", // emerald
"#d946ef", // fuchsia
];
function CalendarColorPicker({ function CalendarColorPicker({
value, value,
@@ -182,6 +164,8 @@ export function CalendarManagementSettings() {
const tImport = useTranslations('calendar.import'); const tImport = useTranslations('calendar.import');
const tSub = useTranslations('calendar.subscription'); const tSub = useTranslations('calendar.subscription');
const timeFormat = useSettingsStore((s) => s.timeFormat); const timeFormat = useSettingsStore((s) => s.timeFormat);
const sharedCalendarColors = useSettingsStore((s) => s.sharedCalendarColors);
const setSharedCalendarColor = useSettingsStore((s) => s.setSharedCalendarColor);
const colorPickerRef = useRef<HTMLDivElement>(null); const colorPickerRef = useRef<HTMLDivElement>(null);
// Load calendars if not yet loaded // Load calendars if not yet loaded
@@ -329,6 +313,15 @@ export function CalendarManagementSettings() {
const handleColorChange = async (calendarId: string, color: string) => { const handleColorChange = async (calendarId: string, color: string) => {
if (!client) return; if (!client) return;
// Shared calendars: recolor locally only - the viewer usually can't write
// the owner's calendar and it'd recolor it for everyone (see #345).
const cal = calendars.find((c) => c.id === calendarId);
if (cal?.isShared) {
setSharedCalendarColor(sharedCalendarColorKey(cal), color);
toast.success(t('color_updated'));
setColorPickerId(null);
return;
}
try { try {
await updateCalendar(client, calendarId, { color }); await updateCalendar(client, calendarId, { color });
toast.success(t('color_updated')); toast.success(t('color_updated'));
@@ -396,7 +389,7 @@ export function CalendarManagementSettings() {
<SettingsSection title={t('title')} description={t('description')}> <SettingsSection title={t('title')} description={t('description')}>
<div className="space-y-2"> <div className="space-y-2">
{calendars.filter(cal => !isSubscriptionCalendar(cal.id)).map((cal) => { {calendars.filter(cal => !isSubscriptionCalendar(cal.id)).map((cal) => {
const color = cal.color || '#3b82f6'; const color = (cal.isShared && sharedCalendarColors[sharedCalendarColorKey(cal)]) || cal.color || '#3b82f6';
if (editingId === cal.id) { if (editingId === cal.id) {
return ( return (
@@ -28,6 +28,8 @@ export function ComposingSettings() {
subAddressDelimiter, subAddressDelimiter,
signaturePosition, signaturePosition,
signatureSeparatorEnabled, signatureSeparatorEnabled,
requestReadReceiptDefault,
readReceiptResponse,
updateSetting, updateSetting,
} = useSettingsStore(); } = useSettingsStore();
const { client } = useAuthStore(); const { client } = useAuthStore();
@@ -78,6 +80,25 @@ export function ComposingSettings() {
/> />
</SettingItem> </SettingItem>
<SettingItem label={t('request_read_receipt.label')} description={t('request_read_receipt.description')}>
<ToggleSwitch
checked={requestReadReceiptDefault}
onChange={(checked) => updateSetting('requestReadReceiptDefault', checked)}
/>
</SettingItem>
<SettingItem label={t('read_receipt_response.label')} description={t('read_receipt_response.description')}>
<Select
value={readReceiptResponse}
onChange={(value) => updateSetting('readReceiptResponse', value as 'ask' | 'always' | 'never')}
options={[
{ value: 'ask', label: t('read_receipt_response.ask') },
{ value: 'always', label: t('read_receipt_response.always') },
{ value: 'never', label: t('read_receipt_response.never') },
]}
/>
</SettingItem>
<SettingItem <SettingItem
label={t('sub_address_delimiter.label')} label={t('sub_address_delimiter.label')}
description={t('sub_address_delimiter.description', { delimiter: subAddressDelimiter })} description={t('sub_address_delimiter.description', { delimiter: subAddressDelimiter })}
+27 -2
View File
@@ -36,7 +36,17 @@ function RuleSummary({ rule }: { rule: FilterRule }) {
const conditions = rule.conditions.slice(0, 2).map((c) => { const conditions = rule.conditions.slice(0, 2).map((c) => {
const field = t(`condition_fields.${c.field}`); const field = t(`condition_fields.${c.field}`);
const comparator = t(`comparators.${c.comparator}`); const comparator = t(`comparators.${c.comparator}`);
return `${field} ${comparator} "${c.value}"`; // has_any is a no-value test ("attachment is present"); appending
// `""` would look broken in the summary line.
if (c.field === "attachment" && c.comparator === "has_any") {
return `${field} ${comparator}`;
}
// Multi-value conditions render as "a" / "b" / "c" with the locale's
// OR-glue between items so the line still reads as natural language.
const valueStr = Array.isArray(c.value)
? c.value.map((v) => `"${v}"`).join(` ${t("or")} `)
: `"${c.value}"`;
return `${field} ${comparator} ${valueStr}`;
}); });
const joiner = rule.matchType === "all" ? t("and") : t("or"); const joiner = rule.matchType === "all" ? t("and") : t("or");
@@ -97,7 +107,22 @@ function VisualRuleSummary({ rule }: { rule: FilterRule }) {
<span className="inline-flex items-baseline gap-1 px-1.5 py-px rounded-sm bg-muted/60 text-foreground"> <span className="inline-flex items-baseline gap-1 px-1.5 py-px rounded-sm bg-muted/60 text-foreground">
<span className="font-medium text-blue-600 dark:text-blue-400">{field}</span> <span className="font-medium text-blue-600 dark:text-blue-400">{field}</span>
<span className="text-muted-foreground">{comparator}</span> <span className="text-muted-foreground">{comparator}</span>
<span className="text-foreground">{c.value}</span> {!(c.field === "attachment" && c.comparator === "has_any") && (
<span className="text-foreground">
{Array.isArray(c.value)
? c.value.map((v, k) => (
<span key={k}>
{k > 0 && (
<span className="text-muted-foreground/70 italic mx-0.5">
{t("or")}
</span>
)}
{v}
</span>
))
: <>{c.value}</>}
</span>
)}
</span> </span>
</span> </span>
); );
+169 -6
View File
@@ -1,10 +1,10 @@
'use client'; 'use client';
import { useEffect } from 'react'; import { useEffect, useState } from 'react';
import { useThemeStore } from '@/stores/theme-store'; import { useThemeStore } from '@/stores/theme-store';
import { SettingsSection } from './settings-section'; import { SettingsSection } from './settings-section';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { Check, Palette, Lock } from 'lucide-react'; import { Check, Lock } from 'lucide-react';
import { toast } from '@/stores/toast-store'; import { toast } from '@/stores/toast-store';
import { usePolicyStore } from '@/stores/policy-store'; import { usePolicyStore } from '@/stores/policy-store';
@@ -14,6 +14,18 @@ export function ThemesSettings() {
const themePolicy = getThemePolicy(); const themePolicy = getThemePolicy();
const forcedThemeId = getForcedThemeId(installedThemes.map((theme) => theme.id)); const forcedThemeId = getForcedThemeId(installedThemes.map((theme) => theme.id));
// Render each preview in the variant matching the app's current mode, and
// keep it in sync when the user toggles light/dark elsewhere.
const [isDark, setIsDark] = useState(false);
useEffect(() => {
const root = document.documentElement;
const update = () => setIsDark(root.classList.contains('dark'));
update();
const obs = new MutationObserver(update);
obs.observe(root, { attributes: true, attributeFilter: ['class'] });
return () => obs.disconnect();
}, []);
// Filter out themes disabled by admin policy // Filter out themes disabled by admin policy
const visibleThemes = installedThemes.filter( const visibleThemes = installedThemes.filter(
theme => !isThemeDisabled(theme.id, !!theme.builtIn) theme => !isThemeDisabled(theme.id, !!theme.builtIn)
@@ -36,6 +48,9 @@ export function ThemesSettings() {
}, [activeThemeId, activateTheme, forcedThemeId, installedThemes, isThemeDisabled]); }, [activeThemeId, activateTheme, forcedThemeId, installedThemes, isThemeDisabled]);
const handleActivate = (id: string | null) => { const handleActivate = (id: string | null) => {
// Clicking the already-active theme is a no-op (no re-apply, no toast).
if (id === activeThemeId) return;
if (forcedThemeId && id !== forcedThemeId) { if (forcedThemeId && id !== forcedThemeId) {
const forcedTheme = installedThemes.find((theme) => theme.id === forcedThemeId); const forcedTheme = installedThemes.find((theme) => theme.id === forcedThemeId);
toast.info(`Theme "${forcedTheme?.name ?? 'Admin theme'}" is forced by admin and cannot be changed`); toast.info(`Theme "${forcedTheme?.name ?? 'Admin theme'}" is forced by admin and cannot be changed`);
@@ -61,6 +76,9 @@ export function ThemesSettings() {
<ThemeCard <ThemeCard
name="Default" name="Default"
author="Bulwark" author="Bulwark"
isDefaultTheme
variants={['light', 'dark']}
isDark={isDark}
isActive={activeThemeId === null} isActive={activeThemeId === null}
isBuiltIn isBuiltIn
isDefault={!themePolicy.defaultThemeId} isDefault={!themePolicy.defaultThemeId}
@@ -77,6 +95,8 @@ export function ThemesSettings() {
name={theme.name} name={theme.name}
author={theme.author} author={theme.author}
preview={theme.preview} preview={theme.preview}
css={theme.css}
isDark={isDark}
isActive={activeThemeId === theme.id} isActive={activeThemeId === theme.id}
isBuiltIn={theme.builtIn} isBuiltIn={theme.builtIn}
isDefault={themePolicy.defaultThemeId === theme.id} isDefault={themePolicy.defaultThemeId === theme.id}
@@ -98,6 +118,9 @@ interface ThemeCardProps {
name: string; name: string;
author: string; author: string;
preview?: string; preview?: string;
css?: string;
isDark?: boolean;
isDefaultTheme?: boolean;
isActive: boolean; isActive: boolean;
isBuiltIn: boolean; isBuiltIn: boolean;
isDefault?: boolean; isDefault?: boolean;
@@ -107,7 +130,8 @@ interface ThemeCardProps {
onActivate: () => void; onActivate: () => void;
} }
function ThemeCard({ name, author, preview, isActive, isDefault, isForceEnabled, disabled, variants, onActivate }: ThemeCardProps) { function ThemeCard({ name, author, preview, css, isDark, isDefaultTheme, isActive, isDefault, isForceEnabled, disabled, variants, onActivate }: ThemeCardProps) {
const colors = resolveThemeColors({ css, variants, isDark: !!isDark, isDefaultTheme: !!isDefaultTheme });
return ( return (
<div data-search-label={name} className="relative"> <div data-search-label={name} className="relative">
<button <button
@@ -122,12 +146,13 @@ function ThemeCard({ name, author, preview, isActive, isDefault, isForceEnabled,
disabled && !isActive && 'hover:border-border' disabled && !isActive && 'hover:border-border'
)} )}
> >
{/* Preview / Placeholder */} {/* Preview: marketplace image when shipped, otherwise a mini app
<div className="w-full aspect-[16/10] rounded-lg mb-2 overflow-hidden bg-muted flex items-center justify-center"> mockup painted from the theme's own colour tokens. */}
<div className="w-full aspect-[16/10] rounded-lg mb-2 overflow-hidden bg-muted">
{preview ? ( {preview ? (
<img src={preview} alt={name} className="w-full h-full object-cover" /> <img src={preview} alt={name} className="w-full h-full object-cover" />
) : ( ) : (
<Palette className="w-8 h-8 text-muted-foreground/40" /> <ThemePreview colors={colors} />
)} )}
</div> </div>
@@ -162,3 +187,141 @@ function ThemeCard({ name, author, preview, isActive, isDefault, isForceEnabled,
</div> </div>
); );
} }
// ─── Theme Preview ───────────────────────────────────────────
interface ThemeColors {
background: string;
sidebar: string;
card: string;
primary: string;
primaryForeground: string;
foreground: string;
mutedForeground: string;
border: string;
}
// The built-in "Default" theme has no `css` of its own (it's the app's base
// tokens); these mirror app/globals.css so its card previews accurately.
const DEFAULT_LIGHT: ThemeColors = {
background: '#ffffff', sidebar: '#f8fafc', card: '#ffffff', primary: '#3b82f6',
primaryForeground: '#ffffff', foreground: '#0f172a', mutedForeground: '#64748b', border: '#e2e8f0',
};
const DEFAULT_DARK: ThemeColors = {
background: '#0a0a0a', sidebar: '#0a0a0a', card: '#141414', primary: '#fafafa',
primaryForeground: '#171717', foreground: '#fafafa', mutedForeground: '#a3a3a3', border: '#262626',
};
// Pull a handful of structural colour tokens out of a theme's compiled CSS.
// Themes declare light tokens under `:root { … }` and dark under `.dark { … }`;
// neither block nests braces, so a non-greedy capture is enough.
function extractThemeColors(css: string, dark: boolean): ThemeColors | null {
const block = css.match(dark ? /\.dark\s*\{([\s\S]*?)\}/ : /:root\s*\{([\s\S]*?)\}/);
if (!block) return null;
const body = block[1];
const get = (name: string): string | undefined => {
const m = body.match(new RegExp(`--color-${name}\\s*:\\s*([^;]+);`));
return m ? m[1].trim() : undefined;
};
const background = get('background');
if (!background) return null;
return {
background,
sidebar: get('sidebar') ?? background,
card: get('card') ?? background,
primary: get('primary') ?? '#888888',
primaryForeground: get('primary-foreground') ?? '#ffffff',
foreground: get('foreground') ?? '#000000',
mutedForeground: get('muted-foreground') ?? '#888888',
border: get('border') ?? 'rgba(128,128,128,0.3)',
};
}
function resolveThemeColors({ css, variants, isDark, isDefaultTheme }: {
css?: string;
variants?: ('light' | 'dark')[];
isDark: boolean;
isDefaultTheme: boolean;
}): ThemeColors {
// Prefer the variant matching the app's current mode; fall back to the one
// the theme actually ships if it's single-variant.
const hasLight = !variants || variants.includes('light');
const hasDark = !variants || variants.includes('dark');
const wantDark = (isDark && hasDark) || !hasLight;
if (isDefaultTheme || !css) return wantDark ? DEFAULT_DARK : DEFAULT_LIGHT;
return (
extractThemeColors(css, wantDark) ??
extractThemeColors(css, !wantDark) ??
(wantDark ? DEFAULT_DARK : DEFAULT_LIGHT)
);
}
// A miniature of the real three-pane mailbox - icon nav rail, folder sidebar,
// message list (first row selected) and reading pane - painted with the
// theme's own colours, standing in for a screenshot.
function ThemePreview({ colors }: { colors: ThemeColors }) {
const rail = `1px solid ${colors.border}`;
return (
<div className="w-full h-full flex overflow-hidden" style={{ backgroundColor: colors.background }}>
{/* Icon nav rail */}
<div
className="flex flex-col items-center gap-1 py-1.5 shrink-0"
style={{ width: '9%', backgroundColor: colors.sidebar, borderRight: rail }}
>
<div className="w-1.5 h-1.5 rounded-sm" style={{ backgroundColor: colors.primary }} />
{[0, 1, 2].map((i) => (
<div key={i} className="w-1.5 h-1.5 rounded-sm" style={{ backgroundColor: colors.mutedForeground, opacity: 0.4 }} />
))}
</div>
{/* Folder sidebar */}
<div
className="flex flex-col gap-1 p-1.5 shrink-0"
style={{ width: '25%', backgroundColor: colors.sidebar, borderRight: rail }}
>
<div className="flex items-center gap-1 mb-0.5">
<div className="w-2 h-2 rounded-full shrink-0" style={{ backgroundColor: colors.mutedForeground, opacity: 0.5 }} />
<div className="h-1 rounded-full flex-1" style={{ backgroundColor: colors.foreground, opacity: 0.55 }} />
</div>
{[0.8, 0.65, 0.72, 0.5].map((w, i) => (
<div key={i} className="flex items-center gap-1">
<div className="w-1 h-1 rounded-sm shrink-0" style={{ backgroundColor: colors.mutedForeground, opacity: 0.45 }} />
<div className="h-1 rounded-full" style={{ width: `${w * 100}%`, backgroundColor: colors.mutedForeground, opacity: 0.4 }} />
</div>
))}
</div>
{/* Message list */}
<div className="flex flex-col shrink-0" style={{ width: '36%', backgroundColor: colors.background, borderRight: rail }}>
{/* Selected row */}
<div className="flex items-start gap-1 p-1" style={{ backgroundColor: colors.primary }}>
<div className="w-2 h-2 rounded-full shrink-0" style={{ backgroundColor: colors.primaryForeground, opacity: 0.85 }} />
<div className="flex-1 flex flex-col gap-0.5">
<div className="h-1 rounded-full" style={{ width: '70%', backgroundColor: colors.primaryForeground, opacity: 0.9 }} />
<div className="h-1 rounded-full" style={{ width: '90%', backgroundColor: colors.primaryForeground, opacity: 0.55 }} />
</div>
</div>
{/* Unread + read rows */}
{[0.6, 0.7, 0.55].map((w, i) => (
<div key={i} className="flex items-start gap-1 p-1" style={{ borderTop: rail }}>
<div className="w-2 h-2 rounded-full shrink-0" style={{ backgroundColor: colors.mutedForeground, opacity: 0.4 }} />
<div className="flex-1 flex flex-col gap-0.5">
<div className="h-1 rounded-full" style={{ width: `${w * 100}%`, backgroundColor: colors.foreground, opacity: i === 0 ? 0.7 : 0.5 }} />
<div className="h-1 rounded-full" style={{ width: '85%', backgroundColor: colors.mutedForeground, opacity: 0.35 }} />
</div>
</div>
))}
</div>
{/* Reading pane */}
<div className="flex-1 flex flex-col gap-1 p-1.5 min-w-0">
<div className="h-1.5 rounded-full" style={{ width: '70%', backgroundColor: colors.foreground, opacity: 0.85 }} />
<div className="h-1 rounded-full mb-1" style={{ width: '40%', backgroundColor: colors.mutedForeground, opacity: 0.5 }} />
{[0.95, 0.85, 0.9, 0.6].map((w, i) => (
<div key={i} className="h-1 rounded-full" style={{ width: `${w * 100}%`, backgroundColor: colors.mutedForeground, opacity: 0.3 }} />
))}
</div>
</div>
);
}
+9
View File
@@ -270,6 +270,15 @@ export function Avatar({ name, email, contactPhotoUri, size = "md", className, d
alt="" alt=""
className="w-full h-full object-cover" className="w-full h-full object-cover"
onError={handleImgError} onError={handleImgError}
// /api/favicon returns a 1x1 transparent PNG (HTTP 200) when no real
// favicon exists, to avoid spamming the DevTools console with 404s.
// Detect that sentinel by naturalWidth and fall back to initials.
onLoad={(e) => {
const img = e.currentTarget;
if (isFavicon && img.naturalWidth <= 1) {
handleImgError();
}
}}
/> />
) : ( ) : (
getInitials() getInitials()
+12
View File
@@ -98,6 +98,17 @@ export function FlagDE(props: FlagProps) {
); );
} }
/** Hungary Red, White, Green horizontal */
export function FlagHU(props: FlagProps) {
return (
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 6 3" width={W} height={H} className={flagClass} {...props}>
<rect width="6" height="1" fill="#CD2A3E" />
<rect y="1" width="6" height="1" fill="#fff" />
<rect y="2" width="6" height="1" fill="#436F4D" />
</svg>
);
}
/** Latvia Maroon, White, Maroon horizontal */ /** Latvia Maroon, White, Maroon horizontal */
export function FlagLV(props: FlagProps) { export function FlagLV(props: FlagProps) {
return ( return (
@@ -219,6 +230,7 @@ export const flagComponents: Record<string, (props: FlagProps) => ReactElement>
en: FlagGB, en: FlagGB,
es: FlagES, es: FlagES,
fr: FlagFR, fr: FlagFR,
hu: FlagHU,
it: FlagIT, it: FlagIT,
ja: FlagJP, ja: FlagJP,
ko: FlagKR, ko: FlagKR,
+1
View File
@@ -15,6 +15,7 @@ const languages = [
{ value: 'es', label: 'Español' }, { value: 'es', label: 'Español' },
{ value: 'fr', label: 'Français' }, { value: 'fr', label: 'Français' },
{ value: 'it', label: 'Italiano' }, { value: 'it', label: 'Italiano' },
{ value: 'hu', label: 'Magyar' },
{ value: 'lv', label: 'Latviešu' }, { value: 'lv', label: 'Latviešu' },
{ value: 'nl', label: 'Nederlands' }, { value: 'nl', label: 'Nederlands' },
{ value: 'pl', label: 'Polski' }, { value: 'pl', label: 'Polski' },
+5 -1
View File
@@ -173,7 +173,11 @@ export function useMailboxDrop({ mailbox, onDropComplete, onSuccess, onError }:
// import call to target the owner's JMAP account. // import call to target the owner's JMAP account.
const jmapDestId = mailbox.originalId || mailbox.id; const jmapDestId = mailbox.originalId || mailbox.id;
const destJmapOverride = mailbox.isShared ? mailbox.accountId : undefined; const destJmapOverride = mailbox.isShared ? mailbox.accountId : undefined;
await crossAccountMoveEmails(bySource, destAccountId, jmapDestId, destJmapOverride); // Mirror image for the source: a shared group mailbox is accessed
// through the viewing user's client, but the email/blob live in the
// owner's JMAP account, so the copy/delete must target it.
const sourceJmapOverride = sourceMb?.isShared ? sourceMb.accountId : undefined;
await crossAccountMoveEmails(bySource, destAccountId, jmapDestId, destJmapOverride, sourceJmapOverride);
} else { } else {
// Single-account or same-account-shared move: bulk JMAP request. // Single-account or same-account-shared move: bulk JMAP request.
await moveEmailsToMailbox(client, emailIds, mailbox.id); await moveEmailsToMailbox(client, emailIds, mailbox.id);
+1 -1
View File
@@ -12,7 +12,7 @@ const localePrefix = (process.env.NEXT_PUBLIC_LOCALE_PREFIX ?? 'never') as
| 'always' | 'always'
| 'as-needed'; | 'as-needed';
const SUPPORTED_LOCALES = ['cs', 'da', 'de', 'en', 'es', 'fr', 'it', 'ja', 'ko', 'lv', 'nl', 'pl', 'pt', 'ru', 'tr', 'uk', 'zh'] as const; const SUPPORTED_LOCALES = ['cs', 'da', 'de', 'en', 'es', 'fr', 'hu', 'it', 'ja', 'ko', 'lv', 'nl', 'pl', 'pt', 'ru', 'tr', 'uk', 'zh'] as const;
// Fallback locale used when the visitor's Accept-Language header does not // Fallback locale used when the visitor's Accept-Language header does not
// match any supported locale (and no NEXT_LOCALE cookie is set yet). Admins // match any supported locale (and no NEXT_LOCALE cookie is set yet). Admins
+74
View File
@@ -0,0 +1,74 @@
import { describe, it, expect, vi, afterEach } from 'vitest';
// `browser-navigation` reads NEXT_PUBLIC_BASE_PATH into a module-load constant
// (mirroring how next.config.ts bakes basePath in at build time), so each case
// has to re-import the module under a fresh env. These tests pin down the
// promise made to subpath deployments: the built-in branding *defaults*
// (FAVICON_URL, LOGIN_LOGO_*) — not just admin-set custom values — get the
// mount prefix, because every consumer wraps them in withBasePath(). See #330.
async function loadNav(basePath?: string) {
vi.resetModules();
if (basePath === undefined) {
delete process.env.NEXT_PUBLIC_BASE_PATH;
} else {
process.env.NEXT_PUBLIC_BASE_PATH = basePath;
}
return import('@/lib/browser-navigation');
}
// The fallback values from CONFIG_ENV_MAP that ship in the box.
const DEFAULT_FAVICON = '/branding/Bulwark_Favicon.svg';
const DEFAULT_LOGIN_LOGO_LIGHT = '/branding/Bulwark_Logo_Color.svg';
const DEFAULT_LOGIN_LOGO_DARK = '/branding/Bulwark_Logo_White.svg';
afterEach(() => {
delete process.env.NEXT_PUBLIC_BASE_PATH;
vi.resetModules();
});
describe('withBasePath — asset-URL fallbacks under a subpath', () => {
it('leaves branding defaults untouched when no base path is set', async () => {
const { withBasePath } = await loadNav(undefined);
// jsdom's window.location.pathname is "/", so runtime detection finds no
// prefix either — the default paths pass through unchanged.
expect(withBasePath(DEFAULT_FAVICON)).toBe(DEFAULT_FAVICON);
expect(withBasePath(DEFAULT_LOGIN_LOGO_LIGHT)).toBe(DEFAULT_LOGIN_LOGO_LIGHT);
expect(withBasePath(DEFAULT_LOGIN_LOGO_DARK)).toBe(DEFAULT_LOGIN_LOGO_DARK);
});
it('prefixes the built-in favicon and login-logo defaults', async () => {
const { withBasePath } = await loadNav('/webmail');
expect(withBasePath(DEFAULT_FAVICON)).toBe('/webmail/branding/Bulwark_Favicon.svg');
expect(withBasePath(DEFAULT_LOGIN_LOGO_LIGHT)).toBe('/webmail/branding/Bulwark_Logo_Color.svg');
expect(withBasePath(DEFAULT_LOGIN_LOGO_DARK)).toBe('/webmail/branding/Bulwark_Logo_White.svg');
});
it('is idempotent for an already-prefixed value (no double prefix)', async () => {
const { withBasePath } = await loadNav('/webmail');
expect(withBasePath('/webmail/branding/Bulwark_Favicon.svg')).toBe('/webmail/branding/Bulwark_Favicon.svg');
expect(withBasePath('/webmail')).toBe('/webmail');
});
it('passes through external, protocol-relative, data, and empty values', async () => {
const { withBasePath } = await loadNav('/webmail');
expect(withBasePath('https://cdn.example.com/logo.svg')).toBe('https://cdn.example.com/logo.svg');
expect(withBasePath('//cdn.example.com/logo.svg')).toBe('//cdn.example.com/logo.svg');
expect(withBasePath('data:image/svg+xml,<svg/>')).toBe('data:image/svg+xml,<svg/>');
expect(withBasePath('')).toBe('');
expect(withBasePath(null)).toBe('');
expect(withBasePath(undefined)).toBe('');
});
it('strips a trailing slash on the configured base path', async () => {
const { withBasePath, getPathPrefix } = await loadNav('/webmail/');
expect(getPathPrefix()).toBe('/webmail');
expect(withBasePath(DEFAULT_FAVICON)).toBe('/webmail/branding/Bulwark_Favicon.svg');
});
it('getPathPrefix reports the static base path on the server (no window needed)', async () => {
const { getPathPrefix } = await loadNav('/webmail');
// SSR consumers (layout.tsx favicon metadata, manifest.ts) rely on this
// resolving from the build-time constant rather than window.location.
expect(getPathPrefix()).toBe('/webmail');
});
});
+4 -2
View File
@@ -2,8 +2,8 @@ import { describe, it, expect } from 'vitest';
import { BUILTIN_THEMES } from '../builtin-themes'; import { BUILTIN_THEMES } from '../builtin-themes';
describe('BUILTIN_THEMES', () => { describe('BUILTIN_THEMES', () => {
it('contains exactly 3 themes', () => { it('contains exactly 6 themes', () => {
expect(BUILTIN_THEMES).toHaveLength(3); expect(BUILTIN_THEMES).toHaveLength(6);
}); });
it('all themes have required fields', () => { it('all themes have required fields', () => {
@@ -43,6 +43,8 @@ describe('BUILTIN_THEMES', () => {
expect(names).toContain('Nord'); expect(names).toContain('Nord');
expect(names).toContain('Catppuccin'); expect(names).toContain('Catppuccin');
expect(names).toContain('Solarized'); expect(names).toContain('Solarized');
expect(names).toContain('Roundcube Elastic');
expect(names).toContain('Aurora Glass');
}); });
it('theme IDs are unique', () => { it('theme IDs are unique', () => {
+61
View File
@@ -7,6 +7,7 @@ import {
getSecurityStatus, getSecurityStatus,
parseSpamLLM, parseSpamLLM,
extractListHeaders, extractListHeaders,
isAuthenticationSpoofed,
} from '../email-headers'; } from '../email-headers';
describe('parseAuthenticationResults', () => { describe('parseAuthenticationResults', () => {
@@ -52,6 +53,66 @@ describe('parseAuthenticationResults', () => {
const result = parseAuthenticationResults('spf=softfail smtp.mailfrom=example.com'); const result = parseAuthenticationResults('spf=softfail smtp.mailfrom=example.com');
expect(result.spf?.result).toBe('softfail'); expect(result.spf?.result).toBe('softfail');
}); });
it('surfaces the most severe result when multiple SPF identities exist', () => {
// HELO temperror, MAIL FROM fail — the harder fail must be the headline.
const header =
'mx.example.com; spf=temperror (mx: dns timeout) smtp.helo=mail.spoof.com; spf=fail (mx: not authorized) smtp.mailfrom=victim.com';
const result = parseAuthenticationResults(header);
expect(result.spf?.result).toBe('fail');
expect(result.spf?.domain).toBe('victim.com');
});
it('exposes all SPF results when more than one identity is evaluated', () => {
const header =
'spf=temperror smtp.helo=mail.spoof.com; spf=fail smtp.mailfrom=victim.com';
const result = parseAuthenticationResults(header);
expect(result.spf?.all).toEqual([
{ result: 'temperror', identity: 'helo', domain: 'mail.spoof.com' },
{ result: 'fail', identity: 'mailfrom', domain: 'victim.com' },
]);
});
it('does not set `all` for a single SPF result', () => {
const result = parseAuthenticationResults('spf=pass smtp.mailfrom=example.com');
expect(result.spf?.all).toBeUndefined();
});
it('prefers the MAIL FROM identity when severities tie', () => {
const header = 'spf=pass smtp.helo=mail.example.com; spf=pass smtp.mailfrom=example.com';
const result = parseAuthenticationResults(header);
expect(result.spf?.domain).toBe('example.com');
});
});
describe('isAuthenticationSpoofed', () => {
it('returns false when no auth results are present', () => {
expect(isAuthenticationSpoofed(undefined)).toBe(false);
expect(isAuthenticationSpoofed({})).toBe(false);
});
it('flags a DMARC fail as spoofed', () => {
expect(isAuthenticationSpoofed({ dmarc: { result: 'fail' } })).toBe(true);
});
it('flags a hard SPF fail without a passing DKIM as spoofed', () => {
expect(isAuthenticationSpoofed({ spf: { result: 'fail' } })).toBe(true);
expect(
isAuthenticationSpoofed({ spf: { result: 'fail' }, dkim: { result: 'fail' } })
).toBe(true);
});
it('does not flag an SPF fail rescued by a passing DKIM', () => {
expect(
isAuthenticationSpoofed({ spf: { result: 'fail' }, dkim: { result: 'pass' } })
).toBe(false);
});
it('does not flag passing or ambiguous results', () => {
expect(isAuthenticationSpoofed({ spf: { result: 'pass' }, dmarc: { result: 'pass' } })).toBe(false);
expect(isAuthenticationSpoofed({ spf: { result: 'softfail' } })).toBe(false);
expect(isAuthenticationSpoofed({ spf: { result: 'temperror' } })).toBe(false);
});
}); });
describe('parseSpamScore', () => { describe('parseSpamScore', () => {
+43
View File
@@ -0,0 +1,43 @@
import { describe, it, expect, afterEach } from 'vitest';
import { snapshotHostTheme, themeSnapshotToCSS, type ThemeSnapshot } from '../plugin-sandbox/host-theme';
describe('host-theme', () => {
describe('themeSnapshotToCSS', () => {
it('emits the token values, font, and color-scheme', () => {
const snapshot: ThemeSnapshot = {
dark: false,
fontFamily: 'Inter, sans-serif',
vars: { '--color-background': '#ffffff', '--color-foreground': '#0f172a' },
};
const css = themeSnapshotToCSS(snapshot);
expect(css).toContain('--color-background: #ffffff;');
expect(css).toContain('--color-foreground: #0f172a;');
expect(css).toContain('font-family: Inter, sans-serif;');
expect(css).toContain('color-scheme: light;');
// Body inherits the theme foreground so unstyled plugin text adapts.
expect(css).toContain('color: var(--color-foreground, inherit);');
expect(css).toContain('background: transparent;');
});
it('reports a dark color-scheme when dark', () => {
const css = themeSnapshotToCSS({ dark: true, fontFamily: 'sans-serif', vars: {} });
expect(css).toContain('color-scheme: dark;');
});
});
describe('snapshotHostTheme', () => {
afterEach(() => {
document.documentElement.classList.remove('dark');
document.documentElement.removeAttribute('style');
});
it('reads the dark flag and declared tokens off <html>', () => {
document.documentElement.classList.add('dark');
document.documentElement.style.setProperty('--color-background', '#0a0a0a');
const snapshot = snapshotHostTheme();
expect(snapshot.dark).toBe(true);
expect(snapshot.vars['--color-background']).toBe('#0a0a0a');
expect(snapshot.fontFamily).toBeTruthy();
});
});
});
+101
View File
@@ -0,0 +1,101 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import type { OAuthMetadata } from '../oauth/discovery';
// Capture status/headers from NextResponse.json (the shared config-route mock
// drops them, but this route's behavior depends on the status code).
vi.mock('next/server', () => ({
NextResponse: {
json: (data: unknown, init?: { status?: number; headers?: Record<string, string> }) => ({
json: async () => data,
status: init?.status ?? 200,
headers: init?.headers ?? {},
}),
},
}));
vi.mock('@/lib/logger', () => ({
logger: { warn: vi.fn(), error: vi.fn(), debug: vi.fn() },
}));
vi.mock('@/lib/admin/config-manager', () => ({
configManager: { ensureLoaded: vi.fn().mockResolvedValue(undefined) },
}));
const getMetadata = vi.fn();
const getRequiredConfig = vi.fn();
vi.mock('@/lib/oauth/token-exchange', () => ({
getMetadata: (...args: unknown[]) => getMetadata(...args),
getRequiredConfig: (...args: unknown[]) => getRequiredConfig(...args),
}));
const VALID_METADATA: OAuthMetadata = {
issuer: 'https://auth.example.com',
authorization_endpoint: 'https://auth.example.com/authorize',
token_endpoint: 'https://auth.example.com/token',
};
function mockRequest(serverId?: string): unknown {
return {
nextUrl: {
searchParams: { get: (k: string) => (k === 'server_id' ? serverId ?? null : null) },
},
};
}
async function callRoute(serverId?: string) {
const { GET } = await import('@/app/api/auth/oauth/metadata/route');
const res = (await GET(mockRequest(serverId) as Parameters<typeof GET>[0])) as unknown as {
status: number;
headers: Record<string, string>;
json: () => Promise<Record<string, unknown>>;
};
return { status: res.status, headers: res.headers, body: await res.json() };
}
describe('oauth metadata route', () => {
beforeEach(() => {
vi.clearAllMocks();
getRequiredConfig.mockReturnValue({ discoveryUrl: 'https://auth.example.com' });
});
it('returns discovered metadata for the resolved server', async () => {
getMetadata.mockResolvedValue(VALID_METADATA);
const { status, body, headers } = await callRoute('server-1');
expect(status).toBe(200);
expect(body).toEqual(VALID_METADATA);
expect(headers['Cache-Control']).toContain('max-age=600');
expect(getMetadata).toHaveBeenCalledWith('server-1');
});
it('returns 404 when OAuth is not configured', async () => {
getRequiredConfig.mockImplementation(() => {
throw new Error('OAuth misconfigured: OAUTH_CLIENT_ID not set');
});
const { status, body } = await callRoute();
expect(status).toBe(404);
expect(body).toEqual({ error: 'OAuth not configured' });
expect(getMetadata).not.toHaveBeenCalled();
});
it('returns 502 when discovery yields no usable endpoints', async () => {
getMetadata.mockResolvedValue(null);
const { status, body } = await callRoute();
expect(status).toBe(502);
expect(body).toEqual({ error: 'OAuth discovery failed' });
});
it('returns 502 when discovery throws', async () => {
getMetadata.mockRejectedValue(new Error('network down'));
const { status, body } = await callRoute();
expect(status).toBe(502);
expect(body).toEqual({ error: 'OAuth discovery failed' });
});
});
@@ -0,0 +1,77 @@
import { describe, it, expect } from 'vitest';
import type { Calendar } from '@/lib/jmap/types';
import {
CALENDAR_COLORS,
sharedCalendarColorKey,
pickUnusedCalendarColor,
} from '../shared-calendar-colors';
function makeCal(overrides: Partial<Calendar>): Calendar {
return {
id: 'cal-1',
name: 'Cal',
description: null,
color: null,
sortOrder: 0,
isSubscribed: true,
isVisible: true,
isDefault: false,
includeInAvailability: 'all',
defaultAlertsWithTime: null,
defaultAlertsWithoutTime: null,
timeZone: null,
shareWith: null,
myRights: {} as Calendar['myRights'],
...overrides,
};
}
describe('sharedCalendarColorKey', () => {
it('is built from local account, JMAP account, and the original id', () => {
const cal = makeCal({
id: 'acct-9:cal-7',
originalId: 'cal-7',
accountId: 'acct-9',
localAccountId: 'slot-2',
});
expect(sharedCalendarColorKey(cal)).toBe('slot-2|acct-9|cal-7');
});
it('is stable regardless of the Pro-shell id prefix', () => {
// Same underlying calendar, shown once under the active account (bare id)
// and once cross-account (prefixed id) - both must map to one key.
const active = makeCal({ id: 'acct-9:cal-7', originalId: 'cal-7', accountId: 'acct-9', localAccountId: 'slot-2' });
const prefixed = makeCal({ id: 'slot-2::acct-9:cal-7', originalId: 'cal-7', accountId: 'acct-9', localAccountId: 'slot-2' });
expect(sharedCalendarColorKey(active)).toBe(sharedCalendarColorKey(prefixed));
});
it('falls back to the id when originalId is absent', () => {
const cal = makeCal({ id: 'cal-7', accountId: 'acct-9' });
expect(sharedCalendarColorKey(cal)).toBe('|acct-9|cal-7');
});
});
describe('pickUnusedCalendarColor', () => {
it('returns a palette color not present in usedColors', () => {
const used = CALENDAR_COLORS.slice(0, CALENDAR_COLORS.length - 1);
const picked = pickUnusedCalendarColor(used);
expect(picked).toBe(CALENDAR_COLORS[CALENDAR_COLORS.length - 1]);
});
it('ignores case when comparing used colors', () => {
const used = CALENDAR_COLORS.slice(0, -1).map((c) => c.toUpperCase());
expect(pickUnusedCalendarColor(used)).toBe(CALENDAR_COLORS[CALENDAR_COLORS.length - 1]);
});
it('still returns a palette color once every color is taken', () => {
expect(CALENDAR_COLORS).toContain(pickUnusedCalendarColor(CALENDAR_COLORS));
});
it('always returns a valid palette color for a small used set', () => {
for (let i = 0; i < 50; i++) {
const picked = pickUnusedCalendarColor(['#3b82f6']);
expect(CALENDAR_COLORS).toContain(picked);
expect(picked).not.toBe('#3b82f6');
}
});
});
+22
View File
@@ -1,6 +1,7 @@
import { describe, expect, it } from 'vitest'; import { describe, expect, it } from 'vitest';
import { import {
appendHtmlSignature,
appendPlainTextSignature, appendPlainTextSignature,
getPlainTextSignature, getPlainTextSignature,
hasMeaningfulHtmlBody, hasMeaningfulHtmlBody,
@@ -27,6 +28,27 @@ describe('signature-utils', () => {
}); });
}); });
describe('appendHtmlSignature', () => {
it('appends a sanitized html signature, preserving formatting', () => {
expect(appendHtmlSignature('<div>Hello</div>', { htmlSignature: '<strong>Alice</strong>' }))
.toBe('<div>Hello</div><br><br>-- <br><strong>Alice</strong>');
});
it('escapes and appends a text signature when no html signature exists', () => {
expect(appendHtmlSignature('<div>Hello</div>', { textSignature: 'Alice\nEng' }))
.toBe('<div>Hello</div><br><br>-- <br>Alice<br>Eng');
});
it('omits the separator marker when disabled', () => {
expect(appendHtmlSignature('<div>Hi</div>', { htmlSignature: '<strong>A</strong>' }, { separator: false }))
.toBe('<div>Hi</div><br><br><strong>A</strong>');
});
it('leaves the body untouched when no signature exists', () => {
expect(appendHtmlSignature('<div>Hi</div>', {})).toBe('<div>Hi</div>');
});
});
describe('hasMeaningfulHtmlBody', () => { describe('hasMeaningfulHtmlBody', () => {
it('prefers html bodies that preserve signature formatting', () => { it('prefers html bodies that preserve signature formatting', () => {
expect(hasMeaningfulHtmlBody('<div>Hello</div><br><p>Alice</p>')).toBe(true); expect(hasMeaningfulHtmlBody('<div>Hello</div><br><p>Alice</p>')).toBe(true);
+4
View File
@@ -15,6 +15,8 @@ export const BRANDING_OVERRIDE_KEYS = [
'appDescription', 'appDescription',
'faviconUrl', 'faviconUrl',
'pwaIconUrl', 'pwaIconUrl',
'pwaScreenshotMobileUrl',
'pwaScreenshotDesktopUrl',
'pwaThemeColor', 'pwaThemeColor',
'pwaBackgroundColor', 'pwaBackgroundColor',
'appLogoLightUrl', 'appLogoLightUrl',
@@ -42,6 +44,8 @@ export interface DomainBrandingEntry {
appDescription?: string; appDescription?: string;
faviconUrl?: string; faviconUrl?: string;
pwaIconUrl?: string; pwaIconUrl?: string;
pwaScreenshotMobileUrl?: string;
pwaScreenshotDesktopUrl?: string;
pwaThemeColor?: string; pwaThemeColor?: string;
pwaBackgroundColor?: string; pwaBackgroundColor?: string;
appLogoLightUrl?: string; appLogoLightUrl?: string;
+1 -1
View File
@@ -98,7 +98,7 @@ async function migrateAdminJson(): Promise<boolean> {
lastLogin: data.lastLogin ?? null, lastLogin: data.lastLogin ?? null,
passwordChangedAt: data.passwordChangedAt ?? now, passwordChangedAt: data.passwordChangedAt ?? now,
}; };
const configData: AdminConfigData = { passwordHash: data.passwordHash }; const configData: AdminConfigData = { passwordHash: data.passwordHash, passwordHashFile: undefined };
await ensureStateDir(); await ensureStateDir();
const statePath = getStatePath('admin-state.json'); const statePath = getStatePath('admin-state.json');
+13 -3
View File
@@ -9,6 +9,7 @@ import {
assertWritable, assertWritable,
} from './paths'; } from './paths';
import type { AdminConfigData, AdminStateData } from './types'; import type { AdminConfigData, AdminStateData } from './types';
import { readFileEnv } from '../read-file-env';
const SCRYPT_KEYLEN = 64; const SCRYPT_KEYLEN = 64;
const SCRYPT_COST = 16384; // 2^14 const SCRYPT_COST = 16384; // 2^14
@@ -146,7 +147,7 @@ export async function initAdminPassword(): Promise<boolean> {
} }
const hash = isHashed(envPassword) ? envPassword : await hashPassword(envPassword); const hash = isHashed(envPassword) ? envPassword : await hashPassword(envPassword);
cachedConfig = { passwordHash: hash }; cachedConfig = { passwordHash: hash, passwordHashFile: undefined };
cachedState = freshState(); cachedState = freshState();
await writeConfigData(cachedConfig); await writeConfigData(cachedConfig);
await writeStateData(cachedState); await writeStateData(cachedState);
@@ -165,7 +166,16 @@ export async function initAdminPassword(): Promise<boolean> {
export async function verifyAdminPassword(password: string): Promise<boolean> { export async function verifyAdminPassword(password: string): Promise<boolean> {
if (!cachedConfig) cachedConfig = await readConfigData(); if (!cachedConfig) cachedConfig = await readConfigData();
if (!cachedConfig) return false; if (!cachedConfig) return false;
if (cachedConfig.passwordHash) {
return verifyPassword(password, cachedConfig.passwordHash); return verifyPassword(password, cachedConfig.passwordHash);
} else if (cachedConfig.passwordHashFile) {
let passwordHash = readFileEnv(cachedConfig.passwordHashFile);
if (passwordHash) {
return verifyPassword(password, passwordHash);
}
}
logger.error('The admin password hash could neither be retrieved from passwordHash nor from passwordHashFile in admin.json');
return false;
} }
/** /**
@@ -176,7 +186,7 @@ export async function changeAdminPassword(currentPassword: string, newPassword:
if (!valid) return false; if (!valid) return false;
const hash = await hashPassword(newPassword); const hash = await hashPassword(newPassword);
cachedConfig = { passwordHash: hash }; cachedConfig = { passwordHash: hash, passwordHashFile: undefined };
await writeConfigData(cachedConfig); await writeConfigData(cachedConfig);
cachedState = { cachedState = {
@@ -205,7 +215,7 @@ export async function setInitialAdminPassword(
const existing = await readConfigData(); const existing = await readConfigData();
if (existing && !options.allowOverwrite) return false; if (existing && !options.allowOverwrite) return false;
const hash = await hashPassword(newPassword); const hash = await hashPassword(newPassword);
cachedConfig = { passwordHash: hash }; cachedConfig = { passwordHash: hash, passwordHashFile: undefined };
cachedState = freshState(); cachedState = freshState();
await writeConfigData(cachedConfig); await writeConfigData(cachedConfig);
await writeStateData(cachedState); await writeStateData(cachedState);
+7
View File
@@ -53,6 +53,13 @@ export interface ServerPlugin {
forceEnabled?: boolean; forceEnabled?: boolean;
configSchema?: Record<string, PluginConfigField>; configSchema?: Record<string, PluginConfigField>;
settingsSchema?: Record<string, PluginSettingsField>; settingsSchema?: Record<string, PluginSettingsField>;
/**
* Optional per-locale translation tables (locale -> key -> string) declared
* in the plugin manifest. Surfaced to the sandbox so plugin code can call
* `api.i18n.t(key)`; without it a plugin's strings stay in its hardcoded
* default language.
*/
locales?: Record<string, Record<string, string>>;
installedAt: string; installedAt: string;
updatedAt: string; updatedAt: string;
/** /**
+5 -1
View File
@@ -6,7 +6,8 @@
* is config; mutable timestamps live in AdminStateData. * is config; mutable timestamps live in AdminStateData.
*/ */
export interface AdminConfigData { export interface AdminConfigData {
passwordHash: string; passwordHash: string | undefined;
passwordHashFile: string | undefined;
} }
/** /**
@@ -136,12 +137,15 @@ export const CONFIG_ENV_MAP: Record<string, { envVar: string; fileEnvVar?: strin
appName: { envVar: 'APP_NAME', type: 'string', defaultValue: 'Webmail' }, appName: { envVar: 'APP_NAME', type: 'string', defaultValue: 'Webmail' },
appShortName: { envVar: 'APP_SHORT_NAME', type: 'string', defaultValue: '' }, appShortName: { envVar: 'APP_SHORT_NAME', type: 'string', defaultValue: '' },
appDescription: { envVar: 'APP_DESCRIPTION', type: 'string', defaultValue: '' }, appDescription: { envVar: 'APP_DESCRIPTION', type: 'string', defaultValue: '' },
searchEngineIndexing: { envVar: 'SEARCH_ENGINE_INDEXING', type: 'boolean', defaultValue: false },
jmapServerUrl: { envVar: 'JMAP_SERVER_URL', type: 'url', defaultValue: '' }, jmapServerUrl: { envVar: 'JMAP_SERVER_URL', type: 'url', defaultValue: '' },
stalwartFeaturesEnabled: { envVar: 'STALWART_FEATURES', type: 'boolean', defaultValue: true }, stalwartFeaturesEnabled: { envVar: 'STALWART_FEATURES', type: 'boolean', defaultValue: true },
demoMode: { envVar: 'DEMO_MODE', type: 'boolean', defaultValue: false }, demoMode: { envVar: 'DEMO_MODE', type: 'boolean', defaultValue: false },
devMode: { envVar: 'DEV_MOCK_JMAP', type: 'boolean', defaultValue: false }, devMode: { envVar: 'DEV_MOCK_JMAP', type: 'boolean', defaultValue: false },
faviconUrl: { envVar: 'FAVICON_URL', type: 'url', defaultValue: '/branding/Bulwark_Favicon.svg' }, faviconUrl: { envVar: 'FAVICON_URL', type: 'url', defaultValue: '/branding/Bulwark_Favicon.svg' },
pwaIconUrl: { envVar: 'PWA_ICON_URL', type: 'url', defaultValue: '' }, pwaIconUrl: { envVar: 'PWA_ICON_URL', type: 'url', defaultValue: '' },
pwaScreenshotMobileUrl: { envVar: 'PWA_SCREENSHOT_MOBILE_URL', type: 'url', defaultValue: '' },
pwaScreenshotDesktopUrl: { envVar: 'PWA_SCREENSHOT_DESKTOP_URL', type: 'url', defaultValue: '' },
pwaThemeColor: { envVar: 'PWA_THEME_COLOR', type: 'string', defaultValue: '#ffffff' }, pwaThemeColor: { envVar: 'PWA_THEME_COLOR', type: 'string', defaultValue: '#ffffff' },
pwaBackgroundColor: { envVar: 'PWA_BACKGROUND_COLOR', type: 'string', defaultValue: '#ffffff' }, pwaBackgroundColor: { envVar: 'PWA_BACKGROUND_COLOR', type: 'string', defaultValue: '#ffffff' },
appLogoLightUrl: { envVar: 'APP_LOGO_LIGHT_URL', type: 'url', defaultValue: '' }, appLogoLightUrl: { envVar: 'APP_LOGO_LIGHT_URL', type: 'url', defaultValue: '' },
+6 -1
View File
@@ -23,7 +23,12 @@ export function getSessionSecret(): string {
if (fromFile) return fromFile; if (fromFile) return fromFile;
const fromAdmin = configManager.get<string>('sessionSecret', ''); const fromAdmin = configManager.get<string>('sessionSecret', '');
return fromAdmin || ''; if (fromAdmin) return fromAdmin;
const fromAdminFile = readFileEnv(
configManager.get<string>('sessionSecretFile', ''),
);
return fromAdminFile || "";
} }
export function hasSessionSecret(): boolean { export function hasSessionSecret(): boolean {
+572
View File
@@ -328,6 +328,552 @@ const solarizedCSS = `
--color-chart-5: #6c71c4; --color-chart-5: #6c71c4;
}`; }`;
// Roundcube "Elastic" skin recreation.
//
// Colour tokens are lifted from skins/elastic/styles/colors.less (CC BY-SA):
// accent #37beff font #27353a border #ddd
// error #ff5552 success #41b849 warning #ffd452
// task-menu #2f3a3f (always dark, both modes)
// list-select tint(#37beff, 90%) -> #ebf8ff
// Dark mode mirrors @color-dark-* (background #21292c, font #c5d1d3, …).
const elasticCSS = `
:root {
--color-border: #dddddd;
--color-input: #ced4da;
--color-ring: #37beff;
--color-background: #ffffff;
--color-foreground: #27353a;
--color-primary: #37beff;
--color-primary-foreground: #ffffff;
--color-secondary: #f4f4f4;
--color-secondary-foreground: #27353a;
--color-muted: #f4f4f4;
--color-muted-foreground: #737677;
--color-accent: #e6f6ff;
--color-accent-foreground: #0070a8;
--color-destructive: #ff5552;
--color-destructive-foreground: #ffffff;
--color-popover: #ffffff;
--color-popover-foreground: #27353a;
--color-sidebar: #ffffff;
--color-sidebar-foreground: #27353a;
--color-sidebar-border: #dddddd;
--color-sidebar-accent: #ebf8ff;
--color-sidebar-accent-foreground: #27353a;
--color-card: #ffffff;
--color-card-foreground: #27353a;
--color-success: #41b849;
--color-success-foreground: #ffffff;
--color-warning: #ffd452;
--color-warning-foreground: #27353a;
--color-info: #37beff;
--color-info-foreground: #ffffff;
--color-selection: #ebf8ff;
--color-selection-foreground: #27353a;
--color-unread: #ffd452;
--color-chart-1: #37beff;
--color-chart-2: #41b849;
--color-chart-3: #ffd452;
--color-chart-4: #ff5552;
--color-chart-5: #9b59b6;
/* Elastic is a 14px Roboto skin with tighter list rows than Bulwark's default */
--font-size-base: 14px;
--list-item-height: 40px;
}
.dark {
--color-border: #4d6066;
--color-input: #4d6066;
--color-ring: #37beff;
--color-background: #21292c;
--color-foreground: #ffffff;
--color-primary: #37beff;
--color-primary-foreground: #ffffff;
--color-secondary: #2c373a;
--color-secondary-foreground: #ffffff;
--color-muted: #2c373a;
/* Roundcube keeps most text near the bright font colour, only true hints
dim out. Bulwark applies muted-foreground far more widely, so brighten it
toward @color-dark-font (#c5d1d3) to match Elastic's overall brightness. */
--color-muted-foreground: #b3bec1;
--color-accent: #374549;
--color-accent-foreground: #37beff;
--color-destructive: #ff5552;
--color-destructive-foreground: #ffffff;
--color-popover: #161b1d;
--color-popover-foreground: #ffffff;
--color-sidebar: #21292c;
--color-sidebar-foreground: #ffffff;
--color-sidebar-border: #4d6066;
--color-sidebar-accent: #374549;
--color-sidebar-accent-foreground: #c5d1d3;
--color-card: #1a2225;
--color-card-foreground: #ffffff;
--color-success: #41b849;
--color-success-foreground: #ffffff;
--color-warning: #ffd452;
--color-warning-foreground: #21292c;
--color-info: #37beff;
--color-info-foreground: #ffffff;
--color-selection: #374549;
--color-selection-foreground: #37beff;
--color-unread: #b88a00;
--color-chart-1: #37beff;
--color-chart-2: #41b849;
--color-chart-3: #ffd452;
--color-chart-4: #ff5552;
--color-chart-5: #b48ead;
}`;
// Component-level overrides that the colour tokens alone can't express:
// Roboto typography and Elastic's signature dark "task menu" rail (which is
// dark in both light and dark mode). Scoped under the skin body attribute so
// it cleanly detaches when the theme is switched off.
const elasticSkin = `
body[data-theme-skin="builtin-roundcube-elastic"] {
font-family: Roboto, "Helvetica Neue", "Segoe UI", Arial, "Noto Sans", sans-serif;
}
/* ── Task menu (left navigation rail) ─────────────────────────── */
body[data-theme-skin="builtin-roundcube-elastic"] .w-14.bg-secondary {
background-color: #2f3a3f !important;
border-right: 1px solid rgba(0, 0, 0, 0.25) !important;
}
/* In dark mode the black hairline vanishes against the dark canvas - use a
light one so the rail still reads as a distinct column. (.dark lives on
<html>, so it must be an ancestor of the skinned <body>.) */
.dark body[data-theme-skin="builtin-roundcube-elastic"] .w-14.bg-secondary {
border-right-color: rgba(255, 255, 255, 0.1) !important;
}
/* Light icons + labels on the dark rail */
body[data-theme-skin="builtin-roundcube-elastic"] .w-14.bg-secondary a,
body[data-theme-skin="builtin-roundcube-elastic"] .w-14.bg-secondary button,
body[data-theme-skin="builtin-roundcube-elastic"] .w-14.bg-secondary .text-muted-foreground {
color: #e7edee !important;
}
/* Hover slab */
body[data-theme-skin="builtin-roundcube-elastic"] .w-14.bg-secondary a:hover,
body[data-theme-skin="builtin-roundcube-elastic"] .w-14.bg-secondary button:hover {
background-color: #41525a !important;
color: #ffffff !important;
}
/* Selected task: brighter slab, accent-blue glyph (matches Elastic) */
body[data-theme-skin="builtin-roundcube-elastic"] .w-14.bg-secondary .bg-primary\\/10 {
background-color: #41525a !important;
}
body[data-theme-skin="builtin-roundcube-elastic"] .w-14.bg-secondary .text-primary {
color: #37beff !important;
}
/* ── Flatten Bulwark's soft radii to Elastic's Bootstrap-flat look ── */
/* Elastic uses ~4px corners on buttons/inputs/cards; rounded-full (pills,
avatars, toggles) is intentionally left alone. */
body[data-theme-skin="builtin-roundcube-elastic"] .rounded-md,
body[data-theme-skin="builtin-roundcube-elastic"] .rounded-lg,
body[data-theme-skin="builtin-roundcube-elastic"] .rounded-xl,
body[data-theme-skin="builtin-roundcube-elastic"] .rounded-2xl,
body[data-theme-skin="builtin-roundcube-elastic"] .rounded-3xl,
body[data-theme-skin="builtin-roundcube-elastic"] input,
body[data-theme-skin="builtin-roundcube-elastic"] textarea,
body[data-theme-skin="builtin-roundcube-elastic"] button:not(.rounded-full) {
border-radius: 4px !important;
}
/* ── Unify panel backgrounds like Roundcube ───────────────────── */
/* In Elastic the folder list, message list and content pane all share one
canvas (white / dark); only the narrow task rail is dark. Bulwark's folder
sidebar uses \`bg-secondary\` (a grey panel), so repaint it with the main
background. The folder sidebar carries the \`border-r\` class; the dark task
rail uses \`bg-secondary\` WITHOUT it (inline-styled border), so this
qualifier leaves the rail dark. */
body[data-theme-skin="builtin-roundcube-elastic"] .bg-secondary.border-r {
background-color: var(--color-background) !important;
}
/* The empty "No conversation selected" pane uses a muted diagonal gradient;
flatten it to the plain canvas so the content area is one solid colour. */
body[data-theme-skin="builtin-roundcube-elastic"] .bg-gradient-to-br.from-muted\\/30.to-muted\\/50 {
background: var(--color-background) !important;
}
/* The message-viewer body sits on a faint muted backing (bg-muted/30); flatten
it to the plain canvas so the whole content pane - search bar, action
toolbar, mail header and body - is one uniform background colour. The
search/toolbar/header are bordered \`bg-background\` strips, so they already
match once we leave them untinted. */
body[data-theme-skin="builtin-roundcube-elastic"] .bg-muted\\/30 {
background-color: var(--color-background) !important;
}
/* ── Elastic primary buttons (.btn-primary) ──────────────────── */
/* Solid accent fill, white label + icon, hairline border, focus halo. Light
= @color-main (#37beff); dark = @color-dark-main (darken 30% -> #006a9d)
with the bright #37beff outline, exactly like the on-switch. */
body[data-theme-skin="builtin-roundcube-elastic"] .bg-primary.text-primary-foreground {
background-color: #37beff !important;
border: 1px solid #37beff !important;
color: #ffffff !important;
}
body[data-theme-skin="builtin-roundcube-elastic"] .bg-primary.text-primary-foreground:hover {
background-color: #19b6fe !important;
border-color: #0bb0ff !important;
}
body[data-theme-skin="builtin-roundcube-elastic"] .bg-primary.text-primary-foreground:focus-visible {
box-shadow: 0 0 0 0.2rem rgba(55, 190, 255, 0.3) !important;
}
.dark body[data-theme-skin="builtin-roundcube-elastic"] .bg-primary.text-primary-foreground {
background-color: #006a9d !important;
border-color: #37beff !important;
color: #ffffff !important;
}
.dark body[data-theme-skin="builtin-roundcube-elastic"] .bg-primary.text-primary-foreground:hover {
background-color: #0079b3 !important;
}
/* ── Elastic on/off switch (.custom-switch) ──────────────────── */
/* Track + knob restyled to the Bootstrap-derived Elastic switch. The toggle
is a [role="switch"] button with a single inner <span> knob. */
body[data-theme-skin="builtin-roundcube-elastic"] [role="switch"] {
background-color: #ced4da !important;
border: 1px solid #b8c0c8 !important;
}
body[data-theme-skin="builtin-roundcube-elastic"] [role="switch"] > span {
background-color: #ffffff !important;
}
body[data-theme-skin="builtin-roundcube-elastic"] [role="switch"][aria-checked="true"] {
background-color: #37beff !important;
border-color: #37beff !important;
}
.dark body[data-theme-skin="builtin-roundcube-elastic"] [role="switch"] {
background-color: #4d6066 !important;
border-color: #5d7077 !important;
}
.dark body[data-theme-skin="builtin-roundcube-elastic"] [role="switch"] > span {
background-color: #c5d1d3 !important;
}
.dark body[data-theme-skin="builtin-roundcube-elastic"] [role="switch"][aria-checked="true"] {
background-color: #006a9d !important;
border-color: #37beff !important;
}
/* ── Recipient name/email rendered as links (like Roundcube) ──── */
/* Sender + recipient triggers (RecipientPopover) sit at \`text-foreground\`
and only turn blue on hover; Elastic shows them link-blue at rest
(@color-link #00acff, brighter in dark). The three-class combo is unique
to these recipient buttons. */
body[data-theme-skin="builtin-roundcube-elastic"] button.hover\\:text-primary.hover\\:underline.cursor-pointer {
color: #00acff !important;
}
.dark body[data-theme-skin="builtin-roundcube-elastic"] button.hover\\:text-primary.hover\\:underline.cursor-pointer {
color: #37beff !important;
}
/* The sender's email address printed under the name is muted grey; Roundcube
shows it link-blue too. It's the div immediately after the name row
(.flex.items-center.flex-wrap), which the contact-card org line is not, so
this sibling selector scopes it to the sender email only. */
body[data-theme-skin="builtin-roundcube-elastic"] .flex.items-center.flex-wrap + div.text-muted-foreground.truncate {
color: #00acff !important;
}
.dark body[data-theme-skin="builtin-roundcube-elastic"] .flex.items-center.flex-wrap + div.text-muted-foreground.truncate {
color: #37beff !important;
}
/* ── Elastic context / popup menus ───────────────────────────── */
/* Roundcube highlights the hovered menu entry with a solid accent fill and
white text (@color-menu-hover-background: @color-main, @color-menu-hover:
#fff) - the same in light and dark. */
body[data-theme-skin="builtin-roundcube-elastic"] [role="menu"] [role="menuitem"]:hover,
body[data-theme-skin="builtin-roundcube-elastic"] [role="menu"] [role="menuitem"]:focus,
body[data-theme-skin="builtin-roundcube-elastic"] [role="menu"] [role="menuitem"]:focus-visible {
background-color: #37beff !important;
color: #ffffff !important;
}
/* Icons (currentColor) and muted accessories (shortcuts, submenu chevron)
follow the white text on hover. */
body[data-theme-skin="builtin-roundcube-elastic"] [role="menu"] [role="menuitem"]:hover svg,
body[data-theme-skin="builtin-roundcube-elastic"] [role="menu"] [role="menuitem"]:focus svg,
body[data-theme-skin="builtin-roundcube-elastic"] [role="menu"] [role="menuitem"]:hover .text-muted-foreground {
color: #ffffff !important;
}
/* ── Elastic folder list: unread count as a rounded pill ─────── */
/* Roundcube shows the unread count in a small grey pill on the right, and no
badge at all when a folder has nothing unread. Bulwark renders plain
"unread / total" text; the counts container is
span.ml-2.flex-shrink-0.gap-1.items-baseline holding an unread span
(.font-semibold), an optional "/" (.text-muted-foreground/60) and the total
(.text-muted-foreground). Reshape it into a pill, drop the total + slash,
and hide the whole badge when there's no unread span. */
body[data-theme-skin="builtin-roundcube-elastic"] .bg-secondary.border-r span.ml-2.flex-shrink-0.gap-1.items-baseline {
align-items: center !important;
justify-content: center;
min-width: 1.6rem;
height: 1.2rem;
padding: 0 0.45rem;
border-radius: 0.75rem;
background-color: #e4e8ea;
}
body[data-theme-skin="builtin-roundcube-elastic"] .bg-secondary.border-r span.ml-2.flex-shrink-0.gap-1.items-baseline > span.text-muted-foreground,
body[data-theme-skin="builtin-roundcube-elastic"] .bg-secondary.border-r span.ml-2.flex-shrink-0.gap-1.items-baseline > span.text-muted-foreground\\/60 {
display: none !important;
}
body[data-theme-skin="builtin-roundcube-elastic"] .bg-secondary.border-r span.ml-2.flex-shrink-0.gap-1.items-baseline > span {
color: #5e6b70 !important;
font-weight: 600 !important;
}
/* No unread span -> no badge (matches Sent/Drafts in Roundcube). */
body[data-theme-skin="builtin-roundcube-elastic"] .bg-secondary.border-r span.ml-2.flex-shrink-0.gap-1.items-baseline:not(:has(span.font-semibold)) {
display: none !important;
}
.dark body[data-theme-skin="builtin-roundcube-elastic"] .bg-secondary.border-r span.ml-2.flex-shrink-0.gap-1.items-baseline {
background-color: #3f4e55;
}
.dark body[data-theme-skin="builtin-roundcube-elastic"] .bg-secondary.border-r span.ml-2.flex-shrink-0.gap-1.items-baseline > span {
color: #c9d3d5 !important;
}
/* A slightly bolder accent bar on the selected folder, like Elastic. */
body[data-theme-skin="builtin-roundcube-elastic"] .bg-secondary.border-r .border-l-2.border-primary {
border-left-width: 3px !important;
}`;
// "Aurora Glass" - an ultra-modern glassmorphism skin. Vibrant indigo→cyan
// accents float over a fixed gradient-mesh canvas; every structural surface
// (nav rail, folder sidebar, cards, popovers, menus, dialogs, toolbars) is a
// translucent frosted pane with heavy backdrop-blur + saturation, hairline
// luminous borders and soft glow on the primary action.
//
// The colour tokens stay near-solid for legible text contrast; the frosted
// translucency + blur all live in the skin, where a single selector can pair
// an rgba background with the matching backdrop-filter.
const auroraCSS = `
:root {
--color-border: rgba(13, 18, 45, 0.10);
--color-input: rgba(13, 18, 45, 0.12);
--color-ring: #6d5cff;
--color-background: #eef1fb;
--color-foreground: #14162e;
--color-primary: #6d5cff;
--color-primary-foreground: #ffffff;
--color-secondary: #e3e7f7;
--color-secondary-foreground: #14162e;
--color-muted: #e7eaf6;
--color-muted-foreground: #5b6080;
--color-accent: #e0e7ff;
--color-accent-foreground: #4338ca;
--color-destructive: #f43f5e;
--color-destructive-foreground: #ffffff;
--color-popover: #ffffff;
--color-popover-foreground: #14162e;
--color-sidebar: #eef1fb;
--color-sidebar-foreground: #14162e;
--color-sidebar-border: rgba(13, 18, 45, 0.08);
--color-sidebar-accent: rgba(109, 92, 255, 0.10);
--color-sidebar-accent-foreground: #4338ca;
--color-card: #ffffff;
--color-card-foreground: #14162e;
--color-success: #10b981;
--color-success-foreground: #ffffff;
--color-warning: #f59e0b;
--color-warning-foreground: #14162e;
--color-info: #06b6d4;
--color-info-foreground: #ffffff;
--color-selection: rgba(109, 92, 255, 0.14);
--color-selection-foreground: #4338ca;
--color-unread: #6d5cff;
--color-chart-1: #6d5cff;
--color-chart-2: #06b6d4;
--color-chart-3: #ec4899;
--color-chart-4: #f59e0b;
--color-chart-5: #10b981;
}
.dark {
--color-border: rgba(255, 255, 255, 0.08);
--color-input: rgba(255, 255, 255, 0.10);
--color-ring: #8b7bff;
--color-background: #06070f;
--color-foreground: #eef0ff;
--color-primary: #8b7bff;
--color-primary-foreground: #ffffff;
--color-secondary: rgba(255, 255, 255, 0.06);
--color-secondary-foreground: #eef0ff;
--color-muted: rgba(255, 255, 255, 0.05);
--color-muted-foreground: #9aa0c4;
--color-accent: rgba(139, 123, 255, 0.16);
--color-accent-foreground: #c4bbff;
--color-destructive: #fb7185;
--color-destructive-foreground: #1a0b10;
--color-popover: #12132a;
--color-popover-foreground: #eef0ff;
--color-sidebar: #08091a;
--color-sidebar-foreground: #eef0ff;
--color-sidebar-border: rgba(255, 255, 255, 0.07);
--color-sidebar-accent: rgba(139, 123, 255, 0.18);
--color-sidebar-accent-foreground: #c4bbff;
--color-card: rgba(255, 255, 255, 0.04);
--color-card-foreground: #eef0ff;
--color-success: #34d399;
--color-success-foreground: #052e1f;
--color-warning: #fbbf24;
--color-warning-foreground: #1a1405;
--color-info: #22d3ee;
--color-info-foreground: #04222a;
--color-selection: rgba(139, 123, 255, 0.22);
--color-selection-foreground: #c4bbff;
--color-unread: #8b7bff;
--color-chart-1: #8b7bff;
--color-chart-2: #22d3ee;
--color-chart-3: #f472b6;
--color-chart-4: #fbbf24;
--color-chart-5: #34d399;
}`;
// Glassmorphism skin: the frosted translucency, backdrop-blur, gradient-mesh
// canvas, luminous borders and glow that the colour tokens alone can't carry.
// Scoped under the skin body attribute so it detaches cleanly on switch-off.
const auroraSkin = `
body[data-theme-skin="builtin-aurora-glass"] {
font-family: "Inter", "SF Pro Display", "Segoe UI", system-ui, -apple-system, sans-serif;
letter-spacing: -0.01em;
}
/* ── Gradient-mesh canvas ──────────────────────────────────────── */
/* A multi-stop radial mesh painted onto the app-shell root (the unique
.bg-background.overflow-hidden container) so the frosted panes have
something luminous to blur. Painting it directly on the shell - rather than
a fixed body::before with z-indexed children - avoids creating stacking
contexts on portaled popups/dialogs (Radix portals render as direct <body>
children), which would otherwise break their layering. */
body[data-theme-skin="builtin-aurora-glass"] .bg-background.overflow-hidden {
background-color: var(--color-background);
background-image:
radial-gradient(60rem 60rem at 12% -10%, rgba(109, 92, 255, 0.22), transparent 60%),
radial-gradient(50rem 50rem at 105% 5%, rgba(6, 182, 212, 0.18), transparent 55%),
radial-gradient(55rem 55rem at 80% 110%, rgba(236, 72, 153, 0.16), transparent 60%),
radial-gradient(45rem 45rem at -5% 95%, rgba(16, 185, 129, 0.12), transparent 55%);
}
.dark body[data-theme-skin="builtin-aurora-glass"] .bg-background.overflow-hidden {
background-image:
radial-gradient(60rem 60rem at 12% -10%, rgba(109, 92, 255, 0.16), transparent 58%),
radial-gradient(50rem 50rem at 105% 5%, rgba(6, 182, 212, 0.11), transparent 52%),
radial-gradient(55rem 55rem at 80% 110%, rgba(236, 72, 153, 0.10), transparent 58%),
radial-gradient(45rem 45rem at -5% 95%, rgba(16, 185, 129, 0.07), transparent 52%);
}
/* ── Pill-soft radii everywhere ───────────────────────────────── */
body[data-theme-skin="builtin-aurora-glass"] .rounded-md,
body[data-theme-skin="builtin-aurora-glass"] .rounded-lg {
border-radius: 0.85rem !important;
}
body[data-theme-skin="builtin-aurora-glass"] .rounded-xl,
body[data-theme-skin="builtin-aurora-glass"] .rounded-2xl {
border-radius: 1.25rem !important;
}
/* ── Frosted panes: the shared glass recipe ───────────────────── */
/* Nav rail, folder sidebar, content backing, cards, popovers, dialogs and
menus all share one frosted treatment - translucent fill + heavy backdrop
blur + saturation so the mesh and neighbours bleed through softly. */
body[data-theme-skin="builtin-aurora-glass"] .w-14.bg-secondary,
body[data-theme-skin="builtin-aurora-glass"] .bg-secondary.border-r {
background-color: rgba(255, 255, 255, 0.55) !important;
backdrop-filter: blur(26px) saturate(180%);
-webkit-backdrop-filter: blur(26px) saturate(180%);
border-color: rgba(13, 18, 45, 0.08) !important;
}
.dark body[data-theme-skin="builtin-aurora-glass"] .w-14.bg-secondary,
.dark body[data-theme-skin="builtin-aurora-glass"] .bg-secondary.border-r {
background-color: rgba(8, 9, 22, 0.82) !important;
border-color: rgba(255, 255, 255, 0.07) !important;
}
/* The "no conversation selected" empty pane sits inside an opaque
bg-background column, so it can't inherit the shell mesh - paint the same
gradient mesh directly onto it so the placeholder reads as glass too. */
body[data-theme-skin="builtin-aurora-glass"] .bg-gradient-to-br.from-muted\\/30.to-muted\\/50 {
background-color: var(--color-background) !important;
background-image:
radial-gradient(50rem 50rem at 15% 0%, rgba(109, 92, 255, 0.22), transparent 60%),
radial-gradient(45rem 45rem at 100% 10%, rgba(6, 182, 212, 0.18), transparent 55%),
radial-gradient(50rem 50rem at 85% 105%, rgba(236, 72, 153, 0.16), transparent 60%) !important;
}
.dark body[data-theme-skin="builtin-aurora-glass"] .bg-gradient-to-br.from-muted\\/30.to-muted\\/50 {
background-image:
radial-gradient(50rem 50rem at 15% 0%, rgba(109, 92, 255, 0.18), transparent 58%),
radial-gradient(45rem 45rem at 100% 10%, rgba(6, 182, 212, 0.12), transparent 52%),
radial-gradient(50rem 50rem at 85% 105%, rgba(236, 72, 153, 0.11), transparent 58%) !important;
}
/* The reading-pane message backing goes clear glass so the frosted panes and
gradient bleed through softly. */
body[data-theme-skin="builtin-aurora-glass"] .bg-muted\\/30 {
background: transparent !important;
}
/* Cards, popovers and dialogs: brighter frosted glass with a luminous edge. */
body[data-theme-skin="builtin-aurora-glass"] .bg-card,
body[data-theme-skin="builtin-aurora-glass"] .bg-popover,
body[data-theme-skin="builtin-aurora-glass"] [role="menu"],
body[data-theme-skin="builtin-aurora-glass"] [role="dialog"],
body[data-theme-skin="builtin-aurora-glass"] [role="listbox"] {
background-color: rgba(255, 255, 255, 0.72) !important;
backdrop-filter: blur(28px) saturate(190%);
-webkit-backdrop-filter: blur(28px) saturate(190%);
border: 1px solid rgba(255, 255, 255, 0.6) !important;
box-shadow: 0 12px 40px -12px rgba(20, 22, 46, 0.28), inset 0 1px 0 rgba(255, 255, 255, 0.7) !important;
}
.dark body[data-theme-skin="builtin-aurora-glass"] .bg-card,
.dark body[data-theme-skin="builtin-aurora-glass"] .bg-popover,
.dark body[data-theme-skin="builtin-aurora-glass"] [role="menu"],
.dark body[data-theme-skin="builtin-aurora-glass"] [role="dialog"],
.dark body[data-theme-skin="builtin-aurora-glass"] [role="listbox"] {
background-color: rgba(16, 17, 38, 0.86) !important;
border: 1px solid rgba(255, 255, 255, 0.10) !important;
box-shadow: 0 16px 48px -12px rgba(0, 0, 0, 0.6), inset 0 1px 0 rgba(255, 255, 255, 0.08) !important;
}
/* ── Primary action: gradient fill + glow ─────────────────────── */
body[data-theme-skin="builtin-aurora-glass"] .bg-primary.text-primary-foreground {
background-image: linear-gradient(135deg, #7c5cff 0%, #6d5cff 45%, #4f9bff 100%) !important;
border: none !important;
color: #ffffff !important;
box-shadow: 0 6px 20px -4px rgba(109, 92, 255, 0.55), inset 0 1px 0 rgba(255, 255, 255, 0.35) !important;
transition: box-shadow 160ms ease, filter 160ms ease;
}
body[data-theme-skin="builtin-aurora-glass"] .bg-primary.text-primary-foreground:hover {
filter: brightness(1.08);
box-shadow: 0 10px 30px -4px rgba(109, 92, 255, 0.7), inset 0 1px 0 rgba(255, 255, 255, 0.4) !important;
}
.dark body[data-theme-skin="builtin-aurora-glass"] .bg-primary.text-primary-foreground {
background-image: linear-gradient(135deg, #8b7bff 0%, #6d5cff 50%, #22d3ee 130%) !important;
box-shadow: 0 6px 24px -4px rgba(139, 123, 255, 0.6), inset 0 1px 0 rgba(255, 255, 255, 0.2) !important;
}
/* ── Selected folder / row: soft tinted glass + accent bar ────── */
body[data-theme-skin="builtin-aurora-glass"] .bg-secondary.border-r .border-l-2.border-primary {
border-left-width: 3px !important;
}
/* ── On/off switch: gradient when active ──────────────────────── */
body[data-theme-skin="builtin-aurora-glass"] [role="switch"][aria-checked="true"] {
background-image: linear-gradient(135deg, #7c5cff, #4f9bff) !important;
border-color: transparent !important;
box-shadow: 0 2px 10px -1px rgba(109, 92, 255, 0.6) !important;
}
.dark body[data-theme-skin="builtin-aurora-glass"] [role="switch"][aria-checked="true"] {
background-image: linear-gradient(135deg, #8b7bff, #22d3ee) !important;
}
/* ── Menu hover: tinted accent slab (keeps glass legible) ─────── */
body[data-theme-skin="builtin-aurora-glass"] [role="menu"] [role="menuitem"]:hover,
body[data-theme-skin="builtin-aurora-glass"] [role="menu"] [role="menuitem"]:focus,
body[data-theme-skin="builtin-aurora-glass"] [role="menu"] [role="menuitem"]:focus-visible {
background-color: rgba(109, 92, 255, 0.14) !important;
color: var(--color-foreground) !important;
}
.dark body[data-theme-skin="builtin-aurora-glass"] [role="menu"] [role="menuitem"]:hover,
.dark body[data-theme-skin="builtin-aurora-glass"] [role="menu"] [role="menuitem"]:focus,
.dark body[data-theme-skin="builtin-aurora-glass"] [role="menu"] [role="menuitem"]:focus-visible {
background-color: rgba(139, 123, 255, 0.22) !important;
}`;
export const BUILTIN_THEMES: InstalledTheme[] = [ export const BUILTIN_THEMES: InstalledTheme[] = [
{ {
id: 'builtin-qui', id: 'builtin-qui',
@@ -373,4 +919,30 @@ export const BUILTIN_THEMES: InstalledTheme[] = [
enabled: true, enabled: true,
builtIn: true, builtIn: true,
}, },
{
id: 'builtin-roundcube-elastic',
name: 'Roundcube Elastic',
version: '1.0.0',
author: 'Built-in',
description: 'Faithful recreation of Roundcube\'s Elastic skin - Roboto, the #37beff blue, and the dark task-menu rail',
css: elasticCSS,
skin: elasticSkin,
variants: ['light', 'dark'],
typography: { fontSans: 'Roboto, "Helvetica Neue", Arial, sans-serif', baseFontSize: '14px' },
enabled: true,
builtIn: true,
},
{
id: 'builtin-aurora-glass',
name: 'Aurora Glass',
version: '1.0.0',
author: 'Built-in',
description: 'Ultra-modern glassmorphism - frosted blurred panes floating over a vibrant indigo→cyan gradient mesh, with glowing gradient actions',
css: auroraCSS,
skin: auroraSkin,
variants: ['light', 'dark'],
typography: { fontSans: '"Inter", "SF Pro Display", "Segoe UI", system-ui, sans-serif' },
enabled: true,
builtIn: true,
},
]; ];
+25 -1
View File
@@ -329,7 +329,7 @@ export class DemoJMAPClient implements IJMAPClient {
this.recalcMailboxCounts(); this.recalcMailboxCounts();
} }
async emptyMailbox(mailboxId: string): Promise<number> { async emptyMailbox(mailboxId: string, _accountId?: string): Promise<number> {
const before = this.data.emails.length; const before = this.data.emails.length;
this.data.emails = this.data.emails.filter(e => !e.mailboxIds[mailboxId]); this.data.emails = this.data.emails.filter(e => !e.mailboxIds[mailboxId]);
const removed = before - this.data.emails.length; const removed = before - this.data.emails.length;
@@ -530,6 +530,14 @@ export class DemoJMAPClient implements IJMAPClient {
return { blobId, size: file.size, type: file.type }; return { blobId, size: file.size, type: file.type };
} }
async importEmail(): Promise<string | null> {
return generateDemoId('email');
}
async sendReadReceipt(): Promise<void> {
// Demo mode: no real network send.
}
getBlobDownloadUrl(blobId: string): string { getBlobDownloadUrl(blobId: string): string {
return `data:application/octet-stream;demo-blob=${blobId}`; return `data:application/octet-stream;demo-blob=${blobId}`;
} }
@@ -894,6 +902,10 @@ export class DemoJMAPClient implements IJMAPClient {
return this.data.fileNodes.filter(n => n.parentId === parentId); return this.data.fileNodes.filter(n => n.parentId === parentId);
} }
async listAllFileNodes(): Promise<FileNode[]> {
return [...this.data.fileNodes];
}
async getFileNodes(ids: string[] | null): Promise<FileNode[]> { async getFileNodes(ids: string[] | null): Promise<FileNode[]> {
if (ids === null) return [...this.data.fileNodes]; if (ids === null) return [...this.data.fileNodes];
return this.data.fileNodes.filter(n => ids.includes(n.id)); return this.data.fileNodes.filter(n => ids.includes(n.id));
@@ -924,6 +936,18 @@ export class DemoJMAPClient implements IJMAPClient {
if (node) Object.assign(node, updates, { updated: new Date().toISOString() }); if (node) Object.assign(node, updates, { updated: new Date().toISOString() });
} }
async updateFileNodes(updates: Record<string, Partial<Pick<FileNode, 'name' | 'parentId'>>>): Promise<{ updated: string[]; notUpdated: Record<string, string> }> {
const updated: string[] = [];
for (const [id, patch] of Object.entries(updates)) {
const node = this.data.fileNodes.find(n => n.id === id);
if (node) {
Object.assign(node, patch, { updated: new Date().toISOString() });
updated.push(id);
}
}
return { updated, notUpdated: {} };
}
async destroyFileNodes(ids: string[]): Promise<{ destroyed: string[]; notDestroyed: string[] }> { async destroyFileNodes(ids: string[]): Promise<{ destroyed: string[]; notDestroyed: string[] }> {
const idSet = new Set(ids); const idSet = new Set(ids);
this.data.fileNodes = this.data.fileNodes.filter(n => !idSet.has(n.id)); this.data.fileNodes = this.data.fileNodes.filter(n => !idSet.has(n.id));
+66 -6
View File
@@ -1,23 +1,83 @@
import { AuthenticationResults } from './jmap/types'; import { AuthenticationResults } from './jmap/types';
import { parseUnsubscribeUrls } from './validation'; import { parseUnsubscribeUrls } from './validation';
type SpfResult = 'pass' | 'fail' | 'softfail' | 'neutral' | 'none' | 'temperror' | 'permerror';
type SpfEntry = NonNullable<NonNullable<AuthenticationResults['spf']>['all']>[number];
/**
* Severity ranking for SPF results. Higher = more severe / more actionable.
* A hard `fail` is a definitive policy violation and must outrank ambiguous
* states like `temperror`, so a spoofed message isn't softened to a
* "temporary failure" headline when one identity hard-fails.
*/
const SPF_SEVERITY: Record<SpfResult, number> = {
fail: 6,
softfail: 5,
permerror: 4,
temperror: 3,
neutral: 2,
none: 1,
pass: 0,
};
/**
* Whether the authentication results indicate the visible From identity can't
* be trusted (i.e. the message is likely spoofed). Used to suppress UI that
* would otherwise imply the message legitimately came from one of the user's
* own identities (e.g. the "via <identity>" badge).
*/
export function isAuthenticationSpoofed(auth?: AuthenticationResults): boolean {
if (!auth) return false;
// DMARC aligns the visible From with SPF/DKIM, so a DMARC fail is the
// strongest single spoofing signal.
if (auth.dmarc?.result === 'fail') return true;
// Otherwise a hard SPF fail with no valid DKIM signature means the sender
// isn't authorized for the envelope domain.
if (auth.spf?.result === 'fail' && auth.dkim?.result !== 'pass') return true;
return false;
}
/** /**
* Parse Authentication-Results header to extract SPF, DKIM, DMARC results * Parse Authentication-Results header to extract SPF, DKIM, DMARC results
*/ */
export function parseAuthenticationResults(header: string): AuthenticationResults { export function parseAuthenticationResults(header: string): AuthenticationResults {
const results: AuthenticationResults = {}; const results: AuthenticationResults = {};
type SpfResult = 'pass' | 'fail' | 'softfail' | 'neutral' | 'none' | 'temperror' | 'permerror';
type DkimResult = 'pass' | 'fail' | 'policy' | 'neutral' | 'temperror' | 'permerror'; type DkimResult = 'pass' | 'fail' | 'policy' | 'neutral' | 'temperror' | 'permerror';
type DmarcResult = 'pass' | 'fail' | 'none'; type DmarcResult = 'pass' | 'fail' | 'none';
type DmarcPolicy = 'reject' | 'quarantine' | 'none'; type DmarcPolicy = 'reject' | 'quarantine' | 'none';
// Parse SPF // Parse SPF. A single Authentication-Results header can carry more than one
const spfMatch = header.match(/spf=(\w+)(?:\s+\([^)]*\))?\s+(?:smtp\.(?:mailfrom|helo)=([^\s;]+))?/); // SPF result when the server evaluates multiple identities (HELO and MAIL
if (spfMatch) { // FROM). Collect them all and surface the most severe as the headline so a
// hard MAIL FROM `fail` isn't softened to a HELO `temperror`.
const spfRegex = /spf=(\w+)(?:\s+\([^)]*\))?(?:\s+smtp\.(mailfrom|helo)=([^\s;]+))?/g;
const spfResults: SpfEntry[] = [];
let spfM: RegExpExecArray | null;
while ((spfM = spfRegex.exec(header)) !== null) {
spfResults.push({
result: spfM[1] as SpfResult,
identity: spfM[2] as SpfEntry['identity'],
domain: spfM[3],
});
}
if (spfResults.length > 0) {
const severity = (r: string) => SPF_SEVERITY[r as SpfResult] ?? -1;
// Most severe wins; on a tie prefer the MAIL FROM identity (more meaningful
// than HELO) and otherwise keep the first occurrence.
const primary = spfResults.reduce((best, cur) => {
if (severity(cur.result) > severity(best.result)) return cur;
if (
severity(cur.result) === severity(best.result) &&
best.identity !== 'mailfrom' &&
cur.identity === 'mailfrom'
) return cur;
return best;
});
results.spf = { results.spf = {
result: spfMatch[1] as SpfResult, result: primary.result,
domain: spfMatch[2] domain: primary.domain,
...(spfResults.length > 1 ? { all: spfResults } : {}),
}; };
} }
+7 -1
View File
@@ -1,4 +1,4 @@
export type FilePreviewKind = 'image' | 'html' | 'text' | 'markdown' | 'pdf' | 'audio' | 'video' | 'unsupported'; export type FilePreviewKind = 'image' | 'html' | 'eml' | 'text' | 'markdown' | 'pdf' | 'audio' | 'video' | 'unsupported';
const IMAGE_EXTENSIONS = new Set(['jpg', 'jpeg', 'png', 'gif', 'webp', 'svg', 'avif', 'bmp', 'ico']); const IMAGE_EXTENSIONS = new Set(['jpg', 'jpeg', 'png', 'gif', 'webp', 'svg', 'avif', 'bmp', 'ico']);
const AUDIO_EXTENSIONS = new Set(['mp3', 'wav', 'ogg', 'm4a', 'flac', 'aac', 'opus']); const AUDIO_EXTENSIONS = new Set(['mp3', 'wav', 'ogg', 'm4a', 'flac', 'aac', 'opus']);
@@ -38,6 +38,12 @@ export function getFilePreviewKind(name?: string, type?: string): FilePreviewKin
return 'html'; return 'html';
} }
// Embedded email message (e.g. a bounce/DSN, or forward-as-attachment).
// Rendered by parsing it and showing it like an email, not as a blob.
if (mimeType === 'message/rfc822' || ext === 'eml') {
return 'eml';
}
if (mimeType === 'application/pdf' || ext === 'pdf') { if (mimeType === 'application/pdf' || ext === 'pdf') {
return 'pdf'; return 'pdf';
} }
+28 -4
View File
@@ -97,7 +97,7 @@ export interface IJMAPClient {
updateEmailKeywords(emailId: string, keywords: Record<string, boolean>): Promise<void>; updateEmailKeywords(emailId: string, keywords: Record<string, boolean>): Promise<void>;
setKeyword(emailId: string, keyword: string): Promise<void>; setKeyword(emailId: string, keyword: string): Promise<void>;
migrateKeyword(oldKeyword: string, newKeyword: string): Promise<number>; migrateKeyword(oldKeyword: string, newKeyword: string): Promise<number>;
deleteEmail(emailId: string): Promise<void>; deleteEmail(emailId: string, accountId?: string): Promise<void>;
moveToTrash(emailId: string, trashMailboxId: string, accountId?: string, markAsRead?: boolean): Promise<void>; moveToTrash(emailId: string, trashMailboxId: string, accountId?: string, markAsRead?: boolean): Promise<void>;
batchDeleteEmails(emailIds: string[]): Promise<void>; batchDeleteEmails(emailIds: string[]): Promise<void>;
batchMoveEmails(emailIds: string[], toMailboxId: string, accountId?: string, markAsRead?: boolean): Promise<void>; batchMoveEmails(emailIds: string[], toMailboxId: string, accountId?: string, markAsRead?: boolean): Promise<void>;
@@ -109,7 +109,7 @@ export interface IJMAPClient {
accountId?: string, accountId?: string,
): Promise<void>; ): Promise<void>;
moveEmail(emailId: string, toMailboxId: string, accountId?: string): Promise<void>; moveEmail(emailId: string, toMailboxId: string, accountId?: string): Promise<void>;
emptyMailbox(mailboxId: string): Promise<number>; emptyMailbox(mailboxId: string, accountId?: string): Promise<number>;
markMailboxAsRead(mailboxId: string, accountId?: string): Promise<number>; markMailboxAsRead(mailboxId: string, accountId?: string): Promise<number>;
markAllAsRead(excludeMailboxIds?: string[], accountId?: string): Promise<number>; markAllAsRead(excludeMailboxIds?: string[], accountId?: string): Promise<number>;
markAsSpam(emailId: string, accountId?: string, markAsRead?: boolean): Promise<void>; markAsSpam(emailId: string, accountId?: string, markAsRead?: boolean): Promise<void>;
@@ -150,8 +150,30 @@ export interface IJMAPClient {
references?: string[], references?: string[],
delayedUntil?: string, delayedUntil?: string,
envelopeMailFrom?: string, envelopeMailFrom?: string,
options?: { requestReadReceipt?: boolean },
): Promise<SendEmailResult>; ): Promise<SendEmailResult>;
importEmail(
blobId: string,
mailboxIds: Record<string, boolean>,
keywords?: Record<string, boolean>,
accountId?: string,
): Promise<string | null>;
sendReadReceipt(params: {
to: string;
fromEmail: string;
fromName?: string;
identityId: string;
originalMessageId?: string | string[];
originalSubject?: string;
originalRecipient?: string;
automatic?: boolean;
accountId?: string;
subject?: string;
humanText?: string;
}): Promise<void>;
sendRawEmail(blob: Blob, identityId: string, sentMailboxId: string, draftMailboxId?: string, delayedUntil?: string, envelopeRecipients?: string[]): Promise<SendEmailResult>; sendRawEmail(blob: Blob, identityId: string, sentMailboxId: string, draftMailboxId?: string, delayedUntil?: string, envelopeRecipients?: string[]): Promise<SendEmailResult>;
getScheduledEmails(limit?: number, position?: number): Promise<{ emails: ScheduledEmail[]; hasMore: boolean; total: number; nextPosition: number }>; getScheduledEmails(limit?: number, position?: number): Promise<{ emails: ScheduledEmail[]; hasMore: boolean; total: number; nextPosition: number }>;
cancelEmailSubmission(submissionId: string): Promise<void>; cancelEmailSubmission(submissionId: string): Promise<void>;
@@ -188,8 +210,8 @@ export interface IJMAPClient {
signal?: AbortSignal; signal?: AbortSignal;
}, },
): Promise<{ blobId: string; size: number; type: string }>; ): Promise<{ blobId: string; size: number; type: string }>;
getBlobDownloadUrl(blobId: string, name?: string, type?: string): string; getBlobDownloadUrl(blobId: string, name?: string, type?: string, accountId?: string): string;
fetchBlob(blobId: string, name?: string, type?: string): Promise<Blob>; fetchBlob(blobId: string, name?: string, type?: string, accountId?: string): Promise<Blob>;
fetchBlobAsObjectUrl(blobId: string, name?: string, type?: string): Promise<string>; fetchBlobAsObjectUrl(blobId: string, name?: string, type?: string): Promise<string>;
fetchBlobArrayBuffer(blobId: string, name?: string, type?: string): Promise<ArrayBuffer>; fetchBlobArrayBuffer(blobId: string, name?: string, type?: string): Promise<ArrayBuffer>;
downloadBlob(blobId: string, name?: string, type?: string): Promise<void>; downloadBlob(blobId: string, name?: string, type?: string): Promise<void>;
@@ -284,10 +306,12 @@ export interface IJMAPClient {
getFilesAccountId(): string; getFilesAccountId(): string;
probeFileNodeSupport(): Promise<boolean>; probeFileNodeSupport(): Promise<boolean>;
listFileNodes(parentId: string | null): Promise<FileNode[]>; listFileNodes(parentId: string | null): Promise<FileNode[]>;
listAllFileNodes(): Promise<FileNode[]>;
getFileNodes(ids: string[] | null, properties?: string[]): Promise<FileNode[]>; getFileNodes(ids: string[] | null, properties?: string[]): Promise<FileNode[]>;
createFileDirectory(name: string, parentId: string | null): Promise<FileNode>; createFileDirectory(name: string, parentId: string | null): Promise<FileNode>;
createFileNode(name: string, blobId: string, type: string, size: number, parentId: string | null): Promise<FileNode>; createFileNode(name: string, blobId: string, type: string, size: number, parentId: string | null): Promise<FileNode>;
updateFileNode(id: string, updates: Partial<Pick<FileNode, 'name' | 'parentId'>>): Promise<void>; updateFileNode(id: string, updates: Partial<Pick<FileNode, 'name' | 'parentId'>>): Promise<void>;
updateFileNodes(updates: Record<string, Partial<Pick<FileNode, 'name' | 'parentId'>>>): Promise<{ updated: string[]; notUpdated: Record<string, string> }>;
destroyFileNodes(ids: string[]): Promise<{ destroyed: string[]; notDestroyed: string[] }>; destroyFileNodes(ids: string[]): Promise<{ destroyed: string[]; notDestroyed: string[] }>;
copyFileNode(id: string, newName: string, parentId: string | null): Promise<FileNode>; copyFileNode(id: string, newName: string, parentId: string | null): Promise<FileNode>;
+207 -61
View File
@@ -1134,7 +1134,9 @@ export class JMAPClient implements IJMAPClient {
const authResultsHeader = headersRecord['Authentication-Results']; const authResultsHeader = headersRecord['Authentication-Results'];
if (authResultsHeader) { if (authResultsHeader) {
const value = Array.isArray(authResultsHeader) ? authResultsHeader[0] : authResultsHeader; // Multiple Authentication-Results headers (or multiple SPF identities in
// one header) must all be considered so the most severe result wins.
const value = Array.isArray(authResultsHeader) ? authResultsHeader.join('; ') : authResultsHeader;
email.authenticationResults = parseAuthenticationResults(value); email.authenticationResults = parseAuthenticationResults(value);
} }
@@ -1271,10 +1273,10 @@ export class JMAPClient implements IJMAPClient {
return allIds.length; return allIds.length;
} }
async deleteEmail(emailId: string): Promise<void> { async deleteEmail(emailId: string, accountId?: string): Promise<void> {
await this.request([ await this.request([
["Email/set", { ["Email/set", {
accountId: this.accountId, accountId: accountId || this.accountId,
destroy: [emailId], destroy: [emailId],
}, "0"], }, "0"],
]); ]);
@@ -1443,19 +1445,20 @@ export class JMAPClient implements IJMAPClient {
} }
} }
async emptyMailbox(mailboxId: string): Promise<number> { async emptyMailbox(mailboxId: string, accountId?: string): Promise<number> {
const targetAccountId = accountId || this.accountId;
let totalDestroyed = 0; let totalDestroyed = 0;
let hasMore = true; let hasMore = true;
while (hasMore) { while (hasMore) {
const response = await this.request([ const response = await this.request([
["Email/query", { ["Email/query", {
accountId: this.accountId, accountId: targetAccountId,
filter: { inMailbox: mailboxId }, filter: { inMailbox: mailboxId },
limit: 500, limit: 500,
}, "0"], }, "0"],
["Email/set", { ["Email/set", {
accountId: this.accountId, accountId: targetAccountId,
"#destroy": { resultOf: "0", name: "Email/query", path: "/ids" }, "#destroy": { resultOf: "0", name: "Email/query", path: "/ids" },
}, "1"], }, "1"],
]); ]);
@@ -2149,11 +2152,18 @@ export class JMAPClient implements IJMAPClient {
inReplyTo?: string[], inReplyTo?: string[],
references?: string[], references?: string[],
delayedUntil?: string, delayedUntil?: string,
envelopeMailFrom?: string envelopeMailFrom?: string,
options?: { requestReadReceipt?: boolean }
): Promise<SendEmailResult> { ): Promise<SendEmailResult> {
const holdForSeconds = delayedUntil ? this.validateDelayedUntil(delayedUntil) : undefined; const holdForSeconds = delayedUntil ? this.validateDelayedUntil(delayedUntil) : undefined;
const emailId = `send-${Date.now()}`; const emailId = `send-${Date.now()}`;
const mailboxes = await this.getMailboxes(); const targetAccountId = (fromEmail && Object.keys(this.accounts).find(id =>
this.accounts[id]?.name?.toLowerCase() === fromEmail.toLowerCase()
)) || this.accountId;
const mboxResp = await this.request([
["Mailbox/get", { accountId: targetAccountId }, "0"]
]);
const mailboxes = (mboxResp.methodResponses?.[0]?.[1]?.list || []) as Mailbox[];
const sentMailbox = mailboxes.find(mb => mb.role === 'sent'); const sentMailbox = mailboxes.find(mb => mb.role === 'sent');
if (!sentMailbox) { if (!sentMailbox) {
throw new Error('No sent mailbox found'); throw new Error('No sent mailbox found');
@@ -2167,25 +2177,25 @@ export class JMAPClient implements IJMAPClient {
let identityReplyTo: EmailAddress[] | undefined; let identityReplyTo: EmailAddress[] | undefined;
{ {
const identityResponse = await this.request([ const identityResponse = await this.request([
["Identity/get", { accountId: this.accountId }, "0"] ["Identity/get", { accountId: targetAccountId }, "0"]
]); ]);
if (!finalIdentityId) { if (!finalIdentityId) {
finalIdentityId = this.accountId; finalIdentityId = targetAccountId;
} }
if (identityResponse.methodResponses?.[0]?.[0] === "Identity/get") { if (identityResponse.methodResponses?.[0]?.[0] === "Identity/get") {
const identities = (identityResponse.methodResponses[0][1].list || []) as Identity[]; const identities = (identityResponse.methodResponses[0][1].list || []) as Identity[];
if (identities.length > 0) { if (identities.length > 0) {
if (!identityId) { let matchingIdentity = identityId
? identities.find((id) => id.id === identityId)
: undefined;
if (!matchingIdentity) {
const target = fromEmail || this.username; const target = fromEmail || this.username;
const matchingIdentity = identities.find((id) => id.email === target) matchingIdentity = identities.find((id) => id.email === target)
|| (!target.includes('@') ? identities.find((id) => id.email.split('@')[0] === target) : undefined); || (!target.includes('@') ? identities.find((id) => id.email.split('@')[0] === target) : undefined);
}
finalIdentityId = matchingIdentity?.id || identities[0].id; finalIdentityId = matchingIdentity?.id || identities[0].id;
identityReplyTo = matchingIdentity?.replyTo || identities[0].replyTo; identityReplyTo = matchingIdentity?.replyTo || identities[0].replyTo;
} else {
const matchedIdentity = identities.find((id) => id.id === identityId);
identityReplyTo = matchedIdentity?.replyTo;
}
} }
} }
} }
@@ -2213,6 +2223,13 @@ export class JMAPClient implements IJMAPClient {
mailboxIds: { [draftsMailbox.id]: true }, mailboxIds: { [draftsMailbox.id]: true },
}; };
if (options?.requestReadReceipt) {
// RFC 8098: ask the recipient's client to return a Message Disposition
// Notification to our address. JMAP lets us set the raw header on create
// via the "header:<Name>:asText" property form.
emailCreate["header:Disposition-Notification-To:asText"] = fromEmail || this.username;
}
if (htmlBody) { if (htmlBody) {
// Send as multipart/alternative with both text and HTML // Send as multipart/alternative with both text and HTML
emailCreate.bodyValues = { emailCreate.bodyValues = {
@@ -2278,21 +2295,21 @@ export class JMAPClient implements IJMAPClient {
destroy: [draftId], destroy: [draftId],
}, "0"]); }, "0"]);
methodCalls.push(["Email/set", { methodCalls.push(["Email/set", {
accountId: this.accountId, accountId: targetAccountId,
create: { [emailId]: emailCreate }, create: { [emailId]: emailCreate },
}, "1"]); }, "1"]);
methodCalls.push(["EmailSubmission/set", { methodCalls.push(["EmailSubmission/set", {
accountId: this.getSubmissionAccountId(), accountId: this.getSubmissionAccountId(targetAccountId),
create: buildSubmissionCreate("1"), create: buildSubmissionCreate("1"),
onSuccessUpdateEmail, onSuccessUpdateEmail,
}, "2"]); }, "2"]);
} else { } else {
methodCalls.push(["Email/set", { methodCalls.push(["Email/set", {
accountId: this.accountId, accountId: targetAccountId,
create: { [emailId]: emailCreate }, create: { [emailId]: emailCreate },
}, "0"]); }, "0"]);
methodCalls.push(["EmailSubmission/set", { methodCalls.push(["EmailSubmission/set", {
accountId: this.getSubmissionAccountId(), accountId: this.getSubmissionAccountId(targetAccountId),
create: buildSubmissionCreate("1"), create: buildSubmissionCreate("1"),
onSuccessUpdateEmail, onSuccessUpdateEmail,
}, "1"]); }, "1"]);
@@ -3001,21 +3018,125 @@ export class JMAPClient implements IJMAPClient {
throw new Error('Invalid upload response: blobId not found'); throw new Error('Invalid upload response: blobId not found');
} }
getBlobDownloadUrl(blobId: string, name?: string, type?: string): string { /**
* Import a raw RFC822 message (referenced by a previously-uploaded blob) into
* one or more mailboxes. Returns the new email id. Used for sending MDNs,
* where the exact MIME bytes must be preserved (Email/set can't express a
* multipart/report report-type parameter reliably).
*/
async importEmail(
blobId: string,
mailboxIds: Record<string, boolean>,
keywords?: Record<string, boolean>,
accountId?: string
): Promise<string | null> {
const targetAccountId = accountId || this.accountId;
const creationId = `imp-${Date.now()}`;
const response = await this.request([
["Email/import", {
accountId: targetAccountId,
emails: {
[creationId]: { blobId, mailboxIds, keywords: keywords || { "$seen": true } },
},
}, "0"],
]);
const res = response.methodResponses?.[0];
if (res?.[0] !== "Email/import") {
console.error('Email/import: unexpected response', res);
return null;
}
const payload = res[1] as {
created?: Record<string, { id: string }>;
notCreated?: Record<string, { type?: string; description?: string }>;
};
const created = payload?.created?.[creationId];
if (!created) {
const reason = payload?.notCreated?.[creationId];
console.error('Email/import failed:', reason || payload);
throw new Error(`Email/import: ${reason?.description || reason?.type || 'unknown error'}`);
}
return created.id;
}
/**
* Send an RFC 8098 Message Disposition Notification (read receipt) in reply
* to a message that carried a Disposition-Notification-To header. Builds the
* multipart/report, uploads it as a blob, imports it into Sent, then submits
* it with an explicit envelope (MAIL FROM = our identity, RCPT TO = the
* requesting address).
*/
async sendReadReceipt(params: {
to: string;
fromEmail: string;
fromName?: string;
identityId: string;
originalMessageId?: string | string[];
originalSubject?: string;
originalRecipient?: string;
automatic?: boolean;
accountId?: string;
subject?: string;
humanText?: string;
}): Promise<void> {
const targetAccountId = params.accountId || this.accountId;
const { buildMdnMessage } = await import("@/lib/mdn");
const raw = buildMdnMessage(params);
const file = new File([raw], "receipt.eml", { type: "message/rfc822" });
const { blobId } = await this.uploadBlob(file);
const mailboxes = await this.getMailboxes();
const targetMailbox = mailboxes.find(mb => mb.role === 'sent') || mailboxes[0];
if (!targetMailbox) throw new Error('No mailbox available for MDN import');
const emailId = await this.importEmail(
blobId,
{ [targetMailbox.id]: true },
{ "$seen": true },
targetAccountId
);
if (!emailId) throw new Error('MDN import failed');
const subId = `mdnsub-${Date.now()}`;
const response = await this.request([
["EmailSubmission/set", {
accountId: targetAccountId,
create: {
[subId]: {
emailId,
identityId: params.identityId,
envelope: {
mailFrom: { email: params.fromEmail },
rcptTo: [{ email: params.to }],
},
},
},
}, "0"],
]);
const subRes = response.methodResponses?.[0];
const notCreated = (subRes?.[1] as { notCreated?: Record<string, { type?: string; description?: string }> })?.notCreated?.[subId];
if (notCreated) {
throw new Error(`MDN submission failed: ${notCreated.description || notCreated.type || 'unknown'}`);
}
}
getBlobDownloadUrl(blobId: string, name?: string, type?: string, accountId?: string): string {
if (!this.downloadUrl) { if (!this.downloadUrl) {
throw new Error('Download URL not available. Please reconnect.'); throw new Error('Download URL not available. Please reconnect.');
} }
// RFC 6570 level 1 URI template expansion // RFC 6570 level 1 URI template expansion. Blobs are scoped per account,
// so a caller fetching a blob from a delegated/shared account must pass
// that owner's accountId rather than defaulting to the primary one.
return this.downloadUrl return this.downloadUrl
.replace('{accountId}', encodeURIComponent(this.accountId)) .replace('{accountId}', encodeURIComponent(accountId || this.accountId))
.replace('{blobId}', encodeURIComponent(blobId)) .replace('{blobId}', encodeURIComponent(blobId))
.replace('{name}', encodeURIComponent(name || 'download')) .replace('{name}', encodeURIComponent(name || 'download'))
.replace('{type}', encodeURIComponent(type || 'application/octet-stream')); .replace('{type}', encodeURIComponent(type || 'application/octet-stream'));
} }
async fetchBlob(blobId: string, name?: string, type?: string): Promise<Blob> { async fetchBlob(blobId: string, name?: string, type?: string, accountId?: string): Promise<Blob> {
const url = this.getBlobDownloadUrl(blobId, name, type); const url = this.getBlobDownloadUrl(blobId, name, type, accountId);
const response = await this.authenticatedFetch(url, {}); const response = await this.authenticatedFetch(url, {});
if (!response.ok) { if (!response.ok) {
throw new Error(`Failed to fetch blob: ${response.status}`); throw new Error(`Failed to fetch blob: ${response.status}`);
@@ -4905,54 +5026,46 @@ export class JMAPClient implements IJMAPClient {
} }
async listFileNodes(parentId: string | null): Promise<FileNode[]> { async listFileNodes(parentId: string | null): Promise<FileNode[]> {
const accountId = this.getFilesAccountId(); // FileNode/query omits folder nodes in Stalwart (see listAllFileNodes), so
const filter: Record<string, unknown> = {}; // we enumerate the whole account and filter children client-side.
if (parentId !== null) { const all = await this.listAllFileNodes();
filter.parentId = parentId; return all.filter(n => (n.parentId ?? null) === parentId);
} }
// When parentId is null (root level), use empty filter to get all nodes.
// Stalwart's FileNode/query does not support parentId: null as a filter value. /**
* Fetch every FileNode in the account, files AND folders, to build the folder
* hierarchy client-side from parentId links.
*
* IMPORTANT: this uses `FileNode/get` with `ids: null` (return-all), NOT
* `FileNode/query`. Stalwart's FileNode/query only returns leaf files - it
* omits container (folder) nodes entirely - so a query-based listing made
* every folder invisible (the whole account looked like a single root file).
*/
async listAllFileNodes(): Promise<FileNode[]> {
const accountId = this.getFilesAccountId();
const response = await this.request( const response = await this.request(
[ [["FileNode/get", { accountId, ids: null, properties: JMAPClient.FILE_NODE_PROPERTIES }, "fng0"]],
["FileNode/query", { accountId, filter }, "fnq0"],
["FileNode/get", { accountId, "#ids": { resultOf: "fnq0", name: "FileNode/query", path: "/ids" }, properties: JMAPClient.FILE_NODE_PROPERTIES }, "fng0"],
],
this.fileUsing(), this.fileUsing(),
); );
// Check if query failed first const getResult = response.methodResponses?.find(r => r[0] === "FileNode/get" || (r[0] === "error" && r[2] === "fng0"));
const queryResult = response.methodResponses?.find(r => r[0] === "FileNode/query" || (r[0] === "error" && r[2] === "fnq0")); if (!getResult || getResult[0] === "error") {
if (queryResult && queryResult[0] === "error") { console.error('[Files] FileNode/get error:', getResult?.[1]);
console.error('[Files] FileNode/query error:', queryResult[1]); throw new Error(getResult?.[1]?.description || "FileNode list failed");
throw new Error(queryResult[1]?.description || "FileNode/query failed");
} }
return (getResult[1].list || []) as FileNode[];
const getResult = response.methodResponses?.find(r => r[0] === "FileNode/get" || (r[0] === "error" && r[2] === "fnq0"));
if (!getResult) {
console.error('[Files] No FileNode/get response. Full response:', JSON.stringify(response.methodResponses));
throw new Error("FileNode list failed - no response");
}
if (getResult[0] === "error") {
console.error('[Files] FileNode/get error:', getResult[1]);
throw new Error(getResult[1]?.description || "FileNode list failed");
}
const nodes = (getResult[1].list || []) as FileNode[];
// When listing root, filter client-side to only show root-level items
if (parentId === null) {
return nodes.filter(n => n.parentId === null);
}
return nodes;
} }
async createFileDirectory(name: string, parentId: string | null): Promise<FileNode> { async createFileDirectory(name: string, parentId: string | null): Promise<FileNode> {
const accountId = this.getFilesAccountId(); const accountId = this.getFilesAccountId();
// Stalwart requires a blobId even for directories - upload an empty blob // A FileNode is a folder (container) only when it has no content - i.e. no
const emptyBlob = new File([], name, { type: 'application/x-directory' }); // blobId, type or size, so the server stores it with `file == null`. Sending
const { blobId } = await this.uploadBlob(emptyBlob); // any of those - as older builds did (type "d" + an empty blob) - makes it a
// 0-byte FILE that nothing can ever be parented under, which is what caused
const dirProps: Record<string, unknown> = { name, type: "d", blobId, size: 0 }; // "Parent ID does not exist or is not a folder" during the #379 migration.
const dirProps: Record<string, unknown> = { name };
if (parentId !== null) { if (parentId !== null) {
dirProps.parentId = parentId; dirProps.parentId = parentId;
} }
@@ -5031,6 +5144,39 @@ export class JMAPClient implements IJMAPClient {
} }
} }
/**
* Update many FileNodes in a single FileNode/set call. Returns the ids that
* were updated and a map of id -> error for any the server rejected. Never
* throws for per-node failures (only for a whole-method error).
*/
async updateFileNodes(updates: Record<string, Partial<Pick<FileNode, 'name' | 'parentId'>>>): Promise<{ updated: string[]; notUpdated: Record<string, string> }> {
const ids = Object.keys(updates);
if (ids.length === 0) return { updated: [], notUpdated: {} };
const accountId = this.getFilesAccountId();
const response = await this.request(
[["FileNode/set", { accountId, update: updates }, "fns0"]],
this.fileUsing(),
);
const result = response.methodResponses?.[0];
if (!result || result[0] === "error") {
throw new Error(result?.[1]?.description || "FileNode/set update failed");
}
const updatedMap: Record<string, unknown> = result[1].updated || {};
const notUpdatedMap: Record<string, { description?: string }> = result[1].notUpdated || {};
const notUpdated: Record<string, string> = {};
for (const id of Object.keys(notUpdatedMap)) {
notUpdated[id] = notUpdatedMap[id]?.description || 'not updated';
}
// Servers may omit the `updated` map; treat anything not rejected as updated.
const updated = Object.keys(updatedMap).length > 0
? Object.keys(updatedMap)
: ids.filter(id => !(id in notUpdated));
return { updated, notUpdated };
}
async destroyFileNodes(ids: string[]): Promise<{ destroyed: string[]; notDestroyed: string[] }> { async destroyFileNodes(ids: string[]): Promise<{ destroyed: string[]; notDestroyed: string[] }> {
const accountId = this.getFilesAccountId(); const accountId = this.getFilesAccountId();
+19 -3
View File
@@ -13,14 +13,21 @@ export interface SieveCapabilities {
externalLists: string[]; externalLists: string[];
} }
export type FilterConditionField = 'from' | 'to' | 'cc' | 'subject' | 'header' | 'size' | 'body'; export type FilterConditionField =
| 'from' | 'to' | 'cc' | 'subject' | 'header' | 'size' | 'body'
| 'attachment';
export type FilterComparator = export type FilterComparator =
| 'contains' | 'not_contains' | 'contains' | 'not_contains'
| 'is' | 'not_is' | 'is' | 'not_is'
| 'starts_with' | 'ends_with' | 'starts_with' | 'ends_with'
| 'matches' | 'matches'
| 'greater_than' | 'less_than'; | 'greater_than' | 'less_than'
// For field === 'attachment':
// has_any → message has any attachment (Content-Disposition: attachment)
// has_type → message has an attachment whose Content-Type matches `value`
// (substring match, e.g. "application/pdf" or "image/")
| 'has_any' | 'has_type';
export type FilterActionType = export type FilterActionType =
| 'move' | 'copy' | 'forward' | 'move' | 'copy' | 'forward'
@@ -30,7 +37,16 @@ export type FilterActionType =
export interface FilterCondition { export interface FilterCondition {
field: FilterConditionField; field: FilterConditionField;
comparator: FilterComparator; comparator: FilterComparator;
value: string; /**
* Match value. Use a string array for OR-within-condition semantics
* (e.g. `["@domain1.com", "@domain2.com"]` matches mail from either).
* Sieve emits the array as a list literal which the implementation
* treats as "matches any item". Use a plain string for single-value
* conditions; existing single-value rules continue to work unchanged.
*
* Not supported for: size (numeric), has_any (no value).
*/
value: string | string[];
headerName?: string; headerName?: string;
} }
+14
View File
@@ -75,6 +75,16 @@ export interface AuthenticationResults {
result: 'pass' | 'fail' | 'softfail' | 'neutral' | 'none' | 'temperror' | 'permerror'; result: 'pass' | 'fail' | 'softfail' | 'neutral' | 'none' | 'temperror' | 'permerror';
domain?: string; domain?: string;
ip?: string; ip?: string;
/**
* All SPF results when the server evaluated multiple identities (HELO and
* MAIL FROM). Present only when more than one result was found; `result`
* above is the most severe of these.
*/
all?: Array<{
result: 'pass' | 'fail' | 'softfail' | 'neutral' | 'none' | 'temperror' | 'permerror';
domain?: string;
identity?: 'helo' | 'mailfrom';
}>;
}; };
dkim?: { dkim?: {
result: 'pass' | 'fail' | 'policy' | 'neutral' | 'temperror' | 'permerror'; result: 'pass' | 'fail' | 'policy' | 'neutral' | 'temperror' | 'permerror';
@@ -488,6 +498,10 @@ export interface Calendar {
// can route mutations to the right client. Distinct from `accountId` // can route mutations to the right client. Distinct from `accountId`
// which is the JMAP server's own account UUID. // which is the JMAP server's own account UUID.
localAccountId?: string; localAccountId?: string;
// Set when `color` has been replaced by the viewer's local override for a
// shared calendar (see lib/shared-calendar-colors). When true, the override
// wins over per-event colors so the whole shared calendar paints uniformly.
colorIsLocalOverride?: boolean;
} }
export interface CalendarRights { export interface CalendarRights {
+154
View File
@@ -0,0 +1,154 @@
// Builds an RFC 8098 Message Disposition Notification (MDN) as a raw RFC 5322
// message string. JMAP/Stalwart has no native MDN support, so the client
// constructs the multipart/report itself and sends it via
// blob-upload -> Email/import -> EmailSubmission/set (see client.sendReadReceipt).
//
// The message has two parts:
// 1. text/plain — human-readable explanation (English, ASCII; rarely shown)
// 2. message/disposition-notification — the machine-readable fields
// The optional third part (original message/headers) is omitted; RFC 8098 §3.1
// permits a two-part report.
export interface MdnOptions {
/** Address that requested the receipt (Disposition-Notification-To) — the MDN recipient. */
to: string;
/** Our identity address (sender of the MDN). */
fromEmail: string;
/** Optional display name for the From header. */
fromName?: string;
/** Original Message-ID. JMAP may hand this back as a string[]
* (header:Message-ID:asMessageIds), so accept both. */
originalMessageId?: string | string[];
/** Original Subject (used to build the MDN subject). */
originalSubject?: string;
/**
* The address the original message was delivered to (our address/alias).
* Used for Final-Recipient/Original-Recipient. Falls back to fromEmail.
*/
originalRecipient?: string;
/** true => automatic-action (setting "always"); false => manual-action (user clicked send). */
automatic?: boolean;
/** Reporting-UA value, e.g. "mail.dornig.de; Bulwark Webmail". */
reportingUa?: string;
/** Localized full Subject line. Defaults to "Read: <originalSubject>". */
subject?: string;
/** Localized human-readable explanation (first report part). Defaults to English. */
humanText?: string;
}
const DAYS = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
const MONTHS = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];
/** RFC 5322 date in UTC, e.g. "Thu, 28 May 2026 14:23:00 +0000". */
function rfc5322Date(d: Date = new Date()): string {
const pad = (n: number) => String(n).padStart(2, "0");
return `${DAYS[d.getUTCDay()]}, ${pad(d.getUTCDate())} ${MONTHS[d.getUTCMonth()]} ${d.getUTCFullYear()} ` +
`${pad(d.getUTCHours())}:${pad(d.getUTCMinutes())}:${pad(d.getUTCSeconds())} +0000`;
}
/** UTF-8 string -> base64, without the deprecated unescape(). */
function utf8ToBase64(value: string): string {
const bytes = new TextEncoder().encode(value);
let binary = "";
for (let i = 0; i < bytes.length; i++) binary += String.fromCharCode(bytes[i]);
return btoa(binary);
}
/** UTF-8 base64 body, wrapped at 76 chars per RFC 2045. */
function base64Body(text: string): string {
return (utf8ToBase64(text).match(/.{1,76}/g) || []).join("\r\n");
}
/** RFC 2047 encoded-word for header values that contain non-ASCII characters. */
function encodeHeaderWord(value: string): string {
// eslint-disable-next-line no-control-regex
if (!/[^\x00-\x7F]/.test(value)) return value;
return `=?UTF-8?B?${utf8ToBase64(value)}?=`;
}
function ensureAngles(messageId: string | string[] | undefined): string {
// JMAP often returns Message-ID as a string[] (header:...:asMessageIds), so
// normalize string | string[] | undefined down to a single bracketed id.
const raw = Array.isArray(messageId) ? messageId[0] : messageId;
if (typeof raw !== "string") return "";
const trimmed = raw.trim();
if (!trimmed) return "";
return trimmed.startsWith("<") ? trimmed : `<${trimmed}>`;
}
function randomToken(): string {
const rnd = Math.random().toString(36).slice(2);
return `${Date.now().toString(36)}.${rnd}`;
}
/**
* Build the raw RFC 5322 MDN message. Lines are CRLF-terminated as required
* by the MIME standard so the bytes import/transmit verbatim.
*/
export function buildMdnMessage(opts: MdnOptions): string {
const finalRecipient = opts.originalRecipient || opts.fromEmail;
const domain = (opts.fromEmail.split("@")[1] || "localhost").trim();
const messageId = `<mdn.${randomToken()}@${domain}>`;
const boundary = `----=_MDN_${randomToken()}`;
const origMsgId = ensureAngles(opts.originalMessageId); // normalized "<...>" or ""
const fromHeader = opts.fromName
? `${encodeHeaderWord(opts.fromName)} <${opts.fromEmail}>`
: opts.fromEmail;
const subject = encodeHeaderWord(
opts.subject ?? `Read: ${opts.originalSubject || ""}`.trim()
);
const disposition = opts.automatic
? "automatic-action/MDN-sent-automatically; displayed"
: "manual-action/MDN-sent-manually; displayed";
const reportingUa = opts.reportingUa || `${domain}; Bulwark Webmail`;
// Human-readable part. Caller passes a localized humanText; fall back to
// English. Encoded as UTF-8/base64 below so any language survives.
const humanText = opts.humanText ?? [
`This is a return receipt for the message you sent to ${finalRecipient}.`,
``,
`Note: This receipt only acknowledges that the message was displayed on the`,
`recipient's computer. There is no guarantee that the recipient has read or`,
`understood the message contents.`,
].join("\r\n");
// Machine-readable disposition-notification part (pure ASCII tokens).
const mdnFields = [
`Reporting-UA: ${reportingUa}`,
`Final-Recipient: rfc822;${finalRecipient}`,
...(opts.originalRecipient ? [`Original-Recipient: rfc822;${opts.originalRecipient}`] : []),
...(origMsgId ? [`Original-Message-ID: ${origMsgId}`] : []),
`Disposition: ${disposition}`,
].join("\r\n");
return [
`Date: ${rfc5322Date()}`,
`From: ${fromHeader}`,
`To: ${opts.to}`,
`Subject: ${subject}`,
`Message-ID: ${messageId}`,
...(origMsgId ? [`In-Reply-To: ${origMsgId}`] : []),
`MIME-Version: 1.0`,
`Content-Type: multipart/report; report-type=disposition-notification;`,
`\tboundary="${boundary}"`,
``,
`--${boundary}`,
`Content-Type: text/plain; charset=utf-8`,
`Content-Transfer-Encoding: base64`,
``,
base64Body(humanText),
``,
`--${boundary}`,
`Content-Type: message/disposition-notification`,
`Content-Transfer-Encoding: 7bit`,
``,
mdnFields,
``,
`--${boundary}--`,
``,
].join("\r\n");
}
+6
View File
@@ -19,6 +19,12 @@ export function getDiscoveryValidator(): EndpointValidator | undefined {
function getGlobalClientSecret(): string { function getGlobalClientSecret(): string {
const adminSecret = configManager.get<string>('oauthClientSecret', ''); const adminSecret = configManager.get<string>('oauthClientSecret', '');
if (adminSecret) return adminSecret; if (adminSecret) return adminSecret;
const adminFileSecret = readFileEnv(
configManager.get<string>('oauthClientSecretFile', ''),
);
if (adminFileSecret) return adminFileSecret;
return process.env.OAUTH_CLIENT_SECRET || readFileEnv(process.env.OAUTH_CLIENT_SECRET_FILE) || ''; return process.env.OAUTH_CLIENT_SECRET || readFileEnv(process.env.OAUTH_CLIENT_SECRET_FILE) || '';
} }
+12 -19
View File
@@ -10,32 +10,25 @@ import {
activateAllSandboxed, activateAllSandboxed,
deactivateAllSandboxed, deactivateAllSandboxed,
setSandboxStoreAccessor, setSandboxStoreAccessor,
setSandboxLocale,
setupSandboxAutoDisable, setupSandboxAutoDisable,
} from './plugin-sandbox/loader'; } from './plugin-sandbox/loader';
import { all as allActive, get as getActive } from './plugin-sandbox/registry'; import { all as allActive, get as getActive } from './plugin-sandbox/registry';
// Re-export so the plugin store can keep the sandbox locale in step via this
// facade, instead of importing lib/plugin-sandbox/loader directly (which would
// also pull the hook buses into consumers' module graphs).
export { setSandboxLocale } from './plugin-sandbox/loader';
/** /**
* Previously: re-published React/ReactDOM on `globalThis.__PLUGIN_EXTERNALS__` * Historically re-published React/ReactDOM on `globalThis` for the blob-import
* so blob-imported plugin code could resolve `react`. With the sandbox model * loader, and later also bootstrapped plugin locale sync. Both are obsolete:
* plugins receive React injected as a function argument inside their iframe * the sandbox injects React per-iframe, and locale sync now lives where plugin
* runtime - there is nothing to expose on the host window. * activation is orchestrated (stores/plugin-store -> initializePlugins, via
* * setSandboxLocale). Kept as a no-op for the legacy activateAllPlugins()
* Kept as a no-op for callers that still invoke it during app bootstrap. * wrapper and its test.
*/ */
export function exposePluginExternals(): void { export function exposePluginExternals(): void {
if (typeof window === 'undefined') return; /* no-op */
// Initialise the locale sync once. Importing the store lazily avoids the
// circular module graph we used to fight before the sandbox refactor.
void import('@/stores/locale-store').then(({ useLocaleStore }) => {
setSandboxLocale(useLocaleStore.getState().locale);
useLocaleStore.subscribe((state) => setSandboxLocale(state.locale));
// Mirror on a global so the slot-iframe component can read it at spawn.
(globalThis as unknown as { __APP_LOCALE__?: string }).__APP_LOCALE__ = useLocaleStore.getState().locale;
useLocaleStore.subscribe((state) => {
(globalThis as unknown as { __APP_LOCALE__?: string }).__APP_LOCALE__ = state.locale;
});
}).catch(() => { /* locale sync is best-effort */ });
} }
// ─── Store accessor (status updates) ────────────────────────── // ─── Store accessor (status updates) ──────────────────────────
+35
View File
@@ -5,6 +5,40 @@
import type { Email } from '@/lib/jmap/types'; import type { Email } from '@/lib/jmap/types';
import type { EmailReadView } from '@/lib/plugin-types'; import type { EmailReadView } from '@/lib/plugin-types';
// Resolve a message's plain-text body from its JMAP body parts. Plugins that
// translate or scan content need the real body, not just the short `preview`
// snippet. Prefers text/plain parts; falls back to stripped HTML, then preview.
function plainTextFromEmail(email: Email): string {
const values = email.bodyValues || {};
const collect = (parts?: { partId: string }[]) =>
(parts || [])
.map((p) => values[p.partId]?.value)
.filter((v): v is string => typeof v === 'string' && v.length > 0)
.join('\n')
.trim();
const text = collect(email.textBody);
if (text) return text;
const html = collect(email.htmlBody);
if (html) {
return html
.replace(/<(script|style)[\s\S]*?<\/\1>/gi, ' ')
.replace(/<[^>]+>/g, ' ')
.replace(/&nbsp;/gi, ' ')
.replace(/&amp;/gi, '&')
.replace(/&lt;/gi, '<')
.replace(/&gt;/gi, '>')
.replace(/&#39;|&apos;/gi, "'")
.replace(/&quot;/gi, '"')
.replace(/[ \t]{2,}/g, ' ')
.replace(/\n{3,}/g, '\n\n')
.trim();
}
return (email.preview || '').trim();
}
export function emailToReadView(email: Email): EmailReadView { export function emailToReadView(email: Email): EmailReadView {
return { return {
id: email.id, id: email.id,
@@ -19,6 +53,7 @@ export function emailToReadView(email: Email): EmailReadView {
isFlagged: !!email.keywords?.['$flagged'], isFlagged: !!email.keywords?.['$flagged'],
hasAttachment: email.hasAttachment, hasAttachment: email.hasAttachment,
preview: email.preview || '', preview: email.preview || '',
text: plainTextFromEmail(email),
keywords: Object.keys(email.keywords || {}).filter(k => email.keywords[k]), keywords: Object.keys(email.keywords || {}).filter(k => email.keywords[k]),
auth: email.authenticationResults, auth: email.authenticationResults,
}; };
+1 -1
View File
@@ -140,7 +140,7 @@ async function doHttpPost(plugin: InstalledPlugin, path: string, body: unknown):
headers['Authorization'] = client.getAuthHeader(); headers['Authorization'] = client.getAuthHeader();
headers['X-JMAP-Username'] = client.getUsername(); headers['X-JMAP-Username'] = client.getUsername();
} }
const res = await fetch(url.pathname + url.search, { const res = await apiFetch(url.pathname + url.search, {
method: 'POST', method: 'POST',
headers, headers,
body: JSON.stringify(body), body: JSON.stringify(body),
+13 -1
View File
@@ -10,6 +10,8 @@
import type { InstalledPlugin, SlotName } from '../plugin-types'; import type { InstalledPlugin, SlotName } from '../plugin-types';
import { dispatchApiCall } from './host-api'; import { dispatchApiCall } from './host-api';
import { SANDBOX_PATH } from './protocol'; import { SANDBOX_PATH } from './protocol';
import { withBasePath } from '../browser-navigation';
import { snapshotHostTheme, type ThemeSnapshot } from './host-theme';
import type { import type {
SandboxToHost, HostToSandbox, InitMsg, InitPayload, SandboxToHost, HostToSandbox, InitMsg, InitPayload,
} from './protocol'; } from './protocol';
@@ -143,7 +145,10 @@ export class SandboxInstance {
this.iframe.style.width = '100%'; this.iframe.style.width = '100%';
this.iframe.style.height = '0px'; this.iframe.style.height = '0px';
} }
this.iframe.src = SANDBOX_PATH; // Prefix with the mount path so the sandbox route resolves under a
// subpath deployment (NEXT_PUBLIC_BASE_PATH=/webmail). A bare
// "/plugin-sandbox" would hit the origin root and 404, breaking plugins.
this.iframe.src = withBasePath(SANDBOX_PATH);
this.listener = (ev) => this.onMessage(ev); this.listener = (ev) => this.onMessage(ev);
window.addEventListener('message', this.listener); window.addEventListener('message', this.listener);
@@ -274,6 +279,12 @@ export class SandboxInstance {
this.send({ type: 'locale-change', locale }); this.send({ type: 'locale-change', locale });
} }
/** Push a new resolved theme so the slot iframe re-injects its theme CSS. */
setTheme(theme: ThemeSnapshot): void {
if (this.destroyed) return;
this.send({ type: 'theme-change', theme });
}
updateProps(props: Record<string, unknown>): void { updateProps(props: Record<string, unknown>): void {
if (this.destroyed) return; if (this.destroyed) return;
// Stale references would leak if we kept growing the table without // Stale references would leak if we kept growing the table without
@@ -342,6 +353,7 @@ export function createSlotInstance(opts: SlotOptions): SandboxInstance {
}, },
extraProps: opts.extraProps, extraProps: opts.extraProps,
locale: opts.locale, locale: opts.locale,
theme: snapshotHostTheme(),
}; };
return new SandboxInstance(opts.plugin, payload, opts.hostContainer, opts.onResize); return new SandboxInstance(opts.plugin, payload, opts.hostContainer, opts.onResize);
} }
+131
View File
@@ -0,0 +1,131 @@
// Theme bridge for plugin slot iframes.
//
// Slot iframes run with an opaque ("null") origin, so they can't load the
// host's globals.css or web fonts cross-origin (see app/(sandbox)/layout.tsx).
// The result: plugin slot UIs fall back to the UA default serif font and have
// no knowledge of the host's light/dark (or custom) theme.
//
// To fix that without any cross-origin asset fetch, the host snapshots the
// *resolved* theme — the computed `--color-*` token values, the resolved
// font-family (which is a pure system-font stack, so no fetch is needed), and
// whether dark mode is active — and ships it across the postMessage bridge.
// The sandbox runtime replays the snapshot as an injected <style> block plus a
// `.dark` class, so every plugin slot inherits the app font and can react to
// theme changes by reading `var(--color-*)`.
/** A replayable description of the host's currently resolved theme. */
export interface ThemeSnapshot {
/** Whether the host has dark mode active (`.dark` on <html>). */
dark: boolean;
/** Resolved font-family stack (system fonts only — safe to replay verbatim). */
fontFamily: string;
/** Resolved values for each mirrored CSS custom property. */
vars: Record<string, string>;
}
// CSS custom properties mirrored into plugin slots. Kept in sync with the
// `:root` token block in app/globals.css. Both the colour tokens (the stable
// theming API surface) and the tier-2 typography/density tokens are included so
// plugins can match the host's metrics, not just its colours.
const THEME_TOKENS: readonly string[] = [
'--color-border',
'--color-input',
'--color-ring',
'--color-background',
'--color-foreground',
'--color-primary',
'--color-primary-foreground',
'--color-secondary',
'--color-secondary-foreground',
'--color-muted',
'--color-muted-foreground',
'--color-accent',
'--color-accent-foreground',
'--color-destructive',
'--color-destructive-foreground',
'--color-popover',
'--color-popover-foreground',
'--color-sidebar',
'--color-sidebar-foreground',
'--color-sidebar-border',
'--color-sidebar-accent',
'--color-sidebar-accent-foreground',
'--color-card',
'--color-card-foreground',
'--color-success',
'--color-success-foreground',
'--color-warning',
'--color-warning-foreground',
'--color-info',
'--color-info-foreground',
'--color-selection',
'--color-selection-foreground',
'--color-unread',
'--color-chart-1',
'--color-chart-2',
'--color-chart-3',
'--color-chart-4',
'--color-chart-5',
'--font-size-base',
'--list-item-height',
'--transition-duration',
'--density-item-py',
'--density-item-gap',
'--density-header-py',
'--density-card-p',
'--density-sidebar-py',
];
// Mirrors the body font stack in app/globals.css. Used when no document is
// available (SSR) or the body has no resolvable font-family yet.
const FALLBACK_FONT_FAMILY =
'-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, "Noto Sans Thai", "Leelawadee UI", Tahoma, sans-serif';
/**
* Read the host's resolved theme off `<html>`. Reading computed styles means
* the snapshot automatically reflects the active built-in *and* custom theme,
* not just the static globals.css defaults.
*/
export function snapshotHostTheme(): ThemeSnapshot {
if (typeof document === 'undefined' || typeof getComputedStyle === 'undefined') {
return { dark: false, fontFamily: FALLBACK_FONT_FAMILY, vars: {} };
}
const root = document.documentElement;
const computed = getComputedStyle(root);
const vars: Record<string, string> = {};
for (const token of THEME_TOKENS) {
const value = computed.getPropertyValue(token).trim();
if (value) vars[token] = value;
}
const bodyFont = document.body ? getComputedStyle(document.body).fontFamily : '';
return {
dark: root.classList.contains('dark'),
fontFamily: bodyFont || FALLBACK_FONT_FAMILY,
vars,
};
}
/**
* Build the CSS the sandbox injects to replay a snapshot. Pure (no DOM access)
* so it can run inside the iframe runtime. The `:root` block restores the
* host's token values; the `html, body` block gives plugin slots the app font
* and a theme-aware default text colour with a transparent background (the
* host's themed container shows through).
*/
export function themeSnapshotToCSS(snapshot: ThemeSnapshot): string {
const declarations = Object.entries(snapshot.vars)
.map(([name, value]) => ` ${name}: ${value};`)
.join('\n');
const colorScheme = snapshot.dark ? 'dark' : 'light';
return [
':root {',
declarations,
` color-scheme: ${colorScheme};`,
'}',
'html, body {',
` font-family: ${snapshot.fontFamily};`,
' color: var(--color-foreground, inherit);',
' background: transparent;',
'}',
].join('\n');
}
+9 -4
View File
@@ -41,11 +41,16 @@ export function setSandboxStoreAccessor(a: StoreAccessor): void { storeAccessor
let currentLocale = 'en'; let currentLocale = 'en';
export function setSandboxLocale(locale: string): void { export function setSandboxLocale(locale: string): void {
// Ignore empty/falsy values so a not-yet-seeded locale store can't clobber a
// good locale back to '' - the initial 'en' default stands until the real
// locale arrives via the store subscription.
if (!locale) return;
currentLocale = locale; currentLocale = locale;
// Push to all active background instances. // Background instances read `currentLocale` at load time; the slot-iframe
// Slot iframes inherit locale at spawn time; they're short-lived. // component reads this global at spawn time (plugin-iframe-slot.tsx). Keep
// (We don't import the registry here to avoid a circular import; the // both in step from one place. Already-running instances are not re-pushed,
// PluginIframeSlot subscribes to locale changes on its own.) // so a locale switch only affects plugins/slots loaded afterwards.
(globalThis as unknown as { __APP_LOCALE__?: string }).__APP_LOCALE__ = locale;
} }
// ─── Bundle fetch ───────────────────────────────────────────── // ─── Bundle fetch ─────────────────────────────────────────────
+11
View File
@@ -9,6 +9,7 @@
// class instances. // class instances.
import type { SlotName } from '../plugin-types'; import type { SlotName } from '../plugin-types';
import type { ThemeSnapshot } from './host-theme';
// ─── Sandbox mode ──────────────────────────────────────────── // ─── Sandbox mode ────────────────────────────────────────────
@@ -62,6 +63,12 @@ export interface SlotInit {
*/ */
extraProps: Record<string, unknown>; extraProps: Record<string, unknown>;
locale: string; locale: string;
/**
* Resolved host theme (colour tokens, font stack, dark flag). The sandbox
* can't load globals.css/fonts cross-origin, so the runtime replays this as
* injected CSS + a `.dark` class. Host pushes updates via 'theme-change'.
*/
theme: ThemeSnapshot;
} }
export type InitPayload = BackgroundInit | SlotInit; export type InitPayload = BackgroundInit | SlotInit;
@@ -163,6 +170,9 @@ export interface HookInvokeMsg {
export interface LocaleChangeMsg { type: 'locale-change'; locale: string; } export interface LocaleChangeMsg { type: 'locale-change'; locale: string; }
/** Host → sandbox: the resolved theme changed; re-inject the slot's theme CSS. */
export interface ThemeChangeMsg { type: 'theme-change'; theme: ThemeSnapshot; }
export interface PropsUpdateMsg { type: 'props-update'; props: Record<string, unknown>; } export interface PropsUpdateMsg { type: 'props-update'; props: Record<string, unknown>; }
export interface SlotShouldShowMsg { export interface SlotShouldShowMsg {
@@ -178,6 +188,7 @@ export type HostToSandbox =
| CallbackResponseMsg | CallbackResponseMsg
| HookInvokeMsg | HookInvokeMsg
| LocaleChangeMsg | LocaleChangeMsg
| ThemeChangeMsg
| PropsUpdateMsg | PropsUpdateMsg
| SlotShouldShowMsg; | SlotShouldShowMsg;
+71 -8
View File
@@ -27,6 +27,7 @@ import type {
BackgroundInit, BackgroundInit,
SlotInit, SlotInit,
} from './protocol'; } from './protocol';
import { themeSnapshotToCSS, type ThemeSnapshot } from './host-theme';
import type { SlotName } from '../plugin-types'; import type { SlotName } from '../plugin-types';
// ─── Module-scope state ────────────────────────────────────── // ─── Module-scope state ──────────────────────────────────────
@@ -67,24 +68,54 @@ function sendToHost(msg: SandboxToHost): void {
parentWindow.postMessage(msg, parentOrigin); parentWindow.postMessage(msg, parentOrigin);
} }
// ─── Theme replay ────────────────────────────────────────────
const THEME_STYLE_ID = '__plugin_host_theme';
/**
* Replay a host theme snapshot inside the iframe: inject the token + font CSS
* and mirror the `.dark` class onto <html> so plugin styles that key off
* `.dark` (or read `var(--color-*)`) behave like the host. Idempotent safe
* to call again on every 'theme-change'.
*/
function applyHostTheme(theme: ThemeSnapshot): void {
if (typeof document === 'undefined') return;
let styleEl = document.getElementById(THEME_STYLE_ID) as HTMLStyleElement | null;
if (!styleEl) {
styleEl = document.createElement('style');
styleEl.id = THEME_STYLE_ID;
document.head.appendChild(styleEl);
}
styleEl.textContent = themeSnapshotToCSS(theme);
document.documentElement.classList.toggle('dark', theme.dark);
}
function uid(): string { function uid(): string {
return Math.random().toString(36).slice(2) + Date.now().toString(36); return Math.random().toString(36).slice(2) + Date.now().toString(36);
} }
// ─── Sandboxed API facade (calls flow to host via postMessage) ─ // ─── Sandboxed API facade (calls flow to host via postMessage) ─
function callApi(method: string, args: unknown[]): Promise<unknown> { const DEFAULT_API_TIMEOUT_MS = 30_000;
function callApi(method: string, args: unknown[], timeoutMs: number = DEFAULT_API_TIMEOUT_MS): Promise<unknown> {
const id = uid(); const id = uid();
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
pendingApi.set(id, { resolve, reject }); pendingApi.set(id, { resolve, reject });
sendToHost({ type: 'api-request', id, method, args }); sendToHost({ type: 'api-request', id, method, args });
// Reject after 30s to prevent unbounded promise leaks if the host hangs. // Bounded so a hung host can't leak the promise forever. Interactive UI
// dialogs (ui.confirm/ui.alert) pass timeoutMs <= 0 to opt out: they wait
// for human input, the host always resolves them on confirm/cancel/close,
// and any still-pending call dies with the iframe on teardown - so there's
// nothing to leak, and a thinking user must not trip a 30s timeout.
if (timeoutMs > 0 && Number.isFinite(timeoutMs)) {
setTimeout(() => { setTimeout(() => {
const entry = pendingApi.get(id); const entry = pendingApi.get(id);
if (!entry) return; if (!entry) return;
pendingApi.delete(id); pendingApi.delete(id);
entry.reject(new Error(`API call ${method} timed out after 30s`)); entry.reject(new Error(`API call ${method} timed out after ${Math.round(timeoutMs / 1000)}s`));
}, 30_000); }, timeoutMs);
}
}); });
} }
@@ -151,12 +182,13 @@ function buildPluginApi(manifest: PluginManifest) {
warning: (m: string) => { void callApi('toast.warning', [m]); }, warning: (m: string) => { void callApi('toast.warning', [m]); },
}, },
ui: { ui: {
/** Opens a host-rendered confirm dialog. Resolves to true on confirm, false otherwise. */ /** Opens a host-rendered confirm dialog. Resolves to true on confirm, false otherwise.
* No timeout - it waits for the user's choice. */
confirm: (opts: { title?: string; message?: string; confirmLabel?: string; cancelLabel?: string; danger?: boolean }) => confirm: (opts: { title?: string; message?: string; confirmLabel?: string; cancelLabel?: string; danger?: boolean }) =>
callApi('ui.confirm', [opts]) as Promise<boolean>, callApi('ui.confirm', [opts], 0) as Promise<boolean>,
/** Opens a host-rendered alert (one button). Resolves once dismissed. */ /** Opens a host-rendered alert (one button). Resolves once dismissed. No timeout. */
alert: (opts: { title?: string; message?: string; confirmLabel?: string }) => alert: (opts: { title?: string; message?: string; confirmLabel?: string }) =>
callApi('ui.alert', [opts]) as Promise<void>, callApi('ui.alert', [opts], 0) as Promise<void>,
/** Opens an http/https URL in a new tab via host `window.open`. */ /** Opens an http/https URL in a new tab via host `window.open`. */
openExternalUrl: (url: string, target?: string) => openExternalUrl: (url: string, target?: string) =>
callApi('ui.openExternalUrl', [url, target]) as Promise<void>, callApi('ui.openExternalUrl', [url, target]) as Promise<void>,
@@ -173,6 +205,26 @@ function buildPluginApi(manifest: PluginManifest) {
warn: (...a: unknown[]) => console.warn(`[plugin:${manifest.id}]`, ...a), warn: (...a: unknown[]) => console.warn(`[plugin:${manifest.id}]`, ...a),
error: (...a: unknown[]) => console.error(`[plugin:${manifest.id}]`, ...a), error: (...a: unknown[]) => console.error(`[plugin:${manifest.id}]`, ...a),
}, },
// Localization for plugins. The host pushes the active locale (init +
// 'locale-change'); `t` resolves a key against the plugin's declared
// `locales` map (manifest.locales), falling back to English then the key
// itself, with optional {placeholder} interpolation.
i18n: {
get locale(): string {
return (globalThis as unknown as { __PLUGIN_LOCALE__?: string }).__PLUGIN_LOCALE__ || 'en';
},
t(key: string, vars?: Record<string, string | number>): string {
const loc = (globalThis as unknown as { __PLUGIN_LOCALE__?: string }).__PLUGIN_LOCALE__ || 'en';
const tables = manifest.locales || {};
let out = tables[loc]?.[key] ?? tables['en']?.[key] ?? key;
if (vars) {
for (const [k, v] of Object.entries(vars)) {
out = out.split('{' + k + '}').join(String(v));
}
}
return out;
},
},
}; };
} }
@@ -281,6 +333,10 @@ async function bootBackground(payload: BackgroundInit): Promise<void> {
} }
function bootSlot(payload: SlotInit): void { function bootSlot(payload: SlotInit): void {
// Replay the host theme before first paint so the slot never flashes the UA
// default serif font or a light-on-light/dark mismatch.
applyHostTheme(payload.theme);
const api = buildPluginApi(payload.manifest); const api = buildPluginApi(payload.manifest);
const exports = evaluateBundle(payload.code, api); const exports = evaluateBundle(payload.code, api);
pluginExports = exports; pluginExports = exports;
@@ -344,6 +400,9 @@ async function handleInit(payload: InitPayload): Promise<void> {
if (bootDone) return; if (bootDone) return;
bootDone = true; bootDone = true;
mode = payload.mode; mode = payload.mode;
// Make the active locale available to plugin code (api.i18n) right away -
// not only after the first 'locale-change' push.
(globalThis as unknown as { __PLUGIN_LOCALE__?: string }).__PLUGIN_LOCALE__ = payload.locale;
try { try {
if (payload.mode === 'background') { if (payload.mode === 'background') {
await bootBackground(payload); await bootBackground(payload);
@@ -430,6 +489,10 @@ function handleHostMessage(ev: MessageEvent): void {
(globalThis as unknown as { __PLUGIN_LOCALE__?: string }).__PLUGIN_LOCALE__ = msg.locale; (globalThis as unknown as { __PLUGIN_LOCALE__?: string }).__PLUGIN_LOCALE__ = msg.locale;
break; break;
case 'theme-change':
applyHostTheme(msg.theme);
break;
case 'props-update': case 'props-update':
slotPropsUpdater?.(msg.props ?? {}); slotPropsUpdater?.(msg.props ?? {});
break; break;
+4
View File
@@ -374,6 +374,10 @@ export interface EmailReadView {
hasAttachment: boolean; hasAttachment: boolean;
preview: string; preview: string;
keywords: string[]; keywords: string[];
/** Full plain-text body of the message (HTML-only bodies are stripped to
* text). Empty string when the host hasn't loaded the body. Same
* `email:read` sensitivity as the rest of this view. */
text: string;
/** /**
* Parsed Authentication-Results header (SPF, DKIM, DMARC, reverse-DNS). * Parsed Authentication-Results header (SPF, DKIM, DMARC, reverse-DNS).
* Absent on stores that didn't parse the header (e.g. bodies not yet * Absent on stores that didn't parse the header (e.g. bodies not yet
+38 -4
View File
@@ -10,6 +10,17 @@ import { formatDateTime } from "@/lib/utils";
import { emailHooks } from "@/lib/plugin-hooks"; import { emailHooks } from "@/lib/plugin-hooks";
import type { QuoteHeader, QuoteHeaderContext } from "@/lib/plugin-types"; import type { QuoteHeader, QuoteHeaderContext } from "@/lib/plugin-types";
// Localized label set the caller passes in. Labels live on the client where
// useTranslations is available; this module stays framework-agnostic.
export interface QuoteHeaderLabels {
/** ICU-formatted reply line, e.g. "On {date}, {from} wrote:" with placeholders already substituted. */
formatReplyLine: (vars: { date: string; from: string }) => string;
forwardedSeparator: string;
fromLabel: string;
dateLabel: string;
subjectLabel: string;
}
interface BuildArgs { interface BuildArgs {
mode: "reply" | "replyAll" | "forward"; mode: "reply" | "replyAll" | "forward";
email: { email: {
@@ -24,10 +35,24 @@ interface BuildArgs {
locale: string; locale: string;
timeFormat: "12h" | "24h"; timeFormat: "12h" | "24h";
unknownLabel: string; unknownLabel: string;
/**
* Localized labels. Optional for backward compatibility; falls back to
* English (matching the original hardcoded behaviour) when not supplied.
*/
labels?: QuoteHeaderLabels;
} }
const DEFAULT_LABELS: QuoteHeaderLabels = {
formatReplyLine: ({ date, from }) => `On ${date}, ${from} wrote:`,
forwardedSeparator: "---------- Forwarded message ----------",
fromLabel: "From",
dateLabel: "Date",
subjectLabel: "Subject",
};
function defaultHeader(args: BuildArgs): QuoteHeader { function defaultHeader(args: BuildArgs): QuoteHeader {
const { mode, email, timeFormat, unknownLabel } = args; const { mode, email, timeFormat, unknownLabel } = args;
const labels = args.labels ?? DEFAULT_LABELS;
const date = email.receivedAt const date = email.receivedAt
? formatDateTime(email.receivedAt, timeFormat, { ? formatDateTime(email.receivedAt, timeFormat, {
weekday: "short", weekday: "short",
@@ -38,16 +63,25 @@ function defaultHeader(args: BuildArgs): QuoteHeader {
: ""; : "";
const from = email.from?.[0]; const from = email.from?.[0];
const fromStr = from ? `${from.name || from.email}` : unknownLabel; const fromStr = from ? `${from.name || from.email}` : unknownLabel;
// Forward header "From:" shows the full sender incl. address ("Name
// <email>"), like every mail client. The reply line keeps the bare name
// (reads more naturally in "On … wrote:").
const fromStrFull = from
? (from.name && from.email && from.name !== from.email
? `${from.name} <${from.email}>`
: (from.email || from.name || unknownLabel))
: unknownLabel;
const subject = email.subject || ""; const subject = email.subject || "";
if (mode === "forward") { if (mode === "forward") {
const text = `---------- Forwarded message ----------\nFrom: ${fromStr}\nDate: ${date}\nSubject: ${subject}\n`; const text = `${labels.forwardedSeparator}\n${labels.fromLabel}: ${fromStrFull}\n${labels.dateLabel}: ${date}\n${labels.subjectLabel}: ${subject}\n`;
const html = `<div>---------- Forwarded message ----------<br>From: ${fromStr}<br>Date: ${date}<br>Subject: ${subject}<br><br></div>`; const html = `<div>${labels.forwardedSeparator}<br>${labels.fromLabel}: ${fromStrFull}<br>${labels.dateLabel}: ${date}<br>${labels.subjectLabel}: ${subject}<br><br></div>`;
return { html, text, wrapInBlockquote: false }; return { html, text, wrapInBlockquote: false };
} }
const text = `On ${date}, ${fromStr} wrote:\n`; const replyLine = labels.formatReplyLine({ date, from: fromStr });
const html = `<div>On ${date}, ${fromStr} wrote:<br></div>`; const text = `${replyLine}\n`;
const html = `<div>${replyLine}<br></div>`;
return { html, text, wrapInBlockquote: true }; return { html, text, wrapInBlockquote: true };
} }
+55
View File
@@ -0,0 +1,55 @@
import type { Calendar } from '@/lib/jmap/types';
/**
* Palette of calendar colors offered in the color picker. Defined here (rather
* than in the settings UI component) so non-React modules can reuse it without
* pulling in component code. The settings color picker re-exports this.
*/
export const CALENDAR_COLORS = [
"#3b82f6", // blue
"#ef4444", // red
"#22c55e", // green
"#f59e0b", // amber
"#8b5cf6", // violet
"#ec4899", // pink
"#14b8a6", // teal
"#f97316", // orange
"#06b6d4", // cyan
"#84cc16", // lime
"#6366f1", // indigo
"#a855f7", // purple
"#e11d48", // rose
"#0ea5e9", // sky
"#10b981", // emerald
"#d946ef", // fuchsia
];
/**
* Stable key for a shared calendar's local color override. Independent of the
* Pro-shell id prefix (which changes with the active account), so the override
* survives shell-mode and account switches. Built from the owning JMAP account
* + the calendar's original server id.
*/
export function sharedCalendarColorKey(
cal: Pick<Calendar, 'id' | 'originalId' | 'accountId' | 'localAccountId'>,
): string {
const localAccount = cal.localAccountId ?? '';
const account = cal.accountId ?? '';
const id = cal.originalId ?? cal.id;
return `${localAccount}|${account}|${id}`;
}
/**
* Pick a random palette color not present in `usedColors`. Once every palette
* entry is taken, fall back to a random palette color (collisions are
* unavoidable past CALENDAR_COLORS.length calendars).
*/
export function pickUnusedCalendarColor(usedColors: Iterable<string>): string {
const used = new Set<string>();
for (const c of usedColors) {
if (c) used.add(c.toLowerCase());
}
const available = CALENDAR_COLORS.filter((c) => !used.has(c.toLowerCase()));
const pool = available.length > 0 ? available : CALENDAR_COLORS;
return pool[Math.floor(Math.random() * pool.length)];
}
+53 -12
View File
@@ -12,42 +12,82 @@ function escapeString(value: string): string {
return value.replace(/\\/g, '\\\\').replace(/"/g, '\\"'); return value.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
} }
// Normalise the condition value to a non-empty string array. Single-value
// conditions stay one-element; arrays are filtered for empty strings.
function toValueList(value: string | string[]): string[] {
const arr = Array.isArray(value) ? value : [value];
return arr.map((v) => (v ?? '').toString()).filter((v) => v.length > 0);
}
// Render one or many strings as a Sieve string-literal-or-list. Sieve treats
// `header :contains "From" ["a", "b"]` as "any of a, b" (built-in OR within
// the condition); the single-string form is emitted unchanged when len === 1
// so existing scripts and tests stay byte-identical.
function formatStringArg(values: string[], transform: (s: string) => string = (s) => s): string {
if (values.length === 1) {
return `"${escapeString(transform(values[0]))}"`;
}
return `[${values.map((v) => `"${escapeString(transform(v))}"`).join(', ')}]`;
}
function generateCondition(condition: FilterCondition): string { function generateCondition(condition: FilterCondition): string {
const { field, comparator, value } = condition; const { field, comparator, value } = condition;
if (field === 'size') { if (field === 'size') {
// Size is numeric, single value only.
const sizeValue = Array.isArray(value) ? value[0] : value;
const op = comparator === 'greater_than' ? ':over' : ':under'; const op = comparator === 'greater_than' ? ':over' : ':under';
return `size ${op} ${value}`; return `size ${op} ${sizeValue}`;
} }
const values = toValueList(value);
if (field === 'body') { if (field === 'body') {
const matchType = comparator === 'is' ? ':is' : ':contains'; const matchType = comparator === 'is' ? ':is' : ':contains';
return `body ${matchType} "${escapeString(value)}"`; return `body ${matchType} ${formatStringArg(values)}`;
}
if (field === 'attachment') {
// RFC 5703: :mime :anychild matches against headers of any MIME part.
// has_any tests Content-Disposition for "attachment"; has_type matches
// the file extension against the filename across BOTH Content-Disposition
// (filename= parameter) and Content-Type (name= parameter) - many older
// senders (Microsoft SMTPSVC, PrintToMail, etc.) put the filename only
// in Content-Type and leave Content-Disposition without a filename.
// RFC 5228 §5.7 allows a string-list for header names; the test passes
// if any listed header matches. Wildcard "*.<ext>*" catches quoted,
// unquoted, and RFC-2231-encoded forms alike since ".<ext>" appears as
// a literal substring in all of them.
// Multiple extensions become a Sieve value-list ["*.pdf*", "*.xml*"]
// = OR within the condition (any item matches → test passes).
if (comparator === 'has_any') {
return `header :mime :anychild :contains "Content-Disposition" "attachment"`;
}
const normalised = values.map((v) => v.replace(/^[.*]+/, '').trim()).filter(Boolean);
return `header :mime :anychild :matches ["Content-Disposition", "Content-Type"] ${formatStringArg(normalised, (ext) => `*.${ext}*`)}`;
} }
const headerName = field === 'header' const headerName = field === 'header'
? (condition.headerName || 'X-Unknown') ? (condition.headerName || 'X-Unknown')
: HEADER_MAP[field]; : HEADER_MAP[field];
const escaped = escapeString(value);
switch (comparator) { switch (comparator) {
case 'contains': case 'contains':
return `header :contains "${headerName}" "${escaped}"`; return `header :contains "${headerName}" ${formatStringArg(values)}`;
case 'not_contains': case 'not_contains':
return `not header :contains "${headerName}" "${escaped}"`; return `not header :contains "${headerName}" ${formatStringArg(values)}`;
case 'is': case 'is':
return `header :is "${headerName}" "${escaped}"`; return `header :is "${headerName}" ${formatStringArg(values)}`;
case 'not_is': case 'not_is':
return `not header :is "${headerName}" "${escaped}"`; return `not header :is "${headerName}" ${formatStringArg(values)}`;
case 'starts_with': case 'starts_with':
return `header :matches "${headerName}" "${escaped}*"`; return `header :matches "${headerName}" ${formatStringArg(values, (v) => `${v}*`)}`;
case 'ends_with': case 'ends_with':
return `header :matches "${headerName}" "*${escaped}"`; return `header :matches "${headerName}" ${formatStringArg(values, (v) => `*${v}`)}`;
case 'matches': case 'matches':
return `header :matches "${headerName}" "${escaped}"`; return `header :matches "${headerName}" ${formatStringArg(values)}`;
default: default:
return `header :contains "${headerName}" "${escaped}"`; return `header :contains "${headerName}" ${formatStringArg(values)}`;
} }
} }
@@ -89,6 +129,7 @@ function computeRequires(rules: FilterRule[], vacation?: VacationSieveConfig): s
for (const rule of enabledRules) { for (const rule of enabledRules) {
for (const condition of rule.conditions) { for (const condition of rule.conditions) {
if (condition.field === 'body') extensions.add('body'); if (condition.field === 'body') extensions.add('body');
if (condition.field === 'attachment') extensions.add('mime');
} }
for (const action of rule.actions) { for (const action of rule.actions) {
switch (action.type) { switch (action.type) {
+133 -22
View File
@@ -36,7 +36,11 @@ const FIELD_FROM_HEADER: Record<string, FilterConditionField> = {
function isValidCondition(c: unknown): boolean { function isValidCondition(c: unknown): boolean {
if (!c || typeof c !== 'object') return false; if (!c || typeof c !== 'object') return false;
const cond = c as Record<string, unknown>; const cond = c as Record<string, unknown>;
return typeof cond.field === 'string' && typeof cond.comparator === 'string' && typeof cond.value === 'string'; if (typeof cond.field !== 'string' || typeof cond.comparator !== 'string') return false;
// value may be a string OR a non-empty array of strings (Patch 11
// multi-value semantics). Accept both.
if (typeof cond.value === 'string') return true;
return Array.isArray(cond.value) && cond.value.every((v) => typeof v === 'string');
} }
function isValidAction(a: unknown): boolean { function isValidAction(a: unknown): boolean {
@@ -334,43 +338,150 @@ function parseAtom(raw: string): FilterCondition | null {
} }
} }
let m = /^header\s+:(contains|is|matches)\s+"((?:[^"\\]|\\.)*)"\s+"((?:[^"\\]|\\.)*)"$/.exec(s); // Parse the value-tail of a header/body test: either a single quoted
// string or a Sieve list literal ["a", "b", ...]. Returns the unwrapped
// value(s), preserving the array shape when present so the caller can
// detect multi-value conditions.
const parseValueTail = (raw: string): string | string[] | null => {
const trimmed = raw.trim();
// List form
if (trimmed.startsWith('[') && trimmed.endsWith(']')) {
const inner = trimmed.slice(1, -1);
const items: string[] = [];
const re = /"((?:[^"\\]|\\.)*)"/g;
let mm: RegExpExecArray | null;
let cursor = 0;
while ((mm = re.exec(inner)) !== null) {
// Ensure only whitespace and commas appear between items
if (inner.slice(cursor, mm.index).replace(/[\s,]/g, '') !== '') return null;
items.push(unescapeSieveString(mm[1]));
cursor = mm.index + mm[0].length;
}
if (inner.slice(cursor).replace(/[\s,]/g, '') !== '') return null;
if (items.length === 0) return null;
return items.length === 1 ? items[0] : items;
}
// Single string form
const single = /^"((?:[^"\\]|\\.)*)"$/.exec(trimmed);
if (single) return unescapeSieveString(single[1]);
return null;
};
// Classify a :matches value (or values) into starts_with / ends_with /
// matches by inspecting wildcard positions. For multi-value, all items
// must share the same shape; otherwise we fall back to 'matches' and
// keep the wildcards verbatim.
const classifyMatches = (
values: string | string[],
): { comparator: 'starts_with' | 'ends_with' | 'matches'; stripped: string | string[] } => {
const arr = Array.isArray(values) ? values : [values];
const isTrailing = (v: string) => {
const stars = [...v].filter((c) => c === '*').length;
return stars === 1 && v.endsWith('*');
};
const isLeading = (v: string) => {
const stars = [...v].filter((c) => c === '*').length;
return stars === 1 && v.startsWith('*');
};
if (arr.every(isTrailing)) {
const stripped = arr.map((v) => v.slice(0, -1));
return { comparator: 'starts_with', stripped: Array.isArray(values) ? stripped : stripped[0] };
}
if (arr.every(isLeading)) {
const stripped = arr.map((v) => v.slice(1));
return { comparator: 'ends_with', stripped: Array.isArray(values) ? stripped : stripped[0] };
}
return { comparator: 'matches', stripped: values };
};
// Match attachment-aware :mime :anychild tests before the generic header
// pattern - emitted by our own generator for field === 'attachment'.
// has_any: ":contains Content-Disposition attachment"
let m = /^header\s+:mime\s+:anychild\s+:contains\s+"Content-Disposition"\s+"attachment"$/.exec(s);
if (m) { if (m) {
const [, tag, headerName, rawValue] = m; return { field: 'attachment', comparator: 'has_any', value: '' };
const value = unescapeSieveString(rawValue); }
// has_type: ":matches <headers> <value-tail>"
// - Current emit form uses a header-list ["Content-Disposition", "Content-Type"]
// to catch senders who put the filename only in Content-Type's name= param
// (Microsoft SMTPSVC, PrintToMail.net, etc.).
// - Legacy emit form used a single "Content-Disposition" header - still
// recognised here so rules saved before the fix remain editable.
// Each value item must be a "*.<ext>*" wildcard pattern.
const tryHasType = (rawHeaders: string, rawValue: string): FilterCondition | null => {
// Header part: accept either a single quoted string or a 2-element list
// containing exactly Content-Disposition + Content-Type (in any order).
const single = /^"Content-Disposition"$/.exec(rawHeaders.trim());
const listForm = /^\[\s*((?:"(?:[^"\\]|\\.)*"\s*,?\s*)+)\]$/.exec(rawHeaders.trim());
let headersOk = false;
if (single) {
headersOk = true;
} else if (listForm) {
const inner = listForm[1];
const items: string[] = [];
const re = /"((?:[^"\\]|\\.)*)"/g;
let mm: RegExpExecArray | null;
while ((mm = re.exec(inner)) !== null) items.push(unescapeSieveString(mm[1]));
const expected = new Set(['Content-Disposition', 'Content-Type']);
const got = new Set(items);
headersOk =
items.length === expected.size &&
[...expected].every((h) => got.has(h));
}
if (!headersOk) return null;
const tail = parseValueTail(rawValue);
if (tail === null) return null;
const arr = Array.isArray(tail) ? tail : [tail];
const exts: string[] = [];
for (const item of arr) {
const em = /^\*\.((?:[^*\\]|\\.)+)\*$/.exec(item);
if (!em) return null;
exts.push(unescapeSieveString(em[1]));
}
return { field: 'attachment', comparator: 'has_type', value: exts.length === 1 ? exts[0] : exts };
};
m = /^header\s+:mime\s+:anychild\s+:matches\s+(\[[\s\S]+?\]|"[^"]+")\s+([\s\S]+)$/.exec(s);
if (m) {
const result = tryHasType(m[1], m[2]);
if (result) return result;
}
// Unknown :mime :anychild pattern (e.g. from external scripts) - bail to
// opaque rendering so we don't silently misrepresent the script.
if (/^header\s+:mime\s+:anychild\b/.test(s)) {
return null;
}
m = /^header\s+:(contains|is|matches)\s+"((?:[^"\\]|\\.)*)"\s+([\s\S]+)$/.exec(s);
if (m) {
const [, tag, headerName, rawTail] = m;
const value = parseValueTail(rawTail);
if (value === null) return null;
const { field, headerName: customHeaderName } = normalizeHeaderName(unescapeSieveString(headerName)); const { field, headerName: customHeaderName } = normalizeHeaderName(unescapeSieveString(headerName));
let comparator: FilterComparator; let comparator: FilterComparator;
let finalValue: string | string[];
if (tag === 'contains') { if (tag === 'contains') {
comparator = negated ? 'not_contains' : 'contains'; comparator = negated ? 'not_contains' : 'contains';
finalValue = value;
} else if (tag === 'is') { } else if (tag === 'is') {
comparator = negated ? 'not_is' : 'is'; comparator = negated ? 'not_is' : 'is';
finalValue = value;
} else { } else {
// :matches - distinguish starts_with / ends_with / matches const classified = classifyMatches(value);
const starPositions = [...value].reduce<number[]>((acc, ch, idx) => (ch === '*' ? [...acc, idx] : acc), []); comparator = classified.comparator;
if (starPositions.length === 1 && starPositions[0] === value.length - 1) { finalValue = classified.stripped;
comparator = 'starts_with';
const cond: FilterCondition = { field, comparator, value: value.slice(0, -1) };
if (customHeaderName !== undefined) cond.headerName = customHeaderName;
return cond;
}
if (starPositions.length === 1 && starPositions[0] === 0) {
comparator = 'ends_with';
const cond: FilterCondition = { field, comparator, value: value.slice(1) };
if (customHeaderName !== undefined) cond.headerName = customHeaderName;
return cond;
}
comparator = 'matches';
} }
const cond: FilterCondition = { field, comparator, value }; const cond: FilterCondition = { field, comparator, value: finalValue };
if (customHeaderName !== undefined) cond.headerName = customHeaderName; if (customHeaderName !== undefined) cond.headerName = customHeaderName;
return cond; return cond;
} }
m = /^body\s+:(contains|is)\s+"((?:[^"\\]|\\.)*)"$/.exec(s); m = /^body\s+:(contains|is)\s+([\s\S]+)$/.exec(s);
if (m) { if (m) {
return { field: 'body', comparator: m[1] === 'is' ? 'is' : 'contains', value: unescapeSieveString(m[2]) }; const value = parseValueTail(m[2]);
if (value === null) return null;
return { field: 'body', comparator: m[1] === 'is' ? 'is' : 'contains', value };
} }
m = /^size\s+:(over|under)\s+(\d+)$/.exec(s); m = /^size\s+:(over|under)\s+(\d+)$/.exec(s);
+29
View File
@@ -124,6 +124,35 @@ export function appendPlainTextSignature(
return `${body}${sep}${plainTextSignature}`; return `${body}${sep}${plainTextSignature}`;
} }
/**
* Append a signature to an HTML body, preserving rich formatting. Used by the
* quick-reply path so an HTML signature keeps its markup instead of being
* flattened to plain text. Mirrors the composer's send-time signature block
* (`buildSignatureHtml` in email-composer.tsx).
*/
export function appendHtmlSignature(
htmlBody: string,
signature?: SignatureSource | null,
options: { separator?: boolean } = {},
): string {
const sep = options.separator === false ? '<br><br>' : '<br><br>-- <br>';
if (signature?.htmlSignature?.trim()) {
return `${htmlBody}${sep}${sanitizeSignatureHtml(signature.htmlSignature)}`;
}
if (signature?.textSignature?.trim()) {
const escaped = signature.textSignature
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/\n/g, '<br>');
return `${htmlBody}${sep}${escaped}`;
}
return htmlBody;
}
export function hasMeaningfulHtmlBody(html: string): boolean { export function hasMeaningfulHtmlBody(html: string): boolean {
if (!html.trim()) return false; if (!html.trim()) return false;
+119
View File
@@ -0,0 +1,119 @@
// Reply / forward subject prefix handling.
//
// Real-world email subjects accumulate prefixes across clients and languages:
// "Re: AW: WG: Fwd: Re: foo". The deduplication regex needs to know ALL
// commonly-used reply/forward markers - not just the current locale's, since
// inbound messages may come from any locale. Failing to strip a foreign-locale
// prefix means the user's locale prefix gets *added on top* and the subject
// chain keeps growing.
//
// Sources: de-facto conventions in Outlook / Thunderbird / Apple Mail per
// language. Includes a handful of legacy short-forms (R:, Fw:) that some
// mobile clients still emit.
const REPLY_TOKENS = [
"Re", // English, Italian, French (also generic ISO)
"RE", // Outlook variant
"AW", // German (Antwort)
"Antw", // German verbose
"Sv", // Danish / Swedish / Norwegian (Svar)
"Yn", // Turkish (Yanit)
"Yanit", // Turkish verbose
"Odp", // Polish (Odpowiedz)
"Ответ", // Russian
"Resp", // Spanish/Portuguese variant
"Vá", // Hungarian
"回复", // Chinese
"回覆", // Chinese traditional
"답장", // Korean
// NB: deliberately no bare "R" token — a single letter would strip the first
// word of legitimate subjects like "R: budget 2024". The full "Re" covers
// the common Italian/English case anyway.
];
const FORWARD_TOKENS = [
"Fwd", // English standard
"Fw", // English short / Polish / German short
"WG", // German (Weitergeleitet)
"Tr", // French (Transfert)
"Vs", // Danish (Videresend)
"Enc", // Portuguese (Encaminhar)
"ENC", // Portuguese caps
"Rv", // Spanish (Reenviar)
"RV", // Spanish caps
"Rvf", // Spanish variant
"Inol", // Italian (Inoltro)
// NB: deliberately no bare "I" token — see the REPLY_TOKENS note above.
"PD", // Polish (Przekazane Dalej)
"PR", // Czech (Preposlat)
"İlt", // Turkish (Ilet)
"Ilt", // Turkish ASCII
"Пересл", // Russian (Peresylka)
"Пер", // Russian short
"转发", // Chinese
"轉寄", // Chinese traditional
"전달", // Korean
];
// Match a single prefix token + optional [N] counter (Outlook) or *N (Eudora)
// + colon + whitespace. Case-insensitive. The non-capturing groups keep the
// regex composable for stripping multiple prefixes in a row.
function buildPrefixRegex(tokens: string[]): RegExp {
// Escape regex specials in tokens (none currently, but be defensive)
const escaped = tokens.map((t) => t.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"));
// Sort by length DESC so longer tokens (e.g. "Пересл") win over their
// shorter prefixes (e.g. "Пер") during alternation matching.
escaped.sort((a, b) => b.length - a.length);
return new RegExp(
`^\\s*(?:${escaped.join("|")})(?:\\[\\d+\\]|\\*\\d*)?\\s*:\\s*`,
"i",
);
}
const ANY_PREFIX_RE = buildPrefixRegex([...REPLY_TOKENS, ...FORWARD_TOKENS]);
/**
* Strip any leading sequence of reply/forward prefixes (across languages) from
* a subject line. Idempotent and safe for empty input.
*
* Examples:
* stripSubjectPrefixes("Re: AW: WG: foo") === "foo"
* stripSubjectPrefixes("Re[2]: foo") === "foo"
* stripSubjectPrefixes("RE: Re: foo") === "foo"
* stripSubjectPrefixes("foo") === "foo"
* stripSubjectPrefixes("") === ""
*/
export function stripSubjectPrefixes(subject: string | undefined | null): string {
if (!subject) return "";
let s = subject;
// Bounded loop: in practice you never see more than ~10 prefixes; the bound
// protects against pathological input. Each iteration must consume input.
for (let i = 0; i < 20; i++) {
const next = s.replace(ANY_PREFIX_RE, "");
if (next === s) break;
s = next;
}
return s;
}
/**
* Build a reply subject with the given locale-aware prefix. Strips any
* pre-existing prefixes (in any language) first so chains don't accumulate.
*
* buildReplySubject("AW: WG: foo", "Re:") === "Re: foo"
* buildReplySubject("foo", "AW:") === "AW: foo"
* buildReplySubject("", "AW:") === "AW:"
*/
export function buildReplySubject(subject: string | undefined | null, prefix: string): string {
const stripped = stripSubjectPrefixes(subject);
return stripped ? `${prefix} ${stripped}` : prefix;
}
/**
* Build a forward subject. Same logic as buildReplySubject but conceptually
* separate for clarity at the call site.
*/
export function buildForwardSubject(subject: string | undefined | null, prefix: string): string {
const stripped = stripSubjectPrefixes(subject);
return stripped ? `${prefix} ${stripped}` : prefix;
}
+52 -6
View File
@@ -1,4 +1,5 @@
{ {
"meta_description": "Minimalistický webmailový klient využívající protokol JMAP",
"login": { "login": {
"title": "Webmail", "title": "Webmail",
"username_label": "E-mail", "username_label": "E-mail",
@@ -252,6 +253,15 @@
"reschedule_prompt": "Zadejte nové datum/čas, např. 2026-05-04T15:30" "reschedule_prompt": "Zadejte nové datum/čas, např. 2026-05-04T15:30"
}, },
"email_viewer": { "email_viewer": {
"read_receipt": {
"prompt": "Odesílatel žádá o potvrzení o přečtení:",
"send": "Odeslat potvrzení",
"ignore": "Ignorovat",
"sent": "Potvrzení o přečtení odesláno.",
"send_failed": "Potvrzení o přečtení se nepodařilo odeslat",
"mdn_subject": "Přečteno: {subject}",
"mdn_body": "Toto je potvrzení o přečtení zprávy, kterou jste odeslali na adresu {recipient}.\n\nPoznámka: Toto potvrzení pouze potvrzuje, že zpráva byla zobrazena na počítači příjemce. Nezaručuje, že příjemce obsah přečetl nebo mu porozuměl."
},
"no_email_selected": "Není vybrána žádná zpráva", "no_email_selected": "Není vybrána žádná zpráva",
"no_email_description": "Vyberte zprávu ze seznamu pro její zobrazení", "no_email_description": "Vyberte zprávu ze seznamu pro její zobrazení",
"no_conversation_selected": "Není vybrána žádná konverzace", "no_conversation_selected": "Není vybrána žádná konverzace",
@@ -537,6 +547,8 @@
"undo_send": "Vrátit odeslání" "undo_send": "Vrátit odeslání"
}, },
"email_composer": { "email_composer": {
"read_receipt_on": "Vyžádáno potvrzení o přečtení (kliknutím vypnete)",
"read_receipt_off": "Vyžádat potvrzení o přečtení",
"new_message": "Nová zpráva", "new_message": "Nová zpráva",
"reply": "Odpovědět", "reply": "Odpovědět",
"reply_all": "Odpovědět všem", "reply_all": "Odpovědět všem",
@@ -553,8 +565,8 @@
"send": "Odeslat", "send": "Odeslat",
"cancel": "Zrušit", "cancel": "Zrušit",
"attach": "Připojit", "attach": "Připojit",
"attach_photos": "Fotky a videa", "attach_photos": "Photos & Videos",
"attach_files": "Soubory", "attach_files": "Files",
"discard": "Zahodit", "discard": "Zahodit",
"discard_draft_title": "Zahodit koncept?", "discard_draft_title": "Zahodit koncept?",
"discard_draft_confirm": "Máte neuložené změny. Chcete tento koncept zahodit?", "discard_draft_confirm": "Máte neuložené změny. Chcete tento koncept zahodit?",
@@ -637,9 +649,7 @@
"schedule_send_unsupported": "Naplánované odeslání není pro tento účet podporováno.", "schedule_send_unsupported": "Naplánované odeslání není pro tento účet podporováno.",
"schedule_send_cleanup_warning": "Naplánované odeslání bylo vytvořeno, ale vyčištění konceptu selhalo.", "schedule_send_cleanup_warning": "Naplánované odeslání bylo vytvořeno, ale vyčištění konceptu selhalo.",
"send_delay_unsupported": "Prodleva odeslání není pro tento účet podporována.", "send_delay_unsupported": "Prodleva odeslání není pro tento účet podporována.",
"send_delay_unsupported_confirm": "Tento účet nepodporuje prodlevu odeslání. Odeslat ihned?", "send_delay_unsupported_confirm": "Tento účet nepodporuje prodlevu odeslání. Odeslat ihned?"
"attach_photos": "Photos & Videos",
"attach_files": "Files"
}, },
"confirm_dialog": { "confirm_dialog": {
"confirm": "Potvrdit", "confirm": "Potvrdit",
@@ -998,6 +1008,17 @@
} }
}, },
"email_behavior": { "email_behavior": {
"request_read_receipt": {
"label": "Standardně vyžadovat potvrzení o přečtení",
"description": "Při psaní nové zprávy předem zapnout žádost o potvrzení o přečtení."
},
"read_receipt_response": {
"label": "Reagovat na žádosti o potvrzení o přečtení",
"description": "Co dělat, když příchozí zpráva žádá o potvrzení o přečtení.",
"ask": "Vždy se zeptat",
"always": "Vždy odeslat",
"never": "Nikdy neodesílat"
},
"title": "Chování e-mailu", "title": "Chování e-mailu",
"description": "Nakonfigurujte způsob zpracování e-mailů", "description": "Nakonfigurujte způsob zpracování e-mailů",
"mark_read": { "mark_read": {
@@ -1422,6 +1443,7 @@
} }
}, },
"folders": { "folders": {
"role_memos": "Poznámky",
"title": "Složky", "title": "Složky",
"description": "Správa složek pošty a přiřazení standardních rolí", "description": "Správa složek pošty a přiřazení standardních rolí",
"folder_list": "Vaše složky", "folder_list": "Vaše složky",
@@ -1624,7 +1646,10 @@
"actions": "Akce", "actions": "Akce",
"add_action": "Přidat akci", "add_action": "Přidat akci",
"stop_processing": "Zastavit zpracování dalších pravidel", "stop_processing": "Zastavit zpracování dalších pravidel",
"attachment_type_placeholder": "např. pdf, doc, jpg",
"value_placeholder_multi": "Hodnota (více oddělených čárkami)",
"condition_fields": { "condition_fields": {
"attachment": "Příloha",
"from": "Od", "from": "Od",
"to": "Komu", "to": "Komu",
"cc": "Kopie", "cc": "Kopie",
@@ -1634,6 +1659,8 @@
"body": "Tělo zprávy" "body": "Tělo zprávy"
}, },
"comparators": { "comparators": {
"has_any": "je přítomna",
"has_type": "typu",
"contains": "obsahuje", "contains": "obsahuje",
"not_contains": "neobsahuje", "not_contains": "neobsahuje",
"is": "je přesně", "is": "je přesně",
@@ -2615,6 +2642,7 @@
"name_placeholder": "Název kalendáře", "name_placeholder": "Název kalendáře",
"color": "Barva", "color": "Barva",
"change_color": "Změnit barvu", "change_color": "Změnit barvu",
"random_color": "Nová náhodná barva",
"add_calendar": "Přidat kalendář", "add_calendar": "Přidat kalendář",
"edit": "Upravit", "edit": "Upravit",
"delete": "Odstranit", "delete": "Odstranit",
@@ -2786,6 +2814,7 @@
"hint": "Klikněte na libovolnou zprávu vlevo pro začátek, nebo využijte průvodce níže." "hint": "Klikněte na libovolnou zprávu vlevo pro začátek, nebo využijte průvodce níže."
}, },
"files": { "files": {
"open_in_new_tab": "Otevřít na nové kartě",
"title": "Soubory", "title": "Soubory",
"search_placeholder": "Hledat soubory...", "search_placeholder": "Hledat soubory...",
"empty_state_title": "Žádné soubory", "empty_state_title": "Žádné soubory",
@@ -2891,7 +2920,9 @@
"stability_warning": "Nahrávání velkých souborů může způsobit nestabilitu serveru. Odstraněné soubory nemusí být okamžitě odstraněny z úložiště. Používejte s opatrností.", "stability_warning": "Nahrávání velkých souborů může způsobit nestabilitu serveru. Odstraněné soubory nemusí být okamžitě odstraněny z úložiště. Používejte s opatrností.",
"no_accounts": "Žádné připojené účty.", "no_accounts": "Žádné připojené účty.",
"open_folder_tree": "Otevřít strom složek", "open_folder_tree": "Otevřít strom složek",
"other_accounts": "Ostatní účty" "other_accounts": "Ostatní účty",
"migration_title": "Aktualizace vašich souborů…",
"migration_description": "Uspořádání složek a souborů do správné struktury. Toto proběhne pouze jednou."
}, },
"smime": { "smime": {
"your_certificates": "Vaše certifikáty", "your_certificates": "Vaše certifikáty",
@@ -3047,5 +3078,20 @@
"manager": "Správce", "manager": "Správce",
"custom": "Vlastní" "custom": "Vlastní"
} }
},
"quote_header": {
"reply_line": "Dne {date} napsal(a) {from}:",
"forwarded_separator": "---------- Přeposlaná zpráva ----------",
"from_label": "Od",
"date_label": "Datum",
"subject_label": "Předmět"
},
"pwa_install": {
"title": "Nainstalovat {appName}",
"description": "Nainstalujte si naši aplikaci pro rychlý přístup a offline podporu.",
"not_now": "Teď ne",
"install": "Nainstalovat",
"dont_remind": "Už mi to nepřipomínat",
"dismiss_aria": "Zavřít výzvu k instalaci"
} }
} }
+49 -1
View File
@@ -1,4 +1,5 @@
{ {
"meta_description": "Minimalistisk webmail-klient, der bruger JMAP-protokollen",
"login": { "login": {
"title": "Webmail", "title": "Webmail",
"username_label": "Email", "username_label": "Email",
@@ -252,6 +253,15 @@
"reschedule_prompt": "Indtast en ny dato/tid, f.eks. 2026-05-04T15:30" "reschedule_prompt": "Indtast en ny dato/tid, f.eks. 2026-05-04T15:30"
}, },
"email_viewer": { "email_viewer": {
"read_receipt": {
"prompt": "Afsenderen anmoder om en læsekvittering:",
"send": "Send kvittering",
"ignore": "Ignorér",
"sent": "Læsekvittering sendt.",
"send_failed": "Læsekvittering kunne ikke sendes",
"mdn_subject": "Læst: {subject}",
"mdn_body": "Dette er en læsekvittering for den besked, du sendte til {recipient}.\n\nBemærk: Denne kvittering bekræfter kun, at beskeden blev vist på modtagerens computer. Der er ingen garanti for, at modtageren har læst eller forstået indholdet."
},
"no_email_selected": "Ingen e-mail valgt", "no_email_selected": "Ingen e-mail valgt",
"no_email_description": "Vælg en e-mail fra listen for at se den her", "no_email_description": "Vælg en e-mail fra listen for at se den her",
"no_conversation_selected": "Ingen samtale valgt", "no_conversation_selected": "Ingen samtale valgt",
@@ -537,6 +547,8 @@
"undo_send": "Fortryd afsendelse" "undo_send": "Fortryd afsendelse"
}, },
"email_composer": { "email_composer": {
"read_receipt_on": "Læsekvittering anmodet (klik for at deaktivere)",
"read_receipt_off": "Anmod om læsekvittering",
"new_message": "Ny besked", "new_message": "Ny besked",
"reply": "Svar", "reply": "Svar",
"reply_all": "Svar alle", "reply_all": "Svar alle",
@@ -999,6 +1011,17 @@
} }
}, },
"email_behavior": { "email_behavior": {
"request_read_receipt": {
"label": "Anmod om læsekvittering som standard",
"description": "Aktivér anmodningen om læsekvittering på forhånd, når du skriver en ny besked."
},
"read_receipt_response": {
"label": "Svar på anmodninger om læsekvittering",
"description": "Hvad der skal ske, når en indgående besked beder om en læsekvittering.",
"ask": "Spørg hver gang",
"always": "Send altid",
"never": "Send aldrig"
},
"title": "E-mail-adfærd", "title": "E-mail-adfærd",
"description": "Konfigurér hvordan e-mails håndteres", "description": "Konfigurér hvordan e-mails håndteres",
"mark_read": { "mark_read": {
@@ -1423,6 +1446,7 @@
} }
}, },
"folders": { "folders": {
"role_memos": "Noter",
"title": "Mapper", "title": "Mapper",
"description": "Administrér dine e-mail-mapper og tildel standardroller", "description": "Administrér dine e-mail-mapper og tildel standardroller",
"folder_list": "Dine mapper", "folder_list": "Dine mapper",
@@ -1625,7 +1649,10 @@
"actions": "Handlinger", "actions": "Handlinger",
"add_action": "Tilføj handling", "add_action": "Tilføj handling",
"stop_processing": "Stop behandling af efterfølgende regler", "stop_processing": "Stop behandling af efterfølgende regler",
"attachment_type_placeholder": "f.eks. pdf, doc, jpg",
"value_placeholder_multi": "Værdi (flere adskilt med komma)",
"condition_fields": { "condition_fields": {
"attachment": "Vedhæftet fil",
"from": "Fra", "from": "Fra",
"to": "Til", "to": "Til",
"cc": "Cc", "cc": "Cc",
@@ -1635,6 +1662,8 @@
"body": "Brødtekst" "body": "Brødtekst"
}, },
"comparators": { "comparators": {
"has_any": "er til stede",
"has_type": "af typen",
"contains": "indeholder", "contains": "indeholder",
"not_contains": "indeholder ikke", "not_contains": "indeholder ikke",
"is": "er præcis", "is": "er præcis",
@@ -2627,6 +2656,7 @@
"name_placeholder": "Kalendernavn", "name_placeholder": "Kalendernavn",
"color": "Farve", "color": "Farve",
"change_color": "Skift farve", "change_color": "Skift farve",
"random_color": "Ny tilfældig farve",
"add_calendar": "Tilføj kalender", "add_calendar": "Tilføj kalender",
"edit": "Redigér", "edit": "Redigér",
"delete": "Slet", "delete": "Slet",
@@ -2807,6 +2837,7 @@
"hint": "Klik på en e-mail til venstre for at komme i gang, eller tag rundvisningen nedenfor." "hint": "Klik på en e-mail til venstre for at komme i gang, eller tag rundvisningen nedenfor."
}, },
"files": { "files": {
"open_in_new_tab": "Åbn i ny fane",
"title": "Filer", "title": "Filer",
"search_placeholder": "Søg efter filer...", "search_placeholder": "Søg efter filer...",
"empty_state_title": "Ingen filer endnu", "empty_state_title": "Ingen filer endnu",
@@ -2912,7 +2943,9 @@
"stability_warning": "Store filuploads kan forårsage serverustabilitet. Slettede filer fjernes muligvis ikke straks fra lageret. Brug med forsigtighed.", "stability_warning": "Store filuploads kan forårsage serverustabilitet. Slettede filer fjernes muligvis ikke straks fra lageret. Brug med forsigtighed.",
"no_accounts": "Ingen tilknyttede konti.", "no_accounts": "Ingen tilknyttede konti.",
"open_folder_tree": "Åbn mappetræ", "open_folder_tree": "Åbn mappetræ",
"other_accounts": "Andre konti" "other_accounts": "Andre konti",
"migration_title": "Opdaterer dine filer…",
"migration_description": "Organiserer mapper og filer i deres rette struktur. Dette sker kun én gang."
}, },
"smime": { "smime": {
"your_certificates": "Dine certifikater", "your_certificates": "Dine certifikater",
@@ -3045,5 +3078,20 @@
}, },
"unified_mailbox": { "unified_mailbox": {
"search_unavailable": "Søgning er ikke tilgængelig i den samlede visning" "search_unavailable": "Søgning er ikke tilgængelig i den samlede visning"
},
"quote_header": {
"reply_line": "Den {date} skrev {from}:",
"forwarded_separator": "---------- Videresendt besked ----------",
"from_label": "Fra",
"date_label": "Dato",
"subject_label": "Emne"
},
"pwa_install": {
"title": "Installer {appName}",
"description": "Installer vores app for hurtig adgang og offline-understøttelse.",
"not_now": "Ikke nu",
"install": "Installer",
"dont_remind": "Påmind mig ikke igen",
"dismiss_aria": "Afvis installationsprompt"
} }
} }
+54 -8
View File
@@ -1,4 +1,5 @@
{ {
"meta_description": "Minimalistischer Webmail-Client mit JMAP-Protokoll",
"login": { "login": {
"title": "Webmail", "title": "Webmail",
"username_label": "E-Mail", "username_label": "E-Mail",
@@ -252,6 +253,15 @@
"reschedule_prompt": "Neues Datum/Uhrzeit eingeben, z. B. 2026-05-04T15:30" "reschedule_prompt": "Neues Datum/Uhrzeit eingeben, z. B. 2026-05-04T15:30"
}, },
"email_viewer": { "email_viewer": {
"read_receipt": {
"prompt": "Der Absender bittet um eine Lesebestätigung:",
"send": "Bestätigung senden",
"ignore": "Ignorieren",
"sent": "Lesebestätigung gesendet.",
"send_failed": "Lesebestätigung konnte nicht gesendet werden",
"mdn_subject": "Gelesen: {subject}",
"mdn_body": "Dies ist eine Lesebestätigung für die Nachricht, die Sie an {recipient} gesendet haben.\n\nHinweis: Diese Bestätigung bestätigt lediglich, dass die Nachricht auf dem Computer des Empfängers angezeigt wurde. Es gibt keine Garantie, dass der Empfänger den Inhalt gelesen oder verstanden hat."
},
"no_email_selected": "Keine E-Mail ausgewählt", "no_email_selected": "Keine E-Mail ausgewählt",
"no_email_description": "Wählen Sie eine E-Mail aus der Liste aus, um sie hier anzuzeigen", "no_email_description": "Wählen Sie eine E-Mail aus der Liste aus, um sie hier anzuzeigen",
"no_conversation_selected": "Keine Unterhaltung ausgewählt", "no_conversation_selected": "Keine Unterhaltung ausgewählt",
@@ -537,6 +547,8 @@
"undo_send": "Senden rückgängig" "undo_send": "Senden rückgängig"
}, },
"email_composer": { "email_composer": {
"read_receipt_on": "Lesebestätigung angefordert (klicken zum Deaktivieren)",
"read_receipt_off": "Lesebestätigung anfordern",
"new_message": "Neue Nachricht", "new_message": "Neue Nachricht",
"reply": "Antworten", "reply": "Antworten",
"reply_all": "Allen antworten", "reply_all": "Allen antworten",
@@ -553,8 +565,8 @@
"send": "Senden", "send": "Senden",
"cancel": "Abbrechen", "cancel": "Abbrechen",
"attach": "Anhängen", "attach": "Anhängen",
"attach_photos": "Fotos & Videos", "attach_photos": "Photos & Videos",
"attach_files": "Dateien", "attach_files": "Files",
"discard": "Verwerfen", "discard": "Verwerfen",
"discard_draft_title": "Entwurf verwerfen?", "discard_draft_title": "Entwurf verwerfen?",
"discard_draft_confirm": "Sie haben ungespeicherte Änderungen. Möchten Sie diesen Entwurf verwerfen?", "discard_draft_confirm": "Sie haben ungespeicherte Änderungen. Möchten Sie diesen Entwurf verwerfen?",
@@ -570,8 +582,8 @@
"subject_label": "Betreff:", "subject_label": "Betreff:",
"file_size_kb": "KB", "file_size_kb": "KB",
"prefix": { "prefix": {
"forward": "Fwd:", "forward": "WG:",
"reply": "Re:" "reply": "AW:"
}, },
"no_subject": "(Kein Betreff)", "no_subject": "(Kein Betreff)",
"unknown_sender": "Unbekannt", "unknown_sender": "Unbekannt",
@@ -637,9 +649,7 @@
"schedule_send_unsupported": "Geplantes Senden wird für dieses Konto nicht unterstützt.", "schedule_send_unsupported": "Geplantes Senden wird für dieses Konto nicht unterstützt.",
"schedule_send_cleanup_warning": "Geplantes Senden wurde erstellt, aber das Bereinigen des Entwurfs ist fehlgeschlagen.", "schedule_send_cleanup_warning": "Geplantes Senden wurde erstellt, aber das Bereinigen des Entwurfs ist fehlgeschlagen.",
"send_delay_unsupported": "Sendeverzögerung wird für dieses Konto nicht unterstützt.", "send_delay_unsupported": "Sendeverzögerung wird für dieses Konto nicht unterstützt.",
"send_delay_unsupported_confirm": "Dieses Konto unterstützt keine Sendeverzögerung. Stattdessen sofort senden?", "send_delay_unsupported_confirm": "Dieses Konto unterstützt keine Sendeverzögerung. Stattdessen sofort senden?"
"attach_photos": "Photos & Videos",
"attach_files": "Files"
}, },
"confirm_dialog": { "confirm_dialog": {
"confirm": "Bestätigen", "confirm": "Bestätigen",
@@ -998,6 +1008,17 @@
} }
}, },
"email_behavior": { "email_behavior": {
"request_read_receipt": {
"label": "Lesebestätigung standardmäßig anfordern",
"description": "Beim Verfassen einer neuen Nachricht die Anforderung einer Lesebestätigung vorab aktivieren."
},
"read_receipt_response": {
"label": "Auf Lesebestätigungs-Anfragen reagieren",
"description": "Verhalten, wenn eine eingehende Nachricht um eine Lesebestätigung bittet.",
"ask": "Jedes Mal fragen",
"always": "Immer senden",
"never": "Nie senden"
},
"title": "E-Mail-Verhalten", "title": "E-Mail-Verhalten",
"description": "Konfigurieren Sie, wie E-Mails verarbeitet werden", "description": "Konfigurieren Sie, wie E-Mails verarbeitet werden",
"mark_read": { "mark_read": {
@@ -1422,6 +1443,7 @@
} }
}, },
"folders": { "folders": {
"role_memos": "Notizen",
"title": "Ordner", "title": "Ordner",
"description": "Verwalten Sie Ihre E-Mail-Ordner und weisen Sie Standardrollen zu", "description": "Verwalten Sie Ihre E-Mail-Ordner und weisen Sie Standardrollen zu",
"folder_list": "Ihre Ordner", "folder_list": "Ihre Ordner",
@@ -1624,7 +1646,10 @@
"actions": "Aktionen", "actions": "Aktionen",
"add_action": "Aktion hinzufügen", "add_action": "Aktion hinzufügen",
"stop_processing": "Verarbeitung nachfolgender Regeln stoppen", "stop_processing": "Verarbeitung nachfolgender Regeln stoppen",
"attachment_type_placeholder": "z.B. pdf, doc, jpg",
"value_placeholder_multi": "Wert (mehrere mit Komma trennen)",
"condition_fields": { "condition_fields": {
"attachment": "Anhang",
"from": "Von", "from": "Von",
"to": "An", "to": "An",
"cc": "Cc", "cc": "Cc",
@@ -1634,6 +1659,8 @@
"body": "Nachrichtentext" "body": "Nachrichtentext"
}, },
"comparators": { "comparators": {
"has_any": "vorhanden",
"has_type": "vom Typ",
"contains": "enthält", "contains": "enthält",
"not_contains": "enthält nicht", "not_contains": "enthält nicht",
"is": "ist genau", "is": "ist genau",
@@ -2615,6 +2642,7 @@
"name_placeholder": "Kalendername", "name_placeholder": "Kalendername",
"color": "Farbe", "color": "Farbe",
"change_color": "Farbe ändern", "change_color": "Farbe ändern",
"random_color": "Neue zufällige Farbe",
"add_calendar": "Kalender hinzufügen", "add_calendar": "Kalender hinzufügen",
"edit": "Bearbeiten", "edit": "Bearbeiten",
"delete": "Löschen", "delete": "Löschen",
@@ -2786,6 +2814,7 @@
"hint": "Klicken Sie links auf eine E-Mail, um loszulegen, oder starten Sie die Tour." "hint": "Klicken Sie links auf eine E-Mail, um loszulegen, oder starten Sie die Tour."
}, },
"files": { "files": {
"open_in_new_tab": "In neuem Tab öffnen",
"title": "Dateien", "title": "Dateien",
"search_placeholder": "Dateien suchen...", "search_placeholder": "Dateien suchen...",
"empty_state_title": "Noch keine Dateien", "empty_state_title": "Noch keine Dateien",
@@ -2891,7 +2920,9 @@
"stability_warning": "Große Datei-Uploads können zu Serverinstabilität führen. Gelöschte Dateien werden möglicherweise nicht sofort aus dem Speicher entfernt. Mit Vorsicht verwenden.", "stability_warning": "Große Datei-Uploads können zu Serverinstabilität führen. Gelöschte Dateien werden möglicherweise nicht sofort aus dem Speicher entfernt. Mit Vorsicht verwenden.",
"no_accounts": "Keine verbundenen Konten.", "no_accounts": "Keine verbundenen Konten.",
"open_folder_tree": "Ordnerbaum öffnen", "open_folder_tree": "Ordnerbaum öffnen",
"other_accounts": "Andere Konten" "other_accounts": "Andere Konten",
"migration_title": "Ihre Dateien werden aktualisiert…",
"migration_description": "Ordner und Dateien werden in die richtige Struktur gebracht. Dies geschieht nur einmal."
}, },
"smime": { "smime": {
"your_certificates": "Ihre Zertifikate", "your_certificates": "Ihre Zertifikate",
@@ -3047,5 +3078,20 @@
"manager": "Verwalten", "manager": "Verwalten",
"custom": "Benutzerdefiniert" "custom": "Benutzerdefiniert"
} }
},
"quote_header": {
"reply_line": "Am {date} schrieb {from}:",
"forwarded_separator": "---------- Weitergeleitete Nachricht ----------",
"from_label": "Von",
"date_label": "Datum",
"subject_label": "Betreff"
},
"pwa_install": {
"title": "{appName} installieren",
"description": "Installiere unsere App für schnellen Zugriff und Offline-Unterstützung.",
"not_now": "Nicht jetzt",
"install": "Installieren",
"dont_remind": "Nicht mehr erinnern",
"dismiss_aria": "Installationshinweis schließen"
} }
} }
+49 -1
View File
@@ -1,4 +1,5 @@
{ {
"meta_description": "Minimalist webmail client using JMAP protocol",
"login": { "login": {
"title": "Webmail", "title": "Webmail",
"username_label": "Email", "username_label": "Email",
@@ -252,6 +253,15 @@
"reschedule_prompt": "Enter a new date/time, e.g. 2026-05-04T15:30" "reschedule_prompt": "Enter a new date/time, e.g. 2026-05-04T15:30"
}, },
"email_viewer": { "email_viewer": {
"read_receipt": {
"prompt": "The sender requested a read receipt:",
"send": "Send receipt",
"ignore": "Ignore",
"sent": "Read receipt sent.",
"send_failed": "Read receipt could not be sent",
"mdn_subject": "Read: {subject}",
"mdn_body": "This is a return receipt for the message you sent to {recipient}.\n\nNote: This receipt only acknowledges that the message was displayed on the recipient''s computer. There is no guarantee that the recipient has read or understood the message contents."
},
"no_email_selected": "No email selected", "no_email_selected": "No email selected",
"no_email_description": "Select an email from the list to view it here", "no_email_description": "Select an email from the list to view it here",
"no_conversation_selected": "No conversation selected", "no_conversation_selected": "No conversation selected",
@@ -537,6 +547,8 @@
"undo_send": "Undo send" "undo_send": "Undo send"
}, },
"email_composer": { "email_composer": {
"read_receipt_on": "Read receipt requested (click to disable)",
"read_receipt_off": "Request a read receipt",
"new_message": "New Message", "new_message": "New Message",
"reply": "Reply", "reply": "Reply",
"reply_all": "Reply All", "reply_all": "Reply All",
@@ -999,6 +1011,17 @@
} }
}, },
"email_behavior": { "email_behavior": {
"request_read_receipt": {
"label": "Request read receipts by default",
"description": "Pre-enable the read-receipt request when composing a new message."
},
"read_receipt_response": {
"label": "Respond to read-receipt requests",
"description": "What to do when an incoming message asks for a read receipt.",
"ask": "Ask each time",
"always": "Always send",
"never": "Never send"
},
"title": "Email Behavior", "title": "Email Behavior",
"description": "Configure how emails are handled", "description": "Configure how emails are handled",
"mark_read": { "mark_read": {
@@ -1423,6 +1446,7 @@
} }
}, },
"folders": { "folders": {
"role_memos": "Notes",
"title": "Folders", "title": "Folders",
"description": "Manage your email folders and assign standard roles", "description": "Manage your email folders and assign standard roles",
"folder_list": "Your Folders", "folder_list": "Your Folders",
@@ -1625,7 +1649,10 @@
"actions": "Actions", "actions": "Actions",
"add_action": "Add Action", "add_action": "Add Action",
"stop_processing": "Stop processing subsequent rules", "stop_processing": "Stop processing subsequent rules",
"attachment_type_placeholder": "e.g. pdf, doc, jpg",
"value_placeholder_multi": "Value (multiple separated by commas)",
"condition_fields": { "condition_fields": {
"attachment": "Attachment",
"from": "From", "from": "From",
"to": "To", "to": "To",
"cc": "Cc", "cc": "Cc",
@@ -1635,6 +1662,8 @@
"body": "Body" "body": "Body"
}, },
"comparators": { "comparators": {
"has_any": "is present",
"has_type": "of type",
"contains": "contains", "contains": "contains",
"not_contains": "does not contain", "not_contains": "does not contain",
"is": "is exactly", "is": "is exactly",
@@ -2628,6 +2657,7 @@
"name_placeholder": "Calendar name", "name_placeholder": "Calendar name",
"color": "Color", "color": "Color",
"change_color": "Change color", "change_color": "Change color",
"random_color": "New random color",
"add_calendar": "Add calendar", "add_calendar": "Add calendar",
"edit": "Edit", "edit": "Edit",
"delete": "Delete", "delete": "Delete",
@@ -2807,6 +2837,7 @@
"hint": "Click any email on the left to get started, or take the tour below." "hint": "Click any email on the left to get started, or take the tour below."
}, },
"files": { "files": {
"open_in_new_tab": "Open in new tab",
"title": "Files", "title": "Files",
"search_placeholder": "Search files...", "search_placeholder": "Search files...",
"empty_state_title": "No files yet", "empty_state_title": "No files yet",
@@ -2912,7 +2943,9 @@
"settings_folder_layout_sidebar": "Sidebar", "settings_folder_layout_sidebar": "Sidebar",
"disabled_title": "Files feature is disabled by your administrator", "disabled_title": "Files feature is disabled by your administrator",
"disabled_description": "Large file uploads via WebDAV can cause Stalwart/RocksDB instability, including out-of-memory crashes and unrecoverable disk usage. Deleted files may not be immediately purged from blob storage. This feature is not recommended for production environments.", "disabled_description": "Large file uploads via WebDAV can cause Stalwart/RocksDB instability, including out-of-memory crashes and unrecoverable disk usage. Deleted files may not be immediately purged from blob storage. This feature is not recommended for production environments.",
"stability_warning": "Large file uploads can cause server instability. Deleted files may not be immediately purged from storage. Use with caution." "stability_warning": "Large file uploads can cause server instability. Deleted files may not be immediately purged from storage. Use with caution.",
"migration_title": "Updating your files…",
"migration_description": "Organising folders and files into their proper structure. This happens only once."
}, },
"smime": { "smime": {
"your_certificates": "Your Certificates", "your_certificates": "Your Certificates",
@@ -3045,5 +3078,20 @@
}, },
"unified_mailbox": { "unified_mailbox": {
"search_unavailable": "Search is not available in the unified view" "search_unavailable": "Search is not available in the unified view"
},
"quote_header": {
"reply_line": "On {date}, {from} wrote:",
"forwarded_separator": "---------- Forwarded message ----------",
"from_label": "From",
"date_label": "Date",
"subject_label": "Subject"
},
"pwa_install": {
"title": "Install {appName}",
"description": "Install our app for quick access and offline support.",
"not_now": "Not now",
"install": "Install",
"dont_remind": "Don't remind me again",
"dismiss_aria": "Dismiss install prompt"
} }
} }
+52 -6
View File
@@ -1,4 +1,5 @@
{ {
"meta_description": "Cliente de correo web minimalista que usa el protocolo JMAP",
"login": { "login": {
"title": "Correo Web", "title": "Correo Web",
"username_label": "Correo electrónico", "username_label": "Correo electrónico",
@@ -252,6 +253,15 @@
"reschedule_prompt": "Introduce una nueva fecha/hora, p. ej. 2026-05-04T15:30" "reschedule_prompt": "Introduce una nueva fecha/hora, p. ej. 2026-05-04T15:30"
}, },
"email_viewer": { "email_viewer": {
"read_receipt": {
"prompt": "El remitente solicita una confirmación de lectura:",
"send": "Enviar confirmación",
"ignore": "Ignorar",
"sent": "Confirmación de lectura enviada.",
"send_failed": "No se pudo enviar la confirmación de lectura",
"mdn_subject": "Leído: {subject}",
"mdn_body": "Este es un acuse de recibo del mensaje que enviaste a {recipient}.\n\nNota: Este acuse solo confirma que el mensaje se mostró en el ordenador del destinatario. No garantiza que el destinatario haya leído o entendido el contenido."
},
"no_email_selected": "Ningún correo seleccionado", "no_email_selected": "Ningún correo seleccionado",
"no_email_description": "Seleccione un correo de la lista para verlo aquí", "no_email_description": "Seleccione un correo de la lista para verlo aquí",
"no_conversation_selected": "Ninguna conversación seleccionada", "no_conversation_selected": "Ninguna conversación seleccionada",
@@ -537,6 +547,8 @@
"undo_send": "Deshacer envío" "undo_send": "Deshacer envío"
}, },
"email_composer": { "email_composer": {
"read_receipt_on": "Confirmación de lectura solicitada (haz clic para desactivar)",
"read_receipt_off": "Solicitar confirmación de lectura",
"new_message": "Nuevo Mensaje", "new_message": "Nuevo Mensaje",
"reply": "Responder", "reply": "Responder",
"reply_all": "Responder a Todos", "reply_all": "Responder a Todos",
@@ -553,8 +565,8 @@
"send": "Enviar", "send": "Enviar",
"cancel": "Cancelar", "cancel": "Cancelar",
"attach": "Adjuntar", "attach": "Adjuntar",
"attach_photos": "Fotos y videos", "attach_photos": "Photos & Videos",
"attach_files": "Archivos", "attach_files": "Files",
"discard": "Descartar", "discard": "Descartar",
"discard_draft_title": "¿Descartar borrador?", "discard_draft_title": "¿Descartar borrador?",
"discard_draft_confirm": "Tiene cambios sin guardar. ¿Desea descartar este borrador?", "discard_draft_confirm": "Tiene cambios sin guardar. ¿Desea descartar este borrador?",
@@ -637,9 +649,7 @@
"schedule_send_unsupported": "El envío programado no es compatible con esta cuenta.", "schedule_send_unsupported": "El envío programado no es compatible con esta cuenta.",
"schedule_send_cleanup_warning": "Se creó el envío programado, pero falló la limpieza del borrador.", "schedule_send_cleanup_warning": "Se creó el envío programado, pero falló la limpieza del borrador.",
"send_delay_unsupported": "La demora de envío no es compatible con esta cuenta.", "send_delay_unsupported": "La demora de envío no es compatible con esta cuenta.",
"send_delay_unsupported_confirm": "Esta cuenta no admite demora de envío. ¿Enviar inmediatamente?", "send_delay_unsupported_confirm": "Esta cuenta no admite demora de envío. ¿Enviar inmediatamente?"
"attach_photos": "Photos & Videos",
"attach_files": "Files"
}, },
"confirm_dialog": { "confirm_dialog": {
"confirm": "Confirmar", "confirm": "Confirmar",
@@ -998,6 +1008,17 @@
} }
}, },
"email_behavior": { "email_behavior": {
"request_read_receipt": {
"label": "Solicitar confirmaciones de lectura de forma predeterminada",
"description": "Activar previamente la solicitud de confirmación de lectura al redactar un mensaje nuevo."
},
"read_receipt_response": {
"label": "Responder a las solicitudes de confirmación de lectura",
"description": "Qué hacer cuando un mensaje entrante solicita una confirmación de lectura.",
"ask": "Preguntar cada vez",
"always": "Enviar siempre",
"never": "No enviar nunca"
},
"title": "Comportamiento del Correo", "title": "Comportamiento del Correo",
"description": "Configure cómo se manejan los correos", "description": "Configure cómo se manejan los correos",
"mark_read": { "mark_read": {
@@ -1422,6 +1443,7 @@
} }
}, },
"folders": { "folders": {
"role_memos": "Notas",
"title": "Carpetas", "title": "Carpetas",
"description": "Administre sus carpetas de correo y asigne roles estándar", "description": "Administre sus carpetas de correo y asigne roles estándar",
"folder_list": "Sus carpetas", "folder_list": "Sus carpetas",
@@ -1624,7 +1646,10 @@
"actions": "Acciones", "actions": "Acciones",
"add_action": "Agregar acción", "add_action": "Agregar acción",
"stop_processing": "Detener el procesamiento de reglas posteriores", "stop_processing": "Detener el procesamiento de reglas posteriores",
"attachment_type_placeholder": "p. ej. pdf, doc, jpg",
"value_placeholder_multi": "Valor (varios separados por comas)",
"condition_fields": { "condition_fields": {
"attachment": "Adjunto",
"from": "De", "from": "De",
"to": "Para", "to": "Para",
"cc": "Cc", "cc": "Cc",
@@ -1634,6 +1659,8 @@
"body": "Cuerpo" "body": "Cuerpo"
}, },
"comparators": { "comparators": {
"has_any": "está presente",
"has_type": "del tipo",
"contains": "contiene", "contains": "contiene",
"not_contains": "no contiene", "not_contains": "no contiene",
"is": "es exactamente", "is": "es exactamente",
@@ -2615,6 +2642,7 @@
"name_placeholder": "Nombre del calendario", "name_placeholder": "Nombre del calendario",
"color": "Color", "color": "Color",
"change_color": "Cambiar color", "change_color": "Cambiar color",
"random_color": "Nuevo color aleatorio",
"add_calendar": "Añadir calendario", "add_calendar": "Añadir calendario",
"edit": "Editar", "edit": "Editar",
"delete": "Eliminar", "delete": "Eliminar",
@@ -2786,6 +2814,7 @@
"hint": "Haz clic en un correo a la izquierda para empezar, o inicia el tour." "hint": "Haz clic en un correo a la izquierda para empezar, o inicia el tour."
}, },
"files": { "files": {
"open_in_new_tab": "Abrir en una pestaña nueva",
"title": "Archivos", "title": "Archivos",
"search_placeholder": "Buscar archivos...", "search_placeholder": "Buscar archivos...",
"empty_state_title": "Aún no hay archivos", "empty_state_title": "Aún no hay archivos",
@@ -2891,7 +2920,9 @@
"stability_warning": "Las cargas de archivos grandes pueden causar inestabilidad del servidor. Los archivos eliminados pueden no eliminarse inmediatamente del almacenamiento. Usar con precaución.", "stability_warning": "Las cargas de archivos grandes pueden causar inestabilidad del servidor. Los archivos eliminados pueden no eliminarse inmediatamente del almacenamiento. Usar con precaución.",
"no_accounts": "No hay cuentas conectadas.", "no_accounts": "No hay cuentas conectadas.",
"open_folder_tree": "Abrir árbol de carpetas", "open_folder_tree": "Abrir árbol de carpetas",
"other_accounts": "Otras cuentas" "other_accounts": "Otras cuentas",
"migration_title": "Actualizando tus archivos…",
"migration_description": "Organizando carpetas y archivos en su estructura adecuada. Esto solo ocurre una vez."
}, },
"smime": { "smime": {
"your_certificates": "Tus certificados", "your_certificates": "Tus certificados",
@@ -3047,5 +3078,20 @@
"manager": "Administrador", "manager": "Administrador",
"custom": "Personalizado" "custom": "Personalizado"
} }
},
"quote_header": {
"reply_line": "El {date}, {from} escribió:",
"forwarded_separator": "---------- Mensaje reenviado ----------",
"from_label": "De",
"date_label": "Fecha",
"subject_label": "Asunto"
},
"pwa_install": {
"title": "Instalar {appName}",
"description": "Instala nuestra app para un acceso rápido y soporte sin conexión.",
"not_now": "Ahora no",
"install": "Instalar",
"dont_remind": "No volver a recordármelo",
"dismiss_aria": "Cerrar aviso de instalación"
} }
} }
+52 -6
View File
@@ -1,4 +1,5 @@
{ {
"meta_description": "Client de messagerie web minimaliste utilisant le protocole JMAP",
"login": { "login": {
"title": "Webmail", "title": "Webmail",
"username_label": "Email", "username_label": "Email",
@@ -252,6 +253,15 @@
"reschedule_prompt": "Saisissez une nouvelle date/heure, p. ex. 2026-05-04T15:30" "reschedule_prompt": "Saisissez une nouvelle date/heure, p. ex. 2026-05-04T15:30"
}, },
"email_viewer": { "email_viewer": {
"read_receipt": {
"prompt": "L''expéditeur demande un accusé de lecture :",
"send": "Envoyer l''accusé",
"ignore": "Ignorer",
"sent": "Accusé de lecture envoyé.",
"send_failed": "Impossible d''envoyer l''accusé de lecture",
"mdn_subject": "Lu : {subject}",
"mdn_body": "Ceci est un accusé de réception du message que vous avez envoyé à {recipient}.\n\nRemarque : cet accusé confirme uniquement que le message a été affiché sur l''ordinateur du destinataire. Il ne garantit pas que le destinataire a lu ou compris le contenu."
},
"no_email_selected": "Aucun email sélectionné", "no_email_selected": "Aucun email sélectionné",
"no_email_description": "Sélectionnez un email dans la liste pour le voir ici", "no_email_description": "Sélectionnez un email dans la liste pour le voir ici",
"no_conversation_selected": "Aucune conversation sélectionnée", "no_conversation_selected": "Aucune conversation sélectionnée",
@@ -537,6 +547,8 @@
"undo_send": "Annuler lenvoi" "undo_send": "Annuler lenvoi"
}, },
"email_composer": { "email_composer": {
"read_receipt_on": "Accusé de lecture demandé (cliquez pour désactiver)",
"read_receipt_off": "Demander un accusé de lecture",
"new_message": "Nouveau message", "new_message": "Nouveau message",
"reply": "Répondre", "reply": "Répondre",
"reply_all": "Répondre à tous", "reply_all": "Répondre à tous",
@@ -553,8 +565,8 @@
"send": "Envoyer", "send": "Envoyer",
"cancel": "Annuler", "cancel": "Annuler",
"attach": "Joindre", "attach": "Joindre",
"attach_photos": "Photos et vidéos", "attach_photos": "Photos & Videos",
"attach_files": "Fichiers", "attach_files": "Files",
"discard": "Supprimer", "discard": "Supprimer",
"discard_draft_title": "Supprimer le brouillon ?", "discard_draft_title": "Supprimer le brouillon ?",
"discard_draft_confirm": "Vous avez des modifications non enregistrées. Voulez-vous supprimer ce brouillon ?", "discard_draft_confirm": "Vous avez des modifications non enregistrées. Voulez-vous supprimer ce brouillon ?",
@@ -637,9 +649,7 @@
"schedule_send_unsupported": "Lenvoi planifié nest pas pris en charge pour ce compte.", "schedule_send_unsupported": "Lenvoi planifié nest pas pris en charge pour ce compte.",
"schedule_send_cleanup_warning": "Lenvoi planifié a été créé, mais le nettoyage du brouillon a échoué.", "schedule_send_cleanup_warning": "Lenvoi planifié a été créé, mais le nettoyage du brouillon a échoué.",
"send_delay_unsupported": "Le délai denvoi nest pas pris en charge pour ce compte.", "send_delay_unsupported": "Le délai denvoi nest pas pris en charge pour ce compte.",
"send_delay_unsupported_confirm": "Ce compte ne prend pas en charge le délai denvoi. Envoyer immédiatement ?", "send_delay_unsupported_confirm": "Ce compte ne prend pas en charge le délai denvoi. Envoyer immédiatement ?"
"attach_photos": "Photos & Videos",
"attach_files": "Files"
}, },
"confirm_dialog": { "confirm_dialog": {
"confirm": "Confirmer", "confirm": "Confirmer",
@@ -998,6 +1008,17 @@
} }
}, },
"email_behavior": { "email_behavior": {
"request_read_receipt": {
"label": "Demander un accusé de lecture par défaut",
"description": "Activer au préalable la demande d''accusé de lecture lors de la rédaction d''un nouveau message."
},
"read_receipt_response": {
"label": "Répondre aux demandes d''accusé de lecture",
"description": "Que faire lorsqu''un message entrant demande un accusé de lecture.",
"ask": "Demander à chaque fois",
"always": "Toujours envoyer",
"never": "Ne jamais envoyer"
},
"title": "Comportement email", "title": "Comportement email",
"description": "Configurez la gestion des emails", "description": "Configurez la gestion des emails",
"mark_read": { "mark_read": {
@@ -1422,6 +1443,7 @@
} }
}, },
"folders": { "folders": {
"role_memos": "Notes",
"title": "Dossiers", "title": "Dossiers",
"description": "Gérez vos dossiers de messagerie et attribuez des rôles standard", "description": "Gérez vos dossiers de messagerie et attribuez des rôles standard",
"folder_list": "Vos dossiers", "folder_list": "Vos dossiers",
@@ -1624,7 +1646,10 @@
"actions": "Actions", "actions": "Actions",
"add_action": "Ajouter une action", "add_action": "Ajouter une action",
"stop_processing": "Arrêter le traitement des règles suivantes", "stop_processing": "Arrêter le traitement des règles suivantes",
"attachment_type_placeholder": "p. ex. pdf, doc, jpg",
"value_placeholder_multi": "Valeur (plusieurs séparées par des virgules)",
"condition_fields": { "condition_fields": {
"attachment": "Pièce jointe",
"from": "De", "from": "De",
"to": "À", "to": "À",
"cc": "Cc", "cc": "Cc",
@@ -1634,6 +1659,8 @@
"body": "Corps" "body": "Corps"
}, },
"comparators": { "comparators": {
"has_any": "est présent",
"has_type": "de type",
"contains": "contient", "contains": "contient",
"not_contains": "ne contient pas", "not_contains": "ne contient pas",
"is": "est exactement", "is": "est exactement",
@@ -2629,6 +2656,7 @@
"name_placeholder": "Nom du calendrier", "name_placeholder": "Nom du calendrier",
"color": "Couleur", "color": "Couleur",
"change_color": "Changer la couleur", "change_color": "Changer la couleur",
"random_color": "Nouvelle couleur aléatoire",
"add_calendar": "Ajouter un calendrier", "add_calendar": "Ajouter un calendrier",
"edit": "Modifier", "edit": "Modifier",
"delete": "Supprimer", "delete": "Supprimer",
@@ -2786,6 +2814,7 @@
"hint": "Cliquez sur un e-mail à gauche pour commencer, ou lancez la visite." "hint": "Cliquez sur un e-mail à gauche pour commencer, ou lancez la visite."
}, },
"files": { "files": {
"open_in_new_tab": "Ouvrir dans un nouvel onglet",
"title": "Fichiers", "title": "Fichiers",
"search_placeholder": "Rechercher des fichiers...", "search_placeholder": "Rechercher des fichiers...",
"empty_state_title": "Aucun fichier pour le moment", "empty_state_title": "Aucun fichier pour le moment",
@@ -2891,7 +2920,9 @@
"stability_warning": "Les téléchargements de fichiers volumineux peuvent provoquer une instabilité du serveur. Les fichiers supprimés peuvent ne pas être immédiatement purgés du stockage. À utiliser avec prudence.", "stability_warning": "Les téléchargements de fichiers volumineux peuvent provoquer une instabilité du serveur. Les fichiers supprimés peuvent ne pas être immédiatement purgés du stockage. À utiliser avec prudence.",
"no_accounts": "Aucun compte connecté.", "no_accounts": "Aucun compte connecté.",
"open_folder_tree": "Ouvrir l'arborescence des dossiers", "open_folder_tree": "Ouvrir l'arborescence des dossiers",
"other_accounts": "Autres comptes" "other_accounts": "Autres comptes",
"migration_title": "Mise à jour de vos fichiers…",
"migration_description": "Organisation des dossiers et fichiers dans leur structure appropriée. Cela ne se produit qu'une seule fois."
}, },
"smime": { "smime": {
"your_certificates": "Vos certificats", "your_certificates": "Vos certificats",
@@ -3047,5 +3078,20 @@
"manager": "Gestionnaire", "manager": "Gestionnaire",
"custom": "Personnalisé" "custom": "Personnalisé"
} }
},
"quote_header": {
"reply_line": "Le {date}, {from} a écrit :",
"forwarded_separator": "---------- Message transféré ----------",
"from_label": "De",
"date_label": "Date",
"subject_label": "Sujet"
},
"pwa_install": {
"title": "Installer {appName}",
"description": "Installez notre application pour un accès rapide et un support hors ligne.",
"not_now": "Pas maintenant",
"install": "Installer",
"dont_remind": "Ne plus me le rappeler",
"dismiss_aria": "Fermer l'invite d'installation"
} }
} }
File diff suppressed because it is too large Load Diff
+52 -6
View File
@@ -1,4 +1,5 @@
{ {
"meta_description": "Client di posta web minimalista che utilizza il protocollo JMAP",
"login": { "login": {
"title": "Webmail", "title": "Webmail",
"username_label": "Email", "username_label": "Email",
@@ -252,6 +253,15 @@
"reschedule_prompt": "Inserisci una nuova data/ora, ad es. 2026-05-04T15:30" "reschedule_prompt": "Inserisci una nuova data/ora, ad es. 2026-05-04T15:30"
}, },
"email_viewer": { "email_viewer": {
"read_receipt": {
"prompt": "Il mittente richiede una conferma di lettura:",
"send": "Invia conferma",
"ignore": "Ignora",
"sent": "Conferma di lettura inviata.",
"send_failed": "Impossibile inviare la conferma di lettura",
"mdn_subject": "Letto: {subject}",
"mdn_body": "Questa è una conferma di lettura del messaggio che hai inviato a {recipient}.\n\nNota: questa conferma attesta solo che il messaggio è stato visualizzato sul computer del destinatario. Non garantisce che il destinatario abbia letto o compreso il contenuto."
},
"no_email_selected": "Nessun messaggio selezionato", "no_email_selected": "Nessun messaggio selezionato",
"no_email_description": "Seleziona un messaggio dall'elenco per visualizzarlo qui", "no_email_description": "Seleziona un messaggio dall'elenco per visualizzarlo qui",
"no_conversation_selected": "Nessuna conversazione selezionata", "no_conversation_selected": "Nessuna conversazione selezionata",
@@ -537,6 +547,8 @@
"undo_send": "Annulla invio" "undo_send": "Annulla invio"
}, },
"email_composer": { "email_composer": {
"read_receipt_on": "Conferma di lettura richiesta (clicca per disattivare)",
"read_receipt_off": "Richiedi una conferma di lettura",
"new_message": "Nuovo messaggio", "new_message": "Nuovo messaggio",
"reply": "Rispondi", "reply": "Rispondi",
"reply_all": "Rispondi a tutti", "reply_all": "Rispondi a tutti",
@@ -553,8 +565,8 @@
"send": "Invia", "send": "Invia",
"cancel": "Annulla", "cancel": "Annulla",
"attach": "Allega", "attach": "Allega",
"attach_photos": "Foto e video", "attach_photos": "Photos & Videos",
"attach_files": "File", "attach_files": "Files",
"discard": "Scarta", "discard": "Scarta",
"discard_draft_title": "Eliminare la bozza?", "discard_draft_title": "Eliminare la bozza?",
"discard_draft_confirm": "Ci sono modifiche non salvate. Vuoi scartare questa bozza?", "discard_draft_confirm": "Ci sono modifiche non salvate. Vuoi scartare questa bozza?",
@@ -637,9 +649,7 @@
"schedule_send_unsupported": "Linvio programmato non è supportato per questo account.", "schedule_send_unsupported": "Linvio programmato non è supportato per questo account.",
"schedule_send_cleanup_warning": "Linvio programmato è stato creato, ma la pulizia della bozza non è riuscita.", "schedule_send_cleanup_warning": "Linvio programmato è stato creato, ma la pulizia della bozza non è riuscita.",
"send_delay_unsupported": "Il ritardo di invio non è supportato per questo account.", "send_delay_unsupported": "Il ritardo di invio non è supportato per questo account.",
"send_delay_unsupported_confirm": "Questo account non supporta il ritardo di invio. Inviare subito?", "send_delay_unsupported_confirm": "Questo account non supporta il ritardo di invio. Inviare subito?"
"attach_photos": "Photos & Videos",
"attach_files": "Files"
}, },
"confirm_dialog": { "confirm_dialog": {
"confirm": "Conferma", "confirm": "Conferma",
@@ -998,6 +1008,17 @@
} }
}, },
"email_behavior": { "email_behavior": {
"request_read_receipt": {
"label": "Richiedi conferme di lettura per impostazione predefinita",
"description": "Attiva in anticipo la richiesta di conferma di lettura durante la composizione di un nuovo messaggio."
},
"read_receipt_response": {
"label": "Rispondi alle richieste di conferma di lettura",
"description": "Cosa fare quando un messaggio in arrivo richiede una conferma di lettura.",
"ask": "Chiedi ogni volta",
"always": "Invia sempre",
"never": "Non inviare mai"
},
"title": "Comportamento email", "title": "Comportamento email",
"description": "Configura come vengono gestiti i messaggi", "description": "Configura come vengono gestiti i messaggi",
"mark_read": { "mark_read": {
@@ -1422,6 +1443,7 @@
} }
}, },
"folders": { "folders": {
"role_memos": "Note",
"title": "Cartelle", "title": "Cartelle",
"description": "Gestisci le cartelle e-mail e assegna ruoli standard", "description": "Gestisci le cartelle e-mail e assegna ruoli standard",
"folder_list": "Le tue cartelle", "folder_list": "Le tue cartelle",
@@ -1624,7 +1646,10 @@
"actions": "Azioni", "actions": "Azioni",
"add_action": "Aggiungi azione", "add_action": "Aggiungi azione",
"stop_processing": "Interrompere l'elaborazione delle regole successive", "stop_processing": "Interrompere l'elaborazione delle regole successive",
"attachment_type_placeholder": "es. pdf, doc, jpg",
"value_placeholder_multi": "Valore (più valori separati da virgole)",
"condition_fields": { "condition_fields": {
"attachment": "Allegato",
"from": "Da", "from": "Da",
"to": "A", "to": "A",
"cc": "Cc", "cc": "Cc",
@@ -1634,6 +1659,8 @@
"body": "Corpo" "body": "Corpo"
}, },
"comparators": { "comparators": {
"has_any": "è presente",
"has_type": "di tipo",
"contains": "contiene", "contains": "contiene",
"not_contains": "non contiene", "not_contains": "non contiene",
"is": "è esattamente", "is": "è esattamente",
@@ -2615,6 +2642,7 @@
"name_placeholder": "Nome del calendario", "name_placeholder": "Nome del calendario",
"color": "Colore", "color": "Colore",
"change_color": "Cambia colore", "change_color": "Cambia colore",
"random_color": "Nuovo colore casuale",
"add_calendar": "Aggiungi calendario", "add_calendar": "Aggiungi calendario",
"edit": "Modifica", "edit": "Modifica",
"delete": "Elimina", "delete": "Elimina",
@@ -2786,6 +2814,7 @@
"hint": "Clicca su un'email a sinistra per iniziare, oppure fai il tour." "hint": "Clicca su un'email a sinistra per iniziare, oppure fai il tour."
}, },
"files": { "files": {
"open_in_new_tab": "Apri in una nuova scheda",
"title": "File", "title": "File",
"search_placeholder": "Cerca file...", "search_placeholder": "Cerca file...",
"empty_state_title": "Nessun file ancora", "empty_state_title": "Nessun file ancora",
@@ -2891,7 +2920,9 @@
"stability_warning": "I caricamenti di file di grandi dimensioni possono causare instabilità del server. I file eliminati potrebbero non essere immediatamente rimossi dallo storage. Usare con cautela.", "stability_warning": "I caricamenti di file di grandi dimensioni possono causare instabilità del server. I file eliminati potrebbero non essere immediatamente rimossi dallo storage. Usare con cautela.",
"no_accounts": "Nessun account collegato.", "no_accounts": "Nessun account collegato.",
"open_folder_tree": "Apri albero cartelle", "open_folder_tree": "Apri albero cartelle",
"other_accounts": "Altri account" "other_accounts": "Altri account",
"migration_title": "Aggiornamento dei tuoi file…",
"migration_description": "Organizzazione di cartelle e file nella loro struttura corretta. Questo avviene solo una volta."
}, },
"smime": { "smime": {
"your_certificates": "I tuoi certificati", "your_certificates": "I tuoi certificati",
@@ -3047,5 +3078,20 @@
"manager": "Gestore", "manager": "Gestore",
"custom": "Personalizzato" "custom": "Personalizzato"
} }
},
"quote_header": {
"reply_line": "Il {date}, {from} ha scritto:",
"forwarded_separator": "---------- Messaggio inoltrato ----------",
"from_label": "Da",
"date_label": "Data",
"subject_label": "Oggetto"
},
"pwa_install": {
"title": "Installa {appName}",
"description": "Installa la nostra app per un accesso rapido e il supporto offline.",
"not_now": "Non ora",
"install": "Installa",
"dont_remind": "Non ricordarmelo più",
"dismiss_aria": "Chiudi avviso di installazione"
} }
} }
+52 -6
View File
@@ -1,4 +1,5 @@
{ {
"meta_description": "JMAPプロトコルを使用するミニマルなウェブメールクライアント",
"login": { "login": {
"title": "ウェブメール", "title": "ウェブメール",
"username_label": "メールアドレス", "username_label": "メールアドレス",
@@ -252,6 +253,15 @@
"reschedule_prompt": "新しい日時を入力してください。例: 2026-05-04T15:30" "reschedule_prompt": "新しい日時を入力してください。例: 2026-05-04T15:30"
}, },
"email_viewer": { "email_viewer": {
"read_receipt": {
"prompt": "送信者が開封確認を求めています:",
"send": "確認を送信",
"ignore": "無視",
"sent": "開封確認を送信しました。",
"send_failed": "開封確認を送信できませんでした",
"mdn_subject": "開封済み: {subject}",
"mdn_body": "これは、あなたが {recipient} に送信したメッセージの開封確認です。\n\n注意: この確認は、メッセージが受信者のコンピューターに表示されたことを示すだけです。受信者が内容を読んだ、または理解したことを保証するものではありません。"
},
"no_email_selected": "メールが選択されていません", "no_email_selected": "メールが選択されていません",
"no_email_description": "リストからメールを選択して表示してください", "no_email_description": "リストからメールを選択して表示してください",
"no_conversation_selected": "会話が選択されていません", "no_conversation_selected": "会話が選択されていません",
@@ -537,6 +547,8 @@
"undo_send": "送信を取り消す" "undo_send": "送信を取り消す"
}, },
"email_composer": { "email_composer": {
"read_receipt_on": "開封確認を要求中(クリックで無効化)",
"read_receipt_off": "開封確認を要求する",
"new_message": "新規メッセージ", "new_message": "新規メッセージ",
"reply": "返信", "reply": "返信",
"reply_all": "全員に返信", "reply_all": "全員に返信",
@@ -553,8 +565,8 @@
"send": "送信", "send": "送信",
"cancel": "キャンセル", "cancel": "キャンセル",
"attach": "添付", "attach": "添付",
"attach_photos": "写真と動画", "attach_photos": "Photos & Videos",
"attach_files": "ファイル", "attach_files": "Files",
"discard": "破棄", "discard": "破棄",
"discard_draft_title": "下書きを破棄しますか?", "discard_draft_title": "下書きを破棄しますか?",
"discard_draft_confirm": "未保存の変更があります。この下書きを破棄しますか?", "discard_draft_confirm": "未保存の変更があります。この下書きを破棄しますか?",
@@ -637,9 +649,7 @@
"schedule_send_unsupported": "このアカウントでは予約送信はサポートされていません。", "schedule_send_unsupported": "このアカウントでは予約送信はサポートされていません。",
"schedule_send_cleanup_warning": "予約送信は作成されましたが、下書きのクリーンアップに失敗しました。", "schedule_send_cleanup_warning": "予約送信は作成されましたが、下書きのクリーンアップに失敗しました。",
"send_delay_unsupported": "このアカウントでは送信遅延はサポートされていません。", "send_delay_unsupported": "このアカウントでは送信遅延はサポートされていません。",
"send_delay_unsupported_confirm": "このアカウントは送信遅延に対応していません。すぐに送信しますか?", "send_delay_unsupported_confirm": "このアカウントは送信遅延に対応していません。すぐに送信しますか?"
"attach_photos": "Photos & Videos",
"attach_files": "Files"
}, },
"confirm_dialog": { "confirm_dialog": {
"confirm": "確認", "confirm": "確認",
@@ -998,6 +1008,17 @@
} }
}, },
"email_behavior": { "email_behavior": {
"request_read_receipt": {
"label": "既定で開封確認を要求する",
"description": "新しいメッセージの作成時に、開封確認の要求をあらかじめ有効にします。"
},
"read_receipt_response": {
"label": "開封確認の要求に応答する",
"description": "受信メッセージが開封確認を求めたときの動作。",
"ask": "毎回確認する",
"always": "常に送信",
"never": "送信しない"
},
"title": "メール動作", "title": "メール動作",
"description": "メールの処理方法を設定", "description": "メールの処理方法を設定",
"mark_read": { "mark_read": {
@@ -1422,6 +1443,7 @@
} }
}, },
"folders": { "folders": {
"role_memos": "メモ",
"title": "フォルダー", "title": "フォルダー",
"description": "メールフォルダーを管理し、標準の役割を割り当てます", "description": "メールフォルダーを管理し、標準の役割を割り当てます",
"folder_list": "フォルダー一覧", "folder_list": "フォルダー一覧",
@@ -1624,7 +1646,10 @@
"actions": "アクション", "actions": "アクション",
"add_action": "アクションを追加", "add_action": "アクションを追加",
"stop_processing": "以降のルールの処理を停止", "stop_processing": "以降のルールの処理を停止",
"attachment_type_placeholder": "例: pdf, doc, jpg",
"value_placeholder_multi": "値(カンマで複数指定可)",
"condition_fields": { "condition_fields": {
"attachment": "添付ファイル",
"from": "差出人", "from": "差出人",
"to": "宛先", "to": "宛先",
"cc": "Cc", "cc": "Cc",
@@ -1634,6 +1659,8 @@
"body": "本文" "body": "本文"
}, },
"comparators": { "comparators": {
"has_any": "あり",
"has_type": "タイプ",
"contains": "を含む", "contains": "を含む",
"not_contains": "を含まない", "not_contains": "を含まない",
"is": "と完全一致", "is": "と完全一致",
@@ -2615,6 +2642,7 @@
"name_placeholder": "カレンダー名", "name_placeholder": "カレンダー名",
"color": "色", "color": "色",
"change_color": "色を変更", "change_color": "色を変更",
"random_color": "新しいランダムな色",
"add_calendar": "カレンダーを追加", "add_calendar": "カレンダーを追加",
"edit": "編集", "edit": "編集",
"delete": "削除", "delete": "削除",
@@ -2786,6 +2814,7 @@
"hint": "左のメールをクリックして始めるか、ツアーを開始してください。" "hint": "左のメールをクリックして始めるか、ツアーを開始してください。"
}, },
"files": { "files": {
"open_in_new_tab": "新しいタブで開く",
"title": "ファイル", "title": "ファイル",
"search_placeholder": "ファイルを検索...", "search_placeholder": "ファイルを検索...",
"empty_state_title": "ファイルがありません", "empty_state_title": "ファイルがありません",
@@ -2891,7 +2920,9 @@
"stability_warning": "大容量ファイルのアップロードはサーバーの不安定性を引き起こす可能性があります。削除されたファイルはストレージからすぐに削除されない場合があります。注意して使用してください。", "stability_warning": "大容量ファイルのアップロードはサーバーの不安定性を引き起こす可能性があります。削除されたファイルはストレージからすぐに削除されない場合があります。注意して使用してください。",
"no_accounts": "接続されているアカウントはありません。", "no_accounts": "接続されているアカウントはありません。",
"open_folder_tree": "フォルダーツリーを開く", "open_folder_tree": "フォルダーツリーを開く",
"other_accounts": "その他のアカウント" "other_accounts": "その他のアカウント",
"migration_title": "ファイルを更新しています…",
"migration_description": "フォルダーとファイルを適切な構造に整理しています。これは一度だけ行われます。"
}, },
"smime": { "smime": {
"your_certificates": "あなたの証明書", "your_certificates": "あなたの証明書",
@@ -3047,5 +3078,20 @@
"manager": "管理者", "manager": "管理者",
"custom": "カスタム" "custom": "カスタム"
} }
},
"quote_header": {
"reply_line": "{date}に{from}が書きました:",
"forwarded_separator": "---------- 転送メッセージ ----------",
"from_label": "差出人",
"date_label": "日付",
"subject_label": "件名"
},
"pwa_install": {
"title": "{appName} をインストール",
"description": "素早いアクセスとオフライン対応のため、アプリをインストールしましょう。",
"not_now": "後で",
"install": "インストール",
"dont_remind": "今後表示しない",
"dismiss_aria": "インストールプロンプトを閉じる"
} }
} }

Some files were not shown because too many files have changed in this diff Show More