Compare commits

...
52 Commits
Author SHA1 Message Date
Linus Rath 00b40fc48a chore: update version to 1.7.2 2026-05-28 20:31:03 +02:00
Linus Rath 856496715b i18n: add missing translation keys across 16 locales 2026-05-28 20:27:40 +02:00
Linus Rath eb7eeae1ac feat: per-domain branding editor in admin panel #332 2026-05-28 20:21:25 +02:00
Linus Rath 6b1a99a70b docs: document DOMAIN_BRANDING env var in examples and README #332 2026-05-28 20:13:11 +02:00
Linus Rath 1da04c254b feat: per-domain branding overrides on /api/config, manifest, pwa-icon #332 2026-05-28 20:06:47 +02:00
Linus Rath 8ae5ecba41 feat: policy-controlled push relay URL with optional user lock 2026-05-28 19:45:14 +02:00
Linus Rath 4ff05bd4ec fix: editable HTML signature in new mail; clean state on every compose entry #329 2026-05-28 19:21:44 +02:00
31e96d6a46 Feat: Scheduled send and send delay #322
* ADD DOC

* Scheduld Send

* add new shortcuts

* fix

* fix

* fix bugs

* rework

* fix draft duplicating

* fix err

* some fixes

* fixes from review

* fixes from review

* fixes from review

* disable password managers for recipients

* fix email store lazy load

* add translations

* fix styling

* fixes

---------

Co-authored-by: Linus Rath <139418639+rathlinus@users.noreply.github.com>
2026-05-28 18:46:49 +02:00
Shuki VakninandLinus Rath 82be047708 fix(email-viewer): stop shattering table cells with word-break: break-word
The global rule

    td, th { word-break: break-word; }

was breaking HTML-email tables one glyph per row whenever a column was
narrow, especially for Hebrew/Arabic/CJK headers and long English
strings. The non-standard `word-break: break-word` keyword behaves
like `break-all` in some engines, splitting words at arbitrary
character boundaries even when the word would fit if the column auto-
expanded.

`overflow-wrap: break-word` is already set on body/table, so the rule
only needs to add min-content relaxation for cells. `overflow-wrap:
anywhere` does exactly that without re-introducing break-all
behaviour.

Repro: any transactional Hebrew/RTL order-summary email — each header
(`מוצר`, `כמות`, `מחיר`) collapses to one glyph per row. After the fix
they render on a single line.

Closes #341.
2026-05-28 18:32:06 +02:00
Linus Rath 7d1fb73290 fix: scope Ctrl/Cmd+Enter send to focused composer 2026-05-28 18:28:07 +02:00
Shuki VakninandLinus Rath 2818a16f06 feat(composer): Ctrl+Enter / Cmd+Enter sends the open draft
Adds the universal "send with the platform modifier" shortcut every
mainstream mail client (Gmail, Outlook, Apple Mail, Proton, Tutanota,
Fastmail, Thunderbird) supports. Closes #343.

Behaviour:

* Window-level keydown listener registered while the composer is
  mounted. Fires when focus is anywhere inside the composer — chip
  inputs, subject, body textarea, or the rich-text contentEditable.
* Plain Enter is untouched; only Enter + Ctrl (Win/Linux) or Cmd
  (macOS) triggers send. Shift/Alt modifiers are ignored so existing
  autocomplete-confirm / chip-commit Enters are not hijacked.
* Routes through a ref so handleSend's per-render rebind doesn't
  re-register the listener every render.
* All existing send-time validation, attachment-warning, draft-save
  and undo-send flows still apply — the shortcut just calls the
  same handleSend() as the toolbar button.
* Listed in the Keyboard Shortcuts dialog under the existing
  Composer section.

Tested:

* Compose -> type body -> Ctrl+Enter -> Outbox.
* Cc/Bcc autocomplete suggestion + Enter still selects (alt-free
  Enter without Ctrl, so the new listener bails).
* Subject input -> Ctrl+Enter -> sends.
* Body Enter without modifier -> newline.
2026-05-28 18:23:37 +02:00
Shuki VakninandGitHub e93dd44111 fix: report real upload progress; XHR with progress events #333
The Files page UI sat at 0% throughout an upload because uploadBlob()
uses fetch(), which does not surface upload progress events. The store
set loaded=0 before the call and loaded=file.size after it, so users
saw the progress bar jump from 0% straight to 100% on completion --
and on slow connections (or large files) it appeared frozen.

Switch uploadBlob() to XHR when the caller passes onProgress or an
AbortSignal, so progress events from xhr.upload.onprogress can drive
the UI. Callers that don't pass either keep the fetch path so we
preserve the existing 401-retry behaviour in authenticatedFetch().

Wire the file store to pass both onProgress (updates uploadProgress
in real time) and the existing AbortController's signal (so cancel
now actually aborts the network request, not just the post-upload
createFileNode step).

uploadBlob() is part of IJMAPClient so the signature change is also
applied to the demo client (synthesises 0% then 100%).
2026-05-28 18:20:22 +02:00
Chuyen NguyenandLinus Rath e86183b44a Fix bug where editing any field closed the form 2026-05-26 08:39:24 +02:00
Linus Rath ad80aa23ca fix: keep empty viewer pane visible in Pro split layout 2026-05-25 19:07:27 +02:00
Linus Rath 3a8daf8bff feat: allow drag-and-drop into shared mailboxes 2026-05-25 18:53:54 +02:00
Linus Rath f8e7cce85a fix: prevent empty main pane when reordering tabs across panes 2026-05-25 18:25:55 +02:00
Linus Rath 62ebe443f9 fix: align continued multi-week events with week's left edge 2026-05-25 18:13:57 +02:00
Linus Rath 3c0faba837 fix: collapse focus mail layout to multi-line on mobile 2026-05-25 18:05:01 +02:00
Linus Rath 956acb69ce feat: add NEXT_PUBLIC_DEFAULT_LOCALE for fallback UI locale #243 2026-05-25 17:01:52 +02:00
Linus Rath e2abc8dee9 fix: prefix remaining <img>, favicon, and WebDAV URLs with basePath #319 2026-05-25 16:45:13 +02:00
Linus Rath 42ec34be21 fix: show end date in event popover for multi-day events #318 2026-05-25 16:28:03 +02:00
Linus Rath 534894c38d feat: locale-aware date format in email list with preset picker #331 2026-05-25 16:17:32 +02:00
Linus Rath 537707d9ed feat: include group inboxes in unified mailbox view #328 2026-05-23 16:01:22 +02:00
Linus Rath afe1e5a67c fix: restore blob: in object-src and frame-src CSP for PDF/HTML previews 2026-05-23 15:53:58 +02:00
Linus Rath d13934c2a6 fix: match user-avatar treatment on quick reply 2026-05-23 15:35:10 +02:00
Linus Rath acc90eb455 feat: add "Move to Trash and mark as read" delete action #323 2026-05-22 19:08:02 +02:00
Linus Rath 5aa6d7a2f0 docs: document OAUTH_ALLOW_PRIVATE_ENDPOINTS in env/config examples 2026-05-22 17:57:41 +02:00
Linus Rath c46de636e0 fix: keep a gutter on bare-HTML emails on mobile 2026-05-22 17:47:24 +02:00
shukivandLinus Rath 8de8babba5 fix(email): keep a small gutter on plain-text emails on mobile
The <=640px rule zeroes .email-content-text horizontal padding, so
plain-text (prose) emails render flush against the viewport edge on
phones, which hurts readability. Use a reduced 0.75rem gutter instead
of 0 — still maximizes width for wide content but keeps text off the
screen edge.
2026-05-22 17:45:53 +02:00
Linus Rath 63f2169ae7 fix: add OAUTH_ALLOW_PRIVATE_ENDPOINTS for split-DNS setups 2026-05-22 17:22:14 +02:00
Linus Rath e843ef0ebb fix: convert recurrenceRules to singular in batch create 2026-05-22 17:00:53 +02:00
Linus Rath 4b463f9691 fix: prefix hand-written URLs with basePath for subpath deployments 2026-05-22 16:38:55 +02:00
Linus Rath 58e4a3d117 feat: add post-export action setting (keep/archive/trash) 2026-05-22 15:36:15 +02:00
Linus Rath e3f6ae874d feat: add settings template for multi-email .zip filename 2026-05-22 15:30:15 +02:00
Linus Rath 52f5a5b42c feat: support importing emails from .zip archives 2026-05-22 15:22:33 +02:00
Linus Rath e7bded82fb i18n: add missing translation keys across 16 locales 2026-05-22 15:09:51 +02:00
Linus Rath 3ac14ecf38 feat: add filename transform settings 2026-05-22 14:57:25 +02:00
Linus Rath 0dca019fe5 fix: stop URL-encoding drag-out filenames and preserve Unicode letters 2026-05-22 14:49:17 +02:00
Linus Rath ca0d6805cf feat: add Downloads settings tab with template editor for .eml and attachment filenames 2026-05-22 14:46:25 +02:00
Linus Rath 8bcb487442 feat: name dragged/exported .eml files as "date (from-to) subject" with ASCII-only chars 2026-05-22 14:28:36 +02:00
Linus Rath 0245ec67e1 feat: enhance email filename generation and sanitization for drag-and-drop functionality 2026-05-22 14:25:30 +02:00
Linus Rath 8810a63262 feat: drag emails out to file explorer as .eml 2026-05-22 14:04:52 +02:00
Linus Rath d3778e6521 fix: handle malformed event dates in calendar route #316 2026-05-22 14:04:02 +02:00
Linus Rath 1fc6185002 chore: update version to 1.7.1 2026-05-22 12:22:03 +02:00
Linus Rath 4269d0589c feat: collapse empty viewer pane and hide placeholder in Pro mode 2026-05-22 12:19:34 +02:00
Linus Rath 2b1b06abd6 fix: collapse empty viewer pane so mail list fills the space 2026-05-22 12:16:49 +02:00
Linus Rath ac4a89120d fix: preserve inline images when replying #163 2026-05-22 12:06:55 +02:00
Linus Rath 704a259432 feat: hide empty-state placeholder in email viewer pane 2026-05-22 12:04:40 +02:00
Linus Rath 1c02970ae1 fix: use canonical INBOX in Sieve filter paths #313 2026-05-22 11:51:25 +02:00
Linus Rath 66b2036e37 feat: expose PWA branding fields in admin Branding tab 2026-05-22 11:22:07 +02:00
Linus Rath bd2ffab3bc fix: resolve destination account id to local namespace in mailbox drop 2026-05-22 11:20:50 +02:00
Linus Rath 7142627cec chore: update version to 1.7.0 2026-05-22 00:51:38 +02:00
105 changed files with 7441 additions and 1037 deletions
+5
View File
@@ -47,3 +47,8 @@ LOG_LEVEL=debug
# LOGIN_IMPRINT_URL=https://example.com/imprint
# LOGIN_PRIVACY_POLICY_URL=https://example.com/privacy
# LOGIN_WEBSITE_URL=https://example.com
# Per-domain branding overrides. Each entry must have "host" (exact or
# "*.subdomain" wildcard) plus any subset of branding fields to override.
# Unset fields fall through to the global values above.
# DOMAIN_BRANDING=[{"host":"localhost","loginCompanyName":"Local Dev"}]
+44
View File
@@ -49,6 +49,11 @@ JMAP_SERVER_URL=https://your-jmap-server.com
# OpenID Connect issuer URL for discovery
# OAUTH_ISSUER_URL=https://your-idp.example.com
# Allow OAuth discovery to resolve to private (RFC-1918 / loopback) addresses.
# Off by default as an SSRF guard. Enable for split-DNS deployments where the
# OAuth issuer's public hostname resolves to an internal IP from this server.
# OAUTH_ALLOW_PRIVATE_ENDPOINTS=true
# =============================================================================
# Session & Security
# =============================================================================
@@ -220,6 +225,29 @@ LOGIN_COMPANY_NAME=Bulwark Webmail
# URL for the company website link on the login page.
LOGIN_WEBSITE_URL=https://bulwarkmail.org
# ---------------------------------------------------------------------------
# Per-domain branding overrides (optional)
# ---------------------------------------------------------------------------
#
# When you serve the webmail on multiple hostnames, each hostname can override
# a subset of branding fields. Unset fields fall back to the global values
# above. Match is on the request's Host (or X-Forwarded-Host) header.
#
# Use the leftmost label "*." to match any subdomain (e.g. "*.example.com"
# matches mail.example.com and any deeper subdomain, but NOT example.com).
# Exact matches always win over wildcards; the longest wildcard suffix wins
# among multiple wildcard matches.
#
# Overridable keys: appName, appShortName, appDescription, faviconUrl,
# pwaIconUrl, pwaThemeColor, pwaBackgroundColor, appLogoLightUrl,
# appLogoDarkUrl, loginLogoLightUrl, loginLogoDarkUrl, loginCompanyName,
# loginImprintUrl, loginPrivacyPolicyUrl, loginWebsiteUrl.
#
# Prefer setting this from the admin dashboard (PATCH /api/admin/config).
# The env-var form is provided for stateless deployments.
#
# DOMAIN_BRANDING=[{"host":"maildomain1.com","loginCompanyName":"Company One","loginLogoLightUrl":"/branding/one-color.svg","loginLogoDarkUrl":"/branding/one-white.svg","loginWebsiteUrl":"https://one.example"},{"host":"maildomain2.com","loginCompanyName":"Company Two","faviconUrl":"/branding/two-favicon.svg"},{"host":"*.intranet.example.com","loginCompanyName":"Internal"}]
# =============================================================================
# Extension Directory / Marketplace
# =============================================================================
@@ -229,6 +257,22 @@ LOGIN_WEBSITE_URL=https://bulwarkmail.org
# your own directory (e.g. http://localhost:3001 for local development).
# EXTENSION_DIRECTORY_URL=https://extensions.bulwarkmail.org
# =============================================================================
# Internationalization
# =============================================================================
# These are build-time variables - to change them with the published Docker
# image, rebuild it with --build-arg (see README "Default UI locale").
#
# Fallback UI locale used when the visitor's Accept-Language header does not
# match any supported locale. Defaults to "en".
# Supported: cs, da, de, en, es, fr, it, ja, ko, lv, nl, pl, pt, ru, tr, uk, zh
# NEXT_PUBLIC_DEFAULT_LOCALE=tr
# Locale prefix mode for URLs. Recommended "always" when proxying under a
# subpath (NEXT_PUBLIC_BASE_PATH) to avoid next-intl rewrite loops.
# Values: never (default) | always | as-needed
# NEXT_PUBLIC_LOCALE_PREFIX=always
# =============================================================================
# Legacy Build-time Variables (still supported as fallback)
# =============================================================================
+61
View File
@@ -1,5 +1,64 @@
# Changelog
## 1.7.2 (2026-05-28)
### Features
- **Mail**: Scheduled send and send delay (#322)
- **Mail**: Drag emails out to the file explorer as `.eml`
- **Mail**: Import emails from `.zip` archives
- **Mail**: "Move to Trash and mark as read" delete action (#323)
- **Mail**: Include group inboxes in the unified mailbox view (#328)
- **Mail**: Locale-aware date format in the email list with a preset picker (#331)
- **Mail**: Allow drag-and-drop into shared mailboxes
- **Composer**: Ctrl/Cmd+Enter sends the open draft
- **Settings**: New Downloads tab with template editor for `.eml` and attachment filenames
- **Settings**: Filename transform settings and an ASCII-only "date (from-to) subject" template
- **Settings**: Post-export action (keep / archive / trash)
- **Settings**: Template for multi-email `.zip` filenames
- **Admin**: Per-domain branding editor with overrides on `/api/config`, manifest, and PWA icon (#332)
- **Admin**: Policy-controlled push relay URL with optional user lock
- **i18n**: `NEXT_PUBLIC_DEFAULT_LOCALE` for fallback UI locale (#243)
### Fixes
- **Mail**: Editable HTML signature in new mail; clean state on every compose entry (#329)
- **Mail**: Report real upload progress with XHR progress events (#333)
- **Mail**: Restore `blob:` in `object-src` and `frame-src` CSP for PDF/HTML previews
- **Mail**: Match user-avatar treatment on quick reply
- **Email viewer**: Stop shattering table cells with `word-break: break-word`
- **Composer**: Scope Ctrl/Cmd+Enter send to the focused composer
- **Composer**: Stop closing the form when editing any field
- **Pro**: Keep the empty viewer pane visible in the split layout
- **Pro**: Prevent an empty main pane when reordering tabs across panes
- **Mobile**: Collapse focus mail layout to multi-line
- **Mobile**: Keep a gutter on bare-HTML and plain-text emails
- **Calendar**: Align continued multi-week events with the week's left edge
- **Calendar**: Show the end date in the event popover for multi-day events (#318)
- **Calendar**: Convert `recurrenceRules` to singular in batch create
- **Calendar**: Handle malformed event dates (#316)
- **Files**: Stop URL-encoding drag-out filenames and preserve Unicode letters
- **Routing**: Prefix remaining `<img>`, favicon, and WebDAV URLs with `basePath` (#319)
- **Routing**: Prefix hand-written URLs with `basePath` for subpath deployments
- **Auth**: `OAUTH_ALLOW_PRIVATE_ENDPOINTS` for split-DNS setups
### i18n
- Add missing translation keys across 16 locales
## 1.7.1 (2026-05-22)
### Features
- **Admin**: Expose PWA branding fields in the admin Branding tab
- **Pro**: Hide empty-state placeholder and collapse the viewer pane in Pro mode so the mail list fills the space
### Fixes
- **Mail**: Preserve inline images when replying (#163)
- **Filters**: Use the canonical `INBOX` mailbox in Sieve filter paths (#313)
- **Mail**: Resolve destination account id to the local namespace on cross-account mailbox drop
## 1.7.0 (2026-05-21)
> **New: Pro mode (experimental).** Opt-in tabbed multi-pane interface for power users. Open multiple mail, calendar, contacts, and file views side-by-side, drag tabs to reorder or split panes at the edges, and work across all logged-in accounts in one shell - cross-account email moves, a unified inbox with search, account-split calendar/contacts/files sidebars, and a per-account "From" dropdown in the composer. Enable from Settings → Appearance; the `proInterface` preference is per-device and not synced.
@@ -21,6 +80,7 @@
- **Pro**: Multi-account contacts and a cross-account file picker
- **Pro**: Composer From dropdown grouped by account
- **Plugins**: Per-plugin admin approval workflow with Ed25519 bundle signing verified on load
- **Plugins**: Marketplace update flow for installed plugins and themes
- **Setup**: Allow the setup wizard over plain HTTP with a dismissable warning gate
- **Setup**: Warn when the JMAP URL points at a local-only host
- **Account**: List and reorder logged-in accounts from settings (#282)
@@ -62,6 +122,7 @@
- **Plugins**: Load `globals.css` and Geist font in the plugin sandbox iframe
- **Plugins**: Sync plugin slot iframe height with reported content height
- **Plugins**: Use plugin slot offer snapshots for `useSyncExternalStore`
- **Plugins**: Trust the directory version on marketplace install and update
- **Filters**: Prevent duplication of Bulwark rules with literal braces in values
- **Setup**: Defer setup wizard HTTP detection to avoid hydration mismatch
- **Routing**: Anchor unmatched URLs into `main` so 404 renders
+5
View File
@@ -8,6 +8,11 @@ ENV NEXT_TELEMETRY_DISABLED=1
# at build time, so it cannot be changed without rebuilding.
ARG NEXT_PUBLIC_BASE_PATH=
ENV NEXT_PUBLIC_BASE_PATH=$NEXT_PUBLIC_BASE_PATH
# Optional: fallback UI locale (e.g. tr, de, fr) used when the visitor's
# Accept-Language header does not match any supported locale. Baked in at
# build time because next-intl wires it into client-side routing too.
ARG NEXT_PUBLIC_DEFAULT_LOCALE=
ENV NEXT_PUBLIC_DEFAULT_LOCALE=$NEXT_PUBLIC_DEFAULT_LOCALE
# Commit SHA shown in the About screen. .dockerignore excludes .git, so
# `git rev-parse` inside the build can't find it - CI must pass it in.
ARG GIT_COMMIT=unknown
+7 -1
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)
[![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.0-green.svg?logo=git&logoColor=white)](CHANGELOG.md)
[![Version](https://img.shields.io/badge/version-1.7.2-green.svg?logo=git&logoColor=white)](CHANGELOG.md)
[![Docker](https://img.shields.io/badge/docker-ghcr.io%2Fbulwarkmail%2Fwebmail-blue?logo=docker&logoColor=white)](https://ghcr.io/bulwarkmail/webmail)
[![Grafana](https://img.shields.io/badge/grafana-dashboard-orange?logo=grafana&logoColor=white)](https://grafana.external.bulwarkmail.org/)
@@ -211,6 +211,12 @@ LOGIN_COMPANY_NAME=My Company
LOGIN_WEBSITE_URL=https://example.com
LOGIN_IMPRINT_URL=https://example.com/imprint
LOGIN_PRIVACY_POLICY_URL=https://example.com/privacy
# Per-domain overrides (optional). When the webmail is served on multiple
# hostnames, each host can override any subset of the branding fields above.
# Match is on the request Host (or X-Forwarded-Host). Use "*.example.com" to
# match any subdomain. Unset fields fall back to the global values.
DOMAIN_BRANDING=[{"host":"maildomain1.com","loginCompanyName":"Company One","loginLogoLightUrl":"/branding/one.svg"},{"host":"maildomain2.com","loginCompanyName":"Company Two"}]
```
</details>
+1 -1
View File
@@ -1 +1 @@
1.7.0
1.7.2
+2 -2
View File
@@ -4,7 +4,7 @@ import { Suspense, useEffect, useState } from "react";
import { useRouter, useSearchParams } from "next/navigation";
import { useTranslations } from "next-intl";
import { useAuthStore } from "@/stores/auth-store";
import { getPathPrefix } from "@/lib/browser-navigation";
import { apiFetch, getPathPrefix } from "@/lib/browser-navigation";
import { Loader2, AlertCircle } from "lucide-react";
import { Button } from "@/components/ui/button";
import { useParams } from "next/navigation";
@@ -96,7 +96,7 @@ function OAuthCallbackInner() {
// the refresh-token cookie write for the same reason.
(async () => {
try {
const res = await fetch("/api/auth/sso/complete", {
const res = await apiFetch("/api/auth/sso/complete", {
method: "POST",
headers: { "Content-Type": "application/json" },
credentials: "include",
+3 -3
View File
@@ -11,7 +11,7 @@ import { useAccountStore } from "@/stores/account-store";
import { useThemeStore } from "@/stores/theme-store";
import { useShallow } from "zustand/react/shallow";
import { useConfig } from "@/hooks/use-config";
import { apiFetch, getPathPrefix } from "@/lib/browser-navigation";
import { apiFetch, getPathPrefix, withBasePath } from "@/lib/browser-navigation";
import { cn } from "@/lib/utils";
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";
@@ -722,7 +722,7 @@ export default function LoginPage() {
<div className="px-8 pt-12 pb-4 text-center">
<div className="inline-flex items-center justify-center w-20 h-20 mb-6">
<img
src={resolvedTheme === 'dark' ? loginLogoDarkUrl : loginLogoLightUrl}
src={withBasePath(resolvedTheme === 'dark' ? loginLogoDarkUrl : loginLogoLightUrl)}
alt={appName}
className="max-w-20 max-h-20 object-contain"
/>
@@ -872,7 +872,7 @@ export default function LoginPage() {
<div className="px-8 pt-10 pb-6 text-center">
<div className="inline-flex items-center justify-center w-16 h-16 mb-5">
<img
src={resolvedTheme === 'dark' ? loginLogoDarkUrl : loginLogoLightUrl}
src={withBasePath(resolvedTheme === 'dark' ? loginLogoDarkUrl : loginLogoLightUrl)}
alt={appName}
className="max-w-16 max-h-16 object-contain"
/>
+404 -133
View File
@@ -15,7 +15,7 @@ import { ThreadGroup, Email, isUnifiedMailboxId, UNIFIED_ROLE_BY_ID } from "@/li
import { useAccountStore } from "@/stores/account-store";
import type { UnifiedAccountClient } from "@/lib/unified-mailbox";
import { KeyboardShortcutsModal } from "@/components/keyboard-shortcuts-modal";
import { useEmailStore } from "@/stores/email-store";
import { useEmailStore, buildUnifiedAccountClients } from "@/stores/email-store";
import { toast } from "@/stores/toast-store";
import { useAuthStore, redirectToLogin } from "@/stores/auth-store";
import { useSettingsStore } from "@/stores/settings-store";
@@ -57,6 +57,7 @@ import { FilePreviewModal } from "@/components/files/file-preview-modal";
import { isFilePreviewable } from "@/lib/file-preview";
import { appendPlainTextSignature } from "@/lib/signature-utils";
import { computeReplyThreadingHeaders } from "@/lib/email-threading";
import { EML_IMPORT_ACCEPT, expandImportableEmails } from "@/lib/eml-import";
import { resolveReplyFrom } from "@/lib/reply-identity";
import { Search, Filter, ChevronDown, X, Paperclip, Star, Mail, MailOpen, RotateCcw, PenSquare, PenLine, CheckSquare, Square, AlertTriangle } from "lucide-react";
import { ResizeHandle } from "@/components/layout/resize-handle";
@@ -74,6 +75,8 @@ import { buildQuoteHeader } from "@/lib/quote-header";
import { useLocaleStore } from "@/stores/locale-store";
import type { QuoteHeader } from "@/lib/plugin-types";
const SCHEDULED_MAILBOX_ID = '__scheduled__';
export default function Home() {
const t = useTranslations();
@@ -107,12 +110,39 @@ export default function Home() {
const [pendingMailtoAccountChoice, setPendingMailtoAccountChoice] = useState<ParsedMailto | null>(null);
const [isProtocolAccountSwitching, setIsProtocolAccountSwitching] = useState(false);
const markAsReadTimeoutRef = useRef<NodeJS.Timeout | null>(null);
const lastUndoToastSubmissionRef = useRef<string | null>(null);
const initialMailLoadClientRef = useRef<object | null>(null);
const { isAuthenticated, client, logout, checkAuth, switchAccount, activeAccountId, isLoading: authLoading, connectionLost, isRateLimited, rateLimitUntil } = useAuthStore();
const { identities } = useIdentityStore();
useIdentitySync();
const trustedSendersAddressBook = useSettingsStore((state) => state.trustedSendersAddressBook);
const sendDelaySeconds = useSettingsStore((state) => state.sendDelaySeconds);
const { loadTrustedSendersBook, trustedSendersLoaded } = useContactStore();
const promptForRescheduleDelayedUntil = useCallback((): string | null => {
const value = window.prompt(t('email_viewer.reschedule_prompt'));
if (!value) return null;
const time = new Date(value).getTime();
if (!Number.isFinite(time)) {
toast.error(t('email_composer.schedule_send_invalid'));
return null;
}
if (time <= Date.now()) {
toast.error(t('email_composer.schedule_send_future'));
return null;
}
if (!client?.hasDelayedSend()) {
toast.error(t('email_composer.schedule_send_unsupported'));
return null;
}
const maxDelayedSend = client.getMaxDelayedSend();
if (maxDelayedSend > 0 && time > Date.now() + maxDelayedSend * 1000) {
toast.error(t('email_composer.schedule_send_too_late'));
return null;
}
return new Date(time).toISOString();
}, [client, t]);
// Load trusted senders address book when feature is enabled
useEffect(() => {
if (trustedSendersAddressBook && client && !trustedSendersLoaded) {
@@ -269,6 +299,21 @@ export default function Home() {
fetchTagCounts,
fetchEmailContent,
isUnifiedView,
scheduledEmails,
scheduledTotal,
scheduledHasMore,
isLoadingScheduled,
isScheduledView,
setScheduledView,
fetchScheduledEmails,
loadMoreScheduledEmails,
cancelScheduledEmail,
cancelScheduledEmailForEdit,
rescheduleScheduledEmail,
refreshScheduledMetadata,
cancelUndoSend,
clearPendingUndoSend,
pendingUndoSend,
fetchUnifiedEmails: fetchUnifiedEmailsAction,
refreshUnifiedCounts,
exitUnifiedView,
@@ -293,47 +338,26 @@ export default function Home() {
useProMultiAccountMailboxes();
const enableUnifiedMailbox = useSettingsStore((s) => s.enableUnifiedMailbox);
const delayedSendSupported = client?.hasDelayedSend() ?? true;
const activeEmails = isScheduledView ? scheduledEmails : emails;
const activeHasMore = isScheduledView ? scheduledHasMore : hasMoreEmails;
const activeIsLoading = isScheduledView ? isLoadingScheduled : isLoading;
const includeGroupInUnified = useSettingsStore((s) => s.includeGroupInUnified);
const accounts = useAccountStore((s) => s.accounts);
const connectedAccountsSignature = useMemo(
() => accounts.filter((a) => a.isConnected).map((a) => a.id).sort().join(","),
[accounts],
);
const buildUnifiedAccounts = useCallback((): UnifiedAccountClient[] => {
const connected = useAccountStore.getState().accounts.filter((a) => a.isConnected);
const clients = useAuthStore.getState().getAllConnectedClients();
const result: UnifiedAccountClient[] = [];
for (const account of connected) {
const accountClient = clients.get(account.id);
if (!accountClient) continue;
result.push({
accountId: account.id,
accountLabel: account.label || account.email,
client: accountClient,
mailboxes: [],
});
}
return result;
// Builds the populated UnifiedAccountClient[] used by the unified-view
// effects and one-shot actions in this page. Reads the includeGroup
// setting at call time so the latest toggle value is always honored.
const buildPopulatedUnifiedAccounts = useCallback(async (): Promise<UnifiedAccountClient[]> => {
return buildUnifiedAccountClients({
includeGroup: useSettingsStore.getState().includeGroupInUnified,
});
}, []);
const populateUnifiedAccountMailboxes = useCallback(
async (list: UnifiedAccountClient[]): Promise<UnifiedAccountClient[]> => {
const populated = await Promise.all(
list.map(async (entry) => {
try {
const mailboxes = await entry.client.getMailboxes();
return { ...entry, mailboxes };
} catch (err) {
debug.error('Failed to load mailboxes for unified account', entry.accountId, err);
return entry;
}
}),
);
return populated;
},
[],
);
const getMailtoProtocolAccounts = useCallback(() => {
const connectedClients = useAuthStore.getState().getAllConnectedClients();
return useAccountStore.getState().accounts.filter((account) =>
@@ -353,7 +377,7 @@ export default function Home() {
conversationThreadId: null as string | null,
});
navRestoreStateRef.current.client = client;
navRestoreStateRef.current.emails = emails;
navRestoreStateRef.current.emails = activeEmails;
navRestoreStateRef.current.mailboxes = mailboxes;
navRestoreStateRef.current.selectedMailbox = selectedMailbox;
navRestoreStateRef.current.selectedEmailId = selectedEmail?.id ?? null;
@@ -379,7 +403,24 @@ export default function Home() {
// Restore mailbox selection. selectMailbox clears the current email,
// which is fine because we re-apply the saved email below.
if (state.mailboxId && state.mailboxId !== ctx.selectedMailbox) {
if (state.mailboxId === SCHEDULED_MAILBOX_ID) {
if (!ctx.client?.hasDelayedSend()) {
setScheduledView(false);
selectEmail(null);
return;
}
setScheduledView(true);
selectMailbox(SCHEDULED_MAILBOX_ID);
selectEmail(null);
if (ctx.client) {
try {
await fetchScheduledEmails(ctx.client);
} catch (error) {
debug.error('Failed to fetch scheduled emails on history restore:', error);
}
}
} else if (state.mailboxId && state.mailboxId !== ctx.selectedMailbox) {
setScheduledView(false);
selectMailbox(state.mailboxId);
if (ctx.client) {
try {
@@ -440,19 +481,19 @@ export default function Home() {
// Keyboard shortcuts handlers
const keyboardHandlers = useMemo(() => ({
onNextEmail: () => {
if (emails.length === 0) return;
const currentIndex = selectedEmail ? emails.findIndex(e => e.id === selectedEmail.id) : -1;
const nextIndex = currentIndex < emails.length - 1 ? currentIndex + 1 : currentIndex;
if (nextIndex >= 0 && nextIndex < emails.length) {
handleEmailSelect(emails[nextIndex]);
if (activeEmails.length === 0) return;
const currentIndex = selectedEmail ? activeEmails.findIndex(e => e.id === selectedEmail.id) : -1;
const nextIndex = currentIndex < activeEmails.length - 1 ? currentIndex + 1 : currentIndex;
if (nextIndex >= 0 && nextIndex < activeEmails.length) {
handleEmailSelect(activeEmails[nextIndex]);
}
},
onPreviousEmail: () => {
if (emails.length === 0) return;
const currentIndex = selectedEmail ? emails.findIndex(e => e.id === selectedEmail.id) : emails.length;
if (activeEmails.length === 0) return;
const currentIndex = selectedEmail ? activeEmails.findIndex(e => e.id === selectedEmail.id) : activeEmails.length;
const prevIndex = currentIndex > 0 ? currentIndex - 1 : 0;
if (prevIndex >= 0 && prevIndex < emails.length) {
handleEmailSelect(emails[prevIndex]);
if (prevIndex >= 0 && prevIndex < activeEmails.length) {
handleEmailSelect(activeEmails[prevIndex]);
}
},
onOpenEmail: () => {
@@ -468,18 +509,23 @@ export default function Home() {
}
},
onReply: () => {
if (isScheduledView) return;
if (selectedEmail) handleReply();
},
onReplyAll: () => {
if (isScheduledView) return;
if (selectedEmail) handleReplyAll();
},
onForward: () => {
if (isScheduledView) return;
if (selectedEmail) handleForward();
},
onToggleStar: () => {
if (isScheduledView) return;
if (selectedEmail) handleToggleStar();
},
onArchive: async () => {
if (isScheduledView) return;
if (selectedEmailIds.size > 0 && client) {
try {
await batchArchive(client);
@@ -491,6 +537,7 @@ export default function Home() {
}
},
onDelete: async () => {
if (isScheduledView) return;
if (selectedEmailIds.size > 0 && client) {
const currentMailbox = mailboxes.find(m => m.id === selectedMailbox);
const isInTrash = currentMailbox?.role === 'trash';
@@ -520,6 +567,7 @@ export default function Home() {
}
},
onMarkAsUnread: async () => {
if (isScheduledView) return;
if (!client) return;
if (selectedEmailIds.size > 0) {
await batchMarkAsRead(client, false);
@@ -528,6 +576,7 @@ export default function Home() {
}
},
onMarkAsRead: async () => {
if (isScheduledView) return;
if (!client) return;
if (selectedEmailIds.size > 0) {
await batchMarkAsRead(client, true);
@@ -536,6 +585,7 @@ export default function Home() {
}
},
onToggleSpam: async () => {
if (isScheduledView) return;
const currentMailbox = mailboxes.find(m => m.id === selectedMailbox);
const isInJunk = currentMailbox?.role === 'junk';
if (selectedEmailIds.size > 0 && client) {
@@ -558,11 +608,13 @@ export default function Home() {
}
},
onCompose: () => {
startFreshComposerSession();
setComposerMode('compose');
setShowComposer(true);
if (isMobile) setActiveView('viewer');
},
onFocusSearch: () => {
if (isScheduledView) return;
const searchInput = document.querySelector('[data-search-input]') as HTMLInputElement;
if (searchInput) {
searchInput.focus();
@@ -574,22 +626,27 @@ export default function Home() {
},
onRefresh: async () => {
if (client && selectedMailbox) {
await fetchEmails(client, selectedMailbox);
if (selectedMailbox === SCHEDULED_MAILBOX_ID) {
await fetchScheduledEmails(client);
} else {
await fetchEmails(client, selectedMailbox);
}
}
},
onSelectAll: () => {
if (isScheduledView) return;
selectAllEmails();
},
onDeselectAll: () => {
clearSelection();
},
// eslint-disable-next-line react-hooks/exhaustive-deps
}), [emails, selectedEmail, client, selectedMailbox, isMobile, isTablet, selectedEmailIds, mailboxes]);
}), [activeEmails, selectedEmail, client, selectedMailbox, isMobile, isTablet, selectedEmailIds, mailboxes, isScheduledView]);
// Initialize keyboard shortcuts
useKeyboardShortcuts({
enabled: isAuthenticated && !showComposer,
emails,
emails: activeEmails,
selectedEmailId: selectedEmail?.id,
selectionCount: selectedEmailIds.size,
handlers: keyboardHandlers,
@@ -602,13 +659,36 @@ export default function Home() {
onRefresh: async () => {
if (!client) return;
const state = useEmailStore.getState();
await Promise.all([
state.fetchMailboxes(client),
state.selectedMailbox ? state.fetchEmails(client, state.selectedMailbox) : state.fetchEmails(client),
]);
if (state.isScheduledView || state.selectedMailbox === SCHEDULED_MAILBOX_ID) {
await Promise.all([
state.fetchMailboxes(client),
state.fetchScheduledEmails(client),
]);
} else {
await state.fetchMailboxes(client);
await state.refreshScheduledMetadata(client);
await (state.selectedMailbox ? state.fetchEmails(client, state.selectedMailbox) : state.fetchEmails(client));
}
},
});
useEffect(() => {
if (!delayedSendSupported && isScheduledView) {
setScheduledView(false);
}
}, [delayedSendSupported, isScheduledView, setScheduledView]);
useEffect(() => {
if (!pendingUndoSend) return;
const pendingSendTime = new Date(pendingUndoSend.sendAt).getTime();
if (!Number.isFinite(pendingSendTime) || pendingSendTime <= Date.now()) {
clearPendingUndoSend();
return;
}
const timer = setTimeout(clearPendingUndoSend, pendingSendTime - Date.now());
return () => clearTimeout(timer);
}, [clearPendingUndoSend, pendingUndoSend]);
// Update page title based on context
useEffect(() => {
let title = appName;
@@ -812,50 +892,61 @@ export default function Home() {
}, [isAuthenticated, client, handleMailtoProtocolRequest]);
// Fallback fetch for paths that didn't go through login()'s prefetch
// (notably checkAuth on page refresh). The prefetch in auth-store/login()
// populates mailboxes before this effect first runs, so on the post-login
// path this block is a no-op.
// (notably checkAuth on page refresh). Settings pages can also prefill
// mailboxes without emails, so bootstrap emails once per client when the
// mail route mounts even if mailbox data is already present.
useEffect(() => {
if (isAuthenticated && client && mailboxes.length === 0) {
let retryTimer: ReturnType<typeof setTimeout> | null = null;
let cancelled = false;
const loadData = async (attempt = 1) => {
try {
await Promise.all([
fetchMailboxes(client),
fetchQuota(client)
]);
const state = useEmailStore.getState();
const selectedMailboxId = state.selectedMailbox;
if (state.mailboxes.length === 0 && attempt <= 5 && !cancelled) {
const delay = Math.min(1000 * attempt, 5000);
debug.log('jmap', `[Mailbox] No mailboxes returned (attempt ${attempt}), retrying in ${delay}ms`);
retryTimer = setTimeout(() => loadData(attempt + 1), delay);
return;
}
if (selectedMailboxId) {
await fetchEmails(client, selectedMailboxId);
} else {
await fetchEmails(client);
}
fetchTagCounts(client);
} catch (error) {
console.error('Error loading email data:', error);
}
};
loadData();
return () => {
cancelled = true;
if (retryTimer) clearTimeout(retryTimer);
};
if (!isAuthenticated || !client) {
initialMailLoadClientRef.current = null;
return;
}
}, [isAuthenticated, client, mailboxes.length, fetchMailboxes, fetchEmails, fetchQuota, fetchTagCounts]);
if (initialMailLoadClientRef.current === client) return;
initialMailLoadClientRef.current = client;
let retryTimer: ReturnType<typeof setTimeout> | null = null;
let cancelled = false;
const loadData = async (attempt = 1) => {
try {
const needsMailboxes = useEmailStore.getState().mailboxes.length === 0;
await Promise.all([
needsMailboxes ? fetchMailboxes(client) : Promise.resolve(),
fetchQuota(client)
]);
const state = useEmailStore.getState();
const selectedMailboxId = state.selectedMailbox;
if (state.mailboxes.length === 0 && attempt <= 5 && !cancelled) {
const delay = Math.min(1000 * attempt, 5000);
debug.log('jmap', `[Mailbox] No mailboxes returned (attempt ${attempt}), retrying in ${delay}ms`);
retryTimer = setTimeout(() => loadData(attempt + 1), delay);
return;
}
await refreshScheduledMetadata(client);
// Fetch emails for the selected mailbox after scheduled metadata is available.
if (selectedMailboxId) {
await fetchEmails(client, selectedMailboxId);
} else {
await fetchEmails(client);
}
fetchTagCounts(client);
} catch (error) {
console.error('Error loading email data:', error);
initialMailLoadClientRef.current = null;
}
};
loadData();
return () => {
cancelled = true;
if (retryTimer) clearTimeout(retryTimer);
};
}, [isAuthenticated, client, fetchMailboxes, fetchEmails, fetchQuota, fetchTagCounts, refreshScheduledMetadata]);
// Push notifications: set up once per client and tear down when the client
// goes away (logout or account switch). Kept separate from the fetch effect
@@ -889,12 +980,12 @@ export default function Home() {
useEffect(() => {
if (!enableUnifiedMailbox && !isEmbedded) return;
if (!isAuthenticated || !client) return;
const built = buildUnifiedAccounts();
if (built.length < 2) return;
populateUnifiedAccountMailboxes(built).then((populated) => {
refreshUnifiedCounts(populated);
buildPopulatedUnifiedAccounts().then((built) => {
const hasGroupEntry = built.some((b) => b.isShared);
if (built.length < 2 && !hasGroupEntry && !isEmbedded) return;
refreshUnifiedCounts(built);
});
}, [enableUnifiedMailbox, isEmbedded, isAuthenticated, client, mailboxes, connectedAccountsSignature, buildUnifiedAccounts, populateUnifiedAccountMailboxes, refreshUnifiedCounts]);
}, [enableUnifiedMailbox, includeGroupInUnified, isEmbedded, isAuthenticated, client, mailboxes, connectedAccountsSignature, buildPopulatedUnifiedAccounts, refreshUnifiedCounts]);
// System-notification click handler. The push SW navigates the user back
// here with `?email=<id>` (specific email it built the toast from) or
@@ -962,7 +1053,7 @@ export default function Home() {
}
// Only set timeout if there's a selected email, it's unread, and we have a client
if (!selectedEmail || !client || selectedEmail.keywords?.$seen) {
if (!selectedEmail || !client || selectedEmail.keywords?.$seen || isScheduledView) {
return;
}
@@ -996,7 +1087,7 @@ export default function Home() {
}
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [selectedEmail?.id]);
}, [selectedEmail?.id, isScheduledView]);
// Handle new email notifications - play sound
useEffect(() => {
@@ -1037,6 +1128,7 @@ export default function Home() {
attachments?: Array<{ blobId: string; name: string; type: string; size: number; disposition?: 'attachment' | 'inline'; cid?: string }>;
inReplyTo?: string[];
references?: string[];
delayedUntil?: string;
}) => {
if (!client) return;
@@ -1044,8 +1136,13 @@ export default function Home() {
const effectiveMode = pendingDraft?.mode ?? composerMode;
const originalEmailId = selectedEmail?.id;
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.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);
setShowComposer(false);
if (result.scheduled) {
await refreshScheduledMetadata(client);
if (isScheduledView) await fetchScheduledEmails(client);
return;
}
// Mark the original email with $answered or $forwarded keyword
if (originalEmailId && (effectiveMode === 'reply' || effectiveMode === 'replyAll')) {
@@ -1063,7 +1160,7 @@ export default function Home() {
}
// Refresh the current mailbox to update the UI
await fetchEmails(client, selectedMailbox);
if (!isScheduledView) await fetchEmails(client, selectedMailbox);
} catch (error) {
console.error("Failed to send email:", error);
}
@@ -1123,6 +1220,15 @@ export default function Home() {
}
}, [tCommon]);
// 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
// keyed on composerSessionId, and pendingDraft would otherwise pin the
// composer to a stale draft from a discarded reply.
const startFreshComposerSession = useCallback(() => {
setComposerSessionId(id => id + 1);
setPendingDraft(null);
}, []);
const handleReply = async (draftText?: string) => {
if (selectedEmail) {
const ok = await emailHooks.onBeforeReply.intercept({
@@ -1135,6 +1241,7 @@ export default function Home() {
} else {
setComposerQuoteHeader(null);
}
startFreshComposerSession();
setComposerDraftText(draftText || "");
setComposerMode('reply');
setShowComposer(true);
@@ -1193,6 +1300,45 @@ export default function Home() {
if (isMobile) setActiveView('viewer');
};
useEffect(() => {
if (!pendingUndoSend || !client) return;
if (lastUndoToastSubmissionRef.current === pendingUndoSend.submissionId) return;
lastUndoToastSubmissionRef.current = pendingUndoSend.submissionId;
const pending = pendingUndoSend;
const undoDurationMs = Math.max(sendDelaySeconds, 8) * 1000;
toast.success(t('email_viewer.scheduled_send_created'), {
duration: undoDurationMs,
action: {
label: t('email_viewer.undo_send'),
onClick: () => {
void (async () => {
try {
const restored = await cancelUndoSend(client, pending);
if (restored && !pending.isSmime) {
await handleEditDraft(restored);
}
if (isScheduledView) await fetchScheduledEmails(client);
} catch (error) {
console.error('Failed to undo scheduled send:', error);
}
})();
},
},
});
const timer = setTimeout(() => {
const current = useEmailStore.getState().pendingUndoSend;
if (current?.submissionId === pending.submissionId) {
clearPendingUndoSend();
}
}, undoDurationMs);
return () => clearTimeout(timer);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [cancelUndoSend, clearPendingUndoSend, client, fetchScheduledEmails, isScheduledView, pendingUndoSend?.submissionId, sendDelaySeconds, t]);
const handleReplyAll = async () => {
if (selectedEmail) {
const ok = await emailHooks.onBeforeReplyAll.intercept({
@@ -1205,6 +1351,7 @@ export default function Home() {
} else {
setComposerQuoteHeader(null);
}
startFreshComposerSession();
setComposerMode('replyAll');
setShowComposer(true);
if (isMobile) setActiveView('viewer');
@@ -1222,6 +1369,7 @@ export default function Home() {
} else {
setComposerQuoteHeader(null);
}
startFreshComposerSession();
setComposerMode('forward');
setShowComposer(true);
if (isMobile) setActiveView('viewer');
@@ -1459,7 +1607,29 @@ export default function Home() {
};
const handleMailboxSelect = async (mailboxId: string) => {
if (mailboxId === SCHEDULED_MAILBOX_ID) {
if (!delayedSendSupported) {
setScheduledView(false);
return;
}
if (isUnifiedView) exitUnifiedView();
setScheduledView(true);
selectMailbox(mailboxId);
selectEmail(null);
clearSelection();
if (isMobile) {
setSidebarOpen(false);
setActiveView("list");
}
if (isTablet) {
setTabletListVisible(true);
}
if (client) await fetchScheduledEmails(client);
return;
}
if (isUnifiedMailboxId(mailboxId)) {
setScheduledView(false);
const role = UNIFIED_ROLE_BY_ID[mailboxId];
if (!role) return;
@@ -1474,8 +1644,7 @@ export default function Home() {
setTabletListVisible(true);
}
const built = buildUnifiedAccounts();
const populated = await populateUnifiedAccountMailboxes(built);
const populated = await buildPopulatedUnifiedAccounts();
await fetchUnifiedEmailsAction(populated, role);
refreshUnifiedCounts(populated);
return;
@@ -1484,6 +1653,7 @@ export default function Home() {
if (isUnifiedView) {
exitUnifiedView();
}
setScheduledView(false);
selectMailbox(mailboxId);
selectEmail(null); // Clear selected email when switching mailboxes
@@ -1510,6 +1680,7 @@ export default function Home() {
};
const handleTagSelect = async (keywordId: string | null) => {
setScheduledView(false);
selectKeyword(keywordId);
// On mobile, close sidebar and go to list view
@@ -1753,17 +1924,24 @@ export default function Home() {
const input = document.createElement('input');
input.type = 'file';
input.accept = '.eml,message/rfc822';
input.accept = EML_IMPORT_ACCEPT;
input.multiple = true;
input.onchange = async (e) => {
const files = Array.from((e.target as HTMLInputElement).files ?? []);
if (files.length === 0) return;
let emails;
try {
emails = await expandImportableEmails(files);
} catch {
toast.error(t('notifications.import_email_error'));
return;
}
let imported = 0;
let failed = 0;
for (const file of files) {
for (const { blob } of emails) {
try {
const blob = new Blob([await file.arrayBuffer()], { type: 'message/rfc822' });
await client.importRawEmail(blob, { [targetMailboxId]: true }, { '$seen': true });
imported++;
} catch {
@@ -1775,7 +1953,7 @@ export default function Home() {
toast.success(t('notifications.import_email_success'));
if (selectedMailbox) await fetchEmails(client, selectedMailbox);
}
if (failed > 0) {
if (failed > 0 || (imported === 0 && emails.length === 0)) {
toast.error(t('notifications.import_email_error'));
}
};
@@ -1813,8 +1991,7 @@ export default function Home() {
if (isUnifiedView) {
const role = useEmailStore.getState().unifiedRole;
if (role) {
const built = buildUnifiedAccounts();
const populated = await populateUnifiedAccountMailboxes(built);
const populated = await buildPopulatedUnifiedAccounts();
await fetchUnifiedEmailsAction(populated, role);
}
return;
@@ -1918,6 +2095,16 @@ export default function Home() {
});
const originalEmailId = selectedEmail.id;
const sendDelaySeconds = useSettingsStore.getState().sendDelaySeconds;
let delayedUntil: string | undefined;
if (sendDelaySeconds > 0) {
if (!client.hasDelayedSend()) {
const confirmed = window.confirm(t('email_composer.send_delay_unsupported_confirm'));
if (!confirmed) return;
} else {
delayedUntil = new Date(Date.now() + sendDelaySeconds * 1000).toISOString();
}
}
// RFC 5322 §3.6.4 threading - keep the conversation stitched together (#234).
const threading = computeReplyThreadingHeaders({
@@ -1926,7 +2113,7 @@ export default function Home() {
});
// Send reply with just the body text
await sendEmail(
const result = await sendEmail(
client,
[sender.email],
`Re: ${selectedEmail.subject || "(no subject)"}`,
@@ -1941,9 +2128,15 @@ export default function Home() {
undefined,
threading?.inReplyTo,
threading?.references,
delayedUntil,
envelopeMailFrom,
);
if (result.scheduled) {
await refreshScheduledMetadata(client);
return;
}
// Mark the original email as answered
try {
await client.setKeyword(originalEmailId, '$answered');
@@ -1968,12 +2161,12 @@ export default function Home() {
}
// Get current mailbox name for mobile header
const currentMailboxName = mailboxes.find(m => m.id === selectedMailbox)?.name || "Inbox";
const currentMailboxName = isScheduledView ? t('sidebar.scheduled') : mailboxes.find(m => m.id === selectedMailbox)?.name || "Inbox";
const isFocusedMailLayout = mailLayout === 'focus';
const isHorizontalMailLayout = mailLayout === 'horizontal' && !isMobile && !isTablet;
const hasViewerContent = showComposer || Boolean(conversationThread) || Boolean(selectedEmail);
const shouldCollapseListPane = (isTablet && !tabletListVisible) || (!isMobile && isFocusedMailLayout && hasViewerContent);
const shouldHideViewerPane = !isMobile && isFocusedMailLayout && !hasViewerContent;
const shouldHideViewerPane = !isMobile && !hasViewerContent && isFocusedMailLayout;
const shouldHideHorizontalViewerPane = isHorizontalMailLayout && !hasViewerContent;
// Handle email selection with mobile view switching
@@ -1985,9 +2178,8 @@ export default function Home() {
setShowComposer(false);
}
// Show the list stub immediately so subject/sender render without
// waiting for the body fetch - avoids the loading flicker.
const listEmail = emails.find(e => e.id === email.id);
const listEmail = activeEmails.find(e => e.id === email.id);
if (listEmail) {
selectEmail(listEmail);
}
@@ -2024,6 +2216,14 @@ export default function Home() {
const fullEmail = await fetchClient.getEmail(email.id, accountId);
if (fullEmail) {
if (listEmail?.isScheduled) {
fullEmail.scheduledSendAt = listEmail.scheduledSendAt;
fullEmail.emailSubmissionId = listEmail.emailSubmissionId;
fullEmail.scheduledIdentityId = listEmail.scheduledIdentityId;
fullEmail.scheduledUndoStatus = listEmail.scheduledUndoStatus;
fullEmail.isScheduled = true;
fullEmail.isSmimeScheduled = listEmail.isSmimeScheduled;
}
if (emailAccountId) {
fullEmail.accountId = emailAccountId;
fullEmail.accountLabel = listEmail?.accountLabel;
@@ -2056,14 +2256,14 @@ export default function Home() {
};
// Navigate to next/previous email in the list
const selectedEmailIndex = selectedEmail ? emails.findIndex(e => e.id === selectedEmail.id) : -1;
const selectedEmailIndex = selectedEmail ? activeEmails.findIndex(e => e.id === selectedEmail.id) : -1;
const handleNavigateNext = selectedEmailIndex >= 0 && selectedEmailIndex < emails.length - 1
? () => handleEmailSelect(emails[selectedEmailIndex + 1])
const handleNavigateNext = selectedEmailIndex >= 0 && selectedEmailIndex < activeEmails.length - 1
? () => handleEmailSelect(activeEmails[selectedEmailIndex + 1])
: undefined;
const handleNavigatePrev = selectedEmailIndex > 0
? () => handleEmailSelect(emails[selectedEmailIndex - 1])
? () => handleEmailSelect(activeEmails[selectedEmailIndex - 1])
: undefined;
// Handle opening conversation view on mobile
@@ -2091,6 +2291,7 @@ export default function Home() {
const handleConversationReply = async (email: Email) => {
selectEmail(email);
await prepareComposerQuoteHeader(email, 'reply');
startFreshComposerSession();
setComposerMode('reply');
setShowComposer(true);
if (isMobile) setActiveView('viewer');
@@ -2099,6 +2300,7 @@ export default function Home() {
const handleConversationReplyAll = async (email: Email) => {
selectEmail(email);
await prepareComposerQuoteHeader(email, 'replyAll');
startFreshComposerSession();
setComposerMode('replyAll');
setShowComposer(true);
if (isMobile) setActiveView('viewer');
@@ -2107,6 +2309,7 @@ export default function Home() {
const handleConversationForward = async (email: Email) => {
selectEmail(email);
await prepareComposerQuoteHeader(email, 'forward');
startFreshComposerSession();
setComposerMode('forward');
setShowComposer(true);
if (isMobile) setActiveView('viewer');
@@ -2218,6 +2421,8 @@ export default function Home() {
mailboxes={mailboxes}
selectedMailbox={selectedMailbox}
selectedKeyword={selectedKeyword}
scheduledTotal={scheduledTotal}
showScheduledMailbox={delayedSendSupported}
onMailboxSelect={handleMailboxSelect}
onTagSelect={handleTagSelect}
onUnreadFilterClick={handleUnreadFilterClick}
@@ -2232,6 +2437,7 @@ export default function Home() {
onImportEmail={handleImportEmailFromContextMenu}
onRefreshMailboxes={handleRefreshMailboxes}
onCompose={() => {
startFreshComposerSession();
setComposerMode('compose');
setShowComposer(true);
if (isMobile) {
@@ -2299,26 +2505,27 @@ export default function Home() {
type="button"
onClick={() => {
if (selectedEmailIds.size > 0) {
if (selectedEmailIds.size === emails.length) {
if (selectedEmailIds.size === activeEmails.length) {
clearSelection();
} else {
selectAllEmails();
}
} else if (emails.length > 0) {
} else if (activeEmails.length > 0) {
const currentId = selectedEmail?.id;
const target = currentId && emails.some((e) => e.id === currentId)
const target = currentId && activeEmails.some((e) => e.id === currentId)
? currentId
: emails[0].id;
: activeEmails[0].id;
toggleEmailSelection(target);
}
}}
disabled={isScheduledView}
className={cn(
"flex-shrink-0 p-2 rounded-md transition-colors",
selectedEmailIds.size > 0
? "bg-primary/10 text-primary"
: "text-muted-foreground hover:text-foreground hover:bg-muted"
)}
title={selectedEmailIds.size > 0 ? (selectedEmailIds.size === emails.length ? t('email_list.batch_actions.clear_selection') : t('email_list.batch_actions.select_all')) : t('email_list.batch_actions.select')}
title={isScheduledView ? t('email_viewer.scheduled_actions_only') : selectedEmailIds.size > 0 ? (selectedEmailIds.size === activeEmails.length ? t('email_list.batch_actions.clear_selection') : t('email_list.batch_actions.select_all')) : t('email_list.batch_actions.select')}
>
{selectedEmailIds.size > 0 ? (
<CheckSquare className="w-4 h-4" />
@@ -2336,6 +2543,8 @@ export default function Home() {
className={cn("pl-9 h-9", searchQuery && "pr-8")}
data-search-input
data-tour="search-input"
disabled={isUnifiedView || isScheduledView}
title={isUnifiedView ? t("unified_mailbox.search_unavailable") : isScheduledView ? t('email_viewer.scheduled_actions_only') : undefined}
/>
{searchQuery && (
<button
@@ -2351,13 +2560,15 @@ export default function Home() {
<button
type="button"
onClick={toggleAdvancedSearch}
disabled={isUnifiedView || isScheduledView}
className={cn(
"relative flex-shrink-0 p-2 rounded-md transition-colors",
(isUnifiedView || isScheduledView) && "opacity-50 cursor-not-allowed",
isAdvancedSearchOpen || activeFilterCount(searchFilters) > 0
? "bg-primary/10 text-primary"
: "text-muted-foreground hover:text-foreground hover:bg-muted"
)}
title={t("advanced_search.toggle_filters")}
title={isUnifiedView ? t("unified_mailbox.search_unavailable") : isScheduledView ? t('email_viewer.scheduled_actions_only') : t("advanced_search.toggle_filters")}
>
<Filter className="w-4 h-4" />
{!isAdvancedSearchOpen && activeFilterCount(searchFilters) > 0 && (
@@ -2499,11 +2710,17 @@ export default function Home() {
)}
</div>
{(searchQuery || !isFilterEmpty(searchFilters)) && !isLoading && (
{(searchQuery || !isFilterEmpty(searchFilters)) && !activeIsLoading && !isScheduledView && (
<div className="px-4 py-1.5 text-xs text-muted-foreground border-b border-border bg-muted/20">
{hasMoreEmails
? t("advanced_search.results_found_more", { count: emails.length })
: t("advanced_search.results_found", { count: emails.length })}
{activeHasMore
? t("advanced_search.results_found_more", { count: activeEmails.length })
: t("advanced_search.results_found", { count: activeEmails.length })}
</div>
)}
{isScheduledView && !activeIsLoading && (
<div className="px-4 py-1.5 text-xs text-muted-foreground border-b border-border bg-muted/20">
{t('email_list.scheduled_count', { count: scheduledTotal })}
</div>
)}
@@ -2512,9 +2729,35 @@ export default function Home() {
<ErrorBoundary fallback={EmailListErrorFallback}>
<EmailList
emails={emails}
emails={activeEmails}
selectedEmailId={selectedEmail?.id}
isLoading={isLoading}
isLoading={activeIsLoading}
hasMore={activeHasMore}
isLoadingMoreItems={isScheduledView ? isLoadingScheduled && activeEmails.length > 0 : undefined}
isScheduledView={isScheduledView}
onLoadMoreScheduled={() => client && loadMoreScheduledEmails(client)}
onCancelScheduled={async (email) => {
if (client && email.emailSubmissionId) await cancelScheduledEmail(client, email.emailSubmissionId, email.id);
}}
onCancelScheduledForEdit={async (email) => {
if (!client) return;
const restored = await cancelScheduledEmailForEdit(client, email);
if (email.isSmimeScheduled) {
setComposerMode('compose');
setPendingDraft(null);
} else if (restored) {
await handleEditDraft(restored);
return;
}
setShowComposer(true);
if (isMobile) setActiveView('viewer');
}}
onRescheduleScheduled={async (email) => {
const delayedUntil = promptForRescheduleDelayedUntil();
if (delayedUntil && client && email.emailSubmissionId && email.scheduledIdentityId) {
await rescheduleScheduledEmail(client, email.emailSubmissionId, email.id, email.scheduledIdentityId, delayedUntil);
}
}}
onEmailSelect={handleEmailSelect}
onEmailDoubleClick={isEmbedded ? ((email) => {
useProTabStore.getState().openEmailTab({
@@ -2579,6 +2822,7 @@ export default function Home() {
{/* Floating Compose Button */}
<Button
onClick={() => {
startFreshComposerSession();
setComposerMode('compose');
setShowComposer(true);
if (isMobile) setActiveView('viewer');
@@ -2596,7 +2840,7 @@ export default function Home() {
</div>
{/* Email list resize handle (desktop only) */}
{!isMobile && !isTablet && !isFocusedMailLayout && !isHorizontalMailLayout && (
{!isMobile && !isTablet && !isFocusedMailLayout && !isHorizontalMailLayout && !shouldHideViewerPane && (
<ResizeHandle
onResizeStart={() => { dragStartWidth.current = emailListWidth; setIsResizing(true); }}
onResize={(delta) => setEmailListWidth(dragStartWidth.current + delta)}
@@ -2675,6 +2919,14 @@ export default function Home() {
await handleEmailSend(data);
setPendingDraft(null);
}}
onScheduledSendCreated={async () => {
if (client) {
await refreshScheduledMetadata(client);
if (isScheduledView) await fetchScheduledEmails(client);
}
setShowComposer(false);
setPendingDraft(null);
}}
onClose={() => {
setShowComposer(false);
setComposerMode('compose');
@@ -2770,7 +3022,26 @@ export default function Home() {
onNavigatePrev={handleNavigatePrev}
onShowShortcuts={() => setShowShortcutsModal(true)}
onEditDraft={handleEditDraft}
onCancelScheduled={async () => {
if (client && selectedEmail?.emailSubmissionId) await cancelScheduledEmail(client, selectedEmail.emailSubmissionId, selectedEmail.id);
}}
onCancelScheduledForEdit={async () => {
if (!client || !selectedEmail) return;
const restored = await cancelScheduledEmailForEdit(client, selectedEmail);
if (selectedEmail.isSmimeScheduled) {
setComposerMode('compose');
setShowComposer(true);
return;
}
if (restored) await handleEditDraft(restored);
}}
onRescheduleScheduled={async (delayedUntil) => {
if (client && selectedEmail?.emailSubmissionId && selectedEmail.scheduledIdentityId) {
await rescheduleScheduledEmail(client, selectedEmail.emailSubmissionId, selectedEmail.id, selectedEmail.scheduledIdentityId, delayedUntil);
}
}}
onCompose={() => {
startFreshComposerSession();
setComposerMode('compose');
setShowComposer(true);
}}
+2 -1
View File
@@ -16,6 +16,7 @@ import { PaneSizeContext } from "@/hooks/use-pane-size";
import { ProTabBar, PRO_TAB_DRAG_MIME } from "@/components/pro/pro-tab-bar";
import { useProTabStore, type ProTab, type ProTabKind, type ProPaneId } from "@/stores/pro-tab-store";
import { cn } from "@/lib/utils";
import { getPathPrefix } from "@/lib/browser-navigation";
import MailPage from "@/app/(main)/[locale]/page";
import CalendarPage from "@/app/(main)/[locale]/calendar/page";
@@ -172,7 +173,7 @@ export default function ProHome() {
// enabled it. If either precondition stops holding, hand the user back
// to the standard shell.
if (isMobile || isTablet || !proInterface) {
window.location.replace("/");
window.location.replace(`${getPathPrefix()}/`);
}
}, [initialCheckDone, isMobile, isTablet, proInterface]);
+8
View File
@@ -33,6 +33,7 @@ import {
Languages,
Info,
Bug,
Download,
X,
type LucideIcon,
} from 'lucide-react';
@@ -59,6 +60,7 @@ import { FolderSettings } from '@/components/settings/folder-settings';
import { KeywordSettings } from '@/components/settings/keyword-settings';
import { AccountSecuritySettings } from '@/components/settings/account-security-settings';
import { FilesSettingsComponent } from '@/components/settings/files-settings';
import { DownloadsSettings } from '@/components/settings/downloads-settings';
import { ContactsSettings } from '@/components/settings/contacts-settings';
import { SmimeSettings } from '@/components/settings/smime-settings';
import { SidebarAppsSettings } from '@/components/settings/sidebar-apps-settings';
@@ -90,6 +92,7 @@ type Tab =
| 'layout'
| 'reading'
| 'composing'
| 'downloads'
| 'identities'
| 'vacation'
| 'filters'
@@ -126,6 +129,7 @@ const tabIcons: Record<Tab, LucideIcon> = {
layout: LayoutGrid,
reading: BookOpen,
composing: PenLine,
downloads: Download,
identities: UserPen,
vacation: PalmtreeIcon,
filters: Filter,
@@ -202,6 +206,7 @@ const tabSearchPaths: Record<Tab, string[]> = {
'settings.email_behavior.signature_position',
'settings.email_behavior.sub_address_delimiter',
],
downloads: ['settings.downloads'],
identities: ['settings.identities'],
vacation: ['settings.vacation'],
filters: ['settings.filters'],
@@ -236,6 +241,7 @@ const tabKeywords: Record<Tab, string> = {
layout: 'toolbar sidebar account switcher unified mailbox icons rail',
reading: 'mark read preview thread conversation archive delete attachment open',
composing: 'editor signature plain text reply forward draft compose',
downloads: 'download filename template eml attachment save export',
identities: 'from address signature email',
vacation: 'auto reply away out of office holiday responder',
filters: 'sieve rules block junk forward',
@@ -580,6 +586,7 @@ export default function SettingsPage() {
// Mail
{ id: 'reading', label: t('tabs.reading'), icon: tabIcons.reading, group: 'mail' },
{ id: 'composing', label: t('tabs.composing'), icon: tabIcons.composing, group: 'mail' },
{ id: 'downloads', label: t('tabs.downloads'), icon: tabIcons.downloads, group: 'mail' },
{ id: 'identities', label: t('tabs.identities'), icon: tabIcons.identities, group: 'mail' },
...(supportsVacation ? [{ id: 'vacation' as Tab, label: t('tabs.vacation'), icon: tabIcons.vacation, group: 'mail' as TabGroup }] : []),
...(supportsSieve ? [{ id: 'filters' as Tab, label: t('tabs.filters'), icon: tabIcons.filters, group: 'mail' as TabGroup }] : []),
@@ -666,6 +673,7 @@ export default function SettingsPage() {
{effectiveActiveTab === 'layout' && <LayoutSettings />}
{effectiveActiveTab === 'reading' && <ReadingSettings />}
{effectiveActiveTab === 'composing' && <ComposingSettings />}
{effectiveActiveTab === 'downloads' && <DownloadsSettings />}
{effectiveActiveTab === 'identities' && <IdentitySettings />}
{effectiveActiveTab === 'vacation' && <VacationSettings />}
{effectiveActiveTab === 'filters' && <FilterSettings />}
@@ -1,6 +1,6 @@
'use client';
import { useEffect, useState } from 'react';
import { useEffect, useRef, useState } from 'react';
import { Plus, Trash2, RotateCcw, ChevronDown, ChevronRight } from 'lucide-react';
import type { JmapServerEntry } from '@/lib/admin/jmap-servers';
@@ -77,30 +77,17 @@ function emptyDraft(): RowDraft {
export function JmapServersSection({ value, source, onChange, onRevert }: Props) {
const [drafts, setDrafts] = useState<RowDraft[]>(() => value.map(entryToDraft));
const lastEmittedRef = useRef(value);
useEffect(() => {
// Re-sync from props when the underlying config value changes (e.g. revert,
// initial load). Skip when drafts already represent the same array to avoid
// clobbering in-progress edits.
setDrafts((prev) => {
if (prev.length === value.length) {
const same = prev.every((d, i) => {
const e = value[i];
return d.id === e.id && d.url === e.url && d.label === e.label;
});
if (same) return prev;
}
return value.map(entryToDraft);
});
if (value === lastEmittedRef.current) return;
setDrafts(value.map(entryToDraft))
}, [value]);
function commit(next: RowDraft[]) {
setDrafts(next);
const entries: JmapServerEntry[] = [];
for (const d of next) {
const e = draftToEntry(d);
if (e) entries.push(e);
}
const entries = next.map(draftToEntry).filter((e): e is JmapServerEntry => e !== null);
lastEmittedRef.current = entries;
onChange(entries);
}
+1
View File
@@ -273,6 +273,7 @@ export function AuthTab() {
<Text label="OAuth Client ID" configKey="oauthClientId" value={currentValue('oauthClientId') as string} source={config.oauthClientId?.source} onChange={handleChange} onRevert={handleRevert} />
<Text label="OAuth Client Secret" configKey="oauthClientSecret" value={currentValue('oauthClientSecret') as string} source={config.oauthClientSecret?.source} onChange={handleChange} onRevert={handleRevert} type="password" placeholder={config.oauthClientSecret?.hasValue ? '•••••••• (saved - type to replace)' : undefined} />
<Text label="OAuth Issuer URL" configKey="oauthIssuerUrl" value={currentValue('oauthIssuerUrl') as string} source={config.oauthIssuerUrl?.source} onChange={handleChange} onRevert={handleRevert} placeholder="https://auth.example.com" />
<Toggle label="Allow private OAuth endpoints" description="Permit discovery to resolve to RFC-1918 / loopback hosts. Enable only for split-DNS deployments where the mail server's public hostname resolves to an internal IP." configKey="oauthAllowPrivateEndpoints" value={currentValue('oauthAllowPrivateEndpoints') as boolean} source={config.oauthAllowPrivateEndpoints?.source} onChange={handleChange} onRevert={handleRevert} />
<Text label="OAuth Scopes" description="Space-separated scopes that replace the defaults. Leave blank to use the built-in scope list." configKey="oauthScopes" value={currentValue('oauthScopes') as string} source={config.oauthScopes?.source} onChange={handleChange} onRevert={handleRevert} placeholder="openid email offline_access" />
<Text label="OAuth Extra Scopes" description="Additional space-separated scopes appended to the defaults." configKey="oauthExtraScopes" value={currentValue('oauthExtraScopes') as string} source={config.oauthExtraScopes?.source} onChange={handleChange} onRevert={handleRevert} placeholder="urn:ietf:params:oauth:..." />
</Section>
+447 -26
View File
@@ -1,8 +1,14 @@
'use client';
import { useEffect, useRef, useState } from 'react';
import { Save, Loader2, RotateCcw, ImageIcon, Upload, Trash2 } from 'lucide-react';
import { useEffect, useMemo, useRef, useState } from 'react';
import { Save, Loader2, RotateCcw, ImageIcon, Upload, Trash2, Globe, Plus, X } from 'lucide-react';
import { apiFetch } from '@/lib/browser-navigation';
import {
BRANDING_OVERRIDE_KEYS,
parseDomainBranding,
type BrandingOverrideKey,
type DomainBrandingEntry,
} from '@/lib/admin/domain-branding';
interface ConfigEntry {
value?: unknown;
@@ -16,28 +22,65 @@ const IMAGE_FIELDS = [
{ key: 'appLogoDarkUrl', label: 'App Logo (Dark Mode)', accept: '.svg,.png,.jpg,.webp' },
{ key: 'loginLogoLightUrl', label: 'Login Logo (Light Mode)', accept: '.svg,.png,.jpg,.webp' },
{ key: 'loginLogoDarkUrl', label: 'Login Logo (Dark Mode)', accept: '.svg,.png,.jpg,.webp' },
];
] as const;
const TEXT_FIELDS = [
{ key: 'loginCompanyName', label: 'Company Name' },
{ key: 'loginImprintUrl', label: 'Imprint URL' },
{ key: 'loginPrivacyPolicyUrl', label: 'Privacy Policy URL' },
{ key: 'loginWebsiteUrl', label: 'Company Website URL' },
];
] as const;
const PWA_IMAGE_FIELDS = [
{ key: 'pwaIconUrl', label: 'PWA Icon', accept: '.svg,.png,.jpg,.webp' },
] as const;
const PWA_TEXT_FIELDS = [
{ key: 'appShortName', label: 'Short Name', placeholder: 'Shown on home screen (max ~12 chars)' },
{ key: 'appDescription', label: 'Description', placeholder: 'App description for install prompts' },
] as const;
const PWA_COLOR_FIELDS = [
{ key: 'pwaThemeColor', label: 'Theme Color', defaultValue: '#ffffff' },
{ key: 'pwaBackgroundColor', label: 'Background Color', defaultValue: '#ffffff' },
] as const;
// Accepts exact hosts and one-level wildcards (e.g. *.example.com).
const HOST_RE = /^(\*\.)?[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)*$/;
// Tighter rule for uploads: wildcards can only point to externally-hosted
// URLs, since we'd have no concrete subdomain to serve a file from.
const EXACT_HOST_RE = /^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)*$/;
export function BrandingTab() {
const [config, setConfig] = useState<Record<string, ConfigEntry>>({});
const [edits, setEdits] = useState<Record<string, unknown>>({});
const [edits, setEdits] = useState<Record<string, string>>({});
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [uploading, setUploading] = useState<string | null>(null);
const [message, setMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null);
const [selectedHost, setSelectedHost] = useState<string | null>(null);
const [addingHost, setAddingHost] = useState(false);
const [newHostInput, setNewHostInput] = useState('');
const [newHostError, setNewHostError] = useState<string | null>(null);
const fileInputRefs = useRef<Record<string, HTMLInputElement | null>>({});
useEffect(() => {
fetchConfig();
}, []);
const domainEntries = useMemo<DomainBrandingEntry[]>(
() => parseDomainBranding(config['domainBranding']?.value),
[config],
);
// Drop selection if the host disappeared from the config (e.g. concurrent edit).
useEffect(() => {
if (selectedHost && !domainEntries.some(e => e.host === selectedHost)) {
setSelectedHost(null);
setEdits({});
}
}, [domainEntries, selectedHost]);
async function fetchConfig() {
setLoading(true);
const res = await apiFetch('/api/admin/config');
@@ -45,29 +88,81 @@ export function BrandingTab() {
setLoading(false);
}
function selectedEntry(): DomainBrandingEntry | null {
if (!selectedHost) return null;
return domainEntries.find(e => e.host === selectedHost) ?? null;
}
function handleChange(key: string, value: string) {
setEdits(prev => ({ ...prev, [key]: value }));
setMessage(null);
}
function currentValue(key: string): string {
if (key in edits) return edits[key] as string;
if (key in edits) return edits[key];
if (selectedHost) {
const entry = selectedEntry();
return (entry?.[key as BrandingOverrideKey] as string | undefined) ?? '';
}
return (config[key]?.value as string) ?? '';
}
function isOverriddenInScope(key: string): boolean {
if (selectedHost) {
const entry = selectedEntry();
const v = entry?.[key as BrandingOverrideKey];
return typeof v === 'string' && v.length > 0;
}
return config[key]?.source === 'admin';
}
const isUploadedFile = (key: string): boolean => {
const val = currentValue(key);
return val.startsWith('/api/admin/branding/');
};
function buildUpdatedDomainBranding(merge: Record<string, string>): DomainBrandingEntry[] {
if (!selectedHost) return domainEntries;
const next = domainEntries.slice();
const idx = next.findIndex(e => e.host === selectedHost);
const base: DomainBrandingEntry =
idx === -1 ? { host: selectedHost } : { ...next[idx] };
const writable = base as unknown as Record<string, string | undefined>;
for (const [key, value] of Object.entries(merge)) {
if (!(BRANDING_OVERRIDE_KEYS as readonly string[]).includes(key)) continue;
if (typeof value === 'string' && value.length > 0) {
writable[key] = value;
} else {
delete writable[key];
}
}
if (idx === -1) next.push(base);
else next[idx] = base;
return next;
}
async function handleSave() {
if (Object.keys(edits).length === 0) return;
setSaving(true);
setMessage(null);
const payload = selectedHost
? { domainBranding: buildUpdatedDomainBranding(edits) }
: edits;
const res = await apiFetch('/api/admin/config', {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(edits),
body: JSON.stringify(payload),
});
if (res.ok) {
setMessage({ type: 'success', text: 'Branding updated. Changes visible on next page load.' });
setMessage({
type: 'success',
text: selectedHost
? `Branding for ${selectedHost} updated. Changes visible on next page load.`
: 'Branding updated. Changes visible on next page load.',
});
setEdits({});
await fetchConfig();
} else {
@@ -78,12 +173,20 @@ export function BrandingTab() {
}
async function handleUpload(slot: string, file: File) {
if (selectedHost && !EXACT_HOST_RE.test(selectedHost)) {
setMessage({
type: 'error',
text: 'Wildcard hosts cannot upload files. Enter a URL instead.',
});
return;
}
setUploading(slot);
setMessage(null);
const formData = new FormData();
formData.append('file', file);
formData.append('slot', slot);
if (selectedHost) formData.append('host', selectedHost);
const res = await apiFetch('/api/admin/branding', {
method: 'POST',
@@ -98,10 +201,9 @@ export function BrandingTab() {
delete next[slot];
return next;
});
setConfig(prev => ({
...prev,
[slot]: { value: data.url, source: 'admin' },
}));
// Refresh from server so domainBranding entries reflect the upload.
await fetchConfig();
void data;
} else {
const data = await res.json();
setMessage({ type: 'error', text: data.error || 'Upload failed' });
@@ -112,10 +214,13 @@ export function BrandingTab() {
async function handleDeleteUpload(slot: string) {
setMessage(null);
const body: { slot: string; host?: string } = { slot };
if (selectedHost) body.host = selectedHost;
const res = await apiFetch('/api/admin/branding', {
method: 'DELETE',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ slot }),
body: JSON.stringify(body),
});
if (res.ok) {
@@ -133,6 +238,25 @@ export function BrandingTab() {
}
async function handleRevert(key: string) {
if (selectedHost) {
// Domain scope: drop the field from the entry and PATCH the array.
const updated = buildUpdatedDomainBranding({ [key]: '' });
const res = await apiFetch('/api/admin/config', {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ domainBranding: updated }),
});
if (res.ok) {
setEdits(prev => {
const next = { ...prev };
delete next[key];
return next;
});
await fetchConfig();
}
return;
}
// Default scope: revert via DELETE /api/admin/config
const res = await apiFetch('/api/admin/config', {
method: 'DELETE',
headers: { 'Content-Type': 'application/json' },
@@ -148,12 +272,71 @@ export function BrandingTab() {
}
}
const isUploadedFile = (key: string): boolean => {
const val = currentValue(key);
return val.startsWith('/api/admin/branding/');
};
async function handleAddDomain() {
const host = newHostInput.trim().toLowerCase().replace(/\.+$/, '');
if (!host) {
setNewHostError('Enter a hostname');
return;
}
if (!HOST_RE.test(host)) {
setNewHostError('Invalid hostname. Use foo.example.com or *.example.com');
return;
}
if (domainEntries.some(e => e.host === host)) {
setNewHostError('A branding entry for this host already exists');
return;
}
setNewHostError(null);
const next: DomainBrandingEntry[] = [...domainEntries, { host }];
const res = await apiFetch('/api/admin/config', {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ domainBranding: next }),
});
if (res.ok) {
setNewHostInput('');
setAddingHost(false);
setSelectedHost(host);
setEdits({});
await fetchConfig();
} else {
const data = await res.json();
setNewHostError(data.error || 'Failed to add domain');
}
}
async function handleDeleteDomain() {
if (!selectedHost) return;
if (!confirm(`Remove branding entry for ${selectedHost}? Uploaded files for this domain will be left behind on disk.`)) {
return;
}
const next = domainEntries.filter(e => e.host !== selectedHost);
const res = await apiFetch('/api/admin/config', {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ domainBranding: next }),
});
if (res.ok) {
setSelectedHost(null);
setEdits({});
await fetchConfig();
setMessage({ type: 'success', text: `Removed branding entry for ${selectedHost}.` });
} else {
const data = await res.json();
setMessage({ type: 'error', text: data.error || 'Failed to remove domain' });
}
}
function handleScopeChange(host: string | null) {
if (Object.keys(edits).length > 0 && !confirm('Discard unsaved changes?')) return;
setSelectedHost(host);
setEdits({});
setMessage(null);
}
const hasEdits = Object.keys(edits).length > 0;
const wildcardScope = !!selectedHost && !EXACT_HOST_RE.test(selectedHost);
if (loading) {
return <div className="flex items-center justify-center py-12 text-muted-foreground text-sm">Loading...</div>;
@@ -178,6 +361,102 @@ export function BrandingTab() {
)}
</div>
{/* Scope picker */}
<div className="border border-border rounded-lg">
<div className="px-4 py-3 border-b border-border bg-muted/30 flex items-center gap-2">
<Globe className="w-4 h-4 text-muted-foreground" />
<h2 className="text-sm font-medium text-foreground">Scope</h2>
</div>
<div className="px-4 py-3 space-y-3">
<div className="flex flex-wrap gap-2">
<button
type="button"
onClick={() => handleScopeChange(null)}
className={`h-8 px-3 rounded-md text-sm font-medium transition-colors ${
selectedHost === null
? 'bg-primary text-primary-foreground'
: 'bg-muted text-foreground hover:bg-muted/70'
}`}
>
Default
</button>
{domainEntries.map(entry => (
<button
key={entry.host}
type="button"
onClick={() => handleScopeChange(entry.host)}
className={`h-8 px-3 rounded-md text-sm font-medium transition-colors ${
selectedHost === entry.host
? 'bg-primary text-primary-foreground'
: 'bg-muted text-foreground hover:bg-muted/70'
}`}
>
{entry.host}
</button>
))}
{!addingHost && (
<button
type="button"
onClick={() => { setAddingHost(true); setNewHostError(null); }}
className="inline-flex items-center gap-1 h-8 px-3 rounded-md border border-dashed border-input text-sm text-muted-foreground hover:bg-muted hover:text-foreground transition-colors"
>
<Plus className="w-3.5 h-3.5" />
Add domain
</button>
)}
</div>
{addingHost && (
<div className="flex flex-wrap items-center gap-2">
<input
type="text"
autoFocus
value={newHostInput}
onChange={(e) => { setNewHostInput(e.target.value); setNewHostError(null); }}
onKeyDown={(e) => { if (e.key === 'Enter') void handleAddDomain(); }}
placeholder="mail.example.com or *.example.com"
className="h-8 w-64 rounded-md border border-input bg-background px-2.5 text-sm text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
/>
<button
type="button"
onClick={handleAddDomain}
className="h-8 px-3 rounded-md bg-primary text-primary-foreground text-sm font-medium hover:bg-primary/90 transition-colors"
>
Add
</button>
<button
type="button"
onClick={() => { setAddingHost(false); setNewHostInput(''); setNewHostError(null); }}
className="h-8 px-2.5 rounded-md text-sm text-muted-foreground hover:text-foreground transition-colors"
>
Cancel
</button>
{newHostError && <span className="text-xs text-destructive">{newHostError}</span>}
</div>
)}
{selectedHost ? (
<div className="flex items-center justify-between gap-3 text-xs">
<p className="text-muted-foreground">
Editing overrides for <span className="font-mono text-foreground">{selectedHost}</span>.
Unset fields fall back to the Default values.
{wildcardScope && ' Uploads are disabled for wildcard hosts; enter a URL instead.'}
</p>
<button
type="button"
onClick={handleDeleteDomain}
className="inline-flex items-center gap-1 text-destructive hover:underline whitespace-nowrap"
>
<X className="w-3.5 h-3.5" />
Remove domain
</button>
</div>
) : (
<p className="text-xs text-muted-foreground">
Editing the Default branding. Add a domain to override branding when the webmail is served on a specific hostname.
</p>
)}
</div>
</div>
{message && (
<div className={`text-sm rounded-md px-3 py-2 ${message.type === 'success' ? 'bg-emerald-50 text-emerald-700 dark:bg-emerald-950/30 dark:text-emerald-300' : 'bg-destructive/10 text-destructive'}`}>
{message.text}
@@ -195,9 +474,9 @@ export function BrandingTab() {
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between sm:gap-4">
<div className="flex items-center gap-2 min-w-0">
<label className="text-sm text-foreground">{field.label}</label>
{config[field.key]?.source === 'admin' && (
{isOverriddenInScope(field.key) && (
<span className="text-[10px] font-medium uppercase tracking-wider px-1.5 py-0.5 rounded bg-primary/10 text-primary">
{isUploadedFile(field.key) ? 'uploaded' : 'admin'}
{isUploadedFile(field.key) ? 'uploaded' : selectedHost ? 'domain' : 'admin'}
</span>
)}
</div>
@@ -206,7 +485,7 @@ export function BrandingTab() {
type="text"
value={currentValue(field.key)}
onChange={(e) => handleChange(field.key, e.target.value)}
placeholder="Enter URL or upload a file"
placeholder={selectedHost ? 'Enter URL (uploads only for default scope)' : 'Enter URL or upload a file'}
className="h-8 w-full sm:w-64 min-w-0 rounded-md border border-input bg-background px-2.5 text-sm text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
/>
<input
@@ -222,9 +501,9 @@ export function BrandingTab() {
/>
<button
onClick={() => fileInputRefs.current[field.key]?.click()}
disabled={uploading === field.key}
disabled={uploading === field.key || wildcardScope}
className="inline-flex items-center gap-1.5 h-8 px-2.5 rounded-md border border-input bg-background text-sm text-foreground hover:bg-muted disabled:opacity-50 transition-colors"
title="Upload file"
title={wildcardScope ? 'Uploads disabled for wildcard hosts' : 'Upload file'}
>
{uploading === field.key ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : <Upload className="w-3.5 h-3.5" />}
</button>
@@ -237,7 +516,7 @@ export function BrandingTab() {
<Trash2 className="w-3.5 h-3.5" />
</button>
)}
{config[field.key]?.source === 'admin' && !isUploadedFile(field.key) && (
{isOverriddenInScope(field.key) && !isUploadedFile(field.key) && (
<button onClick={() => handleRevert(field.key)} className="text-muted-foreground hover:text-foreground" title="Revert to default">
<RotateCcw className="w-3.5 h-3.5" />
</button>
@@ -262,6 +541,146 @@ export function BrandingTab() {
</div>
</div>
<div className="border border-border rounded-lg">
<div className="px-4 py-3 border-b border-border bg-muted/30">
<h2 className="text-sm font-medium text-foreground">Progressive Web App</h2>
<p className="text-xs text-muted-foreground mt-0.5">Shown when users install the webmail to their home screen. Leave fields blank to fall back to the favicon and app name.</p>
</div>
<div className="divide-y divide-border">
{PWA_IMAGE_FIELDS.map(field => (
<div key={field.key} className="px-4 py-3">
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between sm:gap-4">
<div className="flex items-center gap-2 min-w-0">
<label className="text-sm text-foreground">{field.label}</label>
{isOverriddenInScope(field.key) && (
<span className="text-[10px] font-medium uppercase tracking-wider px-1.5 py-0.5 rounded bg-primary/10 text-primary">
{isUploadedFile(field.key) ? 'uploaded' : selectedHost ? 'domain' : 'admin'}
</span>
)}
</div>
<div className="flex items-center gap-2 w-full sm:w-auto">
<input
type="text"
value={currentValue(field.key)}
onChange={(e) => handleChange(field.key, e.target.value)}
placeholder={selectedHost ? 'Enter URL (uploads only for default scope)' : 'Enter URL or upload a file'}
className="h-8 w-full sm:w-64 min-w-0 rounded-md border border-input bg-background px-2.5 text-sm text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
/>
<input
ref={el => { fileInputRefs.current[field.key] = el; }}
type="file"
accept={field.accept}
className="hidden"
onChange={(e) => {
const file = e.target.files?.[0];
if (file) handleUpload(field.key, file);
e.target.value = '';
}}
/>
<button
onClick={() => fileInputRefs.current[field.key]?.click()}
disabled={uploading === field.key || wildcardScope}
className="inline-flex items-center gap-1.5 h-8 px-2.5 rounded-md border border-input bg-background text-sm text-foreground hover:bg-muted disabled:opacity-50 transition-colors"
title={wildcardScope ? 'Uploads disabled for wildcard hosts' : 'Upload file'}
>
{uploading === field.key ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : <Upload className="w-3.5 h-3.5" />}
</button>
{isUploadedFile(field.key) && (
<button
onClick={() => handleDeleteUpload(field.key)}
className="text-muted-foreground hover:text-destructive transition-colors"
title="Remove uploaded file"
>
<Trash2 className="w-3.5 h-3.5" />
</button>
)}
{isOverriddenInScope(field.key) && !isUploadedFile(field.key) && (
<button onClick={() => handleRevert(field.key)} className="text-muted-foreground hover:text-foreground" title="Revert to default">
<RotateCcw className="w-3.5 h-3.5" />
</button>
)}
</div>
</div>
{currentValue(field.key) && (
<div className="mt-2 flex items-center gap-2">
<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">
<img
src={currentValue(field.key)}
alt={field.label}
className="max-h-6 max-w-[200px] object-contain"
onError={(e) => { (e.target as HTMLImageElement).style.display = 'none'; }}
/>
</div>
</div>
)}
</div>
))}
{PWA_TEXT_FIELDS.map(field => (
<div key={field.key} className="px-4 py-3 flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between sm:gap-4">
<div className="flex items-center gap-2 min-w-0">
<label className="text-sm text-foreground">{field.label}</label>
{isOverriddenInScope(field.key) && (
<span className="text-[10px] font-medium uppercase tracking-wider px-1.5 py-0.5 rounded bg-primary/10 text-primary">
{selectedHost ? 'domain' : 'admin'}
</span>
)}
</div>
<div className="flex items-center gap-2 w-full sm:w-auto">
<input
type="text"
value={currentValue(field.key)}
onChange={(e) => handleChange(field.key, e.target.value)}
placeholder={field.placeholder}
className="h-8 w-full sm:w-72 min-w-0 rounded-md border border-input bg-background px-2.5 text-sm text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
/>
{isOverriddenInScope(field.key) && (
<button onClick={() => handleRevert(field.key)} className="text-muted-foreground hover:text-foreground" title="Revert to default">
<RotateCcw className="w-3.5 h-3.5" />
</button>
)}
</div>
</div>
))}
{PWA_COLOR_FIELDS.map(field => {
const value = currentValue(field.key) || field.defaultValue;
return (
<div key={field.key} className="px-4 py-3 flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between sm:gap-4">
<div className="flex items-center gap-2 min-w-0">
<label className="text-sm text-foreground">{field.label}</label>
{isOverriddenInScope(field.key) && (
<span className="text-[10px] font-medium uppercase tracking-wider px-1.5 py-0.5 rounded bg-primary/10 text-primary">
{selectedHost ? 'domain' : 'admin'}
</span>
)}
</div>
<div className="flex items-center gap-2 w-full sm:w-auto">
<input
type="color"
value={/^#[0-9a-fA-F]{6}$/.test(value) ? value : field.defaultValue}
onChange={(e) => handleChange(field.key, e.target.value)}
className="h-8 w-10 cursor-pointer rounded-md border border-input bg-background p-0.5"
title="Pick a color"
/>
<input
type="text"
value={currentValue(field.key)}
onChange={(e) => handleChange(field.key, e.target.value)}
placeholder={field.defaultValue}
className="h-8 w-full sm:w-32 min-w-0 rounded-md border border-input bg-background px-2.5 text-sm font-mono text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
/>
{isOverriddenInScope(field.key) && (
<button onClick={() => handleRevert(field.key)} className="text-muted-foreground hover:text-foreground" title="Revert to default">
<RotateCcw className="w-3.5 h-3.5" />
</button>
)}
</div>
</div>
);
})}
</div>
</div>
<div className="border border-border rounded-lg">
<div className="px-4 py-3 border-b border-border bg-muted/30">
<h2 className="text-sm font-medium text-foreground">Company Information</h2>
@@ -271,8 +690,10 @@ export function BrandingTab() {
<div key={field.key} className="px-4 py-3 flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between sm:gap-4">
<div className="flex items-center gap-2 min-w-0">
<label className="text-sm text-foreground">{field.label}</label>
{config[field.key]?.source === 'admin' && (
<span className="text-[10px] font-medium uppercase tracking-wider px-1.5 py-0.5 rounded bg-primary/10 text-primary">admin</span>
{isOverriddenInScope(field.key) && (
<span className="text-[10px] font-medium uppercase tracking-wider px-1.5 py-0.5 rounded bg-primary/10 text-primary">
{selectedHost ? 'domain' : 'admin'}
</span>
)}
</div>
<div className="flex items-center gap-2 w-full sm:w-auto">
@@ -283,7 +704,7 @@ export function BrandingTab() {
placeholder={field.key.includes('Url') ? 'https://...' : 'Enter value'}
className="h-8 w-full sm:w-72 min-w-0 rounded-md border border-input bg-background px-2.5 text-sm text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
/>
{config[field.key]?.source === 'admin' && (
{isOverriddenInScope(field.key) && (
<button onClick={() => handleRevert(field.key)} className="text-muted-foreground hover:text-foreground" title="Revert to default">
<RotateCcw className="w-3.5 h-3.5" />
</button>
+41 -1
View File
@@ -28,7 +28,7 @@ const RESTRICTABLE_SETTINGS = [
{ key: 'density', label: 'Density', category: 'Appearance', type: 'enum', allowedValues: ['compact', 'regular', 'spacious'] },
{ key: 'animationsEnabled', label: 'Animations', category: 'Appearance', type: 'boolean' },
{ key: 'markAsReadDelay', label: 'Mark as Read Delay', category: 'Email', type: 'number' },
{ key: 'deleteAction', label: 'Delete Action', category: 'Email', type: 'enum', allowedValues: ['trash', 'permanent'] },
{ key: 'deleteAction', label: 'Delete Action', category: 'Email', type: 'enum', allowedValues: ['trash', 'trash-and-read', 'permanent'] },
{ key: 'showPreview', label: 'Show Preview', category: 'Email', type: 'boolean' },
{ key: 'mailLayout', label: 'Mail Layout', category: 'Email', type: 'enum', allowedValues: ['split', 'focus', 'horizontal'] },
{ key: 'emailsPerPage', label: 'Emails Per Page', category: 'Email', type: 'number' },
@@ -74,6 +74,18 @@ export function PolicyTab() {
setMessage(null);
}
function setPushRelayUrl(value: string) {
setPolicy(prev => ({ ...prev, pushRelayUrl: value }));
setDirty(true);
setMessage(null);
}
function togglePushRelayLocked() {
setPolicy(prev => ({ ...prev, pushRelayUrlLocked: !prev.pushRelayUrlLocked }));
setDirty(true);
setMessage(null);
}
function toggleLocked(settingKey: string) {
setPolicy(prev => {
const existing = prev.restrictions[settingKey] || {};
@@ -183,6 +195,34 @@ export function PolicyTab() {
</div>
</div>
<div className="border border-border rounded-lg">
<div className="px-4 py-3 border-b border-border bg-muted/30">
<h2 className="text-sm font-medium text-foreground">Push Relay</h2>
<p className="text-xs text-muted-foreground mt-0.5">Override the Web Push relay URL shown in user notification settings. Leave empty to use the built-in default.</p>
</div>
<div className="px-4 py-3 space-y-3">
<input
type="url"
inputMode="url"
autoComplete="off"
spellCheck={false}
value={policy.pushRelayUrl ?? ''}
onChange={(e) => setPushRelayUrl(e.target.value)}
placeholder="https://notifications.relay.example.com"
className="w-full rounded border border-input bg-background px-3 py-2 text-sm"
/>
<label className="flex items-center gap-1.5 text-xs text-muted-foreground cursor-pointer">
<input
type="checkbox"
checked={!!policy.pushRelayUrlLocked}
onChange={togglePushRelayLocked}
className="rounded border-input"
/>
<Lock className="w-3 h-3" /> Lock - users cannot change this URL
</label>
</div>
</div>
{categories.map(category => (
<div key={category} className="border border-border rounded-lg">
<div className="px-4 py-3 border-b border-border bg-muted/30">
+3 -3
View File
@@ -32,7 +32,7 @@ import { useThemeStore } from '@/stores/theme-store';
import { getActiveAccountSlotHeaders } from '@/lib/auth/active-account-slot';
import { useUpdateStore, selectHasUpdate } from '@/stores/update-store';
import { apiFetch, getPathPrefix } from '@/lib/browser-navigation';
import { apiFetch, getPathPrefix, withBasePath } from '@/lib/browser-navigation';
// Single-page tab navigation: clicks update a Zustand store. The URL stays
// at /admin so React doesn't fire a route transition on every tab switch -
@@ -90,9 +90,9 @@ export default function AdminLayout({ children }: { children: React.ReactNode })
const { appLogoLightUrl, appLogoDarkUrl, loginLogoLightUrl, loginLogoDarkUrl } = useConfig();
const filesEnabled = usePolicyStore((s) => s.isFeatureEnabled('filesEnabled'));
const resolvedTheme = useThemeStore((s) => s.resolvedTheme);
const logoUrl = resolvedTheme === 'dark'
const logoUrl = withBasePath(resolvedTheme === 'dark'
? (appLogoDarkUrl || appLogoLightUrl || loginLogoDarkUrl)
: (appLogoLightUrl || appLogoDarkUrl || loginLogoLightUrl);
: (appLogoLightUrl || appLogoDarkUrl || loginLogoLightUrl));
// Match the navigation rail: red for security/deprecated, amber for normal.
const hasUpdate = useUpdateStore(selectHasUpdate);
+2 -2
View File
@@ -5,7 +5,7 @@ import { useRouter } from 'next/navigation';
import { Shield } from 'lucide-react';
import { useConfig } from '@/hooks/use-config';
import { useThemeStore } from '@/stores/theme-store';
import { apiFetch } from '@/lib/browser-navigation';
import { apiFetch, withBasePath } from '@/lib/browser-navigation';
export default function AdminLoginPage() {
const router = useRouter();
@@ -14,7 +14,7 @@ export default function AdminLoginPage() {
const [loading, setLoading] = useState(false);
const { loginLogoLightUrl, loginLogoDarkUrl } = useConfig();
const resolvedTheme = useThemeStore((s) => s.resolvedTheme);
const logoUrl = resolvedTheme === 'dark' ? loginLogoDarkUrl : loginLogoLightUrl;
const logoUrl = withBasePath(resolvedTheme === 'dark' ? loginLogoDarkUrl : loginLogoLightUrl);
async function handleSubmit(e: FormEvent) {
e.preventDefault();
+2 -1
View File
@@ -5,6 +5,7 @@ import { getLocale } from "next-intl/server";
import { PWAInstallPrompt } from "@/components/pwa-install-prompt";
import { ServiceWorkerRegistration } from "@/components/service-worker-registration";
import { configManager } from "@/lib/admin/config-manager";
import { withBasePath } from "@/lib/browser-navigation";
import "../globals.css";
const geistSans = Geist({
@@ -38,7 +39,7 @@ export async function generateMetadata(): Promise<Metadata> {
formatDetection: {
telephone: false,
},
icons: { icon: faviconUrl },
icons: { icon: withBasePath(faviconUrl) },
};
}
+7 -7
View File
@@ -3,7 +3,7 @@
import { useEffect, useState, type FormEvent, type ReactNode } from 'react';
import { useRouter, useSearchParams } from 'next/navigation';
import { CheckCircle2, AlertTriangle, AlertCircle, Server, ShieldCheck, KeyRound, FileText, Palette, Lock, ShieldAlert } from 'lucide-react';
import { apiFetch } from '@/lib/browser-navigation';
import { apiFetch, getPathPrefix, withBasePath } from '@/lib/browser-navigation';
type State = 'bootstrap' | 'configured' | 'env-managed';
@@ -258,7 +258,7 @@ export default function SetupWizardPage() {
// edge cases that swallow client-side replaces after the
// setupComplete flag flips.
setTimeout(() => {
window.location.assign('/admin/login');
window.location.assign(`${getPathPrefix()}/admin/login`);
}, 1500);
}}
/>
@@ -340,13 +340,13 @@ function CompletedScreen() {
</div>
<div className="mt-6 space-y-2">
<a
href="/admin/login"
href={`${getPathPrefix()}/admin/login`}
className="block w-full rounded-md bg-primary text-primary-foreground text-center px-4 py-2.5 text-sm font-medium hover:bg-primary/90"
>
Sign in to admin dashboard
</a>
<a
href="/"
href={`${getPathPrefix()}/`}
className="block w-full rounded-md border border-border text-center px-4 py-2.5 text-sm font-medium hover:bg-muted"
>
Open webmail login
@@ -422,13 +422,13 @@ function AlreadyConfiguredScreen() {
</div>
<div className="mt-6 space-y-2">
<a
href="/admin/login"
href={`${getPathPrefix()}/admin/login`}
className="block w-full rounded-md bg-primary text-primary-foreground text-center px-4 py-2.5 text-sm font-medium hover:bg-primary/90"
>
Sign in to admin dashboard
</a>
<a
href="/"
href={`${getPathPrefix()}/`}
className="block w-full rounded-md border border-border text-center px-4 py-2.5 text-sm font-medium hover:bg-muted"
>
Open webmail login
@@ -1329,7 +1329,7 @@ function BrandingAsset({
}}
/>
{value ? (
<img src={value} alt="" className="max-w-full max-h-full object-contain" />
<img src={withBasePath(value)} alt="" className="max-w-full max-h-full object-contain" />
) : (
<span className="text-[10px] text-muted-foreground text-center px-1">click or drop</span>
)}
+156 -32
View File
@@ -3,8 +3,13 @@ import { requireAdminAuth, getClientIP } from '@/lib/admin/session';
import { auditLog } from '@/lib/admin/audit';
import { configManager } from '@/lib/admin/config-manager';
import { getConfigDir } from '@/lib/admin/paths';
import {
parseDomainBranding,
type DomainBrandingEntry,
type BrandingOverrideKey,
} from '@/lib/admin/domain-branding';
import { logger } from '@/lib/logger';
import { writeFile, unlink, mkdir } from 'node:fs/promises';
import { writeFile, unlink, mkdir, readdir } from 'node:fs/promises';
import { existsSync } from 'node:fs';
import path from 'node:path';
@@ -22,25 +27,94 @@ const ALLOWED_MIME_TYPES = new Set([
]);
/** Slots that correspond to branding config keys */
const VALID_SLOTS = new Set([
const VALID_SLOTS = new Set<BrandingOverrideKey>([
'faviconUrl',
'pwaIconUrl',
'appLogoLightUrl',
'appLogoDarkUrl',
'loginLogoLightUrl',
'loginLogoDarkUrl',
]);
const EXT_BY_MIME: Record<string, string> = {
'image/svg+xml': '.svg',
'image/png': '.png',
'image/jpeg': '.jpg',
'image/webp': '.webp',
'image/x-icon': '.ico',
'image/vnd.microsoft.icon': '.ico',
};
const POSSIBLE_EXTS = ['.svg', '.png', '.jpg', '.jpeg', '.webp', '.ico'];
// Exact hostnames only (no wildcards): wildcards can't be uploaded against
// because we'd need a real subdomain to serve the file from.
const EXACT_HOST_RE = /^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)*$/;
function sanitizeFilename(name: string): string {
// Strip directory traversal, keep only safe chars
return path.basename(name).replace(/[^a-zA-Z0-9._-]/g, '_');
}
function normalizeHost(raw: string): string {
return raw.trim().toLowerCase().replace(/\.+$/, '');
}
/** Filename used to store a per-host uploaded asset. */
function domainAssetName(host: string, slot: BrandingOverrideKey, ext: string): string {
return sanitizeFilename(`domain__${host}__${slot}${ext}`);
}
/** True if the file belongs to the given host+slot (any extension). */
function isDomainAssetFor(filename: string, host: string, slot: BrandingOverrideKey): boolean {
const prefix = sanitizeFilename(`domain__${host}__${slot}.`);
return filename.startsWith(prefix);
}
/** Merge a per-host update into the existing domainBranding array. */
function mergeDomainEntry(
current: DomainBrandingEntry[],
host: string,
patch: Partial<DomainBrandingEntry>,
): DomainBrandingEntry[] {
const next = current.slice();
const idx = next.findIndex(e => e.host === host);
if (idx === -1) {
next.push({ host, ...patch });
} else {
next[idx] = { ...next[idx], ...patch };
}
return next;
}
/** Remove keys from a host's entry. If the entry has nothing left besides
* `host`, drop it entirely. */
function clearDomainKeys(
current: DomainBrandingEntry[],
host: string,
keys: BrandingOverrideKey[],
): DomainBrandingEntry[] {
const idx = current.findIndex(e => e.host === host);
if (idx === -1) return current;
const entry = { ...current[idx] };
for (const key of keys) delete (entry as Record<string, unknown>)[key];
const next = current.slice();
if (Object.keys(entry).filter(k => k !== 'host').length === 0) {
next.splice(idx, 1);
} else {
next[idx] = entry;
}
return next;
}
/**
* POST /api/admin/branding - Upload a branding image file
*
* Expects multipart/form-data with:
* - file: the image file
* - slot: which branding field this is for (e.g. "faviconUrl")
* - host (optional): when set, the upload is stored against the
* per-domain entry for that hostname instead of the global default.
*/
export async function POST(request: NextRequest) {
try {
@@ -51,15 +125,24 @@ export async function POST(request: NextRequest) {
const formData = await request.formData();
const file = formData.get('file') as File | null;
const slot = formData.get('slot') as string | null;
const rawHost = (formData.get('host') as string | null) ?? '';
if (!file || !slot) {
return NextResponse.json({ error: 'Missing file or slot' }, { status: 400 });
}
if (!VALID_SLOTS.has(slot)) {
if (!VALID_SLOTS.has(slot as BrandingOverrideKey)) {
return NextResponse.json({ error: `Invalid slot: ${slot}` }, { status: 400 });
}
const host = rawHost ? normalizeHost(rawHost) : '';
if (host && !EXACT_HOST_RE.test(host)) {
return NextResponse.json(
{ error: `Invalid host: ${rawHost} (wildcards must be configured by URL, not upload)` },
{ status: 400 },
);
}
if (file.size > MAX_FILE_SIZE) {
return NextResponse.json({ error: 'File too large (max 2 MB)' }, { status: 400 });
}
@@ -71,34 +154,51 @@ export async function POST(request: NextRequest) {
);
}
// Determine extension from mime type
const extMap: Record<string, string> = {
'image/svg+xml': '.svg',
'image/png': '.png',
'image/jpeg': '.jpg',
'image/webp': '.webp',
'image/x-icon': '.ico',
'image/vnd.microsoft.icon': '.ico',
};
const ext = extMap[file.type] || '.png';
const safeName = sanitizeFilename(`${slot}${ext}`);
const ext = EXT_BY_MIME[file.type] ?? '.png';
const safeName = host
? domainAssetName(host, slot as BrandingOverrideKey, ext)
: sanitizeFilename(`${slot}${ext}`);
const filePath = path.join(getBrandingDir(), safeName);
// Ensure branding directory exists
if (!existsSync(getBrandingDir())) {
await mkdir(getBrandingDir(), { recursive: true });
}
// Write file to disk
// Strip any prior asset for the same slot but a different extension so
// the directory doesn't accumulate orphan files on re-upload.
const dir = getBrandingDir();
const allFiles = await readdir(dir).catch(() => [] as string[]);
for (const f of allFiles) {
if (f === safeName) continue;
const isSame = host
? isDomainAssetFor(f, host, slot as BrandingOverrideKey)
: POSSIBLE_EXTS.some(e => f === `${slot}${e}`);
if (isSame) {
try { await unlink(path.join(dir, f)); } catch { /* ignore */ }
}
}
const buffer = Buffer.from(await file.arrayBuffer());
await writeFile(filePath, buffer);
// Update config to point to the served URL
const servedUrl = `/api/admin/branding/${safeName}`;
await configManager.ensureLoaded();
await configManager.setAdminConfig({ [slot]: servedUrl });
await auditLog('branding_upload', { slot, filename: safeName, size: file.size, mimeType: file.type }, ip);
if (host) {
const current = parseDomainBranding(configManager.get<unknown>('domainBranding', []));
const next = mergeDomainEntry(current, host, { [slot]: servedUrl });
await configManager.setAdminConfig({ domainBranding: next });
} else {
await configManager.setAdminConfig({ [slot]: servedUrl });
}
await auditLog('branding_upload', {
slot,
host: host || undefined,
filename: safeName,
size: file.size,
mimeType: file.type,
}, ip);
return NextResponse.json({ url: servedUrl, filename: safeName });
} catch (error) {
@@ -110,7 +210,11 @@ export async function POST(request: NextRequest) {
/**
* DELETE /api/admin/branding - Remove an uploaded branding file
*
* Expects JSON body: { slot: string }
* Expects JSON body: { slot: string, host?: string }
*
* When `host` is provided, only the per-domain asset for that host+slot is
* removed (and the override in `domainBranding[host][slot]` is cleared).
* Otherwise the global asset and config override are removed.
*/
export async function DELETE(request: NextRequest) {
try {
@@ -118,28 +222,48 @@ export async function DELETE(request: NextRequest) {
if ('error' in result) return result.error;
const ip = getClientIP(request);
const { slot } = await request.json();
const body = await request.json().catch(() => ({})) as { slot?: string; host?: string };
const slot = body.slot;
const rawHost = body.host ?? '';
if (!slot || !VALID_SLOTS.has(slot)) {
if (!slot || !VALID_SLOTS.has(slot as BrandingOverrideKey)) {
return NextResponse.json({ error: 'Invalid or missing slot' }, { status: 400 });
}
// Find and remove matching files for this slot
const possibleExts = ['.svg', '.png', '.jpg', '.webp', '.ico'];
const host = rawHost ? normalizeHost(rawHost) : '';
if (host && !EXACT_HOST_RE.test(host)) {
return NextResponse.json({ error: `Invalid host: ${rawHost}` }, { status: 400 });
}
const dir = getBrandingDir();
let removed = false;
for (const ext of possibleExts) {
const filePath = path.join(getBrandingDir(), `${slot}${ext}`);
if (existsSync(filePath)) {
await unlink(filePath);
removed = true;
if (host) {
const allFiles = await readdir(dir).catch(() => [] as string[]);
for (const f of allFiles) {
if (isDomainAssetFor(f, host, slot as BrandingOverrideKey)) {
try { await unlink(path.join(dir, f)); removed = true; } catch { /* ignore */ }
}
}
} else {
for (const ext of POSSIBLE_EXTS) {
const filePath = path.join(dir, `${slot}${ext}`);
if (existsSync(filePath)) {
await unlink(filePath);
removed = true;
}
}
}
// Clear the config override so it falls back to default/env
await configManager.ensureLoaded();
await configManager.removeAdminOverride(slot);
if (host) {
const current = parseDomainBranding(configManager.get<unknown>('domainBranding', []));
const next = clearDomainKeys(current, host, [slot as BrandingOverrideKey]);
await configManager.setAdminConfig({ domainBranding: next });
} else {
await configManager.removeAdminOverride(slot);
}
await auditLog('branding_delete', { slot, fileRemoved: removed }, ip);
await auditLog('branding_delete', { slot, host: host || undefined, fileRemoved: removed }, ip);
return NextResponse.json({ success: true });
} catch (error) {
+20
View File
@@ -4,6 +4,7 @@ import { requireAdminAuth, getClientIP } from '@/lib/admin/session';
import { auditLog } from '@/lib/admin/audit';
import { CONFIG_ENV_MAP, SENSITIVE_CONFIG_KEYS } from '@/lib/admin/types';
import { parseJmapServers } from '@/lib/admin/jmap-servers';
import { parseDomainBranding } from '@/lib/admin/domain-branding';
import { logger } from '@/lib/logger';
// Strings that count as "no real secret configured" - used so the dashboard
@@ -88,6 +89,25 @@ export async function PATCH(request: NextRequest) {
updates.jmapServers = sanitized;
}
// Normalize domainBranding: drop entries with an invalid/missing host or
// duplicate hosts before persisting. Each entry's branding field strings
// are passed through unchanged (URL/string content is the operator's
// responsibility, same as the flat branding fields).
if ('domainBranding' in updates) {
const incoming = updates.domainBranding;
if (incoming != null && !Array.isArray(incoming)) {
return NextResponse.json({ error: 'domainBranding must be an array' }, { status: 400 });
}
const sanitized = parseDomainBranding(incoming);
const incomingCount = Array.isArray(incoming) ? incoming.length : 0;
if (sanitized.length !== incomingCount) {
return NextResponse.json({
error: 'One or more domainBranding entries are invalid (each needs a unique, valid host).',
}, { status: 400 });
}
updates.domainBranding = sanitized;
}
// Get old values for audit
const oldValues: Record<string, unknown> = {};
for (const key of Object.keys(updates)) {
+2 -3
View File
@@ -3,9 +3,8 @@ import { cookies } from 'next/headers';
import { logger } from '@/lib/logger';
import { encryptPayload } from '@/lib/auth/crypto';
import { generateCodeVerifierServer, generateCodeChallengeServer, generateStateServer } from '@/lib/oauth/pkce-server';
import { getRequiredConfig } from '@/lib/oauth/token-exchange';
import { getRequiredConfig, getDiscoveryValidator } from '@/lib/oauth/token-exchange';
import { discoverOAuth } from '@/lib/oauth/discovery';
import { isPublicHttpUrl } from '@/lib/security/url-guard';
import { getOauthScopes } from '@/lib/oauth/tokens';
import { getCookieOptions } from '@/lib/oauth/cookie-config';
import { hasSessionSecret } from '@/lib/auth/session-secret';
@@ -62,7 +61,7 @@ export async function POST(request: NextRequest) {
}
const { clientId, discoveryUrl } = getRequiredConfig(serverId);
const metadata = await discoverOAuth(discoveryUrl, { validateEndpoint: isPublicHttpUrl });
const metadata = await discoverOAuth(discoveryUrl, { validateEndpoint: getDiscoveryValidator() });
if (!metadata?.authorization_endpoint) {
return NextResponse.json({ error: 'OAuth discovery failed' }, { status: 502 });
+11 -3
View File
@@ -2,6 +2,7 @@ import { NextRequest, NextResponse } from 'next/server';
import { cookies } from 'next/headers';
import { logger } from '@/lib/logger';
import { discoverOAuth } from '@/lib/oauth/discovery';
import { getDiscoveryValidator } from '@/lib/oauth/token-exchange';
import { refreshTokenCookieName, refreshTokenServerCookieName } from '@/lib/oauth/tokens';
import { getCookieOptions } from '@/lib/oauth/cookie-config';
import { readFileEnv } from '@/lib/read-file-env';
@@ -52,9 +53,13 @@ async function tryTokenRequest(
}
}
async function findTokenEndpoint(serverUrl: string): Promise<string | null> {
async function findTokenEndpoint(serverUrl: string, adminTrusted: boolean): Promise<string | null> {
// Admin-trusted callers (matched server entry or configured JMAP server URL)
// honor the `oauthAllowPrivateEndpoints` opt-in. User-supplied URLs always
// go through the SSRF validator regardless of the setting.
const validateEndpoint = adminTrusted ? getDiscoveryValidator() : isPublicHttpUrl;
// 1. Try OAuth discovery
const metadata = await discoverOAuth(serverUrl, { validateEndpoint: isPublicHttpUrl });
const metadata = await discoverOAuth(serverUrl, { validateEndpoint });
if (metadata?.token_endpoint) return metadata.token_endpoint;
// 2. Try common Stalwart token endpoint paths directly
@@ -105,14 +110,17 @@ export async function POST(request: NextRequest) {
let upstreamUrl: string;
let resolvedServerId: string | null = null;
let adminTrusted = false;
const requestedEntry = findServerById(serverList, requestedServerId);
const matchedEntry = requestedEntry || findServerByUrl(serverList, serverUrl);
if (matchedEntry) {
upstreamUrl = matchedEntry.url;
resolvedServerId = matchedEntry.id;
adminTrusted = true;
} else if (configuredServerUrl) {
upstreamUrl = configuredServerUrl;
adminTrusted = true;
} else if (allowCustomEndpoint) {
if (!(await isPublicHttpUrl(serverUrl))) {
logger.warn('TOTP token exchange: rejected non-public server URL');
@@ -123,7 +131,7 @@ export async function POST(request: NextRequest) {
return NextResponse.json({ error: 'jmap_server_not_configured' }, { status: 500 });
}
const tokenEndpoint = await findTokenEndpoint(upstreamUrl);
const tokenEndpoint = await findTokenEndpoint(upstreamUrl, adminTrusted);
if (!tokenEndpoint) {
logger.warn('TOTP token exchange: no token endpoint found');
return NextResponse.json({ error: 'no_token_endpoint', detail: 'Could not discover OAuth token endpoint on the mail server' }, { status: 404 });
+66 -36
View File
@@ -1,9 +1,15 @@
import { NextResponse } from 'next/server';
import { NextRequest, NextResponse } from 'next/server';
import { logger } from '@/lib/logger';
import { configManager } from '@/lib/admin/config-manager';
import { parseJmapServers, redactJmapServers } from '@/lib/admin/jmap-servers';
import { hasSessionSecret } from '@/lib/auth/session-secret';
import { getOauthScopes } from '@/lib/oauth/tokens';
import {
matchDomainBranding,
parseDomainBranding,
pickRequestHost,
type BrandingOverrideKey,
} from '@/lib/admin/domain-branding';
/**
* Runtime configuration endpoint
@@ -13,49 +19,73 @@ import { getOauthScopes } from '@/lib/oauth/tokens';
* post-build configuration for Docker deployments.
*
* Priority order:
* 1. Admin dashboard overrides (data/admin/config.json)
* 2. Runtime env vars (APP_NAME, JMAP_SERVER_URL)
* 3. Build-time env vars (NEXT_PUBLIC_APP_NAME, NEXT_PUBLIC_JMAP_SERVER_URL)
* 4. Default values
* 1. Per-domain branding override (admin-configured, matched on request host)
* 2. Admin dashboard overrides (data/admin/config.json)
* 3. Runtime env vars (APP_NAME, JMAP_SERVER_URL)
* 4. Build-time env vars (NEXT_PUBLIC_APP_NAME, NEXT_PUBLIC_JMAP_SERVER_URL)
* 5. Default values
*/
export async function GET() {
export async function GET(request: NextRequest) {
logger.debug('Config requested');
await configManager.ensureLoaded();
const appName = configManager.get<string>('appName') || process.env.NEXT_PUBLIC_APP_NAME || 'Webmail';
const host = pickRequestHost(request);
const domainOverrides = matchDomainBranding(
host,
parseDomainBranding(configManager.get<unknown>('domainBranding', [])),
);
// Per-domain override wins over the global value, but only when the
// entry explicitly sets that key. Otherwise we fall through to the
// global admin/env/default chain.
const branded = <T,>(key: BrandingOverrideKey, fallback: T): T => {
const override = domainOverrides[key];
if (typeof override === 'string' && override.length > 0) return override as T;
return configManager.get<T>(key, fallback);
};
const appName =
branded<string>('appName', '') || process.env.NEXT_PUBLIC_APP_NAME || 'Webmail';
const jmapServerUrl = configManager.get<string>('jmapServerUrl') || process.env.NEXT_PUBLIC_JMAP_SERVER_URL || '';
const oauthEnabled = configManager.get<boolean>('oauthEnabled', false);
const oauthOnly = oauthEnabled && configManager.get<boolean>('oauthOnly', false);
const stalwartFeaturesEnabled = configManager.get<boolean>('stalwartFeaturesEnabled', true);
const allowedFrameAncestors = configManager.get<string>('allowedFrameAncestors', '');
return NextResponse.json({
appName,
jmapServerUrl,
oauthEnabled,
oauthOnly,
oauthClientId: configManager.get<string>('oauthClientId', ''),
oauthIssuerUrl: configManager.get<string>('oauthIssuerUrl', ''),
oauthScopes: getOauthScopes(),
rememberMeEnabled: hasSessionSecret(),
settingsSyncEnabled: configManager.get<boolean>('settingsSyncEnabled', false) && hasSessionSecret(),
stalwartFeaturesEnabled,
devMode: configManager.get<boolean>('devMode', false),
faviconUrl: configManager.get<string>('faviconUrl', '/branding/Bulwark_Favicon.svg'),
appLogoLightUrl: configManager.get<string>('appLogoLightUrl', ''),
appLogoDarkUrl: configManager.get<string>('appLogoDarkUrl', ''),
loginLogoLightUrl: configManager.get<string>('loginLogoLightUrl', '/branding/Bulwark_Logo_Color.svg'),
loginLogoDarkUrl: configManager.get<string>('loginLogoDarkUrl', '/branding/Bulwark_Logo_White.svg'),
loginCompanyName: configManager.get<string>('loginCompanyName', ''),
loginImprintUrl: configManager.get<string>('loginImprintUrl', ''),
loginPrivacyPolicyUrl: configManager.get<string>('loginPrivacyPolicyUrl', ''),
loginWebsiteUrl: configManager.get<string>('loginWebsiteUrl', ''),
demoMode: configManager.get<boolean>('demoMode', false),
allowCustomJmapEndpoint: configManager.get<boolean>('allowCustomJmapEndpoint', false),
jmapServers: redactJmapServers(parseJmapServers(configManager.get<unknown>('jmapServers', []))),
jmapServerAutoPickByDomain: configManager.get<boolean>('jmapServerAutoPickByDomain', false),
autoSsoEnabled: configManager.get<boolean>('autoSsoEnabled', false),
embeddedMode: !!allowedFrameAncestors && allowedFrameAncestors !== "'none'",
parentOrigin: configManager.get<string>('parentOrigin', ''),
});
return NextResponse.json(
{
appName,
jmapServerUrl,
oauthEnabled,
oauthOnly,
oauthClientId: configManager.get<string>('oauthClientId', ''),
oauthIssuerUrl: configManager.get<string>('oauthIssuerUrl', ''),
oauthScopes: getOauthScopes(),
rememberMeEnabled: hasSessionSecret(),
settingsSyncEnabled: configManager.get<boolean>('settingsSyncEnabled', false) && hasSessionSecret(),
stalwartFeaturesEnabled,
devMode: configManager.get<boolean>('devMode', false),
faviconUrl: branded<string>('faviconUrl', '/branding/Bulwark_Favicon.svg'),
appLogoLightUrl: branded<string>('appLogoLightUrl', ''),
appLogoDarkUrl: branded<string>('appLogoDarkUrl', ''),
loginLogoLightUrl: branded<string>('loginLogoLightUrl', '/branding/Bulwark_Logo_Color.svg'),
loginLogoDarkUrl: branded<string>('loginLogoDarkUrl', '/branding/Bulwark_Logo_White.svg'),
loginCompanyName: branded<string>('loginCompanyName', ''),
loginImprintUrl: branded<string>('loginImprintUrl', ''),
loginPrivacyPolicyUrl: branded<string>('loginPrivacyPolicyUrl', ''),
loginWebsiteUrl: branded<string>('loginWebsiteUrl', ''),
demoMode: configManager.get<boolean>('demoMode', false),
allowCustomJmapEndpoint: configManager.get<boolean>('allowCustomJmapEndpoint', false),
jmapServers: redactJmapServers(parseJmapServers(configManager.get<unknown>('jmapServers', []))),
jmapServerAutoPickByDomain: configManager.get<boolean>('jmapServerAutoPickByDomain', false),
autoSsoEnabled: configManager.get<boolean>('autoSsoEnabled', false),
embeddedMode: !!allowedFrameAncestors && allowedFrameAncestors !== "'none'",
parentOrigin: configManager.get<string>('parentOrigin', ''),
},
{
// Branding varies by host, so any cache between us and the browser
// must key its entry by the host headers we consulted.
headers: { Vary: 'Host, X-Forwarded-Host' },
},
);
}
+51 -3
View File
@@ -10,6 +10,8 @@ import { NextRequest, NextResponse } from 'next/server';
*/
const ACCOUNT_ID = 'dev-account-001';
const scheduledSubmissions: Array<{ id: string; emailId: string; identityId: string; sendAt: string; undoStatus: 'pending' | 'final' | 'canceled' }> = [];
const emailCreationIds = new Map<string, string>();
// ---------------------------------------------------------------------------
// Mailboxes
@@ -1533,6 +1535,7 @@ function handleEmailSet(args: MethodArgs, callId: string): MethodResult {
bodyValues: {},
};
emails.unshift(newEmail);
emailCreationIds.set(key, newId);
created[key] = { id: newId };
}
}
@@ -1575,8 +1578,51 @@ function handleThreadGet(args: MethodArgs, callId: string): MethodResult {
return ['Thread/get', { accountId: ACCOUNT_ID, state: nextState(), list, notFound: [] }, callId];
}
function handleEmailSubmissionSet(_args: MethodArgs, callId: string): MethodResult {
return ['EmailSubmission/set', { accountId: ACCOUNT_ID, oldState: nextState(), newState: nextState(), created: { 'sub-1': { id: 'sub-mock-1' } }, notCreated: null }, callId];
function handleEmailSubmissionSet(args: MethodArgs, callId: string): MethodResult {
const created: Record<string, { id: string; sendAt?: string }> = {};
const updated: Record<string, null> = {};
const create = args.create as Record<string, { emailId?: string; identityId?: string; envelope?: { mailFrom?: { parameters?: { HOLDFOR?: string; HOLDUNTIL?: string } } } }> | undefined;
if (create) {
for (const [key, value] of Object.entries(create)) {
const id = `submission-${Date.now()}-${key}`;
const holdFor = value.envelope?.mailFrom?.parameters?.HOLDFOR;
const holdUntil = value.envelope?.mailFrom?.parameters?.HOLDUNTIL;
const holdForSeconds = holdFor ? Number(holdFor) : Number.NaN;
const holdUntilTime = Number.isFinite(holdForSeconds) && holdForSeconds > 0
? Date.now() + holdForSeconds * 1000
: holdUntil ? new Date(holdUntil).getTime() : Number.NaN;
const delayedUntil = Number.isFinite(holdUntilTime) ? new Date(holdUntilTime).toISOString() : undefined;
created[key] = { id, ...(delayedUntil ? { sendAt: delayedUntil } : {}) };
if (delayedUntil && value.emailId && value.identityId) {
const emailId = value.emailId.startsWith('#') ? emailCreationIds.get(value.emailId.slice(1)) || value.emailId : value.emailId;
scheduledSubmissions.push({ id, emailId, identityId: value.identityId, sendAt: delayedUntil, undoStatus: 'pending' });
}
}
}
const update = args.update as Record<string, { undoStatus?: 'pending' | 'final' | 'canceled' }> | undefined;
if (update) {
for (const [id, patch] of Object.entries(update)) {
const submission = scheduledSubmissions.find(s => s.id === id);
if (submission && patch.undoStatus) {
submission.undoStatus = patch.undoStatus;
updated[id] = null;
}
}
}
return ['EmailSubmission/set', { accountId: ACCOUNT_ID, oldState: nextState(), newState: nextState(), created, updated, notCreated: null, notUpdated: null }, callId];
}
function handleEmailSubmissionQuery(args: MethodArgs, callId: string): MethodResult {
const position = Number(args.position || 0);
const limit = Number(args.limit || 50);
const submissions = [...scheduledSubmissions].sort((a, b) => new Date(a.sendAt).getTime() - new Date(b.sendAt).getTime());
return ['EmailSubmission/query', { accountId: ACCOUNT_ID, queryState: nextState(), ids: submissions.slice(position, position + limit).map(s => s.id), total: submissions.length, position, canCalculateChanges: false }, callId];
}
function handleEmailSubmissionGet(args: MethodArgs, callId: string): MethodResult {
const ids = args.ids as string[] | undefined;
const list = ids ? scheduledSubmissions.filter(s => ids.includes(s.id)) : scheduledSubmissions;
return ['EmailSubmission/get', { accountId: ACCOUNT_ID, state: nextState(), list, notFound: [] }, callId];
}
function handleQuotaGet(_args: MethodArgs, callId: string): MethodResult {
@@ -1639,6 +1685,8 @@ const METHOD_HANDLERS: Record<string, (args: MethodArgs, callId: string) => Meth
'Identity/get': handleIdentityGet,
'Identity/set': handleIdentitySet,
'EmailSubmission/set': handleEmailSubmissionSet,
'EmailSubmission/query': handleEmailSubmissionQuery,
'EmailSubmission/get': handleEmailSubmissionGet,
'Quota/get': handleQuotaGet,
'VacationResponse/get': handleVacationResponseGet,
'VacationResponse/set': (_args, callId) => ['VacationResponse/set', { accountId: ACCOUNT_ID, oldState: nextState(), newState: nextState(), updated: { 'vacation-1': null } }, callId],
@@ -1776,7 +1824,7 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{
isReadOnly: false,
accountCapabilities: {
'urn:ietf:params:jmap:mail': {},
'urn:ietf:params:jmap:submission': {},
'urn:ietf:params:jmap:submission': { maxDelayedSend: 2592000, submissionExtensions: { FUTURERELEASE: true } },
'urn:ietf:params:jmap:quota': {},
'urn:ietf:params:jmap:vacationresponse': {},
'urn:ietf:params:jmap:contacts': {},
+31 -6
View File
@@ -3,11 +3,18 @@ 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';
const VALID_SIZES = new Set([192, 512]);
// Cache resized images in memory to avoid reprocessing on every request
const cache = new Map<number, Blob>();
// Cache resized images keyed by (size, source URL) so admin re-uploads or URL
// changes invalidate the prior render instead of serving stale bytes forever.
const cache = new Map<string, Blob>();
async function fetchSourceImage(iconUrl: string): Promise<Buffer> {
// Absolute URL (http/https)
@@ -17,13 +24,21 @@ async function fetchSourceImage(iconUrl: string): Promise<Buffer> {
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,
req: NextRequest,
{ params }: { params: Promise<{ size: string }> }
) {
const { size: sizeParam } = await params;
@@ -34,8 +49,15 @@ export async function GET(
}
await configManager.ensureLoaded();
const host = pickRequestHost(req);
const domainOverrides = matchDomainBranding(
host,
parseDomainBranding(configManager.get<unknown>('domainBranding', [])),
);
const sources = configManager.getAllWithSources();
const iconUrl =
domainOverrides.pwaIconUrl ||
domainOverrides.faviconUrl ||
(sources.pwaIconUrl?.source !== 'default' ? (sources.pwaIconUrl?.value as string) : '') ||
(sources.faviconUrl?.source !== 'default' ? (sources.faviconUrl?.value as string) : '');
if (!iconUrl) {
@@ -45,11 +67,14 @@ export async function GET(
const pngHeaders = {
'Content-Type': 'image/png',
'Cache-Control': 'public, max-age=86400',
Vary: 'Host, X-Forwarded-Host',
};
const cacheKey = `${size}|${iconUrl}`;
try {
if (cache.has(size)) {
return new NextResponse(cache.get(size)!, { headers: pngHeaders });
if (cache.has(cacheKey)) {
return new NextResponse(cache.get(cacheKey)!, { headers: pngHeaders });
}
const sourceBuffer = await fetchSourceImage(iconUrl);
@@ -61,7 +86,7 @@ export async function GET(
const ab = new ArrayBuffer(resized.byteLength);
new Uint8Array(ab).set(resized);
const blob = new Blob([ab], { type: 'image/png' });
cache.set(size, blob);
cache.set(cacheKey, blob);
return new NextResponse(blob, { headers: pngHeaders });
} catch (err) {
+2 -2
View File
@@ -242,8 +242,8 @@ body {
@media (max-width: 640px) {
.email-content-text {
padding-left: 0;
padding-right: 0;
padding-left: 0.75rem;
padding-right: 0.75rem;
}
}
+32 -10
View File
@@ -1,5 +1,12 @@
import type { MetadataRoute } from "next";
import { headers } from "next/headers";
import { configManager } from "@/lib/admin/config-manager";
import {
matchDomainBranding,
parseDomainBranding,
pickRequestHost,
type BrandingOverrideKey,
} from "@/lib/admin/domain-branding";
export const dynamic = "force-dynamic";
@@ -25,25 +32,40 @@ const withBase = (p: string) => `${BASE_PATH}${p}`;
export default async function manifest(): Promise<ExtendedManifest> {
await configManager.ensureLoaded();
const host = pickRequestHost(await headers());
const domainOverrides = matchDomainBranding(
host,
parseDomainBranding(configManager.get<unknown>("domainBranding", [])),
);
const branded = <T,>(key: BrandingOverrideKey, fallback: T): T => {
const override = domainOverrides[key];
if (typeof override === "string" && override.length > 0) return override as T;
return configManager.get<T>(key, fallback);
};
const appName =
configManager.get<string>("appName") ||
branded<string>("appName", "") ||
process.env.NEXT_PUBLIC_APP_NAME ||
"Bulwark Webmail";
const shortName = configManager.get<string>("appShortName") || appName;
const shortName = branded<string>("appShortName", "") || appName;
const description =
configManager.get<string>("appDescription") ||
branded<string>("appDescription", "") ||
"A modern webmail client built for Stalwart Mail Server";
const themeColor = configManager.get<string>("pwaThemeColor") || "#ffffff";
const backgroundColor = configManager.get<string>("pwaBackgroundColor") || "#ffffff";
const themeColor = branded<string>("pwaThemeColor", "") || "#ffffff";
const backgroundColor = branded<string>("pwaBackgroundColor", "") || "#ffffff";
// If pwaIconUrl or faviconUrl was explicitly configured (admin override or
// env var), serve dynamically resized PNGs via /api/pwa-icon/[size].
// Otherwise fall back to the static Bulwark PNGs - sources marked "default"
// are the built-in placeholder paths and not real custom icons.
// If pwaIconUrl or faviconUrl was explicitly configured (admin override,
// env var, or per-domain override), serve dynamically resized PNGs via
// /api/pwa-icon/[size]. Otherwise fall back to the static Bulwark PNGs -
// sources marked "default" are the built-in placeholder paths and not
// real custom icons.
const sources = configManager.getAllWithSources();
const hasCustomIcon =
sources.pwaIconUrl?.source !== "default" || sources.faviconUrl?.source !== "default";
!!domainOverrides.pwaIconUrl ||
!!domainOverrides.faviconUrl ||
sources.pwaIconUrl?.source !== "default" ||
sources.faviconUrl?.source !== "default";
const icons: MetadataRoute.Manifest["icons"] = hasCustomIcon
? [
+5 -2
View File
@@ -78,7 +78,11 @@ export function EventCard({ event, calendar, variant, onClick, onMouseEnter, onM
const calendarName = calendar?.name || "";
const durationMinutes = parseDuration(event.duration);
const endTime = getEventEndDate(event);
const timeString = `${format(startDate, timeFmt)} ${format(endTime, timeFmt)}`;
const safeFormat = (d: Date, fmt: string) => {
if (isNaN(d.getTime())) return "--:--";
try { return format(d, fmt); } catch { return "--:--"; }
};
const timeString = `${safeFormat(startDate, timeFmt)} ${safeFormat(endTime, timeFmt)}`;
const ariaLabel = `${event.title || t("events.no_title")}, ${timeString}${calendarName ? `, ${calendarName}` : ""}`;
const handleDragStart = useCallback((e: DragEvent) => {
@@ -153,7 +157,6 @@ export function EventCard({ event, calendar, variant, onClick, onMouseEnter, onM
"w-full h-full text-left rounded-r px-1.5 py-0.5 text-xs overflow-hidden",
"hover:opacity-90 transition-opacity cursor-pointer",
continuesAfter && "rounded-r-sm",
continuesBefore && "-ml-0.5",
continuesAfter && "pr-2",
isSelected && "ring-2 ring-primary",
isBeingDragged && "opacity-50",
+47 -11
View File
@@ -8,11 +8,11 @@ import {
X, Clock, MapPin, Video, Users, Repeat, Bell, AlignLeft,
Pencil, Trash2, Copy, Send, Check,
} from "lucide-react";
import { format, parseISO } from "date-fns";
import { format, isSameDay, parseISO } from "date-fns";
import { cn } from "@/lib/utils";
import type { CalendarEvent, Calendar, CalendarParticipant } from "@/lib/jmap/types";
import { parseDuration, getEventColor } from "./event-card";
import { getEventEndDate, getEventStartDate } from "@/lib/calendar-utils";
import { getEventDisplayEndDate, getEventEndDate, getEventStartDate } from "@/lib/calendar-utils";
import {
isOrganizer,
getUserParticipantId,
@@ -143,6 +143,8 @@ export function EventDetailPopover({
const startDate = getEventStartDate(event);
const durationMinutes = parseDuration(event.duration);
const endDate = getEventEndDate(event);
const displayEndDate = getEventDisplayEndDate(event);
const isMultiDay = !isSameDay(startDate, displayEndDate);
const locationName = useMemo(() => {
if (!event.locations) return null;
@@ -331,16 +333,50 @@ export function EventDetailPopover({
<div className="flex items-start gap-2.5">
<Clock className="w-4 h-4 text-muted-foreground mt-0.5 flex-shrink-0" />
<div className="text-sm">
<span className="font-medium text-foreground">
{formatEventDate(startDate)}
</span>
{event.showWithoutTime ? (
<span className="text-muted-foreground ml-1.5">{t("events.all_day")}</span>
{isMultiDay ? (
event.showWithoutTime ? (
<>
<div className="font-medium text-foreground">
{formatEventDate(startDate)}
</div>
<div className="font-medium text-foreground">
{formatEventDate(displayEndDate)}
</div>
<div className="text-muted-foreground">{t("events.all_day")}</div>
</>
) : (
<>
<div className="font-medium text-foreground">
{formatEventDate(startDate)}
<span className="ml-1.5 font-normal text-muted-foreground">
{formatTime(startDate)}
</span>
</div>
<div className="font-medium text-foreground">
{formatEventDate(endDate)}
<span className="ml-1.5 font-normal text-muted-foreground">
{formatTime(endDate)}
</span>
</div>
<div className="text-muted-foreground text-xs">
({formatDurationDisplay(durationMinutes)})
</div>
</>
)
) : (
<div className="text-muted-foreground">
{formatTime(startDate)} {formatTime(endDate)}
<span className="ml-1.5 text-xs">({formatDurationDisplay(durationMinutes)})</span>
</div>
<>
<span className="font-medium text-foreground">
{formatEventDate(startDate)}
</span>
{event.showWithoutTime ? (
<span className="text-muted-foreground ml-1.5">{t("events.all_day")}</span>
) : (
<div className="text-muted-foreground">
{formatTime(startDate)} {formatTime(endDate)}
<span className="ml-1.5 text-xs">({formatDurationDisplay(durationMinutes)})</span>
</div>
)}
</>
)}
</div>
</div>
+86 -20
View File
@@ -5,7 +5,7 @@ import { useTranslations } from "next-intl";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { X, Trash2, Check, Users, CalendarDays, Copy, Pencil, Clock, MapPin, Video, Repeat, Bell, AlignLeft, Plus } from "lucide-react";
import { format, parseISO, addHours, addDays } from "date-fns";
import { format, parseISO, addHours, addDays, isSameDay } from "date-fns";
import type { CalendarEvent, Calendar, CalendarParticipant, CalendarEventAlert } from "@/lib/jmap/types";
import { parseDuration, getEventColor } from "./event-card";
import { buildAllDayDuration, getEventDisplayEndDate, getEventEndDate, getEventStartDate, getPrimaryCalendarId } from "@/lib/calendar-utils";
@@ -621,14 +621,42 @@ export function EventModal({
</div>
</div>
<div className="text-sm">
<span className="font-medium">{formatEventDate(startD)}</span>
{!event.showWithoutTime && (
<span className="text-muted-foreground ml-2">
{format(startD, timeDisplayFmt)} {format(endD, timeDisplayFmt)}
</span>
)}
</div>
{(() => {
const displayEnd = getEventDisplayEndDate(event);
const multiDay = !isSameDay(startD, displayEnd);
if (multiDay && event.showWithoutTime) {
return (
<div className="text-sm">
<div className="font-medium">{formatEventDate(startD)} </div>
<div className="font-medium">{formatEventDate(displayEnd)}</div>
</div>
);
}
if (multiDay) {
return (
<div className="text-sm">
<div>
<span className="font-medium">{formatEventDate(startD)}</span>
<span className="text-muted-foreground ml-2">{format(startD, timeDisplayFmt)}</span>
</div>
<div>
<span className="font-medium">{formatEventDate(endD)}</span>
<span className="text-muted-foreground ml-2">{format(endD, timeDisplayFmt)}</span>
</div>
</div>
);
}
return (
<div className="text-sm">
<span className="font-medium">{formatEventDate(startD)}</span>
{!event.showWithoutTime && (
<span className="text-muted-foreground ml-2">
{format(startD, timeDisplayFmt)} {format(endD, timeDisplayFmt)}
</span>
)}
</div>
);
})()}
{event.description && (
<p className="text-sm text-muted-foreground">{event.description}</p>
@@ -742,17 +770,55 @@ export function EventModal({
<div className="flex items-start gap-2.5">
<Clock className="w-4 h-4 text-muted-foreground mt-0.5 flex-shrink-0" />
<div className="text-sm">
<span className="font-medium text-foreground">
{formatEventDate(startD)}
</span>
{event.showWithoutTime ? (
<span className="text-muted-foreground ml-1.5">{t("events.all_day")}</span>
) : (
<div className="text-muted-foreground">
{format(startD, timeDisplayFmt)} {format(endD, timeDisplayFmt)}
<span className="ml-1.5 text-xs">({formatDurationDisplay(durMin)})</span>
</div>
)}
{(() => {
const displayEnd = getEventDisplayEndDate(event);
const multiDay = !isSameDay(startD, displayEnd);
if (multiDay && event.showWithoutTime) {
return (
<>
<div className="font-medium text-foreground">{formatEventDate(startD)} </div>
<div className="font-medium text-foreground">{formatEventDate(displayEnd)}</div>
<div className="text-muted-foreground">{t("events.all_day")}</div>
</>
);
}
if (multiDay) {
return (
<>
<div className="font-medium text-foreground">
{formatEventDate(startD)}
<span className="ml-1.5 font-normal text-muted-foreground">
{format(startD, timeDisplayFmt)}
</span>
</div>
<div className="font-medium text-foreground">
{formatEventDate(endD)}
<span className="ml-1.5 font-normal text-muted-foreground">
{format(endD, timeDisplayFmt)}
</span>
</div>
<div className="text-muted-foreground text-xs">
({formatDurationDisplay(durMin)})
</div>
</>
);
}
return (
<>
<span className="font-medium text-foreground">
{formatEventDate(startD)}
</span>
{event.showWithoutTime ? (
<span className="text-muted-foreground ml-1.5">{t("events.all_day")}</span>
) : (
<div className="text-muted-foreground">
{format(startD, timeDisplayFmt)} {format(endD, timeDisplayFmt)}
<span className="ml-1.5 text-xs">({formatDurationDisplay(durMin)})</span>
</div>
)}
</>
);
})()}
</div>
</div>
+378 -29
View File
@@ -5,7 +5,7 @@ import { useFocusTrap } from "@/hooks/use-focus-trap";
import { useTranslations } from "next-intl";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { X, Paperclip, Send, Save, Check, Loader2, AlertCircle, FileText, BookmarkPlus, ShieldCheck, Lock } from "lucide-react";
import { X, Paperclip, Send, Save, Check, Loader2, AlertCircle, FileText, BookmarkPlus, ShieldCheck, Lock, CalendarClock, ChevronDown } from "lucide-react";
import { cn, formatFileSize, formatDateTime, generateUUID } from "@/lib/utils";
import { debug } from "@/lib/debug";
import { toast } from "@/stores/toast-store";
@@ -35,6 +35,10 @@ import type { EmailTemplate } from "@/lib/template-types";
import { appendPlainTextSignature, getPlainTextSignature } from "@/lib/signature-utils";
import { resolveReplyFrom } from "@/lib/reply-identity";
import { computeReplyThreadingHeaders } from "@/lib/email-threading";
import {
rewriteCidImagesForEditor,
replaceInlineImagePlaceholders,
} from "@/lib/email-composer-utils";
import { RichTextEditor } from "@/components/email/rich-text-editor";
import type { Editor } from "@tiptap/react";
@@ -84,7 +88,9 @@ interface EmailComposerProps {
attachments?: Array<{ blobId: string; name: string; type: string; size: number; disposition?: 'attachment' | 'inline'; cid?: string }>;
inReplyTo?: string[];
references?: string[];
delayedUntil?: string;
}) => void | Promise<void>;
onScheduledSendCreated?: () => void | Promise<void>;
onClose?: () => void;
onDiscardDraft?: (draftId: string) => void;
onSaveState?: (data: ComposerDraftData) => void;
@@ -164,8 +170,21 @@ function buildEmbeddedSignatureHtml(
return '';
}
function formatLocalDateTimeInput(date: Date): string {
const pad = (value: number) => String(value).padStart(2, '0');
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}T${pad(date.getHours())}:${pad(date.getMinutes())}`;
}
function getDefaultScheduleValue(): string {
const tomorrowAtEight = new Date();
tomorrowAtEight.setDate(tomorrowAtEight.getDate() + 1);
tomorrowAtEight.setHours(8, 0, 0, 0);
return formatLocalDateTimeInput(tomorrowAtEight);
}
export function EmailComposer({
onSend,
onScheduledSendCreated,
onClose,
onDiscardDraft,
onSaveState,
@@ -183,6 +202,7 @@ export function EmailComposer({
const autoSelectReplyIdentity = useSettingsStore((state) => state.autoSelectReplyIdentity);
const attachmentReminderEnabled = useSettingsStore((state) => state.attachmentReminderEnabled);
const attachmentReminderKeywords = useSettingsStore((state) => state.attachmentReminderKeywords);
const sendDelaySeconds = useSettingsStore((state) => state.sendDelaySeconds);
const signaturePosition = useSettingsStore((state) => state.signaturePosition);
const signatureSeparatorEnabled = useSettingsStore((state) => state.signatureSeparatorEnabled);
const activeIdentities = useIdentityStore((s) => s.identities);
@@ -208,10 +228,16 @@ export function EmailComposer({
const initialSignatureIdentity = (initialCurrentIdentityForSig?.htmlSignature || initialCurrentIdentityForSig?.textSignature)
? initialCurrentIdentityForSig
: primaryIdentity;
const hasInitialSignature = !!(initialSignatureIdentity?.htmlSignature || initialSignatureIdentity?.textSignature);
const shouldEmbedSignatureAboveQuote =
(mode === 'reply' || mode === 'replyAll' || mode === 'forward') &&
signaturePosition === 'above_quote' &&
!!(initialSignatureIdentity?.htmlSignature || initialSignatureIdentity?.textSignature);
hasInitialSignature;
// New-mail composes always embed the signature into the editor body so it's
// editable/removable (the previous read-only preview below the editor was
// never spec-correct - see #329). Compose mode also ignores any leftover
// `replyTo` from a still-selected email; getInitialBody short-circuits below.
const shouldEmbedSignatureInNewMail = mode === 'compose' && hasInitialSignature;
// Initialize with reply/forward data if provided
const getInitialTo = () => {
@@ -252,6 +278,14 @@ export function EmailComposer({
if (plainTextMode) {
// Plain text mode: produce plain text body with no HTML
const prefix = initialDraftText || "";
// Compose mode: ignore any leftover replyTo (e.g. a selected mail in the
// viewer) and embed the signature directly into the body so it's
// editable. Fixes #329 (A,B).
if (mode === 'compose') {
if (!shouldEmbedSignatureInNewMail) return prefix;
const sep = signatureSeparatorEnabled ? '\n\n-- \n' : '\n\n';
return `${prefix}${sep}${getPlainTextSignature(initialSignatureIdentity)}`;
}
if (!replyTo?.body && !replyTo?.htmlBody) return prefix;
const date = replyTo.receivedAt ? formatDateTime(replyTo.receivedAt, timeFormat, { weekday: 'short', year: 'numeric', month: 'short', day: 'numeric' }) : "";
@@ -285,6 +319,18 @@ export function EmailComposer({
}
const prefix = initialDraftText ? `<p>${initialDraftText.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/\n/g, '<br>')}</p>` : "";
// Compose mode: ignore any leftover replyTo and embed the signature
// directly into the body so the user can edit/delete it. The leading
// empty paragraph gives the cursor a place to land above the signature.
if (mode === 'compose') {
if (!shouldEmbedSignatureInNewMail) return prefix;
const composePrefix = prefix || '<p></p>';
const embedded = buildEmbeddedSignatureHtml(initialSignatureIdentity, {
embed: true,
separator: signatureSeparatorEnabled,
});
return `${composePrefix}${embedded}`;
}
if (!replyTo?.body && !replyTo?.htmlBody) return prefix;
const date = replyTo.receivedAt ? formatDateTime(replyTo.receivedAt, timeFormat, { weekday: 'short', year: 'numeric', month: 'short', day: 'numeric' }) : "";
@@ -300,7 +346,8 @@ export function EmailComposer({
if (replyTo.quoteHeaderHtml !== undefined && (mode === 'reply' || mode === 'replyAll' || mode === 'forward')) {
const wrap = replyTo.quoteWrapInBlockquote !== false;
const originalHtml = replyTo.htmlBody
?? (replyTo.body
? rewriteCidImagesForEditor(replyTo.htmlBody)
: (replyTo.body
? replyTo.body.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/\n/g, '<br>')
: '');
const bodyHtml = wrap
@@ -314,7 +361,10 @@ export function EmailComposer({
const quoteHeader = mode === 'forward'
? `---------- Forwarded message ----------<br>From: ${fromStr}<br>Date: ${date}<br>Subject: ${replyTo.subject || ''}<br><br>`
: `On ${date}, ${fromStr} wrote:<br>`;
return `${prefix}${signatureBlock}<br><div>${quoteHeader}</div><blockquote style="margin:0 0 0 0.8ex;border-left:2px solid #ccc;padding-left:1ex">${replyTo.htmlBody}</blockquote>`;
// cid: image refs are rewritten so they render in the editor (browsers
// can't fetch cid: URLs); see useEffect below for the data-URL backfill.
const quotedHtml = rewriteCidImagesForEditor(replyTo.htmlBody);
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>`;
}
if (replyTo.body) {
@@ -382,6 +432,12 @@ export function EmailComposer({
const [smimePassphraseError, setSmimePassphraseError] = useState('');
const [showAttachmentWarning, setShowAttachmentWarning] = useState(false);
const [attachmentWarningKeyword, setAttachmentWarningKeyword] = useState('');
const [attachmentWarningDelayedUntil, setAttachmentWarningDelayedUntil] = useState<string | undefined>();
const [showScheduleDialog, setShowScheduleDialog] = useState(false);
const [scheduleValue, setScheduleValue] = useState('');
const [scheduleError, setScheduleError] = useState('');
const [showSendMenu, setShowSendMenu] = useState(false);
const sendMenuRef = useRef<HTMLDivElement>(null);
const saveTemplateModalRef = useFocusTrap({
isActive: showSaveAsTemplate,
@@ -441,8 +497,11 @@ export function EmailComposer({
if (!editor) return;
if (!identityChanged && !separatorChanged) return;
if (plainTextMode) return;
if (mode !== 'reply' && mode !== 'replyAll' && mode !== 'forward') return;
if (signaturePosition !== 'above_quote') return;
// Replies/forwards only embed when configured for "above quote". Compose
// always embeds (see getInitialBody), so swap on identity change there too.
const isReplyLike = mode === 'reply' || mode === 'replyAll' || mode === 'forward';
if (!isReplyLike && mode !== 'compose') return;
if (isReplyLike && signaturePosition !== 'above_quote') return;
const currentHtml = editor.getHTML();
const doc = new DOMParser().parseFromString(currentHtml, 'text/html');
@@ -487,6 +546,23 @@ export function EmailComposer({
}
}, [signatureIdentity?.id, signatureIdentity?.htmlSignature, signatureIdentity?.textSignature, signatureSeparatorEnabled, signaturePosition, mode, plainTextMode]);
useEffect(() => {
const handleClickOutsideSendMenu = (event: MouseEvent) => {
if (!sendMenuRef.current?.contains(event.target as Node)) {
setShowSendMenu(false);
}
};
document.addEventListener('mousedown', handleClickOutsideSendMenu);
return () => document.removeEventListener('mousedown', handleClickOutsideSendMenu);
}, []);
const openScheduleDialog = useCallback(() => {
setScheduleError('');
setScheduleValue(getDefaultScheduleValue());
setShowScheduleDialog(true);
setShowSendMenu(false);
}, []);
useEffect(() => {
if (!autoSelectReplyIdentity) return;
if (selectedIdentityId || initialData?.selectedIdentityId) return;
@@ -534,6 +610,76 @@ export function EmailComposer({
selectedIdentityId,
]);
// Hydrate inline images referenced by the quoted body (issue #163).
// `getInitialBody` rewrites `<img src="cid:xxx">` to placeholder src +
// data-cid; here we (1) register each inline attachment in inlineImagesRef
// so the send path re-attaches the blob with the right cid, and (2) fetch
// each blob as a data URL and swap it into the body so the editor actually
// shows the image instead of a blank placeholder.
useEffect(() => {
if (plainTextMode) return;
if (mode !== 'reply' && mode !== 'replyAll' && mode !== 'forward') return;
if (!composerClient || !replyTo?.attachments?.length) return;
const inlineAtts = replyTo.attachments.filter((att) =>
att.cid && att.disposition === 'inline' && (att.type || '').startsWith('image/')
);
if (inlineAtts.length === 0) return;
// Seed the ref synchronously so a fast Send still attaches the right blobs
// even if the FileReader work below hasn't resolved yet.
for (const att of inlineAtts) {
if (!att.cid) continue;
if (inlineImagesRef.current.some((e) => e.cid === att.cid)) continue;
inlineImagesRef.current.push({
cid: att.cid,
blobId: att.blobId,
type: att.type,
name: att.name || 'inline',
size: att.size,
dataUrl: '',
});
}
let cancelled = false;
(async () => {
const updates = new Map<string, string>();
for (const att of inlineAtts) {
if (!att.cid) continue;
try {
const buffer = await composerClient.fetchBlobArrayBuffer(
att.blobId,
att.name || 'inline',
att.type,
);
if (cancelled) return;
const blob = new Blob([buffer], { type: att.type });
const dataUrl = await new Promise<string>((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => resolve(reader.result as string);
reader.onerror = () => reject(reader.error);
reader.readAsDataURL(blob);
});
if (cancelled) return;
const entry = inlineImagesRef.current.find((e) => e.cid === att.cid);
if (entry) entry.dataUrl = dataUrl;
updates.set(att.cid, dataUrl);
} catch (err) {
debug.error('Failed to load inline image for compose', err);
}
}
if (cancelled || updates.size === 0) return;
setBody((prev) => replaceInlineImagePlaceholders(prev, updates));
})();
return () => {
cancelled = true;
};
// We deliberately hydrate once per composer open - subsequent replyTo
// object identity churn from parent renders shouldn't refetch.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [composerClient, plainTextMode, mode]);
const composerSignatureHtml = signatureIdentity?.htmlSignature
? `<div>${sanitizeSignatureHtml(signatureIdentity.htmlSignature)}</div>`
: signatureIdentity?.textSignature
@@ -586,9 +732,16 @@ export function EmailComposer({
// Ref to latest saveDraft for use in event handlers with stale closures
const saveDraftRef = useRef<() => Promise<string | null>>(() => Promise.resolve(null));
// Set by the explicit close paths (clean close, save-and-close, discard) so
// the unmount auto-save below doesn't fire and stash a stale pendingDraft
// on the parent (#329 D). Without this, "Reply → Discard → New mail" would
// open the next composer with the discarded reply's mode/replyTo.
const explicitCloseRef = useRef(false);
// Auto-save state on unmount (when user navigates away without explicitly closing)
useEffect(() => {
return () => {
if (explicitCloseRef.current) return;
if (onSaveState && isDirtyRef.current) {
const s = stateRef.current;
onSaveState({
@@ -1106,6 +1259,33 @@ export function EmailComposer({
return undefined;
};
const validateScheduleValue = (value: string): string | null => {
if (!value) return t('schedule_send_required');
const time = new Date(value).getTime();
if (!Number.isFinite(time)) return t('schedule_send_invalid');
if (time <= Date.now()) return t('schedule_send_future');
if (composerClient) {
const maxDelayedSend = composerClient.getMaxDelayedSend();
if (maxDelayedSend > 0 && time > Date.now() + maxDelayedSend * 1000) {
return t('schedule_send_too_late');
}
}
return null;
};
const resolveDelayedUntil = async (requestedDelayedUntil?: string): Promise<string | undefined> => {
if (requestedDelayedUntil) return requestedDelayedUntil;
if (sendDelaySeconds === 0) return undefined;
if (composerClient?.hasDelayedSend()) {
return new Date(Date.now() + sendDelaySeconds * 1000).toISOString();
}
const confirmed = window.confirm(t('send_delay_unsupported_confirm'));
if (!confirmed) {
throw new Error(t('send_delay_unsupported'));
}
return undefined;
};
// Rewrite data: URLs of dropped images (tagged with data-cid) into cid:
// references so recipient clients that strip data URIs can still render them.
const rewriteInlineImages = (html: string): {
@@ -1149,7 +1329,7 @@ export function EmailComposer({
};
};
const handleSend = async (skipAttachmentCheck = false) => {
const handleSend = async (skipAttachmentCheck = false, delayedUntil?: string) => {
const ccAddresses = cc.split(",").map(e => e.trim()).filter(Boolean);
const bccAddresses = bcc.split(",").map(e => e.trim()).filter(Boolean);
@@ -1177,6 +1357,7 @@ export function EmailComposer({
const matched = attachmentReminderKeywords.find(kw => searchText.includes(kw.toLowerCase()));
if (matched) {
setAttachmentWarningKeyword(matched);
setAttachmentWarningDelayedUntil(delayedUntil);
setShowAttachmentWarning(true);
return;
}
@@ -1227,12 +1408,14 @@ export function EmailComposer({
const envelopeMailFrom = overrideActive ? identityFromEmail : undefined;
// Body is already HTML from the rich text editor (or plain text in plain text mode).
// When "above quote" mode is configured for replies/forwards, the signature
// was embedded into the body during init (see getInitialBody) so the
// trailing append must be skipped to avoid duplicating it.
// The signature is embedded into the body during init for compose mode
// (when the initial identity had a signature) and for above-quote
// replies/forwards - skip the trailing append in those cases so we don't
// duplicate it.
const signatureAlreadyInBody =
(mode === 'reply' || mode === 'replyAll' || mode === 'forward') &&
signaturePosition === 'above_quote';
shouldEmbedSignatureInNewMail ||
((mode === 'reply' || mode === 'replyAll' || mode === 'forward') &&
signaturePosition === 'above_quote');
// Build HTML signature block (used only in rich text mode)
const buildSignatureHtml = (): string => {
@@ -1265,6 +1448,7 @@ export function EmailComposer({
const inlineAttachments = rewritten?.attachments ?? [];
try {
const effectiveDelayedUntil = await resolveDelayedUntil(delayedUntil);
// Let plugins veto the send (external-mail warning, mistyped-domain
// guards, etc.). Returning false from any handler aborts before either
// the S/MIME or standard JMAP path runs.
@@ -1414,7 +1598,16 @@ export function EmailComposer({
}
// 7. Send via raw email path
await sendRawEmail(client, payload, currentIdentity.id);
const result = await sendRawEmail(client, payload, currentIdentity.id, effectiveDelayedUntil, [...toAddresses, ...ccAddresses, ...bccAddresses]);
if (effectiveDelayedUntil && finalDraftId) {
client.deleteEmail(finalDraftId).catch(err => {
debug.warn('email', 'Scheduled S/MIME send created, but plaintext draft cleanup failed:', err);
toast.warning(t('schedule_send_cleanup_warning'));
});
}
if (result.scheduled) {
await onScheduledSendCreated?.();
}
} else {
// Standard JMAP send path
// Collect uploaded attachment blobIds for the send request
@@ -1464,6 +1657,7 @@ export function EmailComposer({
attachments: uploadedAttachments.length > 0 ? uploadedAttachments : undefined,
inReplyTo: threadingHeaders?.inReplyTo,
references: threadingHeaders?.references,
delayedUntil: effectiveDelayedUntil,
});
if (mode === 'reply' || mode === 'replyAll') {
@@ -1488,15 +1682,56 @@ export function EmailComposer({
setDraftId(null);
setSubAddressTag("");
setValidationErrors({});
setShowScheduleDialog(false);
setScheduleValue('');
setScheduleError('');
// Clear ref so unmount effect doesn't re-save
stateRef.current = { to: '', cc: '', bcc: '', subject: '', body: '', showCc: false, showBcc: false, selectedIdentityId: null, subAddressTag: '', draftId: null, fromOverrideEnabled: false, fromOverrideEmail: '', fromOverrideName: '' };
} catch (err) {
debug.error('Failed to send email:', err);
toast.error(t('send_failed'));
toast.error(err instanceof Error ? err.message : t('send_failed'));
}
};
const handleScheduleSend = () => {
if (!composerClient?.hasDelayedSend()) {
setScheduleError(t('schedule_send_unsupported'));
return;
}
const error = validateScheduleValue(scheduleValue);
if (error) {
setScheduleError(error);
return;
}
handleSend(false, new Date(scheduleValue).toISOString());
};
// Ctrl+Enter (Win/Linux) / Cmd+Enter (macOS) sends the open compose
// draft. Scoped to events whose target lives inside this composer's
// DOM tree — in Pro mode multiple composer tabs can be mounted at
// once (inactive tabs are CSS-hidden, not unmounted), so a window
// listener would otherwise fire every mounted composer's handleSend
// on a single keystroke. handleSend is rebound every render, so we
// route through a ref to keep the listener stable.
const composerRootRef = useRef<HTMLDivElement | null>(null);
const handleSendRef = useRef<((skipAttachmentCheck?: boolean) => Promise<void>) | undefined>(undefined);
handleSendRef.current = handleSend;
useEffect(() => {
const handleSendShortcut = (e: KeyboardEvent) => {
if (e.key !== 'Enter') return;
if (!(e.ctrlKey || e.metaKey)) return;
if (e.altKey || e.shiftKey) return;
const root = composerRootRef.current;
if (!root || !(e.target instanceof Node) || !root.contains(e.target)) return;
e.preventDefault();
void handleSendRef.current?.();
};
window.addEventListener('keydown', handleSendShortcut);
return () => window.removeEventListener('keydown', handleSendShortcut);
}, []);
const cleanClose = () => {
explicitCloseRef.current = true;
if (saveTimeoutRef.current) {
clearTimeout(saveTimeoutRef.current);
}
@@ -1505,6 +1740,7 @@ export function EmailComposer({
};
const handleSaveDraftAndClose = async () => {
explicitCloseRef.current = true;
setShowCloseDialog(false);
if (saveTimeoutRef.current) {
clearTimeout(saveTimeoutRef.current);
@@ -1515,6 +1751,7 @@ export function EmailComposer({
};
const handleDiscardAndClose = () => {
explicitCloseRef.current = true;
setShowCloseDialog(false);
if (saveTimeoutRef.current) {
clearTimeout(saveTimeoutRef.current);
@@ -1534,8 +1771,50 @@ export function EmailComposer({
}
};
const handleComposerKeyDown = (e: React.KeyboardEvent<HTMLDivElement>) => {
if (e.defaultPrevented) return;
const isPlainEscape = e.key === 'Escape' && !e.ctrlKey && !e.metaKey && !e.altKey && !e.shiftKey;
const hasPrimaryModifier = e.ctrlKey || e.metaKey;
const isSendShortcut = e.key === 'Enter' && hasPrimaryModifier && !e.altKey && !e.shiftKey;
const isScheduleShortcut = e.key === 'Enter' && hasPrimaryModifier && !e.altKey && e.shiftKey;
if (!isPlainEscape && !isSendShortcut && !isScheduleShortcut) return;
if (
showTemplatePicker ||
showSaveAsTemplate ||
showScheduleDialog ||
smimePassphrasePrompt ||
showAttachmentWarning ||
showCloseDialog
) return;
if (isPlainEscape) {
if (activeAutoField) return;
e.preventDefault();
handleClose();
return;
}
if (isSendShortcut) {
if (e.repeat) {
e.preventDefault();
return;
}
e.preventDefault();
handleSend();
return;
}
if (isScheduleShortcut) {
e.preventDefault();
if (!e.repeat && composerClient?.hasDelayedSend()) openScheduleDialog();
}
};
return (
<div className={cn("flex h-full bg-background", className)}>
<div ref={composerRootRef} className={cn("flex h-full bg-background", className)}>
<PluginSlot
name="composer-sidebar"
className="hidden md:flex shrink-0 h-full overflow-hidden border-r border-border"
@@ -1548,6 +1827,7 @@ export function EmailComposer({
onDragLeave={handleDragLeave}
onDragOver={handleDragOver}
onDrop={handleDrop}
onKeyDown={handleComposerKeyDown}
>
{/* Drag overlay */}
{isDraggingOver && (
@@ -1868,8 +2148,9 @@ export function EmailComposer({
)}
{/* Hide the visual signature preview when the signature has already been
embedded into the body above the quote (otherwise it would appear twice). */}
{((mode === 'reply' || mode === 'replyAll' || mode === 'forward') && signaturePosition === 'above_quote') ? null
embedded into the body (compose, or above-quote replies). */}
{(shouldEmbedSignatureInNewMail
|| ((mode === 'reply' || mode === 'replyAll' || mode === 'forward') && signaturePosition === 'above_quote')) ? null
: plainTextMode ? (
getPlainTextSignature(signatureIdentity) ? (
<div className="px-4 pb-3 text-sm leading-6 text-muted-foreground break-words whitespace-pre-wrap font-mono">
@@ -1975,7 +2256,6 @@ export function EmailComposer({
>
<BookmarkPlus className="w-4 h-4" />
</Button>
{/* S/MIME toggles */}
{canSmimeSign && (
<>
@@ -2013,15 +2293,56 @@ export function EmailComposer({
>
{t('discard')}
</button>
<Button
onClick={() => handleSend()}
disabled={!canSend}
title={getSendTooltip()}
className="hidden md:inline-flex"
>
<Send className="w-4 h-4 mr-2" />
{t('send')}
</Button>
{composerClient?.hasDelayedSend() ? (
<div ref={sendMenuRef} className="relative hidden md:inline-flex">
<Button
onClick={() => handleSend()}
disabled={!canSend}
title={getSendTooltip()}
className="rounded-r-none border-r border-primary-foreground/20"
>
<Send className="w-4 h-4 mr-2" />
{t('send')}
</Button>
<Button
type="button"
onClick={() => setShowSendMenu((open) => !open)}
disabled={!canSend}
title={t('schedule_send')}
className="rounded-l-none px-2"
aria-haspopup="menu"
aria-expanded={showSendMenu}
>
<ChevronDown className="w-4 h-4" />
</Button>
{showSendMenu && (
<div
role="menu"
className="absolute right-0 bottom-full z-50 mb-2 min-w-44 rounded-md border border-border bg-popover p-1 text-popover-foreground shadow-lg"
>
<button
type="button"
role="menuitem"
onClick={openScheduleDialog}
className="flex w-full items-center gap-2 rounded-sm px-3 py-2 text-left text-sm hover:bg-accent hover:text-accent-foreground"
>
<CalendarClock className="w-4 h-4" />
{t('schedule_send')}
</button>
</div>
)}
</div>
) : (
<Button
onClick={() => handleSend()}
disabled={!canSend}
title={getSendTooltip()}
className="hidden md:inline-flex"
>
<Send className="w-4 h-4 mr-2" />
{t('send')}
</Button>
)}
</div>
</div>
@@ -2060,6 +2381,29 @@ export function EmailComposer({
</div>
)}
{showScheduleDialog && (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4 animate-in fade-in duration-150">
<div className="bg-background border border-border rounded-lg shadow-xl w-full max-w-md p-6 animate-in zoom-in-95 duration-200">
<h3 className="text-lg font-semibold text-foreground mb-2">{t('schedule_send')}</h3>
<p className="text-sm text-muted-foreground mb-4">{t('schedule_send_description')}</p>
<Input
type="datetime-local"
value={scheduleValue}
onChange={(e) => {
setScheduleValue(e.target.value);
setScheduleError('');
}}
className={cn(scheduleError && "border-destructive focus-visible:ring-destructive")}
/>
{scheduleError && <p className="mt-2 text-sm text-destructive">{scheduleError}</p>}
<div className="mt-5 flex justify-end gap-2">
<Button variant="ghost" onClick={() => setShowScheduleDialog(false)}>{tCommon('cancel')}</Button>
<Button onClick={handleScheduleSend} disabled={!canSend}>{t('schedule_send')}</Button>
</div>
</div>
</div>
)}
{/* S/MIME passphrase prompt */}
{smimePassphrasePrompt && (
<div
@@ -2136,7 +2480,7 @@ export function EmailComposer({
<Button variant="outline" onClick={() => setShowAttachmentWarning(false)}>
{t('forgot_attachment.back')}
</Button>
<Button onClick={() => { setShowAttachmentWarning(false); handleSend(true); }}>
<Button onClick={() => { setShowAttachmentWarning(false); handleSend(true, attachmentWarningDelayedUntil); setAttachmentWarningDelayedUntil(undefined); }}>
{t('forgot_attachment.send_anyway')}
</Button>
</div>
@@ -2383,6 +2727,11 @@ function RecipientChipInput({
aria-controls={activeAutoField === field ? `autocomplete-${field}` : undefined}
aria-activedescendant={activeAutoField === field && autoSelectedIndex >= 0 ? `autocomplete-option-${autoSelectedIndex}` : undefined}
aria-invalid={validationError || undefined}
data-bwignore="true"
data-1p-ignore
data-op-ignore
data-lpignore="true"
data-form-type="other"
/>
</div>
{validationError && validationMessage && (
@@ -2399,4 +2748,4 @@ function RecipientChipInput({
)}
</div>
);
}
}
+42 -2
View File
@@ -30,6 +30,8 @@ import {
ShieldAlert,
ShieldCheck,
EditIcon,
CalendarClock,
XCircle,
} from "lucide-react";
import { cn, buildMailboxTree, MailboxNode } from "@/lib/utils";
import { useSettingsStore, KEYWORD_PALETTE } from "@/stores/settings-store";
@@ -63,6 +65,9 @@ interface EmailContextMenuProps {
onMarkAsSpam?: () => void;
onUndoSpam?: () => void;
onEditDraft?: () => void;
onCancelScheduled?: () => void;
onCancelScheduledForEdit?: () => void;
onRescheduleScheduled?: () => void;
// Batch actions
onBatchMarkAsRead?: (read: boolean) => void;
onBatchDelete?: () => void;
@@ -133,6 +138,9 @@ export function EmailContextMenu({
onBatchMarkAsSpam,
onBatchUndoSpam,
onEditDraft,
onCancelScheduled,
onCancelScheduledForEdit,
onRescheduleScheduled,
}: EmailContextMenuProps) {
const t = useTranslations("context_menu");
const _tColor = useTranslations("email_viewer.color_tag");
@@ -143,6 +151,8 @@ export function EmailContextMenu({
const currentColors = getCurrentColors(email.keywords);
const showBatchActions = isMultiSelect && selectedCount > 1;
const isInJunkFolder = currentMailboxRole === 'junk';
const isScheduled = email.isScheduled === true;
const canCancelScheduled = isScheduled && email.scheduledUndoStatus === 'pending';
// Build color options from keyword definitions in settings
const colorOptions = emailKeywords.map((kw) => ({
@@ -196,8 +206,36 @@ export function EmailContextMenu({
</ContextMenuHeader>
)}
{isScheduled && !showBatchActions && canCancelScheduled && (
<>
<ContextMenuItem
icon={CalendarClock}
label={t("reschedule_send")}
onClick={() => handleAction(onRescheduleScheduled!)}
disabled={!onRescheduleScheduled}
/>
<ContextMenuItem
icon={XCircle}
label={t("cancel_scheduled_send")}
onClick={() => handleAction(onCancelScheduled!)}
disabled={!onCancelScheduled}
/>
<ContextMenuItem
icon={EditIcon}
label={email.isSmimeScheduled ? t("cancel_and_compose_again") : t("cancel_and_edit")}
onClick={() => handleAction(onCancelScheduledForEdit!)}
disabled={!onCancelScheduledForEdit}
/>
</>
)}
{canCancelScheduled && <ContextMenuSeparator />}
{!isScheduled && (
<>
{/* Edit Draft - only for single draft emails */}
{!showBatchActions && isDraft && onEditDraft && (
{!isScheduled && !showBatchActions && isDraft && onEditDraft && (
<>
<ContextMenuItem
icon={EditIcon}
@@ -209,7 +247,7 @@ export function EmailContextMenu({
)}
{/* Single email actions - Reply, Reply All, Forward */}
{!showBatchActions && (
{!isScheduled && !showBatchActions && (
<>
<ContextMenuItem
icon={Reply}
@@ -375,6 +413,8 @@ export function EmailContextMenu({
)
}
/>
</>
)}
<PluginSlot name="context-menu-email" />
</ContextMenu>
+3 -3
View File
@@ -50,7 +50,9 @@ export function EmailListItem({ email, selected, onClick, onDoubleClick, onConte
const currentMailboxRole = mailboxes.find(mb => mb.id === selectedMailbox)?.role;
const showRecipient = currentMailboxRole === 'sent' || currentMailboxRole === 'drafts';
const sender = showRecipient ? (email.to?.[0] ?? email.from?.[0]) : email.from?.[0];
const isFocusedMailLayout = mailLayout === 'focus';
const isMobile = useUIStore((state) => state.isMobile);
// The horizontal one-line "focus" layout doesn't fit on narrow screens; fall back to multi-line on mobile.
const isFocusedMailLayout = mailLayout === 'focus' && !isMobile;
const hideJunkAvatarImages = currentMailboxRole === 'junk' && !showAvatarsInJunk;
const trimmedPreview = stripInvisibleLeading(email.preview ?? '');
const inlinePreview = showPreview && trimmedPreview ? ` ${trimmedPreview}` : '';
@@ -68,8 +70,6 @@ export function EmailListItem({ email, selected, onClick, onDoubleClick, onConte
sourceMailboxId: selectedMailbox,
});
const isMobile = useUIStore((state) => state.isMobile);
const { onTouchStart, onTouchEnd, onTouchMove, onTouchCancel, isPressed } = useLongPress(
useCallback((pos) => {
onContextMenu?.(
+39 -11
View File
@@ -4,13 +4,14 @@ import { Email, ThreadGroup } from "@/lib/jmap/types";
import { ThreadListItem } from "./thread-list-item";
import { EmailContextMenu } from "./email-context-menu";
import { cn } from "@/lib/utils";
import { Trash2, Mail, MailX, MailOpen, Loader2, SearchX, AlertTriangle } from "lucide-react";
import { Trash2, Mail, MailX, MailOpen, Loader2, SearchX, AlertTriangle, CalendarClock } from "lucide-react";
import { useState, useEffect, useRef, useCallback, useMemo } from "react";
import { Button } from "@/components/ui/button";
import { ConfirmDialog } from "@/components/ui/confirm-dialog";
import { useEmailStore } from "@/stores/email-store";
import { useAuthStore } from "@/stores/auth-store";
import { useSettingsStore } from "@/stores/settings-store";
import { useUIStore } from "@/stores/ui-store";
import { groupEmailsByThread, sortThreadGroups } from "@/lib/thread-utils";
import { useContextMenu } from "@/hooks/use-context-menu";
import { useConfirmDialog } from "@/hooks/use-confirm-dialog";
@@ -26,6 +27,8 @@ interface EmailListProps {
onEmailDoubleClick?: (email: Email) => void;
className?: string;
isLoading?: boolean;
hasMore?: boolean;
isLoadingMoreItems?: boolean;
onOpenConversation?: (thread: ThreadGroup) => void;
onReply?: (email: Email) => void;
onReplyAll?: (email: Email) => void;
@@ -39,6 +42,11 @@ interface EmailListProps {
onMarkAsSpam?: (email: Email) => void;
onUndoSpam?: (email: Email) => void;
onEditDraft?: (email: Email) => void;
isScheduledView?: boolean;
onLoadMoreScheduled?: () => void;
onCancelScheduled?: (email: Email) => void | Promise<void>;
onCancelScheduledForEdit?: (email: Email) => void | Promise<void>;
onRescheduleScheduled?: (email: Email) => void | Promise<void>;
}
export function EmailList({
@@ -48,6 +56,8 @@ export function EmailList({
onEmailDoubleClick,
className,
isLoading = false,
hasMore,
isLoadingMoreItems,
onOpenConversation,
onReply,
onReplyAll,
@@ -61,6 +71,11 @@ export function EmailList({
onUndoSpam,
onMoveToMailbox,
onEditDraft,
isScheduledView = false,
onLoadMoreScheduled,
onCancelScheduled,
onCancelScheduledForEdit,
onRescheduleScheduled,
}: EmailListProps) {
const t = useTranslations('email_list');
const { client } = useAuthStore();
@@ -95,9 +110,9 @@ export function EmailList({
const disableThreading = useSettingsStore((state) => state.disableThreading);
const threadGroups = useMemo(() => {
const groups = groupEmailsByThread(emails, disableThreading);
const groups = groupEmailsByThread(emails, disableThreading || isScheduledView);
return sortThreadGroups(groups);
}, [emails, disableThreading]);
}, [emails, disableThreading, isScheduledView]);
const { contextMenu, openContextMenu, closeContextMenu, menuRef } = useContextMenu<Email>();
const { dialogProps: confirmDialogProps, confirm: confirmDialog } = useConfirmDialog();
@@ -107,7 +122,11 @@ export function EmailList({
const density = useSettingsStore((state) => state.density);
const showPreview = useSettingsStore((state) => state.showPreview);
const mailLayout = useSettingsStore((state) => state.mailLayout);
const isFocusedMailLayout = mailLayout === 'focus';
const footerHasMore = hasMore ?? hasMoreEmails;
const footerIsLoadingMore = isLoadingMoreItems ?? isLoadingMore;
const isMobile = useUIStore((state) => state.isMobile);
// Match the list items: focus layout collapses to multi-line on mobile, so virtualizer estimates must match.
const isFocusedMailLayout = mailLayout === 'focus' && !isMobile;
const estimateSize = useCallback(() => {
if (isFocusedMailLayout) {
@@ -216,10 +235,14 @@ export function EmailList({
};
const handleLoadMore = useCallback(() => {
if (isScheduledView) {
onLoadMoreScheduled?.();
return;
}
if (client && hasMoreEmails && !isLoadingMore && !isLoading) {
loadMoreEmails(client);
}
}, [client, hasMoreEmails, isLoadingMore, isLoading, loadMoreEmails]);
}, [client, hasMoreEmails, isLoadingMore, isLoading, isScheduledView, loadMoreEmails, onLoadMoreScheduled]);
const handleToggleThreadExpansion = useCallback(async (threadId: string) => {
const isExpanded = expandedThreadIds.has(threadId);
@@ -279,7 +302,7 @@ export function EmailList({
<div
className={cn(
"transition-all duration-300 ease-in-out overflow-hidden",
hasSelection ? "max-h-16 opacity-100" : "max-h-0 opacity-0"
hasSelection && !isScheduledView ? "max-h-16 opacity-100" : "max-h-0 opacity-0"
)}
>
<div className="px-4 py-2 border-b bg-accent/30 border-border flex items-center justify-between">
@@ -403,17 +426,19 @@ export function EmailList({
) : emails.length === 0 && !isLoading ? (
<div className="flex flex-col items-center justify-center h-full py-12">
<div className="w-20 h-20 mx-auto mb-6 rounded-full bg-muted shadow-lg flex items-center justify-center">
{searchQuery || !isFilterEmpty(searchFilters) ? (
{isScheduledView ? (
<CalendarClock className="w-10 h-10 text-muted-foreground" />
) : searchQuery || !isFilterEmpty(searchFilters) ? (
<SearchX className="w-10 h-10 text-muted-foreground" />
) : (
<MailX className="w-10 h-10 text-muted-foreground" />
)}
</div>
<p className="text-base font-medium text-foreground">
{searchQuery || !isFilterEmpty(searchFilters) ? t('no_search_results') : t('no_emails')}
{isScheduledView ? t('no_scheduled_emails') : searchQuery || !isFilterEmpty(searchFilters) ? t('no_search_results') : t('no_emails')}
</p>
<p className="text-sm mt-1 text-muted-foreground">
{searchQuery || !isFilterEmpty(searchFilters) ? t('no_search_results_description') : t('no_emails_description')}
{isScheduledView ? t('no_scheduled_emails_description') : searchQuery || !isFilterEmpty(searchFilters) ? t('no_search_results_description') : t('no_emails_description')}
</p>
</div>
) : (
@@ -465,13 +490,13 @@ export function EmailList({
</div>
<div className="py-4 flex justify-center">
{isLoadingMore && hasMoreEmails && (
{footerIsLoadingMore && footerHasMore && (
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Loader2 className="w-4 h-4 animate-spin" />
<span>{t('loading_more')}</span>
</div>
)}
{!hasMoreEmails && emails.length > 0 && (
{!footerHasMore && emails.length > 0 && (
<div className="text-sm text-muted-foreground border-t border-border pt-6">
{t('no_more_emails')}
</div>
@@ -506,6 +531,9 @@ export function EmailList({
onMarkAsSpam={() => onMarkAsSpam?.(contextMenu.data!)}
onUndoSpam={() => onUndoSpam?.(contextMenu.data!)}
onEditDraft={() => onEditDraft?.(contextMenu.data!)}
onCancelScheduled={onCancelScheduled ? () => onCancelScheduled(contextMenu.data!) : undefined}
onCancelScheduledForEdit={onCancelScheduledForEdit ? () => onCancelScheduledForEdit(contextMenu.data!) : undefined}
onRescheduleScheduled={onRescheduleScheduled ? () => onRescheduleScheduled(contextMenu.data!) : undefined}
onBatchMarkAsRead={(read) => client && batchMarkAsRead(client, read)}
onBatchDelete={() => client && batchDelete(client)}
onBatchArchive={async () => {
+220 -57
View File
@@ -3,8 +3,11 @@
import { useState, useEffect, useLayoutEffect, useMemo, useRef, useCallback } from "react";
import DOMPurify from "dompurify";
import { Email, ContactCard, Mailbox } from "@/lib/jmap/types";
import { emailExportFilename, attachmentDownloadFilename, DEFAULT_EMAIL_TEMPLATE, DEFAULT_ATTACHMENT_TEMPLATE } from "@/lib/download-filename";
import { EML_IMPORT_ACCEPT, expandImportableEmails } from "@/lib/eml-import";
import { EMAIL_IFRAME_SANITIZE_CONFIG, collapseBlockedImageContainers, escapeHtml, plainTextToSafeHtml, sanitizeEmailHtml, sanitizePlainTextRenderedHtml } from "@/lib/email-sanitization";
import { hasMeaningfulHtmlBody } from "@/lib/signature-utils";
import { withBasePath } from "@/lib/browser-navigation";
import { Button } from "@/components/ui/button";
import { Avatar } from "@/components/ui/avatar";
import { formatFileSize, cn, buildMailboxTree, MailboxNode, formatDateTime, generateUUID } from "@/lib/utils";
@@ -63,6 +66,7 @@ import {
EditIcon,
PlayCircle,
PenSquare,
CalendarClock,
} from "lucide-react";
import { useTranslations } from "next-intl";
import { useRouter } from "@/i18n/navigation";
@@ -73,12 +77,14 @@ import { useContactStore, getContactDisplayName, getContactPrimaryEmail } from "
import { toast } from "@/stores/toast-store";
import { useDeviceDetection } from "@/hooks/use-media-query";
import { useAuthStore } from "@/stores/auth-store";
import { useAccountStore } from "@/stores/account-store";
import { useEmailStore } from "@/stores/email-store";
import { useThemeStore } from "@/stores/theme-store";
import { EmailIdentityBadge } from "./email-identity-badge";
import { UnsubscribeBanner } from "./unsubscribe-banner";
import { CalendarInvitationBanner } from "./calendar-invitation-banner";
import { useTour } from "@/components/tour/tour-provider";
import { useIsEmbedded } from "@/hooks/use-is-embedded";
import { SmimePassphraseDialog } from "@/components/settings/smime-passphrase-dialog";
import { findCalendarAttachment, isCalendarMimeType } from "@/lib/calendar-invitation";
import { RecipientPopover } from "./recipient-popover";
@@ -121,6 +127,9 @@ interface EmailViewerProps {
onNavigatePrev?: () => void;
onShowShortcuts?: () => void;
onEditDraft?: () => void;
onCancelScheduled?: () => void;
onCancelScheduledForEdit?: () => void;
onRescheduleScheduled?: (delayedUntil: string) => void;
onCompose?: () => void;
currentUserEmail?: string;
currentUserName?: string;
@@ -798,6 +807,7 @@ interface DraggableAttachmentChipProps {
attachment: EffectiveAttachment;
client: IJMAPClient | null;
enabled: boolean;
downloadName?: string;
children: (dragProps: {
draggable: boolean;
onPointerEnter: () => void;
@@ -806,9 +816,9 @@ interface DraggableAttachmentChipProps {
}) => React.ReactNode;
}
function DraggableAttachmentChip({ attachment, client, enabled, children }: DraggableAttachmentChipProps) {
function DraggableAttachmentChip({ attachment, client, enabled, downloadName, children }: DraggableAttachmentChipProps) {
const source = useMemo<AttachmentDragSource>(() => ({
name: attachment.name || 'download',
name: downloadName || attachment.name || 'download',
type: attachment.type || 'application/octet-stream',
getBlobUrl: async () => {
if (attachment.blobId && client) {
@@ -831,7 +841,7 @@ function DraggableAttachmentChip({ attachment, client, enabled, children }: Drag
}
return null;
},
}), [attachment, client]);
}), [attachment, client, downloadName]);
const drag = useAttachmentDrag(source, enabled);
return <>{children(drag)}</>;
}
@@ -869,6 +879,9 @@ export function EmailViewer({
onNavigatePrev,
onShowShortcuts,
onEditDraft,
onCancelScheduled,
onCancelScheduledForEdit,
onRescheduleScheduled,
onCompose,
currentUserEmail,
currentUserName,
@@ -878,6 +891,7 @@ export function EmailViewer({
className,
}: EmailViewerProps) {
const t = useTranslations('email_viewer');
const tComposer = useTranslations('email_composer');
const tNotifications = useTranslations('notifications');
const tCommon = useTranslations('common');
const tSmime = useTranslations('smime');
@@ -900,6 +914,26 @@ export function EmailViewer({
const hideInlineImageAttachments = useSettingsStore((state) => state.hideInlineImageAttachments);
const attachmentImagePreviewsEnabled = useSettingsStore((state) => state.attachmentImagePreviewsEnabled);
const dragOutActive = useMemo(() => isDragOutSupported(), []);
const emailDownloadTemplate = useSettingsStore((state) => state.emailDownloadTemplate) || DEFAULT_EMAIL_TEMPLATE;
const attachmentDownloadTemplate = useSettingsStore((state) => state.attachmentDownloadTemplate) || DEFAULT_ATTACHMENT_TEMPLATE;
const filenameSpaceReplacement = useSettingsStore((state) => state.filenameSpaceReplacement);
const filenameLowercase = useSettingsStore((state) => state.filenameLowercase);
const filenameStripDiacritics = useSettingsStore((state) => state.filenameStripDiacritics);
const filenameCollapseSeparators = useSettingsStore((state) => state.filenameCollapseSeparators);
const emailFilenameOptions = useMemo(() => ({
template: emailDownloadTemplate,
spaceReplacement: filenameSpaceReplacement,
lowercase: filenameLowercase,
stripDiacritics: filenameStripDiacritics,
collapseSeparators: filenameCollapseSeparators,
}), [emailDownloadTemplate, filenameSpaceReplacement, filenameLowercase, filenameStripDiacritics, filenameCollapseSeparators]);
const attachmentFilenameOptions = useMemo(() => ({
template: attachmentDownloadTemplate,
spaceReplacement: filenameSpaceReplacement,
lowercase: filenameLowercase,
stripDiacritics: filenameStripDiacritics,
collapseSeparators: filenameCollapseSeparators,
}), [attachmentDownloadTemplate, filenameSpaceReplacement, filenameLowercase, filenameStripDiacritics, filenameCollapseSeparators]);
const timeFormat = useSettingsStore((state) => state.timeFormat);
const isFocusedMailLayout = mailLayout === 'focus';
@@ -908,6 +942,8 @@ export function EmailViewer({
// Detect if the email is a draft
const isDraft = email?.keywords?.['$draft'] === true;
const isScheduled = email?.isScheduled === true;
const canCancelScheduled = isScheduled && email?.scheduledUndoStatus === 'pending';
// Color options for email tags (from user-defined keyword settings)
const colorOptions = emailKeywords.map((kw) => ({
@@ -920,8 +956,33 @@ export function EmailViewer({
const { isTablet, isMobile } = useDeviceDetection();
const { tabletListVisible } = useUIStore();
const { identities, client, isDemoMode, activeAccountId } = useAuthStore();
const activeAccount = useAccountStore((s) => s.accounts.find((a) => a.id === activeAccountId));
const promptForRescheduleDelayedUntil = useCallback((): string | null => {
const value = window.prompt(t('reschedule_prompt'));
if (!value) return null;
const time = new Date(value).getTime();
if (!Number.isFinite(time)) {
toast.error(tComposer('schedule_send_invalid'));
return null;
}
if (time <= Date.now()) {
toast.error(tComposer('schedule_send_future'));
return null;
}
if (!client?.hasDelayedSend()) {
toast.error(tComposer('schedule_send_unsupported'));
return null;
}
const maxDelayedSend = client.getMaxDelayedSend();
if (maxDelayedSend > 0 && time > Date.now() + maxDelayedSend * 1000) {
toast.error(tComposer('schedule_send_too_late'));
return null;
}
return new Date(time).toISOString();
}, [client, t, tComposer]);
const resolvedTheme = useThemeStore((state) => state.resolvedTheme);
const { startTour } = useTour();
const isEmbedded = useIsEmbedded();
const [showFullHeaders, setShowFullHeaders] = useState(false);
const [showAllBesideAttachments, setShowAllBesideAttachments] = useState(false);
const [showAllMobileAttachments, setShowAllMobileAttachments] = useState(false);
@@ -2559,6 +2620,15 @@ export function EmailViewer({
return emailContent;
}, [cidBlobUrls, emailContent, smimeDecryptedHtml, smimeDecryptedText, tnefHtml, tnefText, embeddedEmailHtml, embeddedEmailText]);
const resolveAttachmentName = useCallback(
(attachment: EffectiveAttachment) => {
const fallback = attachment.name || 'download';
if (!email) return fallback;
return attachmentDownloadFilename(email, { name: attachment.name, type: attachment.type }, attachmentFilenameOptions) || fallback;
},
[email, attachmentFilenameOptions],
);
const handleEffectiveAttachmentOpen = useCallback(async (attachment: EffectiveAttachment) => {
const isPreviewable = isFilePreviewable(attachment.name || undefined, attachment.type);
// Blob URLs inherit our origin; script-bearing MIME types (text/html,
@@ -2568,6 +2638,8 @@ export function EmailViewer({
&& mailAttachmentAction === 'preview'
&& isMimeTypeSafeForInlinePreview(attachment.type);
const downloadName = resolveAttachmentName(attachment);
const info: AttachmentInfo = {
name: attachment.name || '',
type: attachment.type,
@@ -2578,7 +2650,7 @@ export function EmailViewer({
if (attachment.blobId && onDownloadAttachment) {
emailHooks.onAttachmentDownload.emit(info);
onDownloadAttachment(attachment.blobId, attachment.name || 'download', attachment.type);
onDownloadAttachment(attachment.blobId, downloadName, attachment.type);
return;
}
@@ -2598,7 +2670,7 @@ export function EmailViewer({
emailHooks.onAttachmentDownload.emit(info);
const anchor = document.createElement('a');
anchor.href = objectUrl;
anchor.download = attachment.name || 'download';
anchor.download = downloadName;
document.body.appendChild(anchor);
anchor.click();
anchor.remove();
@@ -2628,16 +2700,17 @@ export function EmailViewer({
emailHooks.onAttachmentDownload.emit(info);
const anchor = document.createElement('a');
anchor.href = objectUrl;
anchor.download = attachment.name || 'download';
anchor.download = downloadName;
document.body.appendChild(anchor);
anchor.click();
anchor.remove();
}
setTimeout(() => URL.revokeObjectURL(objectUrl), 60_000);
}, [mailAttachmentAction, onDownloadAttachment, email?.id]);
}, [mailAttachmentAction, onDownloadAttachment, email, resolveAttachmentName]);
const handleEffectiveAttachmentDownload = useCallback((attachment: EffectiveAttachment) => {
const downloadName = resolveAttachmentName(attachment);
const info: AttachmentInfo = {
name: attachment.name || '',
type: attachment.type,
@@ -2647,7 +2720,7 @@ export function EmailViewer({
};
emailHooks.onAttachmentDownload.emit(info);
if (attachment.blobId && onDownloadAttachment) {
onDownloadAttachment(attachment.blobId, attachment.name || 'download', attachment.type, true);
onDownloadAttachment(attachment.blobId, downloadName, attachment.type, true);
return;
}
@@ -2660,7 +2733,7 @@ export function EmailViewer({
const objectUrl = URL.createObjectURL(blob);
const anchor = document.createElement('a');
anchor.href = objectUrl;
anchor.download = attachment.name || 'download';
anchor.download = downloadName;
document.body.appendChild(anchor);
anchor.click();
anchor.remove();
@@ -2676,12 +2749,12 @@ export function EmailViewer({
const objectUrl = URL.createObjectURL(blob);
const anchor = document.createElement('a');
anchor.href = objectUrl;
anchor.download = attachment.name || 'download';
anchor.download = downloadName;
document.body.appendChild(anchor);
anchor.click();
anchor.remove();
setTimeout(() => URL.revokeObjectURL(objectUrl), 60_000);
}, [onDownloadAttachment, email?.id]);
}, [onDownloadAttachment, email?.id, resolveAttachmentName]);
// Pre-fetch object URLs for image attachments so their actual contents can be
// rendered as thumbnails inside the chip. Skips images larger than 10 MB.
@@ -2797,7 +2870,9 @@ export function EmailViewer({
// Word/Outlook HTML emails ship a <style> block but put their gutter in
// @page margins (print-only), so they need a fallback body padding too.
const isWordHtml = /class=["']?(?:Mso|WordSection)|<o:p[\s>/]|urn:schemas-microsoft-com:office:office/i.test(effectiveEmailContent.html);
const bodyPadding = (effectiveEmailContent.hasStyleTag && !isWordHtml) ? '0' : '1rem 1.25rem';
const hasOwnLayout = effectiveEmailContent.hasStyleTag && !isWordHtml;
const bodyPadding = hasOwnLayout ? '0' : '1rem 1.25rem';
const mobileBodyPaddingX = hasOwnLayout ? '0' : '0.75rem';
// Word emails rely on empty <p class=MsoNormal>&nbsp;</p> spacers for vertical
// rhythm. With our default line-height: 1.6 these stack into oversized gaps;
@@ -2820,11 +2895,11 @@ export function EmailViewer({
<style>
html, body { overflow: hidden; }
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: 0; padding-right: 0; } }
@media (max-width: 640px) { body { padding-left: ${mobileBodyPaddingX}; padding-right: ${mobileBodyPaddingX}; } }
img { max-width: 100% !important; height: auto !important; }
a { color: #1a73e8; }
table { max-width: 100% !important; table-layout: auto; overflow-wrap: break-word; }
td, th { word-break: break-word; }
td, th { overflow-wrap: anywhere; }
pre { white-space: pre-wrap; word-wrap: break-word; }
${wordHtmlCSS}
${darkModeCSS}
@@ -3054,35 +3129,58 @@ export function EmailViewer({
const handleExportEmail = async () => {
if (!email?.blobId || !client) return;
try {
const subject = (email.subject || 'email').replace(/[<>:"/\\|?*]+/g, '_').slice(0, 100);
await client.downloadBlob(email.blobId, `${subject}.eml`, 'message/rfc822');
await client.downloadBlob(email.blobId, emailExportFilename(email, emailFilenameOptions), 'message/rfc822');
} catch {
toast.error(tNotifications('export_email_error'));
return;
}
const action = useSettingsStore.getState().postExportAction;
if (action === 'archive') onArchive?.();
else if (action === 'trash') onDelete?.();
};
// Import email from .eml file
// Import email from .eml file or .zip archive containing .eml files
const handleImportEmail = () => {
if (!client) return;
const input = document.createElement('input');
input.type = 'file';
input.accept = '.eml,message/rfc822';
input.accept = EML_IMPORT_ACCEPT;
input.multiple = true;
input.onchange = async (e) => {
const file = (e.target as HTMLInputElement).files?.[0];
if (!file) return;
const files = Array.from((e.target as HTMLInputElement).files ?? []);
if (files.length === 0) return;
const { selectedMailbox, mailboxes, fetchEmails } = useEmailStore.getState();
const mailbox = mailboxes.find(mb => mb.id === selectedMailbox);
const mailboxId = mailbox?.originalId || selectedMailbox;
if (!mailboxId) {
toast.error(tNotifications('import_email_error'));
return;
}
let emails;
try {
const { selectedMailbox, mailboxes, fetchEmails } = useEmailStore.getState();
const mailbox = mailboxes.find(mb => mb.id === selectedMailbox);
const mailboxId = mailbox?.originalId || selectedMailbox;
if (!mailboxId) {
toast.error(tNotifications('import_email_error'));
return;
emails = await expandImportableEmails(files);
} catch {
toast.error(tNotifications('import_email_error'));
return;
}
let imported = 0;
let failed = 0;
for (const { blob } of emails) {
try {
await client.importRawEmail(blob, { [mailboxId]: true }, { '$seen': true });
imported++;
} catch {
failed++;
}
const blob = new Blob([await file.arrayBuffer()], { type: 'message/rfc822' });
await client.importRawEmail(blob, { [mailboxId]: true }, { '$seen': true });
}
if (imported > 0) {
toast.success(tNotifications('import_email_success'));
await fetchEmails(client);
} catch {
}
if (failed > 0 || emails.length === 0) {
toast.error(tNotifications('import_email_error'));
}
};
@@ -3206,9 +3304,9 @@ export function EmailViewer({
if (!email) {
if (isDemoMode) {
const logoSrc = resolvedTheme === 'dark'
const logoSrc = withBasePath(resolvedTheme === 'dark'
? '/branding/Bulwark_Logo_with_Lettering_White_and_Color.svg'
: '/branding/Bulwark_Logo_with_Lettering_Dark_Color.svg';
: '/branding/Bulwark_Logo_with_Lettering_Dark_Color.svg');
return (
<div className={cn("flex-1 flex flex-col items-center justify-center bg-gradient-to-br from-muted/30 to-muted/50", className)}>
<div className="text-center p-8 max-w-md">
@@ -3253,19 +3351,21 @@ export function EmailViewer({
}
return (
<div className={cn("flex-1 flex flex-col items-center justify-center bg-gradient-to-br from-muted/30 to-muted/50", className)}>
<div className="text-center p-8">
<div className="w-20 h-20 mx-auto mb-6 rounded-full bg-background shadow-lg flex items-center justify-center">
<Mail className="w-10 h-10 text-muted-foreground" />
{!isEmbedded && (
<div className="text-center p-8">
<div className="w-20 h-20 mx-auto mb-6 rounded-full bg-background shadow-lg flex items-center justify-center">
<Mail className="w-10 h-10 text-muted-foreground" />
</div>
<h3 className="text-xl font-semibold text-foreground mb-2">{t('no_conversation_selected')}</h3>
<p className="text-muted-foreground">{t('no_conversation_description')}</p>
{onCompose && (
<Button onClick={onCompose} className="mt-6" title={t('compose_hint')}>
<PenSquare className="w-4 h-4 mr-2" />
{t('compose')}
</Button>
)}
</div>
<h3 className="text-xl font-semibold text-foreground mb-2">{t('no_conversation_selected')}</h3>
<p className="text-muted-foreground">{t('no_conversation_description')}</p>
{onCompose && (
<Button onClick={onCompose} className="mt-6" title={t('compose_hint')}>
<PenSquare className="w-4 h-4 mr-2" />
{t('compose')}
</Button>
)}
</div>
)}
</div>
);
}
@@ -3291,7 +3391,32 @@ export function EmailViewer({
<ChevronLeft className="w-5 h-5" />
</Button>
)}
{isDraft && onEditDraft && (
{isScheduled && canCancelScheduled && (
<>
<Button variant="default" size="sm" onClick={onCancelScheduled} className="sm:flex sm:h-8" title={t('cancel_scheduled_send')}>
<X className="w-4 h-4" />
<span className="hidden sm:inline text-sm">{t('cancel_scheduled_send')}</span>
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => {
const delayedUntil = promptForRescheduleDelayedUntil();
if (delayedUntil) onRescheduleScheduled?.(delayedUntil);
}}
className="hidden sm:flex sm:h-8"
title={t('reschedule_send')}
>
<CalendarClock className="w-4 h-4" />
{showToolbarLabels && <span className="hidden sm:inline text-sm">{t('reschedule_send')}</span>}
</Button>
<Button variant="ghost" size="sm" onClick={onCancelScheduledForEdit} className="hidden sm:flex sm:h-8" title={email.isSmimeScheduled ? t('cancel_and_compose_again') : t('cancel_and_edit')}>
<EditIcon className="w-4 h-4" />
{showToolbarLabels && <span className="hidden sm:inline text-sm">{email.isSmimeScheduled ? t('cancel_and_compose_again') : t('cancel_and_edit')}</span>}
</Button>
</>
)}
{!isScheduled && isDraft && onEditDraft && (
<Button
variant="default"
size="sm"
@@ -3303,7 +3428,7 @@ export function EmailViewer({
<span className="text-sm">{t('edit_draft')}</span>
</Button>
)}
{!isDraft && (<>
{!isScheduled && !isDraft && (<>
<Button
variant="ghost"
size="sm"
@@ -3345,6 +3470,7 @@ export function EmailViewer({
</div>
{/* Right: Organize actions - order: archive, delete, move, tag, spam, read state, print, view source */}
{!isScheduled && (
<div className="flex items-center gap-0 sm:gap-0.5">
{/* Archive */}
<Button
@@ -3802,6 +3928,7 @@ export function EmailViewer({
)}
</div>
</div>
)}
</>
);
@@ -3812,13 +3939,13 @@ export function EmailViewer({
className={cn("flex-1 flex flex-row h-full bg-background overflow-hidden animate-in fade-in duration-300 relative", className)}
>
{/* Mobile More menu sidebar overlay */}
{isMobile && moreMenuOpen && (
{!isScheduled && isMobile && moreMenuOpen && (
<div
className="fixed inset-0 bg-black/50 z-[60] sm:hidden"
onClick={() => setMoreMenuOpen(false)}
/>
)}
{isMobile && (
{!isScheduled && isMobile && (
<div className={cn(
"fixed inset-y-0 right-0 w-72 bg-background border-l border-border z-[70] sm:hidden",
"transform transition-transform duration-300 ease-in-out",
@@ -4205,7 +4332,7 @@ export function EmailViewer({
const opensPreview = isPreviewable && mailAttachmentAction === 'preview';
const thumbUrl = imageThumbUrls[attachment.id];
return (
<DraggableAttachmentChip key={attachment.id} attachment={attachment} client={client} enabled={dragOutActive}>
<DraggableAttachmentChip key={attachment.id} attachment={attachment} client={client} enabled={dragOutActive} downloadName={resolveAttachmentName(attachment)}>
{(dragProps) => (
<div
className={cn(
@@ -4285,7 +4412,7 @@ export function EmailViewer({
const isPreviewable = isFilePreviewable(attachment.name || undefined, attachment.type);
const opensPreview = isPreviewable && mailAttachmentAction === 'preview';
return (
<DraggableAttachmentChip key={attachment.id} attachment={attachment} client={client} enabled={dragOutActive}>
<DraggableAttachmentChip key={attachment.id} attachment={attachment} client={client} enabled={dragOutActive} downloadName={resolveAttachmentName(attachment)}>
{(dragProps) => (
<div
className="flex items-center gap-1.5 px-2 py-1 rounded-md hover:bg-muted/60 group relative cursor-pointer w-full"
@@ -4750,10 +4877,44 @@ export function EmailViewer({
</div>
)}
{/* Scheduled Banner */}
{isScheduled && (
<div className="border-b border-border bg-primary/10">
<div className="max-w-4xl mx-auto px-6 py-2.5 flex flex-wrap items-center justify-between gap-2">
<div className="flex items-center gap-2 text-primary">
<CalendarClock className="w-4 h-4" />
<span className="text-sm font-medium">
{t('scheduled_banner', { date: email.scheduledSendAt ? formatDateTime(email.scheduledSendAt, timeFormat) : '' })}
</span>
</div>
<div className="flex items-center gap-2">
{canCancelScheduled && (
<>
<Button size="sm" variant="outline" onClick={onCancelScheduled}>{t('cancel_scheduled_send')}</Button>
<Button
size="sm"
variant="outline"
onClick={() => {
const delayedUntil = promptForRescheduleDelayedUntil();
if (delayedUntil) onRescheduleScheduled?.(delayedUntil);
}}
>
{t('reschedule_send')}
</Button>
<Button size="sm" variant="outline" onClick={onCancelScheduledForEdit}>
{email.isSmimeScheduled ? t('cancel_and_compose_again') : t('cancel_and_edit')}
</Button>
</>
)}
</div>
</div>
</div>
)}
{/* Draft Banner */}
{isDraft && (
<div className="border-b border-border bg-warning/10">
<div className="max-w-4xl mx-auto px-6 py-2.5 flex items-center justify-between">
<div className="px-6 py-2.5 flex items-center justify-between">
<div className="flex items-center gap-2 text-warning">
<File className="w-4 h-4" />
<span className="text-sm font-medium">{t('draft_banner')}</span>
@@ -4904,7 +5065,7 @@ export function EmailViewer({
const opensPreview = isPreviewable && mailAttachmentAction === 'preview';
const thumbUrl = imageThumbUrls[attachment.id];
return (
<DraggableAttachmentChip key={attachment.id} attachment={attachment} client={client} enabled={dragOutActive}>
<DraggableAttachmentChip key={attachment.id} attachment={attachment} client={client} enabled={dragOutActive} downloadName={resolveAttachmentName(attachment)}>
{(dragProps) => (
<div
className={cn(
@@ -4989,7 +5150,7 @@ export function EmailViewer({
const isPreviewable = isFilePreviewable(attachment.name || undefined, attachment.type);
const opensPreview = isPreviewable && mailAttachmentAction === 'preview';
return (
<DraggableAttachmentChip key={attachment.id} attachment={attachment} client={client} enabled={dragOutActive}>
<DraggableAttachmentChip key={attachment.id} attachment={attachment} client={client} enabled={dragOutActive} downloadName={resolveAttachmentName(attachment)}>
{(dragProps) => (
<div
className="flex items-center gap-1.5 px-2 py-1 rounded-md hover:bg-muted/60 group relative cursor-pointer w-full"
@@ -5047,7 +5208,7 @@ export function EmailViewer({
const opensPreview = isPreviewable && mailAttachmentAction === 'preview';
const thumbUrl = imageThumbUrls[attachment.id];
return (
<DraggableAttachmentChip key={attachment.id} attachment={attachment} client={client} enabled={dragOutActive}>
<DraggableAttachmentChip key={attachment.id} attachment={attachment} client={client} enabled={dragOutActive} downloadName={resolveAttachmentName(attachment)}>
{(dragProps) => (
<div
className={cn(
@@ -5126,7 +5287,7 @@ export function EmailViewer({
const isPreviewable = isFilePreviewable(attachment.name || undefined, attachment.type);
const opensPreview = isPreviewable && mailAttachmentAction === 'preview';
return (
<DraggableAttachmentChip key={attachment.id} attachment={attachment} client={client} enabled={dragOutActive}>
<DraggableAttachmentChip key={attachment.id} attachment={attachment} client={client} enabled={dragOutActive} downloadName={resolveAttachmentName(attachment)}>
{(dragProps) => (
<div
className="flex items-center gap-1.5 px-2 py-1 rounded-md hover:bg-muted/60 group relative cursor-pointer w-full"
@@ -5222,14 +5383,16 @@ export function EmailViewer({
<PluginSlot name="email-footer" />
{/* Quick Reply Section - hidden for drafts and while loading a new email */}
{!isDraft && !isBodyLoading && (effectiveEmailContent.isHtml ? iframeReady : true) && (<div className="bg-background border-t border-border px-6 mt-auto" style={{ paddingBlock: 'var(--density-header-py)' }}>
{!isDraft && !isScheduled && !isBodyLoading && (effectiveEmailContent.isHtml ? iframeReady : true) && (<div className="bg-background border-t border-border px-6 mt-auto" style={{ paddingBlock: 'var(--density-header-py)' }}>
<div className="flex items-start" style={{ gap: 'var(--density-item-gap)' }}>
<div className="flex-shrink-0">
<Avatar
name={currentUserName || "You"}
email={currentUserEmail || ""}
name={activeAccount?.displayName || currentUserName || "You"}
email={activeAccount?.email || activeAccount?.username || currentUserEmail || ""}
size="lg"
className="shadow-sm w-10 h-10"
disableFavicon
fallbackColor={activeAccount?.avatarColor}
/>
</div>
<div className="flex-1 min-w-0 space-y-3">
+11 -1
View File
@@ -115,7 +115,17 @@ export const ResizableImage = Node.create({
width: { default: null },
cid: {
default: null,
parseHTML: (el) => el.getAttribute("data-cid"),
parseHTML: (el) => {
const dataCid = el.getAttribute("data-cid");
if (dataCid) return dataCid;
// Fall back to deriving the cid from `src="cid:xxx"` so inline
// image refs survive editor round-trips even when data-cid was
// never set (defensive — the composer normally pre-rewrites
// quoted-body cid: refs into data-cid).
const src = el.getAttribute("src") || "";
if (/^cid:/i.test(src)) return src.slice(4) || null;
return null;
},
renderHTML: (attrs) => (attrs.cid ? { "data-cid": attrs.cid } : {}),
},
};
+107 -54
View File
@@ -1,11 +1,11 @@
"use client";
import React, { useCallback } from "react";
import { formatDate, stripInvisibleLeading } from "@/lib/utils";
import { formatDate, formatDateTime, stripInvisibleLeading } from "@/lib/utils";
import { Email, ThreadGroup } from "@/lib/jmap/types";
import { cn } from "@/lib/utils";
import { Avatar } from "@/components/ui/avatar";
import { Paperclip, Star, Circle, ChevronRight, ChevronDown, Loader2, MessageSquare, CheckSquare, Square, Reply, Forward } from "lucide-react";
import { Paperclip, Star, Circle, ChevronRight, ChevronDown, Loader2, MessageSquare, CheckSquare, Square, Reply, Forward, CalendarClock } from "lucide-react";
import { useSettingsStore, KEYWORD_PALETTE } from "@/stores/settings-store";
import { useUIStore } from "@/stores/ui-store";
import { useEmailStore } from "@/stores/email-store";
@@ -67,15 +67,21 @@ const SingleEmailItem = React.forwardRef<HTMLDivElement, SingleEmailItemProps>(
const emailKeywords = useSettingsStore((state) => state.emailKeywords);
const density = useSettingsStore((state) => state.density);
const mailLayout = useSettingsStore((state) => state.mailLayout);
const timeFormat = useSettingsStore((state) => state.timeFormat);
const showAvatarsInJunk = useSettingsStore((state) => state.showAvatarsInJunk);
const hideJunkAvatarImages = currentMailboxRole === 'junk' && !showAvatarsInJunk;
const isUnifiedView = useEmailStore((state) => state.isUnifiedView);
const getAccountById = useAccountStore((state) => state.getAccountById);
const accountColor = email.accountId ? getAccountById(email.accountId)?.avatarColor : undefined;
const isChecked = selectedEmailIds.has(email.id);
const isFocusedMailLayout = mailLayout === 'focus';
const isMobile = useUIStore((state) => state.isMobile);
// The horizontal one-line "focus" layout doesn't fit on narrow screens; fall back to multi-line on mobile.
const isFocusedMailLayout = mailLayout === 'focus' && !isMobile;
const trimmedPreview = stripInvisibleLeading(email.preview ?? '');
const inlinePreview = showPreview && trimmedPreview ? ` ${trimmedPreview}` : '';
const scheduledSendLabel = email.isScheduled && email.scheduledSendAt
? formatDateTime(email.scheduledSendAt, timeFormat)
: null;
// Resolve color tags using keyword definitions; unknown tags fall back to gray
const tagIds = getEmailColorTags(email.keywords);
@@ -91,8 +97,6 @@ const SingleEmailItem = React.forwardRef<HTMLDivElement, SingleEmailItemProps>(
sourceMailboxId: selectedMailbox,
});
const isMobile = useUIStore((state) => state.isMobile);
const { onTouchStart, onTouchEnd, onTouchMove, onTouchCancel, isPressed } = useLongPress(
useCallback((pos) => {
onContextMenu?.(
@@ -241,12 +245,22 @@ const SingleEmailItem = React.forwardRef<HTMLDivElement, SingleEmailItemProps>(
{resolvedKeywordDefs.map((kd) => (
<span key={kd.id} className={cn('h-2.5 w-2.5 rounded-full', KEYWORD_PALETTE[kd.color]?.dot || 'bg-gray-400')} />
))}
<span className={cn(
'text-xs tabular-nums',
isUnread ? 'text-foreground font-semibold' : 'text-muted-foreground'
)}>
{formatDate(email.receivedAt)}
</span>
{scheduledSendLabel ? (
<span
className="inline-flex max-w-[11rem] shrink-0 items-center gap-1 truncate rounded-full border border-sky-500/20 bg-sky-500/10 px-2 py-0.5 text-xs font-medium tabular-nums text-sky-700 dark:text-sky-300"
title={scheduledSendLabel}
>
<CalendarClock className="h-3 w-3 shrink-0" />
<span className="truncate">{scheduledSendLabel}</span>
</span>
) : (
<span className={cn(
'text-xs tabular-nums',
isUnread ? 'text-foreground font-semibold' : 'text-muted-foreground'
)}>
{formatDate(email.receivedAt)}
</span>
)}
</div>
</div>
) : (
@@ -299,14 +313,24 @@ const SingleEmailItem = React.forwardRef<HTMLDivElement, SingleEmailItemProps>(
{kd.label}
</span>
))}
<span className={cn(
"text-xs tabular-nums",
isUnread
? "text-foreground font-semibold"
: "text-muted-foreground"
)}>
{formatDate(email.receivedAt)}
</span>
{scheduledSendLabel ? (
<span
className="inline-flex max-w-[11rem] shrink-0 items-center gap-1 truncate rounded-full border border-sky-500/20 bg-sky-500/10 px-2 py-0.5 text-[11px] font-medium tabular-nums text-sky-700 dark:text-sky-300"
title={scheduledSendLabel}
>
<CalendarClock className="h-3 w-3 shrink-0" />
<span className="truncate">{scheduledSendLabel}</span>
</span>
) : (
<span className={cn(
"text-xs tabular-nums",
isUnread
? "text-foreground font-semibold"
: "text-muted-foreground"
)}>
{formatDate(email.receivedAt)}
</span>
)}
</div>
</div>
@@ -335,16 +359,18 @@ const SingleEmailItem = React.forwardRef<HTMLDivElement, SingleEmailItemProps>(
</div>
{/* Hover Quick Actions */}
<EmailHoverActions
email={email}
backgroundClassName={resolvedColorTag ? resolvedColorTag : ((selected || isChecked) ? "bg-accent" : "bg-muted")}
onToggleStar={onToggleStar}
onMarkAsRead={onMarkAsRead}
onDelete={onDelete}
onArchive={onArchive}
onSetColorTag={onSetColorTag}
onMarkAsSpam={onMarkAsSpam}
/>
{!email.isScheduled && (
<EmailHoverActions
email={email}
backgroundClassName={resolvedColorTag ? resolvedColorTag : ((selected || isChecked) ? "bg-accent" : "bg-muted")}
onToggleStar={onToggleStar}
onMarkAsRead={onMarkAsRead}
onDelete={onDelete}
onArchive={onArchive}
onSetColorTag={onSetColorTag}
onMarkAsSpam={onMarkAsSpam}
/>
)}
</div>
);
}
@@ -374,12 +400,17 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
const showPreview = useSettingsStore((state) => state.showPreview);
const density = useSettingsStore((state) => state.density);
const mailLayout = useSettingsStore((state) => state.mailLayout);
const timeFormat = useSettingsStore((state) => state.timeFormat);
const showAvatarsInJunk = useSettingsStore((state) => state.showAvatarsInJunk);
const isMobile = useUIStore((state) => state.isMobile);
const { latestEmail, participantNames, hasUnread, hasStarred, hasAttachment, hasAnswered, hasForwarded, emailCount } = thread;
const isFocusedMailLayout = mailLayout === 'focus';
// The horizontal one-line "focus" layout doesn't fit on narrow screens; fall back to multi-line on mobile.
const isFocusedMailLayout = mailLayout === 'focus' && !isMobile;
const trimmedPreview = stripInvisibleLeading(latestEmail.preview ?? '');
const inlinePreview = showPreview && trimmedPreview ? ` ${trimmedPreview}` : '';
const scheduledSendLabel = latestEmail.isScheduled && latestEmail.scheduledSendAt
? formatDateTime(latestEmail.scheduledSendAt, timeFormat)
: null;
const { selectedMailbox, mailboxes, selectedEmailIds, toggleEmailSelection, selectRangeEmails, clearSelection, isUnifiedView } = useEmailStore();
const getAccountById = useAccountStore((state) => state.getAccountById);
@@ -645,12 +676,22 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
{keywordDef && (
<span className={cn('h-2.5 w-2.5 rounded-full', KEYWORD_PALETTE[keywordDef.color]?.dot || 'bg-gray-400')} />
)}
<span className={cn(
'text-xs tabular-nums',
hasUnread ? 'text-foreground font-semibold' : 'text-muted-foreground'
)}>
{formatDate(latestEmail.receivedAt)}
</span>
{scheduledSendLabel ? (
<span
className="inline-flex max-w-[11rem] shrink-0 items-center gap-1 truncate rounded-full border border-sky-500/20 bg-sky-500/10 px-2 py-0.5 text-xs font-medium tabular-nums text-sky-700 dark:text-sky-300"
title={scheduledSendLabel}
>
<CalendarClock className="h-3 w-3 shrink-0" />
<span className="truncate">{scheduledSendLabel}</span>
</span>
) : (
<span className={cn(
'text-xs tabular-nums',
hasUnread ? 'text-foreground font-semibold' : 'text-muted-foreground'
)}>
{formatDate(latestEmail.receivedAt)}
</span>
)}
</div>
</div>
) : (
@@ -715,14 +756,24 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
{keywordDef.label}
</span>
)}
<span className={cn(
"text-xs tabular-nums",
hasUnread
? "text-foreground font-semibold"
: "text-muted-foreground"
)}>
{formatDate(latestEmail.receivedAt)}
</span>
{scheduledSendLabel ? (
<span
className="inline-flex max-w-[11rem] shrink-0 items-center gap-1 truncate rounded-full border border-sky-500/20 bg-sky-500/10 px-2 py-0.5 text-[11px] font-medium tabular-nums text-sky-700 dark:text-sky-300"
title={scheduledSendLabel}
>
<CalendarClock className="h-3 w-3 shrink-0" />
<span className="truncate">{scheduledSendLabel}</span>
</span>
) : (
<span className={cn(
"text-xs tabular-nums",
hasUnread
? "text-foreground font-semibold"
: "text-muted-foreground"
)}>
{formatDate(latestEmail.receivedAt)}
</span>
)}
</div>
</div>
@@ -751,16 +802,18 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
</div>
{/* Hover Quick Actions for thread header */}
<EmailHoverActions
email={latestEmail}
backgroundClassName={colorTag ? colorTag : ((isSelected || isChecked) ? "bg-accent" : "bg-muted")}
onToggleStar={onToggleStar ? () => onToggleStar(latestEmail) : undefined}
onMarkAsRead={onMarkAsRead ? (read) => onMarkAsRead(latestEmail, read) : undefined}
onDelete={onDelete ? () => onDelete(latestEmail) : undefined}
onArchive={onArchive ? () => onArchive(latestEmail) : undefined}
onSetColorTag={onSetColorTag ? (color) => onSetColorTag(latestEmail.id, color) : undefined}
onMarkAsSpam={onMarkAsSpam ? () => onMarkAsSpam(latestEmail) : undefined}
/>
{!latestEmail.isScheduled && (
<EmailHoverActions
email={latestEmail}
backgroundClassName={colorTag ? colorTag : ((isSelected || isChecked) ? "bg-accent" : "bg-muted")}
onToggleStar={onToggleStar ? () => onToggleStar(latestEmail) : undefined}
onMarkAsRead={onMarkAsRead ? (read) => onMarkAsRead(latestEmail, read) : undefined}
onDelete={onDelete ? () => onDelete(latestEmail) : undefined}
onArchive={onArchive ? () => onArchive(latestEmail) : undefined}
onSetColorTag={onSetColorTag ? (color) => onSetColorTag(latestEmail.id, color) : undefined}
onMarkAsSpam={onMarkAsSpam ? () => onMarkAsSpam(latestEmail) : undefined}
/>
)}
</div>
{isExpanded && !isMobile && !isFocusedMailLayout && (
+4 -1
View File
@@ -78,7 +78,10 @@ export function FilterRuleModal({
const pathMap = new Map<string, string>();
const buildPaths = (nodes: MailboxNode[], parentPath = "") => {
for (const node of nodes) {
const fullPath = parentPath ? `${parentPath}/${node.name}` : node.name;
// Sieve fileinto expects the IMAP-canonical "INBOX" for the inbox,
// not the localized JMAP display name (e.g. "Entrada" in pt-BR).
const segment = node.role === "inbox" ? "INBOX" : node.name;
const fullPath = parentPath ? `${parentPath}/${segment}` : segment;
pathMap.set(node.id, fullPath);
if (node.children.length > 0) buildPaths(node.children, fullPath);
}
+4 -4
View File
@@ -21,7 +21,7 @@ import { getMaxAccounts } from "@/lib/account-utils";
import { cn, formatFileSize } from "@/lib/utils";
import { PluginSlot } from "@/components/plugins/plugin-slot";
import { KeyboardShortcutsModal } from "@/components/keyboard-shortcuts-modal";
import { apiFetch } from "@/lib/browser-navigation";
import { apiFetch, getPathPrefix, withBasePath } from "@/lib/browser-navigation";
import { Avatar } from "@/components/ui/avatar";
interface NavItem {
@@ -385,7 +385,7 @@ export function NavigationRail({
{/* Admin (Stalwart admins) - hard nav because /admin lives outside the [locale] tree */}
{isStalwartAdmin && (
<a
href="/admin"
href={`${getPathPrefix()}/admin`}
className={cn(
"flex flex-col items-center justify-center gap-1 py-2 px-1 min-h-[44px] grow shrink-0 basis-[64px]",
"transition-colors duration-150",
@@ -444,7 +444,7 @@ export function NavigationRail({
)}
>
{(() => {
const logoUrl = resolvedTheme === 'dark' ? (appLogoDarkUrl || appLogoLightUrl) : (appLogoLightUrl || appLogoDarkUrl);
const logoUrl = withBasePath(resolvedTheme === 'dark' ? (appLogoDarkUrl || appLogoLightUrl) : (appLogoLightUrl || appLogoDarkUrl));
return logoUrl ? (
<div className="flex items-center justify-center py-3 px-1">
<img
@@ -572,7 +572,7 @@ export function NavigationRail({
<div className="mt-auto flex flex-col items-center gap-2 pb-3 px-1">
{isStalwartAdmin && (
<a
href="/admin"
href={`${getPathPrefix()}/admin`}
className="flex items-center justify-center w-10 h-10 rounded-md transition-colors text-muted-foreground hover:text-foreground hover:bg-muted relative"
title={t("admin") || "Admin"}
>
+70 -35
View File
@@ -1,6 +1,6 @@
"use client";
import { useState, useEffect, ReactNode } from "react";
import { useState, useEffect, useMemo, ReactNode } from "react";
import { useTranslations } from "next-intl";
import { useRouter } from "@/i18n/navigation";
import { PluginSlot } from "@/components/plugins/plugin-slot";
@@ -74,6 +74,8 @@ interface SidebarProps {
onDeleteFolder?: (mailboxId: string) => void;
onImportEmail?: (mailboxId: string) => void;
onRefreshMailboxes?: () => void;
scheduledTotal?: number;
showScheduledMailbox?: boolean;
className?: string;
/**
* Multi-account (Pro) mode props. When `multiAccountMode` is true, the
@@ -674,6 +676,8 @@ export function Sidebar({
onDeleteFolder,
onImportEmail,
onRefreshMailboxes,
scheduledTotal = 0,
showScheduledMailbox = false,
className,
multiAccountMode = false,
accountMailboxes,
@@ -732,15 +736,20 @@ export function Sidebar({
// pane.
const hideAccountSwitcher = useSettingsStore(s => s.hideAccountSwitcher) || isEmbedded;
const enableUnifiedMailbox = useSettingsStore(s => s.enableUnifiedMailbox);
const includeGroupInUnified = useSettingsStore(s => s.includeGroupInUnified);
const colorfulSidebarIcons = useSettingsStore(s => s.colorfulSidebarIcons);
const tagCounts = useEmailStore(s => s.tagCounts);
const accounts = useAccountStore(s => s.accounts);
const connectedAccounts = accounts.filter(a => a.isConnected);
const hasGroupInboxes = useMemo(() => mailboxes.some(m => m.isShared), [mailboxes]);
// Pro shell treats the unified mailbox as a core part of the multi-account
// UI, so it ignores the user-facing `enableUnifiedMailbox` toggle. The
// 2+ account requirement still applies - with a single account the
// unified counts would just duplicate that account's inbox.
const showUnified = (multiAccountMode || enableUnifiedMailbox) && connectedAccounts.length > 1;
// UI, so it ignores the user-facing `enableUnifiedMailbox` toggle. With a
// single account we still surface unified when the user has opted into
// merging group/shared inboxes — otherwise the counts would just duplicate
// the one inbox.
const showUnified =
(multiAccountMode || enableUnifiedMailbox) &&
(connectedAccounts.length > 1 || (includeGroupInUnified && hasGroupInboxes));
const { unifiedCounts } = useEmailStore();
const t = useTranslations('sidebar');
@@ -1022,22 +1031,35 @@ export function Sidebar({
{!isCollapsed && t("loading_mailboxes")}
</div>
) : (
tree.map((node) => (
<MailboxTreeItem
key={node.id}
node={node}
selectedMailbox={selectedKeyword || !isViewing ? "" : selectedMailbox}
expandedFolders={expandedFolders}
onMailboxSelect={(mailboxId) =>
onAccountMailboxSelect?.(isActive ? null : account.id, mailboxId)
}
onToggleExpand={handleToggleExpand}
isCollapsed={isCollapsed}
onUnreadFilterClick={isActive ? onUnreadFilterClick : undefined}
colorful={colorfulSidebarIcons}
onContextMenu={isActive ? handleMailboxContextMenu : undefined}
/>
))
<>
{tree.map((node) => (
<MailboxTreeItem
key={node.id}
node={node}
selectedMailbox={selectedKeyword || !isViewing ? "" : selectedMailbox}
expandedFolders={expandedFolders}
onMailboxSelect={(mailboxId) =>
onAccountMailboxSelect?.(isActive ? null : account.id, mailboxId)
}
onToggleExpand={handleToggleExpand}
isCollapsed={isCollapsed}
onUnreadFilterClick={isActive ? onUnreadFilterClick : undefined}
colorful={colorfulSidebarIcons}
onContextMenu={isActive ? handleMailboxContextMenu : undefined}
/>
))}
{isActive && showScheduledMailbox && (
<SidebarRow
icon={<CalendarClock className={cn("w-4 h-4 flex-shrink-0", selectedMailbox === '__scheduled__' ? "text-foreground" : "text-muted-foreground")} />}
label={t('scheduled')}
depth={0}
isSelected={!selectedKeyword && selectedMailbox === '__scheduled__'}
total={scheduledTotal}
onClick={() => onMailboxSelect?.('__scheduled__')}
isCollapsed={isCollapsed}
/>
)}
</>
)}
</>
)}
@@ -1062,20 +1084,33 @@ export function Sidebar({
{!isCollapsed && t("loading_mailboxes")}
</div>
) : (
ownTree.map((node) => (
<MailboxTreeItem
key={node.id}
node={node}
selectedMailbox={selectedKeyword ? "" : selectedMailbox}
expandedFolders={expandedFolders}
onMailboxSelect={onMailboxSelect}
onToggleExpand={handleToggleExpand}
isCollapsed={isCollapsed}
onUnreadFilterClick={onUnreadFilterClick}
colorful={colorfulSidebarIcons}
onContextMenu={handleMailboxContextMenu}
/>
))
<>
{ownTree.map((node) => (
<MailboxTreeItem
key={node.id}
node={node}
selectedMailbox={selectedKeyword ? "" : selectedMailbox}
expandedFolders={expandedFolders}
onMailboxSelect={onMailboxSelect}
onToggleExpand={handleToggleExpand}
isCollapsed={isCollapsed}
onUnreadFilterClick={onUnreadFilterClick}
colorful={colorfulSidebarIcons}
onContextMenu={handleMailboxContextMenu}
/>
))}
{showScheduledMailbox && (
<SidebarRow
icon={<CalendarClock className={cn("w-4 h-4 flex-shrink-0", selectedMailbox === '__scheduled__' ? "text-foreground" : "text-muted-foreground")} />}
label={t('scheduled')}
depth={0}
isSelected={!selectedKeyword && selectedMailbox === '__scheduled__'}
total={scheduledTotal}
onClick={() => onMailboxSelect?.('__scheduled__')}
isCollapsed={isCollapsed}
/>
)}
</>
)}
</>
)}
+20 -2
View File
@@ -26,7 +26,10 @@ export function ProComposeTabBody({ tabId, data }: ProComposeTabBodyProps) {
const client = useAuthStore((s) => s.client);
const sendEmail = useEmailStore((s) => s.sendEmail);
const fetchEmails = useEmailStore((s) => s.fetchEmails);
const fetchScheduledEmails = useEmailStore((s) => s.fetchScheduledEmails);
const refreshScheduledMetadata = useEmailStore((s) => s.refreshScheduledMetadata);
const selectedMailbox = useEmailStore((s) => s.selectedMailbox);
const isScheduledView = useEmailStore((s) => s.isScheduledView);
const closeTab = useProTabStore((s) => s.closeTab);
const updateTabTitle = useProTabStore((s) => s.updateTabTitle);
const updateComposeDraft = useProTabStore((s) => s.updateComposeDraft);
@@ -36,10 +39,18 @@ export function ProComposeTabBody({ tabId, data }: ProComposeTabBodyProps) {
const tabIdRef = useRef(tabId);
tabIdRef.current = tabId;
const handleScheduledSendCreated = useCallback(async () => {
if (client) {
await refreshScheduledMetadata(client);
if (isScheduledView) await fetchScheduledEmails(client);
}
closeTab(tabIdRef.current);
}, [client, refreshScheduledMetadata, isScheduledView, fetchScheduledEmails, closeTab]);
const handleSend = useCallback(async (sendData: Parameters<NonNullable<React.ComponentProps<typeof EmailComposer>['onSend']>>[0]) => {
if (!client) return;
try {
await sendEmail(
const result = await sendEmail(
client,
sendData.to,
sendData.subject,
@@ -54,9 +65,15 @@ export function ProComposeTabBody({ tabId, data }: ProComposeTabBodyProps) {
sendData.attachments,
sendData.inReplyTo,
sendData.references,
sendData.delayedUntil,
sendData.envelopeMailFrom,
);
if (result.scheduled) {
await handleScheduledSendCreated();
return;
}
// Mark the original message as $answered / $forwarded so the standard
// viewer and list reflect the action (same behaviour as inline compose).
if (data.sourceEmailId && (data.mode === 'reply' || data.mode === 'replyAll')) {
@@ -81,7 +98,7 @@ export function ProComposeTabBody({ tabId, data }: ProComposeTabBodyProps) {
console.error('Failed to send email:', error);
toast.error(t('notifications.error_sending'));
}
}, [client, sendEmail, fetchEmails, selectedMailbox, closeTab, data.sourceEmailId, data.mode, t]);
}, [client, sendEmail, fetchEmails, selectedMailbox, closeTab, data.sourceEmailId, data.mode, t, handleScheduledSendCreated]);
const handleClose = useCallback(() => {
closeTab(tabIdRef.current);
@@ -125,6 +142,7 @@ export function ProComposeTabBody({ tabId, data }: ProComposeTabBodyProps) {
initialDraftText={data.initialDraftText}
initialData={data.initialData}
onSend={handleSend}
onScheduledSendCreated={handleScheduledSendCreated}
onClose={handleClose}
onDiscardDraft={handleDiscardDraft}
onSaveState={handleSaveState}
+4 -2
View File
@@ -3,6 +3,7 @@
import { useEffect, useState } from "react";
import { X, Download } from "lucide-react";
import { useConfig } from "@/hooks/use-config";
import { withBasePath } from "@/lib/browser-navigation";
interface BeforeInstallPromptEvent extends Event {
prompt: () => Promise<void>;
@@ -58,7 +59,8 @@ export function PWAInstallPrompt() {
return null;
}
const logoSrc = appLogoLightUrl || faviconUrl;
const logoSrc = withBasePath(appLogoLightUrl || faviconUrl);
const darkLogoSrc = withBasePath(appLogoDarkUrl || faviconUrl);
return (
<div className="fixed bottom-4 right-4 z-50 bg-white dark:bg-neutral-900 rounded-lg shadow-lg border border-neutral-200 dark:border-neutral-800 p-4 max-w-sm animate-in slide-in-from-bottom-4">
@@ -75,7 +77,7 @@ export function PWAInstallPrompt() {
)}
{logoSrc && (
<img
src={appLogoDarkUrl || faviconUrl}
src={darkLogoSrc}
alt={appName}
className="w-8 h-8 shrink-0 object-contain hidden dark:block"
/>
+3 -2
View File
@@ -10,6 +10,7 @@ import { usePolicyStore } from '@/stores/policy-store';
import { useUpdateStore } from '@/stores/update-store';
import { ExternalLink } from 'lucide-react';
import { cn } from '@/lib/utils';
import { getPathPrefix } from '@/lib/browser-navigation';
import { SpamSiegeGame } from './spam-siege-game';
const APP_VERSION = process.env.NEXT_PUBLIC_APP_VERSION || "0.0.0";
@@ -123,12 +124,12 @@ export function AboutDataSettings() {
<button onClick={handleLogoClick} className="flex items-center gap-4 flex-1 text-left focus:outline-none group/about cursor-pointer" aria-label="About">
<div className="shrink-0">
<img
src="/branding/Bulwark_Logo_Color.svg"
src={`${getPathPrefix()}/branding/Bulwark_Logo_Color.svg`}
alt="Bulwark"
className="w-12 h-12 object-contain dark:hidden group-hover/about:scale-105 group-active/about:scale-95 transition-transform"
/>
<img
src="/branding/Bulwark_Logo_White.svg"
src={`${getPathPrefix()}/branding/Bulwark_Logo_White.svg`}
alt="Bulwark"
className="w-12 h-12 object-contain hidden dark:block group-hover/about:scale-105 group-active/about:scale-95 transition-transform"
/>
+1 -26
View File
@@ -4,17 +4,14 @@ import { useTranslations } from 'next-intl';
import { useCalendarStore, CalendarViewMode } from '@/stores/calendar-store';
import { useSettingsStore } from '@/stores/settings-store';
import { usePolicyStore } from '@/stores/policy-store';
import { SettingsSection, SettingItem, Select, RadioGroup, ToggleSwitch } from './settings-section';
import { SettingsSection, SettingItem, Select, ToggleSwitch } from './settings-section';
export function CalendarSettings() {
const t = useTranslations('calendar.settings');
const tViews = useTranslations('calendar.views');
const tDays = useTranslations('calendar.days');
const { viewMode, setViewMode } = useCalendarStore();
const {
timeFormat,
firstDayOfWeek,
showTimeInMonthView,
showWeekNumbers,
enableCalendarTasks,
@@ -40,28 +37,6 @@ export function CalendarSettings() {
/>
</SettingItem>
<SettingItem label={t('week_starts_on')}>
<Select
value={firstDayOfWeek.toString()}
onChange={(value) => updateSetting('firstDayOfWeek', parseInt(value) as 0 | 1)}
options={[
{ value: '1', label: tDays('monday') },
{ value: '0', label: tDays('sunday') },
]}
/>
</SettingItem>
<SettingItem label={t('time_format')}>
<RadioGroup
value={timeFormat}
onChange={(value) => updateSetting('timeFormat', value as '12h' | '24h')}
options={[
{ value: '12h', label: t('time_format_12h') },
{ value: '24h', label: t('time_format_24h') },
]}
/>
</SettingItem>
<SettingItem
label={t('show_time_in_month_view')}
description={t('show_time_in_month_view_desc')}
@@ -3,6 +3,8 @@
import { useState } from 'react';
import { useTranslations } from 'next-intl';
import { useSettingsStore } from '@/stores/settings-store';
import type { SendDelaySeconds } from '@/stores/settings-store';
import { useAuthStore } from '@/stores/auth-store';
import { SettingsSection, SettingItem, Select, ToggleSwitch } from './settings-section';
import { X } from 'lucide-react';
import {
@@ -22,11 +24,14 @@ export function ComposingSettings() {
autoSelectReplyIdentity,
attachmentReminderEnabled,
attachmentReminderKeywords,
sendDelaySeconds,
subAddressDelimiter,
signaturePosition,
signatureSeparatorEnabled,
updateSetting,
} = useSettingsStore();
const { client } = useAuthStore();
const delayedSendSupported = client?.hasDelayedSend() ?? false;
return (
<SettingsSection title={t('title')} description={t('description')}>
@@ -37,6 +42,24 @@ export function ComposingSettings() {
/>
</SettingItem>
<SettingItem label={t('send_delay.label')} description={t('send_delay.description')}>
<div className="flex flex-col items-end gap-1">
<Select
value={String(sendDelaySeconds)}
onChange={(value) => updateSetting('sendDelaySeconds', Number(value) as SendDelaySeconds)}
options={[
{ value: '0', label: t('send_delay.off') },
{ value: '10', label: t('send_delay.seconds', { seconds: 10 }) },
{ value: '30', label: t('send_delay.seconds', { seconds: 30 }) },
{ value: '60', label: t('send_delay.seconds', { seconds: 60 }) },
]}
/>
{sendDelaySeconds > 0 && !delayedSendSupported && (
<p className="max-w-64 text-right text-xs text-amber-600 dark:text-amber-400">{t('send_delay.unsupported')}</p>
)}
</div>
</SettingItem>
<SettingItem label={t('signature_position.label')} description={t('signature_position.description')}>
<Select
value={signaturePosition}
+266
View File
@@ -0,0 +1,266 @@
"use client";
import { useMemo, useRef } from "react";
import { useTranslations } from "next-intl";
import { useSettingsStore } from "@/stores/settings-store";
import { SettingsSection, SettingItem, Select, ToggleSwitch } from "./settings-section";
import { RotateCcw } from "lucide-react";
import { cn } from "@/lib/utils";
import {
DEFAULT_ATTACHMENT_TEMPLATE,
DEFAULT_BUNDLE_TEMPLATE,
DEFAULT_EMAIL_TEMPLATE,
EMAIL_TOKENS,
ATTACHMENT_TOKENS,
BUNDLE_TOKENS,
bundleExportFilename,
emailExportFilename,
attachmentDownloadFilename,
buildSampleEmail,
type EmailFilenameOptions,
} from "@/lib/download-filename";
function insertTokenAtCursor(
input: HTMLInputElement,
token: string,
current: string,
onChange: (next: string) => void,
): void {
const start = input.selectionStart ?? current.length;
const end = input.selectionEnd ?? current.length;
const before = current.slice(0, start);
const after = current.slice(end);
const insertion = `{${token}}`;
const next = `${before}${insertion}${after}`;
onChange(next);
// Restore focus and place caret after the inserted token.
requestAnimationFrame(() => {
input.focus();
const caret = before.length + insertion.length;
input.setSelectionRange(caret, caret);
});
}
interface TemplateEditorProps {
label: string;
description: string;
value: string;
defaultValue: string;
tokens: { token: string; description: string }[];
preview: string;
onChange: (next: string) => void;
resetLabel: string;
previewLabel: string;
placeholder?: string;
}
function TemplateEditor({
label,
description,
value,
defaultValue,
tokens,
preview,
onChange,
resetLabel,
previewLabel,
placeholder,
}: TemplateEditorProps) {
const inputRef = useRef<HTMLInputElement>(null);
return (
<div data-search-label={label} className="space-y-3 py-3 border-b border-border last:border-0">
<div>
<label className="text-sm font-medium text-foreground">{label}</label>
<p className="text-xs text-muted-foreground mt-1">{description}</p>
</div>
<div className="flex flex-col gap-2">
<div className="flex items-stretch gap-2">
<input
ref={inputRef}
type="text"
value={value}
placeholder={placeholder}
onChange={(e) => onChange(e.target.value)}
spellCheck={false}
className="flex-1 px-3 py-1.5 text-sm rounded-md bg-muted border border-border text-foreground font-mono focus:outline-none focus:ring-2 focus:ring-ring transition-colors duration-150"
/>
<button
type="button"
onClick={() => onChange(defaultValue)}
disabled={value === defaultValue}
title={resetLabel}
className={cn(
"px-2 rounded-md border border-border text-foreground transition-colors duration-150",
value === defaultValue
? "opacity-40 cursor-not-allowed"
: "hover:bg-muted cursor-pointer",
)}
>
<RotateCcw className="w-4 h-4" />
</button>
</div>
<div className="flex flex-wrap gap-1.5">
{tokens.map((t) => (
<button
key={t.token}
type="button"
title={t.description}
onClick={() => {
const input = inputRef.current;
if (!input) {
onChange(`${value}{${t.token}}`);
return;
}
insertTokenAtCursor(input, t.token, value, onChange);
}}
className="px-2 py-0.5 text-xs font-mono rounded bg-muted hover:bg-accent border border-border text-foreground transition-colors duration-150 cursor-pointer"
>
{`{${t.token}}`}
</button>
))}
</div>
<div className="text-xs text-muted-foreground">
<span className="opacity-70">{previewLabel} </span>
<span className="font-mono text-foreground/90 break-all">{preview}</span>
</div>
</div>
</div>
);
}
export function DownloadsSettings() {
const t = useTranslations("settings.downloads");
const {
emailDownloadTemplate,
attachmentDownloadTemplate,
bundleDownloadTemplate,
filenameSpaceReplacement,
filenameLowercase,
filenameStripDiacritics,
filenameCollapseSeparators,
postExportAction,
updateSetting,
} = useSettingsStore();
const sampleEmail = useMemo(() => buildSampleEmail(), []);
const sampleAttachment = useMemo(() => ({ name: "Invoice-2026-05.pdf", type: "application/pdf" }), []);
const transform = useMemo(
() => ({
spaceReplacement: filenameSpaceReplacement,
lowercase: filenameLowercase,
stripDiacritics: filenameStripDiacritics,
collapseSeparators: filenameCollapseSeparators,
}),
[filenameSpaceReplacement, filenameLowercase, filenameStripDiacritics, filenameCollapseSeparators],
);
const emailOptions: EmailFilenameOptions = useMemo(
() => ({ ...transform, template: emailDownloadTemplate || DEFAULT_EMAIL_TEMPLATE }),
[transform, emailDownloadTemplate],
);
const attachmentOptions: EmailFilenameOptions = useMemo(
() => ({ ...transform, template: attachmentDownloadTemplate || DEFAULT_ATTACHMENT_TEMPLATE }),
[transform, attachmentDownloadTemplate],
);
const bundleOptions: EmailFilenameOptions = useMemo(
() => ({ ...transform, template: bundleDownloadTemplate || DEFAULT_BUNDLE_TEMPLATE }),
[transform, bundleDownloadTemplate],
);
const emlPreview = useMemo(
() => emailExportFilename(sampleEmail, emailOptions),
[sampleEmail, emailOptions],
);
const attachmentPreview = useMemo(
() => attachmentDownloadFilename(sampleEmail, sampleAttachment, attachmentOptions),
[sampleEmail, sampleAttachment, attachmentOptions],
);
const bundlePreview = useMemo(
// Render with the email's fixed sample date so the preview is stable as the
// user types in the template field.
() => bundleExportFilename(3, bundleOptions, sampleEmail.receivedAt ?? undefined),
[bundleOptions, sampleEmail],
);
return (
<SettingsSection title={t("title")} description={t("description")}>
<TemplateEditor
label={t("email_template.label")}
description={t("email_template.description")}
value={emailDownloadTemplate}
defaultValue={DEFAULT_EMAIL_TEMPLATE}
tokens={EMAIL_TOKENS}
preview={emlPreview}
onChange={(next) => updateSetting("emailDownloadTemplate", next)}
resetLabel={t("reset")}
previewLabel={t("preview")}
placeholder={DEFAULT_EMAIL_TEMPLATE}
/>
<TemplateEditor
label={t("attachment_template.label")}
description={t("attachment_template.description")}
value={attachmentDownloadTemplate}
defaultValue={DEFAULT_ATTACHMENT_TEMPLATE}
tokens={ATTACHMENT_TOKENS}
preview={attachmentPreview}
onChange={(next) => updateSetting("attachmentDownloadTemplate", next)}
resetLabel={t("reset")}
previewLabel={t("preview")}
placeholder={DEFAULT_ATTACHMENT_TEMPLATE}
/>
<TemplateEditor
label={t("bundle_template.label")}
description={t("bundle_template.description")}
value={bundleDownloadTemplate}
defaultValue={DEFAULT_BUNDLE_TEMPLATE}
tokens={BUNDLE_TOKENS}
preview={bundlePreview}
onChange={(next) => updateSetting("bundleDownloadTemplate", next)}
resetLabel={t("reset")}
previewLabel={t("preview")}
placeholder={DEFAULT_BUNDLE_TEMPLATE}
/>
<SettingItem label={t("spaces.label")} description={t("spaces.description")}>
<Select
value={filenameSpaceReplacement}
onChange={(value) => updateSetting("filenameSpaceReplacement", value as "keep" | "underscore" | "dash")}
options={[
{ value: "keep", label: t("spaces.keep") },
{ value: "underscore", label: t("spaces.underscore") },
{ value: "dash", label: t("spaces.dash") },
]}
/>
</SettingItem>
<SettingItem label={t("lowercase.label")} description={t("lowercase.description")}>
<ToggleSwitch
checked={filenameLowercase}
onChange={(checked) => updateSetting("filenameLowercase", checked)}
/>
</SettingItem>
<SettingItem label={t("strip_diacritics.label")} description={t("strip_diacritics.description")}>
<ToggleSwitch
checked={filenameStripDiacritics}
onChange={(checked) => updateSetting("filenameStripDiacritics", checked)}
/>
</SettingItem>
<SettingItem label={t("collapse_separators.label")} description={t("collapse_separators.description")}>
<ToggleSwitch
checked={filenameCollapseSeparators}
onChange={(checked) => updateSetting("filenameCollapseSeparators", checked)}
/>
</SettingItem>
<SettingItem label={t("after_export.label")} description={t("after_export.description")}>
<Select
value={postExportAction}
onChange={(value) => updateSetting("postExportAction", value as "keep" | "archive" | "trash")}
options={[
{ value: "keep", label: t("after_export.keep") },
{ value: "archive", label: t("after_export.archive") },
{ value: "trash", label: t("after_export.trash") },
]}
/>
</SettingItem>
</SettingsSection>
);
}
+3 -2
View File
@@ -6,6 +6,7 @@ import { Folder, FolderOpen, FileText, FileCode, ImageIcon, FileAudio, File, Hom
import { SettingsSection, SettingItem, ToggleSwitch, RadioGroup } from "./settings-section";
import { loadFilesSettings, saveFilesSettings, type FilesSettings, type FolderLayout } from "@/components/files/files-settings-dialog";
import { cn } from "@/lib/utils";
import { withBasePath } from "@/lib/browser-navigation";
interface SampleFile {
name: string;
@@ -95,7 +96,7 @@ function FilesSettingsPreview({ settings }: { settings: FilesSettings }) {
)}
>
{settings.showThumbnails && file.thumbnailUrl ? (
<img src={file.thumbnailUrl} alt="" className="w-4 h-4 rounded object-cover flex-shrink-0" />
<img src={withBasePath(file.thumbnailUrl)} alt="" className="w-4 h-4 rounded object-cover flex-shrink-0" />
) : settings.showIcons ? (
getPreviewIcon(file, settings.coloredIcons, "sm")
) : null}
@@ -125,7 +126,7 @@ function FilesSettingsPreview({ settings }: { settings: FilesSettings }) {
)}
>
{settings.showThumbnails && file.thumbnailUrl ? (
<img src={file.thumbnailUrl} alt="" className="w-8 h-8 rounded object-cover flex-shrink-0" />
<img src={withBasePath(file.thumbnailUrl)} alt="" className="w-8 h-8 rounded object-cover flex-shrink-0" />
) : settings.showIcons ? (
getPreviewIcon(file, settings.coloredIcons, "lg")
) : (
+86 -3
View File
@@ -1,17 +1,100 @@
"use client";
import { useMemo } from 'react';
import { useTranslations } from 'next-intl';
import { LanguageSwitcher } from '@/components/ui/language-switcher';
import { SettingsSection, SettingItem } from './settings-section';
import { useLocaleStore } from '@/stores/locale-store';
import { useSettingsStore } from '@/stores/settings-store';
import type { DateFormat, TimeFormat, FirstDayOfWeek } from '@/stores/settings-store';
import { formatDate } from '@/lib/utils';
import { SettingsSection, SettingItem, Select, RadioGroup } from './settings-section';
export function LanguageSettings() {
const t = useTranslations('settings.appearance');
const t = useTranslations('settings.language_region');
const tDays = useTranslations('calendar.days');
const { dateFormat, timeFormat, firstDayOfWeek, updateSetting } = useSettingsStore();
// Subscribe to locale changes so the preview re-renders on language switch
// (formatDate reads it via getState() and would otherwise stay stale).
const locale = useLocaleStore((s) => s.locale);
const preview = useMemo(() => {
// Build sample timestamps for each bucket so users see what their pick
// will look like in practice. Use offsets relative to "now" so the
// bucketing is stable even though the wall-clock keeps moving.
void locale; void dateFormat; void timeFormat;
const now = new Date();
const today = new Date(now);
today.setHours(15, 31, 0, 0);
const thisWeek = new Date(now);
thisWeek.setDate(now.getDate() - 2);
thisWeek.setHours(15, 31, 0, 0);
const older = new Date(now);
older.setMonth(now.getMonth() - 2);
older.setHours(15, 31, 0, 0);
return {
today: formatDate(today),
thisWeek: formatDate(thisWeek),
older: formatDate(older),
};
}, [locale, dateFormat, timeFormat]);
return (
<SettingsSection title={t('language.label')} description={t('language.description')}>
<SettingsSection title={t('title')} description={t('description')}>
<SettingItem label={t('language.label')} description={t('language.description')}>
<LanguageSwitcher />
</SettingItem>
<SettingItem label={t('date_format.label')} description={t('date_format.description')}>
<div className="flex flex-col items-end gap-2">
<Select
value={dateFormat}
onChange={(value) => updateSetting('dateFormat', value as DateFormat)}
options={[
{ value: 'smart', label: t('date_format.smart') },
{ value: 'relative', label: t('date_format.relative') },
{ value: 'full', label: t('date_format.full') },
]}
/>
<div className="text-xs text-muted-foreground text-right space-y-0.5 font-mono">
<div>
<span className="opacity-70">{t('date_format.preview_today')} </span>
<span className="text-foreground/90">{preview.today}</span>
</div>
<div>
<span className="opacity-70">{t('date_format.preview_this_week')} </span>
<span className="text-foreground/90">{preview.thisWeek}</span>
</div>
<div>
<span className="opacity-70">{t('date_format.preview_older')} </span>
<span className="text-foreground/90">{preview.older}</span>
</div>
</div>
</div>
</SettingItem>
<SettingItem label={t('time_format.label')} description={t('time_format.description')}>
<RadioGroup
value={timeFormat}
onChange={(value) => updateSetting('timeFormat', value as TimeFormat)}
options={[
{ value: '12h', label: t('time_format.12h') },
{ value: '24h', label: t('time_format.24h') },
]}
/>
</SettingItem>
<SettingItem label={t('first_day.label')} description={t('first_day.description')}>
<Select
value={firstDayOfWeek.toString()}
onChange={(value) => updateSetting('firstDayOfWeek', parseInt(value) as FirstDayOfWeek)}
options={[
{ value: '1', label: tDays('monday') },
{ value: '0', label: tDays('sunday') },
]}
/>
</SettingItem>
</SettingsSection>
);
}
+22 -2
View File
@@ -1,11 +1,13 @@
"use client";
import { useMemo } from 'react';
import { useTranslations } from 'next-intl';
import { useSettingsStore, type ToolbarPosition, type MailLayout } from '@/stores/settings-store';
import { SettingsSection, SettingItem, RadioGroup, ToggleSwitch } from './settings-section';
import { cn } from '@/lib/utils';
import { usePolicyStore } from '@/stores/policy-store';
import { useAccountStore } from '@/stores/account-store';
import { useEmailStore } from '@/stores/email-store';
const MAIL_LAYOUT_PREVIEW_ROWS = [
{ sender: 'Alice', subject: 'Quarterly roadmap', preview: 'The draft is ready for review.', selected: false },
@@ -115,9 +117,11 @@ function MailLayoutPreview({
export function LayoutSettings() {
const t = useTranslations('settings.appearance');
const tEmail = useTranslations('settings.email_behavior');
const { toolbarPosition, showToolbarLabels, hideAccountSwitcher, showRailAccountList, enableUnifiedMailbox, colorfulSidebarIcons, mailLayout, proInterface, updateSetting } = useSettingsStore();
const { toolbarPosition, showToolbarLabels, hideAccountSwitcher, showRailAccountList, enableUnifiedMailbox, includeGroupInUnified, colorfulSidebarIcons, mailLayout, proInterface, updateSetting } = useSettingsStore();
const { isSettingLocked, isSettingHidden } = usePolicyStore();
const accounts = useAccountStore(s => s.accounts);
const mailboxes = useEmailStore(s => s.mailboxes);
const hasGroupInboxes = useMemo(() => mailboxes.some(m => m.isShared), [mailboxes]);
return (
<SettingsSection title={t('title')} description={t('description')}>
@@ -177,10 +181,11 @@ export function LayoutSettings() {
/>
</SettingItem>
{accounts.length > 1 && (
{(accounts.length > 1 || hasGroupInboxes) && !isSettingHidden('enableUnifiedMailbox') && (
<SettingItem
label={t('unified_mailbox.label')}
description={t('unified_mailbox.description')}
locked={isSettingLocked('enableUnifiedMailbox')}
>
<ToggleSwitch
checked={enableUnifiedMailbox}
@@ -189,6 +194,21 @@ export function LayoutSettings() {
</SettingItem>
)}
{enableUnifiedMailbox && hasGroupInboxes && !isSettingHidden('includeGroupInUnified') && (
<div className="ml-4 border-l-2 border-border pl-4 -mt-2">
<SettingItem
label={t('unified_mailbox.include_group.label')}
description={t('unified_mailbox.include_group.description')}
locked={isSettingLocked('includeGroupInUnified')}
>
<ToggleSwitch
checked={includeGroupInUnified}
onChange={(v) => updateSetting('includeGroupInUnified', v)}
/>
</SettingItem>
</div>
)}
<SettingItem label={t('pro_interface.label')} description={t('pro_interface.description')}>
<ToggleSwitch
checked={proInterface}
+25 -5
View File
@@ -7,7 +7,7 @@ import { SettingsSection, SettingItem, ToggleSwitch, Select } from './settings-s
import { playNotificationSound, NOTIFICATION_SOUNDS } from '@/lib/notification-sound';
import type { NotificationSoundChoice } from '@/lib/notification-sound';
import { Button } from '@/components/ui/button';
import { CheckCircle2, Volume2, XCircle } from 'lucide-react';
import { CheckCircle2, Lock, Volume2, XCircle } from 'lucide-react';
import { usePolicyStore } from '@/stores/policy-store';
import { useAuthStore } from '@/stores/auth-store';
import { ConfirmDialog } from '@/components/ui/confirm-dialog';
@@ -40,16 +40,30 @@ export function NotificationSettings() {
updateSetting,
} = useSettingsStore();
const { isSettingLocked, isSettingHidden } = usePolicyStore();
const adminPushRelayUrl = usePolicyStore((s) => s.policy.pushRelayUrl);
const pushRelayLocked = usePolicyStore((s) => s.policy.pushRelayUrlLocked) === true;
const client = useAuthStore((s) => s.client);
const username = useAuthStore((s) => s.username);
const { dialogProps: confirmDialogProps, confirm: confirmDialog } = useConfirmDialog();
const supported = typeof window !== 'undefined' && isWebPushSupported();
const [relayUrl, setRelayUrl] = useState(DEFAULT_RELAY_BASE_URL);
const adminUrl = (adminPushRelayUrl ?? '').trim();
const [relayUrl, setRelayUrl] = useState(adminUrl || DEFAULT_RELAY_BASE_URL);
const [pushStatus, setPushStatus] = useState<PushStatus>(
supported ? { kind: 'idle' } : { kind: 'unsupported' },
);
// Pull the admin-configured URL into local state when policy loads/changes.
// When locked, the admin value always wins; when only set (not locked), use
// it as the initial default but let the user override.
useEffect(() => {
if (pushRelayLocked && adminUrl) {
setRelayUrl(adminUrl);
} else if (adminUrl) {
setRelayUrl((current) => (current === DEFAULT_RELAY_BASE_URL ? adminUrl : current));
}
}, [adminUrl, pushRelayLocked]);
useEffect(() => {
if (!supported) return;
if (!client) return;
@@ -125,12 +139,17 @@ export function NotificationSettings() {
<SettingsSection title={t('push.title')} description={t('push.description')}>
<div className="rounded-md border p-4 space-y-3">
<div className="flex items-center justify-between gap-3">
<label className="text-sm font-medium" htmlFor="push-relay-url">
<label className="text-sm font-medium inline-flex items-center gap-1.5" htmlFor="push-relay-url">
{t('push.relay_label')}
{pushRelayLocked && (
<Lock className="w-3 h-3 text-muted-foreground" aria-label={t('push.relay_locked')} />
)}
</label>
<PushStatusBadge status={pushStatus} t={t} />
</div>
<p className="text-xs text-muted-foreground">{t('push.relay_desc')}</p>
<p className="text-xs text-muted-foreground">
{pushRelayLocked ? t('push.relay_locked_desc') : t('push.relay_desc')}
</p>
<input
id="push-relay-url"
type="url"
@@ -140,7 +159,8 @@ export function NotificationSettings() {
value={relayUrl}
onChange={(e) => setRelayUrl(e.target.value)}
placeholder={t('push.relay_placeholder')}
disabled={busy || pushStatus.kind === 'unsupported'}
disabled={busy || pushStatus.kind === 'unsupported' || pushRelayLocked}
readOnly={pushRelayLocked}
className="w-full rounded border bg-background px-3 py-2 text-sm disabled:opacity-50"
/>
<div className="flex flex-wrap gap-2">
+2 -1
View File
@@ -120,9 +120,10 @@ export function ReadingSettings() {
<div className="flex flex-col gap-2">
<Select
value={deleteAction}
onChange={(value) => updateSetting('deleteAction', value as 'trash' | 'permanent')}
onChange={(value) => updateSetting('deleteAction', value as 'trash' | 'trash-and-read' | 'permanent')}
options={[
{ value: 'trash', label: t('delete_action.trash') },
{ value: 'trash-and-read', label: t('delete_action.trash_and_read') },
{ value: 'permanent', label: t('delete_action.permanent') },
]}
/>
+2 -1
View File
@@ -6,6 +6,7 @@ import { useSettingsStore } from "@/stores/settings-store";
import { useContactStore, getContactPhotoUri } from "@/stores/contact-store";
import { useConfig } from "@/hooks/use-config";
import { avatarHooks } from "@/lib/plugin-hooks";
import { withBasePath } from "@/lib/browser-navigation";
const IS_DEV = process.env.NODE_ENV !== "production";
@@ -236,7 +237,7 @@ export function Avatar({ name, email, contactPhotoUri, size = "md", className, d
const customAvatar = devMode && email ? CUSTOM_AVATARS[email.toLowerCase()] : null;
const pluginAvatar = pluginAvatarFailed ? null : pluginAvatarUrl;
const photoSrc = resolvedContactPhoto || pluginAvatar || customAvatar || profilePic || null;
const faviconSrc = !imgError && !domainFailed && showFavicon ? `/api/favicon?domain=${encodeURIComponent(faviconDomain!)}` : null;
const faviconSrc = !imgError && !domainFailed && showFavicon ? withBasePath(`/api/favicon?domain=${encodeURIComponent(faviconDomain!)}`) : null;
const imgSrc = disableImages ? null : (photoSrc || faviconSrc);
const isFavicon = imgSrc !== null && imgSrc === faviconSrc;
+5 -3
View File
@@ -92,9 +92,11 @@ export function useAttachmentDrag(
return;
}
// `DownloadURL` format: <mime>:<filename>:<url>. Chromium reads this on
// drop and writes a real file at the destination.
e.dataTransfer.setData("DownloadURL", `${type}:${encodeURIComponent(name)}:${url}`);
// `DownloadURL` format: <mime>:<filename>:<url>. The filename must be
// raw - URL-encoding it lands literally on disk (`%20` instead of a
// space). Callers are expected to sanitise reserved chars (`:` etc.)
// beforehand.
e.dataTransfer.setData("DownloadURL", `${type}:${name}:${url}`);
e.dataTransfer.effectAllowed = "copyMove";
},
[source.name, source.type, prefetch],
+202 -5
View File
@@ -1,10 +1,21 @@
"use client";
"use client";
import { useCallback, DragEvent } from "react";
import { useCallback, useEffect, useMemo, useRef, DragEvent } from "react";
import { Email } from "@/lib/jmap/types";
import { IJMAPClient } from "@/lib/jmap/client-interface";
import { useEmailStore } from "@/stores/email-store";
import { useAuthStore } from "@/stores/auth-store";
import { useDragDropContext } from "@/contexts/drag-drop-context";
import { useUIStore } from "@/stores/ui-store";
import { isDragOutSupported } from "@/hooks/use-attachment-drag";
import {
bundleExportFilename,
DEFAULT_BUNDLE_TEMPLATE,
DEFAULT_EMAIL_TEMPLATE,
emailExportFilename,
type EmailFilenameOptions,
} from "@/lib/download-filename";
import { useSettingsStore } from "@/stores/settings-store";
interface UseEmailDragOptions {
email: Email;
@@ -15,6 +26,7 @@ interface UseEmailDragOptions {
interface UseEmailDragReturn {
dragHandlers: {
draggable: boolean;
onPointerEnter?: () => void;
onDragStart: (e: DragEvent<HTMLDivElement>) => void;
onDragEnd: (e: DragEvent<HTMLDivElement>) => void;
};
@@ -44,10 +56,153 @@ function createDragPreview(count: number): HTMLElement {
return preview;
}
function bundleFilename(count: number, options: EmailFilenameOptions): string {
return bundleExportFilename(count, options);
}
// Shared bundle cache. The .zip is keyed by the sorted list of email IDs in
// the selection, so two rows in the same selection reuse the same in-flight
// build. When the selection changes, the previous bundle URL is scheduled for
// revoke and a new build starts.
type BundleEntry = {
key: string;
name: string;
url: string | null;
promise: Promise<string | null> | null;
};
let currentBundle: BundleEntry | null = null;
function selectionKey(ids: string[]): string {
return [...ids].sort().join(",");
}
async function buildEmailZip(client: IJMAPClient, emails: Email[], options: EmailFilenameOptions): Promise<string | null> {
const eligible = emails.filter((em) => !!em.blobId);
if (eligible.length === 0) return null;
const { default: JSZip } = await import("jszip");
const zip = new JSZip();
const used = new Set<string>();
await Promise.all(
eligible.map(async (em) => {
const base = emailExportFilename(em, options).replace(/\.eml$/, "");
let name = `${base}.eml`;
while (used.has(name)) name = `${base} [${em.id.slice(0, 6)}].eml`;
used.add(name);
try {
const blob = await client.fetchBlob(em.blobId!, name, "message/rfc822");
zip.file(name, blob);
} catch {
// Skip individual failures; remaining messages still bundle.
}
}),
);
const zipBlob = await zip.generateAsync({ type: "blob", mimeType: "application/zip" });
return URL.createObjectURL(zipBlob);
}
function prefetchEmailBundle(
client: IJMAPClient,
emails: Email[],
emailOptions: EmailFilenameOptions,
bundleOptions: EmailFilenameOptions,
): void {
const key = selectionKey(emails.map((e) => e.id));
if (currentBundle && currentBundle.key === key) return;
if (currentBundle?.url) {
const old = currentBundle.url;
setTimeout(() => URL.revokeObjectURL(old), 60_000);
}
const entry: BundleEntry = {
key,
name: bundleFilename(emails.length, bundleOptions),
url: null,
promise: null,
};
entry.promise = buildEmailZip(client, emails, emailOptions)
.then((url) => {
if (url && currentBundle === entry) entry.url = url;
return url;
})
.catch(() => null);
currentBundle = entry;
}
function getReadyBundle(emails: Email[]): { url: string; name: string } | null {
const key = selectionKey(emails.map((e) => e.id));
if (currentBundle && currentBundle.key === key && currentBundle.url) {
return { url: currentBundle.url, name: currentBundle.name };
}
return null;
}
export function useEmailDrag({ email, sourceMailboxId, threadEmails }: UseEmailDragOptions): UseEmailDragReturn {
const { selectedEmailIds, emails } = useEmailStore();
const { startDrag, endDrag, isDragging, draggedEmails } = useDragDropContext();
const isMobile = useUIStore((state) => state.isMobile);
const client = useAuthStore((state) => state.client);
const template = useSettingsStore((s) => s.emailDownloadTemplate) || DEFAULT_EMAIL_TEMPLATE;
const bundleTemplate = useSettingsStore((s) => s.bundleDownloadTemplate) || DEFAULT_BUNDLE_TEMPLATE;
const spaceReplacement = useSettingsStore((s) => s.filenameSpaceReplacement);
const lowercase = useSettingsStore((s) => s.filenameLowercase);
const stripDiacritics = useSettingsStore((s) => s.filenameStripDiacritics);
const collapseSeparators = useSettingsStore((s) => s.filenameCollapseSeparators);
const filenameOptions: EmailFilenameOptions = useMemo(
() => ({ template, spaceReplacement, lowercase, stripDiacritics, collapseSeparators }),
[template, spaceReplacement, lowercase, stripDiacritics, collapseSeparators],
);
const bundleOptions: EmailFilenameOptions = useMemo(
() => ({ template: bundleTemplate, spaceReplacement, lowercase, stripDiacritics, collapseSeparators }),
[bundleTemplate, spaceReplacement, lowercase, stripDiacritics, collapseSeparators],
);
const dragOutEnabled = !isMobile && isDragOutSupported() && !!client;
const singleBlobUrlRef = useRef<string | null>(null);
const inFlightRef = useRef<Promise<string | null> | null>(null);
useEffect(() => {
return () => {
if (singleBlobUrlRef.current) {
const url = singleBlobUrlRef.current;
singleBlobUrlRef.current = null;
// Defer revoke - Chromium asynchronously reads the blob: URL after the
// drop completes, so revoking immediately can race the OS.
setTimeout(() => URL.revokeObjectURL(url), 60_000);
}
inFlightRef.current = null;
};
}, [email.id]);
const prefetchSingle = useCallback(() => {
if (!dragOutEnabled || !client || !email.blobId) return;
if (singleBlobUrlRef.current || inFlightRef.current) return;
const name = emailExportFilename(email, filenameOptions);
inFlightRef.current = client
.fetchBlobAsObjectUrl(email.blobId, name, "message/rfc822")
.then((url) => {
if (url && !singleBlobUrlRef.current) singleBlobUrlRef.current = url;
return url;
})
.catch(() => null)
.finally(() => {
inFlightRef.current = null;
});
}, [dragOutEnabled, client, email, filenameOptions]);
const handlePointerEnter = useCallback(() => {
if (!dragOutEnabled || !client) return;
const isSelected = selectedEmailIds.has(email.id);
const isMulti = isSelected && selectedEmailIds.size > 1;
if (isMulti) {
const selected = emails.filter((em) => selectedEmailIds.has(em.id));
// Only worth bundling when at least one selected email has a blobId.
if (selected.some((em) => em.blobId)) {
prefetchEmailBundle(client, selected, filenameOptions, bundleOptions);
}
} else {
prefetchSingle();
}
}, [dragOutEnabled, client, selectedEmailIds, email.id, emails, prefetchSingle, filenameOptions, bundleOptions]);
const handleDragStart = useCallback((e: DragEvent<HTMLDivElement>) => {
// Determine which emails to drag:
@@ -59,8 +214,7 @@ export function useEmailDrag({ email, sourceMailboxId, threadEmails }: UseEmailD
? emails.filter(em => selectedEmailIds.has(em.id))
: threadEmails || [email];
// Set data transfer
e.dataTransfer.effectAllowed = "move";
e.dataTransfer.effectAllowed = "copyMove";
e.dataTransfer.setData(
"application/x-email-ids",
JSON.stringify(emailsToDrag.map(em => em.id))
@@ -70,6 +224,40 @@ export function useEmailDrag({ email, sourceMailboxId, threadEmails }: UseEmailD
emailsToDrag.map(em => em.subject || "(no subject)").join(", ")
);
// Drag-out to file explorer.
if (dragOutEnabled && client) {
if (emailsToDrag.length === 1 && emailsToDrag[0].blobId) {
const url = singleBlobUrlRef.current;
if (url) {
const name = emailExportFilename(emailsToDrag[0], filenameOptions);
// `DownloadURL` format: <mime>:<filename>:<url>. Chromium expects
// the filename raw - URL-encoding it ends up literally on disk
// (e.g. `%20` instead of a space). The sanitiser already removed
// `:` and other reserved chars, so embedding the name as-is is
// safe. Firefox/Safari ignore this entry entirely.
e.dataTransfer.setData(
"DownloadURL",
`message/rfc822:${name}:${url}`,
);
} else {
// Not warmed up yet — kick off so the next attempt works. Don't
// preventDefault: in-app drop still has to function.
prefetchSingle();
}
} else if (emailsToDrag.length > 1) {
const ready = getReadyBundle(emailsToDrag);
if (ready) {
e.dataTransfer.setData(
"DownloadURL",
`application/zip:${ready.name}:${ready.url}`,
);
} else {
// Kick off the bundle build for the next attempt.
prefetchEmailBundle(client, emailsToDrag, filenameOptions, bundleOptions);
}
}
}
// Create custom drag image
const dragPreview = createDragPreview(emailsToDrag.length);
e.dataTransfer.setDragImage(dragPreview, 0, 0);
@@ -80,10 +268,18 @@ export function useEmailDrag({ email, sourceMailboxId, threadEmails }: UseEmailD
});
startDrag(emailsToDrag, sourceMailboxId);
}, [email, selectedEmailIds, emails, sourceMailboxId, startDrag, threadEmails]);
}, [email, selectedEmailIds, emails, sourceMailboxId, startDrag, threadEmails, dragOutEnabled, client, prefetchSingle, filenameOptions, bundleOptions]);
const handleDragEnd = useCallback(() => {
endDrag();
// Defer-revoke the per-row single .eml URL. The shared bundle URL stays
// cached until the selection changes - revoking it here would break a
// subsequent drag of the same selection.
if (singleBlobUrlRef.current) {
const url = singleBlobUrlRef.current;
singleBlobUrlRef.current = null;
setTimeout(() => URL.revokeObjectURL(url), 60_000);
}
}, [endDrag]);
// Check if this specific email is being dragged
@@ -94,6 +290,7 @@ export function useEmailDrag({ email, sourceMailboxId, threadEmails }: UseEmailD
? { draggable: false, onDragStart: () => {}, onDragEnd: () => {} }
: {
draggable: true,
onPointerEnter: dragOutEnabled ? handlePointerEnter : undefined,
onDragStart: handleDragStart,
onDragEnd: handleDragEnd,
},
+3
View File
@@ -289,6 +289,9 @@ export const KEYBOARD_SHORTCUTS = {
{ key: "x", description: "shortcuts.threads.expand_collapse" },
],
composer: [
{ key: "Ctrl + Enter", description: "shortcuts.composer.send" },
{ key: "Ctrl + Shift + Enter", description: "shortcuts.composer.schedule_send" },
{ key: "t", description: "shortcuts.composer.template_picker" },
{ key: "Ctrl/Cmd + Enter", description: "shortcuts.composer.send" },
],
} as const;
+49 -20
View File
@@ -21,6 +21,27 @@ function resolveSourceAccountId(email: Email | undefined): string | null {
return useAuthStore.getState().activeAccountId;
}
/**
* Returns the local accountId ("user@host") that owns the destination
* mailbox. `mailbox.accountId` is the JMAP server's opaque account id, but
* `clients`, `activeAccountId`, and `email.accountId` all live in the local
* namespace. We map back by matching the JMAP id against each connected
* client's `getAccountId()`. Falls back to the viewing/active account so
* single-account flows (no connected clients map entry yet, in-memory edits,
* etc.) still resolve correctly.
*/
function resolveDestAccountId(mailbox: Mailbox): string | null {
const jmapId = mailbox.accountId;
if (jmapId) {
const clients = useAuthStore.getState().getAllConnectedClients();
for (const [localId, client] of clients) {
if (client.getAccountId() === jmapId) return localId;
}
}
return useEmailStore.getState().viewingAccountId
?? useAuthStore.getState().activeAccountId;
}
interface UseMailboxDropOptions {
mailbox: Mailbox;
onDropComplete?: () => void;
@@ -60,20 +81,8 @@ export function useMailboxDrop({ mailbox, onDropComplete, onSuccess, onError }:
// Virtual nodes (shared folder headers) cannot be drop targets
if (mailbox.id.startsWith("shared-")) return false;
// Shared (delegated) mailboxes still require the source to belong to the
// same delegating account. Real cross-account moves between primary
// accounts go through the cross-account path further down, but the
// shared-folder semantics here are about ACLs rather than transport, so
// they remain disallowed.
if (mailbox.isShared && draggedEmails[0]) {
const sourceMb = useEmailStore.getState().mailboxes.find(mb => mb.id === sourceMailboxId);
if (sourceMb?.accountId !== mailbox.accountId) {
return false;
}
}
return true;
}, [isDragging, mailbox, sourceMailboxId, draggedEmails]);
}, [isDragging, mailbox, sourceMailboxId]);
const handleDragOver = useCallback((e: DragEvent<HTMLDivElement>) => {
e.preventDefault();
@@ -123,7 +132,7 @@ export function useMailboxDrop({ mailbox, onDropComplete, onSuccess, onError }:
// Group dragged emails by source account. In single-account flows this
// collapses to one bucket; in unified view or the Pro multi-account
// sidebar a single drag can mix sources.
const destAccountId = mailbox.accountId;
const destAccountId = resolveDestAccountId(mailbox);
const idToEmail = new Map(draggedEmails.map((em) => [em.id, em]));
const bySource = new Map<string, string[]>();
for (const id of emailIds) {
@@ -133,18 +142,38 @@ export function useMailboxDrop({ mailbox, onDropComplete, onSuccess, onError }:
bySource.get(srcAccountId)!.push(id);
}
// Decide whether to route via the cross-account (blob copy + import)
// pipeline. Two cases require it:
// 1. Destination is a delegated/shared mailbox whose owner JMAP
// account differs from the source mailbox's JMAP account. There's
// no atomic Email/set across accounts, even via the same client.
// 2. Destination is a primary mailbox on a different connected local
// account than the source — the historical multi-account case.
const sourceMb = useEmailStore.getState().mailboxes.find(mb => mb.id === sourceMailboxId);
const sourceJmapAccountId = sourceMb?.accountId;
const destJmapAccountId = mailbox.accountId;
const sourceAccountIds = Array.from(bySource.keys());
const isCrossAccount =
!!destAccountId &&
const isJmapCrossAccount =
!!sourceJmapAccountId &&
!!destJmapAccountId &&
sourceJmapAccountId !== destJmapAccountId;
const isLocalCrossAccount =
!mailbox.isShared &&
!!destAccountId &&
sourceAccountIds.some((src) => src !== destAccountId);
const isCrossAccount = isJmapCrossAccount || isLocalCrossAccount;
if (isCrossAccount) {
// JMAP can't natively move an email between primary accounts, so the
// store reuploads each source blob into the destination account and
// then deletes the original.
if (!destAccountId) {
throw new Error('Could not resolve destination account');
}
// For a shared destination there is no separately-connected client
// for the owner; we reuse the viewing user's client but tell the
// import call to target the owner's JMAP account.
const jmapDestId = mailbox.originalId || mailbox.id;
await crossAccountMoveEmails(bySource, destAccountId, jmapDestId);
const destJmapOverride = mailbox.isShared ? mailbox.accountId : undefined;
await crossAccountMoveEmails(bySource, destAccountId, jmapDestId, destJmapOverride);
} else {
// Single-account or same-account-shared move: bulk JMAP request.
await moveEmailsToMailbox(client, emailIds, mailbox.id);
+14 -2
View File
@@ -12,9 +12,21 @@ const localePrefix = (process.env.NEXT_PUBLIC_LOCALE_PREFIX ?? 'never') as
| 'always'
| 'as-needed';
const SUPPORTED_LOCALES = ['cs', 'da', 'de', 'en', 'es', 'fr', '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
// match any supported locale (and no NEXT_LOCALE cookie is set yet). Admins
// set this via NEXT_PUBLIC_DEFAULT_LOCALE at build time to localise greenfield
// deployments without having every user change their preference manually.
const envDefaultLocale = process.env.NEXT_PUBLIC_DEFAULT_LOCALE?.trim();
const resolvedDefaultLocale =
envDefaultLocale && (SUPPORTED_LOCALES as readonly string[]).includes(envDefaultLocale)
? (envDefaultLocale as (typeof SUPPORTED_LOCALES)[number])
: 'en';
export const routing = defineRouting({
locales: ['cs', 'da', 'de', 'en', 'es', 'fr', 'it', 'ja', 'ko', 'lv', 'nl', 'pl', 'pt', 'ru', 'tr', 'uk', 'zh'],
defaultLocale: 'en',
locales: SUPPORTED_LOCALES,
defaultLocale: resolvedDefaultLocale,
localePrefix
});
+81 -2
View File
@@ -36,16 +36,29 @@ describe('config API route', () => {
delete process.env.LOGIN_IMPRINT_URL;
delete process.env.LOGIN_PRIVACY_POLICY_URL;
delete process.env.LOGIN_WEBSITE_URL;
delete process.env.DOMAIN_BRANDING;
});
afterEach(() => {
process.env = { ...originalEnv };
});
async function getConfig() {
function mockRequest(headers: Record<string, string> = {}): unknown {
const lc: Record<string, string> = {};
for (const [k, v] of Object.entries(headers)) lc[k.toLowerCase()] = v;
return {
headers: {
get(name: string) {
return lc[name.toLowerCase()] ?? null;
},
},
};
}
async function getConfig(headers?: Record<string, string>) {
// Re-import to pick up env changes
const { GET } = await import('@/app/api/config/route');
const response = await GET();
const response = await GET(mockRequest(headers) as Parameters<typeof GET>[0]);
return response.json();
}
@@ -192,4 +205,70 @@ describe('config API route', () => {
expect(config.appLogoLightUrl).toBe('/branding/my-logo.svg');
expect(config.appLogoDarkUrl).toBe('/branding/my-logo-white.svg');
});
describe('per-domain branding overrides', () => {
it('applies overrides for the matching host', async () => {
process.env.LOGIN_COMPANY_NAME = 'Default Co';
process.env.LOGIN_WEBSITE_URL = 'https://default.example';
process.env.DOMAIN_BRANDING = JSON.stringify([
{
host: 'mail1.example.com',
loginCompanyName: 'Brand One',
loginWebsiteUrl: 'https://one.example',
},
]);
const config = await getConfig({ host: 'mail1.example.com' });
expect(config.loginCompanyName).toBe('Brand One');
expect(config.loginWebsiteUrl).toBe('https://one.example');
});
it('falls through to the global value when the host has no entry', async () => {
process.env.LOGIN_COMPANY_NAME = 'Default Co';
process.env.DOMAIN_BRANDING = JSON.stringify([
{ host: 'mail1.example.com', loginCompanyName: 'Brand One' },
]);
const config = await getConfig({ host: 'unmapped.example.com' });
expect(config.loginCompanyName).toBe('Default Co');
});
it('falls through field-by-field when the matching entry omits a field', async () => {
process.env.LOGIN_COMPANY_NAME = 'Default Co';
process.env.LOGIN_WEBSITE_URL = 'https://default.example';
process.env.DOMAIN_BRANDING = JSON.stringify([
{ host: 'mail1.example.com', loginCompanyName: 'Brand One' },
]);
const config = await getConfig({ host: 'mail1.example.com' });
expect(config.loginCompanyName).toBe('Brand One');
expect(config.loginWebsiteUrl).toBe('https://default.example');
});
it('prefers X-Forwarded-Host over Host', async () => {
process.env.DOMAIN_BRANDING = JSON.stringify([
{ host: 'public.example.com', loginCompanyName: 'Public' },
]);
const config = await getConfig({
host: 'internal.example.com',
'x-forwarded-host': 'public.example.com',
});
expect(config.loginCompanyName).toBe('Public');
});
it('strips the port from the host header before matching', async () => {
process.env.DOMAIN_BRANDING = JSON.stringify([
{ host: 'mail1.example.com', loginCompanyName: 'Brand One' },
]);
const config = await getConfig({ host: 'mail1.example.com:8443' });
expect(config.loginCompanyName).toBe('Brand One');
});
});
});
+151
View File
@@ -0,0 +1,151 @@
import { describe, it, expect } from 'vitest';
import {
matchDomainBranding,
parseDomainBranding,
pickRequestHost,
type DomainBrandingEntry,
} from '@/lib/admin/domain-branding';
function mockHeaders(map: Record<string, string>): Headers {
const lc: Record<string, string> = {};
for (const [k, v] of Object.entries(map)) lc[k.toLowerCase()] = v;
return {
get(name: string) {
return lc[name.toLowerCase()] ?? null;
},
} as unknown as Headers;
}
describe('parseDomainBranding', () => {
it('returns [] for null/undefined/empty', () => {
expect(parseDomainBranding(null)).toEqual([]);
expect(parseDomainBranding(undefined)).toEqual([]);
expect(parseDomainBranding('')).toEqual([]);
expect(parseDomainBranding([])).toEqual([]);
});
it('parses a stringified JSON array', () => {
const raw = JSON.stringify([{ host: 'mail.example.com', loginCompanyName: 'Acme' }]);
expect(parseDomainBranding(raw)).toEqual([
{ host: 'mail.example.com', loginCompanyName: 'Acme' },
]);
});
it('lower-cases hosts and strips trailing dots', () => {
expect(parseDomainBranding([{ host: 'Mail.Example.COM.' }])).toEqual([
{ host: 'mail.example.com' },
]);
});
it('accepts wildcard hosts', () => {
expect(parseDomainBranding([{ host: '*.example.com', loginCompanyName: 'Wild' }])).toEqual([
{ host: '*.example.com', loginCompanyName: 'Wild' },
]);
});
it('drops entries with invalid hosts', () => {
const out = parseDomainBranding([
{ host: '' },
{ host: 'has space.com' },
{ host: 'foo..bar' },
{ host: '*.*.example.com' }, // embedded wildcard not allowed
{ host: 'good.example.com' },
]);
expect(out.map(e => e.host)).toEqual(['good.example.com']);
});
it('drops duplicate hosts, keeping the first', () => {
const out = parseDomainBranding([
{ host: 'foo.com', loginCompanyName: 'First' },
{ host: 'FOO.com', loginCompanyName: 'Second' },
]);
expect(out).toEqual([{ host: 'foo.com', loginCompanyName: 'First' }]);
});
it('ignores non-string and empty-string override fields', () => {
const out = parseDomainBranding([
{
host: 'foo.com',
loginCompanyName: '',
loginImprintUrl: 42,
loginWebsiteUrl: 'https://foo.com',
},
]);
expect(out).toEqual([{ host: 'foo.com', loginWebsiteUrl: 'https://foo.com' }]);
});
it('ignores unknown fields', () => {
const out = parseDomainBranding([
{ host: 'foo.com', notARealField: 'x', loginCompanyName: 'OK' },
]);
expect(out).toEqual([{ host: 'foo.com', loginCompanyName: 'OK' }]);
});
});
describe('pickRequestHost', () => {
it('returns null when no host headers are set', () => {
expect(pickRequestHost(mockHeaders({}))).toBeNull();
});
it('prefers X-Forwarded-Host over Host', () => {
expect(pickRequestHost(mockHeaders({
'x-forwarded-host': 'forwarded.example.com',
host: 'origin.example.com',
}))).toBe('forwarded.example.com');
});
it('falls back to Host when X-Forwarded-Host is absent', () => {
expect(pickRequestHost(mockHeaders({ host: 'origin.example.com' }))).toBe('origin.example.com');
});
it('strips the port', () => {
expect(pickRequestHost(mockHeaders({ host: 'example.com:8080' }))).toBe('example.com');
});
it('takes the first entry of a comma-separated X-Forwarded-Host', () => {
expect(pickRequestHost(mockHeaders({
'x-forwarded-host': 'first.example.com, second.example.com',
}))).toBe('first.example.com');
});
it('lower-cases the result', () => {
expect(pickRequestHost(mockHeaders({ host: 'EXAMPLE.com' }))).toBe('example.com');
});
});
describe('matchDomainBranding', () => {
const entries: DomainBrandingEntry[] = [
{ host: 'mail.example.com', loginCompanyName: 'Exact' },
{ host: '*.example.com', loginCompanyName: 'Wildcard' },
{ host: '*.dev.example.com', loginCompanyName: 'Specific Wildcard' },
{ host: 'other.com', loginCompanyName: 'Other' },
];
it('returns {} when host is null', () => {
expect(matchDomainBranding(null, entries)).toEqual({});
});
it('returns {} when no entries match', () => {
expect(matchDomainBranding('unknown.org', entries)).toEqual({});
});
it('prefers exact match over wildcard', () => {
expect(matchDomainBranding('mail.example.com', entries).loginCompanyName).toBe('Exact');
});
it('matches wildcards on subdomains', () => {
expect(matchDomainBranding('foo.example.com', entries).loginCompanyName).toBe('Wildcard');
});
it('prefers the longest wildcard suffix', () => {
expect(matchDomainBranding('app.dev.example.com', entries).loginCompanyName).toBe('Specific Wildcard');
});
it('does not match the wildcard host against the apex domain', () => {
expect(matchDomainBranding('example.com', entries)).toEqual({});
});
it('is case-insensitive on the request host', () => {
expect(matchDomainBranding('Mail.Example.COM', entries).loginCompanyName).toBe('Exact');
});
});
+89 -1
View File
@@ -1,5 +1,10 @@
import { describe, expect, it } from "vitest";
import { plainTextToComposerBody } from "../email-composer-utils";
import {
plainTextToComposerBody,
rewriteCidImagesForEditor,
replaceInlineImagePlaceholders,
INLINE_IMAGE_PLACEHOLDER,
} from "../email-composer-utils";
describe("plainTextToComposerBody", () => {
it("returns an empty string for empty input", () => {
@@ -24,3 +29,86 @@ describe("plainTextToComposerBody", () => {
);
});
});
describe("rewriteCidImagesForEditor", () => {
it("returns input unchanged when no cid: refs are present", () => {
const html = '<p>hi</p><img src="https://example.com/x.png">';
expect(rewriteCidImagesForEditor(html)).toBe(html);
});
it("handles empty input", () => {
expect(rewriteCidImagesForEditor("")).toBe("");
});
it("rewrites a cid: src to placeholder + data-cid", () => {
const out = rewriteCidImagesForEditor(
'<img src="cid:abc@x" alt="logo">'
);
expect(out).toContain('data-cid="abc@x"');
expect(out).toContain(`src="${INLINE_IMAGE_PLACEHOLDER}"`);
expect(out).toContain('alt="logo"');
expect(out).not.toContain('src="cid:');
});
it("preserves an existing data-cid attribute", () => {
const out = rewriteCidImagesForEditor(
'<img src="cid:abc" data-cid="kept">'
);
expect(out).toContain('data-cid="kept"');
expect(out).not.toContain('data-cid="abc"');
});
it("leaves non-cid images alone", () => {
const out = rewriteCidImagesForEditor(
'<img src="https://example.com/x.png"><img src="cid:y">'
);
expect(out).toContain('src="https://example.com/x.png"');
expect(out).toContain('data-cid="y"');
});
});
describe("replaceInlineImagePlaceholders", () => {
it("returns input unchanged when the map is empty", () => {
const html = '<img src="..." data-cid="x">';
expect(replaceInlineImagePlaceholders(html, new Map())).toBe(html);
});
it("swaps the placeholder src to the data URL for matching cids", () => {
const html = `<img src="${INLINE_IMAGE_PLACEHOLDER}" data-cid="abc">`;
const out = replaceInlineImagePlaceholders(
html,
new Map([["abc", "data:image/png;base64,AAAA"]])
);
expect(out).toContain('src="data:image/png;base64,AAAA"');
expect(out).toContain('data-cid="abc"');
});
it("also rewrites raw cid: src refs that lack a placeholder", () => {
const html = '<img src="cid:abc" data-cid="abc">';
const out = replaceInlineImagePlaceholders(
html,
new Map([["abc", "data:image/png;base64,AAAA"]])
);
expect(out).toContain('src="data:image/png;base64,AAAA"');
});
it("does not overwrite images the user has re-pointed away from the cid", () => {
const html =
'<img src="https://example.com/other.png" data-cid="abc">';
const out = replaceInlineImagePlaceholders(
html,
new Map([["abc", "data:image/png;base64,AAAA"]])
);
expect(out).toContain('src="https://example.com/other.png"');
expect(out).not.toContain("data:image/png;base64,AAAA");
});
it("leaves unknown cids untouched", () => {
const html = `<img src="${INLINE_IMAGE_PLACEHOLDER}" data-cid="missing">`;
const out = replaceInlineImagePlaceholders(
html,
new Map([["abc", "data:image/png;base64,AAAA"]])
);
expect(out).toBe(html);
});
});
+97 -1
View File
@@ -11,6 +11,34 @@ function createClient(): JMAPClient {
return client;
}
function enableDelayedSend(client: JMAPClient) {
Object.assign(client, {
capabilities: {
'urn:ietf:params:jmap:core': {},
'urn:ietf:params:jmap:mail': {},
'urn:ietf:params:jmap:submission': {},
},
session: {
primaryAccounts: {
'urn:ietf:params:jmap:mail': 'account-1',
'urn:ietf:params:jmap:submission': 'submission-account-1',
},
accounts: {
'account-1': {
accountCapabilities: {
'urn:ietf:params:jmap:mail': {},
},
},
'submission-account-1': {
accountCapabilities: {
'urn:ietf:params:jmap:submission': { maxDelayedSend: 3600, submissionExtensions: { FUTURERELEASE: true } },
},
},
},
},
});
}
interface JMAPMethodCall {
0: string;
1: Record<string, unknown>;
@@ -62,7 +90,7 @@ function mockSendEmailFlow() {
payload = {
methodResponses: [
['Email/set', { created: { [Object.keys((captured[callIdx].methodCalls[0][1] as { create: Record<string, unknown> }).create)[0]]: { id: 'sent-id-1' } } }, '0'],
['EmailSubmission/set', { created: { '1': { id: 'sub-1' } } }, '1'],
['EmailSubmission/set', { created: { '1': { id: 'sub-1', sendAt: '2026-05-08T18:00:00Z' } } }, '1'],
],
};
}
@@ -167,4 +195,72 @@ describe('JMAPClient.sendEmail threading headers', () => {
expect(draft.inReplyTo).toEqual(['real@example.com']);
expect(draft.references).toBeUndefined();
});
it('uses FUTURERELEASE envelope and submission capability for scheduled sends', async () => {
const client = createClient();
enableDelayedSend(client);
const captured = mockSendEmailFlow();
const delayedUntil = new Date(Date.now() + 60_000).toISOString();
const result = await client.sendEmail(
['recipient@example.com'],
'Scheduled test',
'body',
undefined, undefined, 'identity-1', 'user@example.com',
undefined, undefined, undefined, undefined,
undefined,
undefined,
delayedUntil,
);
const identityRequest = captured[1];
expect(identityRequest.using).toContain('urn:ietf:params:jmap:submission');
const submissionCall = captured[2].methodCalls.find(call => call[0] === 'EmailSubmission/set');
expect(submissionCall?.[1].accountId).toBe('submission-account-1');
expect(submissionCall?.[1].create).toEqual({
'1': {
emailId: expect.stringMatching(/^#send-/),
identityId: 'identity-1',
envelope: {
mailFrom: {
email: 'user@example.com',
parameters: { HOLDFOR: expect.stringMatching(/^\d+$/) },
},
rcptTo: [{ email: 'recipient@example.com' }],
},
},
});
expect(JSON.stringify(submissionCall?.[1].create)).not.toContain('sendAt');
expect(result).toMatchObject({ scheduled: true, emailSubmissionId: 'sub-1', sendAt: '2026-05-08T18:00:00Z' });
});
it('cleans up replacement submission if canceling the original fails during reschedule', async () => {
const client = createClient();
enableDelayedSend(client);
vi.spyOn(client, 'getMailboxes').mockResolvedValue([
{ id: 'mb-drafts', name: 'Drafts', role: 'drafts' },
{ id: 'mb-sent', name: 'Sent', role: 'sent' },
] as never);
vi.spyOn(client, 'getIdentities').mockResolvedValue([
{ id: 'identity-1', name: 'User', email: 'user@example.com', mayDelete: false },
]);
const requestSpy = vi.spyOn(client as unknown as { request: JMAPClient['request'] }, 'request')
.mockImplementation(async (methodCalls) => {
const args = methodCalls[0][1] as { create?: unknown; update?: Record<string, unknown> };
if (args.create) {
return { methodResponses: [['EmailSubmission/set', { created: { replacement: { id: 'sub-new' } } }, '0']] };
}
if (args.update?.['sub-old']) {
return { methodResponses: [['EmailSubmission/set', { notUpdated: { 'sub-old': { type: 'cannotUnsend' } } }, '0']] };
}
return { methodResponses: [['EmailSubmission/set', { updated: { 'sub-new': null } }, '0']] };
});
await expect(client.rescheduleEmailSubmission('sub-old', 'email-1', 'identity-1', new Date(Date.now() + 60_000).toISOString()))
.rejects.toThrow('could not cancel the original');
expect(requestSpy).toHaveBeenCalledWith(expect.arrayContaining([
expect.arrayContaining(['EmailSubmission/set', expect.objectContaining({ update: { 'sub-new': { undoStatus: 'canceled' } } })]),
]));
});
});
+25 -8
View File
@@ -33,7 +33,8 @@ function buildMailboxPathMap(tree: MailboxNode[]): Map<string, string> {
const pathMap = new Map<string, string>();
const walk = (nodes: MailboxNode[], parentPath = '') => {
for (const node of nodes) {
const fullPath = parentPath ? `${parentPath}/${node.name}` : node.name;
const segment = node.role === 'inbox' ? 'INBOX' : node.name;
const fullPath = parentPath ? `${parentPath}/${segment}` : segment;
pathMap.set(node.id, fullPath);
if (node.children.length > 0) walk(node.children, fullPath);
}
@@ -47,7 +48,7 @@ describe('mailbox path building for sieve fileinto', () => {
const mailboxes = [makeMailbox({ id: 'inbox', name: 'Inbox', role: 'inbox' })];
const tree = buildMailboxTree(mailboxes);
const paths = buildMailboxPathMap(tree);
expect(paths.get('inbox')).toBe('Inbox');
expect(paths.get('inbox')).toBe('INBOX');
});
it('should produce correct path for a single-level subfolder', () => {
@@ -57,7 +58,7 @@ describe('mailbox path building for sieve fileinto', () => {
];
const tree = buildMailboxTree(mailboxes);
const paths = buildMailboxPathMap(tree);
expect(paths.get('sub1')).toBe('Inbox/Projects');
expect(paths.get('sub1')).toBe('INBOX/Projects');
});
it('should produce correct path for deeply nested subfolders', () => {
@@ -68,7 +69,7 @@ describe('mailbox path building for sieve fileinto', () => {
];
const tree = buildMailboxTree(mailboxes);
const paths = buildMailboxPathMap(tree);
expect(paths.get('sub2')).toBe('Inbox/Test/Test2');
expect(paths.get('sub2')).toBe('INBOX/Test/Test2');
});
it('should handle multiple root-level folders', () => {
@@ -79,7 +80,7 @@ describe('mailbox path building for sieve fileinto', () => {
];
const tree = buildMailboxTree(mailboxes);
const paths = buildMailboxPathMap(tree);
expect(paths.get('inbox')).toBe('Inbox');
expect(paths.get('inbox')).toBe('INBOX');
expect(paths.get('archive')).toBe('Archive');
expect(paths.get('sub1')).toBe('Archive/Work');
});
@@ -99,9 +100,25 @@ describe('mailbox path building for sieve fileinto', () => {
expect(paths.has(node.id)).toBe(true);
}
expect(paths.get('inbox')).toBe('Inbox');
expect(paths.get('sub1')).toBe('Inbox/Projects');
expect(paths.get('sub2')).toBe('Inbox/Projects/Active');
expect(paths.get('inbox')).toBe('INBOX');
expect(paths.get('sub1')).toBe('INBOX/Projects');
expect(paths.get('sub2')).toBe('INBOX/Projects/Active');
});
it('uses canonical INBOX even when JMAP returns a localized inbox name', () => {
// Stalwart returns localized display names for the inbox based on the
// user's locale (e.g. "Entrada" for pt-BR). Sieve fileinto must still
// target the IMAP-canonical "INBOX" so the message is filed correctly.
const mailboxes = [
makeMailbox({ id: 'inbox', name: 'Entrada', role: 'inbox' }),
makeMailbox({ id: 'host', name: 'Host', parentId: 'inbox' }),
makeMailbox({ id: 'eveo', name: 'EVEO', parentId: 'host' }),
];
const tree = buildMailboxTree(mailboxes);
const paths = buildMailboxPathMap(tree);
expect(paths.get('inbox')).toBe('INBOX');
expect(paths.get('host')).toBe('INBOX/Host');
expect(paths.get('eveo')).toBe('INBOX/Host/EVEO');
});
it('should preserve depth info in flattened tree', () => {
+17
View File
@@ -146,6 +146,23 @@ describe('oauth/discovery', () => {
expect(consoleSpy).toHaveBeenCalled();
});
it('accepts private/loopback endpoints when validateEndpoint is omitted (admin opted in)', async () => {
// Split-DNS deployments: mail.example.com resolves to an RFC-1918 address
// locally. With the SSRF validator off, discovery must succeed.
vi.stubGlobal('fetch', vi.fn().mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve({
issuer: 'https://mail.example.com',
authorization_endpoint: 'http://10.0.0.5/authorize',
token_endpoint: 'http://10.0.0.5/token',
}),
}));
const result = await discoverOAuth('https://mail.example.com');
expect(result?.token_endpoint).toBe('http://10.0.0.5/token');
});
it('caches results - second call for same server URL does not re-fetch', async () => {
vi.stubGlobal('fetch', vi.fn().mockResolvedValueOnce({
ok: true,
+144
View File
@@ -0,0 +1,144 @@
/**
* Per-domain branding overrides: schema, parsing, host extraction, and match.
*
* The webmail can be served on multiple hostnames (e.g. mail1.example.com,
* mail2.other.com). Each hostname can override a subset of branding fields;
* unset fields fall back to the global admin/env/default value.
*/
import type { NextRequest } from 'next/server';
/** Config keys that can be overridden per domain. */
export const BRANDING_OVERRIDE_KEYS = [
'appName',
'appShortName',
'appDescription',
'faviconUrl',
'pwaIconUrl',
'pwaThemeColor',
'pwaBackgroundColor',
'appLogoLightUrl',
'appLogoDarkUrl',
'loginLogoLightUrl',
'loginLogoDarkUrl',
'loginCompanyName',
'loginImprintUrl',
'loginPrivacyPolicyUrl',
'loginWebsiteUrl',
] as const;
export type BrandingOverrideKey = (typeof BRANDING_OVERRIDE_KEYS)[number];
export interface DomainBrandingEntry {
/**
* Hostname this entry applies to. Either an exact host like
* "mail.example.com" or a wildcard like "*.example.com" (matches any
* direct or deeper subdomain). Case-insensitive; trailing dots are
* stripped on parse.
*/
host: string;
appName?: string;
appShortName?: string;
appDescription?: string;
faviconUrl?: string;
pwaIconUrl?: string;
pwaThemeColor?: string;
pwaBackgroundColor?: string;
appLogoLightUrl?: string;
appLogoDarkUrl?: string;
loginLogoLightUrl?: string;
loginLogoDarkUrl?: string;
loginCompanyName?: string;
loginImprintUrl?: string;
loginPrivacyPolicyUrl?: string;
loginWebsiteUrl?: string;
}
// Accepts plain hostnames (foo, foo.bar, foo.bar.baz) and one-level wildcards
// at the leftmost label (*.example.com). Rejects IPs, scheme/path/userinfo,
// and embedded wildcards.
const HOST_RE = /^(\*\.)?[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)*$/;
function normalizeHost(host: string): string {
return host.trim().toLowerCase().replace(/\.+$/, '');
}
/** Parse the raw config value (array of entries, or string-JSON). Invalid entries are dropped. */
export function parseDomainBranding(raw: unknown): DomainBrandingEntry[] {
if (!raw) return [];
let value = raw;
if (typeof value === 'string') {
if (!value.trim()) return [];
try {
value = JSON.parse(value);
} catch {
return [];
}
}
if (!Array.isArray(value)) return [];
const seen = new Set<string>();
const out: DomainBrandingEntry[] = [];
for (const item of value) {
if (!item || typeof item !== 'object') continue;
const rec = item as Record<string, unknown>;
const rawHost = typeof rec.host === 'string' ? rec.host : '';
const host = normalizeHost(rawHost);
if (!host || !HOST_RE.test(host)) continue;
if (seen.has(host)) continue;
seen.add(host);
const entry: DomainBrandingEntry = { host };
const writable = entry as unknown as Record<string, string>;
for (const key of BRANDING_OVERRIDE_KEYS) {
const v = rec[key];
if (typeof v === 'string' && v.length > 0) {
writable[key] = v;
}
}
out.push(entry);
}
return out;
}
type HeadersLike = Headers | { get(name: string): string | null };
/**
* Pick the request's host, preferring X-Forwarded-Host (first entry if
* comma-separated) over Host. Strips the port. Returns null when no usable
* host header is set.
*/
export function pickRequestHost(headersOrReq: NextRequest | HeadersLike): string | null {
const headers: HeadersLike = 'headers' in headersOrReq ? (headersOrReq as NextRequest).headers : headersOrReq;
const raw = headers.get('x-forwarded-host') || headers.get('host');
if (!raw) return null;
const first = raw.split(',')[0]?.trim();
if (!first) return null;
return normalizeHost(first.split(':')[0]);
}
/**
* Find the entry whose host matches `host`. Exact match always wins; among
* wildcards the longest (most-specific) suffix wins. Returns {} when no
* entry matches.
*/
export function matchDomainBranding(
host: string | null,
entries: DomainBrandingEntry[],
): Partial<DomainBrandingEntry> {
if (!host || entries.length === 0) return {};
const lower = normalizeHost(host);
let wildcardMatch: DomainBrandingEntry | undefined;
for (const entry of entries) {
if (entry.host === lower) return entry;
if (entry.host.startsWith('*.')) {
const suffix = entry.host.slice(1); // ".example.com"
if (lower.endsWith(suffix) && lower.length > suffix.length) {
if (!wildcardMatch || entry.host.length > wildcardMatch.host.length) {
wildcardMatch = entry;
}
}
}
}
return wildcardMatch ?? {};
}
+8
View File
@@ -106,6 +106,10 @@ export interface SettingsPolicy {
approvedPlugins: string[];
/** Theme IDs that are force-enabled (users cannot deactivate) */
forceEnabledThemes: string[];
/** Web Push relay base URL shown to users. Empty means the built-in default. */
pushRelayUrl?: string;
/** When true, users cannot change pushRelayUrl in notification settings. */
pushRelayUrlLocked?: boolean;
}
export const DEFAULT_POLICY: SettingsPolicy = {
@@ -116,6 +120,8 @@ export const DEFAULT_POLICY: SettingsPolicy = {
forceEnabledPlugins: [],
approvedPlugins: [],
forceEnabledThemes: [],
pushRelayUrl: '',
pushRelayUrlLocked: false,
};
export interface AuditEntry {
@@ -153,9 +159,11 @@ export const CONFIG_ENV_MAP: Record<string, { envVar: string; fileEnvVar?: strin
oauthIssuerUrl: { envVar: 'OAUTH_ISSUER_URL', type: 'url', defaultValue: '' },
oauthScopes: { envVar: 'OAUTH_SCOPES', type: 'string', defaultValue: '' },
oauthExtraScopes: { envVar: 'OAUTH_EXTRA_SCOPES', type: 'string', defaultValue: '' },
oauthAllowPrivateEndpoints: { envVar: 'OAUTH_ALLOW_PRIVATE_ENDPOINTS', type: 'boolean', defaultValue: false },
allowCustomJmapEndpoint: { envVar: 'ALLOW_CUSTOM_JMAP_ENDPOINT', type: 'boolean', defaultValue: false },
jmapServers: { envVar: 'JMAP_SERVERS', type: 'json', defaultValue: [] },
jmapServerAutoPickByDomain: { envVar: 'JMAP_SERVER_AUTO_PICK_BY_DOMAIN', type: 'boolean', defaultValue: false },
domainBranding: { envVar: 'DOMAIN_BRANDING', type: 'json', defaultValue: [] },
autoSsoEnabled: { envVar: 'AUTO_SSO_ENABLED', type: 'boolean', defaultValue: false },
cookieSameSite: { envVar: 'COOKIE_SAME_SITE', type: 'enum', defaultValue: 'lax', enumValues: ['lax', 'strict', 'none'] },
allowedFrameAncestors: { envVar: 'ALLOWED_FRAME_ANCESTORS', type: 'string', defaultValue: '' },
+20
View File
@@ -75,6 +75,26 @@ export function apiFetch(input: string, init?: RequestInit): Promise<Response> {
return fetch(input, init);
}
/**
* Mount-prefix-aware wrapper for URL strings used in `<img src>`, `<link href>`,
* `window.location.*`, etc. anything the browser resolves itself, where
* `apiFetch` can't help.
*
* Idempotent: passing an already-prefixed value, an external URL, a
* protocol-relative URL, or an empty/falsy value returns it unchanged. So it's
* safe to wrap admin-configurable values that might be either a local path
* (`/branding/foo.svg`, `/api/admin/branding/...`) or a full URL.
*/
export function withBasePath(url: string | null | undefined): string {
if (!url) return url ?? '';
if (url.charCodeAt(0) !== 47) return url; // not absolute (e.g. https://, data:, blob:)
if (url.charCodeAt(1) === 47) return url; // protocol-relative //cdn...
const prefix = getPathPrefix();
if (!prefix) return url;
if (url === prefix || url.startsWith(prefix + '/')) return url;
return prefix + url;
}
/**
* Extracts the locale from the current URL, skipping any mount prefix.
+10 -3
View File
@@ -24,8 +24,14 @@ export interface TimedEventLayout {
export function getEventStartDate(
event: Pick<CalendarEvent, 'start' | 'utcStart' | 'showWithoutTime'>,
): Date {
const source = !event.showWithoutTime && event.utcStart ? event.utcStart : event.start;
return parseISO(source);
// Prefer utcStart for timed events but fall back to start if utcStart is
// missing or unparseable - a malformed utcStart used to surface as an
// Invalid Date that crashed downstream format() calls (#316).
if (!event.showWithoutTime && event.utcStart) {
const utc = parseISO(event.utcStart);
if (!isNaN(utc.getTime())) return utc;
}
return parseISO(event.start);
}
export function packWeekSegments(rawSegments: CalendarWeekSegment[]): CalendarWeekSegment[] {
@@ -56,7 +62,8 @@ export function packWeekSegments(rawSegments: CalendarWeekSegment[]): CalendarWe
export function getEventEndDate(event: CalendarEvent): Date {
if (!event.showWithoutTime && event.utcEnd) {
return parseISO(event.utcEnd);
const utc = parseISO(event.utcEnd);
if (!isNaN(utc.getTime())) return utc;
}
const start = getEventStartDate(event);
+119 -14
View File
@@ -1,5 +1,5 @@
import type { IJMAPClient } from '@/lib/jmap/client-interface';
import type { Email, Mailbox, StateChange, AccountStates, Thread, Identity, EmailAddress, ContactCard, AddressBook, VacationResponse, Calendar, CalendarEvent, CalendarEventFilter, CalendarTask, FileNode } from '@/lib/jmap/types';
import type { Email, Mailbox, StateChange, AccountStates, Thread, Identity, EmailAddress, ContactCard, AddressBook, VacationResponse, Calendar, CalendarEvent, CalendarEventFilter, CalendarTask, FileNode, ScheduledEmail, SendEmailResult } from '@/lib/jmap/types';
import type { SieveScript, SieveCapabilities } from '@/lib/jmap/sieve-types';
import { getDemoData, type DemoData } from './demo-data';
import { generateDemoId } from './demo-utils';
@@ -11,6 +11,7 @@ import { generateDemoId } from './demo-utils';
export class DemoJMAPClient implements IJMAPClient {
private data: DemoData;
private blobStore = new Map<string, Blob>();
private scheduledSubmissions = new Map<string, { id: string; emailId: string; identityId: string; sendAt: string; undoStatus: 'pending' | 'final' | 'canceled'; isSmime: boolean }>();
private connectionCallback: ((connected: boolean) => void) | null = null;
private stateChangeCallback: ((change: StateChange) => void) | null = null;
private lastStates: AccountStates = {};
@@ -49,15 +50,15 @@ export class DemoJMAPClient implements IJMAPClient {
// ── Capabilities ──────────────────────────────────────────────
hasAccountCapability(_capability: string, _accountId?: string): boolean {
return false;
hasAccountCapability(capability: string, _accountId?: string): boolean {
return capability === 'urn:ietf:params:jmap:submission';
}
getCapabilities(): Record<string, unknown> {
return {
'urn:ietf:params:jmap:core': { maxSizeUpload: 50_000_000, maxCallsInRequest: 16, maxObjectsInGet: 500 },
'urn:ietf:params:jmap:mail': {},
'urn:ietf:params:jmap:submission': {},
'urn:ietf:params:jmap:submission': { maxDelayedSend: 30 * 24 * 60 * 60, submissionExtensions: { FUTURERELEASE: true } },
'urn:ietf:params:jmap:vacationresponse': {},
'urn:ietf:params:jmap:contacts': {},
'urn:ietf:params:jmap:calendars': {},
@@ -70,6 +71,8 @@ export class DemoJMAPClient implements IJMAPClient {
getMaxSizeUpload(): number { return 50_000_000; }
getMaxCallsInRequest(): number { return 16; }
getMaxObjectsInGet(): number { return 500; }
getMaxDelayedSend(): number { return 30 * 24 * 60 * 60; }
hasDelayedSend(): boolean { return true; }
getEventSourceUrl(): string | null { return null; }
supportsEmailSubmission(): boolean { return true; }
supportsQuota(): boolean { return true; }
@@ -264,10 +267,11 @@ export class DemoJMAPClient implements IJMAPClient {
this.recalcMailboxCounts();
}
async moveToTrash(emailId: string, trashMailboxId: string): Promise<void> {
async moveToTrash(emailId: string, trashMailboxId: string, _accountId?: string, markAsRead?: boolean): Promise<void> {
const email = this.data.emails.find(e => e.id === emailId);
if (!email) return;
email.mailboxIds = { [trashMailboxId]: true };
if (markAsRead) email.keywords.$seen = true;
this.recalcMailboxCounts();
}
@@ -277,10 +281,13 @@ export class DemoJMAPClient implements IJMAPClient {
this.recalcMailboxCounts();
}
async batchMoveEmails(emailIds: string[], toMailboxId: string): Promise<void> {
async batchMoveEmails(emailIds: string[], toMailboxId: string, _accountId?: string, markAsRead?: boolean): Promise<void> {
for (const id of emailIds) {
const email = this.data.emails.find(e => e.id === id);
if (email) email.mailboxIds = { [toMailboxId]: true };
if (email) {
email.mailboxIds = { [toMailboxId]: true };
if (markAsRead) email.keywords.$seen = true;
}
}
this.recalcMailboxCounts();
}
@@ -289,6 +296,8 @@ export class DemoJMAPClient implements IJMAPClient {
emails: Array<{ id: string; receivedAt: string }>,
archiveMailboxId: string,
mode: 'single' | 'year' | 'month',
_existingMailboxes: Mailbox[],
_accountId?: string,
): Promise<void> {
if (emails.length === 0) return;
if (mode === 'single') {
@@ -355,10 +364,13 @@ export class DemoJMAPClient implements IJMAPClient {
return count;
}
async markAsSpam(emailId: string): Promise<void> {
async markAsSpam(emailId: string, _accountId?: string, markAsRead?: boolean): Promise<void> {
const email = this.data.emails.find(e => e.id === emailId);
const junkMb = this.data.mailboxes.find(m => m.role === 'junk');
if (email && junkMb) email.mailboxIds = { [junkMb.id]: true };
if (email && junkMb) {
email.mailboxIds = { [junkMb.id]: true };
if (markAsRead) email.keywords.$seen = true;
}
this.recalcMailboxCounts();
}
@@ -449,7 +461,9 @@ export class DemoJMAPClient implements IJMAPClient {
attachments?: Array<{ blobId: string; name: string; type: string; size: number; disposition?: 'attachment' | 'inline'; cid?: string }>,
inReplyTo?: string[],
references?: string[],
): Promise<void> {
delayedUntil?: string,
_envelopeMailFrom?: string,
): Promise<SendEmailResult> {
// Remove draft if updating
if (draftId) {
this.data.emails = this.data.emails.filter(e => e.id !== draftId);
@@ -457,8 +471,8 @@ export class DemoJMAPClient implements IJMAPClient {
const sentMb = this.data.mailboxes.find(m => m.role === 'sent');
const email: Email = {
id: generateDemoId('email'), threadId: generateDemoId('thread'),
mailboxIds: { [sentMb?.id || 'demo-mailbox-sent']: true },
keywords: { $seen: true },
mailboxIds: { [delayedUntil ? (this.data.mailboxes.find(m => m.role === 'drafts')?.id || 'demo-mailbox-drafts') : (sentMb?.id || 'demo-mailbox-sent')]: true },
keywords: delayedUntil ? { $seen: true, $draft: true } : { $seen: true },
size: body.length + (htmlBody?.length || 0),
receivedAt: new Date().toISOString(),
from: [{ name: 'Demo User', email: 'demo@example.com' }],
@@ -478,7 +492,22 @@ export class DemoJMAPClient implements IJMAPClient {
references: references?.length ? references : undefined,
};
this.data.emails.push(email);
let emailSubmissionId: string | undefined;
if (delayedUntil) {
emailSubmissionId = generateDemoId('submission');
this.scheduledSubmissions.set(emailSubmissionId, {
id: emailSubmissionId,
emailId: email.id,
identityId: _identityId || 'demo-identity',
sendAt: delayedUntil,
undoStatus: 'pending',
isSmime: false,
});
}
this.recalcMailboxCounts();
return delayedUntil
? { scheduled: true, emailId: email.id, emailSubmissionId, sendAt: delayedUntil }
: { scheduled: false, emailId: email.id };
}
async sendImipReply(): Promise<void> { /* no-op in demo */ }
@@ -487,9 +516,17 @@ export class DemoJMAPClient implements IJMAPClient {
// ── Blobs ─────────────────────────────────────────────────────
async uploadBlob(file: File): Promise<{ blobId: string; size: number; type: string }> {
async uploadBlob(
file: File,
opts?: { onProgress?: (loaded: number, total: number) => void; signal?: AbortSignal },
): Promise<{ blobId: string; size: number; type: string }> {
if (opts?.signal?.aborted) {
throw new DOMException('Upload aborted', 'AbortError');
}
opts?.onProgress?.(0, file.size);
const blobId = generateDemoId('blob');
this.blobStore.set(blobId, file);
opts?.onProgress?.(file.size, file.size);
return { blobId, size: file.size, type: file.type };
}
@@ -903,7 +940,75 @@ export class DemoJMAPClient implements IJMAPClient {
async importRawEmail(): Promise<string> { return generateDemoId('email'); }
async submitEmail(): Promise<void> { /* no-op */ }
async sendRawEmail(): Promise<void> { /* no-op */ }
async sendRawEmail(_blob?: Blob, identityId = 'demo-identity', _sentMailboxId?: string, _draftMailboxId?: string, delayedUntil?: string, _envelopeRecipients?: string[]): Promise<SendEmailResult> {
const emailId = generateDemoId('email');
const draftsMailbox = this.data.mailboxes.find(m => m.role === 'drafts');
const sentMailbox = this.data.mailboxes.find(m => m.role === 'sent');
const email: Email = {
id: emailId,
threadId: generateDemoId('thread'),
mailboxIds: { [(delayedUntil ? draftsMailbox?.id : sentMailbox?.id) || 'demo-mailbox-sent']: true },
keywords: delayedUntil ? { $seen: true, $draft: true } : { $seen: true },
size: 1024,
receivedAt: new Date().toISOString(),
from: [{ name: 'Demo User', email: 'demo@example.com' }],
to: [],
subject: 'S/MIME message',
preview: 'Signed/encrypted demo message',
hasAttachment: false,
};
this.data.emails.push(email);
let emailSubmissionId: string | undefined;
if (delayedUntil) {
emailSubmissionId = generateDemoId('submission');
this.scheduledSubmissions.set(emailSubmissionId, { id: emailSubmissionId, emailId, identityId, sendAt: delayedUntil, undoStatus: 'pending', isSmime: true });
}
this.recalcMailboxCounts();
return delayedUntil ? { scheduled: true, emailId, emailSubmissionId, sendAt: delayedUntil, isSmime: true } : { scheduled: false, emailId, isSmime: true };
}
async getScheduledEmails(limit = 50, position = 0): Promise<{ emails: ScheduledEmail[]; hasMore: boolean; total: number; nextPosition: number }> {
const pending = Array.from(this.scheduledSubmissions.values())
.filter(s => s.undoStatus === 'pending')
.sort((a, b) => new Date(a.sendAt).getTime() - new Date(b.sendAt).getTime());
const page = pending.slice(position, position + limit);
const emails = page.map((submission) => {
const email = this.data.emails.find(e => e.id === submission.emailId);
if (!email) return null;
return {
...email,
scheduledSendAt: submission.sendAt,
emailSubmissionId: submission.id,
scheduledIdentityId: submission.identityId,
scheduledUndoStatus: submission.undoStatus,
isScheduled: true,
isSmimeScheduled: submission.isSmime,
} satisfies ScheduledEmail;
}).filter((email): email is ScheduledEmail => email !== null);
const nextPosition = position + page.length;
return { emails, hasMore: nextPosition < pending.length, total: pending.length, nextPosition };
}
async cancelEmailSubmission(submissionId: string): Promise<void> {
const submission = this.scheduledSubmissions.get(submissionId);
if (submission) submission.undoStatus = 'canceled';
}
async rescheduleEmailSubmission(submissionId: string, emailId: string, identityId: string, delayedUntil: string): Promise<SendEmailResult> {
await this.cancelEmailSubmission(submissionId);
const replacement = generateDemoId('submission');
this.scheduledSubmissions.set(replacement, { id: replacement, emailId, identityId, sendAt: delayedUntil, undoStatus: 'pending', isSmime: false });
return { scheduled: true, emailId, emailSubmissionId: replacement, sendAt: delayedUntil };
}
async restoreEmailToDraft(emailId: string, draftMailboxId: string, sentMailboxId?: string): Promise<void> {
const email = this.data.emails.find(e => e.id === emailId);
if (!email) return;
email.mailboxIds[draftMailboxId] = true;
if (sentMailboxId) delete email.mailboxIds[sentMailboxId];
email.keywords.$draft = true;
this.recalcMailboxCounts();
}
// ── Internal helpers ──────────────────────────────────────────
+264
View File
@@ -0,0 +1,264 @@
import type { Email } from "@/lib/jmap/types";
// Allow any Unicode letter or digit (so umlauts, accents, CJK survive) plus a
// small set of safe punctuation. Everything else - emojis, RTL/zero-width
// marks, control chars, and the filesystem-reserved `<>:"/\|?*` - collapses
// to `_`. Keeps filenames usable across Windows/macOS/Linux without flattening
// non-ASCII scripts.
const SAFE_CHARS = /[^\p{L}\p{N} _\-().,!@#&+=[\]{}']/gu;
export type SpaceReplacement = "keep" | "underscore" | "dash";
export interface FilenameTransformOptions {
spaceReplacement?: SpaceReplacement;
lowercase?: boolean;
stripDiacritics?: boolean;
collapseSeparators?: boolean;
}
export interface EmailFilenameOptions extends FilenameTransformOptions {
template?: string;
}
export const DEFAULT_TRANSFORM: Required<FilenameTransformOptions> = {
spaceReplacement: "keep",
lowercase: false,
stripDiacritics: false,
collapseSeparators: true,
};
export const DEFAULT_EMAIL_TEMPLATE = "{date} ({from}-{to}) {subject}";
export const DEFAULT_ATTACHMENT_TEMPLATE = "{filename}";
export const DEFAULT_BUNDLE_TEMPLATE = "emails-{count}";
export const EMAIL_TOKENS: { token: string; description: string }[] = [
{ token: "date", description: "Full date and time, e.g. 2026-05-22 14.05.33" },
{ token: "date_short", description: "Date only, e.g. 2026-05-22" },
{ token: "time", description: "Time only, e.g. 14.05.33" },
{ token: "year", description: "4-digit year" },
{ token: "month", description: "2-digit month" },
{ token: "day", description: "2-digit day" },
{ token: "from", description: "Sender display name (falls back to email user part)" },
{ token: "from_email", description: "Sender full email address" },
{ token: "from_name", description: "Sender name only" },
{ token: "to", description: "First recipient display name" },
{ token: "to_email", description: "First recipient full email address" },
{ token: "to_name", description: "First recipient name only" },
{ token: "subject", description: "Email subject" },
];
export const ATTACHMENT_TOKENS: { token: string; description: string }[] = [
...EMAIL_TOKENS,
{ token: "filename", description: "Original attachment filename including extension" },
{ token: "name", description: "Attachment filename without extension" },
{ token: "ext", description: "Attachment file extension without leading dot" },
];
export const BUNDLE_TOKENS: { token: string; description: string }[] = [
{ token: "count", description: "Number of emails in the bundle" },
{ token: "date", description: "Current date and time, e.g. 2026-05-22 14.05.33" },
{ token: "date_short", description: "Current date, e.g. 2026-05-22" },
{ token: "time", description: "Current time, e.g. 14.05.33" },
{ token: "year", description: "4-digit year" },
{ token: "month", description: "2-digit month" },
{ token: "day", description: "2-digit day" },
];
function sanitizePart(input: string, maxLen = 80): string {
const cleaned = input
.replace(SAFE_CHARS, "_")
.replace(/_+/g, "_")
.replace(/\s+/g, " ")
.trim()
.replace(/^[._-]+|[._-]+$/g, "");
return cleaned.slice(0, maxLen);
}
function applyTransforms(input: string, opts: FilenameTransformOptions): string {
let s = input;
if (opts.stripDiacritics) {
// NFD splits "ä" into "a" + U+0308 (combining diaeresis); stripping all
// combining marks then leaves plain ASCII letters. `ß` has no
// decomposition so it survives as-is.
s = s.normalize("NFD").replace(/\p{M}+/gu, "");
}
const repl = opts.spaceReplacement ?? "keep";
if (repl === "underscore") s = s.replace(/ +/g, "_");
else if (repl === "dash") s = s.replace(/ +/g, "-");
if (opts.collapseSeparators ?? true) {
s = s.replace(/_+/g, "_").replace(/-+/g, "-").replace(/ +/g, " ");
}
if (opts.lowercase) s = s.toLocaleLowerCase();
return s.replace(/^[._\- ]+|[._\- ]+$/g, "");
}
function pad2(n: number): string {
return String(n).padStart(2, "0");
}
function dateParts(iso: string | null | undefined) {
const d = iso ? new Date(iso) : new Date();
if (Number.isNaN(d.getTime())) {
return {
date: "0000-00-00 00.00.00",
date_short: "0000-00-00",
time: "00.00.00",
year: "0000",
month: "00",
day: "00",
};
}
const year = String(d.getFullYear());
const month = pad2(d.getMonth() + 1);
const day = pad2(d.getDate());
const time = `${pad2(d.getHours())}.${pad2(d.getMinutes())}.${pad2(d.getSeconds())}`;
return {
date: `${year}-${month}-${day} ${time}`,
date_short: `${year}-${month}-${day}`,
time,
year,
month,
day,
};
}
function addrLabel(addr: { name?: string | null; email: string } | undefined): {
name: string;
email: string;
label: string;
} {
if (!addr) return { name: "", email: "", label: "unknown" };
const name = (addr.name && addr.name.trim()) || "";
const email = addr.email || "";
const label = name || email.split("@")[0] || email || "unknown";
return { name, email, label };
}
export function emailVars(email: Email): Record<string, string> {
const dp = dateParts(email.receivedAt || email.sentAt);
const from = addrLabel(email.from?.[0]);
const to = addrLabel(email.to?.[0]);
return {
...dp,
from: from.label,
from_email: from.email,
from_name: from.name,
to: to.label,
to_email: to.email,
to_name: to.name,
subject: email.subject || "no subject",
};
}
export interface AttachmentLike {
name?: string | null;
type?: string | null;
}
export function attachmentVars(email: Email, attachment: AttachmentLike): Record<string, string> {
const filename = (attachment.name || "attachment").trim();
const dot = filename.lastIndexOf(".");
const hasExt = dot > 0 && dot < filename.length - 1;
const name = hasExt ? filename.slice(0, dot) : filename;
const ext = hasExt ? filename.slice(dot + 1) : "";
return {
...emailVars(email),
filename,
name,
ext,
};
}
function renderRaw(template: string, vars: Record<string, string>): string {
return template.replace(/\{(\w+)\}/g, (_, key: string) => {
const value = vars[key];
if (value === undefined) return "";
return sanitizePart(value);
});
}
export function emailExportFilename(
email: Email,
options: EmailFilenameOptions | string = {},
): string {
const opts = typeof options === "string" ? { template: options } : options;
const template = opts.template ?? DEFAULT_EMAIL_TEMPLATE;
const rendered = renderRaw(template, emailVars(email));
const cleaned = sanitizePart(rendered, 200);
const transformed = applyTransforms(cleaned, opts);
const stem = transformed.slice(0, 200) || "email";
return `${stem}.eml`;
}
export function attachmentDownloadFilename(
email: Email | null | undefined,
attachment: AttachmentLike,
options: EmailFilenameOptions | string = {},
): string {
const opts = typeof options === "string" ? { template: options } : options;
const template = opts.template ?? DEFAULT_ATTACHMENT_TEMPLATE;
if (!email) {
const filename = (attachment.name || "attachment").trim();
const cleaned = sanitizePart(filename, 200) || "attachment";
return applyTransforms(cleaned, opts) || cleaned;
}
const vars = attachmentVars(email, attachment);
const rendered = template.replace(/\{(\w+)\}/g, (_, key: string) => {
const value = vars[key];
if (value === undefined) return "";
// Preserve dots in {filename} so the original extension survives the
// sanitiser (it strips trailing dots otherwise).
return key === "filename" ? value.replace(SAFE_CHARS, "_") : sanitizePart(value);
});
const templateMentionsExt = /\{(ext|filename)\}/.test(template);
const cleaned = sanitizePart(rendered, 200) || "attachment";
if (templateMentionsExt) {
return applyTransforms(cleaned, opts) || cleaned;
}
const transformedStem = applyTransforms(cleaned, opts) || cleaned;
const ext = vars.ext;
if (!ext) return transformedStem;
const transformedExt = opts.lowercase ? ext.toLocaleLowerCase() : ext;
return `${transformedStem}.${transformedExt}`;
}
export function bundleVars(count: number, iso?: string): Record<string, string> {
const dp = dateParts(iso ?? new Date().toISOString());
return { ...dp, count: String(count) };
}
export function bundleExportFilename(
count: number,
options: EmailFilenameOptions | string = {},
iso?: string,
): string {
const opts = typeof options === "string" ? { template: options } : options;
const template = opts.template ?? DEFAULT_BUNDLE_TEMPLATE;
const rendered = renderRaw(template, bundleVars(count, iso));
const cleaned = sanitizePart(rendered, 200);
const transformed = applyTransforms(cleaned, opts);
const stem = transformed.slice(0, 200) || "emails";
return `${stem}.zip`;
}
// Build a synthetic email for previewing templates in the settings UI.
export function buildSampleEmail(): Email {
// Use a fixed date so the preview doesn't churn as the user types.
const iso = "2026-05-22T14:05:33Z";
return {
id: "sample-1",
threadId: "sample-thread-1",
mailboxIds: { inbox: true },
keywords: { $seen: true },
size: 12345,
receivedAt: iso,
sentAt: iso,
from: [{ name: "Alice Sender", email: "alice@example.com" }],
to: [{ name: "Bob Recipient", email: "bob@example.com" }],
cc: [],
subject: "Benachrichtigung von Ihrem Gerät",
preview: "",
hasAttachment: true,
blobId: "sample-blob",
};
}
+56
View File
@@ -21,3 +21,59 @@ export function plainTextToComposerBody(text: string): string {
.map((paragraph) => `<p>${escapeHtml(paragraph).replace(/\n/g, "<br>")}</p>`)
.join("");
}
// Transparent 1x1 GIF used as a stand-in src while the real inline image is
// being fetched from JMAP. Browsers cannot render `cid:` URLs directly, so
// without this swap the editor would show a broken-image icon (issue #163).
export const INLINE_IMAGE_PLACEHOLDER =
"data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7";
/**
* Rewrites `<img src="cid:xxx">` references into `<img src="<placeholder>" data-cid="xxx">`
* so TipTap can render the editor (the original cid: URL would 404) while still
* carrying the cid through edits. The placeholder is swapped to the actual
* image data once the corresponding inline blob has been fetched.
*/
export function rewriteCidImagesForEditor(html: string): string {
if (!html || html.indexOf("cid:") === -1) return html;
const doc = new DOMParser().parseFromString(`<body>${html}</body>`, "text/html");
let touched = false;
doc.querySelectorAll("img").forEach((img) => {
const src = img.getAttribute("src") || "";
if (!/^cid:/i.test(src)) return;
const cid = src.slice(4);
if (!cid) return;
if (!img.getAttribute("data-cid")) {
img.setAttribute("data-cid", cid);
}
img.setAttribute("src", INLINE_IMAGE_PLACEHOLDER);
touched = true;
});
return touched ? doc.body.innerHTML : html;
}
/**
* Replaces the placeholder src on `<img data-cid="...">` elements with the
* resolved data URL once the inline blob has been fetched. Leaves images
* whose src has been edited away from the placeholder/cid alone.
*/
export function replaceInlineImagePlaceholders(
html: string,
cidToDataUrl: Map<string, string>
): string {
if (!html || cidToDataUrl.size === 0) return html;
if (html.indexOf("data-cid") === -1) return html;
const doc = new DOMParser().parseFromString(`<body>${html}</body>`, "text/html");
let changed = false;
doc.querySelectorAll("img[data-cid]").forEach((img) => {
const cid = img.getAttribute("data-cid");
if (!cid) return;
const dataUrl = cidToDataUrl.get(cid);
if (!dataUrl) return;
const currentSrc = img.getAttribute("src") || "";
if (currentSrc !== INLINE_IMAGE_PLACEHOLDER && !/^cid:/i.test(currentSrc)) return;
img.setAttribute("src", dataUrl);
changed = true;
});
return changed ? doc.body.innerHTML : html;
}
+48
View File
@@ -0,0 +1,48 @@
export interface ImportableEmail {
name: string;
blob: Blob;
}
const EMAIL_MIME = "message/rfc822";
function isEmlName(name: string): boolean {
return /\.eml$/i.test(name);
}
function isZipName(name: string): boolean {
return /\.zip$/i.test(name);
}
async function extractEmlsFromZip(file: File): Promise<ImportableEmail[]> {
const { default: JSZip } = await import("jszip");
const zip = await JSZip.loadAsync(await file.arrayBuffer());
const out: ImportableEmail[] = [];
const entries = Object.values(zip.files);
for (const entry of entries) {
if (entry.dir) continue;
if (!isEmlName(entry.name)) continue;
const data = await entry.async("arraybuffer");
out.push({
name: entry.name.split(/[\\/]/).pop() || entry.name,
blob: new Blob([data], { type: EMAIL_MIME }),
});
}
return out;
}
export async function expandImportableEmails(
files: File[],
): Promise<ImportableEmail[]> {
const out: ImportableEmail[] = [];
for (const file of files) {
if (isZipName(file.name) || file.type === "application/zip") {
out.push(...(await extractEmlsFromZip(file)));
continue;
}
const blob = new Blob([await file.arrayBuffer()], { type: EMAIL_MIME });
out.push({ name: file.name, blob });
}
return out;
}
export const EML_IMPORT_ACCEPT = ".eml,.zip,message/rfc822,application/zip";
+25 -8
View File
@@ -1,4 +1,4 @@
import type { Email, Mailbox, StateChange, AccountStates, Thread, Identity, EmailAddress, ContactCard, AddressBook, AddressBookRights, VacationResponse, Calendar, CalendarRights, CalendarEvent, CalendarEventFilter, CalendarTask, FileNode, Principal, PushSubscription } from "./types";
import type { Email, Mailbox, StateChange, AccountStates, Thread, Identity, EmailAddress, ContactCard, AddressBook, AddressBookRights, VacationResponse, Calendar, CalendarRights, CalendarEvent, CalendarEventFilter, CalendarTask, FileNode, Principal, PushSubscription, ScheduledEmail, SendEmailResult } from "./types";
import type { SieveScript, SieveCapabilities } from "./sieve-types";
/**
@@ -31,6 +31,8 @@ export interface IJMAPClient {
getMaxSizeUpload(): number;
getMaxCallsInRequest(): number;
getMaxObjectsInGet(): number;
getMaxDelayedSend(accountId?: string): number;
hasDelayedSend(accountId?: string): boolean;
getEventSourceUrl(): string | null;
supportsEmailSubmission(): boolean;
supportsQuota(): boolean;
@@ -96,9 +98,9 @@ export interface IJMAPClient {
setKeyword(emailId: string, keyword: string): Promise<void>;
migrateKeyword(oldKeyword: string, newKeyword: string): Promise<number>;
deleteEmail(emailId: string): Promise<void>;
moveToTrash(emailId: string, trashMailboxId: string, accountId?: string): Promise<void>;
moveToTrash(emailId: string, trashMailboxId: string, accountId?: string, markAsRead?: boolean): Promise<void>;
batchDeleteEmails(emailIds: string[]): Promise<void>;
batchMoveEmails(emailIds: string[], toMailboxId: string, accountId?: string): Promise<void>;
batchMoveEmails(emailIds: string[], toMailboxId: string, accountId?: string, markAsRead?: boolean): Promise<void>;
batchArchiveEmails(
emails: Array<{ id: string; receivedAt: string }>,
archiveMailboxId: string,
@@ -110,7 +112,7 @@ export interface IJMAPClient {
emptyMailbox(mailboxId: string): Promise<number>;
markMailboxAsRead(mailboxId: string, accountId?: string): Promise<number>;
markAllAsRead(excludeMailboxIds?: string[], accountId?: string): Promise<number>;
markAsSpam(emailId: string, accountId?: string): Promise<void>;
markAsSpam(emailId: string, accountId?: string, markAsRead?: boolean): Promise<void>;
undoSpam(emailId: string, originalMailboxId: string, accountId?: string): Promise<void>;
// ── Threads ───────────────────────────────────────────────────
@@ -146,8 +148,15 @@ export interface IJMAPClient {
attachments?: Array<{ blobId: string; name: string; type: string; size: number; disposition?: 'attachment' | 'inline'; cid?: string }>,
inReplyTo?: string[],
references?: string[],
delayedUntil?: string,
envelopeMailFrom?: string,
): Promise<void>;
): 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 }>;
cancelEmailSubmission(submissionId: string): Promise<void>;
rescheduleEmailSubmission(submissionId: string, emailId: string, identityId: string, delayedUntil: string): Promise<SendEmailResult>;
restoreEmailToDraft(emailId: string, draftMailboxId: string, sentMailboxId?: string): Promise<void>;
sendImipReply(opts: {
organizerEmail: string;
@@ -169,7 +178,16 @@ export interface IJMAPClient {
sendImipCancellation(event: CalendarEvent): Promise<void>;
// ── Blobs ─────────────────────────────────────────────────────
uploadBlob(file: File): Promise<{ blobId: string; size: number; type: string }>;
uploadBlob(
file: File,
optsOrAccountId?:
| string
| {
accountId?: string;
onProgress?: (loaded: number, total: number) => void;
signal?: AbortSignal;
},
): Promise<{ blobId: string; size: number; type: string }>;
getBlobDownloadUrl(blobId: string, name?: string, type?: string): string;
fetchBlob(blobId: string, name?: string, type?: string): Promise<Blob>;
fetchBlobAsObjectUrl(blobId: string, name?: string, type?: string): Promise<string>;
@@ -274,7 +292,6 @@ export interface IJMAPClient {
copyFileNode(id: string, newName: string, parentId: string | null): Promise<FileNode>;
// ── S/MIME raw-email helpers ──────────────────────────────────
importRawEmail(blob: Blob, mailboxIds: Record<string, boolean>, keywords?: Record<string, boolean>): Promise<string>;
importRawEmail(blob: Blob, mailboxIds: Record<string, boolean>, keywords?: Record<string, boolean>, accountId?: string): Promise<string>;
submitEmail(emailId: string, identityId: string): Promise<void>;
sendRawEmail(blob: Blob, identityId: string, sentMailboxId: string, draftMailboxId?: string): Promise<void>;
}
+509 -49
View File
@@ -1,4 +1,4 @@
import type { Email, Mailbox, StateChange, AccountStates, Thread, Identity, EmailAddress, ContactCard, AddressBook, AddressBookRights, VacationResponse, Calendar, CalendarRights, CalendarEvent, CalendarEventFilter, CalendarTask, FileNode, FileNodeFilter, Principal, PushSubscription } from "./types";
import type { Email, Mailbox, StateChange, AccountStates, Thread, Identity, EmailAddress, ContactCard, AddressBook, AddressBookRights, VacationResponse, Calendar, CalendarRights, CalendarEvent, CalendarEventFilter, CalendarTask, FileNode, FileNodeFilter, Principal, PushSubscription, EmailSubmission, ScheduledEmail, SendEmailResult } from "./types";
import type { SieveScript, SieveCapabilities } from "./sieve-types";
import type { IJMAPClient } from "./client-interface";
import { toWildcardQuery } from "./search-utils";
@@ -61,6 +61,12 @@ interface JMAPEmailHeader {
type JMAPMethodCall = [string, Record<string, unknown>, string];
const SUBMISSION_USING = [
'urn:ietf:params:jmap:core',
'urn:ietf:params:jmap:mail',
'urn:ietf:params:jmap:submission',
] as const;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
type JMAPResponseResult = Record<string, any>;
@@ -93,6 +99,8 @@ const EMAIL_LIST_PROPERTIES = [
"subject",
"preview",
"hasAttachment",
// Needed so list rows can serve drag-out to the file system as .eml.
"blobId",
] as const;
// Stalwart's default property list for Calendar/get omits shareWith, isVisible,
@@ -310,6 +318,27 @@ function computeHasMore(position: number, emailCount: number, total: number, lim
return emailCount === limit;
}
function hasSubmissionMethod(methodCalls: JMAPMethodCall[]): boolean {
return methodCalls.some(([method]) => method.startsWith('Identity/') || method.startsWith('EmailSubmission/'));
}
function isSmimeEmail(email: Email): boolean {
const types: string[] = [];
const collect = (part: Email['bodyStructure']): void => {
if (!part) return;
if (part.type) types.push(part.type.toLowerCase());
part.subParts?.forEach(collect);
};
collect(email.bodyStructure);
email.attachments?.forEach(att => types.push((att.type || '').toLowerCase()));
return types.some(type =>
type.includes('pkcs7') ||
type.includes('x-pkcs7') ||
type === 'application/pkcs7-mime' ||
type === 'application/pkcs7-signature'
);
}
/**
* Fold a single iCalendar content line per RFC 5545 §3.1.
* Lines longer than 75 octets MUST be split with CRLF + a single linear white space character.
@@ -345,6 +374,33 @@ function sanitizeIdentityDisplayName(name: string | undefined | null): string {
return name.replace(/\s*<[^>]*>\s*$/, '').trim();
}
function normalizeEnvelopeRecipients(recipients?: Array<string | EmailAddress>): Array<{ email: string }> {
return (recipients || [])
.map((recipient) => typeof recipient === 'string' ? recipient : recipient.email)
.map((email) => email.trim())
.filter(Boolean)
.map((email) => ({ email }));
}
function createDelayedSubmissionEnvelope(fromEmail: string, holdForSeconds?: number, recipients?: Array<string | EmailAddress>): Record<string, unknown> | undefined {
if (!holdForSeconds) return undefined;
const rcptTo = normalizeEnvelopeRecipients(recipients);
return {
mailFrom: {
email: fromEmail,
parameters: {
HOLDFOR: String(holdForSeconds),
},
},
rcptTo,
};
}
type SubmissionCapability = {
maxDelayedSend?: number;
submissionExtensions?: unknown;
};
export class JMAPClient implements IJMAPClient {
private static readonly RATE_LIMIT_TOAST_THROTTLE_MS = 10_000;
@@ -701,7 +757,7 @@ export class JMAPClient implements IJMAPClient {
}
const requestBody = {
using: using || ["urn:ietf:params:jmap:core", "urn:ietf:params:jmap:mail"],
using: using || (hasSubmissionMethod(methodCalls) ? [...SUBMISSION_USING] : ["urn:ietf:params:jmap:core", "urn:ietf:params:jmap:mail"]),
methodCalls,
};
@@ -1224,16 +1280,14 @@ export class JMAPClient implements IJMAPClient {
]);
}
async moveToTrash(emailId: string, trashMailboxId: string, accountId?: string): Promise<void> {
async moveToTrash(emailId: string, trashMailboxId: string, accountId?: string, markAsRead?: boolean): Promise<void> {
const targetAccountId = accountId || this.accountId;
const patch: Record<string, unknown> = { mailboxIds: { [trashMailboxId]: true } };
if (markAsRead) patch["keywords/$seen"] = true;
await this.request([
["Email/set", {
accountId: targetAccountId,
update: {
[emailId]: {
mailboxIds: { [trashMailboxId]: true },
},
},
update: { [emailId]: patch },
}, "0"],
]);
}
@@ -1249,10 +1303,15 @@ export class JMAPClient implements IJMAPClient {
]);
}
async batchMoveEmails(emailIds: string[], toMailboxId: string, accountId?: string): Promise<void> {
async batchMoveEmails(emailIds: string[], toMailboxId: string, accountId?: string, markAsRead?: boolean): Promise<void> {
if (emailIds.length === 0) return;
const updates = Object.fromEntries(emailIds.map(id => [id, { mailboxIds: { [toMailboxId]: true } }]));
const buildPatch = () => {
const patch: Record<string, unknown> = { mailboxIds: { [toMailboxId]: true } };
if (markAsRead) patch["keywords/$seen"] = true;
return patch;
};
const updates = Object.fromEntries(emailIds.map(id => [id, buildPatch()]));
await this.request([
["Email/set", { accountId: accountId || this.accountId, update: updates }, "0"],
]);
@@ -1505,7 +1564,7 @@ export class JMAPClient implements IJMAPClient {
return totalMarked;
}
async markAsSpam(emailId: string, accountId?: string): Promise<void> {
async markAsSpam(emailId: string, accountId?: string, markAsRead?: boolean): Promise<void> {
const targetAccountId = accountId || this.accountId;
const mailboxes = await this.getMailboxes();
@@ -1524,14 +1583,13 @@ export class JMAPClient implements IJMAPClient {
? junkMailbox.originalId
: junkMailbox.id;
const patch: Record<string, unknown> = { mailboxIds: { [mailboxId]: true } };
if (markAsRead) patch["keywords/$seen"] = true;
await this.request([
["Email/set", {
accountId: targetAccountId,
update: {
[emailId]: {
mailboxIds: { [mailboxId]: true },
},
},
update: { [emailId]: patch },
}, "0"],
]);
}
@@ -2090,8 +2148,10 @@ export class JMAPClient implements IJMAPClient {
attachments?: Array<{ blobId: string; name: string; type: string; size: number; disposition?: 'attachment' | 'inline'; cid?: string }>,
inReplyTo?: string[],
references?: string[],
delayedUntil?: string,
envelopeMailFrom?: string
): Promise<void> {
): Promise<SendEmailResult> {
const holdForSeconds = delayedUntil ? this.validateDelayedUntil(delayedUntil) : undefined;
const emailId = `send-${Date.now()}`;
const mailboxes = await this.getMailboxes();
const sentMailbox = mailboxes.find(mb => mb.role === 'sent');
@@ -2193,12 +2253,19 @@ export class JMAPClient implements IJMAPClient {
// e.g. sending from a domain-catch-all alias without a dedicated Identity),
// set the EmailSubmission envelope explicitly. JMAP §7.3: when `envelope`
// is omitted the server derives mailFrom from the Identity.
const submissionCreate = (submissionId: string): Record<string, unknown> => {
const buildSubmissionCreate = (submissionId: string): Record<string, unknown> => {
const create: Record<string, unknown> = { emailId: `#${emailId}`, identityId: finalIdentityId };
if (envelopeMailFrom) {
if (holdForSeconds || envelopeMailFrom) {
const envelopeRecipients = [...to, ...(cc || []), ...(bcc || [])]
.map((email) => email.trim())
.filter(Boolean)
.map((email) => ({ email }));
create.envelope = {
mailFrom: { email: envelopeMailFrom },
rcptTo: [...to, ...(cc || []), ...(bcc || [])].map((email) => ({ email })),
mailFrom: {
email: envelopeMailFrom || fromEmail || this.username,
...(holdForSeconds ? { parameters: { HOLDFOR: String(holdForSeconds) } } : {}),
},
rcptTo: envelopeRecipients,
};
}
return { [submissionId]: create };
@@ -2215,8 +2282,8 @@ export class JMAPClient implements IJMAPClient {
create: { [emailId]: emailCreate },
}, "1"]);
methodCalls.push(["EmailSubmission/set", {
accountId: this.accountId,
create: submissionCreate("1"),
accountId: this.getSubmissionAccountId(),
create: buildSubmissionCreate("1"),
onSuccessUpdateEmail,
}, "2"]);
} else {
@@ -2225,14 +2292,18 @@ export class JMAPClient implements IJMAPClient {
create: { [emailId]: emailCreate },
}, "0"]);
methodCalls.push(["EmailSubmission/set", {
accountId: this.accountId,
create: submissionCreate("1"),
accountId: this.getSubmissionAccountId(),
create: buildSubmissionCreate("1"),
onSuccessUpdateEmail,
}, "1"]);
}
const response = await this.request(methodCalls);
let createdEmailId: string | undefined;
let emailSubmissionId: string | undefined;
let serverSendAt: string | undefined;
if (response.methodResponses) {
for (const [methodName, result] of response.methodResponses) {
if (methodName.endsWith('/error')) {
@@ -2264,8 +2335,24 @@ export class JMAPClient implements IJMAPClient {
`${firstError?.description || firstError?.type || 'Failed to send email'}${typeHint}${propsHint}`,
);
}
if (methodName === 'Email/set' && result.created?.[emailId]?.id) {
createdEmailId = result.created[emailId].id;
}
if (methodName === 'EmailSubmission/set' && result.created?.['1']?.id) {
emailSubmissionId = result.created['1'].id;
serverSendAt = result.created['1'].sendAt;
}
}
}
if (delayedUntil && emailSubmissionId && !serverSendAt) {
serverSendAt = await this.getEmailSubmissionSendAt(emailSubmissionId);
}
return delayedUntil
? { scheduled: true, emailId: createdEmailId, emailSubmissionId, sendAt: serverSendAt }
: { scheduled: false, emailId: createdEmailId, emailSubmissionId };
}
/**
@@ -2427,7 +2514,7 @@ export class JMAPClient implements IJMAPClient {
create: { [emailId]: emailCreate },
}, "0"],
["EmailSubmission/set", {
accountId: this.accountId,
accountId: this.getSubmissionAccountId(),
create: { "sub-1": { emailId: `#${emailId}`, identityId: finalIdentityId } },
onSuccessUpdateEmail: {
"#sub-1": {
@@ -2605,7 +2692,7 @@ export class JMAPClient implements IJMAPClient {
create: { [emailId]: emailCreate },
}, "0"],
["EmailSubmission/set", {
accountId: this.accountId,
accountId: this.getSubmissionAccountId(),
create: { "sub-1": { emailId: `#${emailId}`, identityId } },
onSuccessUpdateEmail: {
"#sub-1": {
@@ -2757,7 +2844,7 @@ export class JMAPClient implements IJMAPClient {
create: { [emailId]: emailCreate },
}, "0"],
["EmailSubmission/set", {
accountId: this.accountId,
accountId: this.getSubmissionAccountId(),
create: { "sub-1": { emailId: `#${emailId}`, identityId } },
onSuccessUpdateEmail: {
"#sub-1": {
@@ -2784,7 +2871,71 @@ export class JMAPClient implements IJMAPClient {
}
}
async uploadBlob(file: File): Promise<{ blobId: string; size: number; type: string }> {
private xhrUpload(
url: string,
file: File,
onProgress?: (loaded: number, total: number) => void,
signal?: AbortSignal,
): Promise<string> {
return new Promise((resolve, reject) => {
if (signal?.aborted) {
reject(new DOMException('Upload aborted', 'AbortError'));
return;
}
const xhr = new XMLHttpRequest();
xhr.open('POST', url, true);
xhr.setRequestHeader('Content-Type', file.type || 'application/octet-stream');
xhr.setRequestHeader('Authorization', this.authHeader);
xhr.responseType = 'text';
const onAbort = () => xhr.abort();
if (signal) signal.addEventListener('abort', onAbort, { once: true });
const cleanup = () => signal?.removeEventListener('abort', onAbort);
if (onProgress) {
// Fire 0% immediately so the UI leaves its initial state even
// before the first network packet flushes.
onProgress(0, file.size);
xhr.upload.onprogress = (ev) => {
// ev.total is only meaningful when lengthComputable; fall back
// to file.size so callers always get a usable denominator.
const total = ev.lengthComputable ? ev.total : file.size;
onProgress(ev.loaded, total);
};
}
xhr.onload = () => {
cleanup();
if (xhr.status >= 200 && xhr.status < 300) {
resolve(xhr.responseText);
} else {
reject(new Error(`Failed to upload file: ${xhr.status} - ${xhr.responseText}`));
}
};
xhr.onerror = () => { cleanup(); reject(new Error('Upload network error')); };
xhr.onabort = () => { cleanup(); reject(new DOMException('Upload aborted', 'AbortError')); };
xhr.send(file);
});
}
// Signature accepts either the legacy positional accountId string OR
// the options bag introduced for progress / signal so existing
// call-sites keep compiling without touching every plugin.
async uploadBlob(
file: File,
optsOrAccountId?:
| string
| {
accountId?: string;
onProgress?: (loaded: number, total: number) => void;
signal?: AbortSignal;
},
): Promise<{ blobId: string; size: number; type: string }> {
const opts =
typeof optsOrAccountId === 'string'
? { accountId: optsOrAccountId }
: optsOrAccountId ?? {};
if (!this.session) {
throw new Error('Not connected. Call connect() first.');
}
@@ -2794,19 +2945,33 @@ export class JMAPClient implements IJMAPClient {
throw new Error('Upload URL not available');
}
const finalUploadUrl = uploadUrl.replace('{accountId}', encodeURIComponent(this.accountId));
const response = await this.authenticatedFetch(finalUploadUrl, {
method: 'POST',
headers: { 'Content-Type': file.type || 'application/octet-stream' },
body: file,
});
const targetAccountId = opts.accountId || this.accountId;
const finalUploadUrl = uploadUrl.replace('{accountId}', encodeURIComponent(targetAccountId));
if (!response.ok) {
const errorText = await response.text();
throw new Error(`Failed to upload file: ${response.status} - ${errorText}`);
// XHR path: fetch() does not expose upload progress events, so when the
// caller wants progress (or an AbortSignal) we use XMLHttpRequest. The
// fetch path is kept for callers that don't need either, to preserve
// existing 401/retry behaviour through authenticatedFetch().
let responseText: string;
if (opts.onProgress || opts.signal) {
responseText = await this.xhrUpload(
finalUploadUrl,
file,
opts.onProgress,
opts.signal,
);
} else {
const response = await this.authenticatedFetch(finalUploadUrl, {
method: 'POST',
headers: { 'Content-Type': file.type || 'application/octet-stream' },
body: file,
});
if (!response.ok) {
const errorText = await response.text();
throw new Error(`Failed to upload file: ${response.status} - ${errorText}`);
}
responseText = await response.text();
}
const responseText = await response.text();
let result;
try {
result = JSON.parse(responseText);
@@ -2824,7 +2989,7 @@ export class JMAPClient implements IJMAPClient {
}
// Nested format: { [accountId]: { blobId, type, size } }
const blobInfo = result[this.accountId];
const blobInfo = result[targetAccountId];
if (blobInfo?.blobId) {
return {
blobId: blobInfo.blobId,
@@ -2893,6 +3058,85 @@ export class JMAPClient implements IJMAPClient {
return coreCapability?.maxObjectsInGet || 500;
}
getMaxDelayedSend(accountId?: string): number {
const maxDelayedSend = this.getSubmissionCapability(accountId)?.maxDelayedSend;
return typeof maxDelayedSend === 'number' ? maxDelayedSend : 0;
}
hasDelayedSend(accountId?: string): boolean {
const submissionCapability = this.getSubmissionCapability(accountId);
const submissionExtensions = submissionCapability?.submissionExtensions;
const hasFutureRelease = this.hasSubmissionExtension(submissionExtensions, 'FUTURERELEASE');
return !!submissionCapability
&& hasFutureRelease
&& this.getMaxDelayedSend(accountId) > 0;
}
private validateDelayedUntil(delayedUntil: string, accountId?: string): number {
const time = new Date(delayedUntil).getTime();
if (!Number.isFinite(time)) {
throw new Error('Scheduled send time is invalid');
}
const now = Date.now();
if (time <= now) {
throw new Error('Scheduled send time must be in the future');
}
const maxDelayedSend = this.getMaxDelayedSend(accountId);
if (!this.hasDelayedSend(accountId) || maxDelayedSend <= 0) {
throw new Error('Scheduled send is not supported for this account');
}
if (time > now + maxDelayedSend * 1000) {
throw new Error('Scheduled send time is later than the server allows');
}
return Math.ceil((time - now) / 1000);
}
private async getEmailSubmissionSendAt(submissionId: string): Promise<string | undefined> {
const response = await this.request([
['EmailSubmission/get', {
accountId: this.getSubmissionAccountId(),
ids: [submissionId],
properties: ['sendAt', 'undoStatus'],
}, '0'],
]);
const submission = response.methodResponses?.[0]?.[1]?.list?.[0] as { sendAt?: string } | undefined;
return submission?.sendAt;
}
private async getEmailSubmissionEnvelope(submissionId: string): Promise<{ rcptTo?: Array<{ email: string }> } | undefined> {
const response = await this.request([
['EmailSubmission/get', {
accountId: this.getSubmissionAccountId(),
ids: [submissionId],
properties: ['envelope'],
}, '0'],
]);
const submission = response.methodResponses?.[0]?.[1]?.list?.[0] as { envelope?: { rcptTo?: Array<{ email: string }> } } | undefined;
return submission?.envelope;
}
private getSubmissionAccountId(accountId?: string): string {
return accountId || this.session?.primaryAccounts?.['urn:ietf:params:jmap:submission'] || this.accountId;
}
private getSubmissionCapability(accountId?: string): SubmissionCapability | undefined {
const submissionAccountId = this.getSubmissionAccountId(accountId);
return this.session?.accounts?.[submissionAccountId]?.accountCapabilities?.['urn:ietf:params:jmap:submission'] as SubmissionCapability | undefined;
}
private hasSubmissionExtension(submissionExtensions: unknown, extension: string): boolean {
const target = extension.toUpperCase();
if (Array.isArray(submissionExtensions)) {
return submissionExtensions.some(item => typeof item === 'string' && item.toUpperCase() === target);
}
if (submissionExtensions && typeof submissionExtensions === 'object') {
return Object.entries(submissionExtensions as Record<string, unknown>)
.some(([key, value]) => key.toUpperCase() === target && value !== false && value != null);
}
return false;
}
getEventSourceUrl(): string | null {
if (!this.session) return null;
@@ -4117,6 +4361,7 @@ export class JMAPClient implements IJMAPClient {
const createMap: Record<string, Partial<CalendarEvent>> = {};
for (let i = 0; i < events.length; i++) {
const { originalId: _oi, originalCalendarIds: _oc, accountId: _ai, accountName: _an, isShared: _is, ...clean } = events[i] as CalendarEvent;
cleanRecurrenceRules(clean as unknown as Record<string, unknown>);
createMap[`new-${i}`] = clean;
}
@@ -5297,20 +5542,29 @@ export class JMAPClient implements IJMAPClient {
return response.arrayBuffer();
}
/** Import a raw MIME message blob into the account. */
/**
* Import a raw MIME message blob into the account. Pass `accountId` to
* target a delegated account the caller has rights on (e.g. importing into
* a shared mailbox owned by another user). When omitted, falls back to the
* client's own primary account.
*/
async importRawEmail(
blob: Blob,
mailboxIds: Record<string, boolean>,
keywords?: Record<string, boolean>,
accountId?: string,
): Promise<string> {
// First upload the blob
const targetAccountId = accountId || this.accountId;
// First upload the blob. Blob uploads are scoped to an account too —
// when importing into a delegated account, upload there so the resulting
// blobId is visible to Email/import on that account.
const file = new File([blob], 'message.eml', { type: 'message/rfc822' });
const { blobId } = await this.uploadBlob(file);
const { blobId } = await this.uploadBlob(file, targetAccountId);
// Then import via Email/import
const response = await this.request([
['Email/import', {
accountId: this.accountId,
accountId: targetAccountId,
emails: {
'smime-import': {
blobId,
@@ -5338,7 +5592,7 @@ export class JMAPClient implements IJMAPClient {
async submitEmail(emailId: string, identityId: string): Promise<void> {
const response = await this.request([
['EmailSubmission/set', {
accountId: this.accountId,
accountId: this.getSubmissionAccountId(),
create: { 'smime-submit': { emailId, identityId } },
}, '0'],
]);
@@ -5359,7 +5613,10 @@ export class JMAPClient implements IJMAPClient {
identityId: string,
sentMailboxId: string,
draftMailboxId?: string,
): Promise<void> {
delayedUntil?: string,
envelopeRecipients?: string[],
): Promise<SendEmailResult> {
const holdForSeconds = delayedUntil ? this.validateDelayedUntil(delayedUntil) : undefined;
// Upload the raw message
const file = new File([blob], 'message.eml', { type: 'message/rfc822' });
const { blobId } = await this.uploadBlob(file);
@@ -5367,6 +5624,10 @@ export class JMAPClient implements IJMAPClient {
// Import into Drafts first, then move to Sent after submission succeeds.
// This avoids encrypt-on-append affecting the SMTP send. See #188.
const importMailboxId = draftMailboxId || sentMailboxId;
const identities = await this.getIdentities();
const identity = identities.find(item => item.id === identityId);
const envelope = createDelayedSubmissionEnvelope(identity?.email || this.username, holdForSeconds, envelopeRecipients);
const methodCalls: [string, Record<string, unknown>, string][] = [
['Email/import', {
accountId: this.accountId,
@@ -5379,11 +5640,12 @@ export class JMAPClient implements IJMAPClient {
},
}, '0'],
['EmailSubmission/set', {
accountId: this.accountId,
accountId: this.getSubmissionAccountId(),
create: {
'raw-submit': {
emailId: '#raw-import',
identityId,
...(envelope ? { envelope } : {}),
},
},
...(draftMailboxId ? {
@@ -5399,6 +5661,9 @@ export class JMAPClient implements IJMAPClient {
];
const response = await this.request(methodCalls);
let emailId: string | undefined;
let emailSubmissionId: string | undefined;
let serverSendAt: string | undefined;
// Check for errors
for (const [methodName, result] of response.methodResponses ?? []) {
@@ -5410,6 +5675,201 @@ export class JMAPClient implements IJMAPClient {
const firstErr = Object.values(r.notCreated)[0];
throw new Error(firstErr?.description || firstErr?.type || 'Failed to send raw email');
}
if (methodName === 'Email/import') {
emailId = (result as { created?: Record<string, { id?: string }> }).created?.['raw-import']?.id;
}
if (methodName === 'EmailSubmission/set') {
const created = (result as { created?: Record<string, { id?: string; sendAt?: string }> }).created?.['raw-submit'];
emailSubmissionId = created?.id;
serverSendAt = created?.sendAt;
}
}
if (delayedUntil && emailSubmissionId && !serverSendAt) {
serverSendAt = await this.getEmailSubmissionSendAt(emailSubmissionId);
}
return delayedUntil
? { scheduled: true, emailId, emailSubmissionId, sendAt: serverSendAt, isSmime: true }
: { scheduled: false, emailId, emailSubmissionId, isSmime: true };
}
async getScheduledEmails(limit = 50, position = 0): Promise<{ emails: ScheduledEmail[]; hasMore: boolean; total: number; nextPosition: number }> {
if (!this.hasDelayedSend()) {
return { emails: [], hasMore: false, total: 0, nextPosition: position };
}
const now = Date.now();
const pageSize = Math.max(limit, 50);
const submissions: EmailSubmission[] = [];
let rawPosition = 0;
let rawTotal = 0;
do {
const queryResponse = await this.request([
['EmailSubmission/query', {
accountId: this.getSubmissionAccountId(),
limit: pageSize,
position: rawPosition,
}, '0'],
]);
const query = queryResponse.methodResponses?.[0]?.[1] as { ids?: string[]; total?: number; position?: number } | undefined;
const ids = query?.ids ?? [];
rawTotal = query?.total ?? rawPosition + ids.length;
if (ids.length === 0) break;
const submissionResponse = await this.request([
['EmailSubmission/get', {
accountId: this.getSubmissionAccountId(),
ids,
properties: ['id', 'emailId', 'identityId', 'threadId', 'sendAt', 'undoStatus', 'deliveryStatus'],
}, '0'],
]);
submissions.push(...((submissionResponse.methodResponses?.[0]?.[1]?.list ?? []) as EmailSubmission[])
.filter(submission => {
if (submission.undoStatus !== 'pending' || !submission.sendAt) return false;
const sendAtTime = new Date(submission.sendAt).getTime();
return Number.isFinite(sendAtTime) && sendAtTime > now;
}));
rawPosition += ids.length;
} while (rawPosition < rawTotal);
submissions.sort((a, b) => new Date(a.sendAt || '').getTime() - new Date(b.sendAt || '').getTime());
const total = submissions.length;
const pageSubmissions = submissions.slice(position, position + limit);
const nextPosition = position + pageSubmissions.length;
if (pageSubmissions.length === 0) {
return { emails: [], hasMore: false, total, nextPosition };
}
const emailResponse = await this.request([
['Email/get', {
accountId: this.accountId,
ids: pageSubmissions.map(submission => submission.emailId),
properties: [
'id', 'threadId', 'mailboxIds', 'keywords', 'size', 'receivedAt', 'from', 'to', 'cc', 'bcc', 'replyTo',
'subject', 'preview', 'textBody', 'htmlBody', 'bodyValues', 'attachments', 'hasAttachment', 'sentAt',
'messageId', 'inReplyTo', 'references', 'headers', 'blobId', 'bodyStructure',
],
fetchTextBodyValues: true,
fetchHTMLBodyValues: true,
fetchAllBodyValues: true,
maxBodyValueBytes: 256000,
}, '0'],
]);
const emailById = new Map(((emailResponse.methodResponses?.[0]?.[1]?.list ?? []) as Email[]).map(email => [email.id, email]));
const emails = pageSubmissions
.map((submission): ScheduledEmail | null => {
const email = emailById.get(submission.emailId);
if (!email || !submission.sendAt) return null;
return {
...email,
threadId: submission.threadId || email.threadId,
scheduledSendAt: submission.sendAt,
emailSubmissionId: submission.id,
scheduledIdentityId: submission.identityId,
scheduledUndoStatus: submission.undoStatus,
scheduledDeliveryStatus: submission.deliveryStatus,
isScheduled: true,
isSmimeScheduled: isSmimeEmail(email),
};
})
.filter((email): email is ScheduledEmail => email !== null)
.sort((a, b) => new Date(a.scheduledSendAt).getTime() - new Date(b.scheduledSendAt).getTime());
return { emails, hasMore: nextPosition < total, total, nextPosition };
}
async cancelEmailSubmission(submissionId: string): Promise<void> {
const response = await this.request([
['EmailSubmission/set', {
accountId: this.getSubmissionAccountId(),
update: { [submissionId]: { undoStatus: 'canceled' } },
}, '0'],
]);
const result = response.methodResponses?.[0]?.[1];
const error = result?.notUpdated?.[submissionId];
if (error) {
throw new Error(error.description || error.type || 'Failed to cancel scheduled send');
}
}
async rescheduleEmailSubmission(submissionId: string, emailId: string, identityId: string, delayedUntil: string): Promise<SendEmailResult> {
const holdForSeconds = this.validateDelayedUntil(delayedUntil);
const mailboxes = await this.getMailboxes();
const draftsMailbox = mailboxes.find(mb => mb.role === 'drafts');
const sentMailbox = mailboxes.find(mb => mb.role === 'sent');
const identities = await this.getIdentities();
const identity = identities.find(item => item.id === identityId);
const existingEnvelope = await this.getEmailSubmissionEnvelope(submissionId);
const email = existingEnvelope?.rcptTo?.length ? undefined : await this.getEmail(emailId);
const envelopeRecipients = existingEnvelope?.rcptTo?.length
? existingEnvelope.rcptTo
: [...(email?.to || []), ...(email?.cc || []), ...(email?.bcc || [])];
const envelope = createDelayedSubmissionEnvelope(identity?.email || this.username, holdForSeconds, envelopeRecipients);
const response = await this.request([
['EmailSubmission/set', {
accountId: this.getSubmissionAccountId(),
create: { replacement: { emailId, identityId, ...(envelope ? { envelope } : {}) } },
...(draftsMailbox && sentMailbox ? {
onSuccessUpdateEmail: {
'#replacement': {
[`mailboxIds/${draftsMailbox.id}`]: null,
[`mailboxIds/${sentMailbox.id}`]: true,
'keywords/$draft': null,
},
},
} : {}),
}, '0'],
]);
const result = response.methodResponses?.[0]?.[1];
const createError = result?.notCreated?.replacement;
if (createError) {
throw new Error(createError.description || createError.type || 'Failed to reschedule email');
}
const replacementId = result?.created?.replacement?.id;
const serverSendAt = result?.created?.replacement?.sendAt;
if (!replacementId) {
throw new Error('Server did not return a replacement scheduled send ID');
}
const finalSendAt = serverSendAt || await this.getEmailSubmissionSendAt(replacementId);
try {
await this.cancelEmailSubmission(submissionId);
} catch (error) {
try {
await this.cancelEmailSubmission(replacementId);
} catch (cleanupError) {
console.error('Failed to clean up replacement scheduled send after reschedule failure:', cleanupError);
}
const message = error instanceof Error ? error.message : 'Failed to cancel original scheduled send';
throw new Error(`Reschedule created a replacement but could not cancel the original: ${message}`);
}
return { scheduled: true, emailId, emailSubmissionId: replacementId, sendAt: finalSendAt };
}
async restoreEmailToDraft(emailId: string, draftMailboxId: string, sentMailboxId?: string): Promise<void> {
const update: Record<string, unknown> = {
[`mailboxIds/${draftMailboxId}`]: true,
'keywords/$draft': true,
};
if (sentMailboxId) {
update[`mailboxIds/${sentMailboxId}`] = null;
}
const response = await this.request([
['Email/set', {
accountId: this.accountId,
update: { [emailId]: update },
}, '0'],
]);
const result = response.methodResponses?.[0]?.[1];
const error = result?.notUpdated?.[emailId];
if (error) {
throw new Error(error.description || error.type || 'Failed to restore email to drafts');
}
}
@@ -5488,4 +5948,4 @@ export class JMAPClient implements IJMAPClient {
['urn:ietf:params:jmap:core'],
);
}
}
}
+27 -1
View File
@@ -42,6 +42,32 @@ export interface Email {
// Unified mailbox support - set when displaying emails from multiple accounts
accountId?: string;
accountLabel?: string;
// Client-only scheduled-send metadata, populated from EmailSubmission/query.
scheduledSendAt?: string;
emailSubmissionId?: string;
scheduledIdentityId?: string;
scheduledUndoStatus?: 'pending' | 'final' | 'canceled';
scheduledDeliveryStatus?: Record<string, DeliveryStatus>;
isScheduled?: boolean;
isSmimeScheduled?: boolean;
}
export interface SendEmailResult {
scheduled: boolean;
emailId?: string;
emailSubmissionId?: string;
sendAt?: string;
isSmime?: boolean;
}
export interface ScheduledEmail extends Email {
scheduledSendAt: string;
emailSubmissionId: string;
scheduledIdentityId: string;
scheduledUndoStatus: 'pending' | 'final' | 'canceled';
scheduledDeliveryStatus?: Record<string, DeliveryStatus>;
isScheduled: true;
isSmimeScheduled: boolean;
}
export interface AuthenticationResults {
@@ -785,4 +811,4 @@ export const UNIFIED_ROLE_BY_ID: Record<string, UnifiedMailboxRole> = Object.fro
export function isUnifiedMailboxId(id: string): boolean {
return id in UNIFIED_ROLE_BY_ID;
}
}
+13 -3
View File
@@ -1,11 +1,21 @@
import { logger } from '@/lib/logger';
import { discoverOAuth } from '@/lib/oauth/discovery';
import type { OAuthMetadata } from '@/lib/oauth/discovery';
import type { EndpointValidator, OAuthMetadata } from '@/lib/oauth/discovery';
import { isPublicHttpUrl } from '@/lib/security/url-guard';
import { readFileEnv } from '@/lib/read-file-env';
import { configManager } from '@/lib/admin/config-manager';
import { parseJmapServers, findServerById } from '@/lib/admin/jmap-servers';
// SSRF guard for OAuth discovery. When `oauthAllowPrivateEndpoints` is set,
// the admin opts in to discovery resolving to RFC-1918 / loopback hosts —
// required for split-DNS deployments where the JMAP server's public hostname
// resolves to an internal IP locally. The guard remains in force for any
// caller that passes a user-supplied serverUrl (see totp-token-exchange).
export function getDiscoveryValidator(): EndpointValidator | undefined {
const allowPrivate = configManager.get<boolean>('oauthAllowPrivateEndpoints', false);
return allowPrivate ? undefined : isPublicHttpUrl;
}
function getGlobalClientSecret(): string {
const adminSecret = configManager.get<string>('oauthClientSecret', '');
if (adminSecret) return adminSecret;
@@ -47,7 +57,7 @@ function getClientSecret(serverId?: string | null): string {
export async function getTokenEndpoint(serverId?: string | null): Promise<string> {
const { discoveryUrl } = getRequiredConfig(serverId);
const metadata = await discoverOAuth(discoveryUrl, { validateEndpoint: isPublicHttpUrl });
const metadata = await discoverOAuth(discoveryUrl, { validateEndpoint: getDiscoveryValidator() });
if (!metadata?.token_endpoint) {
throw new Error('OAuth token endpoint not found');
}
@@ -56,7 +66,7 @@ export async function getTokenEndpoint(serverId?: string | null): Promise<string
export async function getMetadata(serverId?: string | null): Promise<OAuthMetadata | null> {
const { discoveryUrl } = getRequiredConfig(serverId);
return discoverOAuth(discoveryUrl, { validateEndpoint: isPublicHttpUrl });
return discoverOAuth(discoveryUrl, { validateEndpoint: getDiscoveryValidator() });
}
export function buildOAuthParams(base: Record<string, string>, serverId?: string | null): URLSearchParams {
+3 -1
View File
@@ -10,6 +10,8 @@
// installing their own code. Verification kicks in for server-managed
// bundles only (the `managed: true` flag on `InstalledPlugin`).
import { apiFetch } from '@/lib/browser-navigation';
let cachedPubKey: CryptoKey | null = null;
let pubKeyPromise: Promise<CryptoKey | null> | null = null;
@@ -26,7 +28,7 @@ async function importEd25519PublicKey(raw: Uint8Array): Promise<CryptoKey | null
async function fetchPublicKey(): Promise<CryptoKey | null> {
try {
const res = await fetch('/api/plugin-signing-pubkey', { credentials: 'same-origin' });
const res = await apiFetch('/api/plugin-signing-pubkey', { credentials: 'same-origin' });
if (!res.ok) return null;
const data = await res.json() as { algorithm?: string; publicKey?: string };
if (data.algorithm !== 'ed25519' || typeof data.publicKey !== 'string') return null;
+32 -4
View File
@@ -6,6 +6,11 @@ export interface UnifiedAccountClient {
accountLabel: string;
client: IJMAPClient;
mailboxes: Mailbox[];
// When true, this entry represents a group/shared account owned by
// `accountId` but accessed through someone else's `client`. JMAP requests
// must use the mailbox's `originalId` and explicitly target this accountId
// so the server routes to the owner's data.
isShared?: boolean;
}
export interface UnifiedFetchResult {
@@ -60,10 +65,11 @@ export async function fetchUnifiedEmails(
const mailbox = findMailboxByRole(account.mailboxes, role);
if (!mailbox) return null;
const { jmapMailboxId, jmapAccountId } = resolveJmapTarget(account, mailbox);
try {
const result = await account.client.getEmails(
mailbox.id,
undefined,
jmapMailboxId,
jmapAccountId,
limit,
position,
);
@@ -131,7 +137,8 @@ export async function searchUnifiedEmails(
position: number,
): Promise<UnifiedFetchResult> {
return fanOutUnifiedQuery(accounts, role, async (account, mailbox) => {
return account.client.searchEmails(query, mailbox.id, undefined, limit, position);
const { jmapMailboxId, jmapAccountId } = resolveJmapTarget(account, mailbox);
return account.client.searchEmails(query, jmapMailboxId, jmapAccountId, limit, position);
});
}
@@ -149,10 +156,31 @@ export async function advancedSearchUnifiedEmails(
position: number,
): Promise<UnifiedFetchResult> {
return fanOutUnifiedQuery(accounts, role, async (account, mailbox) => {
return account.client.advancedSearchEmails(filterFor(mailbox.id), undefined, limit, position);
const { jmapMailboxId, jmapAccountId } = resolveJmapTarget(account, mailbox);
return account.client.advancedSearchEmails(filterFor(jmapMailboxId), jmapAccountId, limit, position);
});
}
/**
* Resolves the JMAP-side mailbox id and accountId for a mailbox living inside
* a UnifiedAccountClient. For personal-account entries we use the JMAP id as
* returned by the primary client; for shared-owner entries the mailbox id is
* namespaced (`${ownerId}:${origId}`) so we must use `originalId` and pass the
* owner's accountId through the request.
*/
function resolveJmapTarget(
account: UnifiedAccountClient,
mailbox: Mailbox,
): { jmapMailboxId: string; jmapAccountId: string | undefined } {
if (account.isShared) {
return {
jmapMailboxId: mailbox.originalId ?? mailbox.id,
jmapAccountId: account.accountId,
};
}
return { jmapMailboxId: mailbox.id, jmapAccountId: undefined };
}
async function fanOutUnifiedQuery(
accounts: UnifiedAccountClient[],
role: UnifiedMailboxRole,
+76 -12
View File
@@ -3,6 +3,8 @@ import { twMerge } from "tailwind-merge";
import { Mailbox, UNIFIED_MAILBOX_IDS } from "./jmap/types";
import type { UnifiedMailboxRole } from "./jmap/types";
import { debug } from "./debug";
import { useLocaleStore } from "@/stores/locale-store";
import { useSettingsStore } from "@/stores/settings-store";
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
@@ -40,24 +42,86 @@ export function generateUUID(): string {
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;
}
/**
* Formats a received-at date for the email list. The output style is
* controlled by the `dateFormat` user setting:
*
* - `smart` (default) locale-aware, age-bucketed:
* today time only ("15:31" or "3:31 PM")
* last 7 days short weekday+time ("Fr 15:31", "Fri 3:31 PM")
* older full locale date ("28.04.2026", "04/28/2026")
* - `relative` legacy en-US relative format ("1h ago", "2d ago").
* - `full` always the full locale date+time.
*
* Both the locale (from the language picker) and 12h/24h preference are
* read via `getState()` so this stays SSR-safe.
*/
export function formatDate(date: Date | string): string {
const d = typeof date === "string" ? new Date(date) : date;
const now = new Date();
const diff = now.getTime() - d.getTime();
const minutes = Math.floor(diff / 60000);
const hours = Math.floor(diff / 3600000);
const days = Math.floor(diff / 86400000);
const localeRaw = useLocaleStore.getState().locale;
const locale = localeRaw && localeRaw.length > 0 ? localeRaw : "en";
// `en` alone resolves to en-US in Intl; everything else uses the language
// subtag as-is and lets the runtime pick a sensible default region.
const intlLocale = locale === "en" ? "en-US" : locale;
const { dateFormat, timeFormat } = useSettingsStore.getState();
const hour12 = timeFormat === "12h";
if (minutes < 1) return "Just now";
if (minutes < 60) return `${minutes}m ago`;
if (hours < 24) return `${hours}h ago`;
if (days < 7) return `${days}d ago`;
if (dateFormat === "relative") {
const diff = now.getTime() - d.getTime();
const minutes = Math.floor(diff / 60000);
const hours = Math.floor(diff / 3600000);
const days = Math.floor(diff / 86400000);
if (minutes < 1) return "Just now";
if (minutes < 60) return `${minutes}m ago`;
if (hours < 24) return `${hours}h ago`;
if (days < 7) return `${days}d ago`;
return d.toLocaleDateString(intlLocale, {
month: "short",
day: "numeric",
year: d.getFullYear() !== now.getFullYear() ? "numeric" : undefined,
});
}
return d.toLocaleDateString("en-US", {
month: "short",
day: "numeric",
year: d.getFullYear() !== now.getFullYear() ? "numeric" : undefined,
if (dateFormat === "full") {
return d.toLocaleString(intlLocale, {
year: "numeric",
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
hour12,
});
}
// 'smart' (default)
const timeStr = d.toLocaleTimeString(intlLocale, {
hour: "2-digit",
minute: "2-digit",
hour12,
});
const isSameDay =
d.getFullYear() === now.getFullYear() &&
d.getMonth() === now.getMonth() &&
d.getDate() === now.getDate();
if (isSameDay) return timeStr;
const daysAgo = Math.floor((now.getTime() - d.getTime()) / 86400000);
if (daysAgo < 7) {
// German Intl outputs "Fr." with a trailing dot for `weekday: 'short'`;
// strip it so the result reads cleanly next to the time.
const weekday = d
.toLocaleDateString(intlLocale, { weekday: "short" })
.replace(/\.$/, "");
return `${weekday} ${timeStr}`;
}
return d.toLocaleDateString(intlLocale, {
year: "numeric",
month: "2-digit",
day: "2-digit",
});
}
+3 -2
View File
@@ -4,6 +4,7 @@
*/
import { getActiveAccountSlotHeaders } from '@/lib/auth/active-account-slot';
import { apiFetch, withBasePath } from '@/lib/browser-navigation';
export interface WebDAVResource {
href: string;
@@ -32,7 +33,7 @@ export class WebDAVClient {
...options?.headers,
};
return fetch(this.proxyUrl, {
return apiFetch(this.proxyUrl, {
method: 'POST',
headers,
body: options?.body,
@@ -118,7 +119,7 @@ export class WebDAVClient {
// Use XMLHttpRequest for progress tracking
return new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest();
xhr.open('POST', this.proxyUrl);
xhr.open('POST', withBasePath(this.proxyUrl));
xhr.setRequestHeader('X-WebDAV-Method', 'PUT');
xhr.setRequestHeader('X-WebDAV-Path', path);
const slotHeaders = getActiveAccountSlotHeaders();
+115 -15
View File
@@ -132,7 +132,8 @@
"shared": "Sdílené",
"mail": "Pošta",
"nav_label": "Navigace",
"add_app": "Aplikace"
"add_app": "Aplikace",
"scheduled": "Naplánováno"
},
"protocol_handlers": {
"title": "Výchozí aplikace",
@@ -239,7 +240,16 @@
"confirm_button": "Vyprázdnit složku",
"junk_hint": "Můžete vyprázdnit složku Spam a trvale odstranit všechny zprávy.",
"trash_hint": "Můžete vyprázdnit Koš a trvale odstranit všechny zprávy."
}
},
"no_scheduled_emails": "Žádné naplánované e-maily",
"no_scheduled_emails_description": "Zprávy naplánované na později se zobrazí zde.",
"scheduled_count": "{count, plural, one {1 naplánovaný e-mail} few {# naplánované e-maily} other {# naplánovaných e-mailů}}",
"scheduled_actions_hint": "Naplánované zprávy můžete zrušit, přeplánovat nebo upravit pomocí akcí plánování.",
"cancel_scheduled_send": "Zrušit odeslání",
"reschedule_send": "Přeplánovat",
"cancel_and_edit": "Zrušit a upravit",
"cancel_and_compose_again": "Zrušit a napsat znovu",
"reschedule_prompt": "Zadejte nové datum/čas, např. 2026-05-04T15:30"
},
"email_viewer": {
"no_email_selected": "Není vybrána žádná zpráva",
@@ -270,7 +280,7 @@
"print": "Tisk",
"view_source": "Zobrazit zdrojový kód",
"export_email": "Exportovat jako .eml",
"import_email": "Importovat .eml",
"import_email": "Importovat .eml nebo .zip",
"keyboard_shortcuts": "Klávesové zkratky (?)",
"email_source": "Zdrojový kód zprávy",
"draft_banner": "Tato zpráva je koncept",
@@ -514,7 +524,17 @@
"ai_verdict": "Verdikt AI",
"account": "Účet",
"no_subject": "(bez předmětu)"
}
},
"scheduled_banner": "Naplánováno k odeslání {date}",
"scheduled_send_created": "E-mail byl naplánován k odeslání",
"cancel_scheduled_send": "Zrušit odeslání",
"reschedule_send": "Přeplánovat",
"cancel_and_edit": "Zrušit a upravit",
"cancel_and_compose_again": "Zrušit a napsat znovu",
"reschedule_prompt": "Zadejte nové datum/čas, např. 2026-05-04T15:30",
"scheduled_actions_only": "Zobrazení Naplánováno podporuje pouze akce plánování",
"undo_send_scheduled": "Zpráva je naplánována k odeslání",
"undo_send": "Vrátit odeslání"
},
"email_composer": {
"new_message": "Nová zpráva",
@@ -607,7 +627,19 @@
},
"add_link": "Přidat odkaz",
"link_url_prompt": "Zadejte URL",
"sending": "Odesílání..."
"sending": "Odesílání...",
"schedule_send": "Naplánovat odeslání",
"schedule_send_description": "Vyberte, kdy má server tuto zprávu odeslat.",
"schedule_send_required": "Vyberte datum a čas.",
"schedule_send_invalid": "Zadejte platné datum a čas.",
"schedule_send_future": "Vyberte budoucí datum a čas.",
"schedule_send_too_late": "Tento čas je později, než server dovoluje.",
"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.",
"send_delay_unsupported": "Prodleva odeslání není pro tento účet podporována.",
"send_delay_unsupported_confirm": "Tento účet nepodporuje prodlevu odeslání. Odeslat ihned?",
"attach_photos": "Photos & Videos",
"attach_files": "Files"
},
"confirm_dialog": {
"confirm": "Potvrdit",
@@ -752,7 +784,7 @@
"keep_editing": "Pokračovat v úpravách",
"tabs": {
"appearance": "Vzhled",
"language": "Jazyk a region",
"language": "Jazyk, region a čas",
"email": "Chování e-mailu",
"composer": "Psaní zpráv",
"privacy": "Soukromí a bezpečnost",
@@ -775,6 +807,7 @@
"layout": "Vzhled",
"reading": "Čtení",
"composing": "Psaní",
"downloads": "Stažené",
"content_senders": "Obsah a odesílatelé",
"about_data": "Info a data",
"debug": "Ladění"
@@ -843,7 +876,11 @@
},
"unified_mailbox": {
"label": "Sjednocená schránka",
"description": "Zobrazovat sloučené složky (Doručené, Odeslané atd.) ze všech připojených účtů"
"description": "Zobrazovat sloučené složky (Doručené, Odeslané atd.) ze všech připojených účtů",
"include_group": {
"label": "Zahrnout skupinové schránky",
"description": "Zahrnout do sjednoceného zobrazení také sdílené/skupinové schránky."
}
},
"colorful_sidebar_icons": {
"label": "Barevné ikony postranního panelu",
@@ -918,6 +955,8 @@
"reenable": "Znovu zaregistrovat",
"relay_desc": "Výchozí je hostovaný relay Bulwark. Změňte pouze pokud používáte vlastní hosting.",
"relay_label": "Push relay",
"relay_locked": "Nastaveno administrátorem",
"relay_locked_desc": "URL push relay byla nastavena administrátorem a nelze ji změnit.",
"relay_placeholder": "https://notifications.relay.example.com",
"status_active": "Aktivní na tomto zařízení",
"status_busy": "Pracuji…",
@@ -927,8 +966,8 @@
}
},
"language_region": {
"title": "Jazyk a region",
"description": "Nakonfigurujte jazykové a místní předvolby",
"title": "Jazyk, region a čas",
"description": "Jazyk, formát data, formát času a další regionální předvolby",
"language": {
"label": "Jazyk",
"description": "Vyberte preferovaný jazyk",
@@ -937,10 +976,13 @@
},
"date_format": {
"label": "Formát data",
"description": "Jak se mají zobrazovat data",
"regional": "Místní",
"iso": "ISO 8601",
"custom": "Vlastní"
"description": "Jak se zobrazují data v seznamu e-mailů",
"smart": "Chytrý (regionál)",
"relative": "Relativní (před 1 h, před 2 d)",
"full": "Vždy úplné datum",
"preview_today": "Dnes:",
"preview_this_week": "Tento týden:",
"preview_older": "Starší:"
},
"time_format": {
"label": "Formát času",
@@ -970,6 +1012,7 @@
"label": "Akce odstranění",
"description": "Co se má stát po odstranění e-mailu",
"trash": "Přesunout do Koše",
"trash_and_read": "Přesunout do Koše a označit jako přečtené",
"permanent": "Trvale odstranit",
"warning": "E-maily budou trvale odstraněny a nebude možné je obnovit. Tato akce je nevratná."
},
@@ -1126,6 +1169,13 @@
"attachment_image_previews": {
"label": "Zobrazit náhledy obrázků v přílohách",
"description": "Zobrazovat obrázkové přílohy jako miniatury místo obecných ikon souborů"
},
"send_delay": {
"label": "Vrátit odeslání / prodleva odeslání",
"description": "Pozdržet běžné odeslání krátkým oknem na serveru.",
"off": "Vypnuto",
"seconds": "{seconds} sekund",
"unsupported": "Aktuální účet neoznamuje podporu odloženého odeslání. Nastavení zůstane uloženo pro jiné účty."
}
},
"composer": {
@@ -1495,6 +1545,50 @@
"categories_description": "Přejmenovat kategorie kontaktů",
"no_categories": "Nenalezeny žádné kategorie"
},
"downloads": {
"title": "Stažené",
"description": "Přizpůsobte si, jak se pojmenovávají stažené e-maily a přílohy.",
"reset": "Obnovit výchozí",
"preview": "Náhled:",
"email_template": {
"label": "Název souboru e-mailu (.eml)",
"description": "Šablona použitá při exportu e-mailu nebo jeho přetažení do souborového systému. Přípona .eml se přidává automaticky."
},
"attachment_template": {
"label": "Název souboru přílohy",
"description": "Šablona použitá při stahování nebo přetahování přílohy. Pokud vynecháte '{filename}' a '{ext}', původní přípona se zachová."
},
"bundle_template": {
"label": "Název .zip souboru s více e-maily",
"description": "Šablona použitá při přetažení nebo stažení několika vybraných e-mailů jako jediného .zip archivu. Přípona .zip se přidává automaticky."
},
"spaces": {
"label": "Mezery",
"description": "Nahradit mezery ve výsledném názvu souboru jiným znakem.",
"keep": "Zachovat mezery",
"underscore": "Nahradit za _",
"dash": "Nahradit za -"
},
"lowercase": {
"label": "Malá písmena",
"description": "Vynutit celý název souboru malými písmeny."
},
"strip_diacritics": {
"label": "Odstranit diakritiku",
"description": "Převést písmena s diakritikou na ASCII ekvivalenty (á → a, é → e). Užitečné pro nástroje, které špatně zpracovávají Unicode názvy."
},
"collapse_separators": {
"label": "Sloučit opakované oddělovače",
"description": "Sloučit posloupnosti mezer, podtržítek nebo pomlček do jednoho znaku."
},
"after_export": {
"label": "Po exportu",
"description": "Po exportu jako .eml lze e-mail volitelně přesunout.",
"keep": "Ponechat ve schránce",
"archive": "Přesunout do archivu",
"trash": "Přesunout do koše"
}
},
"filters": {
"title": "Filtry e-mailů",
"description": "Vytvářejte pravidla pro automatické třídění, štítkování a správu příchozích e-mailů",
@@ -1751,7 +1845,11 @@
"color_tag": "Štítek",
"remove_color": "Odebrat štítek",
"items_selected": "{count} vybraných zpráv",
"edit_draft": "Upravit koncept"
"edit_draft": "Upravit koncept",
"cancel_scheduled_send": "Zrušit odeslání",
"reschedule_send": "Přeplánovat",
"cancel_and_edit": "Zrušit a upravit",
"cancel_and_compose_again": "Zrušit a napsat znovu"
},
"mailbox_context_menu": {
"mark_folder_read": "Označit složku jako přečtenou",
@@ -1760,7 +1858,7 @@
"new_subfolder": "Nová podsložka...",
"new_folder": "Nová složka...",
"rename": "Přejmenovat...",
"import_email": "Importovat .eml...",
"import_email": "Importovat .eml nebo .zip...",
"empty_folder": "Vyprázdnit složku",
"empty_folder_generic": "Vyprázdnit složku",
"delete_folder": "Smazat složku",
@@ -1829,6 +1927,8 @@
"expand_collapse": "Rozbalit/sbalit vlákno"
},
"composer": {
"send": "Odeslat e-mail",
"schedule_send": "Naplánovat odeslání",
"template_picker": "Otevřít výběr šablon"
}
},
+114 -16
View File
@@ -132,7 +132,8 @@
"shared": "Delt",
"mail": "Mail",
"nav_label": "Navigation",
"add_app": "Apps"
"add_app": "Apps",
"scheduled": "Planlagt"
},
"protocol_handlers": {
"title": "Standardapps",
@@ -239,7 +240,16 @@
"confirm_button": "Tøm mappe",
"junk_hint": "Du kan tømme spam-mappen for permanent at fjerne alle beskeder.",
"trash_hint": "Du kan tømme papirkurven for permanent at fjerne alle beskeder."
}
},
"no_scheduled_emails": "Ingen planlagte e-mails",
"no_scheduled_emails_description": "Beskeder planlagt til senere vises her.",
"scheduled_count": "{count, plural, one {1 planlagt e-mail} other {# planlagte e-mails}}",
"scheduled_actions_hint": "Planlagte beskeder kan annulleres, planlægges om eller redigeres fra deres planlagte handlinger.",
"cancel_scheduled_send": "Annuller afsendelse",
"reschedule_send": "Planlæg om",
"cancel_and_edit": "Annuller og redigér",
"cancel_and_compose_again": "Annuller og skriv igen",
"reschedule_prompt": "Indtast en ny dato/tid, f.eks. 2026-05-04T15:30"
},
"email_viewer": {
"no_email_selected": "Ingen e-mail valgt",
@@ -270,7 +280,7 @@
"print": "Udskriv",
"view_source": "Vis kilde",
"export_email": "Eksportér som .eml",
"import_email": "Importér .eml",
"import_email": "Importér .eml eller .zip",
"keyboard_shortcuts": "Tastaturgenveje (?)",
"email_source": "E-mail-kilde",
"draft_banner": "Denne besked er en kladde",
@@ -514,7 +524,17 @@
"collapse": "Skjul detaljer"
},
"send": "Send",
"more": "mere"
"more": "mere",
"scheduled_banner": "Planlagt til afsendelse {date}",
"scheduled_send_created": "E-mail planlagt til afsendelse",
"cancel_scheduled_send": "Annuller afsendelse",
"reschedule_send": "Planlæg om",
"cancel_and_edit": "Annuller og redigér",
"cancel_and_compose_again": "Annuller og skriv igen",
"reschedule_prompt": "Indtast en ny dato/tid, f.eks. 2026-05-04T15:30",
"scheduled_actions_only": "Planlagt-visningen understøtter kun planlagte handlinger",
"undo_send_scheduled": "Besked planlagt til afsendelse",
"undo_send": "Fortryd afsendelse"
},
"email_composer": {
"new_message": "Ny besked",
@@ -607,7 +627,17 @@
"send_anyway": "Send alligevel",
"back": "Tilbage til redigering"
},
"link_url_prompt": "Indtast URL'en"
"link_url_prompt": "Indtast URL'en",
"schedule_send": "Planlæg afsendelse",
"schedule_send_description": "Vælg hvornår serveren skal frigive denne besked.",
"schedule_send_required": "Vælg en dato og et tidspunkt.",
"schedule_send_invalid": "Indtast en gyldig dato og tid.",
"schedule_send_future": "Vælg en fremtidig dato og tid.",
"schedule_send_too_late": "Dette tidspunkt er senere end serveren tillader.",
"schedule_send_unsupported": "Planlagt afsendelse understøttes ikke for denne konto.",
"schedule_send_cleanup_warning": "Planlagt afsendelse blev oprettet, men oprydning af kladde mislykkedes.",
"send_delay_unsupported": "Sendeforsinkelse understøttes ikke for denne konto.",
"send_delay_unsupported_confirm": "Denne konto understøtter ikke sendeforsinkelse. Send straks i stedet?"
},
"confirm_dialog": {
"confirm": "Bekræft",
@@ -755,7 +785,7 @@
"search_no_results": "Ingen match i indstillinger",
"tabs": {
"appearance": "Udseende",
"language": "Sprog & region",
"language": "Sprog, region & tid",
"email": "E-mail-adfærd",
"composer": "Komponist",
"privacy": "Privatliv & sikkerhed",
@@ -778,6 +808,7 @@
"layout": "Layout",
"reading": "Læsning",
"composing": "Skrivning",
"downloads": "Downloads",
"content_senders": "Indhold & afsendere",
"about_data": "Om & data",
"debug": "Debug"
@@ -846,7 +877,11 @@
},
"unified_mailbox": {
"label": "Samlet postkasse",
"description": "Vis samlede mapper (Indbakke, Sendt osv.) på tværs af alle tilknyttede konti"
"description": "Vis samlede mapper (Indbakke, Sendt osv.) på tværs af alle tilknyttede konti",
"include_group": {
"label": "Inkluder gruppepostkasser",
"description": "Inkluder også delte/gruppepostkasser i den samlede visning."
}
},
"colorful_sidebar_icons": {
"label": "Farverige sidepane-ikoner",
@@ -892,6 +927,8 @@
"description": "Modtag systemnotifikationer for ny mail, når dette site er lukket. Leveres via Bulwark push-relæet; relæet ser aldrig mail-indhold.",
"relay_label": "Push-relæ",
"relay_desc": "Som standard det hosted Bulwark-relæ. Ændr kun, hvis du selv-host'er.",
"relay_locked": "Indstillet af administrator",
"relay_locked_desc": "Push-relæ-URL'en er indstillet af din administrator og kan ikke ændres.",
"relay_placeholder": "https://notifikationer.relay.eksempel.dk",
"status_active": "Aktiv på denne enhed",
"status_inactive": "Ikke aktiveret på denne enhed",
@@ -930,8 +967,8 @@
}
},
"language_region": {
"title": "Sprog & region",
"description": "Konfigurér sprog og regionale præferencer",
"title": "Sprog, region & tid",
"description": "Sprog, datoformat, tidsformat og andre regionale indstillinger",
"language": {
"label": "Sprog",
"description": "Vælg dit foretrukne sprog",
@@ -940,10 +977,13 @@
},
"date_format": {
"label": "Datoformat",
"description": "Hvordan datoer skal vises",
"regional": "Regionalt",
"iso": "ISO 8601",
"custom": "Brugerdefineret"
"description": "Sådan vises datoer på e-mail-listen",
"smart": "Smart (regionalt)",
"relative": "Relativ (for 1 t siden, for 2 d siden)",
"full": "Altid fuld dato",
"preview_today": "I dag:",
"preview_this_week": "Denne uge:",
"preview_older": "Ældre:"
},
"time_format": {
"label": "Tidsformat",
@@ -973,6 +1013,7 @@
"label": "Slet-handling",
"description": "Hvad der sker, når du sletter en e-mail",
"trash": "Flyt til papirkurv",
"trash_and_read": "Flyt til papirkurv og marker som læst",
"permanent": "Slet permanent",
"warning": "E-mails vil blive slettet permanent og kan ikke gendannes. Denne handling er irreversibel."
},
@@ -1129,6 +1170,13 @@
"attachment_image_previews": {
"label": "Vis billedforhåndsvisninger i vedhæftninger",
"description": "Vis billedvedhæftninger som miniaturekort i stedet for generiske filikoner"
},
"send_delay": {
"label": "Fortryd afsendelse / sendeforsinkelse",
"description": "Forsink normale afsendelser med et kort serverstyret tidsrum.",
"off": "Fra",
"seconds": "{seconds} sekunder",
"unsupported": "Den aktuelle konto annoncerer ikke understøttelse af forsinket afsendelse. Indstillingen gemmes stadig for andre konti."
}
},
"composer": {
@@ -1498,6 +1546,50 @@
"categories_description": "Omdøb kontaktkategorier",
"no_categories": "Ingen kategorier fundet"
},
"downloads": {
"title": "Downloads",
"description": "Tilpas hvordan downloadede e-mails og vedhæftede filer navngives.",
"reset": "Gendan standard",
"preview": "Forhåndsvisning:",
"email_template": {
"label": "E-mail-filnavn (.eml)",
"description": "Skabelon som bruges når du eksporterer en e-mail eller trækker den ud i filsystemet. Endelsen .eml tilføjes automatisk."
},
"attachment_template": {
"label": "Filnavn på vedhæftet fil",
"description": "Skabelon som bruges når du downloader eller trækker en vedhæftet fil ud. Hvis du udelader '{filename}' og '{ext}', bevares den oprindelige endelse."
},
"bundle_template": {
"label": "Filnavn for .zip med flere e-mails",
"description": "Skabelon som bruges når du trækker ud eller downloader flere valgte e-mails som ét .zip-arkiv. Endelsen .zip tilføjes automatisk."
},
"spaces": {
"label": "Mellemrum",
"description": "Erstat mellemrum i det endelige filnavn med et andet tegn.",
"keep": "Behold mellemrum",
"underscore": "Erstat med _",
"dash": "Erstat med -"
},
"lowercase": {
"label": "Små bogstaver",
"description": "Tving hele filnavnet til små bogstaver."
},
"strip_diacritics": {
"label": "Fjern diakritiske tegn",
"description": "Konvertér bogstaver med accenter til deres ASCII-pendant (ä → a, é → e). Nyttigt for værktøjer, der ikke håndterer Unicode-navne korrekt."
},
"collapse_separators": {
"label": "Slå gentagne skilletegn sammen",
"description": "Slå serier af mellemrum, understreger eller bindestreger sammen til ét tegn."
},
"after_export": {
"label": "Efter eksport",
"description": "Flyt eventuelt e-mailen efter den er eksporteret som .eml.",
"keep": "Behold i postkassen",
"archive": "Flyt til arkiv",
"trash": "Flyt til papirkurv"
}
},
"filters": {
"title": "E-mail-filtre",
"description": "Opret regler til automatisk at sortere, mærke og administrere indkommende e-mails",
@@ -1751,7 +1843,11 @@
"color_tag": "Tag",
"remove_color": "Fjern tag",
"items_selected": "{count} e-mails valgt",
"edit_draft": "Redigér kladde"
"edit_draft": "Redigér kladde",
"cancel_scheduled_send": "Annuller afsendelse",
"reschedule_send": "Planlæg om",
"cancel_and_edit": "Annuller og redigér",
"cancel_and_compose_again": "Annuller og skriv igen"
},
"mailbox_context_menu": {
"mark_folder_read": "Markér mappe som læst",
@@ -1760,7 +1856,7 @@
"new_subfolder": "Ny undermappe...",
"new_folder": "Ny mappe...",
"rename": "Omdøb...",
"import_email": "Importér .eml...",
"import_email": "Importér .eml eller .zip...",
"empty_folder": "Tøm mappe",
"empty_folder_generic": "Tøm mappe",
"delete_folder": "Slet mappe",
@@ -1829,6 +1925,8 @@
"expand_collapse": "Udvid/skjul tråd"
},
"composer": {
"send": "Send e-mail",
"schedule_send": "Planlæg afsendelse",
"template_picker": "Åbn skabelonvælger"
}
},
@@ -2948,4 +3046,4 @@
"unified_mailbox": {
"search_unavailable": "Søgning er ikke tilgængelig i den samlede visning"
}
}
}
+115 -15
View File
@@ -132,7 +132,8 @@
"mail": "E-Mail",
"nav_label": "Navigation",
"add_app": "Apps",
"shared": "Geteilt"
"shared": "Geteilt",
"scheduled": "Geplant"
},
"protocol_handlers": {
"title": "Standard-Apps",
@@ -239,7 +240,16 @@
"confirm_button": "Ordner leeren",
"junk_hint": "Sie können den Spam-Ordner leeren, um alle Nachrichten dauerhaft zu entfernen.",
"trash_hint": "Sie können den Papierkorb leeren, um alle Nachrichten dauerhaft zu entfernen."
}
},
"no_scheduled_emails": "Keine geplanten E-Mails",
"no_scheduled_emails_description": "Nachrichten, die für später geplant sind, erscheinen hier.",
"scheduled_count": "{count, plural, one {1 geplante E-Mail} other {# geplante E-Mails}}",
"scheduled_actions_hint": "Geplante Nachrichten können über die Planungsaktionen abgebrochen, neu geplant oder bearbeitet werden.",
"cancel_scheduled_send": "Senden abbrechen",
"reschedule_send": "Neu planen",
"cancel_and_edit": "Abbrechen und bearbeiten",
"cancel_and_compose_again": "Abbrechen und neu verfassen",
"reschedule_prompt": "Neues Datum/Uhrzeit eingeben, z. B. 2026-05-04T15:30"
},
"email_viewer": {
"no_email_selected": "Keine E-Mail ausgewählt",
@@ -270,7 +280,7 @@
"print": "Drucken",
"view_source": "Quelltext anzeigen",
"export_email": "Als .eml exportieren",
"import_email": ".eml importieren",
"import_email": ".eml oder .zip importieren",
"keyboard_shortcuts": "Tastaturkürzel (?)",
"email_source": "E-Mail-Quelltext",
"draft_banner": "Diese Nachricht ist ein Entwurf",
@@ -514,7 +524,17 @@
"ai_verdict": "KI-Urteil",
"account": "Konto",
"no_subject": "(kein Betreff)"
}
},
"scheduled_banner": "Geplant zum Senden um {date}",
"scheduled_send_created": "E-Mail wurde zum Senden geplant",
"cancel_scheduled_send": "Senden abbrechen",
"reschedule_send": "Neu planen",
"cancel_and_edit": "Abbrechen und bearbeiten",
"cancel_and_compose_again": "Abbrechen und neu verfassen",
"reschedule_prompt": "Neues Datum/Uhrzeit eingeben, z. B. 2026-05-04T15:30",
"scheduled_actions_only": "Die Geplant-Ansicht unterstützt nur Planungsaktionen",
"undo_send_scheduled": "Nachricht ist zum Senden geplant",
"undo_send": "Senden rückgängig"
},
"email_composer": {
"new_message": "Neue Nachricht",
@@ -607,7 +627,19 @@
},
"add_link": "Link hinzufügen",
"link_url_prompt": "URL eingeben",
"sending": "Wird gesendet..."
"sending": "Wird gesendet...",
"schedule_send": "Senden planen",
"schedule_send_description": "Wählen Sie, wann der Server diese Nachricht freigeben soll.",
"schedule_send_required": "Wählen Sie Datum und Uhrzeit.",
"schedule_send_invalid": "Geben Sie ein gültiges Datum und eine gültige Uhrzeit ein.",
"schedule_send_future": "Wählen Sie ein Datum und eine Uhrzeit in der Zukunft.",
"schedule_send_too_late": "Dieser Zeitpunkt liegt später, als der Server erlaubt.",
"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.",
"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?",
"attach_photos": "Photos & Videos",
"attach_files": "Files"
},
"confirm_dialog": {
"confirm": "Bestätigen",
@@ -752,7 +784,7 @@
"keep_editing": "Weiter bearbeiten",
"tabs": {
"appearance": "Darstellung",
"language": "Sprache & Region",
"language": "Sprache, Region & Zeit",
"email": "E-Mail-Verhalten",
"composer": "Editor",
"privacy": "Datenschutz & Sicherheit",
@@ -775,6 +807,7 @@
"layout": "Layout",
"reading": "Lesen",
"composing": "Verfassen",
"downloads": "Downloads",
"content_senders": "Inhalte & Absender",
"about_data": "Über & Daten",
"debug": "Debug"
@@ -843,7 +876,11 @@
},
"unified_mailbox": {
"label": "Gemeinsames Postfach",
"description": "Kombinierte Ordner (Posteingang, Gesendet usw.) für alle verbundenen Konten anzeigen"
"description": "Kombinierte Ordner (Posteingang, Gesendet usw.) für alle verbundenen Konten anzeigen",
"include_group": {
"label": "Gruppenpostfächer einbeziehen",
"description": "Gemeinsam genutzte/Gruppenpostfächer ebenfalls in die vereinheitlichte Ansicht aufnehmen."
}
},
"colorful_sidebar_icons": {
"label": "Farbige Seitenleistensymbole",
@@ -918,6 +955,8 @@
"reenable": "Neu registrieren",
"relay_desc": "Standardmäßig wird das gehostete Bulwark-Relay verwendet. Nur ändern, wenn Sie selbst hosten.",
"relay_label": "Push-Relay",
"relay_locked": "Vom Administrator festgelegt",
"relay_locked_desc": "Die Push-Relay-URL wurde von Ihrem Administrator festgelegt und kann nicht geändert werden.",
"relay_placeholder": "https://notifications.relay.example.com",
"status_active": "Auf diesem Gerät aktiv",
"status_busy": "Wird verarbeitet…",
@@ -927,8 +966,8 @@
}
},
"language_region": {
"title": "Sprache & Region",
"description": "Konfigurieren Sie Sprach- und Regionaleinstellungen",
"title": "Sprache, Region & Zeit",
"description": "Sprache, Datums- und Zeitformat sowie weitere regionale Einstellungen",
"language": {
"label": "Sprache",
"description": "Wählen Sie Ihre bevorzugte Sprache",
@@ -937,10 +976,13 @@
},
"date_format": {
"label": "Datumsformat",
"description": "Wie Daten angezeigt werden sollen",
"regional": "Regional",
"iso": "ISO 8601",
"custom": "Benutzerdefiniert"
"description": "Wie Daten in der E-Mail-Liste angezeigt werden",
"smart": "Intelligent (gebietsschemaabhängig)",
"relative": "Relativ (vor 1 Std., vor 2 Tagen)",
"full": "Immer vollständiges Datum",
"preview_today": "Heute:",
"preview_this_week": "Diese Woche:",
"preview_older": "Älter:"
},
"time_format": {
"label": "Zeitformat",
@@ -970,6 +1012,7 @@
"label": "Löschaktion",
"description": "Was passiert, wenn Sie eine E-Mail löschen",
"trash": "In Papierkorb verschieben",
"trash_and_read": "In Papierkorb verschieben und als gelesen markieren",
"permanent": "Dauerhaft löschen",
"warning": "E-Mails werden dauerhaft gelöscht und können nicht wiederhergestellt werden. Diese Aktion ist unwiderruflich."
},
@@ -1126,6 +1169,13 @@
"attachment_image_previews": {
"label": "Bildvorschau in Anhängen anzeigen",
"description": "Bildanhänge als Miniaturansichten statt als generische Dateisymbole anzeigen"
},
"send_delay": {
"label": "Senden rückgängig / Sendeverzögerung",
"description": "Normales Senden serverseitig kurz verzögern.",
"off": "Aus",
"seconds": "{seconds} Sekunden",
"unsupported": "Das aktuelle Konto meldet keine Unterstützung für verzögertes Senden. Die Einstellung bleibt für andere Konten gespeichert."
}
},
"composer": {
@@ -1495,6 +1545,50 @@
"group_by_letter_description": "Alphabetische Abschnittsüberschriften in der Kontaktliste anzeigen",
"group_by_letter_label": "Nach Anfangsbuchstaben gruppieren"
},
"downloads": {
"title": "Downloads",
"description": "Lege fest, wie heruntergeladene E-Mails und Anhänge benannt werden.",
"reset": "Standard wiederherstellen",
"preview": "Vorschau:",
"email_template": {
"label": "E-Mail-Dateiname (.eml)",
"description": "Vorlage, die beim Exportieren oder Herausziehen einer E-Mail verwendet wird. Die Endung .eml wird automatisch ergänzt."
},
"attachment_template": {
"label": "Anhang-Dateiname",
"description": "Vorlage zum Herunterladen oder Herausziehen eines Anhangs. Wenn '{filename}' und '{ext}' fehlen, bleibt die ursprüngliche Endung erhalten."
},
"bundle_template": {
"label": "Mehrere E-Mails als .zip",
"description": "Vorlage, die beim Herausziehen oder Herunterladen mehrerer ausgewählter E-Mails als einzelnes .zip-Archiv verwendet wird. Die Endung .zip wird automatisch ergänzt."
},
"spaces": {
"label": "Leerzeichen",
"description": "Ersetze Leerzeichen im Dateinamen durch ein anderes Zeichen.",
"keep": "Leerzeichen behalten",
"underscore": "Durch _ ersetzen",
"dash": "Durch - ersetzen"
},
"lowercase": {
"label": "Kleinschreibung",
"description": "Wandle den gesamten Dateinamen in Kleinbuchstaben um."
},
"strip_diacritics": {
"label": "Diakritika entfernen",
"description": "Wandle Buchstaben mit Akzenten in ASCII-Äquivalente um (ä → a, é → e). Nützlich für Tools, die Unicode-Dateinamen verstümmeln."
},
"collapse_separators": {
"label": "Trennzeichen zusammenfassen",
"description": "Aufeinanderfolgende Leerzeichen, Unterstriche oder Bindestriche zu einem Zeichen zusammenfassen."
},
"after_export": {
"label": "Nach dem Export",
"description": "E-Mail nach dem Export als .eml optional verschieben.",
"keep": "Im Postfach behalten",
"archive": "Ins Archiv verschieben",
"trash": "In den Papierkorb verschieben"
}
},
"filters": {
"title": "E-Mail-Filter",
"description": "Erstellen Sie Regeln, um eingehende E-Mails automatisch zu sortieren, zu kennzeichnen und zu verwalten",
@@ -1751,7 +1845,11 @@
"color_tag": "Label",
"remove_color": "Label entfernen",
"items_selected": "{count} E-Mails ausgewählt",
"edit_draft": "Entwurf bearbeiten"
"edit_draft": "Entwurf bearbeiten",
"cancel_scheduled_send": "Senden abbrechen",
"reschedule_send": "Neu planen",
"cancel_and_edit": "Abbrechen und bearbeiten",
"cancel_and_compose_again": "Abbrechen und neu verfassen"
},
"mailbox_context_menu": {
"mark_folder_read": "Ordner als gelesen markieren",
@@ -1760,7 +1858,7 @@
"new_subfolder": "Neuer Unterordner...",
"new_folder": "Neuer Ordner...",
"rename": "Umbenennen...",
"import_email": ".eml importieren...",
"import_email": ".eml oder .zip importieren...",
"empty_folder": "Ordner leeren",
"empty_folder_generic": "Ordner leeren",
"delete_folder": "Ordner löschen",
@@ -1829,6 +1927,8 @@
"expand_collapse": "Unterhaltung erweitern/einklappen"
},
"composer": {
"send": "E-Mail senden",
"schedule_send": "Senden planen",
"template_picker": "Vorlagenauswahl öffnen"
}
},
+113 -15
View File
@@ -132,7 +132,8 @@
"shared": "Shared",
"mail": "Mail",
"nav_label": "Navigation",
"add_app": "Apps"
"add_app": "Apps",
"scheduled": "Scheduled"
},
"protocol_handlers": {
"title": "Default apps",
@@ -239,7 +240,16 @@
"confirm_button": "Empty folder",
"junk_hint": "You can empty the Junk folder to permanently remove all messages.",
"trash_hint": "You can empty the Trash folder to permanently remove all messages."
}
},
"no_scheduled_emails": "No scheduled emails",
"no_scheduled_emails_description": "Messages scheduled for later will appear here.",
"scheduled_count": "{count, plural, one {1 scheduled email} other {# scheduled emails}}",
"scheduled_actions_hint": "Scheduled messages can be canceled, rescheduled, or edited from their scheduled actions.",
"cancel_scheduled_send": "Cancel send",
"reschedule_send": "Reschedule",
"cancel_and_edit": "Cancel and edit",
"cancel_and_compose_again": "Cancel and compose again",
"reschedule_prompt": "Enter a new date/time, e.g. 2026-05-04T15:30"
},
"email_viewer": {
"no_email_selected": "No email selected",
@@ -270,7 +280,7 @@
"print": "Print",
"view_source": "View source",
"export_email": "Export as .eml",
"import_email": "Import .eml",
"import_email": "Import .eml or .zip",
"keyboard_shortcuts": "Keyboard shortcuts (?)",
"email_source": "Email Source",
"draft_banner": "This message is a draft",
@@ -514,7 +524,17 @@
"collapse": "Hide details"
},
"send": "Send",
"more": "more"
"more": "more",
"scheduled_banner": "Scheduled to send at {date}",
"scheduled_send_created": "Email scheduled for sending",
"cancel_scheduled_send": "Cancel send",
"reschedule_send": "Reschedule",
"cancel_and_edit": "Cancel and edit",
"cancel_and_compose_again": "Cancel and compose again",
"reschedule_prompt": "Enter a new date/time, e.g. 2026-05-04T15:30",
"scheduled_actions_only": "Scheduled view supports scheduled actions only",
"undo_send_scheduled": "Message scheduled for sending",
"undo_send": "Undo send"
},
"email_composer": {
"new_message": "New Message",
@@ -607,7 +627,17 @@
"message": "Your message mentions \"{keyword}\" but no file is attached. Send anyway?",
"send_anyway": "Send anyway",
"back": "Back to editing"
}
},
"schedule_send": "Schedule send",
"schedule_send_description": "Choose when the server should release this message.",
"schedule_send_required": "Choose a date and time.",
"schedule_send_invalid": "Enter a valid date and time.",
"schedule_send_future": "Choose a future date and time.",
"schedule_send_too_late": "This time is later than the server allows.",
"schedule_send_unsupported": "Scheduled send is not supported for this account.",
"schedule_send_cleanup_warning": "Scheduled send was created, but draft cleanup failed.",
"send_delay_unsupported": "Send delay is not supported for this account.",
"send_delay_unsupported_confirm": "This account does not support send delay. Send immediately instead?"
},
"confirm_dialog": {
"confirm": "Confirm",
@@ -755,7 +785,7 @@
"search_no_results": "No matching settings",
"tabs": {
"appearance": "Appearance",
"language": "Language & Region",
"language": "Language, Region & Time",
"email": "Email Behavior",
"composer": "Composer",
"privacy": "Privacy & Security",
@@ -778,6 +808,7 @@
"layout": "Layout",
"reading": "Reading",
"composing": "Composing",
"downloads": "Downloads",
"content_senders": "Content & Senders",
"about_data": "About & Data",
"debug": "Debug"
@@ -846,7 +877,11 @@
},
"unified_mailbox": {
"label": "Unified Mailbox",
"description": "Show combined folders (Inbox, Sent, etc.) across all connected accounts"
"description": "Show combined folders (Inbox, Sent, etc.) across all connected accounts",
"include_group": {
"label": "Include group inboxes",
"description": "Also merge shared/group inboxes into the unified view."
}
},
"colorful_sidebar_icons": {
"label": "Colorful Sidebar Icons",
@@ -892,6 +927,8 @@
"description": "Receive system notifications for new mail when this site is closed. Delivered via the Bulwark push relay; the relay never sees mail content.",
"relay_label": "Push relay",
"relay_desc": "Defaults to the hosted Bulwark relay. Change only if you self-host.",
"relay_locked": "Set by administrator",
"relay_locked_desc": "The push relay URL has been set by your administrator and cannot be changed.",
"relay_placeholder": "https://notifications.relay.example.com",
"status_active": "Active on this device",
"status_inactive": "Not enabled on this device",
@@ -930,8 +967,8 @@
}
},
"language_region": {
"title": "Language & Region",
"description": "Configure language and regional preferences",
"title": "Language, Region & Time",
"description": "Language, date format, time format, and other regional preferences",
"language": {
"label": "Language",
"description": "Choose your preferred language",
@@ -940,10 +977,13 @@
},
"date_format": {
"label": "Date Format",
"description": "How dates should be displayed",
"regional": "Regional",
"iso": "ISO 8601",
"custom": "Custom"
"description": "How dates are shown in the email list",
"smart": "Smart (locale-aware)",
"relative": "Relative (1h ago, 2d ago)",
"full": "Always full date",
"preview_today": "Today:",
"preview_this_week": "This week:",
"preview_older": "Older:"
},
"time_format": {
"label": "Time Format",
@@ -973,6 +1013,7 @@
"label": "Delete Action",
"description": "What happens when you delete an email",
"trash": "Move to Trash",
"trash_and_read": "Move to Trash and mark as read",
"permanent": "Delete Permanently",
"warning": "Emails will be permanently deleted and cannot be recovered. This action is irreversible."
},
@@ -1129,6 +1170,13 @@
"attachment_image_previews": {
"label": "Show image previews in attachments",
"description": "Render image attachments as thumbnail cards instead of generic file icons"
},
"send_delay": {
"label": "Undo send / send delay",
"description": "Delay normal sends by a short server-side window.",
"off": "Off",
"seconds": "{seconds} seconds",
"unsupported": "The current account does not advertise delayed-send support. The setting is still saved for other accounts."
}
},
"composer": {
@@ -1498,6 +1546,50 @@
"categories_description": "Rename contact categories",
"no_categories": "No categories found"
},
"downloads": {
"title": "Downloads",
"description": "Customize how downloaded emails and attachments are named.",
"reset": "Restore default",
"preview": "Preview:",
"email_template": {
"label": "Email (.eml) filename",
"description": "Template used when you export an email or drag one out to the file system. The .eml extension is added automatically."
},
"attachment_template": {
"label": "Attachment filename",
"description": "Template used when downloading or dragging out an attachment. If you omit '{filename}' and '{ext}', the original extension is preserved."
},
"bundle_template": {
"label": "Multi-email .zip filename",
"description": "Template used when you drag out or download several selected emails as a single .zip archive. The .zip extension is added automatically."
},
"spaces": {
"label": "Spaces",
"description": "Replace spaces in the resulting filename with another character.",
"keep": "Keep spaces",
"underscore": "Replace with _",
"dash": "Replace with -"
},
"lowercase": {
"label": "Lowercase",
"description": "Force the entire filename to lowercase."
},
"strip_diacritics": {
"label": "Strip diacritics",
"description": "Convert accented letters to their ASCII equivalents (ä → a, é → e). Useful for tools that mangle Unicode filenames."
},
"collapse_separators": {
"label": "Collapse repeated separators",
"description": "Collapse runs of spaces, underscores, or dashes to a single character."
},
"after_export": {
"label": "After export",
"description": "Optionally move the email after exporting it as .eml.",
"keep": "Keep in mailbox",
"archive": "Move to archive",
"trash": "Move to trash"
}
},
"filters": {
"title": "Email Filters",
"description": "Create rules to automatically sort, label, and manage incoming emails",
@@ -1751,7 +1843,11 @@
"color_tag": "Tag",
"remove_color": "Remove tag",
"items_selected": "{count} emails selected",
"edit_draft": "Edit Draft"
"edit_draft": "Edit Draft",
"cancel_scheduled_send": "Cancel send",
"reschedule_send": "Reschedule",
"cancel_and_edit": "Cancel and edit",
"cancel_and_compose_again": "Cancel and compose again"
},
"mailbox_context_menu": {
"mark_folder_read": "Mark folder as read",
@@ -1760,7 +1856,7 @@
"new_subfolder": "New subfolder...",
"new_folder": "New folder...",
"rename": "Rename...",
"import_email": "Import .eml...",
"import_email": "Import .eml or .zip...",
"empty_folder": "Empty folder",
"empty_folder_generic": "Empty folder",
"delete_folder": "Delete folder",
@@ -1829,6 +1925,8 @@
"expand_collapse": "Expand/collapse thread"
},
"composer": {
"send": "Send email",
"schedule_send": "Schedule send",
"template_picker": "Open template picker"
}
},
+115 -15
View File
@@ -132,7 +132,8 @@
"mail": "Correo",
"nav_label": "Navegación",
"add_app": "Apps",
"shared": "Compartido"
"shared": "Compartido",
"scheduled": "Programados"
},
"protocol_handlers": {
"title": "Aplicaciones predeterminadas",
@@ -239,7 +240,16 @@
"confirm_button": "Vaciar carpeta",
"junk_hint": "Puede vaciar la carpeta de Spam para eliminar permanentemente todos los mensajes.",
"trash_hint": "Puede vaciar la Papelera para eliminar permanentemente todos los mensajes."
}
},
"no_scheduled_emails": "No hay correos programados",
"no_scheduled_emails_description": "Los mensajes programados para más tarde aparecerán aquí.",
"scheduled_count": "{count, plural, one {1 correo programado} other {# correos programados}}",
"scheduled_actions_hint": "Los mensajes programados se pueden cancelar, reprogramar o editar desde sus acciones.",
"cancel_scheduled_send": "Cancelar envío",
"reschedule_send": "Reprogramar",
"cancel_and_edit": "Cancelar y editar",
"cancel_and_compose_again": "Cancelar y redactar de nuevo",
"reschedule_prompt": "Introduce una nueva fecha/hora, p. ej. 2026-05-04T15:30"
},
"email_viewer": {
"no_email_selected": "Ningún correo seleccionado",
@@ -270,7 +280,7 @@
"print": "Imprimir",
"view_source": "Ver código fuente",
"export_email": "Exportar como .eml",
"import_email": "Importar .eml",
"import_email": "Importar .eml o .zip",
"keyboard_shortcuts": "Atajos de teclado (?)",
"email_source": "Código Fuente del Correo",
"draft_banner": "Este mensaje es un borrador",
@@ -514,7 +524,17 @@
"ai_verdict": "Veredicto de IA",
"account": "Cuenta",
"no_subject": "(sin asunto)"
}
},
"scheduled_banner": "Programado para enviarse el {date}",
"scheduled_send_created": "Correo programado para envío",
"cancel_scheduled_send": "Cancelar envío",
"reschedule_send": "Reprogramar",
"cancel_and_edit": "Cancelar y editar",
"cancel_and_compose_again": "Cancelar y redactar de nuevo",
"reschedule_prompt": "Introduce una nueva fecha/hora, p. ej. 2026-05-04T15:30",
"scheduled_actions_only": "La vista Programados solo admite acciones de programación",
"undo_send_scheduled": "Mensaje programado para envío",
"undo_send": "Deshacer envío"
},
"email_composer": {
"new_message": "Nuevo Mensaje",
@@ -607,7 +627,19 @@
},
"add_link": "Añadir enlace",
"link_url_prompt": "Introduce la URL",
"sending": "Enviando..."
"sending": "Enviando...",
"schedule_send": "Programar envío",
"schedule_send_description": "Elige cuándo debe liberar el servidor este mensaje.",
"schedule_send_required": "Elige una fecha y hora.",
"schedule_send_invalid": "Introduce una fecha y hora válidas.",
"schedule_send_future": "Elige una fecha y hora futuras.",
"schedule_send_too_late": "Esta hora es posterior a lo que permite el servidor.",
"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.",
"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?",
"attach_photos": "Photos & Videos",
"attach_files": "Files"
},
"confirm_dialog": {
"confirm": "Confirmar",
@@ -752,7 +784,7 @@
"keep_editing": "Seguir editando",
"tabs": {
"appearance": "Apariencia",
"language": "Idioma y Región",
"language": "Idioma, Región y Hora",
"email": "Comportamiento del Correo",
"composer": "Editor",
"privacy": "Privacidad y Seguridad",
@@ -775,6 +807,7 @@
"layout": "Diseño",
"reading": "Lectura",
"composing": "Redacción",
"downloads": "Descargas",
"content_senders": "Contenido y remitentes",
"about_data": "Acerca de y datos",
"debug": "Depuración"
@@ -843,7 +876,11 @@
},
"unified_mailbox": {
"label": "Buzón unificado",
"description": "Mostrar carpetas combinadas (Entrada, Enviados, etc.) de todas las cuentas conectadas"
"description": "Mostrar carpetas combinadas (Entrada, Enviados, etc.) de todas las cuentas conectadas",
"include_group": {
"label": "Incluir buzones de grupo",
"description": "Incluir también los buzones compartidos o de grupo en la vista unificada."
}
},
"colorful_sidebar_icons": {
"label": "Iconos de barra lateral a color",
@@ -918,6 +955,8 @@
"reenable": "Volver a registrar",
"relay_desc": "Usa el relay alojado de Bulwark de forma predeterminada. Cámbialo solo si te alojas tú mismo.",
"relay_label": "Relay push",
"relay_locked": "Establecido por el administrador",
"relay_locked_desc": "La URL del relay push ha sido establecida por tu administrador y no se puede cambiar.",
"relay_placeholder": "https://notifications.relay.example.com",
"status_active": "Activo en este dispositivo",
"status_busy": "Trabajando…",
@@ -927,8 +966,8 @@
}
},
"language_region": {
"title": "Idioma y Región",
"description": "Configure las preferencias de idioma y región",
"title": "Idioma, Región y Hora",
"description": "Idioma, formato de fecha, formato de hora y otras preferencias regionales",
"language": {
"label": "Idioma",
"description": "Elija su idioma preferido",
@@ -937,10 +976,13 @@
},
"date_format": {
"label": "Formato de Fecha",
"description": "Cómo se deben mostrar las fechas",
"regional": "Regional",
"iso": "ISO 8601",
"custom": "Personalizado"
"description": "Cómo se muestran las fechas en la lista de correos",
"smart": "Inteligente (según región)",
"relative": "Relativo (hace 1 h, hace 2 d)",
"full": "Fecha completa siempre",
"preview_today": "Hoy:",
"preview_this_week": "Esta semana:",
"preview_older": "Más antiguo:"
},
"time_format": {
"label": "Formato de Hora",
@@ -970,6 +1012,7 @@
"label": "Acción al Eliminar",
"description": "Qué sucede cuando elimina un correo",
"trash": "Mover a Papelera",
"trash_and_read": "Mover a Papelera y marcar como leído",
"permanent": "Eliminar Permanentemente",
"warning": "Los correos se eliminarán permanentemente y no se podrán recuperar. Esta acción es irreversible."
},
@@ -1126,6 +1169,13 @@
"attachment_image_previews": {
"label": "Mostrar vistas previas de imágenes en adjuntos",
"description": "Mostrar las imágenes adjuntas como tarjetas de miniatura en lugar de iconos de archivo genéricos"
},
"send_delay": {
"label": "Deshacer envío / demora de envío",
"description": "Retrasa envíos normales con una breve ventana del servidor.",
"off": "Desactivado",
"seconds": "{seconds} segundos",
"unsupported": "La cuenta actual no anuncia compatibilidad con envío demorado. La opción seguirá guardada para otras cuentas."
}
},
"composer": {
@@ -1495,6 +1545,50 @@
"group_by_letter_description": "Mostrar encabezados alfabéticos en la lista de contactos",
"group_by_letter_label": "Agrupar por primera letra"
},
"downloads": {
"title": "Descargas",
"description": "Personaliza cómo se nombran los correos y archivos adjuntos descargados.",
"reset": "Restaurar predeterminado",
"preview": "Vista previa:",
"email_template": {
"label": "Nombre del archivo de correo (.eml)",
"description": "Plantilla usada al exportar un correo o al arrastrarlo al sistema de archivos. La extensión .eml se añade automáticamente."
},
"attachment_template": {
"label": "Nombre del archivo adjunto",
"description": "Plantilla usada al descargar o arrastrar un adjunto. Si omites '{filename}' y '{ext}', se conserva la extensión original."
},
"bundle_template": {
"label": "Nombre de archivo .zip multi-correo",
"description": "Plantilla utilizada al arrastrar o descargar varios correos seleccionados como un único archivo .zip. La extensión .zip se añade automáticamente."
},
"spaces": {
"label": "Espacios",
"description": "Reemplaza los espacios del nombre de archivo resultante por otro carácter.",
"keep": "Conservar espacios",
"underscore": "Reemplazar por _",
"dash": "Reemplazar por -"
},
"lowercase": {
"label": "Minúsculas",
"description": "Forzar todo el nombre del archivo a minúsculas."
},
"strip_diacritics": {
"label": "Eliminar diacríticos",
"description": "Convierte las letras acentuadas en sus equivalentes ASCII (ä → a, é → e). Útil para herramientas que estropean nombres Unicode."
},
"collapse_separators": {
"label": "Combinar separadores repetidos",
"description": "Combina secuencias de espacios, guiones bajos o guiones en un único carácter."
},
"after_export": {
"label": "Después de exportar",
"description": "Opcionalmente, mover el correo después de exportarlo como .eml.",
"keep": "Mantener en el buzón",
"archive": "Mover al archivo",
"trash": "Mover a la papelera"
}
},
"filters": {
"title": "Filtros de correo",
"description": "Cree reglas para ordenar, etiquetar y gestionar automáticamente los correos entrantes",
@@ -1751,7 +1845,11 @@
"color_tag": "Etiqueta",
"remove_color": "Eliminar etiqueta",
"items_selected": "{count} correos seleccionados",
"edit_draft": "Editar borrador"
"edit_draft": "Editar borrador",
"cancel_scheduled_send": "Cancelar envío",
"reschedule_send": "Reprogramar",
"cancel_and_edit": "Cancelar y editar",
"cancel_and_compose_again": "Cancelar y redactar de nuevo"
},
"mailbox_context_menu": {
"mark_folder_read": "Marcar carpeta como leída",
@@ -1760,7 +1858,7 @@
"new_subfolder": "Nueva subcarpeta...",
"new_folder": "Nueva carpeta...",
"rename": "Renombrar...",
"import_email": "Importar .eml...",
"import_email": "Importar .eml o .zip...",
"empty_folder": "Vaciar carpeta",
"empty_folder_generic": "Vaciar carpeta",
"delete_folder": "Eliminar carpeta",
@@ -1829,6 +1927,8 @@
"expand_collapse": "Expandir/contraer conversación"
},
"composer": {
"send": "Enviar correo",
"schedule_send": "Programar envío",
"template_picker": "Abrir selector de plantillas"
}
},
+115 -15
View File
@@ -132,7 +132,8 @@
"mail": "Messagerie",
"nav_label": "Navigation",
"add_app": "Apps",
"shared": "Partagé"
"shared": "Partagé",
"scheduled": "Planifiés"
},
"protocol_handlers": {
"title": "Applications par défaut",
@@ -239,7 +240,16 @@
"confirm_button": "Vider le dossier",
"junk_hint": "Vous pouvez vider le dossier Indésirables pour supprimer définitivement tous les messages.",
"trash_hint": "Vous pouvez vider la corbeille pour supprimer définitivement tous les messages."
}
},
"no_scheduled_emails": "Aucun e-mail planifié",
"no_scheduled_emails_description": "Les messages planifiés pour plus tard apparaîtront ici.",
"scheduled_count": "{count, plural, one {1 e-mail planifié} other {# e-mails planifiés}}",
"scheduled_actions_hint": "Les messages planifiés peuvent être annulés, replanifiés ou modifiés depuis leurs actions.",
"cancel_scheduled_send": "Annuler lenvoi",
"reschedule_send": "Replanifier",
"cancel_and_edit": "Annuler et modifier",
"cancel_and_compose_again": "Annuler et rédiger à nouveau",
"reschedule_prompt": "Saisissez une nouvelle date/heure, p. ex. 2026-05-04T15:30"
},
"email_viewer": {
"no_email_selected": "Aucun email sélectionné",
@@ -270,7 +280,7 @@
"print": "Imprimer",
"view_source": "Voir la source",
"export_email": "Exporter en .eml",
"import_email": "Importer un .eml",
"import_email": "Importer .eml ou .zip",
"keyboard_shortcuts": "Raccourcis clavier (?)",
"email_source": "Source de l'email",
"draft_banner": "Ce message est un brouillon",
@@ -514,7 +524,17 @@
"ai_verdict": "Verdict IA",
"account": "Compte",
"no_subject": "(sans objet)"
}
},
"scheduled_banner": "Envoi planifié à {date}",
"scheduled_send_created": "E-mail planifié pour envoi",
"cancel_scheduled_send": "Annuler lenvoi",
"reschedule_send": "Replanifier",
"cancel_and_edit": "Annuler et modifier",
"cancel_and_compose_again": "Annuler et rédiger à nouveau",
"reschedule_prompt": "Saisissez une nouvelle date/heure, p. ex. 2026-05-04T15:30",
"scheduled_actions_only": "La vue Planifiés prend uniquement en charge les actions de planification",
"undo_send_scheduled": "Message planifié pour envoi",
"undo_send": "Annuler lenvoi"
},
"email_composer": {
"new_message": "Nouveau message",
@@ -607,7 +627,19 @@
},
"add_link": "Ajouter un lien",
"link_url_prompt": "Saisissez l'URL",
"sending": "Envoi..."
"sending": "Envoi...",
"schedule_send": "Planifier lenvoi",
"schedule_send_description": "Choisissez quand le serveur doit libérer ce message.",
"schedule_send_required": "Choisissez une date et une heure.",
"schedule_send_invalid": "Saisissez une date et une heure valides.",
"schedule_send_future": "Choisissez une date et une heure futures.",
"schedule_send_too_late": "Cette heure est au-delà de ce que le serveur autorise.",
"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é.",
"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 ?",
"attach_photos": "Photos & Videos",
"attach_files": "Files"
},
"confirm_dialog": {
"confirm": "Confirmer",
@@ -752,7 +784,7 @@
"keep_editing": "Continuer l'édition",
"tabs": {
"appearance": "Apparence",
"language": "Langue et région",
"language": "Langue, région et heure",
"email": "Comportement email",
"composer": "Compositeur",
"privacy": "Confidentialité et sécurité",
@@ -775,6 +807,7 @@
"layout": "Mise en page",
"reading": "Lecture",
"composing": "Rédaction",
"downloads": "Téléchargements",
"content_senders": "Contenu et expéditeurs",
"about_data": "À propos et données",
"debug": "Débogage"
@@ -843,7 +876,11 @@
},
"unified_mailbox": {
"label": "Boîte aux lettres unifiée",
"description": "Afficher les dossiers combinés (Réception, Envoyés, etc.) de tous les comptes connectés"
"description": "Afficher les dossiers combinés (Réception, Envoyés, etc.) de tous les comptes connectés",
"include_group": {
"label": "Inclure les boîtes de groupe",
"description": "Inclure également les boîtes partagées ou de groupe dans la vue unifiée."
}
},
"colorful_sidebar_icons": {
"label": "Icônes colorées dans la barre latérale",
@@ -918,6 +955,8 @@
"reenable": "Réenregistrer",
"relay_desc": "Utilise par défaut le relais Bulwark hébergé. Ne le changez que si vous l'hébergez vous-même.",
"relay_label": "Relais push",
"relay_locked": "Défini par l'administrateur",
"relay_locked_desc": "L'URL du relais push a été définie par votre administrateur et ne peut pas être modifiée.",
"relay_placeholder": "https://notifications.relay.example.com",
"status_active": "Actif sur cet appareil",
"status_busy": "Traitement en cours…",
@@ -927,8 +966,8 @@
}
},
"language_region": {
"title": "Langue et région",
"description": "Configurez vos préférences linguistiques et régionales",
"title": "Langue, région et heure",
"description": "Langue, format de date, format d'heure et autres préférences régionales",
"language": {
"label": "Langue",
"description": "Choisissez votre langue préférée",
@@ -937,10 +976,13 @@
},
"date_format": {
"label": "Format de date",
"description": "Comment les dates doivent être affichées",
"regional": "Régional",
"iso": "ISO 8601",
"custom": "Personnalisé"
"description": "Comment les dates apparaissent dans la liste des e-mails",
"smart": "Intelligent (adapté à la région)",
"relative": "Relatif (il y a 1 h, il y a 2 j)",
"full": "Toujours la date complète",
"preview_today": "Aujourd'hui :",
"preview_this_week": "Cette semaine :",
"preview_older": "Plus ancien :"
},
"time_format": {
"label": "Format d'heure",
@@ -970,6 +1012,7 @@
"label": "Action de suppression",
"description": "Que se passe-t-il quand vous supprimez un email",
"trash": "Déplacer vers la corbeille",
"trash_and_read": "Déplacer vers la corbeille et marquer comme lu",
"permanent": "Supprimer définitivement",
"warning": "Les emails seront supprimés définitivement et ne pourront pas être récupérés. Cette action est irréversible."
},
@@ -1126,6 +1169,13 @@
"attachment_image_previews": {
"label": "Afficher les aperçus d'images dans les pièces jointes",
"description": "Afficher les images jointes sous forme de vignettes plutôt qu'avec des icônes de fichier génériques"
},
"send_delay": {
"label": "Annuler lenvoi / délai denvoi",
"description": "Retarde les envois normaux avec une courte fenêtre côté serveur.",
"off": "Désactivé",
"seconds": "{seconds} secondes",
"unsupported": "Le compte actuel nannonce pas la prise en charge de lenvoi différé. Le réglage reste enregistré pour dautres comptes."
}
},
"composer": {
@@ -1495,6 +1545,50 @@
"group_by_letter_description": "Afficher des en-têtes alphabétiques dans la liste de contacts",
"group_by_letter_label": "Grouper par première lettre"
},
"downloads": {
"title": "Téléchargements",
"description": "Personnalisez la façon dont les e-mails et pièces jointes téléchargés sont nommés.",
"reset": "Restaurer la valeur par défaut",
"preview": "Aperçu :",
"email_template": {
"label": "Nom du fichier e-mail (.eml)",
"description": "Modèle utilisé lors de l'export ou du glisser-déposer d'un e-mail vers le système de fichiers. L'extension .eml est ajoutée automatiquement."
},
"attachment_template": {
"label": "Nom du fichier de pièce jointe",
"description": "Modèle utilisé lors du téléchargement ou du glisser-déposer d'une pièce jointe. Si vous omettez '{filename}' et '{ext}', l'extension d'origine est conservée."
},
"bundle_template": {
"label": "Nom du fichier .zip multi-e-mails",
"description": "Modèle utilisé lorsque vous faites glisser ou téléchargez plusieurs e-mails sélectionnés sous forme d'archive .zip unique. L'extension .zip est ajoutée automatiquement."
},
"spaces": {
"label": "Espaces",
"description": "Remplacer les espaces dans le nom de fichier final par un autre caractère.",
"keep": "Conserver les espaces",
"underscore": "Remplacer par _",
"dash": "Remplacer par -"
},
"lowercase": {
"label": "Minuscules",
"description": "Forcer tout le nom de fichier en minuscules."
},
"strip_diacritics": {
"label": "Supprimer les diacritiques",
"description": "Convertir les lettres accentuées en leur équivalent ASCII (ä → a, é → e). Utile pour les outils qui gèrent mal les noms Unicode."
},
"collapse_separators": {
"label": "Fusionner les séparateurs répétés",
"description": "Fusionner les suites despaces, de tirets bas ou de tirets en un seul caractère."
},
"after_export": {
"label": "Après lexport",
"description": "Déplacer éventuellement le-mail après lavoir exporté en .eml.",
"keep": "Garder dans la boîte",
"archive": "Déplacer vers les archives",
"trash": "Déplacer vers la corbeille"
}
},
"filters": {
"title": "Filtres de courrier",
"description": "Créez des règles pour trier, étiqueter et gérer automatiquement les courriers entrants",
@@ -1751,7 +1845,11 @@
"color_tag": "Étiquette",
"remove_color": "Supprimer l'étiquette",
"items_selected": "{count} emails sélectionnés",
"edit_draft": "Modifier le brouillon"
"edit_draft": "Modifier le brouillon",
"cancel_scheduled_send": "Annuler lenvoi",
"reschedule_send": "Replanifier",
"cancel_and_edit": "Annuler et modifier",
"cancel_and_compose_again": "Annuler et rédiger à nouveau"
},
"mailbox_context_menu": {
"mark_folder_read": "Marquer le dossier comme lu",
@@ -1760,7 +1858,7 @@
"new_subfolder": "Nouveau sous-dossier...",
"new_folder": "Nouveau dossier...",
"rename": "Renommer...",
"import_email": "Importer un .eml...",
"import_email": "Importer .eml ou .zip...",
"empty_folder": "Vider le dossier",
"empty_folder_generic": "Vider le dossier",
"delete_folder": "Supprimer le dossier",
@@ -1829,6 +1927,8 @@
"expand_collapse": "Développer/réduire la conversation"
},
"composer": {
"send": "Envoyer l'e-mail",
"schedule_send": "Planifier l'envoi",
"template_picker": "Ouvrir le sélecteur de modèles"
}
},
+115 -15
View File
@@ -132,7 +132,8 @@
"mail": "Posta",
"nav_label": "Navigazione",
"add_app": "App",
"shared": "Condiviso"
"shared": "Condiviso",
"scheduled": "Programmate"
},
"protocol_handlers": {
"title": "App predefinite",
@@ -239,7 +240,16 @@
"confirm_button": "Svuota cartella",
"junk_hint": "Puoi svuotare la cartella Spam per rimuovere definitivamente tutti i messaggi.",
"trash_hint": "Puoi svuotare il Cestino per rimuovere definitivamente tutti i messaggi."
}
},
"no_scheduled_emails": "Nessuna e-mail programmata",
"no_scheduled_emails_description": "I messaggi programmati per più tardi appariranno qui.",
"scheduled_count": "{count, plural, one {1 e-mail programmata} other {# e-mail programmate}}",
"scheduled_actions_hint": "I messaggi programmati possono essere annullati, riprogrammati o modificati dalle azioni di programmazione.",
"cancel_scheduled_send": "Annulla invio",
"reschedule_send": "Riprogramma",
"cancel_and_edit": "Annulla e modifica",
"cancel_and_compose_again": "Annulla e componi di nuovo",
"reschedule_prompt": "Inserisci una nuova data/ora, ad es. 2026-05-04T15:30"
},
"email_viewer": {
"no_email_selected": "Nessun messaggio selezionato",
@@ -270,7 +280,7 @@
"print": "Stampa",
"view_source": "Visualizza sorgente",
"export_email": "Esporta come .eml",
"import_email": "Importa .eml",
"import_email": "Importa .eml o .zip",
"keyboard_shortcuts": "Scorciatoie da tastiera (?)",
"email_source": "Sorgente del messaggio",
"draft_banner": "Questo messaggio è una bozza",
@@ -514,7 +524,17 @@
"ai_verdict": "Verdetto IA",
"account": "Account",
"no_subject": "(nessun oggetto)"
}
},
"scheduled_banner": "Programmato per linvio il {date}",
"scheduled_send_created": "E-mail programmata per linvio",
"cancel_scheduled_send": "Annulla invio",
"reschedule_send": "Riprogramma",
"cancel_and_edit": "Annulla e modifica",
"cancel_and_compose_again": "Annulla e componi di nuovo",
"reschedule_prompt": "Inserisci una nuova data/ora, ad es. 2026-05-04T15:30",
"scheduled_actions_only": "La vista Programmate supporta solo azioni di programmazione",
"undo_send_scheduled": "Messaggio programmato per linvio",
"undo_send": "Annulla invio"
},
"email_composer": {
"new_message": "Nuovo messaggio",
@@ -607,7 +627,19 @@
},
"add_link": "Aggiungi link",
"link_url_prompt": "Inserisci l'URL",
"sending": "Invio in corso..."
"sending": "Invio in corso...",
"schedule_send": "Programma invio",
"schedule_send_description": "Scegli quando il server deve rilasciare questo messaggio.",
"schedule_send_required": "Scegli una data e unora.",
"schedule_send_invalid": "Inserisci una data e unora valide.",
"schedule_send_future": "Scegli una data e unora future.",
"schedule_send_too_late": "Questo orario supera il limite consentito dal server.",
"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.",
"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?",
"attach_photos": "Photos & Videos",
"attach_files": "Files"
},
"confirm_dialog": {
"confirm": "Conferma",
@@ -752,7 +784,7 @@
"keep_editing": "Continua a modificare",
"tabs": {
"appearance": "Aspetto",
"language": "Lingua e regione",
"language": "Lingua, regione e ora",
"email": "Comportamento email",
"composer": "Editor",
"privacy": "Privacy e sicurezza",
@@ -775,6 +807,7 @@
"layout": "Layout",
"reading": "Lettura",
"composing": "Composizione",
"downloads": "Download",
"content_senders": "Contenuto e mittenti",
"about_data": "Informazioni e dati",
"debug": "Debug"
@@ -843,7 +876,11 @@
},
"unified_mailbox": {
"label": "Casella di posta unificata",
"description": "Mostra le cartelle combinate (Posta in arrivo, Inviati, ecc.) di tutti gli account collegati"
"description": "Mostra le cartelle combinate (Posta in arrivo, Inviati, ecc.) di tutti gli account collegati",
"include_group": {
"label": "Includi le caselle di gruppo",
"description": "Includi anche le caselle condivise o di gruppo nella vista unificata."
}
},
"colorful_sidebar_icons": {
"label": "Icone colorate nella barra laterale",
@@ -918,6 +955,8 @@
"reenable": "Registra di nuovo",
"relay_desc": "Per impostazione predefinita usa il relay Bulwark ospitato. Cambialo solo se ospiti in autonomia.",
"relay_label": "Relay push",
"relay_locked": "Impostato dall'amministratore",
"relay_locked_desc": "L'URL del relay push è stato impostato dall'amministratore e non può essere modificato.",
"relay_placeholder": "https://notifications.relay.example.com",
"status_active": "Attivo su questo dispositivo",
"status_busy": "In elaborazione…",
@@ -927,8 +966,8 @@
}
},
"language_region": {
"title": "Lingua e regione",
"description": "Configura le preferenze di lingua e regionali",
"title": "Lingua, regione e ora",
"description": "Lingua, formato data, formato ora e altre preferenze regionali",
"language": {
"label": "Lingua",
"description": "Scegli la tua lingua preferita",
@@ -937,10 +976,13 @@
},
"date_format": {
"label": "Formato data",
"description": "Come devono essere visualizzate le date",
"regional": "Regionale",
"iso": "ISO 8601",
"custom": "Personalizzato"
"description": "Come vengono visualizzate le date nell'elenco delle e-mail",
"smart": "Intelligente (in base alla regione)",
"relative": "Relativo (1 h fa, 2 g fa)",
"full": "Sempre data completa",
"preview_today": "Oggi:",
"preview_this_week": "Questa settimana:",
"preview_older": "Più vecchio:"
},
"time_format": {
"label": "Formato ora",
@@ -970,6 +1012,7 @@
"label": "Azione di eliminazione",
"description": "Cosa accade quando elimini un messaggio",
"trash": "Sposta nel cestino",
"trash_and_read": "Sposta nel cestino e segna come letto",
"permanent": "Elimina definitivamente",
"warning": "I messaggi verranno eliminati definitivamente e non potranno essere recuperati. Questa azione è irreversibile."
},
@@ -1126,6 +1169,13 @@
"attachment_image_previews": {
"label": "Mostra anteprime delle immagini negli allegati",
"description": "Mostra gli allegati immagine come miniature anziché con icone di file generiche"
},
"send_delay": {
"label": "Annulla invio / ritardo di invio",
"description": "Ritarda gli invii normali con una breve finestra lato server.",
"off": "Disattivato",
"seconds": "{seconds} secondi",
"unsupported": "Laccount corrente non dichiara il supporto allinvio ritardato. Limpostazione resta salvata per altri account."
}
},
"composer": {
@@ -1495,6 +1545,50 @@
"group_by_letter_description": "Mostra intestazioni alfabetiche nell'elenco dei contatti",
"group_by_letter_label": "Raggruppa per prima lettera"
},
"downloads": {
"title": "Download",
"description": "Personalizza come vengono nominati gli email e gli allegati scaricati.",
"reset": "Ripristina predefinito",
"preview": "Anteprima:",
"email_template": {
"label": "Nome file email (.eml)",
"description": "Modello usato quando esporti un'email o la trascini sul file system. L'estensione .eml viene aggiunta automaticamente."
},
"attachment_template": {
"label": "Nome file dellallegato",
"description": "Modello usato quando scarichi o trascini un allegato. Se ometti '{filename}' e '{ext}', l'estensione originale viene mantenuta."
},
"bundle_template": {
"label": "Nome del file .zip multi-email",
"description": "Modello usato quando trascini o scarichi più email selezionate come un singolo archivio .zip. L'estensione .zip viene aggiunta automaticamente."
},
"spaces": {
"label": "Spazi",
"description": "Sostituisci gli spazi nel nome file risultante con un altro carattere.",
"keep": "Mantieni gli spazi",
"underscore": "Sostituisci con _",
"dash": "Sostituisci con -"
},
"lowercase": {
"label": "Minuscolo",
"description": "Forza l'intero nome file in minuscolo."
},
"strip_diacritics": {
"label": "Rimuovi i segni diacritici",
"description": "Converti le lettere accentate nei loro equivalenti ASCII (ä → a, é → e). Utile per strumenti che gestiscono male i nomi Unicode."
},
"collapse_separators": {
"label": "Riduci i separatori ripetuti",
"description": "Riduci sequenze di spazi, underscore o trattini a un solo carattere."
},
"after_export": {
"label": "Dopo lesportazione",
"description": "Sposta facoltativamente lemail dopo averla esportata come .eml.",
"keep": "Mantieni nella casella",
"archive": "Sposta nellarchivio",
"trash": "Sposta nel cestino"
}
},
"filters": {
"title": "Filtri email",
"description": "Crea regole per ordinare, etichettare e gestire automaticamente le email in arrivo",
@@ -1751,7 +1845,11 @@
"color_tag": "Etichetta",
"remove_color": "Rimuovi etichetta",
"items_selected": "{count} messaggi selezionati",
"edit_draft": "Modifica bozza"
"edit_draft": "Modifica bozza",
"cancel_scheduled_send": "Annulla invio",
"reschedule_send": "Riprogramma",
"cancel_and_edit": "Annulla e modifica",
"cancel_and_compose_again": "Annulla e componi di nuovo"
},
"mailbox_context_menu": {
"mark_folder_read": "Segna cartella come letta",
@@ -1760,7 +1858,7 @@
"new_subfolder": "Nuova sottocartella...",
"new_folder": "Nuova cartella...",
"rename": "Rinomina...",
"import_email": "Importa .eml...",
"import_email": "Importa .eml o .zip...",
"empty_folder": "Svuota cartella",
"empty_folder_generic": "Svuota cartella",
"delete_folder": "Elimina cartella",
@@ -1829,6 +1927,8 @@
"expand_collapse": "Espandi/comprimi conversazione"
},
"composer": {
"send": "Invia email",
"schedule_send": "Programma invio",
"template_picker": "Apri selettore modelli"
}
},
+115 -15
View File
@@ -132,7 +132,8 @@
"mail": "メール",
"nav_label": "ナビゲーション",
"add_app": "アプリ",
"shared": "共有"
"shared": "共有",
"scheduled": "予約済み"
},
"protocol_handlers": {
"title": "既定のアプリ",
@@ -239,7 +240,16 @@
"confirm_button": "フォルダを空にする",
"junk_hint": "迷惑メールフォルダを空にして、すべてのメールを完全に削除できます。",
"trash_hint": "ゴミ箱を空にして、すべてのメールを完全に削除できます。"
}
},
"no_scheduled_emails": "予約送信はありません",
"no_scheduled_emails_description": "後で送信するよう予約したメッセージがここに表示されます。",
"scheduled_count": "{count, plural, one {1 件の予約送信} other {# 件の予約送信}}",
"scheduled_actions_hint": "予約メッセージは予約アクションからキャンセル、再予約、編集できます。",
"cancel_scheduled_send": "送信をキャンセル",
"reschedule_send": "再予約",
"cancel_and_edit": "キャンセルして編集",
"cancel_and_compose_again": "キャンセルして新規作成",
"reschedule_prompt": "新しい日時を入力してください。例: 2026-05-04T15:30"
},
"email_viewer": {
"no_email_selected": "メールが選択されていません",
@@ -270,7 +280,7 @@
"print": "印刷",
"view_source": "ソースを表示",
"export_email": ".emlとしてエクスポート",
"import_email": ".emlをインポート",
"import_email": ".eml または .zip をインポート",
"keyboard_shortcuts": "キーボードショートカット (?)",
"email_source": "メールソース",
"draft_banner": "このメッセージは下書きです",
@@ -514,7 +524,17 @@
"ai_verdict": "AIの判定",
"account": "アカウント",
"no_subject": "(件名なし)"
}
},
"scheduled_banner": "{date} に送信予定",
"scheduled_send_created": "メールを送信予約しました",
"cancel_scheduled_send": "送信をキャンセル",
"reschedule_send": "再予約",
"cancel_and_edit": "キャンセルして編集",
"cancel_and_compose_again": "キャンセルして新規作成",
"reschedule_prompt": "新しい日時を入力してください。例: 2026-05-04T15:30",
"scheduled_actions_only": "予約済みビューでは予約アクションのみ利用できます",
"undo_send_scheduled": "メッセージは送信予約されています",
"undo_send": "送信を取り消す"
},
"email_composer": {
"new_message": "新規メッセージ",
@@ -607,7 +627,19 @@
},
"add_link": "リンクを追加",
"link_url_prompt": "URLを入力してください",
"sending": "送信中..."
"sending": "送信中...",
"schedule_send": "送信を予約",
"schedule_send_description": "サーバーがこのメッセージを送信する日時を選択します。",
"schedule_send_required": "日時を選択してください。",
"schedule_send_invalid": "有効な日時を入力してください。",
"schedule_send_future": "未来の日時を選択してください。",
"schedule_send_too_late": "この日時はサーバーが許可する範囲を超えています。",
"schedule_send_unsupported": "このアカウントでは予約送信はサポートされていません。",
"schedule_send_cleanup_warning": "予約送信は作成されましたが、下書きのクリーンアップに失敗しました。",
"send_delay_unsupported": "このアカウントでは送信遅延はサポートされていません。",
"send_delay_unsupported_confirm": "このアカウントは送信遅延に対応していません。すぐに送信しますか?",
"attach_photos": "Photos & Videos",
"attach_files": "Files"
},
"confirm_dialog": {
"confirm": "確認",
@@ -752,7 +784,7 @@
"keep_editing": "編集を続ける",
"tabs": {
"appearance": "外観",
"language": "言語地域",
"language": "言語地域、時刻",
"email": "メール動作",
"composer": "作成",
"privacy": "プライバシーとセキュリティ",
@@ -775,6 +807,7 @@
"layout": "レイアウト",
"reading": "閲覧",
"composing": "作成",
"downloads": "ダウンロード",
"content_senders": "コンテンツと送信者",
"about_data": "情報とデータ",
"debug": "デバッグ"
@@ -843,7 +876,11 @@
},
"unified_mailbox": {
"label": "統合メールボックス",
"description": "接続されたすべてのアカウントの統合フォルダ(受信トレイ、送信済みなど)を表示"
"description": "接続されたすべてのアカウントの統合フォルダ(受信トレイ、送信済みなど)を表示",
"include_group": {
"label": "グループ受信トレイを含める",
"description": "共有/グループ受信トレイも統合ビューに含めます。"
}
},
"colorful_sidebar_icons": {
"label": "カラフルなサイドバーアイコン",
@@ -918,6 +955,8 @@
"reenable": "再登録",
"relay_desc": "デフォルトはホストされた Bulwark リレーです。セルフホストする場合のみ変更してください。",
"relay_label": "プッシュリレー",
"relay_locked": "管理者が設定",
"relay_locked_desc": "プッシュリレーの URL は管理者によって設定されており、変更できません。",
"relay_placeholder": "https://notifications.relay.example.com",
"status_active": "このデバイスで有効",
"status_busy": "処理中…",
@@ -927,8 +966,8 @@
}
},
"language_region": {
"title": "言語地域",
"description": "言語地域設定を構成",
"title": "言語地域、時刻",
"description": "言語、日付形式、時刻形式、その他の地域設定",
"language": {
"label": "言語",
"description": "お好みの言語を選択",
@@ -937,10 +976,13 @@
},
"date_format": {
"label": "日付形式",
"description": "日付の表示形式",
"regional": "地域設定",
"iso": "ISO 8601",
"custom": "カスタム"
"description": "メール一覧での日付の表示方法",
"smart": "スマート(地域に合わせる)",
"relative": "相対表示(1時間前、2日前)",
"full": "常に完全な日付",
"preview_today": "今日:",
"preview_this_week": "今週:",
"preview_older": "それ以前:"
},
"time_format": {
"label": "時刻形式",
@@ -970,6 +1012,7 @@
"label": "削除動作",
"description": "メール削除時の動作",
"trash": "ゴミ箱に移動",
"trash_and_read": "ゴミ箱に移動して既読にする",
"permanent": "完全に削除",
"warning": "メールは完全に削除され、復元できません。この操作は元に戻せません。"
},
@@ -1126,6 +1169,13 @@
"attachment_image_previews": {
"label": "添付ファイル内の画像プレビューを表示",
"description": "画像の添付ファイルを汎用ファイルアイコンではなくサムネイルカードとして表示します"
},
"send_delay": {
"label": "送信取り消し / 送信遅延",
"description": "通常の送信をサーバー側で短時間遅延します。",
"off": "オフ",
"seconds": "{seconds} 秒",
"unsupported": "現在のアカウントは遅延送信のサポートを通知していません。この設定は他のアカウント用に保存されます。"
}
},
"composer": {
@@ -1495,6 +1545,50 @@
"group_by_letter_description": "連絡先リストにアルファベット順のセクション見出しを表示",
"group_by_letter_label": "頭文字でグループ化"
},
"downloads": {
"title": "ダウンロード",
"description": "ダウンロードしたメールや添付ファイルの名前の付け方をカスタマイズします。",
"reset": "既定に戻す",
"preview": "プレビュー:",
"email_template": {
"label": "メールのファイル名 (.eml)",
"description": "メールをエクスポートするか、ファイルシステムにドラッグしたときに使用するテンプレートです。.eml 拡張子は自動的に付加されます。"
},
"attachment_template": {
"label": "添付ファイルのファイル名",
"description": "添付ファイルをダウンロードまたはドラッグするときに使用するテンプレートです。'{filename}' と '{ext}' を省略すると、元の拡張子が保持されます。"
},
"bundle_template": {
"label": "複数メールの .zip ファイル名",
"description": "複数の選択したメールを 1 つの .zip としてドラッグまたはダウンロードするときに使用するテンプレートです。拡張子 .zip は自動的に付加されます。"
},
"spaces": {
"label": "スペース",
"description": "最終的なファイル名のスペースを別の文字に置き換えます。",
"keep": "スペースを残す",
"underscore": "_ に置換",
"dash": "- に置換"
},
"lowercase": {
"label": "小文字",
"description": "ファイル名全体を小文字に強制します。"
},
"strip_diacritics": {
"label": "ダイアクリティカルマークを除去",
"description": "アクセント付き文字を ASCII の同等の文字に変換します (ä → a, é → e)。Unicode のファイル名を正しく扱えないツールで便利です。"
},
"collapse_separators": {
"label": "連続した区切り文字をまとめる",
"description": "連続するスペース、アンダースコア、ハイフンを 1 文字にまとめます。"
},
"after_export": {
"label": "エクスポート後",
"description": "メールを .eml としてエクスポートした後、必要に応じて移動します。",
"keep": "メールボックスに残す",
"archive": "アーカイブへ移動",
"trash": "ごみ箱へ移動"
}
},
"filters": {
"title": "メールフィルター",
"description": "受信メールを自動で振り分け、ラベル付け、管理するルールを作成します",
@@ -1751,7 +1845,11 @@
"color_tag": "ラベル",
"remove_color": "ラベルを削除",
"items_selected": "{count}件のメールを選択",
"edit_draft": "下書きを編集"
"edit_draft": "下書きを編集",
"cancel_scheduled_send": "送信をキャンセル",
"reschedule_send": "再予約",
"cancel_and_edit": "キャンセルして編集",
"cancel_and_compose_again": "キャンセルして新規作成"
},
"mailbox_context_menu": {
"mark_folder_read": "フォルダーを既読にする",
@@ -1760,7 +1858,7 @@
"new_subfolder": "新しいサブフォルダー...",
"new_folder": "新しいフォルダー...",
"rename": "名前を変更...",
"import_email": ".eml をインポート...",
"import_email": ".eml または .zip をインポート...",
"empty_folder": "フォルダーを空にする",
"empty_folder_generic": "フォルダーを空にする",
"delete_folder": "フォルダーを削除",
@@ -1829,6 +1927,8 @@
"expand_collapse": "スレッドの展開/折りたたみ"
},
"composer": {
"send": "メールを送信",
"schedule_send": "送信を予約",
"template_picker": "テンプレートピッカーを開く"
}
},
+115 -15
View File
@@ -132,7 +132,8 @@
"mail": "메일",
"nav_label": "내비게이션",
"add_app": "앱",
"shared": "공유됨"
"shared": "공유됨",
"scheduled": "예약됨"
},
"protocol_handlers": {
"title": "기본 앱",
@@ -239,7 +240,16 @@
"confirm_button": "폴더 비우기",
"junk_hint": "스팸함을 비워 모든 메시지를 영구적으로 삭제할 수 있어요.",
"trash_hint": "휴지통을 비워 모든 메시지를 영구적으로 삭제할 수 있어요."
}
},
"no_scheduled_emails": "예약된 메일 없음",
"no_scheduled_emails_description": "나중에 보내도록 예약한 메시지가 여기에 표시됩니다.",
"scheduled_count": "{count, plural, one {예약된 메일 1개} other {예약된 메일 #개}}",
"scheduled_actions_hint": "예약된 메시지는 예약 작업에서 취소, 재예약 또는 편집할 수 있습니다.",
"cancel_scheduled_send": "보내기 취소",
"reschedule_send": "다시 예약",
"cancel_and_edit": "취소하고 편집",
"cancel_and_compose_again": "취소하고 다시 작성",
"reschedule_prompt": "새 날짜/시간을 입력하세요. 예: 2026-05-04T15:30"
},
"email_viewer": {
"no_email_selected": "메일이 선택되지 않았어요",
@@ -270,7 +280,7 @@
"print": "인쇄",
"view_source": "원본 보기",
"export_email": ".eml 파일로 내보내기",
"import_email": ".eml 파일 가져오기",
"import_email": ".eml 또는 .zip 가져오기",
"keyboard_shortcuts": "단축키 (?)",
"email_source": "메일 원본",
"draft_banner": "작성 중인 임시보관 메일이에요",
@@ -514,7 +524,17 @@
"ai_verdict": "AI 판정",
"account": "계정",
"no_subject": "(제목 없음)"
}
},
"scheduled_banner": "{date}에 보내도록 예약됨",
"scheduled_send_created": "메일 보내기가 예약되었습니다",
"cancel_scheduled_send": "보내기 취소",
"reschedule_send": "다시 예약",
"cancel_and_edit": "취소하고 편집",
"cancel_and_compose_again": "취소하고 다시 작성",
"reschedule_prompt": "새 날짜/시간을 입력하세요. 예: 2026-05-04T15:30",
"scheduled_actions_only": "예약됨 보기에서는 예약 작업만 사용할 수 있습니다",
"undo_send_scheduled": "메시지가 보내기로 예약되었습니다",
"undo_send": "보내기 실행 취소"
},
"email_composer": {
"new_message": "새 메시지",
@@ -607,7 +627,19 @@
},
"add_link": "링크 추가",
"link_url_prompt": "URL을 입력하세요",
"sending": "전송 중..."
"sending": "전송 중...",
"schedule_send": "보내기 예약",
"schedule_send_description": "서버가 이 메시지를 보낼 시간을 선택하세요.",
"schedule_send_required": "날짜와 시간을 선택하세요.",
"schedule_send_invalid": "유효한 날짜와 시간을 입력하세요.",
"schedule_send_future": "미래의 날짜와 시간을 선택하세요.",
"schedule_send_too_late": "이 시간은 서버가 허용하는 범위를 벗어납니다.",
"schedule_send_unsupported": "이 계정은 예약 보내기를 지원하지 않습니다.",
"schedule_send_cleanup_warning": "예약 보내기는 생성되었지만 초안 정리에 실패했습니다.",
"send_delay_unsupported": "이 계정은 보내기 지연을 지원하지 않습니다.",
"send_delay_unsupported_confirm": "이 계정은 보내기 지연을 지원하지 않습니다. 바로 보낼까요?",
"attach_photos": "Photos & Videos",
"attach_files": "Files"
},
"confirm_dialog": {
"confirm": "확인",
@@ -752,7 +784,7 @@
"keep_editing": "계속 수정하기",
"tabs": {
"appearance": "화면 설정",
"language": "언어 지역",
"language": "언어, 지역 및 시간",
"email": "메일 동작",
"composer": "메일 쓰기",
"privacy": "개인정보 및 보안",
@@ -775,6 +807,7 @@
"layout": "레이아웃",
"reading": "읽기",
"composing": "작성",
"downloads": "다운로드",
"content_senders": "콘텐츠 및 발신자",
"about_data": "정보 및 데이터",
"debug": "디버그"
@@ -843,7 +876,11 @@
},
"unified_mailbox": {
"label": "통합 메일함",
"description": "연결된 모든 계정의 통합 폴더(받은편지함, 보낸편지함 등)를 표시합니다"
"description": "연결된 모든 계정의 통합 폴더(받은편지함, 보낸편지함 등)를 표시합니다",
"include_group": {
"label": "그룹 받은편지함 포함",
"description": "공유/그룹 받은편지함도 통합 보기에 포함합니다."
}
},
"colorful_sidebar_icons": {
"label": "컬러풀한 사이드바 아이콘",
@@ -918,6 +955,8 @@
"reenable": "다시 등록",
"relay_desc": "기본값은 호스팅된 Bulwark 릴레이입니다. 셀프 호스팅 시에만 변경하세요.",
"relay_label": "푸시 릴레이",
"relay_locked": "관리자에 의해 설정됨",
"relay_locked_desc": "푸시 릴레이 URL은 관리자가 설정했으며 변경할 수 없어요.",
"relay_placeholder": "https://notifications.relay.example.com",
"status_active": "이 기기에서 활성",
"status_busy": "처리 중…",
@@ -927,8 +966,8 @@
}
},
"language_region": {
"title": "언어 지역",
"description": "언어와 지역 형식을 설정해 주세요",
"title": "언어, 지역 및 시간",
"description": "언어, 날짜 형식, 시간 형식 및 기타 지역 환경설정",
"language": {
"label": "언어",
"description": "사용할 언어를 선택해 주세요",
@@ -937,10 +976,13 @@
},
"date_format": {
"label": "날짜 형식",
"description": "날짜가 표시되는 방식을 설정해요",
"regional": "지역 설정",
"iso": "ISO 8601",
"custom": "사용자 지정"
"description": "이메일 목록에 날짜가 표시되는 방식",
"smart": "스마트 (지역에 맞춤)",
"relative": "상대 시간 (1시간 전, 2일 전)",
"full": "항상 전체 날짜",
"preview_today": "오늘:",
"preview_this_week": "이번 주:",
"preview_older": "이전:"
},
"time_format": {
"label": "시간 형식",
@@ -970,6 +1012,7 @@
"label": "삭제 동작",
"description": "메일을 삭제할 때의 동작을 선택해 주세요",
"trash": "휴지통으로 이동",
"trash_and_read": "휴지통으로 이동하고 읽음으로 표시",
"permanent": "영구 삭제",
"warning": "메일이 영구적으로 삭제되며 복구할 수 없어요. 주의해서 사용해 주세요."
},
@@ -1126,6 +1169,13 @@
"attachment_image_previews": {
"label": "첨부 파일에 이미지 미리보기 표시",
"description": "이미지 첨부 파일을 일반 파일 아이콘 대신 썸네일 카드로 표시합니다"
},
"send_delay": {
"label": "보내기 취소 / 보내기 지연",
"description": "일반 보내기를 서버 측 짧은 시간 동안 지연합니다.",
"off": "끔",
"seconds": "{seconds}초",
"unsupported": "현재 계정은 지연 보내기 지원을 알리지 않습니다. 설정은 다른 계정을 위해 계속 저장됩니다."
}
},
"composer": {
@@ -1495,6 +1545,50 @@
"group_by_letter_description": "연락처 목록에 알파벳순 섹션 헤더 표시",
"group_by_letter_label": "첫 글자로 그룹화"
},
"downloads": {
"title": "다운로드",
"description": "다운로드한 이메일과 첨부 파일의 이름을 지정하는 방식을 사용자 정의합니다.",
"reset": "기본값 복원",
"preview": "미리 보기:",
"email_template": {
"label": "이메일 파일 이름 (.eml)",
"description": "이메일을 내보내거나 파일 시스템으로 드래그할 때 사용하는 템플릿입니다. .eml 확장자는 자동으로 추가됩니다."
},
"attachment_template": {
"label": "첨부 파일 이름",
"description": "첨부 파일을 다운로드하거나 드래그할 때 사용하는 템플릿입니다. '{filename}'과 '{ext}'를 생략하면 원래 확장자가 유지됩니다."
},
"bundle_template": {
"label": "다중 이메일 .zip 파일 이름",
"description": "여러 선택한 이메일을 하나의 .zip 아카이브로 드래그하거나 다운로드할 때 사용하는 템플릿입니다. .zip 확장자는 자동으로 추가됩니다."
},
"spaces": {
"label": "공백",
"description": "결과 파일 이름의 공백을 다른 문자로 바꿉니다.",
"keep": "공백 유지",
"underscore": "_로 바꾸기",
"dash": "-로 바꾸기"
},
"lowercase": {
"label": "소문자",
"description": "전체 파일 이름을 소문자로 강제 변환합니다."
},
"strip_diacritics": {
"label": "발음 구별 부호 제거",
"description": "악센트가 있는 문자를 ASCII 등가 문자로 변환합니다 (ä → a, é → e). 유니코드 파일 이름을 제대로 처리하지 못하는 도구에 유용합니다."
},
"collapse_separators": {
"label": "반복된 구분자 결합",
"description": "연속된 공백, 밑줄, 대시를 단일 문자로 결합합니다."
},
"after_export": {
"label": "내보낸 후",
"description": ".eml로 내보낸 후 이메일을 선택적으로 이동합니다.",
"keep": "메일함에 유지",
"archive": "보관함으로 이동",
"trash": "휴지통으로 이동"
}
},
"filters": {
"title": "이메일 필터",
"description": "새로 온 메일을 자동으로 분류하고 라벨을 붙이는 규칙을 만들어 보세요",
@@ -1751,7 +1845,11 @@
"color_tag": "태그",
"remove_color": "태그 제거",
"items_selected": "{count}개의 메일 선택됨",
"edit_draft": "임시보관 메일 수정"
"edit_draft": "임시보관 메일 수정",
"cancel_scheduled_send": "보내기 취소",
"reschedule_send": "다시 예약",
"cancel_and_edit": "취소하고 편집",
"cancel_and_compose_again": "취소하고 다시 작성"
},
"mailbox_context_menu": {
"mark_folder_read": "폴더를 읽음으로 표시",
@@ -1760,7 +1858,7 @@
"new_subfolder": "새 하위 폴더...",
"new_folder": "새 폴더...",
"rename": "이름 바꾸기...",
"import_email": ".eml 가져오기...",
"import_email": ".eml 또는 .zip 가져오기...",
"empty_folder": "폴더 비우기",
"empty_folder_generic": "폴더 비우기",
"delete_folder": "폴더 삭제",
@@ -1829,6 +1927,8 @@
"expand_collapse": "대화 펼치기/접기"
},
"composer": {
"send": "이메일 보내기",
"schedule_send": "보내기 예약",
"template_picker": "템플릿 선택기 열기"
}
},
+115 -15
View File
@@ -132,7 +132,8 @@
"mail": "Pasts",
"nav_label": "Navigācija",
"add_app": "Lietotnes",
"shared": "Koplietots"
"shared": "Koplietots",
"scheduled": "Ieplānots"
},
"protocol_handlers": {
"title": "Noklusējuma lietotnes",
@@ -239,7 +240,16 @@
"confirm_button": "Iztīrīt mapi",
"junk_hint": "Jūs varat iztīrīt mapi Mēstules, lai neatgriezeniski dzēstu visus ziņojumus.",
"trash_hint": "Jūs varat iztīrīt Atkritni, lai neatgriezeniski dzēstu visus ziņojumus."
}
},
"no_scheduled_emails": "Nav ieplānotu e-pastu",
"no_scheduled_emails_description": "Ziņojumi, kas ieplānoti vēlākai nosūtīšanai, būs redzami šeit.",
"scheduled_count": "{count, plural, one {1 ieplānots e-pasts} other {# ieplānoti e-pasti}}",
"scheduled_actions_hint": "Ieplānotos ziņojumus var atcelt, pārplānot vai rediģēt no ieplānotajām darbībām.",
"cancel_scheduled_send": "Atcelt sūtīšanu",
"reschedule_send": "Pārplānot",
"cancel_and_edit": "Atcelt un rediģēt",
"cancel_and_compose_again": "Atcelt un rakstīt no jauna",
"reschedule_prompt": "Ievadiet jaunu datumu/laiku, piem. 2026-05-04T15:30"
},
"email_viewer": {
"no_email_selected": "Nav atlasīta neviena vēstule",
@@ -270,7 +280,7 @@
"print": "Drukāt",
"view_source": "Skatīt avota kodu",
"export_email": "Eksportēt kā .eml",
"import_email": "Importēt .eml",
"import_email": "Importēt .eml vai .zip",
"keyboard_shortcuts": "Īsinājumtaustiņi (?)",
"email_source": "Vēstules avota kods",
"draft_banner": "Šī vēstule ir melnraksts",
@@ -514,7 +524,17 @@
"ai_verdict": "AI vērtējums",
"account": "Konts",
"no_subject": "(bez temata)"
}
},
"scheduled_banner": "Ieplānots nosūtīšanai {date}",
"scheduled_send_created": "E-pasts ieplānots nosūtīšanai",
"cancel_scheduled_send": "Atcelt sūtīšanu",
"reschedule_send": "Pārplānot",
"cancel_and_edit": "Atcelt un rediģēt",
"cancel_and_compose_again": "Atcelt un rakstīt no jauna",
"reschedule_prompt": "Ievadiet jaunu datumu/laiku, piem. 2026-05-04T15:30",
"scheduled_actions_only": "Ieplānoto skatā ir pieejamas tikai ieplānošanas darbības",
"undo_send_scheduled": "Ziņojums ir ieplānots nosūtīšanai",
"undo_send": "Atsaukt sūtīšanu"
},
"email_composer": {
"new_message": "Jauns ziņojums",
@@ -607,7 +627,19 @@
},
"add_link": "Pievienot saiti",
"link_url_prompt": "Ievadiet URL",
"sending": "Sūta..."
"sending": "Sūta...",
"schedule_send": "Ieplānot sūtīšanu",
"schedule_send_description": "Izvēlieties, kad serverim jānosūta šis ziņojums.",
"schedule_send_required": "Izvēlieties datumu un laiku.",
"schedule_send_invalid": "Ievadiet derīgu datumu un laiku.",
"schedule_send_future": "Izvēlieties datumu un laiku nākotnē.",
"schedule_send_too_late": "Šis laiks ir vēlāk, nekā serveris atļauj.",
"schedule_send_unsupported": "Šim kontam ieplānota sūtīšana netiek atbalstīta.",
"schedule_send_cleanup_warning": "Ieplānotā sūtīšana tika izveidota, bet melnraksta tīrīšana neizdevās.",
"send_delay_unsupported": "Šim kontam sūtīšanas aizture netiek atbalstīta.",
"send_delay_unsupported_confirm": "Šis konts neatbalsta sūtīšanas aizturi. Sūtīt uzreiz?",
"attach_photos": "Photos & Videos",
"attach_files": "Files"
},
"confirm_dialog": {
"confirm": "Apstiprināt",
@@ -752,7 +784,7 @@
"keep_editing": "Turpināt rediģēšanu",
"tabs": {
"appearance": "Izskats",
"language": "Valoda un reģions",
"language": "Valoda, reģions un laiks",
"email": "Pasta darbība",
"composer": "Redaktors",
"privacy": "Privātums un drošība",
@@ -775,6 +807,7 @@
"layout": "Izkārtojums",
"reading": "Lasīšana",
"composing": "Rakstīšana",
"downloads": "Lejupielādes",
"content_senders": "Saturs un sūtītāji",
"about_data": "Par un dati",
"debug": "Atkļūdošana"
@@ -843,7 +876,11 @@
},
"unified_mailbox": {
"label": "Apvienotā pastkaste",
"description": "Rādīt apvienotās mapes (Iesūtne, Nosūtītie u.c.) no visiem pievienotajiem kontiem"
"description": "Rādīt apvienotās mapes (Iesūtne, Nosūtītie u.c.) no visiem pievienotajiem kontiem",
"include_group": {
"label": "Iekļaut grupas pastkastes",
"description": "Iekļaut apvienotajā skatā arī koplietotās/grupas pastkastes."
}
},
"colorful_sidebar_icons": {
"label": "Krāsainas sānjoslas ikonas",
@@ -918,6 +955,8 @@
"reenable": "Reģistrēt vēlreiz",
"relay_desc": "Pēc noklusējuma izmanto izmitināto Bulwark releju. Mainiet tikai tad, ja izmitināt pats.",
"relay_label": "Push relejs",
"relay_locked": "Iestatījis administrators",
"relay_locked_desc": "Push releja URL ir iestatījis administrators, un to nevar mainīt.",
"relay_placeholder": "https://notifications.relay.example.com",
"status_active": "Aktīvs šajā ierīcē",
"status_busy": "Notiek darbs…",
@@ -927,8 +966,8 @@
}
},
"language_region": {
"title": "Valoda un reģions",
"description": "Iestatiet valodas un reģionālās preferences",
"title": "Valoda, reģions un laiks",
"description": "Valoda, datuma formāts, laika formāts un citas reģionālās preferences",
"language": {
"label": "Valoda",
"description": "Izvēlieties vēlamo valodu",
@@ -937,10 +976,13 @@
},
"date_format": {
"label": "Datuma formāts",
"description": "Kā attēlot datumus",
"regional": "Reģionālais",
"iso": "ISO 8601",
"custom": "Pielāgots"
"description": "Kā datumi tiek attēloti e-pasta sarakstā",
"smart": "Gudrs (atbilstoši reģionam)",
"relative": "Relatīvs (pirms 1 st., pirms 2 d.)",
"full": "Vienmēr pilns datums",
"preview_today": "Šodien:",
"preview_this_week": "Šajā nedēļā:",
"preview_older": "Vecāks:"
},
"time_format": {
"label": "Laika formāts",
@@ -970,6 +1012,7 @@
"label": "Darbība dzēšot",
"description": "Kas notiek, izdzēšot vēstuli",
"trash": "Pārvietot uz atkritni",
"trash_and_read": "Pārvietot uz atkritni un atzīmēt kā lasītu",
"permanent": "Dzēst neatgriezeniski",
"warning": "Vēstules tiks dzēstas neatgriezeniski. Šī darbība nav atceļama."
},
@@ -1126,6 +1169,13 @@
"attachment_image_previews": {
"label": "Rādīt attēlu priekšskatījumus pielikumos",
"description": "Rādīt attēlu pielikumus kā sīktēlu kartītes, nevis vispārīgas failu ikonas"
},
"send_delay": {
"label": "Atsaukt sūtīšanu / sūtīšanas aizture",
"description": "Aizturiet parastu sūtīšanu īsā servera puses logā.",
"off": "Izslēgts",
"seconds": "{seconds} sekundes",
"unsupported": "Pašreizējais konts neziņo par aizturētas sūtīšanas atbalstu. Iestatījums joprojām tiks saglabāts citiem kontiem."
}
},
"composer": {
@@ -1495,6 +1545,50 @@
"group_by_letter_description": "Rādīt alfabētiskos sadaļu virsrakstus kontaktu sarakstā",
"group_by_letter_label": "Grupēt pēc pirmā burta"
},
"downloads": {
"title": "Lejupielādes",
"description": "Pielāgojiet, kā tiek nosaukti lejupielādētie e-pasti un pielikumi.",
"reset": "Atjaunot noklusējumu",
"preview": "Priekšskatījums:",
"email_template": {
"label": "E-pasta faila nosaukums (.eml)",
"description": "Veidne, ko izmanto, eksportējot e-pastu vai velkot to uz failu sistēmu. Paplašinājums .eml tiek pievienots automātiski."
},
"attachment_template": {
"label": "Pielikuma faila nosaukums",
"description": "Veidne, ko izmanto, lejupielādējot vai velkot pielikumu. Ja izlaižat '{filename}' un '{ext}', sākotnējais paplašinājums tiek saglabāts."
},
"bundle_template": {
"label": "Vairāku e-pastu .zip faila nosaukums",
"description": "Veidne, ko izmanto, kad velkat vai lejupielādējat vairākus atlasītos e-pastus kā vienu .zip arhīvu. Paplašinājums .zip tiek pievienots automātiski."
},
"spaces": {
"label": "Atstarpes",
"description": "Aizstāt atstarpes izvades faila nosaukumā ar citu rakstzīmi.",
"keep": "Saglabāt atstarpes",
"underscore": "Aizstāt ar _",
"dash": "Aizstāt ar -"
},
"lowercase": {
"label": "Mazie burti",
"description": "Piespiest visu faila nosaukumu uz mazajiem burtiem."
},
"strip_diacritics": {
"label": "Noņemt diakritikas zīmes",
"description": "Pārvērst burtus ar akcentiem to ASCII ekvivalentos (ä → a, é → e). Noderīgi rīkiem, kas slikti apstrādā Unicode failu nosaukumus."
},
"collapse_separators": {
"label": "Apvienot atkārtotus atdalītājus",
"description": "Apvienot virknes atstarpju, pasvītrojumu vai defisu vienā rakstzīmē."
},
"after_export": {
"label": "Pēc eksporta",
"description": "Pēc e-pasta eksportēšanas kā .eml to var pārvietot.",
"keep": "Paturēt pastkastē",
"archive": "Pārvietot uz arhīvu",
"trash": "Pārvietot uz miskasti"
}
},
"filters": {
"title": "Pasta filtri",
"description": "Izveidojiet noteikumus automātiskai vēstuļu šķirošanai un pārvaldībai",
@@ -1751,7 +1845,11 @@
"color_tag": "Tags",
"remove_color": "Noņemt tagu",
"items_selected": "{count} vēstules atlasītas",
"edit_draft": "Rediģēt melnrakstu"
"edit_draft": "Rediģēt melnrakstu",
"cancel_scheduled_send": "Atcelt sūtīšanu",
"reschedule_send": "Pārplānot",
"cancel_and_edit": "Atcelt un rediģēt",
"cancel_and_compose_again": "Atcelt un rakstīt no jauna"
},
"mailbox_context_menu": {
"mark_folder_read": "Atzīmēt mapi kā lasītu",
@@ -1760,7 +1858,7 @@
"new_subfolder": "Jauna apakšmape...",
"new_folder": "Jauna mape...",
"rename": "Pārsaukt...",
"import_email": "Importēt .eml...",
"import_email": "Importēt .eml vai .zip...",
"empty_folder": "Iztukšot mapi",
"empty_folder_generic": "Iztukšot mapi",
"delete_folder": "Dzēst mapi",
@@ -1829,6 +1927,8 @@
"expand_collapse": "Izvērst/sairt sarunu"
},
"composer": {
"send": "Nosūtīt e-pastu",
"schedule_send": "Ieplānot nosūtīšanu",
"template_picker": "Atvērt veidņu izvēli"
}
},
+115 -15
View File
@@ -132,7 +132,8 @@
"mail": "E-mail",
"nav_label": "Navigatie",
"add_app": "Apps",
"shared": "Gedeeld"
"shared": "Gedeeld",
"scheduled": "Gepland"
},
"protocol_handlers": {
"title": "Standaardapps",
@@ -239,7 +240,16 @@
"confirm_button": "Map legen",
"junk_hint": "U kunt de map Spam legen om alle berichten permanent te verwijderen.",
"trash_hint": "U kunt de prullenbak legen om alle berichten permanent te verwijderen."
}
},
"no_scheduled_emails": "Geen geplande e-mails",
"no_scheduled_emails_description": "Berichten die voor later zijn gepland verschijnen hier.",
"scheduled_count": "{count, plural, one {1 geplande e-mail} other {# geplande e-mails}}",
"scheduled_actions_hint": "Geplande berichten kunnen worden geannuleerd, opnieuw gepland of bewerkt via hun geplande acties.",
"cancel_scheduled_send": "Verzenden annuleren",
"reschedule_send": "Opnieuw plannen",
"cancel_and_edit": "Annuleren en bewerken",
"cancel_and_compose_again": "Annuleren en opnieuw opstellen",
"reschedule_prompt": "Voer een nieuwe datum/tijd in, bijv. 2026-05-04T15:30"
},
"email_viewer": {
"no_email_selected": "Geen e-mail geselecteerd",
@@ -270,7 +280,7 @@
"print": "Afdrukken",
"view_source": "Bron bekijken",
"export_email": "Exporteren als .eml",
"import_email": ".eml importeren",
"import_email": ".eml of .zip importeren",
"keyboard_shortcuts": "Sneltoetsen (?)",
"email_source": "E-mailbron",
"draft_banner": "Dit bericht is een concept",
@@ -514,7 +524,17 @@
"ai_verdict": "AI-oordeel",
"account": "Account",
"no_subject": "(geen onderwerp)"
}
},
"scheduled_banner": "Gepland om te verzenden op {date}",
"scheduled_send_created": "E-mail gepland voor verzending",
"cancel_scheduled_send": "Verzenden annuleren",
"reschedule_send": "Opnieuw plannen",
"cancel_and_edit": "Annuleren en bewerken",
"cancel_and_compose_again": "Annuleren en opnieuw opstellen",
"reschedule_prompt": "Voer een nieuwe datum/tijd in, bijv. 2026-05-04T15:30",
"scheduled_actions_only": "De geplande weergave ondersteunt alleen geplande acties",
"undo_send_scheduled": "Bericht gepland voor verzending",
"undo_send": "Verzenden ongedaan maken"
},
"email_composer": {
"new_message": "Nieuw bericht",
@@ -607,7 +627,19 @@
},
"add_link": "Link toevoegen",
"link_url_prompt": "Voer de URL in",
"sending": "Bezig met verzenden..."
"sending": "Bezig met verzenden...",
"schedule_send": "Verzenden plannen",
"schedule_send_description": "Kies wanneer de server dit bericht moet vrijgeven.",
"schedule_send_required": "Kies een datum en tijd.",
"schedule_send_invalid": "Voer een geldige datum en tijd in.",
"schedule_send_future": "Kies een toekomstige datum en tijd.",
"schedule_send_too_late": "Dit tijdstip is later dan de server toestaat.",
"schedule_send_unsupported": "Gepland verzenden wordt niet ondersteund voor dit account.",
"schedule_send_cleanup_warning": "Gepland verzenden is aangemaakt, maar het opschonen van de conceptversie is mislukt.",
"send_delay_unsupported": "Verzendvertraging wordt niet ondersteund voor dit account.",
"send_delay_unsupported_confirm": "Dit account ondersteunt geen verzendvertraging. Meteen verzenden?",
"attach_photos": "Photos & Videos",
"attach_files": "Files"
},
"confirm_dialog": {
"confirm": "Bevestigen",
@@ -752,7 +784,7 @@
"keep_editing": "Doorgaan met bewerken",
"tabs": {
"appearance": "Uiterlijk",
"language": "Taal & Regio",
"language": "Taal, Regio & Tijd",
"email": "E-mailgedrag",
"composer": "Opstellen",
"privacy": "Privacy & Beveiliging",
@@ -775,6 +807,7 @@
"layout": "Indeling",
"reading": "Lezen",
"composing": "Opstellen",
"downloads": "Downloads",
"content_senders": "Inhoud en afzenders",
"about_data": "Over en gegevens",
"debug": "Debuggen"
@@ -843,7 +876,11 @@
},
"unified_mailbox": {
"label": "Gecombineerd postvak",
"description": "Gecombineerde mappen (Postvak IN, Verzonden, enz.) van alle verbonden accounts weergeven"
"description": "Gecombineerde mappen (Postvak IN, Verzonden, enz.) van alle verbonden accounts weergeven",
"include_group": {
"label": "Groepspostvakken meenemen",
"description": "Gedeelde/groepspostvakken ook in de gecombineerde weergave opnemen."
}
},
"colorful_sidebar_icons": {
"label": "Gekleurde zijbalkpictogrammen",
@@ -918,6 +955,8 @@
"reenable": "Opnieuw registreren",
"relay_desc": "Gebruikt standaard de gehoste Bulwark-relay. Wijzig alleen als je zelf hostt.",
"relay_label": "Push-relay",
"relay_locked": "Ingesteld door beheerder",
"relay_locked_desc": "De push-relay-URL is ingesteld door je beheerder en kan niet worden gewijzigd.",
"relay_placeholder": "https://notifications.relay.example.com",
"status_active": "Actief op dit apparaat",
"status_busy": "Bezig…",
@@ -927,8 +966,8 @@
}
},
"language_region": {
"title": "Taal & Regio",
"description": "Configureer taal- en regiovoorkeuren",
"title": "Taal, Regio & Tijd",
"description": "Taal, datumnotatie, tijdnotatie en andere regionale voorkeuren",
"language": {
"label": "Taal",
"description": "Kies je voorkeurstaal",
@@ -937,10 +976,13 @@
},
"date_format": {
"label": "Datumnotatie",
"description": "Hoe datums moeten worden weergegeven",
"regional": "Regionaal",
"iso": "ISO 8601",
"custom": "Aangepast"
"description": "Hoe datums worden weergegeven in de e-maillijst",
"smart": "Slim (regionaal)",
"relative": "Relatief (1 u geleden, 2 d geleden)",
"full": "Altijd volledige datum",
"preview_today": "Vandaag:",
"preview_this_week": "Deze week:",
"preview_older": "Ouder:"
},
"time_format": {
"label": "Tijdnotatie",
@@ -970,6 +1012,7 @@
"label": "Verwijderactie",
"description": "Wat er gebeurt wanneer je een e-mail verwijdert",
"trash": "Verplaatsen naar prullenbak",
"trash_and_read": "Verplaatsen naar prullenbak en als gelezen markeren",
"permanent": "Permanent verwijderen",
"warning": "E-mails worden permanent verwijderd en kunnen niet worden hersteld. Deze actie is onomkeerbaar."
},
@@ -1126,6 +1169,13 @@
"attachment_image_previews": {
"label": "Voorbeelden van afbeeldingen tonen in bijlagen",
"description": "Toon afbeeldingsbijlagen als miniatuurkaarten in plaats van generieke bestandspictogrammen"
},
"send_delay": {
"label": "Verzenden ongedaan maken / verzendvertraging",
"description": "Vertraag normale verzending met een korte server-side periode.",
"off": "Uit",
"seconds": "{seconds} seconden",
"unsupported": "Het huidige account meldt geen ondersteuning voor vertraagd verzenden. De instelling blijft opgeslagen voor andere accounts."
}
},
"composer": {
@@ -1495,6 +1545,50 @@
"group_by_letter_description": "Toon alfabetische sectiekoppen in de contactenlijst",
"group_by_letter_label": "Groeperen op eerste letter"
},
"downloads": {
"title": "Downloads",
"description": "Pas aan hoe gedownloade e-mails en bijlagen worden genoemd.",
"reset": "Standaard herstellen",
"preview": "Voorbeeld:",
"email_template": {
"label": "E-mailbestandsnaam (.eml)",
"description": "Sjabloon dat wordt gebruikt bij het exporteren of slepen van een e-mail naar het bestandssysteem. De extensie .eml wordt automatisch toegevoegd."
},
"attachment_template": {
"label": "Bestandsnaam van bijlage",
"description": "Sjabloon bij het downloaden of slepen van een bijlage. Als je '{filename}' en '{ext}' weglaat, blijft de oorspronkelijke extensie behouden."
},
"bundle_template": {
"label": "Bestandsnaam .zip met meerdere e-mails",
"description": "Sjabloon dat wordt gebruikt wanneer je meerdere geselecteerde e-mails als één .zip-archief sleept of downloadt. De extensie .zip wordt automatisch toegevoegd."
},
"spaces": {
"label": "Spaties",
"description": "Vervang spaties in de uiteindelijke bestandsnaam door een ander teken.",
"keep": "Spaties behouden",
"underscore": "Vervangen door _",
"dash": "Vervangen door -"
},
"lowercase": {
"label": "Kleine letters",
"description": "Forceer de hele bestandsnaam in kleine letters."
},
"strip_diacritics": {
"label": "Diakritische tekens verwijderen",
"description": "Zet letters met accenten om naar hun ASCII-equivalent (ä → a, é → e). Handig voor tools die Unicode-namen niet goed verwerken."
},
"collapse_separators": {
"label": "Herhaalde scheidingstekens samenvoegen",
"description": "Voeg reeksen spaties, underscores of streepjes samen tot één teken."
},
"after_export": {
"label": "Na exporteren",
"description": "Verplaats de e-mail eventueel nadat deze als .eml is geëxporteerd.",
"keep": "In postvak houden",
"archive": "Naar archief verplaatsen",
"trash": "Naar prullenbak verplaatsen"
}
},
"filters": {
"title": "E-mailfilters",
"description": "Maak regels om inkomende e-mails automatisch te sorteren, labelen en beheren",
@@ -1751,7 +1845,11 @@
"color_tag": "Label",
"remove_color": "Label verwijderen",
"items_selected": "{count} e-mails geselecteerd",
"edit_draft": "Concept bewerken"
"edit_draft": "Concept bewerken",
"cancel_scheduled_send": "Verzenden annuleren",
"reschedule_send": "Opnieuw plannen",
"cancel_and_edit": "Annuleren en bewerken",
"cancel_and_compose_again": "Annuleren en opnieuw opstellen"
},
"mailbox_context_menu": {
"mark_folder_read": "Map markeren als gelezen",
@@ -1760,7 +1858,7 @@
"new_subfolder": "Nieuwe submap...",
"new_folder": "Nieuwe map...",
"rename": "Hernoemen...",
"import_email": ".eml importeren...",
"import_email": ".eml of .zip importeren...",
"empty_folder": "Map leegmaken",
"empty_folder_generic": "Map leegmaken",
"delete_folder": "Map verwijderen",
@@ -1829,6 +1927,8 @@
"expand_collapse": "Gesprek uitklappen/inklappen"
},
"composer": {
"send": "E-mail verzenden",
"schedule_send": "Verzenden plannen",
"template_picker": "Sjabloonkiezer openen"
}
},
+115 -15
View File
@@ -132,7 +132,8 @@
"mail": "Poczta",
"nav_label": "Nawigacja",
"add_app": "Aplikacje",
"shared": "Udostępnione"
"shared": "Udostępnione",
"scheduled": "Zaplanowane"
},
"protocol_handlers": {
"title": "Aplikacje domyślne",
@@ -239,7 +240,16 @@
"confirm_button": "Opróżnij folder",
"junk_hint": "Możesz opróżnić folder Spam, aby trwale usunąć wszystkie wiadomości.",
"trash_hint": "Możesz opróżnić Kosz, aby trwale usunąć wszystkie wiadomości."
}
},
"no_scheduled_emails": "Brak zaplanowanych e-maili",
"no_scheduled_emails_description": "Wiadomości zaplanowane na później pojawią się tutaj.",
"scheduled_count": "{count, plural, one {1 zaplanowany e-mail} few {# zaplanowane e-maile} other {# zaplanowanych e-maili}}",
"scheduled_actions_hint": "Zaplanowane wiadomości można anulować, przełożyć lub edytować z poziomu akcji planowania.",
"cancel_scheduled_send": "Anuluj wysyłkę",
"reschedule_send": "Przełóż",
"cancel_and_edit": "Anuluj i edytuj",
"cancel_and_compose_again": "Anuluj i napisz ponownie",
"reschedule_prompt": "Wprowadź nową datę/godzinę, np. 2026-05-04T15:30"
},
"email_viewer": {
"no_email_selected": "Nie wybrano wiadomości",
@@ -270,7 +280,7 @@
"print": "Drukuj",
"view_source": "Pokaż źródło",
"export_email": "Eksportuj jako .eml",
"import_email": "Importuj .eml",
"import_email": "Importuj .eml lub .zip",
"keyboard_shortcuts": "Skróty klawiszowe (?)",
"email_source": "Źródło wiadomości",
"draft_banner": "Ta wiadomość jest szkicem",
@@ -514,7 +524,17 @@
"ai_verdict": "Werdykt AI",
"account": "Konto",
"no_subject": "(brak tematu)"
}
},
"scheduled_banner": "Zaplanowano wysyłkę na {date}",
"scheduled_send_created": "E-mail zaplanowany do wysłania",
"cancel_scheduled_send": "Anuluj wysyłkę",
"reschedule_send": "Przełóż",
"cancel_and_edit": "Anuluj i edytuj",
"cancel_and_compose_again": "Anuluj i napisz ponownie",
"reschedule_prompt": "Wprowadź nową datę/godzinę, np. 2026-05-04T15:30",
"scheduled_actions_only": "Widok Zaplanowane obsługuje tylko akcje planowania",
"undo_send_scheduled": "Wiadomość zaplanowana do wysłania",
"undo_send": "Cofnij wysyłkę"
},
"email_composer": {
"new_message": "Nowa wiadomość",
@@ -607,7 +627,19 @@
},
"add_link": "Dodaj link",
"link_url_prompt": "Wprowadź adres URL",
"sending": "Wysyłanie..."
"sending": "Wysyłanie...",
"schedule_send": "Zaplanuj wysyłkę",
"schedule_send_description": "Wybierz, kiedy serwer ma wysłać tę wiadomość.",
"schedule_send_required": "Wybierz datę i godzinę.",
"schedule_send_invalid": "Wprowadź prawidłową datę i godzinę.",
"schedule_send_future": "Wybierz datę i godzinę w przyszłości.",
"schedule_send_too_late": "Ten czas jest późniejszy, niż pozwala serwer.",
"schedule_send_unsupported": "Zaplanowana wysyłka nie jest obsługiwana dla tego konta.",
"schedule_send_cleanup_warning": "Zaplanowana wysyłka została utworzona, ale czyszczenie wersji roboczej nie powiodło się.",
"send_delay_unsupported": "Opóźnienie wysyłki nie jest obsługiwane dla tego konta.",
"send_delay_unsupported_confirm": "To konto nie obsługuje opóźnienia wysyłki. Wysłać natychmiast?",
"attach_photos": "Photos & Videos",
"attach_files": "Files"
},
"confirm_dialog": {
"confirm": "Potwierdź",
@@ -752,7 +784,7 @@
"keep_editing": "Kontynuuj edycję",
"tabs": {
"appearance": "Wygląd",
"language": "Język i region",
"language": "Język, region i czas",
"email": "Zachowanie poczty e-mail",
"composer": "Redagowanie",
"privacy": "Prywatność i bezpieczeństwo",
@@ -775,6 +807,7 @@
"layout": "Układ",
"reading": "Czytanie",
"composing": "Tworzenie",
"downloads": "Pobrane",
"content_senders": "Treść i nadawcy",
"about_data": "O programie i dane",
"debug": "Debugowanie"
@@ -843,7 +876,11 @@
},
"unified_mailbox": {
"label": "Wspólna skrzynka",
"description": "Wyświetlaj połączone foldery (Odebrane, Wysłane itp.) ze wszystkich połączonych kont"
"description": "Wyświetlaj połączone foldery (Odebrane, Wysłane itp.) ze wszystkich połączonych kont",
"include_group": {
"label": "Uwzględnij skrzynki grupowe",
"description": "Dodaj również udostępnione/grupowe skrzynki do widoku wspólnego."
}
},
"colorful_sidebar_icons": {
"label": "Kolorowe ikony paska bocznego",
@@ -918,6 +955,8 @@
"reenable": "Zarejestruj ponownie",
"relay_desc": "Domyślnie używa hostowanego przekaźnika Bulwark. Zmień tylko jeśli hostujesz samodzielnie.",
"relay_label": "Przekaźnik push",
"relay_locked": "Ustawione przez administratora",
"relay_locked_desc": "Adres URL przekaźnika push został ustawiony przez administratora i nie można go zmienić.",
"relay_placeholder": "https://notifications.relay.example.com",
"status_active": "Aktywne na tym urządzeniu",
"status_busy": "Przetwarzanie…",
@@ -927,8 +966,8 @@
}
},
"language_region": {
"title": "Język i region",
"description": "Skonfiguruj preferencje językowe i regionalne",
"title": "Język, region i czas",
"description": "Język, format daty, format godziny i inne preferencje regionalne",
"language": {
"label": "Język",
"description": "Wybierz preferowany język",
@@ -937,10 +976,13 @@
},
"date_format": {
"label": "Format daty",
"description": "Jak mają być wyświetlane daty",
"regional": "Regionalny",
"iso": "ISO 8601",
"custom": "Niestandardowy"
"description": "Jak daty są wyświetlane na liście e-maili",
"smart": "Inteligentny (regionalny)",
"relative": "Względny (1 g temu, 2 d temu)",
"full": "Zawsze pełna data",
"preview_today": "Dzisiaj:",
"preview_this_week": "W tym tygodniu:",
"preview_older": "Starsze:"
},
"time_format": {
"label": "Format czasu",
@@ -970,6 +1012,7 @@
"label": "Akcja usuwania",
"description": "Co ma się stać po usunięciu wiadomości e-mail",
"trash": "Przenieś do kosza",
"trash_and_read": "Przenieś do kosza i oznacz jako przeczytane",
"permanent": "Usuń trwale",
"warning": "Wiadomości e-mail zostaną trwale usunięte i nie będzie można ich odzyskać. Ta akcja jest nieodwracalna."
},
@@ -1126,6 +1169,13 @@
"attachment_image_previews": {
"label": "Pokaż podgląd obrazów w załącznikach",
"description": "Wyświetlaj załączone obrazy jako miniatury zamiast ogólnych ikon plików"
},
"send_delay": {
"label": "Cofnij wysyłkę / opóźnienie wysyłki",
"description": "Opóźnia normalne wysyłanie krótkim oknem po stronie serwera.",
"off": "Wyłączone",
"seconds": "{seconds} sekund",
"unsupported": "Bieżące konto nie zgłasza obsługi opóźnionej wysyłki. Ustawienie pozostanie zapisane dla innych kont."
}
},
"composer": {
@@ -1495,6 +1545,50 @@
"group_by_letter_description": "Pokaż alfabetyczne nagłówki sekcji na liście kontaktów",
"group_by_letter_label": "Grupuj według pierwszej litery"
},
"downloads": {
"title": "Pobrane",
"description": "Dostosuj sposób nazywania pobranych wiadomości i załączników.",
"reset": "Przywróć domyślne",
"preview": "Podgląd:",
"email_template": {
"label": "Nazwa pliku wiadomości (.eml)",
"description": "Szablon używany przy eksporcie wiadomości lub jej przeciąganiu do systemu plików. Rozszerzenie .eml jest dodawane automatycznie."
},
"attachment_template": {
"label": "Nazwa pliku załącznika",
"description": "Szablon używany przy pobieraniu lub przeciąganiu załącznika. Jeśli pominiesz '{filename}' i '{ext}', oryginalne rozszerzenie zostanie zachowane."
},
"bundle_template": {
"label": "Nazwa pliku .zip wielu wiadomości",
"description": "Szablon używany przy przeciąganiu lub pobieraniu kilku wybranych wiadomości jako jednego archiwum .zip. Rozszerzenie .zip jest dodawane automatycznie."
},
"spaces": {
"label": "Spacje",
"description": "Zastąp spacje w nazwie pliku innym znakiem.",
"keep": "Zachowaj spacje",
"underscore": "Zamień na _",
"dash": "Zamień na -"
},
"lowercase": {
"label": "Małe litery",
"description": "Wymuś małe litery w całej nazwie pliku."
},
"strip_diacritics": {
"label": "Usuń znaki diakrytyczne",
"description": "Zamień litery z akcentami na ich odpowiedniki ASCII (ą → a, é → e). Przydatne dla narzędzi, które źle obsługują nazwy Unicode."
},
"collapse_separators": {
"label": "Łącz powtórzone separatory",
"description": "Połącz ciągi spacji, podkreśleń lub myślników w jeden znak."
},
"after_export": {
"label": "Po eksporcie",
"description": "Opcjonalnie przenieś wiadomość po wyeksportowaniu jako .eml.",
"keep": "Zachowaj w skrzynce",
"archive": "Przenieś do archiwum",
"trash": "Przenieś do kosza"
}
},
"filters": {
"title": "Filtry wiadomości e-mail",
"description": "Twórz reguły, aby automatycznie sortować, etykietować i zarządzać przychodzącymi wiadomościami e-mail",
@@ -1751,7 +1845,11 @@
"color_tag": "Etykieta",
"remove_color": "Usuń etykietę",
"items_selected": "{count} zaznaczonych wiadomości",
"edit_draft": "Edytuj szkic"
"edit_draft": "Edytuj szkic",
"cancel_scheduled_send": "Anuluj wysyłkę",
"reschedule_send": "Przełóż",
"cancel_and_edit": "Anuluj i edytuj",
"cancel_and_compose_again": "Anuluj i napisz ponownie"
},
"mailbox_context_menu": {
"mark_folder_read": "Oznacz folder jako przeczytany",
@@ -1760,7 +1858,7 @@
"new_subfolder": "Nowy podfolder...",
"new_folder": "Nowy folder...",
"rename": "Zmień nazwę...",
"import_email": "Importuj .eml...",
"import_email": "Importuj .eml lub .zip...",
"empty_folder": "Opróżnij folder",
"empty_folder_generic": "Opróżnij folder",
"delete_folder": "Usuń folder",
@@ -1829,6 +1927,8 @@
"expand_collapse": "Rozwiń/zwiń wątek"
},
"composer": {
"send": "Wyślij e-mail",
"schedule_send": "Zaplanuj wysyłkę",
"template_picker": "Otwórz wybór szablonu"
}
},
+115 -15
View File
@@ -132,7 +132,8 @@
"mail": "E-mail",
"nav_label": "Navegação",
"add_app": "Apps",
"shared": "Compartilhado"
"shared": "Compartilhado",
"scheduled": "Agendados"
},
"protocol_handlers": {
"title": "Aplicativos padrão",
@@ -239,7 +240,16 @@
"confirm_button": "Esvaziar pasta",
"junk_hint": "Você pode esvaziar a pasta de Spam para remover permanentemente todas as mensagens.",
"trash_hint": "Você pode esvaziar a Lixeira para remover permanentemente todas as mensagens."
}
},
"no_scheduled_emails": "Nenhum e-mail agendado",
"no_scheduled_emails_description": "Mensagens agendadas para mais tarde aparecerão aqui.",
"scheduled_count": "{count, plural, one {1 e-mail agendado} other {# e-mails agendados}}",
"scheduled_actions_hint": "Mensagens agendadas podem ser canceladas, reagendadas ou editadas pelas ações de agendamento.",
"cancel_scheduled_send": "Cancelar envio",
"reschedule_send": "Reagendar",
"cancel_and_edit": "Cancelar e editar",
"cancel_and_compose_again": "Cancelar e escrever novamente",
"reschedule_prompt": "Informe uma nova data/hora, ex. 2026-05-04T15:30"
},
"email_viewer": {
"no_email_selected": "Nenhum e-mail selecionado",
@@ -270,7 +280,7 @@
"print": "Imprimir",
"view_source": "Ver código-fonte",
"export_email": "Exportar como .eml",
"import_email": "Importar .eml",
"import_email": "Importar .eml ou .zip",
"keyboard_shortcuts": "Atalhos de teclado (?)",
"email_source": "Código-fonte do E-mail",
"draft_banner": "Esta mensagem é um rascunho",
@@ -514,7 +524,17 @@
"ai_verdict": "Veredito da IA",
"account": "Conta",
"no_subject": "(sem assunto)"
}
},
"scheduled_banner": "Agendado para enviar em {date}",
"scheduled_send_created": "E-mail agendado para envio",
"cancel_scheduled_send": "Cancelar envio",
"reschedule_send": "Reagendar",
"cancel_and_edit": "Cancelar e editar",
"cancel_and_compose_again": "Cancelar e escrever novamente",
"reschedule_prompt": "Informe uma nova data/hora, ex. 2026-05-04T15:30",
"scheduled_actions_only": "A visualização Agendados permite apenas ações de agendamento",
"undo_send_scheduled": "Mensagem agendada para envio",
"undo_send": "Desfazer envio"
},
"email_composer": {
"new_message": "Nova Mensagem",
@@ -607,7 +627,19 @@
},
"add_link": "Adicionar link",
"link_url_prompt": "Insira a URL",
"sending": "Enviando..."
"sending": "Enviando...",
"schedule_send": "Agendar envio",
"schedule_send_description": "Escolha quando o servidor deve liberar esta mensagem.",
"schedule_send_required": "Escolha uma data e hora.",
"schedule_send_invalid": "Informe uma data e hora válidas.",
"schedule_send_future": "Escolha uma data e hora no futuro.",
"schedule_send_too_late": "Este horário é posterior ao permitido pelo servidor.",
"schedule_send_unsupported": "Envio agendado não é compatível com esta conta.",
"schedule_send_cleanup_warning": "O envio agendado foi criado, mas a limpeza do rascunho falhou.",
"send_delay_unsupported": "Atraso de envio não é compatível com esta conta.",
"send_delay_unsupported_confirm": "Esta conta não oferece atraso de envio. Enviar imediatamente?",
"attach_photos": "Photos & Videos",
"attach_files": "Files"
},
"confirm_dialog": {
"confirm": "Confirmar",
@@ -752,7 +784,7 @@
"keep_editing": "Continuar editando",
"tabs": {
"appearance": "Aparência",
"language": "Idioma e Região",
"language": "Idioma, Região e Hora",
"email": "Comportamento de E-mail",
"composer": "Editor",
"privacy": "Privacidade e Segurança",
@@ -775,6 +807,7 @@
"layout": "Layout",
"reading": "Leitura",
"composing": "Composição",
"downloads": "Downloads",
"content_senders": "Conteúdo e remetentes",
"about_data": "Sobre e dados",
"debug": "Depuração"
@@ -843,7 +876,11 @@
},
"unified_mailbox": {
"label": "Caixa de correio unificada",
"description": "Mostrar pastas combinadas (Entrada, Enviados, etc.) de todas as contas conectadas"
"description": "Mostrar pastas combinadas (Entrada, Enviados, etc.) de todas as contas conectadas",
"include_group": {
"label": "Incluir caixas de grupo",
"description": "Incluir também as caixas partilhadas ou de grupo na vista unificada."
}
},
"colorful_sidebar_icons": {
"label": "Ícones coloridos na barra lateral",
@@ -918,6 +955,8 @@
"reenable": "Registrar novamente",
"relay_desc": "Usa o relay Bulwark hospedado por padrão. Altere apenas se você hospedar.",
"relay_label": "Relay push",
"relay_locked": "Definido pelo administrador",
"relay_locked_desc": "A URL do relay push foi definida pelo seu administrador e não pode ser alterada.",
"relay_placeholder": "https://notifications.relay.example.com",
"status_active": "Ativo neste dispositivo",
"status_busy": "Processando…",
@@ -927,8 +966,8 @@
}
},
"language_region": {
"title": "Idioma e Região",
"description": "Configure preferências de idioma e região",
"title": "Idioma, Região e Hora",
"description": "Idioma, formato de data, formato de hora e outras preferências regionais",
"language": {
"label": "Idioma",
"description": "Escolha seu idioma preferido",
@@ -937,10 +976,13 @@
},
"date_format": {
"label": "Formato de Data",
"description": "Como as datas devem ser exibidas",
"regional": "Regional",
"iso": "ISO 8601",
"custom": "Personalizado"
"description": "Como as datas são mostradas na lista de e-mails",
"smart": "Inteligente (regional)",
"relative": "Relativo (há 1 h, há 2 d)",
"full": "Sempre data completa",
"preview_today": "Hoje:",
"preview_this_week": "Esta semana:",
"preview_older": "Mais antigo:"
},
"time_format": {
"label": "Formato de Hora",
@@ -970,6 +1012,7 @@
"label": "Ação de Exclusão",
"description": "O que acontece quando você exclui um e-mail",
"trash": "Mover para Lixeira",
"trash_and_read": "Mover para Lixeira e marcar como lida",
"permanent": "Excluir Permanentemente",
"warning": "Os e-mails serão excluídos permanentemente e não poderão ser recuperados. Esta ação é irreversível."
},
@@ -1126,6 +1169,13 @@
"attachment_image_previews": {
"label": "Mostrar prévias de imagens em anexos",
"description": "Renderizar anexos de imagem como miniaturas em vez de ícones genéricos de arquivo"
},
"send_delay": {
"label": "Desfazer envio / atraso de envio",
"description": "Atrase envios normais com uma breve janela no servidor.",
"off": "Desativado",
"seconds": "{seconds} segundos",
"unsupported": "A conta atual não anuncia suporte a envio atrasado. A configuração continuará salva para outras contas."
}
},
"composer": {
@@ -1495,6 +1545,50 @@
"group_by_letter_description": "Mostrar cabeçalhos de seção alfabéticos na lista de contatos",
"group_by_letter_label": "Agrupar pela primeira letra"
},
"downloads": {
"title": "Downloads",
"description": "Personalize como os e-mails e anexos baixados são nomeados.",
"reset": "Restaurar padrão",
"preview": "Pré-visualização:",
"email_template": {
"label": "Nome do arquivo de e-mail (.eml)",
"description": "Modelo usado ao exportar um e-mail ou arrastá-lo para o sistema de arquivos. A extensão .eml é adicionada automaticamente."
},
"attachment_template": {
"label": "Nome do arquivo do anexo",
"description": "Modelo usado ao baixar ou arrastar um anexo. Se você omitir '{filename}' e '{ext}', a extensão original é preservada."
},
"bundle_template": {
"label": "Nome do arquivo .zip multi-e-mails",
"description": "Modelo usado ao arrastar ou baixar vários e-mails selecionados como um único arquivo .zip. A extensão .zip é adicionada automaticamente."
},
"spaces": {
"label": "Espaços",
"description": "Substitui espaços no nome de arquivo resultante por outro caractere.",
"keep": "Manter espaços",
"underscore": "Substituir por _",
"dash": "Substituir por -"
},
"lowercase": {
"label": "Minúsculas",
"description": "Forçar todo o nome do arquivo para minúsculas."
},
"strip_diacritics": {
"label": "Remover acentos",
"description": "Converte letras acentuadas para o equivalente ASCII (ä → a, é → e). Útil para ferramentas que estragam nomes Unicode."
},
"collapse_separators": {
"label": "Combinar separadores repetidos",
"description": "Combina sequências de espaços, sublinhados ou hifens em um único caractere."
},
"after_export": {
"label": "Após exportar",
"description": "Opcionalmente, mover o e-mail depois de exportá-lo como .eml.",
"keep": "Manter na caixa",
"archive": "Mover para o arquivo",
"trash": "Mover para a lixeira"
}
},
"filters": {
"title": "Filtros de e-mail",
"description": "Crie regras para classificar, rotular e gerenciar automaticamente os e-mails recebidos",
@@ -1751,7 +1845,11 @@
"color_tag": "Etiqueta",
"remove_color": "Remover etiqueta",
"items_selected": "{count} e-mails selecionados",
"edit_draft": "Editar rascunho"
"edit_draft": "Editar rascunho",
"cancel_scheduled_send": "Cancelar envio",
"reschedule_send": "Reagendar",
"cancel_and_edit": "Cancelar e editar",
"cancel_and_compose_again": "Cancelar e escrever novamente"
},
"mailbox_context_menu": {
"mark_folder_read": "Marcar pasta como lida",
@@ -1760,7 +1858,7 @@
"new_subfolder": "Nova subpasta...",
"new_folder": "Nova pasta...",
"rename": "Renomear...",
"import_email": "Importar .eml...",
"import_email": "Importar .eml ou .zip...",
"empty_folder": "Esvaziar pasta",
"empty_folder_generic": "Esvaziar pasta",
"delete_folder": "Excluir pasta",
@@ -1829,6 +1927,8 @@
"expand_collapse": "Expandir/recolher conversa"
},
"composer": {
"send": "Enviar e-mail",
"schedule_send": "Agendar envio",
"template_picker": "Abrir seletor de modelos"
}
},
+115 -15
View File
@@ -132,7 +132,8 @@
"mail": "Почта",
"nav_label": "Навигация",
"add_app": "Приложения",
"shared": "Общие"
"shared": "Общие",
"scheduled": "Запланировано"
},
"protocol_handlers": {
"title": "Приложения по умолчанию",
@@ -239,7 +240,16 @@
"confirm_button": "Очистить папку",
"junk_hint": "Вы можете очистить папку Спам для постоянного удаления всех сообщений.",
"trash_hint": "Вы можете очистить Корзину для постоянного удаления всех сообщений."
}
},
"no_scheduled_emails": "Нет запланированных писем",
"no_scheduled_emails_description": "Сообщения, запланированные на позже, появятся здесь.",
"scheduled_count": "{count, plural, one {# запланированное письмо} few {# запланированных письма} other {# запланированных писем}}",
"scheduled_actions_hint": "Запланированные сообщения можно отменить, перенести или изменить через действия планирования.",
"cancel_scheduled_send": "Отменить отправку",
"reschedule_send": "Перенести",
"cancel_and_edit": "Отменить и изменить",
"cancel_and_compose_again": "Отменить и написать заново",
"reschedule_prompt": "Введите новую дату/время, например 2026-05-04T15:30"
},
"email_viewer": {
"no_email_selected": "Письмо не выбрано",
@@ -270,7 +280,7 @@
"print": "Распечатать",
"view_source": "Просмотреть исходный код",
"export_email": "Экспортировать как .eml",
"import_email": "Импортировать .eml",
"import_email": "Импортировать .eml или .zip",
"keyboard_shortcuts": "Сочетания клавиш (?)",
"email_source": "Исходный код письма",
"draft_banner": "Это письмо является черновиком",
@@ -514,7 +524,17 @@
"ai_verdict": "Вердикт ИИ",
"account": "Аккаунт",
"no_subject": "(без темы)"
}
},
"scheduled_banner": "Запланировано к отправке {date}",
"scheduled_send_created": "Письмо запланировано к отправке",
"cancel_scheduled_send": "Отменить отправку",
"reschedule_send": "Перенести",
"cancel_and_edit": "Отменить и изменить",
"cancel_and_compose_again": "Отменить и написать заново",
"reschedule_prompt": "Введите новую дату/время, например 2026-05-04T15:30",
"scheduled_actions_only": "В представлении запланированных доступны только действия планирования",
"undo_send_scheduled": "Сообщение запланировано к отправке",
"undo_send": "Отменить отправку"
},
"email_composer": {
"new_message": "Новое письмо",
@@ -607,7 +627,19 @@
},
"add_link": "Добавить ссылку",
"link_url_prompt": "Введите URL",
"sending": "Отправка..."
"sending": "Отправка...",
"schedule_send": "Запланировать отправку",
"schedule_send_description": "Выберите, когда сервер должен отправить это сообщение.",
"schedule_send_required": "Выберите дату и время.",
"schedule_send_invalid": "Введите действительные дату и время.",
"schedule_send_future": "Выберите дату и время в будущем.",
"schedule_send_too_late": "Это время позже, чем разрешает сервер.",
"schedule_send_unsupported": "Запланированная отправка не поддерживается для этой учетной записи.",
"schedule_send_cleanup_warning": "Запланированная отправка создана, но очистка черновика не удалась.",
"send_delay_unsupported": "Задержка отправки не поддерживается для этой учетной записи.",
"send_delay_unsupported_confirm": "Эта учетная запись не поддерживает задержку отправки. Отправить сразу?",
"attach_photos": "Photos & Videos",
"attach_files": "Files"
},
"confirm_dialog": {
"confirm": "Подтвердить",
@@ -752,7 +784,7 @@
"keep_editing": "Продолжить редактирование",
"tabs": {
"appearance": "Внешний вид",
"language": "Язык и регион",
"language": "Язык, регион и время",
"email": "Поведение почты",
"composer": "Редактор",
"privacy": "Конфиденциальность и безопасность",
@@ -775,6 +807,7 @@
"layout": "Макет",
"reading": "Чтение",
"composing": "Написание",
"downloads": "Загрузки",
"content_senders": "Содержимое и отправители",
"about_data": "О программе и данные",
"debug": "Отладка"
@@ -843,7 +876,11 @@
},
"unified_mailbox": {
"label": "Общий почтовый ящик",
"description": "Показывать объединённые папки (Входящие, Отправленные и др.) для всех подключённых аккаунтов"
"description": "Показывать объединённые папки (Входящие, Отправленные и др.) для всех подключённых аккаунтов",
"include_group": {
"label": "Включать групповые ящики",
"description": "Также объединять общие/групповые ящики в едином представлении."
}
},
"colorful_sidebar_icons": {
"label": "Цветные значки боковой панели",
@@ -918,6 +955,8 @@
"reenable": "Перерегистрировать",
"relay_desc": "По умолчанию используется размещённый релей Bulwark. Меняйте только если хостите самостоятельно.",
"relay_label": "Push-релей",
"relay_locked": "Установлено администратором",
"relay_locked_desc": "URL push-релея установлен вашим администратором и не может быть изменён.",
"relay_placeholder": "https://notifications.relay.example.com",
"status_active": "Активно на этом устройстве",
"status_busy": "Выполняется…",
@@ -927,8 +966,8 @@
}
},
"language_region": {
"title": "Язык и регион",
"description": "Настройте языковые и региональные предпочтения",
"title": "Язык, регион и время",
"description": "Язык, формат даты, формат времени и другие региональные настройки",
"language": {
"label": "Язык",
"description": "Выберите предпочтительный язык",
@@ -937,10 +976,13 @@
},
"date_format": {
"label": "Формат даты",
"description": "Как отображать даты",
"regional": "Региональный",
"iso": "ISO 8601",
"custom": "Пользовательский"
"description": "Как отображаются даты в списке писем",
"smart": "Умный (по региону)",
"relative": "Относительный (1 ч назад, 2 д назад)",
"full": "Всегда полная дата",
"preview_today": "Сегодня:",
"preview_this_week": "На этой неделе:",
"preview_older": "Старее:"
},
"time_format": {
"label": "Формат времени",
@@ -970,6 +1012,7 @@
"label": "Действие при удалении",
"description": "Что происходит при удалении письма",
"trash": "Переместить в корзину",
"trash_and_read": "Переместить в корзину и пометить как прочитанное",
"permanent": "Удалить навсегда",
"warning": "Письма будут удалены навсегда и не могут быть восстановлены. Это действие необратимо."
},
@@ -1126,6 +1169,13 @@
"attachment_image_previews": {
"label": "Показывать миниатюры изображений во вложениях",
"description": "Отображать вложенные изображения в виде миниатюр вместо обычных значков файлов"
},
"send_delay": {
"label": "Отмена отправки / задержка отправки",
"description": "Задерживает обычную отправку на короткий серверный интервал.",
"off": "Выкл.",
"seconds": "{seconds} сек.",
"unsupported": "Текущая учетная запись не заявляет поддержку отложенной отправки. Настройка сохранится для других учетных записей."
}
},
"composer": {
@@ -1495,6 +1545,50 @@
"group_by_letter_description": "Показывать алфавитные заголовки разделов в списке контактов",
"group_by_letter_label": "Группировать по первой букве"
},
"downloads": {
"title": "Загрузки",
"description": "Настройте, как именуются загруженные письма и вложения.",
"reset": "Восстановить по умолчанию",
"preview": "Предпросмотр:",
"email_template": {
"label": "Имя файла письма (.eml)",
"description": "Шаблон, используемый при экспорте письма или при перетаскивании его в файловую систему. Расширение .eml добавляется автоматически."
},
"attachment_template": {
"label": "Имя файла вложения",
"description": "Шаблон, используемый при загрузке или перетаскивании вложения. Если опустить '{filename}' и '{ext}', исходное расширение сохраняется."
},
"bundle_template": {
"label": "Имя файла .zip с несколькими письмами",
"description": "Шаблон, используемый при перетаскивании или загрузке нескольких выбранных писем одним .zip-архивом. Расширение .zip добавляется автоматически."
},
"spaces": {
"label": "Пробелы",
"description": "Заменять пробелы в итоговом имени файла другим символом.",
"keep": "Сохранять пробелы",
"underscore": "Заменять на _",
"dash": "Заменять на -"
},
"lowercase": {
"label": "Нижний регистр",
"description": "Принудительно перевести всё имя файла в нижний регистр."
},
"strip_diacritics": {
"label": "Удалять диакритику",
"description": "Преобразовать буквы с акцентами в их ASCII-эквиваленты (ä → a, é → e). Полезно для инструментов, плохо работающих с Unicode-именами."
},
"collapse_separators": {
"label": "Сжимать повторяющиеся разделители",
"description": "Сжимать последовательности пробелов, подчёркиваний или дефисов до одного символа."
},
"after_export": {
"label": "После экспорта",
"description": "При необходимости переместить письмо после экспорта в .eml.",
"keep": "Оставить в ящике",
"archive": "Переместить в архив",
"trash": "Переместить в корзину"
}
},
"filters": {
"title": "Фильтры почты",
"description": "Создавайте правила для автоматической сортировки, маркировки и управления входящими письмами",
@@ -1751,7 +1845,11 @@
"color_tag": "Тег",
"remove_color": "Удалить тег",
"items_selected": "{count} писем выбрано",
"edit_draft": "Редактировать черновик"
"edit_draft": "Редактировать черновик",
"cancel_scheduled_send": "Отменить отправку",
"reschedule_send": "Перенести",
"cancel_and_edit": "Отменить и изменить",
"cancel_and_compose_again": "Отменить и написать заново"
},
"mailbox_context_menu": {
"mark_folder_read": "Отметить папку как прочитанную",
@@ -1760,7 +1858,7 @@
"new_subfolder": "Новая вложенная папка...",
"new_folder": "Новая папка...",
"rename": "Переименовать...",
"import_email": "Импортировать .eml...",
"import_email": "Импортировать .eml или .zip...",
"empty_folder": "Очистить папку",
"empty_folder_generic": "Очистить папку",
"delete_folder": "Удалить папку",
@@ -1829,6 +1927,8 @@
"expand_collapse": "Развернуть/свернуть цепочку"
},
"composer": {
"send": "Отправить письмо",
"schedule_send": "Запланировать отправку",
"template_picker": "Открыть выбор шаблонов"
}
},
+115 -15
View File
@@ -132,7 +132,8 @@
"shared": "Paylaşılan",
"mail": "Posta",
"nav_label": "Gezinme",
"add_app": "Uygulamalar"
"add_app": "Uygulamalar",
"scheduled": "Zamanlandı"
},
"protocol_handlers": {
"title": "Varsayılan uygulamalar",
@@ -239,7 +240,16 @@
"confirm_button": "Klasörü boşalt",
"junk_hint": "Tüm iletileri kalıcı olarak kaldırmak için Önemsiz klasörünü boşaltabilirsiniz.",
"trash_hint": "Tüm iletileri kalıcı olarak kaldırmak için Çöp Kutusu klasörünü boşaltabilirsiniz."
}
},
"no_scheduled_emails": "Zamanlanmış e-posta yok",
"no_scheduled_emails_description": "Daha sonra gönderilecek iletiler burada görünür.",
"scheduled_count": "{count, plural, one {1 zamanlanmış e-posta} other {# zamanlanmış e-posta}}",
"scheduled_actions_hint": "Zamanlanmış iletiler zamanlama işlemlerinden iptal edilebilir, yeniden zamanlanabilir veya düzenlenebilir.",
"cancel_scheduled_send": "Göndermeyi iptal et",
"reschedule_send": "Yeniden zamanla",
"cancel_and_edit": "İptal et ve düzenle",
"cancel_and_compose_again": "İptal et ve yeniden yaz",
"reschedule_prompt": "Yeni tarih/saat girin, ör. 2026-05-04T15:30"
},
"email_viewer": {
"no_email_selected": "E-posta seçilmedi",
@@ -270,7 +280,7 @@
"print": "Yazdır",
"view_source": "Kaynağı görüntüle",
"export_email": ".eml olarak dışa aktar",
"import_email": ".eml içe aktar",
"import_email": ".eml veya .zip içe aktar",
"keyboard_shortcuts": "Klavye kısayolları (?)",
"email_source": "E-posta Kaynağı",
"draft_banner": "Bu ileti bir taslaktır",
@@ -514,7 +524,17 @@
"ai_verdict": "Yapay zeka kararı",
"account": "Hesap",
"no_subject": "(konu yok)"
}
},
"scheduled_banner": "{date} tarihinde gönderilmek üzere zamanlandı",
"scheduled_send_created": "E-posta gönderim için zamanlandı",
"cancel_scheduled_send": "Göndermeyi iptal et",
"reschedule_send": "Yeniden zamanla",
"cancel_and_edit": "İptal et ve düzenle",
"cancel_and_compose_again": "İptal et ve yeniden yaz",
"reschedule_prompt": "Yeni tarih/saat girin, ör. 2026-05-04T15:30",
"scheduled_actions_only": "Zamanlandı görünümü yalnızca zamanlama işlemlerini destekler",
"undo_send_scheduled": "İleti gönderim için zamanlandı",
"undo_send": "Göndermeyi geri al"
},
"email_composer": {
"new_message": "Yeni İleti",
@@ -607,7 +627,19 @@
"message": "İletinizde \"{keyword}\" geçiyor ancak hiçbir dosya eklenmemiş. Yine de gönderilsin mi?",
"send_anyway": "Yine de gönder",
"back": "Düzenlemeye dön"
}
},
"schedule_send": "Göndermeyi zamanla",
"schedule_send_description": "Sunucunun bu iletiyi ne zaman göndereceğini seçin.",
"schedule_send_required": "Bir tarih ve saat seçin.",
"schedule_send_invalid": "Geçerli bir tarih ve saat girin.",
"schedule_send_future": "Gelecekte bir tarih ve saat seçin.",
"schedule_send_too_late": "Bu zaman sunucunun izin verdiğinden daha geç.",
"schedule_send_unsupported": "Bu hesap için zamanlanmış gönderim desteklenmiyor.",
"schedule_send_cleanup_warning": "Zamanlanmış gönderim oluşturuldu, ancak taslak temizliği başarısız oldu.",
"send_delay_unsupported": "Bu hesap için gönderim gecikmesi desteklenmiyor.",
"send_delay_unsupported_confirm": "Bu hesap gönderim gecikmesini desteklemiyor. Hemen gönderilsin mi?",
"attach_photos": "Photos & Videos",
"attach_files": "Files"
},
"confirm_dialog": {
"confirm": "Onayla",
@@ -752,7 +784,7 @@
"keep_editing": "Düzenlemeye devam et",
"tabs": {
"appearance": "Görünüm",
"language": "Dil ve Bölge",
"language": "Dil, Bölge ve Saat",
"email": "E-posta Davranışı",
"composer": "Yazıcı",
"privacy": "Gizlilik ve Güvenlik",
@@ -775,6 +807,7 @@
"layout": "Düzen",
"reading": "Okuma",
"composing": "Yazma",
"downloads": "İndirilenler",
"content_senders": "İçerik ve Göndericiler",
"about_data": "Hakkında ve Veriler",
"debug": "Hata Ayıklama"
@@ -843,7 +876,11 @@
},
"unified_mailbox": {
"label": "Birleşik Posta Kutusu",
"description": "Bağlı tüm hesaplardaki birleşik klasörleri (Gelen Kutusu, Gönderilenler vb.) göster"
"description": "Bağlı tüm hesaplardaki birleşik klasörleri (Gelen Kutusu, Gönderilenler vb.) göster",
"include_group": {
"label": "Grup gelen kutularını dahil et",
"description": "Paylaşılan/grup gelen kutularını da birleşik görünüme dahil et."
}
},
"colorful_sidebar_icons": {
"label": "Renkli Kenar Çubuğu Simgeleri",
@@ -918,6 +955,8 @@
"reenable": "Yeniden kaydet",
"relay_desc": "Varsayılan olarak barındırılan Bulwark rölesini kullanır. Yalnızca kendiniz barındırıyorsanız değiştirin.",
"relay_label": "Push röle",
"relay_locked": "Yönetici tarafından ayarlandı",
"relay_locked_desc": "Push röle URL'si yöneticiniz tarafından ayarlandı ve değiştirilemez.",
"relay_placeholder": "https://notifications.relay.example.com",
"status_active": "Bu cihazda etkin",
"status_busy": "Çalışıyor…",
@@ -927,8 +966,8 @@
}
},
"language_region": {
"title": "Dil ve Bölge",
"description": "Dil ve bölgesel tercihleri yapılandırın",
"title": "Dil, Bölge ve Saat",
"description": "Dil, tarih biçimi, saat biçimi ve diğer bölgesel tercihler",
"language": {
"label": "Dil",
"description": "Tercih ettiğiniz dili seçin",
@@ -937,10 +976,13 @@
},
"date_format": {
"label": "Tarih Biçimi",
"description": "Tarihlerin nasıl görüntüleneceği",
"regional": "Bölgesel",
"iso": "ISO 8601",
"custom": "Özel"
"description": "Tarihlerin e-posta listesinde nasıl gösterileceği",
"smart": "Akıllı (bölgeye duyarlı)",
"relative": "Göreceli (1 sa önce, 2 g önce)",
"full": "Her zaman tam tarih",
"preview_today": "Bugün:",
"preview_this_week": "Bu hafta:",
"preview_older": "Daha eski:"
},
"time_format": {
"label": "Saat Biçimi",
@@ -970,6 +1012,7 @@
"label": "Silme İşlemi",
"description": "Bir e-postayı sildiğinizde ne olacağı",
"trash": "Çöp Kutusuna Taşı",
"trash_and_read": "Çöp Kutusuna Taşı ve okundu olarak işaretle",
"permanent": "Kalıcı Olarak Sil",
"warning": "E-postalar kalıcı olarak silinecek ve kurtarılamayacak. Bu işlem geri alınamaz."
},
@@ -1126,6 +1169,13 @@
"attachment_image_previews": {
"label": "Eklerde resim önizlemelerini göster",
"description": "Resim eklerini, genel dosya simgeleri yerine küçük resim kartları olarak göster"
},
"send_delay": {
"label": "Göndermeyi geri al / gönderim gecikmesi",
"description": "Normal gönderimleri sunucu tarafında kısa bir süre geciktirin.",
"off": "Kapalı",
"seconds": "{seconds} saniye",
"unsupported": "Geçerli hesap gecikmeli gönderim desteği bildirmiyor. Ayar diğer hesaplar için kaydedilmeye devam eder."
}
},
"composer": {
@@ -1495,6 +1545,50 @@
"categories_description": "Kişi kategorilerini yeniden adlandırın",
"no_categories": "Kategori bulunamadı"
},
"downloads": {
"title": "İndirilenler",
"description": "İndirilen e-postaların ve eklerin nasıl adlandırılacağını özelleştirin.",
"reset": "Varsayılana sıfırla",
"preview": "Önizleme:",
"email_template": {
"label": "E-posta dosya adı (.eml)",
"description": "Bir e-postayı dışa aktarırken veya dosya sistemine sürüklediğinizde kullanılan şablon. .eml uzantısı otomatik olarak eklenir."
},
"attachment_template": {
"label": "Ek dosyası adı",
"description": "Bir eki indirirken veya sürüklerken kullanılan şablon. '{filename}' ve '{ext}' yazılmazsa orijinal uzantı korunur."
},
"bundle_template": {
"label": "Çoklu e-posta .zip dosya adı",
"description": "Birden çok seçili e-postayı tek bir .zip arşivi olarak sürüklediğinizde veya indirdiğinizde kullanılan şablon. .zip uzantısı otomatik olarak eklenir."
},
"spaces": {
"label": "Boşluklar",
"description": "Sonuçtaki dosya adındaki boşlukları başka bir karakterle değiştirin.",
"keep": "Boşlukları koru",
"underscore": "_ ile değiştir",
"dash": "- ile değiştir"
},
"lowercase": {
"label": "Küçük harf",
"description": "Tüm dosya adını küçük harfe zorla."
},
"strip_diacritics": {
"label": "Aksanları kaldır",
"description": "Aksanlı harfleri ASCII karşılıklarına dönüştür (ä → a, é → e). Unicode dosya adlarıyla sorun yaşayan araçlar için yararlıdır."
},
"collapse_separators": {
"label": "Tekrarlanan ayırıcıları birleştir",
"description": "Art arda gelen boşluk, alt çizgi veya tireleri tek bir karaktere indir."
},
"after_export": {
"label": "Dışa aktarmadan sonra",
"description": "E-postayı .eml olarak dışa aktardıktan sonra isteğe bağlı olarak taşı.",
"keep": "Posta kutusunda tut",
"archive": "Arşive taşı",
"trash": "Çöp kutusuna taşı"
}
},
"filters": {
"title": "E-posta Filtreleri",
"description": "Gelen e-postaları otomatik olarak sıralamak, etiketlemek ve yönetmek için kurallar oluşturun",
@@ -1751,7 +1845,11 @@
"color_tag": "Etiket",
"remove_color": "Etiketi kaldır",
"items_selected": "{count} e-posta seçildi",
"edit_draft": "Taslağı Düzenle"
"edit_draft": "Taslağı Düzenle",
"cancel_scheduled_send": "Göndermeyi iptal et",
"reschedule_send": "Yeniden zamanla",
"cancel_and_edit": "İptal et ve düzenle",
"cancel_and_compose_again": "İptal et ve yeniden yaz"
},
"mailbox_context_menu": {
"mark_folder_read": "Klasörü okundu olarak işaretle",
@@ -1760,7 +1858,7 @@
"new_subfolder": "Yeni alt klasör...",
"new_folder": "Yeni klasör...",
"rename": "Yeniden adlandır...",
"import_email": ".eml içe aktar...",
"import_email": ".eml veya .zip içe aktar...",
"empty_folder": "Klasörü boşalt",
"empty_folder_generic": "Klasörü boşalt",
"delete_folder": "Klasörü sil",
@@ -1829,6 +1927,8 @@
"expand_collapse": "İleti dizisini genişlet/daralt"
},
"composer": {
"send": "E-posta gönder",
"schedule_send": "Göndermeyi planla",
"template_picker": "Şablon seçiciyi aç"
}
},
+115 -15
View File
@@ -132,7 +132,8 @@
"mail": "Пошта",
"nav_label": "Навігація",
"add_app": "програми",
"shared": "Спільні"
"shared": "Спільні",
"scheduled": "Заплановано"
},
"protocol_handlers": {
"title": "Програми за замовчуванням",
@@ -239,7 +240,16 @@
"confirm_button": "Порожня папка",
"junk_hint": "Ви можете очистити папку «Сміття», щоб остаточно видалити всі повідомлення.",
"trash_hint": "Ви можете очистити папку «Кошик», щоб остаточно видалити всі повідомлення."
}
},
"no_scheduled_emails": "Немає запланованих листів",
"no_scheduled_emails_description": "Повідомлення, заплановані на пізніше, з’являться тут.",
"scheduled_count": "{count, plural, one {# запланований лист} few {# заплановані листи} other {# запланованих листів}}",
"scheduled_actions_hint": "Заплановані повідомлення можна скасувати, перенести або редагувати через дії планування.",
"cancel_scheduled_send": "Скасувати надсилання",
"reschedule_send": "Перенести",
"cancel_and_edit": "Скасувати й редагувати",
"cancel_and_compose_again": "Скасувати й написати знову",
"reschedule_prompt": "Введіть нову дату/час, напр. 2026-05-04T15:30"
},
"email_viewer": {
"no_email_selected": "Електронна адреса не вибрана",
@@ -270,7 +280,7 @@
"print": "Роздрукувати",
"view_source": "Переглянути джерело",
"export_email": "Експортувати як .eml",
"import_email": "Імпорт .eml",
"import_email": "Імпорт .eml або .zip",
"keyboard_shortcuts": "Комбінації клавіш (?)",
"email_source": "Джерело електронної пошти",
"draft_banner": "Це повідомлення є чернеткою",
@@ -514,7 +524,17 @@
"ai_verdict": "Висновок ШІ",
"account": "Обліковий запис",
"no_subject": "(без теми)"
}
},
"scheduled_banner": "Заплановано на надсилання {date}",
"scheduled_send_created": "Лист заплановано до надсилання",
"cancel_scheduled_send": "Скасувати надсилання",
"reschedule_send": "Перенести",
"cancel_and_edit": "Скасувати й редагувати",
"cancel_and_compose_again": "Скасувати й написати знову",
"reschedule_prompt": "Введіть нову дату/час, напр. 2026-05-04T15:30",
"scheduled_actions_only": "У поданні Заплановано доступні лише дії планування",
"undo_send_scheduled": "Повідомлення заплановано до надсилання",
"undo_send": "Скасувати надсилання"
},
"email_composer": {
"new_message": "Нове повідомлення",
@@ -607,7 +627,19 @@
},
"add_link": "Додати посилання",
"link_url_prompt": "Введіть URL",
"sending": "Надсилання..."
"sending": "Надсилання...",
"schedule_send": "Запланувати надсилання",
"schedule_send_description": "Виберіть, коли сервер має надіслати це повідомлення.",
"schedule_send_required": "Виберіть дату й час.",
"schedule_send_invalid": "Введіть дійсні дату й час.",
"schedule_send_future": "Виберіть дату й час у майбутньому.",
"schedule_send_too_late": "Цей час пізніший, ніж дозволяє сервер.",
"schedule_send_unsupported": "Заплановане надсилання не підтримується для цього облікового запису.",
"schedule_send_cleanup_warning": "Заплановане надсилання створено, але очищення чернетки не вдалося.",
"send_delay_unsupported": "Затримка надсилання не підтримується для цього облікового запису.",
"send_delay_unsupported_confirm": "Цей обліковий запис не підтримує затримку надсилання. Надіслати негайно?",
"attach_photos": "Photos & Videos",
"attach_files": "Files"
},
"confirm_dialog": {
"confirm": "Підтвердити",
@@ -752,7 +784,7 @@
"keep_editing": "Продовжуйте редагувати",
"tabs": {
"appearance": "Зовнішній вигляд",
"language": "Мова та регіон",
"language": "Мова, регіон і час",
"email": "Поведінка електронної пошти",
"composer": "Композитор",
"privacy": "Конфіденційність і безпека",
@@ -775,6 +807,7 @@
"layout": "Макет",
"reading": "Читання",
"composing": "Написання",
"downloads": "Завантаження",
"content_senders": "Вміст і відправники",
"about_data": "Про програму та дані",
"debug": "Налагодження"
@@ -843,7 +876,11 @@
},
"unified_mailbox": {
"label": "Спільна поштова скринька",
"description": "Показувати об'єднані папки (Вхідні, Надіслані тощо) для всіх підключених облікових записів"
"description": "Показувати об'єднані папки (Вхідні, Надіслані тощо) для всіх підключених облікових записів",
"include_group": {
"label": "Включати групові скриньки",
"description": "Також об'єднувати спільні/групові скриньки у спільному перегляді."
}
},
"colorful_sidebar_icons": {
"label": "Кольорові значки бічної панелі",
@@ -918,6 +955,8 @@
"reenable": "Перереєструвати",
"relay_desc": "Типово використовується розміщене реле Bulwark. Змінюйте лише, якщо хостите самостійно.",
"relay_label": "Push-реле",
"relay_locked": "Встановлено адміністратором",
"relay_locked_desc": "URL push-реле встановлено вашим адміністратором і його не можна змінити.",
"relay_placeholder": "https://notifications.relay.example.com",
"status_active": "Активно на цьому пристрої",
"status_busy": "Виконується…",
@@ -927,8 +966,8 @@
}
},
"language_region": {
"title": "Мова та регіон",
"description": "Налаштуйте мовні та регіональні параметри",
"title": "Мова, регіон і час",
"description": "Мова, формат дати, формат часу та інші регіональні налаштування",
"language": {
"label": "Мова",
"description": "Виберіть бажану мову",
@@ -937,10 +976,13 @@
},
"date_format": {
"label": "Формат дати",
"description": "Як мають відображатися дати",
"regional": "Регіональний",
"iso": "ISO 8601",
"custom": "Довільний"
"description": "Як відображаються дати у списку листів",
"smart": "Розумний (за регіоном)",
"relative": "Відносний (1 год тому, 2 дн тому)",
"full": "Завжди повна дата",
"preview_today": "Сьогодні:",
"preview_this_week": "Цього тижня:",
"preview_older": "Старіше:"
},
"time_format": {
"label": "Формат часу",
@@ -970,6 +1012,7 @@
"label": "Видалити дію",
"description": "Що відбувається, коли ви видаляєте електронний лист",
"trash": "Перемістити в кошик",
"trash_and_read": "Перемістити в кошик і позначити як прочитане",
"permanent": "Видалити назавжди",
"warning": "Електронні листи буде видалено назавжди, і їх неможливо буде відновити. Ця дія незворотна."
},
@@ -1126,6 +1169,13 @@
"attachment_image_previews": {
"label": "Показувати попередній перегляд зображень у вкладеннях",
"description": "Відображати вкладені зображення як ескізи замість загальних піктограм файлів"
},
"send_delay": {
"label": "Скасувати надсилання / затримка надсилання",
"description": "Затримує звичайне надсилання на короткий серверний інтервал.",
"off": "Вимкнено",
"seconds": "{seconds} с",
"unsupported": "Поточний обліковий запис не повідомляє про підтримку відкладеного надсилання. Налаштування залишиться збереженим для інших облікових записів."
}
},
"composer": {
@@ -1495,6 +1545,50 @@
"group_by_letter_description": "Показувати алфавітні заголовки розділів у списку контактів",
"group_by_letter_label": "Групувати за першою літерою"
},
"downloads": {
"title": "Завантаження",
"description": "Налаштуйте, як називаються завантажені листи та вкладення.",
"reset": "Відновити стандартне",
"preview": "Попередній перегляд:",
"email_template": {
"label": "Ім'я файлу листа (.eml)",
"description": "Шаблон, що використовується при експорті листа або перетягуванні його у файлову систему. Розширення .eml додається автоматично."
},
"attachment_template": {
"label": "Ім'я файлу вкладення",
"description": "Шаблон, що використовується при завантаженні або перетягуванні вкладення. Якщо пропустити '{filename}' та '{ext}', оригінальне розширення зберігається."
},
"bundle_template": {
"label": "Ім'я файлу .zip з кількома листами",
"description": "Шаблон, що використовується при перетягуванні або завантаженні кількох вибраних листів одним .zip-архівом. Розширення .zip додається автоматично."
},
"spaces": {
"label": "Пробіли",
"description": "Замінити пробіли в підсумковому імені файлу іншим символом.",
"keep": "Залишити пробіли",
"underscore": "Замінити на _",
"dash": "Замінити на -"
},
"lowercase": {
"label": "Нижній регістр",
"description": "Примусово перетворити все ім'я файлу на нижній регістр."
},
"strip_diacritics": {
"label": "Видалити діакритику",
"description": "Перетворити літери з акцентами на ASCII-еквіваленти (ä → a, é → e). Корисно для інструментів, що погано обробляють Unicode."
},
"collapse_separators": {
"label": "Об'єднати повторювані роздільники",
"description": "Об'єднати послідовності пробілів, підкреслень або дефісів в один символ."
},
"after_export": {
"label": "Після експорту",
"description": "За потреби перемістити лист після експорту як .eml.",
"keep": "Залишити у скриньці",
"archive": "Перемістити в архів",
"trash": "Перемістити у кошик"
}
},
"filters": {
"title": "Фільтри електронної пошти",
"description": "Створіть правила для автоматичного сортування, позначення та керування вхідними електронними листами",
@@ -1751,7 +1845,11 @@
"color_tag": "Мітка",
"remove_color": "Видалити мітку",
"items_selected": "Вибрано електронних листів: {count}",
"edit_draft": "Редагувати чернетку"
"edit_draft": "Редагувати чернетку",
"cancel_scheduled_send": "Скасувати надсилання",
"reschedule_send": "Перенести",
"cancel_and_edit": "Скасувати й редагувати",
"cancel_and_compose_again": "Скасувати й написати знову"
},
"mailbox_context_menu": {
"mark_folder_read": "Позначити папку як прочитану",
@@ -1760,7 +1858,7 @@
"new_subfolder": "Нова вкладена папка...",
"new_folder": "Нова папка...",
"rename": "Перейменувати...",
"import_email": "Імпортувати .eml...",
"import_email": "Імпортувати .eml або .zip...",
"empty_folder": "Очистити папку",
"empty_folder_generic": "Очистити папку",
"delete_folder": "Видалити папку",
@@ -1829,6 +1927,8 @@
"expand_collapse": "Розгорнути/згорнути ланцюжок"
},
"composer": {
"send": "Надіслати лист",
"schedule_send": "Запланувати надсилання",
"template_picker": "Відкрити засіб вибору шаблону"
}
},
+115 -15
View File
@@ -132,7 +132,8 @@
"mail": "邮件",
"nav_label": "导航",
"add_app": "应用",
"shared": "共享"
"shared": "共享",
"scheduled": "已计划"
},
"protocol_handlers": {
"title": "默认应用",
@@ -239,7 +240,16 @@
"confirm_button": "清空文件夹",
"junk_hint": "您可以清空垃圾邮件文件夹以永久删除所有邮件。",
"trash_hint": "您可以清空已删除文件夹以永久删除所有邮件。"
}
},
"no_scheduled_emails": "没有计划发送的邮件",
"no_scheduled_emails_description": "计划稍后发送的邮件会显示在这里。",
"scheduled_count": "{count, plural, one {1 封计划发送邮件} other {# 封计划发送邮件}}",
"scheduled_actions_hint": "计划发送的邮件可以通过计划操作取消、重新计划或编辑。",
"cancel_scheduled_send": "取消发送",
"reschedule_send": "重新计划",
"cancel_and_edit": "取消并编辑",
"cancel_and_compose_again": "取消并重新撰写",
"reschedule_prompt": "输入新的日期/时间,例如 2026-05-04T15:30"
},
"email_viewer": {
"no_email_selected": "未选择邮件",
@@ -270,7 +280,7 @@
"print": "打印",
"view_source": "查看源码",
"export_email": "导出为 .eml",
"import_email": "导入 .eml",
"import_email": "导入 .eml 或 .zip",
"keyboard_shortcuts": "键盘快捷键(?)",
"email_source": "邮件源码",
"draft_banner": "这是一封草稿邮件",
@@ -514,7 +524,17 @@
"ai_verdict": "AI 判断",
"account": "账户",
"no_subject": "(无主题)"
}
},
"scheduled_banner": "计划于 {date} 发送",
"scheduled_send_created": "邮件已计划发送",
"cancel_scheduled_send": "取消发送",
"reschedule_send": "重新计划",
"cancel_and_edit": "取消并编辑",
"cancel_and_compose_again": "取消并重新撰写",
"reschedule_prompt": "输入新的日期/时间,例如 2026-05-04T15:30",
"scheduled_actions_only": "计划发送视图仅支持计划操作",
"undo_send_scheduled": "邮件已计划发送",
"undo_send": "撤销发送"
},
"email_composer": {
"new_message": "新邮件",
@@ -607,7 +627,19 @@
},
"add_link": "添加链接",
"link_url_prompt": "输入 URL",
"sending": "发送中..."
"sending": "发送中...",
"schedule_send": "计划发送",
"schedule_send_description": "选择服务器应何时发送此邮件。",
"schedule_send_required": "请选择日期和时间。",
"schedule_send_invalid": "请输入有效的日期和时间。",
"schedule_send_future": "请选择未来的日期和时间。",
"schedule_send_too_late": "该时间晚于服务器允许的范围。",
"schedule_send_unsupported": "此账户不支持计划发送。",
"schedule_send_cleanup_warning": "已创建计划发送,但清理草稿失败。",
"send_delay_unsupported": "此账户不支持发送延迟。",
"send_delay_unsupported_confirm": "此账户不支持发送延迟。是否立即发送?",
"attach_photos": "Photos & Videos",
"attach_files": "Files"
},
"confirm_dialog": {
"confirm": "确认",
@@ -752,7 +784,7 @@
"keep_editing": "继续编辑",
"tabs": {
"appearance": "外观",
"language": "语言地区",
"language": "语言地区与时间",
"email": "邮件行为",
"composer": "邮件撰写",
"privacy": "隐私与安全",
@@ -775,6 +807,7 @@
"layout": "布局",
"reading": "阅读",
"composing": "撰写",
"downloads": "下载",
"content_senders": "内容和发件人",
"about_data": "关于和数据",
"debug": "调试"
@@ -843,7 +876,11 @@
},
"unified_mailbox": {
"label": "统一邮箱",
"description": "显示所有已连接账户的合并文件夹(收件箱、已发送等)"
"description": "显示所有已连接账户的合并文件夹(收件箱、已发送等)",
"include_group": {
"label": "包含群组收件箱",
"description": "在统一视图中也合并共享/群组收件箱。"
}
},
"colorful_sidebar_icons": {
"label": "彩色侧边栏图标",
@@ -918,6 +955,8 @@
"reenable": "重新注册",
"relay_desc": "默认使用托管的 Bulwark 中继。仅当您自托管时才更改。",
"relay_label": "推送中继",
"relay_locked": "由管理员设置",
"relay_locked_desc": "推送中继 URL 已由您的管理员设置,无法更改。",
"relay_placeholder": "https://notifications.relay.example.com",
"status_active": "在此设备上活动",
"status_busy": "工作中…",
@@ -927,8 +966,8 @@
}
},
"language_region": {
"title": "语言地区",
"description": "配置语言和区域首选项",
"title": "语言地区与时间",
"description": "语言、日期格式、时间格式以及其他区域设置",
"language": {
"label": "语言",
"description": "选择您的首选语言",
@@ -937,10 +976,13 @@
},
"date_format": {
"label": "日期格式",
"description": "日期应如何显示",
"regional": "区域格式",
"iso": "ISO 8601",
"custom": "自定义"
"description": "电子邮件列表中日期的显示方式",
"smart": "智能(按地区)",
"relative": "相对时间(1 小时前、2 天前)",
"full": "始终完整日期",
"preview_today": "今天:",
"preview_this_week": "本周:",
"preview_older": "更早:"
},
"time_format": {
"label": "时间格式",
@@ -970,6 +1012,7 @@
"label": "删除操作",
"description": "删除邮件后的行为",
"trash": "移至已删除",
"trash_and_read": "移至已删除并标记为已读",
"permanent": "永久删除",
"warning": "邮件将被永久删除且无法恢复。此操作是不可逆转的。"
},
@@ -1126,6 +1169,13 @@
"attachment_image_previews": {
"label": "在附件中显示图片预览",
"description": "将图片附件显示为缩略图卡片,而不是通用文件图标"
},
"send_delay": {
"label": "撤销发送 / 发送延迟",
"description": "通过服务器端短暂窗口延迟普通发送。",
"off": "关闭",
"seconds": "{seconds} 秒",
"unsupported": "当前账户未声明支持延迟发送。该设置仍会为其他账户保存。"
}
},
"composer": {
@@ -1495,6 +1545,50 @@
"group_by_letter_description": "在联系人列表中显示按字母顺序排列的分节标题",
"group_by_letter_label": "按首字母分组"
},
"downloads": {
"title": "下载",
"description": "自定义下载的邮件和附件的命名方式。",
"reset": "恢复默认",
"preview": "预览:",
"email_template": {
"label": "邮件文件名 (.eml)",
"description": "导出邮件或将其拖到文件系统时使用的模板。扩展名 .eml 会自动添加。"
},
"attachment_template": {
"label": "附件文件名",
"description": "下载或拖出附件时使用的模板。如果省略 '{filename}' 和 '{ext}',将保留原始扩展名。"
},
"bundle_template": {
"label": "多邮件 .zip 文件名",
"description": "将多个选中的邮件作为单个 .zip 拖出或下载时使用的模板。扩展名 .zip 会自动添加。"
},
"spaces": {
"label": "空格",
"description": "用其他字符替换最终文件名中的空格。",
"keep": "保留空格",
"underscore": "替换为 _",
"dash": "替换为 -"
},
"lowercase": {
"label": "小写",
"description": "将整个文件名强制转换为小写。"
},
"strip_diacritics": {
"label": "去除变音符号",
"description": "将带重音的字母转换为对应的 ASCII 字符(ä → a, é → e)。对处理 Unicode 文件名有问题的工具很有用。"
},
"collapse_separators": {
"label": "合并重复的分隔符",
"description": "将连续的空格、下划线或短横线合并为单个字符。"
},
"after_export": {
"label": "导出后",
"description": "将邮件导出为 .eml 后可选择移动到其他位置。",
"keep": "保留在邮箱中",
"archive": "移动到归档",
"trash": "移动到回收站"
}
},
"filters": {
"title": "邮件过滤器",
"description": "创建规则,自动整理、标记和管理收到的邮件",
@@ -1751,7 +1845,11 @@
"color_tag": "标签",
"remove_color": "删除标签",
"items_selected": "已选择 {count} 封邮件",
"edit_draft": "编辑草稿"
"edit_draft": "编辑草稿",
"cancel_scheduled_send": "取消发送",
"reschedule_send": "重新计划",
"cancel_and_edit": "取消并编辑",
"cancel_and_compose_again": "取消并重新撰写"
},
"mailbox_context_menu": {
"mark_folder_read": "将文件夹标记为已读",
@@ -1760,7 +1858,7 @@
"new_subfolder": "新建子文件夹...",
"new_folder": "新建文件夹...",
"rename": "重命名...",
"import_email": "导入 .eml...",
"import_email": "导入 .eml 或 .zip...",
"empty_folder": "清空文件夹",
"empty_folder_generic": "清空文件夹",
"delete_folder": "删除文件夹",
@@ -1829,6 +1927,8 @@
"expand_collapse": "展开/折叠会话"
},
"composer": {
"send": "发送邮件",
"schedule_send": "定时发送",
"template_picker": "打开模板选择器"
}
},
+2 -14
View File
@@ -1,12 +1,12 @@
{
"name": "bulwark-webmail",
"version": "1.7.0",
"version": "1.7.2",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "bulwark-webmail",
"version": "1.7.0",
"version": "1.7.2",
"license": "AGPL-3.0-only",
"dependencies": {
"@tanstack/react-virtual": "^3.13.24",
@@ -5971,7 +5971,6 @@
"version": "2.3.2",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"optional": true,
@@ -7643,17 +7642,6 @@
}
}
},
"node_modules/next-intl/node_modules/@swc/helpers": {
"version": "0.5.19",
"resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.19.tgz",
"integrity": "sha512-QamiFeIK3txNjgUTNppE6MiG3p7TdninpZu0E0PbqVh1a9FNLT2FRhisaa4NcaX52XVhA5l7Pk58Ft7Sqi/2sA==",
"license": "Apache-2.0",
"optional": true,
"peer": true,
"dependencies": {
"tslib": "^2.8.0"
}
},
"node_modules/next/node_modules/postcss": {
"version": "8.4.31",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.31.tgz",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "bulwark-webmail",
"version": "1.7.0",
"version": "1.7.2",
"description": "Bulwark Webmail - a modern webmail client built for Stalwart Mail Server",
"author": "Bulwark Webmail <bulwark@rbm.systems>",
"license": "AGPL-3.0-only",
+3 -3
View File
@@ -97,8 +97,8 @@ export async function proxy(request: NextRequest) {
const pluginFrameOrigins = await getEnabledPluginFrameOrigins();
const frameSrc =
pluginFrameOrigins.length > 0
? `frame-src 'self' ${pluginFrameOrigins.join(" ")}`
: `frame-src 'self'`;
? `frame-src 'self' blob: ${pluginFrameOrigins.join(" ")}`
: `frame-src 'self' blob:`;
const csp = [
`default-src 'self'`,
@@ -108,7 +108,7 @@ export async function proxy(request: NextRequest) {
`font-src 'self'`,
`connect-src ${connectSrc}`,
frameSrc,
`object-src 'none'`,
`object-src 'self' blob:`,
`base-uri 'self'`,
`form-action 'self'`,
`frame-ancestors ${frameAncestors}`,

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