Compare commits

...
45 Commits
Author SHA1 Message Date
Linus Rath 0872d3dc8d chore: update version to 1.7.4 2026-06-15 23:28:20 +02:00
Linus Rath 1f889b0965 Merge branch 'main' of https://github.com/bulwarkmail/webmail 2026-06-15 23:16:10 +02:00
Linus Rath 0b9fe5451f fix: preserve line breaks in generated text/plain alternative #421 2026-06-15 23:15:04 +02:00
Max HaoandLinus Rath fd700f412e fix: fix inconsistent behavior with threading email messages in the inbox/folders 2026-06-15 23:00:30 +02:00
Max HaoandLinus Rath f7d4f9d53c fix: prevent draft emails from being marked as unread 2026-06-15 23:00:00 +02:00
Stefan HildebrandtandLinus Rath 84aced7b4e test(dev-mock): use comma display names in a mock email
Give email-002 ("Project Update - Q1 Review") a sender and CC with
"Lastname, Firstname" display names so Reply/Reply-All in dev mode
exercises the comma-in-name recipient case end to end.
2026-06-15 22:59:46 +02:00
Stefan HildebrandtandLinus Rath 94b1f5aa48 refactor: model composer recipients as arrays instead of delimited strings
Alternative to the quote-aware string fix: represent committed To/Cc/Bcc
recipients as Recipient[] ({name?, email}) with a separate input-text
string per field, instead of a single comma-joined string parsed with
split(','). Structured recipients can never be torn apart on a delimiter,
so a display name containing a comma ("Doo, John <john@doo.org>", as
produced on Reply-All) stays a single chip.

- email-composer-utils: add Recipient type, parseRecipient/formatRecipient,
  and parseRecipientList/formatRecipientList for the (de)serialization
  boundary (ComposerDraftData stays a string; quoting keeps it lossless).
  Remove the now-unused string-chip helpers.
- email-composer: to/cc/bcc are Recipient[]; toInput/ccInput/bccInput hold
  the in-progress text. Reply/forward init, autocomplete, chip edit, drag &
  drop (payload now carries the structured recipient), send/draft/validation
  and template paths all operate on arrays. withInput() folds uncommitted
  typed text into the send/validation set.
- Tests updated for the array contract; add comma-in-name chip coverage.
2026-06-15 22:59:46 +02:00
Linus Rath c51c3655d5 feat: add "All Mail" view 2026-06-15 18:42:23 +02:00
Linus Rath 404a1e847c fix: move "Plain Text Only" setting from Reading to Composing #422 2026-06-15 17:39:18 +02:00
Linus Rath dcea4fdd5e feat: show recipient address in chip drag preview 2026-06-15 15:28:03 +02:00
Linus Rath 701f96adb3 Merge branch 'main' of https://github.com/bulwarkmail/webmail 2026-06-15 15:23:09 +02:00
Stefan HildebrandtandLinus Rath 1ebfb286ad feat: drag and drop recipient chips between To/CC/BCC fields
Adds native HTML5 drag-and-drop so users can move recipient email
address chips between the To, CC, and BCC fields in the composer.
Chips dragged onto the Cc/Bcc toggle buttons auto-reveal the hidden
field and place the chip there.
2026-06-15 15:23:00 +02:00
Linus Rath b5f15dfdb7 i18n: add missing translation keys across 17 locales 2026-06-15 15:18:47 +02:00
Stefan HildebrandtandLinus Rath aee4bd78db feat: add Edit contact button to email viewer contact sidebar
Clicking an email address in the viewer already shows a contact detail
sidebar. An "Edit" button now appears there (for known contacts) that
navigates directly to the contact edit form via the existing URL-param
intent system (?contactId=…&view=edit), removing the need to open the
Contacts page manually and search for the contact.
2026-06-15 15:06:35 +02:00
Linus Rath 798a33495e refactor: remove JMAP status from admin dashboard 2026-06-14 17:15:52 +02:00
Linus Rath 4c6c1aab60 feat: manage shared/group account settings from Accounts page 2026-06-14 17:05:29 +02:00
Linus Rath 848ed9774d feat: show avatars in recipient autocomplete suggestions 2026-06-14 14:54:27 +02:00
Linus Rath b32e102ff9 feat: include directory users in recipient autocomplete 2026-06-14 14:52:13 +02:00
Linus Rath e8feb11983 feat: add telemetry to web setup wizard 2026-06-14 14:35:48 +02:00
Linus Rath 3e12fca517 fix: make telemetry opt-in 2026-06-14 14:31:26 +02:00
Linus Rath 8df483d8c7 fix: don't send connected-account key as JMAP accountId when sharing files #408 2026-06-12 00:36:45 +02:00
Linus Rath 1e63e2469a fix: strip build-time basePath from router.push redirects after login #390 2026-06-12 00:23:00 +02:00
Linus Rath e1c28e767a fix: context menu invisible on first right-click after page load 2026-06-12 00:21:09 +02:00
Linus Rath fe5645c818 refactor: redesign custom recurrence editor to match modal UI 2026-06-12 00:15:54 +02:00
Linus Rath 38570b1723 Merge branch 'main' of https://github.com/bulwarkmail/webmail 2026-06-12 00:03:52 +02:00
Linus Rath a4f476945d feat: JMAP file/folder sharing in Files app #408 2026-06-12 00:02:45 +02:00
Linus Rath 20fd9ff4de fix: prevent wide email tables from rendering with rotated headers #409 2026-06-11 19:24:05 +02:00
Linus Rath 569dde9985 fix: preserve folder list when mailbox refetch hits concurrent-request limit 2026-06-11 19:10:06 +02:00
Linus Rath 08f344403b feat: recurrence editor, set-default calendar, and timezone-aware calendar queries 2026-06-11 19:05:11 +02:00
Linus Rath be58cee989 fix: dedupe scheduling emails, Stalwart-compatible calendar filters 2026-06-11 18:24:43 +02:00
Max HaoandLinus Rath 7c11e2b3c9 add localized translation placeholders 2026-06-10 19:06:44 +02:00
Max HaoandLinus Rath f2913a7c7d feat: add email display name support to the composer. 2026-06-10 19:06:44 +02:00
Linus Rath a27a5be3dd fix: correct dark-mode background-image inversion and height clipping in email viewer 2026-06-08 15:53:44 +02:00
Linus Rath 964136b540 feat: require re-authentication for device pairing and SSO 2026-06-05 19:23:06 +02:00
Linus Rath 569f688fbf feat: QR-code SSO login between webmail and mobile app 2026-06-05 18:33:20 +02:00
Linus Rath 1d050f8469 feat: add QR code device pairing for mobile app login 2026-06-05 17:41:55 +02:00
Linus Rath 2e4c0f9eea Merge branch 'main' of https://github.com/bulwarkmail/webmail 2026-06-05 16:09:12 +02:00
Linus Rath fd0b866339 fix: open recent contact emails at "/" instead of 404ing on "/mail" 2026-06-05 16:07:38 +02:00
MaartenandLinus Rath 1518ba04dd fix(nav): hide Add App button when sidebarAppsEnabled is false 2026-06-05 14:32:56 +02:00
Norbert Balák-HorváthandLinus Rath c5672c64fb res lock file 2026-06-04 19:52:42 +02:00
Norbert Balák-HorváthandLinus Rath 5997579f54 rem lock file 2026-06-04 19:52:42 +02:00
Norbert Balák-HorváthandLinus Rath f77d2e9103 Fix HU i18n 2026-06-04 19:52:42 +02:00
Linus Rath 6350e9dabc chore: update version to 1.7.4 2026-06-04 12:59:28 +02:00
Linus Rath 7723c134ff chore: update version to 1.7.4 2026-06-04 12:39:50 +02:00
Linus Rath d11ed904a9 feat: calendar agenda plugin sidecar + persist email detail sidebar state 2026-06-04 12:35:56 +02:00
105 changed files with 6969 additions and 920 deletions
+8 -4
View File
@@ -110,12 +110,16 @@ JMAP_SERVER_URL=https://your-jmap-server.com
# Anonymous Telemetry
# =============================================================================
# Anonymous instance telemetry is enabled by default. Heartbeats contain no PII:
# version, platform, bucketed account counts, and feature toggles only. See
# Anonymous instance telemetry is OPT-IN and disabled by default. Enabling it
# helps us understand how Bulwark is used so we can make the product better.
# Heartbeats contain no PII: version, platform, bucketed account counts, and
# feature toggles only - never email addresses, hostnames, or IPs. See
# https://bulwarkmail.org/docs/legal/privacy/telemetry for the full schema.
#
# Disable telemetry entirely (overrides the admin UI):
# BULWARK_TELEMETRY=off
# Enable telemetry (also toggleable in the admin UI):
# BULWARK_TELEMETRY=on
#
# Setting this (on or off) locks the choice and disables the admin UI toggle.
# Directory for telemetry state: instance id, consent, login HMACs
# (default: ./data/telemetry). For Docker, the default resolves to
+38
View File
@@ -1,5 +1,43 @@
# Changelog
## 1.7.4 (2026-06-15)
### Features
- **Mail**: New "All Mail" view across folders and accounts
- **Mail**: Edit contact directly from the email viewer contact sidebar
- **Calendar**: Recurrence editor, set-default calendar, and timezone-aware calendar queries
- **Calendar**: Agenda plugin sidecar
- **Composer**: Email display name support
- **Composer**: Drag-and-drop recipient chips between To/CC/BCC fields, with the address shown in the drag preview
- **Composer**: Avatars in recipient autocomplete suggestions, including directory users
- **Files**: JMAP file/folder sharing in the Files app (#408)
- **Auth**: QR-code SSO login and device pairing between webmail and the mobile app
- **Auth**: Require re-authentication for device pairing and SSO
- **Accounts**: Manage shared/group account settings from the Accounts page
- **Setup**: Opt-in telemetry in the web setup wizard
- **Mail**: Persist the email detail sidebar state
### Fixes
- **Mail**: Preserve line breaks in the generated `text/plain` alternative (#421)
- **Mail**: Fix inconsistent threading of email messages in the inbox and folders
- **Mail**: Stop draft emails from being marked as unread
- **Mail**: Prevent wide email tables from rendering with rotated headers (#409)
- **Mail**: Preserve the folder list when a mailbox refetch hits the concurrent-request limit
- **Mail**: Correct dark-mode background-image inversion and height clipping in the email viewer
- **Calendar**: Dedupe scheduling emails and use Stalwart-compatible calendar filters
- **Calendar**: Redesign the custom recurrence editor to match the modal UI
- **Files**: Don't send the connected-account key as the JMAP `accountId` when sharing files (#408)
- **Routing**: Strip the build-time `basePath` from `router.push` redirects after login (#390)
- **Nav**: Open recent contact emails at `/` instead of 404ing on `/mail`
- **Nav**: Hide the Add App button when `sidebarAppsEnabled` is false
- **Settings**: Move the "Plain Text Only" setting from Reading to Composing (#422)
- **Privacy**: Make telemetry opt-in
- **UI**: Fix the context menu being invisible on first right-click after page load
- **Admin**: Remove the JMAP status from the admin dashboard
- **i18n**: Add missing translation keys across 17 locales
## 1.7.3 (2026-06-04)
### Features
+2 -1
View File
@@ -69,6 +69,7 @@
- Grid and list views with sorting by name, size, or date
- Previews for images, text, audio, and video
- Clipboard operations (cut, copy, paste, duplicate), favorites, and recent files
- JMAP sharing (RFC 9670) for files and folders share with users or groups at read, read/write, or manager levels via a principal picker, with share indicators and a "Shared with me" sidebar section for folders other principals have shared with you
## Security & Privacy
@@ -135,7 +136,7 @@ Automatic browser detection with persistent preference. Configurable locale URL
- Progressive Web App with service worker, install prompt, web push notifications for inbox mail, dynamic manifest, and configurable (per-domain) install screenshots
- Automatic update check with server-side logging of new releases and a non-dismissible update notice
- Structured logging (`text` or `json`) with category-based levels
- Anonymous instance telemetry (opt-out via admin UI or `BULWARK_TELEMETRY=off`) version, platform, bucketed account counts, feature toggles only
- Anonymous instance telemetry (opt-in via admin UI, the installer, or `BULWARK_TELEMETRY=on`; off by default) version, platform, bucketed account counts, feature toggles only
- Release (`main`) and development (`dev`) Docker images on GHCR
- Subpath deployment via `NEXT_PUBLIC_BASE_PATH` for mounting behind a reverse proxy
- Demo mode with fixture data no mail server required
+1 -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.3-green.svg?logo=git&logoColor=white)](CHANGELOG.md)
[![Version](https://img.shields.io/badge/version-1.7.4-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/)
+1 -1
View File
@@ -1 +1 @@
1.7.3
1.7.4
+40 -4
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 { apiFetch, getPathPrefix } from "@/lib/browser-navigation";
import { apiFetch, getPathPrefix, toRouterPath } from "@/lib/browser-navigation";
import { Loader2, AlertCircle } from "lucide-react";
import { Button } from "@/components/ui/button";
import { useParams } from "next/navigation";
@@ -32,6 +32,42 @@ function OAuthCallbackInner() {
return;
}
// Step-up re-auth for device pairing: the QR generator sent the user here
// via prompt=login. Don't create a login session — just confirm the fresh
// auth (sets the short-lived pairing proof cookie) and bounce back to the
// Security settings, where the QR generation auto-resumes.
let pairReauthResume = false;
try {
pairReauthResume = sessionStorage.getItem("pair_reauth_resume") === "1";
} catch { /* sessionStorage unavailable */ }
if (pairReauthResume && state) {
try { sessionStorage.removeItem("pair_reauth_resume"); } catch { /* ignore */ }
(async () => {
try {
const res = await apiFetch("/api/auth/reauth/sso/complete", {
method: "POST",
headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify({ code, state }),
});
if (!res.ok) {
setError("token_exchange_failed");
return;
}
try {
sessionStorage.setItem("pair_reauth_done", "1");
// Land back on the Security tab (readPersistedTab reads this key).
sessionStorage.setItem("settings-deep-link-tab", "security");
} catch { /* ignore */ }
const prefix = getPathPrefix(params.locale as string);
router.push(toRouterPath(`${prefix}/${params.locale}/settings`));
} catch {
setError("token_exchange_failed");
}
})();
return;
}
const savedState = sessionStorage.getItem("oauth_state");
if (savedState) {
@@ -69,7 +105,7 @@ function OAuthCallbackInner() {
redirectTo = saved;
}
} catch { /* sessionStorage may be unavailable */ }
router.push(redirectTo);
router.push(toRouterPath(redirectTo));
} else {
setError("token_exchange_failed");
}
@@ -153,7 +189,7 @@ function OAuthCallbackInner() {
redirectTo = saved;
}
} catch { /* sessionStorage may be unavailable */ }
router.push(redirectTo);
router.push(toRouterPath(redirectTo));
} else {
setError("token_exchange_failed");
}
@@ -181,7 +217,7 @@ function OAuthCallbackInner() {
</p>
<Button
variant="outline"
onClick={() => router.push(`${getPathPrefix(params.locale as string)}/${params.locale}/login`)}
onClick={() => router.push(toRouterPath(`${getPathPrefix(params.locale as string)}/${params.locale}/login`))}
>
{t("oauth_error.back_to_login")}
</Button>
+2 -1
View File
@@ -173,12 +173,13 @@ export default function ContactsPage() {
const addEmail = searchParams.get('addEmail');
const addName = searchParams.get('addName');
const from = searchParams.get('from');
const viewParam = searchParams.get('view');
if (!contactId && !addEmail && !from) return;
intentAppliedRef.current = true;
if (from === 'email') setReturnToEmail(true);
if (contactId) {
setSelectedContact(contactId);
setView('detail');
setView(viewParam === 'edit' ? 'edit' : 'detail');
} else if (addEmail) {
setCreatePrefill({ email: addEmail, name: addName ?? undefined });
setSelectedContact(null);
+14
View File
@@ -22,6 +22,7 @@ import { useIsMobile } from "@/hooks/use-media-query";
import { useRefreshGesture } from "@/hooks/use-refresh-gesture";
import { usePolicyStore } from "@/stores/policy-store";
import { FileBrowser } from "@/components/files/file-browser";
import type { FileNodeRights } from "@/lib/jmap/types";
import { ImagePreviewModal } from "@/components/files/image-preview-modal";
import { FilePreviewModal } from "@/components/files/file-preview-modal";
import { loadFilesSettings } from "@/components/files/files-settings-dialog";
@@ -88,6 +89,7 @@ export default function FilesPage() {
cancelUpload,
undoLastAction,
lastAction,
shareResource,
} = useFileStore();
const isMobile = useIsMobile();
@@ -401,6 +403,14 @@ export default function FilesPage() {
const currentFilesAccountId = useFileStore((s) => s.currentAccountId);
// Sharing: the browsing client (store-attached) drives the principal picker
// and share mutations. supportsPrincipals() gates the whole Share affordance.
const sharingEnabled = !!storeClient?.supportsPrincipals();
const filesAccountId = storeClient?.getFilesAccountId() ?? null;
const handleShare = useCallback(async (id: string, principalId: string, rights: FileNodeRights | null) => {
await shareResource(id, principalId, rights);
}, [shareResource]);
// Pro shell only: all connected accounts are equal top-level entries at
// the root. The root path "/" itself is a cross-account picker - no
// account's files are shown until the user enters one.
@@ -540,6 +550,10 @@ export default function FilesPage() {
onSelectAccount={handleSelectAccount}
accountPickerMode={isAccountPicker}
accountLabel={currentAccountLabel}
client={storeClient}
ownAccountId={filesAccountId}
sharingEnabled={sharingEnabled}
onShare={handleShare}
/>
</div>
)}
+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, withBasePath } from "@/lib/browser-navigation";
import { apiFetch, getPathPrefix, toRouterPath, 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 { type OAuthMetadata } from "@/lib/oauth/discovery";
@@ -279,7 +279,7 @@ export default function LoginPage() {
redirectTo = saved;
}
} catch { /* ignore */ }
router.push(redirectTo);
router.push(toRouterPath(redirectTo));
}
}, [isAuthenticated, router, isAddAccountMode, isMobileHandoff, mobileRedirectUri, mobileState]);
@@ -655,7 +655,7 @@ export default function LoginPage() {
redirectTo = saved;
}
} catch { /* ignore */ }
router.push(redirectTo);
router.push(toRouterPath(redirectTo));
}
};
+48 -4
View File
@@ -11,8 +11,9 @@ import type { ComposerDraftData } from "@/components/email/email-composer";
import { ProtocolAccountPicker } from "@/components/protocol/protocol-account-picker";
import { ThreadConversationView } from "@/components/email/thread-conversation-view";
import { MobileHeader } from "@/components/layout/mobile-header";
import { ThreadGroup, Email, isUnifiedMailboxId, UNIFIED_ROLE_BY_ID } from "@/lib/jmap/types";
import { ThreadGroup, Email, isUnifiedMailboxId, UNIFIED_ROLE_BY_ID, ALL_MAIL_MAILBOX_ID } from "@/lib/jmap/types";
import { useAccountStore } from "@/stores/account-store";
import { usePolicyStore } from "@/stores/policy-store";
import type { UnifiedAccountClient } from "@/lib/unified-mailbox";
import { KeyboardShortcutsModal } from "@/components/keyboard-shortcuts-modal";
import { useEmailStore, buildUnifiedAccountClients } from "@/stores/email-store";
@@ -334,6 +335,7 @@ export default function Home() {
viewingAccountId,
selectAccountMailbox,
setViewingAccount,
refreshCurrentMailbox,
} = useEmailStore();
// Pro shell: populate per-account mailbox cache so the sidebar can render
@@ -341,7 +343,10 @@ export default function Home() {
useProMultiAccountMailboxes();
const enableUnifiedMailbox = useSettingsStore((s) => s.enableUnifiedMailbox);
const enableAllMailView = useSettingsStore((s) => s.enableAllMailView);
const delayedSendSupported = client?.hasDelayedSend() ?? true;
const allMailViewEnabled = usePolicyStore((s) => s.isFeatureEnabled('allMailViewEnabled'));
const showAllMailMailbox = allMailViewEnabled && enableAllMailView;
const activeEmails = isScheduledView ? scheduledEmails : emails;
const activeHasMore = isScheduledView ? scheduledHasMore : hasMoreEmails;
const activeIsLoading = isScheduledView ? isLoadingScheduled : isLoading;
@@ -1164,7 +1169,26 @@ export default function Home() {
}
// Refresh the current mailbox to update the UI
if (!isScheduledView) await fetchEmails(client, selectedMailbox);
if (!isScheduledView) {
await refreshCurrentMailbox(client);
// Re-fetch the replied thread's cross-folder data so the expanded
// view shows the newly sent reply without collapsing.
if (originalEmailId) {
const emailState = useEmailStore.getState();
const repliedEmail = emailState.emails.find(e => e.id === originalEmailId);
if (repliedEmail?.threadId && emailState.expandedThreadIds.has(repliedEmail.threadId)) {
const accountId = client.getAccountId();
const fullEmails = await client.getThreadEmails(repliedEmail.threadId, accountId);
if (fullEmails.length > 0) {
useEmailStore.setState((state) => {
const c = new Map(state.threadEmailsCache);
c.set(repliedEmail.threadId!, fullEmails);
return { threadEmailsCache: c };
});
}
}
}
}
} catch (error) {
console.error("Failed to send email:", error);
}
@@ -2197,7 +2221,22 @@ export default function Home() {
}
// Refresh emails to show the sent reply
await fetchEmails(client, selectedMailbox);
await refreshCurrentMailbox(client);
// Re-fetch the replied thread's cross-folder data so the expanded
// view shows the newly sent reply without collapsing.
const emailState = useEmailStore.getState();
const repliedEmail = emailState.emails.find(e => e.id === originalEmailId);
if (repliedEmail?.threadId && emailState.expandedThreadIds.has(repliedEmail.threadId)) {
const accountId = client.getAccountId();
const fullEmails = await client.getThreadEmails(repliedEmail.threadId, accountId);
if (fullEmails.length > 0) {
useEmailStore.setState((state) => {
const c = new Map(state.threadEmailsCache);
c.set(repliedEmail.threadId!, fullEmails);
return { threadEmailsCache: c };
});
}
}
};
// Show loading state while checking auth
@@ -2213,7 +2252,11 @@ export default function Home() {
}
// Get current mailbox name for mobile header
const currentMailboxName = isScheduledView ? t('sidebar.scheduled') : mailboxes.find(m => m.id === selectedMailbox)?.name || "Inbox";
const currentMailboxName = isScheduledView
? t('sidebar.scheduled')
: selectedMailbox === ALL_MAIL_MAILBOX_ID
? t('sidebar.mailboxes.all_mail')
: 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);
@@ -2475,6 +2518,7 @@ export default function Home() {
selectedKeyword={selectedKeyword}
scheduledTotal={scheduledTotal}
showScheduledMailbox={delayedSendSupported}
showAllMailMailbox={showAllMailMailbox}
onMailboxSelect={handleMailboxSelect}
onTagSelect={handleTagSelect}
onUnreadFilterClick={handleUnreadFilterClick}
+64 -8
View File
@@ -74,6 +74,7 @@ import { useEmailStore } from '@/stores/email-store';
import { usePluginStore } from '@/stores/plugin-store';
import { useThemeStore } from '@/stores/theme-store';
import { useSettingsStore } from '@/stores/settings-store';
import { useManagedAccountStore } from '@/stores/managed-account-store';
import { useIsDesktop } from '@/hooks/use-media-query';
import { NavigationRail } from '@/components/layout/navigation-rail';
import { SidebarAppsModal } from '@/components/layout/sidebar-apps-modal';
@@ -182,6 +183,7 @@ const tabSearchPaths: Record<Tab, string[]> = {
'settings.appearance.hide_account_switcher',
'settings.appearance.show_rail_account_list',
'settings.appearance.unified_mailbox',
'settings.appearance.all_mail',
'settings.appearance.colorful_sidebar_icons',
'settings.email_behavior.mail_layout',
],
@@ -198,11 +200,11 @@ const tabSearchPaths: Record<Tab, string[]> = {
'settings.email_behavior.hover_actions',
'settings.email_behavior.permanently_delete_junk',
'settings.email_behavior.show_preview',
'settings.email_behavior.plain_text_mode',
],
composing: [
'settings.email_behavior.attachment_reminder',
'settings.email_behavior.auto_select_reply_identity',
'settings.email_behavior.plain_text_mode',
'settings.email_behavior.default_mail_program',
'settings.email_behavior.signature_position',
'settings.email_behavior.sub_address_delimiter',
@@ -385,6 +387,12 @@ export default function SettingsPage() {
const sidebarAppsList = useSettingsStore((s) => s.sidebarApps);
const proInterface = useSettingsStore((s) => s.proInterface);
// When set, the settings panel is scoped to a shared/group account: a reduced
// tab list and a "Managing: <name>" header. null = the user's own account.
const managedAccountId = useManagedAccountStore((s) => s.managedAccountId);
const managedAccount = useManagedAccountStore((s) => s.managedAccount);
const clearManagedAccount = useManagedAccountStore((s) => s.clear);
// Build a per-tab haystack for fulltext search and a list of sub-results
// (individual settings) per tab. Sub-results come from translation entries
// that have a `label`/`title` field, plus dynamic content (installed
@@ -488,6 +496,10 @@ export default function SettingsPage() {
return () => window.removeEventListener('settings-tab-change', handler);
}, []);
// Leaving the settings panel drops any shared-account scope so it never
// leaks into the next visit or another session.
useEffect(() => () => clearManagedAccount(), [clearManagedAccount]);
useEffect(() => {
if (initialCheckDone && !isAuthenticated && !authLoading) {
try { sessionStorage.setItem('redirect_after_login', window.location.pathname); } catch { /* ignore */ }
@@ -626,12 +638,28 @@ export default function SettingsPage() {
...(isFeatureEnabled('debugModeEnabled') ? [{ id: 'debug' as Tab, label: t('tabs.debug'), icon: tabIcons.debug, group: 'advanced' as TabGroup }] : []),
];
// In scoped (shared-account) mode, restrict to the account-relevant tabs the
// account actually advertises. Folders is intentionally excluded (mailbox CRUD
// is hardwired to the active account). Gated on both the per-account
// capability and the session-level support/feature flags.
const scopedTabIds: Tab[] = managedAccount
? ([
managedAccount.capabilities.sieve && supportsSieve ? 'filters' : null,
managedAccount.capabilities.mail && supportsVacation ? 'vacation' : null,
managedAccount.capabilities.calendars && supportsCalendar ? 'calendar' : null,
managedAccount.capabilities.contacts && isFeatureEnabled('contactsEnabled') ? 'contacts' : null,
].filter(Boolean) as Tab[])
: [];
const visibleTabs = managedAccountId
? tabs.filter((tab) => scopedTabIds.includes(tab.id))
: tabs;
// Group tabs by category
const groupedTabs = tabGroupOrder
.map((group) => ({
group,
label: t(`tab_groups.${group}`),
items: tabs.filter((tab) => tab.group === group),
items: visibleTabs.filter((tab) => tab.group === group),
}))
.filter((g) => g.items.length > 0);
@@ -659,9 +687,13 @@ export default function SettingsPage() {
.filter((g) => g.items.length > 0)
: groupedTabs;
// If active tab is not in the visible list (e.g., feature disabled), fall back.
const isActiveVisible = tabs.some((tab) => tab.id === activeTab);
const effectiveActiveTab: Tab = isActiveVisible ? activeTab : 'appearance';
// If active tab is not in the visible list (e.g., feature disabled, or scoped
// mode hides it), fall back. In scoped mode fall back to the first scoped tab;
// otherwise the usual 'appearance' default.
const isActiveVisible = visibleTabs.some((tab) => tab.id === activeTab);
const effectiveActiveTab: Tab = isActiveVisible
? activeTab
: (managedAccountId ? (visibleTabs[0]?.id ?? 'appearance') : 'appearance');
const handleTabSelect = (tabId: Tab) => {
setActiveTab(tabId);
@@ -676,10 +708,26 @@ export default function SettingsPage() {
setPendingHighlight({ tab: tabId, label: sub.label, pluginId: sub.pluginId });
};
const activeTabLabel = tabs.find((tab) => tab.id === effectiveActiveTab)?.label ?? '';
const activeTabLabel = visibleTabs.find((tab) => tab.id === effectiveActiveTab)?.label ?? '';
const renderTabContent = () => (
<>
{managedAccountId && managedAccount && (
<button
type="button"
onClick={() => {
clearManagedAccount();
handleTabSelect('account');
}}
className="flex items-center gap-2 w-full mb-4 px-3 py-2 rounded-md border border-border bg-muted/40 hover:bg-muted text-left transition-colors"
>
<ArrowLeft className="w-4 h-4 text-muted-foreground flex-shrink-0" />
<span className="text-sm text-muted-foreground">{t('scoped.back')}</span>
<span className="ml-auto text-sm font-medium truncate">
{t('scoped.managing', { name: managedAccount.name })}
</span>
</button>
)}
{effectiveActiveTab === 'account' && <AccountSettings />}
{effectiveActiveTab === 'language' && <LanguageSettings />}
{effectiveActiveTab === 'notifications' && <NotificationSettings />}
@@ -697,8 +745,16 @@ export default function SettingsPage() {
{effectiveActiveTab === 'security' && <AccountSecuritySettings />}
{effectiveActiveTab === 'encryption' && <SmimeSettings />}
{effectiveActiveTab === 'content_senders' && <ContentSendersSettings />}
{effectiveActiveTab === 'calendar' && <><CalendarSettings /><div className="mt-8"><CalendarManagementSettings /></div></>}
{effectiveActiveTab === 'contacts' && <><ContactsSettings /><div className="mt-8"><AddressBookManagementSettings /></div></>}
{effectiveActiveTab === 'calendar' && (
managedAccountId
? <CalendarManagementSettings />
: <><CalendarSettings /><div className="mt-8"><CalendarManagementSettings /></div></>
)}
{effectiveActiveTab === 'contacts' && (
managedAccountId
? <AddressBookManagementSettings />
: <><ContactsSettings /><div className="mt-8"><AddressBookManagementSettings /></div></>
)}
{effectiveActiveTab === 'files' && <FilesSettingsComponent />}
{effectiveActiveTab === 'protocol_handlers' && <ProtocolHandlerSettings supportsCalendar={supportsCalendar} />}
{effectiveActiveTab === 'sidebar_apps' && <SidebarAppsSettings />}
-20
View File
@@ -32,7 +32,6 @@ export function DashboardTab() {
const [themeCount, setThemeCount] = useState(0);
const [policyRuleCount, setPolicyRuleCount] = useState(0);
const [accountCounts, setAccountCounts] = useState<{ total: number; active7d: number } | null>(null);
const [jmapHealth, setJmapHealth] = useState<'unknown' | 'ok' | 'error'>('unknown');
useEffect(() => {
fetchDashboardData();
@@ -82,15 +81,6 @@ export function DashboardTab() {
}
}
if (configData?.jmapServerUrl) {
try {
const jmapRes = await apiFetch('/api/config');
setJmapHealth(jmapRes.ok ? 'ok' : 'error');
} catch {
setJmapHealth('error');
}
}
const w: string[] = [];
if (adminConfigRes.ok) {
const sources = await adminConfigRes.json();
@@ -128,16 +118,6 @@ export function DashboardTab() {
<SettingItem label="JMAP Server" description={jmapUrl !== '-' ? jmapUrl : undefined}>
<span className="text-sm text-foreground">{jmapHostname}</span>
</SettingItem>
<SettingItem label="JMAP Connection">
<span className={`inline-flex items-center gap-1.5 text-sm font-medium ${
jmapHealth === 'ok' ? 'text-green-600 dark:text-green-400' : jmapHealth === 'error' ? 'text-red-600 dark:text-red-400' : 'text-muted-foreground'
}`}>
<span className={`w-2 h-2 rounded-full ${
jmapHealth === 'ok' ? 'bg-green-500' : jmapHealth === 'error' ? 'bg-red-500' : 'bg-muted-foreground/40'
}`} />
{jmapHealth === 'ok' ? 'Connected' : jmapHealth === 'error' ? 'Error' : 'Unknown'}
</span>
</SettingItem>
<SettingItem label="Last Login">
<span className="text-sm text-foreground">
{status?.lastLogin ? new Date(status.lastLogin).toLocaleString() : 'Never'}
+1
View File
@@ -21,6 +21,7 @@ const FEATURE_GATE_LABELS: Partial<Record<keyof FeatureGates, { label: string; d
folderIconsEnabled: { label: 'Folder Icons', description: 'Allow custom folder icon picker' },
hoverActionsConfigEnabled: { label: 'Hover Actions Config', description: 'Allow users to customize email hover actions' },
filesEnabled: { label: 'Files (WebDAV)', description: 'Enable file storage via WebDAV. WARNING: Large uploads can cause Stalwart/RocksDB instability. Not recommended for production.' },
allMailViewEnabled: { label: 'All Mail View', description: 'Show a virtual "All Mail" folder that merges messages from across an accounts folders into one list. Users choose which folders are included.' },
};
const RESTRICTABLE_SETTINGS = [
+6 -5
View File
@@ -118,9 +118,10 @@ export function TelemetryTab() {
<header className="space-y-2">
<h1 className="text-2xl font-semibold">Anonymous Usage Stats</h1>
<p className="text-sm text-muted-foreground">
Bulwark sends one anonymous heartbeat per day so we can see how many instances are
running, on what platforms, and which features they use. <strong>Enabled by default</strong>;
one click below disables it. No email addresses, no hostnames, no IPs are sent.{' '}
Bulwark can send one anonymous heartbeat per day so we can see how many instances are
running, on what platforms, and which features they use. It&apos;s <strong>off by
default</strong>; one click below enables it and helps us make the product better. No
email addresses, no hostnames, no IPs are sent.{' '}
<a
href="https://bulwarkmail.org/docs/legal/privacy/telemetry"
target="_blank"
@@ -138,8 +139,8 @@ export function TelemetryTab() {
<div className="font-medium">Status</div>
<div className="text-sm text-muted-foreground">
{status.consent === 'pending' && 'Initialising - no heartbeats sent yet.'}
{status.consent === 'on' && 'Heartbeats are enabled (default).'}
{status.consent === 'off' && 'Heartbeats are off.'}
{status.consent === 'on' && 'Heartbeats are enabled. Thanks for helping us improve!'}
{status.consent === 'off' && 'Heartbeats are off (default).'}
{envOverridden && (
<> Locked by <code>BULWARK_TELEMETRY</code> env var.</>
)}
+16 -1
View File
@@ -38,6 +38,7 @@ interface WizardConfig {
// Security
sessionSecret: string;
settingsSyncEnabled: boolean;
telemetryEnabled: boolean;
// Logging
logFormat: 'text' | 'json';
logLevel: 'error' | 'warn' | 'info' | 'debug';
@@ -66,6 +67,7 @@ const EMPTY_CONFIG: WizardConfig = {
oauthIssuerUrl: '',
sessionSecret: '',
settingsSyncEnabled: true,
telemetryEnabled: false,
logFormat: 'text',
logLevel: 'info',
faviconUrl: '',
@@ -1002,8 +1004,11 @@ function SecurityStep({ config, setConfig, onNext, onBack }: Pick<StepProps, 'co
e.preventDefault();
setSubmitting(true);
try {
const values: Partial<WizardConfig> = {
// telemetryConsent is persisted to the telemetry state file by the API,
// not to admin config - see app/api/setup/step/route.ts.
const values: Record<string, unknown> = {
settingsSyncEnabled: config.settingsSyncEnabled,
telemetryConsent: config.telemetryEnabled ? 'on' : 'off',
};
if (config.sessionSecret) values.sessionSecret = config.sessionSecret;
await onNext('security', values);
@@ -1068,6 +1073,15 @@ function SecurityStep({ config, setConfig, onNext, onBack }: Pick<StepProps, 'co
hint="Stores user preferences server-side, encrypted with the session secret."
disabled={!config.sessionSecret}
/>
<div className="rounded-md border border-border bg-muted/20 p-3">
<Toggle
checked={config.telemetryEnabled}
onChange={(v) => setConfig({ ...config, telemetryEnabled: v })}
label="Send anonymous usage stats to help improve Bulwark"
hint="Off by default. One anonymous heartbeat per day with version, platform, and which features are enabled - never email addresses, hostnames, or IPs. You can change this anytime in admin settings."
/>
</div>
<Footer>
<SecondaryButton onClick={onBack}>Back</SecondaryButton>
<PrimaryButton type="submit" disabled={submitting}>
@@ -1480,6 +1494,7 @@ function ReviewStep({ config, onBack, onFinish }: { config: WizardConfig; onBack
: 'Off'
}
/>
<SummaryRow label="Anonymous telemetry" value={config.telemetryEnabled ? 'On' : 'Off'} />
</SummaryGroup>
<SummaryGroup icon={<FileText className="w-4 h-4" />} title="Logging">
+96
View File
@@ -0,0 +1,96 @@
import { NextRequest, NextResponse } from 'next/server';
import { cookies } from 'next/headers';
import { logger } from '@/lib/logger';
import { refreshTokenCookieName, refreshTokenServerCookieName } from '@/lib/oauth/tokens';
import { buildOAuthParams, getRequiredConfig, getTokenEndpoint } from '@/lib/oauth/token-exchange';
import { getCookieOptions } from '@/lib/oauth/cookie-config';
import { createPairing } from '@/lib/auth/pairing-store';
import { hasValidPairReauth } from '@/lib/auth/pair-reauth';
import { MAX_ACCOUNT_SLOTS } from '@/lib/account-utils';
// Desktop side of the cross-device QR login. The caller must be a signed-in
// webmail session (its refresh token lives in the httpOnly jmap_rt cookie). We
// refresh that token to (a) prove the session is live and (b) obtain a fresh
// access token to hand the phone, then stash the bundle under a one-time
// pairing code. The desktop renders the returned code as a QR; the phone
// redeems it at /api/auth/pair/redeem.
//
// Token sharing note: the phone receives the SAME refresh token as the desktop.
// That is correct for OAuth servers (such as Stalwart in its default config)
// that do not rotate refresh tokens on use. If the server rotates refresh
// tokens, the two devices would fight over the latest token — such deployments
// should disable rotation for this client or use a token-exchange grant.
export async function POST(request: NextRequest) {
const cookieStore = await cookies();
try {
// Step-up gate: minting a pairing code grants new-device access, so it
// requires a recent fresh IdP re-authentication (see the reauth SSO flow).
// The client turns this 401 into a re-auth redirect, then retries.
if (!(await hasValidPairReauth())) {
return NextResponse.json({ error: 'reauth_required' }, { status: 401 });
}
const body = await request.json().catch(() => ({}));
const slot =
typeof body.slot === 'number' && body.slot >= 0 && body.slot < MAX_ACCOUNT_SLOTS
? body.slot
: 0;
const cookieName = refreshTokenCookieName(slot);
const refreshToken = cookieStore.get(cookieName)?.value;
if (!refreshToken) {
return NextResponse.json({ error: 'Not signed in' }, { status: 401 });
}
const serverId = cookieStore.get(refreshTokenServerCookieName(slot))?.value || null;
const tokenEndpoint = await getTokenEndpoint(serverId);
const params = buildOAuthParams({ grant_type: 'refresh_token', refresh_token: refreshToken }, serverId);
const tokenResponse = await fetch(tokenEndpoint, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: params.toString(),
});
if (!tokenResponse.ok) {
const errorText = await tokenResponse.text();
logger.warn('Pair create: refresh failed', { status: tokenResponse.status, error: errorText });
// Stale session — clear the dead cookie so the user is prompted to log
// back in, mirroring the token route's behaviour.
cookieStore.delete(cookieName);
cookieStore.delete(refreshTokenServerCookieName(slot));
return NextResponse.json({ error: 'Session expired' }, { status: 401 });
}
const tokens = await tokenResponse.json();
if (!tokens.access_token) {
logger.error('Pair create: refresh response missing access_token');
return NextResponse.json({ error: 'Invalid token response' }, { status: 502 });
}
// If the server rotated the refresh token, persist the new one back to the
// desktop's cookie so this very session keeps working. The phone will get
// the same (new) token below.
const effectiveRefreshToken = tokens.refresh_token || refreshToken;
if (tokens.refresh_token) {
cookieStore.set(cookieName, tokens.refresh_token, getCookieOptions());
}
const { clientId, serverUrl } = getRequiredConfig(serverId);
const { code, expiresIn } = createPairing({
accessToken: tokens.access_token,
refreshToken: effectiveRefreshToken,
expiresIn: tokens.expires_in,
tokenEndpoint,
clientId,
serverUrl,
serverId,
});
return NextResponse.json({ pairing_code: code, server_url: serverUrl, expires_in: expiresIn });
} catch (error) {
logger.error('Pair create error', { error: error instanceof Error ? error.message : 'Unknown error' });
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
}
}
+36
View File
@@ -0,0 +1,36 @@
import { NextRequest, NextResponse } from 'next/server';
import { logger } from '@/lib/logger';
import { consumePairing } from '@/lib/auth/pairing-store';
// Phone side of the cross-device QR login. The app POSTs the pairing code it
// scanned; we hand back the OAuth token bundle the desktop stashed at
// /api/auth/pair/create. The code is the only credential required — it is
// high-entropy, single-use, and expires within ~2 minutes — so this route is
// intentionally unauthenticated (the scanning device has no webmail cookies).
export async function POST(request: NextRequest) {
try {
const { pairing_code: pairingCode } = await request.json().catch(() => ({}));
if (!pairingCode || typeof pairingCode !== 'string') {
return NextResponse.json({ error: 'Missing pairing code' }, { status: 400 });
}
const tokens = consumePairing(pairingCode);
if (!tokens) {
// Unknown, expired, or already redeemed — do not distinguish.
return NextResponse.json({ error: 'Invalid or expired pairing code' }, { status: 400 });
}
return NextResponse.json({
flow: 'oauth',
server_url: tokens.serverUrl,
access_token: tokens.accessToken,
...(tokens.refreshToken ? { refresh_token: tokens.refreshToken } : {}),
...(typeof tokens.expiresIn === 'number' ? { expires_in: tokens.expiresIn } : {}),
token_endpoint: tokens.tokenEndpoint,
client_id: tokens.clientId,
});
} catch (error) {
logger.error('Pair redeem error', { error: error instanceof Error ? error.message : 'Unknown error' });
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
}
}
+70
View File
@@ -0,0 +1,70 @@
import { NextRequest, NextResponse } from 'next/server';
import { cookies } from 'next/headers';
import { logger } from '@/lib/logger';
import { decryptPayload } from '@/lib/auth/crypto';
import { exchangeCodeForTokens } from '@/lib/oauth/token-exchange';
import { setPairReauth } from '@/lib/auth/pair-reauth';
// Completes the step-up re-authentication for device pairing. The user was sent
// to the IdP with prompt=login (see /api/auth/sso/start with purpose=reauth);
// here we verify the returned code against the pending state and exchange it to
// confirm a fresh login actually happened, then set the short-lived pairing
// re-auth proof cookie. We deliberately do NOT issue a login session or write
// any refresh-token cookies — the user is already signed in; this only proves
// recency for the pairing action.
const SSO_PENDING_COOKIE = 'sso_pending';
const SSO_PENDING_MAX_AGE_MS = 5 * 60 * 1000;
export async function POST(request: NextRequest) {
const cookieStore = await cookies();
try {
const { code, state } = await request.json();
if (!code || !state) {
return NextResponse.json({ error: 'Missing code or state' }, { status: 400 });
}
const pendingCookie = cookieStore.get(SSO_PENDING_COOKIE)?.value;
if (!pendingCookie) {
return NextResponse.json({ error: 'No pending re-auth session' }, { status: 400 });
}
const pending = decryptPayload(pendingCookie);
cookieStore.delete(SSO_PENDING_COOKIE);
if (!pending) {
return NextResponse.json({ error: 'Invalid re-auth session' }, { status: 400 });
}
// Only honor pending sessions that were started for the reauth purpose, so
// a normal login code can't be redirected into setting a pairing proof.
if (pending.purpose !== 'reauth') {
return NextResponse.json({ error: 'Not a re-auth session' }, { status: 400 });
}
if (pending.state !== state) {
return NextResponse.json({ error: 'State mismatch' }, { status: 400 });
}
const createdAt = pending.created_at as number;
if (!createdAt || Date.now() - createdAt > SSO_PENDING_MAX_AGE_MS) {
return NextResponse.json({ error: 'Re-auth session expired' }, { status: 400 });
}
const codeVerifier = pending.code_verifier as string;
const redirectUri = pending.redirect_uri as string;
const pendingServerId = typeof pending.server_id === 'string' ? pending.server_id : null;
if (!codeVerifier || !redirectUri) {
return NextResponse.json({ error: 'Invalid re-auth session data' }, { status: 400 });
}
// A successful exchange proves the user just authenticated at the IdP (the
// freshness is enforced by prompt=login on the authorize request). We don't
// keep the resulting tokens.
await exchangeCodeForTokens(code, codeVerifier, redirectUri, pendingServerId);
await setPairReauth();
return NextResponse.json({ ok: true });
} catch (error) {
cookieStore.delete(SSO_PENDING_COOKIE);
logger.error('Reauth complete error', { error: error instanceof Error ? error.message : 'Unknown error' });
return NextResponse.json({ error: 'Re-authentication failed' }, { status: 401 });
}
}
+15
View File
@@ -30,8 +30,14 @@ export async function POST(request: NextRequest) {
server_id: bodyServerId,
mobile_redirect_uri: rawMobileRedirectUri,
mobile_state: rawMobileState,
purpose: rawPurpose,
} = await request.json();
// `reauth` drives the step-up flow for device pairing: it forces a fresh
// IdP login (prompt=login) and the /reauth/sso/complete handler sets the
// short-lived pairing re-auth proof instead of logging the user in again.
const isReauth = rawPurpose === 'reauth';
if (!redirect_uri || typeof redirect_uri !== 'string') {
return NextResponse.json({ error: 'Missing redirect_uri' }, { status: 400 });
}
@@ -85,6 +91,7 @@ export async function POST(request: NextRequest) {
...(serverId ? { server_id: serverId } : {}),
...(mobileRedirectUri ? { mobile_redirect_uri: mobileRedirectUri } : {}),
...(mobileState ? { mobile_state: mobileState } : {}),
...(isReauth ? { purpose: 'reauth' } : {}),
};
const encrypted = encryptPayload(pendingData);
@@ -109,6 +116,14 @@ export async function POST(request: NextRequest) {
authUrl.searchParams.set('ui_locales', locale);
}
// Force a fresh credential entry for step-up re-auth. prompt=login and
// max_age=0 both ask the IdP to re-authenticate even if it has an active
// session; honoring them depends on the IdP supporting these OIDC params.
if (isReauth) {
authUrl.searchParams.set('prompt', 'login');
authUrl.searchParams.set('max_age', '0');
}
return NextResponse.json({
authorize_url: authUrl.toString(),
state,
+334
View File
@@ -0,0 +1,334 @@
import { NextRequest, NextResponse } from 'next/server';
import { logger } from '@/lib/logger';
import { getStalwartCredentials } from '@/lib/stalwart/credentials';
import { normalizeCalendarEventLike } from '@/lib/calendar-event-normalization';
import { expandRecurringEvents } from '@/lib/recurrence-expansion';
import { parseISO } from 'date-fns';
import type { CalendarEvent } from '@/lib/jmap/types';
/**
* POST /api/calendar-agenda
*
* Sidecar for the "Calendar Agenda" plugin. Resolves the caller's calendar
* account from the stored Stalwart auth context, queries upcoming
* CalendarEvents over JMAP, expands recurring series server-side, and returns
* a slim, structured-cloneable agenda the plugin can render directly.
*
* Credentials never leave the server — the sandboxed plugin only ever sees
* the resulting agenda DTOs.
*
* Body: { days?: number (1-90, default 7), limit?: number (1-200, default 50) }
*/
const CALENDAR_CAP = 'urn:ietf:params:jmap:calendars';
const PRINCIPALS_CAP = 'urn:ietf:params:jmap:principals';
// Mirror of lib/jmap/client.ts CALENDAR_EVENT_PROPERTIES, trimmed to what the
// agenda actually needs (start/recurrence/display fields).
const EVENT_PROPERTIES = [
'id', '@type', 'uid', 'calendarIds', 'title', 'start', 'duration', 'timeZone',
'showWithoutTime', 'utcStart', 'utcEnd', 'status', 'freeBusyStatus', 'color',
'locations', 'recurrenceId', 'recurrenceIdTimeZone', 'recurrenceRule',
'recurrenceOverrides', 'excludedRecurrenceRule',
] as const;
interface JmapSession {
apiUrl?: string;
primaryAccounts?: Record<string, string>;
capabilities?: Record<string, unknown>;
}
interface AgendaEvent {
id: string;
uid: string | null;
title: string;
start: string;
end: string;
allDay: boolean;
status: string | null;
color: string | null;
location: string | null;
calendarId: string | null;
}
// Pure, server-safe equivalents of lib/calendar-utils' getEventStartDate /
// getEventEndDate. We can't import that module here because it transitively
// pulls in a "use client" calendar component (which throws at load on the
// server). Logic mirrors the originals.
function parseDurationMinutes(duration: string | undefined): number {
if (!duration) return 0;
let total = 0;
const week = duration.match(/(\d+)W/);
const day = duration.match(/(\d+)D/);
const hour = duration.match(/(\d+)H/);
const min = duration.match(/(\d+)M/);
if (week) total += parseInt(week[1], 10) * 7 * 24 * 60;
if (day) total += parseInt(day[1], 10) * 24 * 60;
if (hour) total += parseInt(hour[1], 10) * 60;
if (min) total += parseInt(min[1], 10);
return total;
}
function eventStart(event: Partial<CalendarEvent>): Date {
if (!event.showWithoutTime && event.utcStart) {
const utc = parseISO(event.utcStart);
if (!isNaN(utc.getTime())) return utc;
}
return parseISO(event.start as string);
}
function eventEnd(event: Partial<CalendarEvent>): Date {
if (!event.showWithoutTime && event.utcEnd) {
const utc = parseISO(event.utcEnd);
if (!isNaN(utc.getTime())) return utc;
}
const start = eventStart(event);
if (!event.duration) return start;
return new Date(start.getTime() + parseDurationMinutes(event.duration) * 60000);
}
function firstLocationName(event: Partial<CalendarEvent>): string | null {
const locations = event.locations;
if (!locations || typeof locations !== 'object') return null;
for (const loc of Object.values(locations)) {
const name = (loc as { name?: unknown })?.name;
if (typeof name === 'string' && name.trim()) return name.trim();
}
return null;
}
function firstCalendarId(event: Partial<CalendarEvent>): string | null {
const ids = event.calendarIds;
if (!ids || typeof ids !== 'object') return null;
const keys = Object.keys(ids);
return keys.length > 0 ? keys[0] : null;
}
export async function POST(request: NextRequest) {
try {
const creds = await getStalwartCredentials(request);
if (!creds) {
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
}
let body: { days?: unknown; limit?: unknown } = {};
try {
body = await request.json();
} catch {
/* empty body is fine */
}
const days = clampInt(body.days, 1, 90, 7);
const limit = clampInt(body.limit, 1, 200, 50);
// ── Resolve the calendar account from the JMAP session ──
// Hit Stalwart's canonical session endpoint on the SAME host as serverUrl.
// We deliberately avoid /.well-known/jmap: it 301s to the server's
// configured public hostname, which the host process may not be able to
// resolve (the browser client rewrites those URLs back to the origin for
// the same reason). Fall back to /.well-known/jmap for non-Stalwart servers.
const session = await fetchJmapSession(creds.serverUrl, creds.authHeader);
if (!session) {
return NextResponse.json({ error: 'JMAP session fetch failed' }, { status: 502 });
}
const accountId = session.primaryAccounts?.[CALENDAR_CAP];
if (!accountId) {
// No calendar account for this user — return an empty agenda, not an error.
return NextResponse.json({ events: [], generatedAt: new Date().toISOString() });
}
const using = ['urn:ietf:params:jmap:core', CALENDAR_CAP];
if (session.capabilities && PRINCIPALS_CAP in session.capabilities) {
using.push('urn:ietf:params:jmap:principals:owner');
}
// Send method calls to the same-origin JMAP endpoint the app's passthrough
// uses — never to session.apiUrl's (possibly unreachable) public host.
const apiUrl = `${creds.serverUrl}/jmap/`;
const now = new Date();
const horizon = new Date(now.getTime() + days * 24 * 60 * 60 * 1000);
// Expand from the start of today so all-day / already-running events still
// show in the agenda.
const windowStart = new Date(now);
windowStart.setHours(0, 0, 0, 0);
// ── 1) Query event IDs in range + load calendars (colours) ──
const queryReq = {
using,
methodCalls: [
[
'CalendarEvent/query',
{
accountId,
// Mirror the app's calendar store: an { after, before } window lets
// Stalwart evaluate recurrence so masters with occurrences in range
// are returned (a `before`-only filter can drop unbounded series).
filter: { after: windowStart.toISOString(), before: horizon.toISOString() },
limit: 1000,
},
'0',
],
[
'Calendar/get',
{ accountId, ids: null, properties: ['id', 'name', 'color'] },
'c',
],
],
};
const queryRes = await jmapPost(apiUrl, creds.authHeader, queryReq);
const queryResp = findResponse(queryRes, 'CalendarEvent/query', '0');
if (!queryResp) {
const err = findResponse(queryRes, 'error', '0');
return NextResponse.json(
{ error: (err?.description as string) || 'CalendarEvent/query failed' },
{ status: 502 },
);
}
const ids = (queryResp.ids as string[]) || [];
const calColors = new Map<string, { name: string; color: string | null }>();
const calResp = findResponse(queryRes, 'Calendar/get', 'c');
for (const cal of ((calResp?.list as Array<Record<string, unknown>>) || [])) {
if (typeof cal.id === 'string') {
calColors.set(cal.id, {
name: typeof cal.name === 'string' ? cal.name : '',
color: typeof cal.color === 'string' ? cal.color : null,
});
}
}
if (ids.length === 0) {
return NextResponse.json({ events: [], generatedAt: now.toISOString() });
}
// ── 2) Fetch full event objects (batched) ──
const raw: Array<Record<string, unknown>> = [];
const BATCH = 100;
for (let i = 0; i < ids.length; i += BATCH) {
const batch = ids.slice(i, i + BATCH);
const getRes = await jmapPost(apiUrl, creds.authHeader, {
using,
methodCalls: [
['CalendarEvent/get', { accountId, ids: batch, properties: EVENT_PROPERTIES }, '0'],
],
});
const getResp = findResponse(getRes, 'CalendarEvent/get', '0');
if (getResp?.list) raw.push(...(getResp.list as Array<Record<string, unknown>>));
}
// ── 3) Normalize + expand recurrences server-side ──
const normalized = raw
.map((e) => normalizeCalendarEventLike(e as Partial<CalendarEvent>))
.filter((e) => (e['@type'] ?? 'Event') === 'Event')
// Drop malformed events without a parseable start (would crash format()/
// parseISO downstream) — mirrors the calendar store guard (#316).
.filter((e) => typeof e.start === 'string' && e.start && !isNaN(parseISO(e.start).getTime())) as CalendarEvent[];
const expanded = expandRecurringEvents(
normalized,
windowStart.toISOString(),
horizon.toISOString(),
);
// ── 4) Keep ongoing/upcoming, sort, slice, map to DTOs ──
const agenda: AgendaEvent[] = expanded
.filter((e) => eventEnd(e).getTime() >= now.getTime())
.sort((a, b) => eventStart(a).getTime() - eventStart(b).getTime())
.slice(0, limit)
.map((e) => {
const calId = firstCalendarId(e);
const cal = calId ? calColors.get(calId) : undefined;
return {
id: String(e.id ?? ''),
uid: e.uid ?? null,
title: (e.title ?? '').trim() || '(no title)',
start: eventStart(e).toISOString(),
end: eventEnd(e).toISOString(),
allDay: !!e.showWithoutTime,
status: e.status ?? null,
color: e.color || cal?.color || null,
location: firstLocationName(e),
calendarId: calId,
};
});
return NextResponse.json({ events: agenda, generatedAt: now.toISOString() });
} catch (error) {
// `fetch failed` from undici is too generic to debug — the real reason
// (ENOTFOUND, ECONNREFUSED, self-signed TLS, …) lives on `error.cause`.
const err = error as Error & { cause?: { code?: string; message?: string } };
logger.error('Calendar agenda error', {
error: err?.message ?? 'Unknown',
causeCode: err?.cause?.code,
causeMessage: err?.cause?.message,
});
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
}
}
function clampInt(value: unknown, min: number, max: number, fallback: number): number {
const n = typeof value === 'number' ? value : Number(value);
if (!Number.isFinite(n)) return fallback;
return Math.min(max, Math.max(min, Math.round(n)));
}
/**
* Fetch the JMAP session from the same host as `serverUrl`. Tries Stalwart's
* canonical /jmap/session first (no redirect), then /.well-known/jmap as a
* fallback for other servers. Returns null if neither yields a usable session.
*/
async function fetchJmapSession(
serverUrl: string,
authHeader: string,
): Promise<JmapSession | null> {
const candidates = [`${serverUrl}/jmap/session`, `${serverUrl}/.well-known/jmap`];
for (const url of candidates) {
try {
const res = await fetch(url, {
method: 'GET',
headers: { Authorization: authHeader },
redirect: 'follow',
});
if (!res.ok) continue;
const session = (await res.json()) as JmapSession;
if (session && typeof session === 'object' && session.primaryAccounts) {
return session;
}
} catch {
// Try the next candidate (e.g. canonical path 404s on a non-Stalwart server).
}
}
return null;
}
async function jmapPost(
apiUrl: string,
authHeader: string,
payload: unknown,
): Promise<unknown> {
const res = await fetch(apiUrl, {
method: 'POST',
headers: { Authorization: authHeader, 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
});
if (!res.ok) {
throw new Error(`JMAP request failed (${res.status})`);
}
return res.json();
}
function findResponse(
res: unknown,
name: string,
callId: string,
): Record<string, unknown> | null {
const responses = (res as { methodResponses?: unknown[] })?.methodResponses;
if (!Array.isArray(responses)) return null;
for (const entry of responses) {
if (Array.isArray(entry) && entry[0] === name && entry[2] === callId) {
return entry[1] as Record<string, unknown>;
}
}
return null;
}
+2 -2
View File
@@ -121,9 +121,9 @@ const emails: MockEmail[] = [
},
{
id: 'email-002', threadId: 'thread-002', mailboxIds: { 'mb-inbox': true }, keywords: { $seen: true, $flagged: true, '$label:blue': true }, size: 5100, receivedAt: daysAgo(1),
from: [{ name: 'Pierre Dubois', email: 'pierre@dubois.example' }],
from: [{ name: 'Dubois, Pierre', email: 'pierre@dubois.example' }],
to: [{ name: 'Dev User', email: 'dev@localhost' }],
cc: [{ name: 'Karel de Vries', email: 'karel@devries.example' }],
cc: [{ name: 'de Vries, Karel', email: 'karel@devries.example' }],
subject: 'Project Update - Q1 Review',
preview: 'Salut team, I wanted to share the latest project numbers. We are on track to meet our targets for Q1.',
hasAttachment: true,
+26 -1
View File
@@ -4,6 +4,7 @@ import { authenticateWizardRequest } from '@/lib/setup/session';
import { configManager } from '@/lib/admin/config-manager';
import { CONFIG_ENV_MAP } from '@/lib/admin/types';
import { parseJmapServers } from '@/lib/admin/jmap-servers';
import { effectiveConsent, loadState, saveState, reschedule } from '@/lib/telemetry';
import { logger } from '@/lib/logger';
export const dynamic = 'force-dynamic';
@@ -76,8 +77,32 @@ export async function POST(request: NextRequest) {
return NextResponse.json({ error: 'values must be an object' }, { status: 400 });
}
const valuesObj = { ...(values as Record<string, unknown>) };
// Telemetry consent lives in the telemetry state file, not admin config, so
// it has no CONFIG_ENV_MAP entry. Pull it out of the security step and
// persist it directly, mirroring POST /api/admin/telemetry (set-consent).
if (step === 'security' && 'telemetryConsent' in valuesObj) {
const consent = valuesObj.telemetryConsent;
delete valuesObj.telemetryConsent;
if (consent !== 'on' && consent !== 'off') {
return NextResponse.json({ error: 'telemetryConsent must be "on" or "off"' }, { status: 400 });
}
// A BULWARK_TELEMETRY env var hard-locks the choice; don't fight it.
const { source } = await effectiveConsent();
if (source !== 'env') {
const tstate = await loadState();
tstate.consent = consent;
if (consent === 'on' && !tstate.consentedAt) {
tstate.consentedAt = new Date().toISOString();
}
await saveState(tstate);
await reschedule();
}
}
const updates: Record<string, unknown> = {};
for (const [key, value] of Object.entries(values as Record<string, unknown>)) {
for (const [key, value] of Object.entries(valuesObj)) {
if (!allowedKeys.includes(key)) {
return NextResponse.json({ error: `Key not allowed in step ${step}: ${key}` }, { status: 400 });
}
+21 -2
View File
@@ -2,7 +2,7 @@
import { useMemo, useState } from "react";
import { useTranslations } from "next-intl";
import { ChevronDown, ChevronRight, Globe, ListTodo, Pencil, RefreshCw, Share2, Trash2, Cake, User, Users, Plus, Eraser, Palette, Shuffle } from "lucide-react";
import { ChevronDown, ChevronRight, Globe, ListTodo, Pencil, RefreshCw, Share2, Star, Trash2, Cake, User, Users, Plus, Eraser, Palette, Shuffle } from "lucide-react";
import { cn, formatDateTime } from "@/lib/utils";
import type { Calendar } from "@/lib/jmap/types";
import { CalendarColorPicker } from "@/components/settings/calendar-management-settings";
@@ -96,6 +96,7 @@ export function CalendarSidebarPanel({
);
const refreshICalSubscription = useCalendarStore((s) => s.refreshICalSubscription);
const removeICalSubscription = useCalendarStore((s) => s.removeICalSubscription);
const setDefaultCalendar = useCalendarStore((s) => s.setDefaultCalendar);
const timeFormat = useSettingsStore((s) => s.timeFormat);
const sharedCalendarColors = useSettingsStore((s) => s.sharedCalendarColors);
const enableCalendarTasks = useSettingsStore((s) => s.enableCalendarTasks);
@@ -219,6 +220,16 @@ export function CalendarSidebarPanel({
}
};
const handleSetDefault = async (calendarId: string) => {
if (!client) return;
try {
await setDefaultCalendar(client, calendarId);
toast.success(tMgmt('default_updated'));
} catch {
toast.error(tMgmt('error_default'));
}
};
if (calendars.length === 0 && !onSubscribe) return null;
const renderCalendarItem = (cal: Calendar) => {
@@ -306,12 +317,13 @@ export function CalendarSidebarPanel({
const isBirthday = cal.id === BIRTHDAY_CALENDAR_ID;
const canCreate = onCreateEvent && !isBirthday && cal.myRights?.mayWriteOwn !== false;
const canShare = onShareCalendar && cal.myRights?.mayShare && !cal.isShared;
const canSetDefault = !!client && !isBirthday && !cal.isShared && !cal.isDefault;
const canChangeColor = !!onColorChange;
const hasColorOverride = !!cal.isShared && !!sharedCalendarColors[sharedCalendarColorKey(cal)];
const canResetColor = !!onResetColor && hasColorOverride;
const canClear = onClearCalendar && !isBirthday && cal.myRights?.mayDelete !== false;
const canDelete = onDeleteCalendar && !isBirthday && !cal.isDefault && !cal.isShared;
const showSeparator = (canCreate || canShare || canChangeColor || canResetColor) && (canClear || canDelete);
const showSeparator = (canCreate || canShare || canSetDefault || canChangeColor || canResetColor) && (canClear || canDelete);
const color = cal.color || "#3b82f6";
return (
@@ -330,6 +342,13 @@ export function CalendarSidebarPanel({
onClick={() => { closeContextMenu(); onShareCalendar(cal); }}
/>
)}
{canSetDefault && (
<ContextMenuItem
icon={Star}
label={tMgmt('set_default')}
onClick={() => { closeContextMenu(); handleSetDefault(cal.id); }}
/>
)}
{canChangeColor && (
<ContextMenuSubMenu icon={Palette} label={tMgmt('change_color')}>
<div className="px-2 py-1.5 w-[200px]">
+6 -11
View File
@@ -1,7 +1,7 @@
"use client";
import { useState, useEffect, useRef, useMemo, useCallback, useLayoutEffect } from "react";
import { useTranslations } from "next-intl";
import { useTranslations, useLocale } from "next-intl";
import { createPortal } from "react-dom";
import { Button } from "@/components/ui/button";
import {
@@ -13,6 +13,7 @@ import { cn } from "@/lib/utils";
import type { CalendarEvent, Calendar, CalendarParticipant } from "@/lib/jmap/types";
import { parseDuration, getEventColor } from "./event-card";
import { getEventDisplayEndDate, getEventEndDate, getEventStartDate } from "@/lib/calendar-utils";
import { buildRecurrenceSummary } from "./recurrence-editor";
import {
isOrganizer,
getUserParticipantId,
@@ -101,16 +102,9 @@ function getAlertLabel(event: CalendarEvent, t: ReturnType<typeof useTranslation
return null;
}
function getRecurrenceLabel(event: CalendarEvent, t: ReturnType<typeof useTranslations>): string | null {
function getRecurrenceLabel(event: CalendarEvent, t: ReturnType<typeof useTranslations>, locale: string): string | null {
if (!event.recurrenceRules?.length) return null;
const freq = event.recurrenceRules[0].frequency;
const labels: Record<string, string> = {
daily: t("recurrence.daily"),
weekly: t("recurrence.weekly"),
monthly: t("recurrence.monthly"),
yearly: t("recurrence.yearly"),
};
return labels[freq] || null;
return buildRecurrenceSummary(event.recurrenceRules[0], t, locale);
}
export function EventDetailPopover({
@@ -130,6 +124,7 @@ export function EventDetailPopover({
isMobile,
}: EventDetailPopoverProps) {
const t = useTranslations("calendar");
const locale = useLocale();
const popoverRef = useRef<HTMLDivElement>(null);
const noteInputRef = useRef<HTMLTextAreaElement>(null);
const [position, setPosition] = useState<{ top: number; left: number } | null>(null);
@@ -158,7 +153,7 @@ export function EventDetailPopover({
}, [event.virtualLocations]);
const participants = useMemo(() => getParticipantList(event), [event]);
const recurrenceLabel = useMemo(() => getRecurrenceLabel(event, t), [event, t]);
const recurrenceLabel = useMemo(() => getRecurrenceLabel(event, t, locale), [event, t, locale]);
const alertLabel = useMemo(() => getAlertLabel(event, t), [event, t]);
const userIsOrganizer = useMemo(() => {
+85 -27
View File
@@ -1,12 +1,13 @@
"use client";
import { useState, useEffect, useCallback, useRef, useMemo } from "react";
import { useTranslations } from "next-intl";
import { useTranslations, useLocale } 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, isSameDay } from "date-fns";
import type { CalendarEvent, Calendar, CalendarParticipant, CalendarEventAlert } from "@/lib/jmap/types";
import type { CalendarEvent, Calendar, CalendarParticipant, CalendarEventAlert, CalendarRecurrenceRule } from "@/lib/jmap/types";
import { RecurrenceEditor, buildRecurrenceSummary, isSimpleRecurrenceRule } from "./recurrence-editor";
import { parseDuration, getEventColor } from "./event-card";
import { buildAllDayDuration, getEventDisplayEndDate, getEventEndDate, getEventStartDate, getPrimaryCalendarId } from "@/lib/calendar-utils";
import { ParticipantInput, type ParticipantInputHandle } from "./participant-input";
@@ -75,7 +76,7 @@ function buildDuration(startDate: Date, endDate: Date): string {
return dur;
}
type RecurrenceOption = "none" | "daily" | "weekly" | "monthly" | "yearly";
type RecurrenceOption = "none" | "daily" | "weekly" | "monthly" | "yearly" | "custom";
type AlertUnit = "at_time" | "minutes" | "hours" | "days" | "weeks";
@@ -157,16 +158,9 @@ function getAlertLabel(event: CalendarEvent, t: ReturnType<typeof useTranslation
return labels.join(", ");
}
function getRecurrenceLabel(event: CalendarEvent, t: ReturnType<typeof useTranslations>): string | null {
function getRecurrenceLabel(event: CalendarEvent, t: ReturnType<typeof useTranslations>, locale: string): string | null {
if (!event.recurrenceRules?.length) return null;
const freq = event.recurrenceRules[0].frequency;
const labels: Record<string, string> = {
daily: t("recurrence.daily"),
weekly: t("recurrence.weekly"),
monthly: t("recurrence.monthly"),
yearly: t("recurrence.yearly"),
};
return labels[freq] || null;
return buildRecurrenceSummary(event.recurrenceRules[0], t, locale);
}
export function EventModal({
@@ -186,6 +180,7 @@ export function EventModal({
isMobile = false,
}: EventModalProps) {
const t = useTranslations("calendar");
const locale = useLocale();
const timeFormat = useSettingsStore((s) => s.timeFormat);
const timeDisplayFmt = timeFormat === "12h" ? "h:mm a" : "HH:mm";
const isEdit = !!event;
@@ -270,8 +265,35 @@ export function EventModal({
});
const [recurrence, setRecurrence] = useState<RecurrenceOption>(() => {
if (!event?.recurrenceRules?.length) return "none";
return event.recurrenceRules[0].frequency as RecurrenceOption;
const rule = event.recurrenceRules[0];
return isSimpleRecurrenceRule(rule) ? (rule.frequency as RecurrenceOption) : "custom";
});
const [customRule, setCustomRule] = useState<CalendarRecurrenceRule | null>(() => {
if (!event?.recurrenceRules?.length) return null;
const rule = event.recurrenceRules[0];
return isSimpleRecurrenceRule(rule) ? null : rule;
});
const [showRecurrenceEditor, setShowRecurrenceEditor] = useState(false);
// Dropdown value to restore when the custom editor is cancelled without a saved rule.
const recurrenceBeforeCustomRef = useRef<RecurrenceOption>("none");
const handleRecurrenceEditorSave = useCallback((rule: CalendarRecurrenceRule) => {
setCustomRule(rule);
setRecurrence("custom");
setShowRecurrenceEditor(false);
}, []);
const handleRecurrenceEditorCancel = useCallback(() => {
setShowRecurrenceEditor(false);
if (!customRule) {
setRecurrence(recurrenceBeforeCustomRef.current);
}
}, [customRule]);
const customRuleSummary = useMemo(
() => (customRule ? buildRecurrenceSummary(customRule, t, locale) : null),
[customRule, t, locale]
);
const preservedAlertsRef = useRef<Record<string, CalendarEventAlert>>({});
const [alertRows, setAlertRows] = useState<AlertRow[]>(() => {
if (!event?.alerts) return [];
@@ -444,7 +466,9 @@ export function EventModal({
data.virtualLocations = null;
}
if (recurrence !== "none") {
if (recurrence === "custom" && customRule) {
data.recurrenceRules = [customRule];
} else if (recurrence !== "none" && recurrence !== "custom") {
data.recurrenceRules = [{
"@type": "RecurrenceRule",
frequency: recurrence,
@@ -511,7 +535,7 @@ export function EventModal({
} finally {
setIsSaving(false);
}
}, [title, description, location, virtualLocation, startDate, startTime, endDate, endTime, allDay, calendarId, recurrence, alertRows, attendees, sendInvitations, currentUserEmails, existingParticipants, event, onSave, isSaving]);
}, [title, description, location, virtualLocation, startDate, startTime, endDate, endTime, allDay, calendarId, recurrence, customRule, alertRows, attendees, sendInvitations, currentUserEmails, existingParticipants, event, onSave, isSaving]);
const handleRsvp = useCallback((status: CalendarParticipant['participationStatus']) => {
if (!event || !userParticipantId || !onRsvp) return;
@@ -737,7 +761,7 @@ export function EventModal({
const locationName = event.locations ? Object.values(event.locations)[0]?.name || null : null;
const virtualLoc = event.virtualLocations ? Object.values(event.virtualLocations)[0]?.uri || null : null;
const viewParticipants = getParticipantList(event);
const recurrenceLabel = getRecurrenceLabel(event, t);
const recurrenceLabel = getRecurrenceLabel(event, t, locale);
const alertLabel = getAlertLabel(event, t);
const eventCalendar = calendars.find(c => event.calendarIds[c.id]);
const color = getEventColor(event, eventCalendar);
@@ -1131,17 +1155,51 @@ export function EventModal({
<div>
<label className="text-sm font-medium mb-1 block">{t("recurrence.title")}</label>
<select
value={recurrence}
onChange={(e) => setRecurrence(e.target.value as RecurrenceOption)}
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-ring"
>
<option value="none">{t("recurrence.none")}</option>
<option value="daily">{t("recurrence.daily")}</option>
<option value="weekly">{t("recurrence.weekly")}</option>
<option value="monthly">{t("recurrence.monthly")}</option>
<option value="yearly">{t("recurrence.yearly")}</option>
</select>
<div className="flex items-center gap-2">
<select
value={recurrence}
onChange={(e) => {
const value = e.target.value as RecurrenceOption;
if (value === "custom") {
recurrenceBeforeCustomRef.current = recurrence;
setRecurrence("custom");
setShowRecurrenceEditor(true);
} else {
setRecurrence(value);
setShowRecurrenceEditor(false);
}
}}
className="flex-1 min-w-0 rounded-md border border-input bg-background px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-ring"
>
<option value="none">{t("recurrence.none")}</option>
<option value="daily">{t("recurrence.daily")}</option>
<option value="weekly">{t("recurrence.weekly")}</option>
<option value="monthly">{t("recurrence.monthly")}</option>
<option value="yearly">{t("recurrence.yearly")}</option>
<option value="custom">{customRuleSummary || t("recurrence.custom")}</option>
</select>
{recurrence === "custom" && !showRecurrenceEditor && (
<button
type="button"
onClick={() => setShowRecurrenceEditor(true)}
className="p-2 rounded-md text-muted-foreground hover:text-foreground hover:bg-muted transition-colors"
aria-label={t("recurrence.edit_custom")}
>
<Pencil className="w-4 h-4" />
</button>
)}
</div>
{showRecurrenceEditor && (
<RecurrenceEditor
rule={customRule}
eventStart={(() => {
const d = new Date(`${startDate}T${allDay ? "00:00" : (startTime || "00:00")}:00`);
return isNaN(d.getTime()) ? new Date() : d;
})()}
onSave={handleRecurrenceEditorSave}
onCancel={handleRecurrenceEditorCancel}
/>
)}
</div>
<div>
+9 -5
View File
@@ -4,6 +4,7 @@ import { useState, useRef, useCallback, useEffect, forwardRef, useImperativeHand
import { useTranslations } from "next-intl";
import { X } from "lucide-react";
import { Input } from "@/components/ui/input";
import { Avatar } from "@/components/ui/avatar";
import { useContactStore } from "@/stores/contact-store";
interface Participant {
@@ -144,14 +145,17 @@ export const ParticipantInput = forwardRef<ParticipantInputHandle, ParticipantIn
role="option"
aria-selected={i === activeIndex}
onMouseDown={(e) => { e.preventDefault(); addParticipant(s); }}
className={`px-3 py-2 text-sm cursor-pointer transition-colors ${
className={`px-3 py-2 text-sm cursor-pointer transition-colors flex items-center gap-2 ${
i === activeIndex ? "bg-accent text-accent-foreground" : "hover:bg-muted"
}`}
>
<div className="font-medium truncate">{s.name || s.email}</div>
{s.name && (
<div className="text-xs text-muted-foreground truncate">{s.email}</div>
)}
<Avatar name={s.name} email={s.email} size="sm" className="shrink-0 w-7 h-7 text-[10px]" />
<div className="min-w-0">
<div className="font-medium truncate">{s.name || s.email}</div>
{s.name && (
<div className="text-xs text-muted-foreground truncate">{s.email}</div>
)}
</div>
</li>
))}
</ul>
+427
View File
@@ -0,0 +1,427 @@
"use client";
import { useState } from "react";
import { useLocale, useTranslations } from "next-intl";
import { addYears, format } from "date-fns";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import type { CalendarRecurrenceRule } from "@/lib/jmap/types";
type EditorFrequency = "daily" | "weekly" | "monthly" | "yearly";
type MonthlyMode = "day" | "nth";
type EndsMode = "never" | "on" | "after";
const EDITOR_FREQUENCIES: EditorFrequency[] = ["daily", "weekly", "monthly", "yearly"];
// 2024-01-01 is a Monday - used to render localized weekday names via Intl.
const WEEKDAYS: string[] = ["mo", "tu", "we", "th", "fr", "sa", "su"];
const DAY_TO_REF_DATE: Record<string, number> = { mo: 1, tu: 2, we: 3, th: 4, fr: 5, sa: 6, su: 7 };
const INDEX_TO_DAY = ["su", "mo", "tu", "we", "th", "fr", "sa"];
const UNIT_LABEL_KEYS: Record<EditorFrequency, string> = {
daily: "recurrence.editor_unit_days",
weekly: "recurrence.editor_unit_weeks",
monthly: "recurrence.editor_unit_months",
yearly: "recurrence.editor_unit_years",
};
type CalendarT = ReturnType<typeof useTranslations>;
function weekdayName(day: string, locale: string, style: "long" | "short" = "long"): string {
const ref = new Date(2024, 0, DAY_TO_REF_DATE[day] ?? 1);
return new Intl.DateTimeFormat(locale, { weekday: style }).format(ref);
}
function monthName(month: number, locale: string): string {
return new Intl.DateTimeFormat(locale, { month: "long" }).format(new Date(2024, month - 1, 1));
}
function nthLabel(nth: number, t: CalendarT): string {
if (nth === -1) return t("recurrence.nth_last");
if (nth >= 1 && nth <= 4) return t(`recurrence.nth_${nth}`);
return String(nth);
}
function capitalize(s: string): string {
return s.charAt(0).toUpperCase() + s.slice(1);
}
/**
* Extract an "nth weekday" pattern from a rule, accepting both the
* byDay+nthOfPeriod encoding and the byDay+bySetPosition encoding.
*/
function getNthDay(rule: CalendarRecurrenceRule): { day: string; nth: number } | null {
if (rule.byDay?.length === 1) {
const nd = rule.byDay[0];
if (nd.nthOfPeriod) return { day: nd.day, nth: nd.nthOfPeriod };
if (rule.bySetPosition?.length === 1) return { day: nd.day, nth: rule.bySetPosition[0] };
}
return null;
}
/**
* True when the rule is exactly what the plain Daily/Weekly/Monthly/Yearly
* dropdown presets produce, i.e. it needs no custom editor to represent.
*/
export function isSimpleRecurrenceRule(rule: CalendarRecurrenceRule): boolean {
return (
(EDITOR_FREQUENCIES as string[]).includes(rule.frequency) &&
(!rule.interval || rule.interval === 1) &&
!rule.byDay?.length &&
!rule.byMonthDay?.length &&
!rule.byMonth?.length &&
!rule.byYearDay?.length &&
!rule.byWeekNo?.length &&
!rule.bySetPosition?.length &&
!rule.count &&
!rule.until
);
}
/**
* Human-readable summary of a recurrence rule, e.g.
* "Every 2 months on the third Thursday · 12 occurrences".
* Returns null for frequencies the UI cannot describe (hourly etc.).
*/
export function buildRecurrenceSummary(
rule: CalendarRecurrenceRule,
t: CalendarT,
locale: string,
): string | null {
const interval = rule.interval || 1;
let base: string;
switch (rule.frequency) {
case "daily":
base = interval > 1 ? t("recurrence.every_n_days", { count: interval }) : t("recurrence.daily");
break;
case "weekly":
base = interval > 1 ? t("recurrence.every_n_weeks", { count: interval }) : t("recurrence.weekly");
break;
case "monthly":
base = interval > 1 ? t("recurrence.every_n_months", { count: interval }) : t("recurrence.monthly");
break;
case "yearly":
base = interval > 1 ? t("recurrence.every_n_years", { count: interval }) : t("recurrence.yearly");
break;
default:
return null;
}
const parts = [base];
if (rule.frequency === "weekly" && rule.byDay?.length) {
const days = rule.byDay
.filter((d) => WEEKDAYS.includes(d.day))
.sort((a, b) => WEEKDAYS.indexOf(a.day) - WEEKDAYS.indexOf(b.day))
.map((d) => weekdayName(d.day, locale, "short"))
.join(", ");
if (days) parts.push(t("recurrence.on_days", { days }));
}
if (rule.frequency === "monthly" || rule.frequency === "yearly") {
if (rule.frequency === "yearly" && rule.byMonth?.length) {
const m = parseInt(rule.byMonth[0], 10);
if (m >= 1 && m <= 12) parts.push(t("recurrence.in_month", { month: monthName(m, locale) }));
}
const nthDay = getNthDay(rule);
if (nthDay) {
parts.push(t("recurrence.on_the_nth", {
nth: nthLabel(nthDay.nth, t),
day: weekdayName(nthDay.day, locale),
}));
} else if (rule.byMonthDay?.length) {
parts.push(t("recurrence.on_day_n", { day: rule.byMonthDay[0] }));
}
}
let summary = parts.join(" ");
if (rule.count) {
summary += ` · ${t("recurrence.occurrences", { count: rule.count })}`;
} else if (rule.until) {
const d = new Date(rule.until);
if (!isNaN(d.getTime())) {
summary += ` · ${t("recurrence.until")} ${new Intl.DateTimeFormat(locale, { dateStyle: "medium" }).format(d)}`;
}
}
return summary;
}
interface RecurrenceEditorProps {
rule: CalendarRecurrenceRule | null;
eventStart: Date;
onSave: (rule: CalendarRecurrenceRule) => void;
onCancel: () => void;
}
export function RecurrenceEditor({ rule, eventStart, onSave, onCancel }: RecurrenceEditorProps) {
const t = useTranslations("calendar");
const locale = useLocale();
const startDay = INDEX_TO_DAY[eventStart.getDay()];
const initialNthDay = rule ? getNthDay(rule) : null;
const [frequency, setFrequency] = useState<EditorFrequency>(() =>
rule && (EDITOR_FREQUENCIES as string[]).includes(rule.frequency)
? (rule.frequency as EditorFrequency)
: "weekly"
);
const [interval, setIntervalValue] = useState<number>(rule?.interval || 1);
const [weekDays, setWeekDays] = useState<string[]>(() => {
if (rule?.frequency === "weekly" && rule.byDay?.length) {
const days = rule.byDay.map((d) => d.day).filter((d) => WEEKDAYS.includes(d));
if (days.length) return days;
}
return [startDay];
});
const [monthlyMode, setMonthlyMode] = useState<MonthlyMode>(initialNthDay ? "nth" : "day");
const [monthDay, setMonthDay] = useState<number>(() => {
const md = rule?.byMonthDay?.[0];
return md && md >= 1 && md <= 31 ? md : eventStart.getDate();
});
const [nth, setNth] = useState<number>(() => {
if (initialNthDay && (initialNthDay.nth === -1 || (initialNthDay.nth >= 1 && initialNthDay.nth <= 4))) {
return initialNthDay.nth;
}
return Math.min(4, Math.floor((eventStart.getDate() - 1) / 7) + 1);
});
const [nthDay, setNthDay] = useState<string>(() =>
initialNthDay && WEEKDAYS.includes(initialNthDay.day) ? initialNthDay.day : startDay
);
const [month, setMonth] = useState<number>(() => {
const m = rule?.byMonth?.length ? parseInt(rule.byMonth[0], 10) : NaN;
return m >= 1 && m <= 12 ? m : eventStart.getMonth() + 1;
});
const [endsMode, setEndsMode] = useState<EndsMode>(rule?.count ? "after" : rule?.until ? "on" : "never");
const [untilDate, setUntilDate] = useState<string>(() => {
if (rule?.until) {
const d = new Date(rule.until);
if (!isNaN(d.getTime())) return format(d, "yyyy-MM-dd");
}
return format(addYears(eventStart, 1), "yyyy-MM-dd");
});
const [count, setCount] = useState<number>(rule?.count ?? 12);
const toggleWeekDay = (day: string) => {
setWeekDays((prev) =>
prev.includes(day)
? prev.length > 1 ? prev.filter((d) => d !== day) : prev
: [...prev, day]
);
};
const buildRule = (): CalendarRecurrenceRule => {
const built: CalendarRecurrenceRule = {
"@type": "RecurrenceRule",
frequency,
interval: Math.max(1, interval),
rscale: "gregorian",
skip: "omit",
firstDayOfWeek: "mo",
byDay: null,
byMonthDay: null,
byMonth: null,
byYearDay: null,
byWeekNo: null,
byHour: null,
byMinute: null,
bySecond: null,
bySetPosition: null,
count: endsMode === "after" ? Math.max(1, count) : null,
until: endsMode === "on" && untilDate ? `${untilDate}T23:59:59` : null,
};
if (frequency === "weekly") {
const days = weekDays.length ? weekDays : [startDay];
built.byDay = WEEKDAYS.filter((d) => days.includes(d)).map((day) => ({ day }));
} else if (frequency === "monthly" || frequency === "yearly") {
if (monthlyMode === "nth") {
built.byDay = [{ day: nthDay, nthOfPeriod: nth }];
} else {
built.byMonthDay = [Math.min(31, Math.max(1, monthDay))];
}
if (frequency === "yearly") {
built.byMonth = [String(month)];
}
}
return built;
};
const handleSave = () => onSave(buildRule());
const summary = buildRecurrenceSummary(buildRule(), t, locale);
const selectCls = "rounded-md border border-input bg-background px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-ring";
return (
<div className="mt-2 rounded-md border border-border bg-muted/20 p-3 space-y-3">
<div className="flex items-center gap-2 text-sm">
<span className="shrink-0">{t("recurrence.editor_every")}</span>
<Input
type="number"
min={1}
max={999}
value={interval}
onChange={(e) => {
const n = parseInt(e.target.value, 10);
setIntervalValue(Number.isFinite(n) ? Math.max(1, n) : 1);
}}
className="w-16 shrink-0"
aria-label={t("recurrence.editor_every")}
/>
<select
value={frequency}
onChange={(e) => setFrequency(e.target.value as EditorFrequency)}
className={`${selectCls} flex-1 min-w-0`}
aria-label={t("recurrence.title")}
>
{EDITOR_FREQUENCIES.map((f) => (
<option key={f} value={f}>{t(UNIT_LABEL_KEYS[f])}</option>
))}
</select>
</div>
{frequency === "weekly" && (
<div className="flex gap-1">
{WEEKDAYS.map((day) => (
<button
key={day}
type="button"
onClick={() => toggleWeekDay(day)}
title={weekdayName(day, locale)}
aria-pressed={weekDays.includes(day)}
className={
weekDays.includes(day)
? "flex-1 min-w-0 px-1 py-1.5 text-xs font-medium rounded-md border border-primary text-primary bg-primary/10 transition-colors"
: "flex-1 min-w-0 px-1 py-1.5 text-xs font-medium rounded-md border border-input text-muted-foreground hover:text-foreground hover:bg-muted transition-colors"
}
>
{weekdayName(day, locale, "short")}
</button>
))}
</div>
)}
{frequency === "yearly" && (
<div className="flex items-center gap-2 text-sm">
<span className="shrink-0">{capitalize(t("recurrence.editor_in"))}</span>
<select
value={month}
onChange={(e) => setMonth(parseInt(e.target.value, 10))}
className={`${selectCls} flex-1 min-w-0`}
aria-label={t("recurrence.editor_in")}
>
{Array.from({ length: 12 }, (_, i) => i + 1).map((m) => (
<option key={m} value={m}>{capitalize(monthName(m, locale))}</option>
))}
</select>
</div>
)}
{(frequency === "monthly" || frequency === "yearly") && (
<div className="flex items-center gap-2 text-sm">
<select
value={monthlyMode}
onChange={(e) => setMonthlyMode(e.target.value as MonthlyMode)}
className={`${selectCls} shrink-0`}
aria-label={t("recurrence.editor_repeats_on")}
>
<option value="day">{capitalize(t("recurrence.editor_on_day"))}</option>
<option value="nth">{capitalize(t("recurrence.editor_on_the"))}</option>
</select>
{monthlyMode === "day" ? (
<Input
type="number"
min={1}
max={31}
value={monthDay}
onChange={(e) => {
const n = parseInt(e.target.value, 10);
setMonthDay(Number.isFinite(n) ? Math.min(31, Math.max(1, n)) : 1);
}}
className="w-16 shrink-0"
aria-label={t("recurrence.editor_on_day")}
/>
) : (
<>
<select
value={nth}
onChange={(e) => setNth(parseInt(e.target.value, 10))}
className={`${selectCls} flex-1 min-w-0`}
aria-label={t("recurrence.editor_on_the")}
>
{[1, 2, 3, 4, -1].map((n) => (
<option key={n} value={n}>{capitalize(nthLabel(n, t))}</option>
))}
</select>
<select
value={nthDay}
onChange={(e) => setNthDay(e.target.value)}
className={`${selectCls} flex-1 min-w-0`}
aria-label={t("recurrence.editor_on_the")}
>
{WEEKDAYS.map((d) => (
<option key={d} value={d}>{capitalize(weekdayName(d, locale))}</option>
))}
</select>
</>
)}
</div>
)}
<div className="flex items-center gap-2 text-sm">
<span className="shrink-0">{t("recurrence.editor_ends")}</span>
<select
value={endsMode}
onChange={(e) => setEndsMode(e.target.value as EndsMode)}
className={`${selectCls} ${endsMode === "never" ? "flex-1" : "shrink-0"} min-w-0`}
aria-label={t("recurrence.editor_ends")}
>
<option value="never">{t("recurrence.editor_never")}</option>
<option value="on">{t("recurrence.until")}</option>
<option value="after">{t("recurrence.editor_ends_after")}</option>
</select>
{endsMode === "on" && (
<input
type="date"
value={untilDate}
onChange={(e) => setUntilDate(e.target.value)}
className={`${selectCls} flex-1 min-w-0`}
aria-label={t("recurrence.editor_ends_on")}
/>
)}
{endsMode === "after" && (
<>
<Input
type="number"
min={1}
max={999}
value={count}
onChange={(e) => {
const n = parseInt(e.target.value, 10);
setCount(Number.isFinite(n) ? Math.max(1, n) : 1);
}}
className="w-16 shrink-0"
aria-label={t("recurrence.editor_ends_after")}
/>
<span className="text-muted-foreground truncate">{t("recurrence.editor_occurrences")}</span>
</>
)}
</div>
<div className="flex items-center justify-between gap-3 border-t border-border pt-3">
<p className="text-xs text-muted-foreground truncate min-w-0" title={summary ?? undefined}>
{summary}
</p>
<div className="flex gap-2 shrink-0">
<Button variant="outline" size="sm" onClick={onCancel}>
{t("form.cancel")}
</Button>
<Button size="sm" onClick={handleSave}>
{t("form.save")}
</Button>
</div>
</div>
</div>
);
}
+1 -1
View File
@@ -172,7 +172,7 @@ export function ContactActivity({ contact }: ContactActivityProps) {
const handleOpenEmail = (email: Email) => {
selectEmail(email);
router.push("/mail");
router.push("/");
};
const handleOpenEvent = (event: CalendarEvent) => {
@@ -0,0 +1,106 @@
import { render, screen, fireEvent } from '@testing-library/react';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { ContactSidebarPanel } from '../email-viewer';
import type { ContactCard } from '@/lib/jmap/types';
const contact: ContactCard = {
id: 'c1',
addressBookIds: {},
name: {
components: [
{ kind: 'given', value: 'Alice' },
{ kind: 'surname', value: 'Smith' },
],
isOrdered: true,
},
emails: { e0: { address: 'alice@example.com' } },
};
const unknownEmail = 'unknown@example.com';
describe('ContactSidebarPanel', () => {
beforeEach(() => {
Object.assign(navigator, {
clipboard: { writeText: vi.fn().mockResolvedValue(undefined) },
});
});
it('shows Edit button when contact is known and onEditContact is provided', () => {
render(
<ContactSidebarPanel
email="alice@example.com"
contact={contact}
onClose={vi.fn()}
onEditContact={vi.fn()}
/>,
);
// useTranslations mock returns the key, so we look for the common.edit key
expect(screen.getByTitle('contact_sidebar.action_edit_title')).toBeInTheDocument();
expect(screen.getByText('edit')).toBeInTheDocument();
});
it('calls onEditContact when Edit button is clicked', () => {
const onEditContact = vi.fn();
render(
<ContactSidebarPanel
email="alice@example.com"
contact={contact}
onClose={vi.fn()}
onEditContact={onEditContact}
/>,
);
fireEvent.click(screen.getByTitle('contact_sidebar.action_edit_title'));
expect(onEditContact).toHaveBeenCalledOnce();
});
it('does not show Edit button when contact is null', () => {
render(
<ContactSidebarPanel
email={unknownEmail}
contact={null}
onClose={vi.fn()}
onEditContact={vi.fn()}
/>,
);
expect(screen.queryByTitle('contact_sidebar.action_edit_title')).not.toBeInTheDocument();
});
it('does not show Edit button when onEditContact is not provided', () => {
render(
<ContactSidebarPanel
email="alice@example.com"
contact={contact}
onClose={vi.fn()}
/>,
);
expect(screen.queryByTitle('contact_sidebar.action_edit_title')).not.toBeInTheDocument();
});
it('shows "not in contacts" message and Add button for unknown email', () => {
const onAddToContacts = vi.fn();
render(
<ContactSidebarPanel
email={unknownEmail}
contact={null}
onClose={vi.fn()}
onAddToContacts={onAddToContacts}
/>,
);
expect(screen.getByText('contact_sidebar.not_in_contacts')).toBeInTheDocument();
fireEvent.click(screen.getByText('contact_sidebar.add_to_contacts'));
expect(onAddToContacts).toHaveBeenCalledWith(unknownEmail, undefined);
});
it('calls onClose when the close button is clicked', () => {
const onClose = vi.fn();
render(
<ContactSidebarPanel
email="alice@example.com"
contact={contact}
onClose={onClose}
/>,
);
fireEvent.click(screen.getByLabelText('contact_sidebar.close'));
expect(onClose).toHaveBeenCalledOnce();
});
});
@@ -0,0 +1,363 @@
import { render, screen, act } from '@testing-library/react';
import { fireEvent } from '@testing-library/dom';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import React from 'react';
import { EmailComposer } from '../email-composer';
// ─── Heavy component mocks ────────────────────────────────────────────────────
vi.mock('@/components/email/rich-text-editor', () => ({
RichTextEditor: ({ onChange }: { onChange?: (html: string) => void }) => (
React.createElement('div', { 'data-testid': 'rich-text-editor', onClick: () => onChange?.('') })
),
}));
vi.mock('@/components/plugins/plugin-slot', () => ({ PluginSlot: () => null }));
vi.mock('@/components/identity/sub-address-helper', () => ({ SubAddressHelper: () => null }));
vi.mock('@/components/templates/template-picker', () => ({ TemplatePicker: () => null }));
vi.mock('@/components/templates/template-form', () => ({ TemplateForm: () => null }));
vi.mock('@/components/files/file-preview-modal', () => ({ FilePreviewModal: () => null }));
vi.mock('@/hooks/use-focus-trap', () => ({
useFocusTrap: () => ({ ref: { current: null } }),
}));
vi.mock('@/hooks/use-pro-multi-account-identities', () => ({
useProMultiAccountIdentities: () => ({ enabled: false, groups: [], allIdentities: [] }),
stripCrossAccountIdentityPrefix: (id: string) => ({ localAccountId: null, rawId: id }),
}));
// ─── Store mocks ──────────────────────────────────────────────────────────────
// vi.mock factories are hoisted, so all values must be defined inline.
vi.mock('@/stores/auth-store', () => {
const state = {
client: null,
identities: [],
primaryIdentity: null,
isAuthenticated: false,
isDemoMode: false,
activeAccountId: null,
connectionLost: false,
getClientForAccount: () => undefined,
getAllConnectedClients: () => new Map(),
syncIdentities: () => {},
refreshIdentities: async () => {},
};
const hook = (sel?: (s: typeof state) => unknown) =>
typeof sel === 'function' ? sel(state) : state;
hook.getState = () => state;
hook.setState = (p: Partial<typeof state>) => Object.assign(state, p);
return { useAuthStore: hook };
});
vi.mock('@/stores/identity-store', () => {
const state = { identities: [], defaultIdentityId: null };
const hook = (sel?: (s: typeof state) => unknown) =>
typeof sel === 'function' ? sel(state) : state;
hook.getState = () => state;
hook.setState = (p: Partial<typeof state>) => Object.assign(state, p);
return { useIdentityStore: hook };
});
vi.mock('@/stores/account-store', () => {
const state = { accounts: [], getAccountById: () => undefined };
const hook = (sel?: (s: typeof state) => unknown) =>
typeof sel === 'function' ? sel(state) : state;
hook.getState = () => state;
hook.setState = (p: Partial<typeof state>) => Object.assign(state, p);
return { useAccountStore: hook };
});
vi.mock('@/stores/smime-store', () => {
const state = {
certs: [],
signingEnabled: false,
encryptionEnabled: false,
defaultSigningCertId: null,
defaultEncryptionCertId: null,
};
const hook = (sel?: (s: typeof state) => unknown) =>
typeof sel === 'function' ? sel(state) : state;
hook.getState = () => state;
hook.setState = (p: Partial<typeof state>) => Object.assign(state, p);
return { useSmimeStore: hook };
});
vi.mock('@/stores/email-store', () => {
const state = {
draftSaveEnabled: false,
sendRawEmail: async () => ({ sent: true }),
};
const hook = (sel?: (s: typeof state) => unknown) =>
typeof sel === 'function' ? sel(state) : state;
hook.getState = () => state;
hook.setState = (p: Partial<typeof state>) => Object.assign(state, p);
return { useEmailStore: hook };
});
vi.mock('@/stores/settings-store', () => {
const state = {
timeFormat: '24h',
plainTextMode: false,
subAddressDelimiter: '+',
autoSelectReplyIdentity: true,
attachmentReminderEnabled: false,
attachmentReminderKeywords: [],
sendDelaySeconds: 0,
signaturePosition: 'above_quote',
signatureSeparatorEnabled: false,
requestReadReceiptDefault: false,
addTrustedSender: () => {},
trustedSendersAddressBook: null,
};
const hook = (sel?: (s: typeof state) => unknown) =>
typeof sel === 'function' ? sel(state) : state;
hook.getState = () => state;
hook.setState = (p: Partial<typeof state>) => Object.assign(state, p);
return { useSettingsStore: hook };
});
vi.mock('@/stores/contact-store', () => {
const state = {
contacts: [],
getAutocomplete: async () => [],
addToTrustedSendersBook: async () => {},
};
const hook = (sel?: (s: typeof state) => unknown) =>
typeof sel === 'function' ? sel(state) : state;
hook.getState = () => state;
hook.setState = (p: Partial<typeof state>) => Object.assign(state, p);
return { useContactStore: hook };
});
vi.mock('@/stores/template-store', () => {
const state = { templates: [], addTemplate: async () => {} };
const hook = (sel?: (s: typeof state) => unknown) =>
typeof sel === 'function' ? sel(state) : state;
hook.getState = () => state;
hook.setState = (p: Partial<typeof state>) => Object.assign(state, p);
return { useTemplateStore: hook };
});
// ─── Misc dependency mocks ────────────────────────────────────────────────────
vi.mock('@/stores/toast-store', () => ({
toast: { info: () => {}, error: () => {}, success: () => {} },
}));
vi.mock('@/lib/plugin-hooks', () => ({
emailHooks: {
onComposerOpen: { call: async () => [] },
onRecipientChange: { call: async () => [] },
getRecipientSuggestions: { call: async () => [] },
onSend: { call: async () => [] },
beforeSend: { call: async () => [] },
},
contactHooks: {
search: { call: async () => [] },
},
}));
vi.mock('@/lib/email-sanitization', () => ({
sanitizeSignatureHtml: (v: string) => v,
sanitizeEmailHtml: (v: string) => v,
}));
vi.mock('@/lib/reply-identity', () => ({ resolveReplyFrom: () => null }));
vi.mock('@/lib/email-threading', () => ({
computeReplyThreadingHeaders: () => ({ inReplyTo: [], references: [] }),
}));
vi.mock('@/lib/signature-utils', () => ({
appendPlainTextSignature: (body: string) => body,
getPlainTextSignature: () => '',
}));
vi.mock('@/lib/sub-addressing', () => ({ generateSubAddress: () => '' }));
vi.mock('@/lib/smime/smime-sign', () => ({ smimeSign: async () => null }));
vi.mock('@/lib/smime/smime-encrypt', () => ({ smimeEncrypt: async () => null }));
vi.mock('@/lib/smime/mime-builder', () => ({
buildMimeMessage: () => null,
wrapCmsAsSmimeMessage: () => null,
}));
vi.mock('@/lib/debug', () => ({ debug: () => {} }));
vi.mock('@/components/email/quoted-html', () => ({
buildQuotedHtmlBlock: () => '',
serializeEditorContent: () => '',
}));
vi.mock('@/lib/template-utils', () => ({ substitutePlaceholders: (s: string) => s }));
// ─── DataTransfer polyfill ────────────────────────────────────────────────────
/** jsdom's built-in DataTransfer doesn't fully support setData/getData in synthetic drag events. */
class MockDataTransfer {
private _data: Record<string, string> = {};
types: string[] = [];
effectAllowed = '';
dropEffect = '';
setData(type: string, data: string) {
this._data[type] = data;
if (!this.types.includes(type)) this.types.push(type);
}
getData(type: string): string {
return this._data[type] ?? '';
}
setDragImage(_image: Element, _x: number, _y: number) {
// no-op: jsdom has no rendering, but the chip drag handler calls this.
}
}
// ─── Shared test data ─────────────────────────────────────────────────────────
const BASE_DATA = {
to: 'alice@example.com, ',
cc: '',
bcc: '',
subject: '',
body: '',
showCc: true,
showBcc: true,
selectedIdentityId: null,
subAddressTag: '',
mode: 'compose' as const,
draftId: null,
};
// ─── Tests ────────────────────────────────────────────────────────────────────
describe('RecipientChipInput drag and drop', () => {
beforeEach(() => { vi.clearAllMocks(); });
it('renders recipient chips with draggable="true"', async () => {
render(<EmailComposer initialData={BASE_DATA} />);
const chipText = await screen.findByText('alice@example.com');
const chipSpan = chipText.closest('[draggable]');
expect(chipSpan).not.toBeNull();
expect(chipSpan).toHaveAttribute('draggable', 'true');
});
it('onDragStart encodes the recipient and source field into dataTransfer', async () => {
render(<EmailComposer initialData={BASE_DATA} />);
const chipText = await screen.findByText('alice@example.com');
const chipSpan = chipText.closest('[draggable]') as HTMLElement;
const dt = new MockDataTransfer();
fireEvent.dragStart(chipSpan, { dataTransfer: dt });
const payload = JSON.parse(dt.getData('application/x-recipient-chip'));
expect(payload).toEqual({ recipient: { email: 'alice@example.com' }, fromField: 'to' });
});
it('keeps a display name with a comma in a single chip (array model)', async () => {
render(<EmailComposer initialData={{ ...BASE_DATA, to: '"Doo, John" <john@doo.org>, ' }} />);
// One chip, displayed as "Doo, John (john@doo.org)" — not split on the comma.
const chip = await screen.findByText('Doo, John (john@doo.org)');
const chipSpan = chip.closest('[draggable]') as HTMLElement;
expect(chipSpan).not.toBeNull();
const dt = new MockDataTransfer();
fireEvent.dragStart(chipSpan, { dataTransfer: dt });
const payload = JSON.parse(dt.getData('application/x-recipient-chip'));
expect(payload).toEqual({ recipient: { name: 'Doo, John', email: 'john@doo.org' }, fromField: 'to' });
});
it('onDragEnd clears the opacity class on the chip', async () => {
render(<EmailComposer initialData={BASE_DATA} />);
const chipText = await screen.findByText('alice@example.com');
const chipSpan = chipText.closest('[draggable]') as HTMLElement;
fireEvent.dragStart(chipSpan, { dataTransfer: new MockDataTransfer() });
expect(chipSpan.className).toContain('opacity-50');
fireEvent.dragEnd(chipSpan);
expect(chipSpan.className).not.toContain('opacity-50');
});
it('dragOver on a different field container adds ring indicator; dragLeave removes it', async () => {
render(<EmailComposer initialData={BASE_DATA} />);
await screen.findByText('alice@example.com');
// The flex-wrap containers are the actual drop zones
const allContainers = Array.from(document.querySelectorAll('[class*="flex-wrap"]'));
// To-container has the draggable chip; cc-container doesn't
const toContainer = allContainers.find(el => el.querySelector('[draggable]')) as HTMLElement;
const ccContainer = allContainers.find(el => el !== toContainer) as HTMLElement;
if (!ccContainer) return;
const dt = new MockDataTransfer();
dt.setData('application/x-recipient-chip', JSON.stringify({ recipient: { email: 'alice@example.com' }, fromField: 'to' }));
fireEvent.dragOver(ccContainer, { dataTransfer: dt });
expect(ccContainer.className).toContain('ring-primary');
fireEvent.dragLeave(ccContainer, { relatedTarget: null });
expect(ccContainer.className).not.toContain('ring-primary');
});
it('drop on a different field container moves the chip', async () => {
render(<EmailComposer initialData={BASE_DATA} />);
await screen.findByText('alice@example.com');
const allContainers = Array.from(document.querySelectorAll('[class*="flex-wrap"]'));
const toContainer = allContainers.find(el => el.querySelector('[draggable]')) as HTMLElement;
const ccContainer = allContainers.find(el => el !== toContainer) as HTMLElement;
if (!toContainer || !ccContainer) return;
const dt = new MockDataTransfer();
dt.setData('application/x-recipient-chip', JSON.stringify({ recipient: { email: 'alice@example.com' }, fromField: 'to' }));
fireEvent.dragOver(ccContainer, { dataTransfer: dt });
act(() => {
fireEvent.drop(ccContainer, { dataTransfer: dt });
});
// Chip should still appear exactly once (moved, not duplicated or lost)
await screen.findByText('alice@example.com');
expect(screen.getAllByText('alice@example.com')).toHaveLength(1);
// The To container must now be empty
expect(toContainer.querySelectorAll('[draggable]')).toHaveLength(0);
});
it('drop on the same field container is a no-op', async () => {
render(<EmailComposer initialData={BASE_DATA} />);
await screen.findByText('alice@example.com');
const allContainers = Array.from(document.querySelectorAll('[class*="flex-wrap"]'));
const toContainer = allContainers.find(el => el.querySelector('[draggable]')) as HTMLElement;
const dt = new MockDataTransfer();
dt.setData('application/x-recipient-chip', JSON.stringify({ recipient: { email: 'alice@example.com' }, fromField: 'to' }));
fireEvent.dragOver(toContainer, { dataTransfer: dt });
act(() => {
fireEvent.drop(toContainer, { dataTransfer: dt });
});
// Chip stays present exactly once
expect(screen.getAllByText('alice@example.com')).toHaveLength(1);
});
it('dropping a chip onto the hidden Cc button shows the CC field', async () => {
render(<EmailComposer initialData={{ ...BASE_DATA, showCc: false, showBcc: false }} />);
await screen.findByText('alice@example.com');
const ccButton = screen.getByRole('button', { name: 'Cc' });
const dt = new MockDataTransfer();
dt.setData('application/x-recipient-chip', JSON.stringify({ recipient: { email: 'alice@example.com' }, fromField: 'to' }));
fireEvent.dragOver(ccButton, { dataTransfer: dt });
act(() => {
fireEvent.drop(ccButton, { dataTransfer: dt });
});
// cc_label is rendered by the mock translation as its key string
const ccLabel = await screen.findByText('cc_label');
expect(ccLabel).toBeInTheDocument();
});
});
@@ -553,40 +553,10 @@ export function CalendarInvitationBanner({ email }: CalendarInvitationBannerProp
const replyToForRsvp = parsedEvent.replyTo
|| (parsedEvent.organizerCalendarAddress ? { imip: parsedEvent.organizerCalendarAddress } : null);
const imipStatus = status.toUpperCase() as 'ACCEPTED' | 'TENTATIVE' | 'DECLINED';
const organizerEmail = parsedEvent?.replyTo?.imip?.replace('mailto:', '')
|| parsedEvent?.organizerCalendarAddress?.replace('mailto:', '')
|| summary?.organizerEmail
|| null;
// Send the iMIP REPLY email to the organizer (client-side scheduling).
// Called after updating the local calendar event. Best-effort - if it
// fails we still report the RSVP as sent since the calendar was updated.
const sendImipReply = async () => {
if (!organizerEmail || !parsedEvent?.uid || !currentUserEmail) {
return;
}
try {
await client.sendImipReply({
organizerEmail,
organizerName: summary?.organizer || undefined,
attendeeEmail: currentUserEmail,
attendeeName: myParticipant?.participant.name || undefined,
uid: parsedEvent.uid,
summary: parsedEvent.title,
dtStart: parsedEvent.start || undefined,
dtEnd: summary?.end || undefined,
timeZone: parsedEvent.timeZone || undefined,
isAllDay: parsedEvent.showWithoutTime || false,
sequence: parsedEvent.sequence,
status: imipStatus,
});
} catch {
// Best-effort: don't block the RSVP success notification
}
};
// The iMIP REPLY to the organizer is sent by the server: rsvpEvent /
// updateEvent below pass sendSchedulingMessages=true to CalendarEvent/set
// and Stalwart queues the iTIP REPLY itself. A manual client-side reply
// here produced duplicate emails.
try {
const eventForRsvp = existingEvent
? await resolveExistingEventForRsvp()
@@ -601,7 +571,6 @@ export function CalendarInvitationBanner({ email }: CalendarInvitationBannerProp
);
if (eventForRsvp && existingEventParticipant) {
await rsvpEvent(client, eventForRsvp.id, existingEventParticipant.id, status, replyToForRsvp);
await sendImipReply();
setRsvpStatus(status);
setActionNotice(t('rsvp_sent'));
setState('parsed');
@@ -615,7 +584,6 @@ export function CalendarInvitationBanner({ email }: CalendarInvitationBannerProp
participants: repairedParticipants,
replyTo: replyToForRsvp ?? undefined,
}, true);
await sendImipReply();
setRsvpStatus(status);
setActionNotice(t('rsvp_sent'));
setState('parsed');
@@ -632,7 +600,6 @@ export function CalendarInvitationBanner({ email }: CalendarInvitationBannerProp
|| (newEvent && currentUserEmail ? findParticipantByEmail(newEvent, currentUserEmail) : null);
if (newEvent && participant) {
await rsvpEvent(client, newEvent.id, participant.id, status, replyToForRsvp);
await sendImipReply();
setRsvpStatus(status);
setActionNotice(t('rsvp_sent'));
} else {
+460 -166
View File
@@ -9,6 +9,8 @@ import { X, Paperclip, Send, Save, Check, Loader2, AlertCircle, FileText, Bookma
import { cn, formatFileSize, formatDateTime, generateUUID } from "@/lib/utils";
import { debug } from "@/lib/debug";
import { toast } from "@/stores/toast-store";
import { useContextMenu } from "@/hooks/use-context-menu";
import { ContextMenu, ContextMenuItem, ContextMenuSeparator } from "@/components/ui/context-menu";
import { sanitizeSignatureHtml, sanitizeEmailHtml } from "@/lib/email-sanitization";
import { buildReplySubject, buildForwardSubject } from "@/lib/subject-prefix";
import { isFilePreviewable } from "@/lib/file-preview";
@@ -26,6 +28,7 @@ import { buildMimeMessage, wrapCmsAsSmimeMessage } from "@/lib/smime/mime-builde
import type { MimeAttachment } from "@/lib/smime/mime-builder";
import { smimeSign } from "@/lib/smime/smime-sign";
import { PluginSlot } from "@/components/plugins/plugin-slot";
import { Avatar } from "@/components/ui/avatar";
import { FilePreviewModal } from "@/components/files/file-preview-modal";
import { smimeEncrypt } from "@/lib/smime/smime-encrypt";
import { useContactStore } from "@/stores/contact-store";
@@ -42,14 +45,51 @@ import { computeReplyThreadingHeaders } from "@/lib/email-threading";
import {
rewriteCidImagesForEditor,
replaceInlineImagePlaceholders,
formatRecipient,
parseRecipient,
parseRecipientList,
formatRecipientList,
type Recipient,
} from "@/lib/email-composer-utils";
import { RichTextEditor } from "@/components/email/rich-text-editor";
import type { Editor } from "@tiptap/react";
import { htmlToPlainText as htmlToPlainTextShared } from "@/lib/html-to-text";
/** Strip HTML tags and decode entities to get a plain-text version */
/**
* Derives the text/plain alternative from the composer's HTML body, preserving
* line structure from block elements and <br> tags. Paragraph spacing is on so
* <p> blocks are separated by a blank line, matching their visual rendering (#421).
*/
function htmlToPlainText(html: string): string {
const doc = new DOMParser().parseFromString(html, 'text/html');
return doc.body.textContent || '';
return htmlToPlainTextShared(html, { paragraphSpacing: true });
}
/**
* Build a floating drag image showing the recipient's address, mirroring the
* email-list drag preview so dragging a chip feels consistent. The element is
* positioned off-screen; the browser snapshots it for the drag cursor.
*/
function createChipDragPreview(label: string): HTMLElement {
const preview = document.createElement("div");
preview.className = "drag-preview";
preview.style.cssText = `
position: fixed;
top: -9999px;
left: 0;
padding: 4px 12px;
background-color: var(--color-primary, #3b82f6);
color: var(--color-primary-foreground, #ffffff);
border-radius: 8px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
font-size: 13px;
font-weight: 500;
z-index: 9999;
white-space: nowrap;
pointer-events: none;
`;
preview.textContent = label;
document.body.appendChild(preview);
return preview;
}
export interface ComposerDraftData {
@@ -246,27 +286,35 @@ export function EmailComposer({
// `replyTo` from a still-selected email; getInitialBody short-circuits below.
const shouldEmbedSignatureInNewMail = mode === 'compose' && hasInitialSignature;
// Format a single EmailAddress for display in the composer input
const toRecipient = (r: { name?: string; email?: string }): Recipient =>
({ name: r.name && r.name !== r.email ? r.name : undefined, email: r.email ?? "" });
// Initialize with reply/forward data if provided
const getInitialTo = () => {
if (!replyTo) return "";
const getInitialTo = (): Recipient[] => {
if (!replyTo) return [];
// RFC 5322: use Reply-To header if present, otherwise fall back to From
const replyTarget = replyTo.replyToAddresses?.length
? replyTo.replyToAddresses.filter(r => r.email).map(r => r.email).join(", ")
: replyTo.from?.[0]?.email || "";
? replyTo.replyToAddresses.filter(r => r.email).map(toRecipient)
: (replyTo.from?.[0]?.email ? [toRecipient(replyTo.from[0])] : []);
if (mode === 'reply') {
return replyTarget ? replyTarget + ', ' : "";
return replyTarget;
} else if (mode === 'replyAll') {
const originalTo = replyTo.to?.filter(r => r.email).map(r => r.email).join(", ") || "";
const combined = [replyTarget, originalTo].filter(Boolean).join(", ");
return combined ? combined + ', ' : "";
const ownEmails = new Set(identities.map(i => i.email?.trim().toLowerCase()).filter(Boolean));
const originalTo = (replyTo.to ?? [])
.filter(r => r.email && !ownEmails.has(r.email.trim().toLowerCase()))
.map(toRecipient);
return [...replyTarget, ...originalTo];
}
return "";
return [];
};
const getInitialCc = () => {
if (!replyTo || mode !== 'replyAll') return "";
const cc = replyTo.cc?.map(r => r.email).join(", ") || "";
return cc ? cc + ', ' : "";
const getInitialCc = (): Recipient[] => {
if (!replyTo || mode !== 'replyAll') return [];
const ownEmails = new Set(identities.map(i => i.email?.trim().toLowerCase()).filter(Boolean));
return (replyTo.cc ?? [])
.filter(r => r.email && !ownEmails.has(r.email.trim().toLowerCase()))
.map(toRecipient);
};
const getInitialSubject = () => {
@@ -406,13 +454,27 @@ export function EmailComposer({
return prefix;
};
const [to, setTo] = useState(initialData?.to ?? getInitialTo());
const [cc, setCc] = useState(initialData?.cc ?? getInitialCc());
const [bcc, setBcc] = useState(initialData?.bcc ?? "");
// Committed recipients are structured arrays; the in-progress text the user
// is typing lives in a separate `*Input` string per field. This keeps a
// display name containing a comma (e.g. "Doo, John") intact instead of
// tearing it apart on a delimiter.
const [to, setTo] = useState<Recipient[]>(initialData ? parseRecipientList(initialData.to) : getInitialTo());
const [cc, setCc] = useState<Recipient[]>(initialData ? parseRecipientList(initialData.cc) : getInitialCc());
const [bcc, setBcc] = useState<Recipient[]>(initialData ? parseRecipientList(initialData.bcc) : []);
const [toInput, setToInput] = useState('');
const [ccInput, setCcInput] = useState('');
const [bccInput, setBccInput] = useState('');
const [subject, setSubject] = useState(initialData?.subject ?? getInitialSubject());
const [body, setBody] = useState(initialData?.body ?? getInitialBody());
const [showCc, setShowCc] = useState(initialData?.showCc ?? !!getInitialCc());
const [showCc, setShowCc] = useState(initialData?.showCc ?? getInitialCc().length > 0);
const [showBcc, setShowBcc] = useState(initialData?.showBcc ?? false);
// Committed recipients plus any not-yet-committed text the user has typed.
// Send/validation/draft paths treat a typed-but-uncommitted address as a
// real recipient, matching the previous string-based behavior.
const withInput = (chips: Recipient[], input: string): Recipient[] =>
input.trim() ? [...chips, parseRecipient(input)] : chips;
const [isDraggingChipOverCc, setIsDraggingChipOverCc] = useState(false);
const [isDraggingChipOverBcc, setIsDraggingChipOverBcc] = useState(false);
const [requestReadReceipt, setRequestReadReceipt] = useState(requestReadReceiptDefault);
const [draftId, setDraftId] = useState<string | null>(initialData?.draftId ?? null);
// Mirror of draftId for synchronous reads inside chained saves; React's
@@ -739,10 +801,11 @@ export function EmailComposer({
const canSmimeSign = !!smimeKeyRecord;
const canSmimeEncrypt = (() => {
if (!smimeKeyRecord) return false;
const toAddrs = to.split(',').map(e => e.trim()).filter(Boolean);
const ccAddrs = cc.split(',').map(e => e.trim()).filter(Boolean);
const bccAddrs = bcc.split(',').map(e => e.trim()).filter(Boolean);
const allRecipients = [...toAddrs, ...ccAddrs, ...bccAddrs];
const allRecipients = [
...withInput(to, toInput),
...withInput(cc, ccInput),
...withInput(bcc, bccInput),
].map(r => r.email);
if (allRecipients.length === 0) return false;
const { missing } = smimeStore.getRecipientCerts(allRecipients);
return missing.length === 0;
@@ -758,15 +821,21 @@ export function EmailComposer({
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [currentSmimeIdentityId]);
// Serialized recipient strings for ComposerDraftData (string-shaped) and for
// by-value dirty comparison. Folds in any uncommitted typed text.
const toStr = formatRecipientList(withInput(to, toInput));
const ccStr = formatRecipientList(withInput(cc, ccInput));
const bccStr = formatRecipientList(withInput(bcc, bccInput));
// Keep a ref to current state for the unmount save
const stateRef = useRef({ to, cc, bcc, subject, body, showCc, showBcc, selectedIdentityId, subAddressTag, draftId, fromOverrideEnabled, fromOverrideEmail, fromOverrideName });
stateRef.current = { to, cc, bcc, subject, body, showCc, showBcc, selectedIdentityId, subAddressTag, draftId, fromOverrideEnabled, fromOverrideEmail, fromOverrideName };
const stateRef = useRef({ to: toStr, cc: ccStr, bcc: bccStr, subject, body, showCc, showBcc, selectedIdentityId, subAddressTag, draftId, fromOverrideEnabled, fromOverrideEmail, fromOverrideName });
stateRef.current = { to: toStr, cc: ccStr, bcc: bccStr, subject, body, showCc, showBcc, selectedIdentityId, subAddressTag, draftId, fromOverrideEnabled, fromOverrideEmail, fromOverrideName };
// Track initial values for dirty detection (captured once on first render)
const initialValuesRef = useRef({ to, cc, bcc, subject, body, attachmentCount: attachments.length });
const initialValuesRef = useRef({ to: toStr, cc: ccStr, bcc: bccStr, subject, body, attachmentCount: attachments.length });
const isDirtyRef = useRef(false);
isDirtyRef.current = to !== initialValuesRef.current.to || cc !== initialValuesRef.current.cc ||
bcc !== initialValuesRef.current.bcc || subject !== initialValuesRef.current.subject ||
isDirtyRef.current = toStr !== initialValuesRef.current.to || ccStr !== initialValuesRef.current.cc ||
bccStr !== initialValuesRef.current.bcc || subject !== initialValuesRef.current.subject ||
body !== initialValuesRef.current.body || attachments.length > initialValuesRef.current.attachmentCount;
// Ref to latest saveDraft for use in event handlers with stale closures
@@ -843,13 +912,26 @@ export function EmailComposer({
}
}, [plainTextMode]);
const handleAutocomplete = useCallback((value: string, field: 'to' | 'cc' | 'bcc') => {
const handleMoveChip = useCallback((recipient: Recipient, fromField: 'to' | 'cc' | 'bcc', toField: 'to' | 'cc' | 'bcc') => {
if (fromField === toField) return;
const setters = { to: setTo, cc: setCc, bcc: setBcc };
const sameRecipient = (a: Recipient, b: Recipient) => a.email === b.email && (a.name ?? '') === (b.name ?? '');
setters[fromField](prev => {
const idx = prev.findIndex(r => sameRecipient(r, recipient));
return idx === -1 ? prev : prev.filter((_, i) => i !== idx);
});
setters[toField](prev => prev.some(r => sameRecipient(r, recipient)) ? prev : [...prev, recipient]);
if (toField === 'cc') setShowCc(true);
if (toField === 'bcc') setShowBcc(true);
}, [setTo, setCc, setBcc, setShowCc, setShowBcc]);
const handleAutocomplete = useCallback((inputText: string, field: 'to' | 'cc' | 'bcc') => {
if (autocompleteTimeoutRef.current) {
clearTimeout(autocompleteTimeoutRef.current);
}
const lastPart = value.split(',').pop()?.trim() || '';
if (lastPart.length < 1) {
const query = inputText.trim();
if (query.length < 1) {
setAutocompleteResults([]);
setActiveAutoField(null);
setAutoSelectedIndex(-1);
@@ -857,26 +939,22 @@ export function EmailComposer({
}
autocompleteTimeoutRef.current = setTimeout(async () => {
const localResults = getAutocomplete(lastPart);
const localResults = getAutocomplete(query);
// Let plugins contribute extra suggestions (Slack handles, GitHub, CRM, …).
const initial: RecipientSuggestion[] = localResults.map(r => ({ name: r.name, email: r.email }));
const merged = await contactHooks.onProvideRecipientSuggestions.transform(initial, { query: lastPart });
const merged = await contactHooks.onProvideRecipientSuggestions.transform(initial, { query });
setAutocompleteResults(merged.map(s => ({ name: s.name, email: s.email })));
setActiveAutoField(merged.length > 0 ? field : null);
setAutoSelectedIndex(-1);
}, 200);
}, [getAutocomplete]);
const insertAutocomplete = (email: string, field: 'to' | 'cc' | 'bcc') => {
const insertAutocomplete = (suggestion: { name: string; email: string }, field: 'to' | 'cc' | 'bcc') => {
const setter = field === 'to' ? setTo : field === 'cc' ? setCc : setBcc;
const getter = field === 'to' ? to : field === 'cc' ? cc : bcc;
const inputSetter = field === 'to' ? setToInput : field === 'cc' ? setCcInput : setBccInput;
const parts = getter.split(',').map(s => s.trim()).filter(Boolean);
if (!getter.trimEnd().endsWith(',') && parts.length > 0) {
parts.pop();
}
parts.push(email);
setter(parts.join(', ') + ', ');
setter(prev => [...prev, toRecipient(suggestion)]);
inputSetter('');
setAutocompleteResults([]);
setActiveAutoField(null);
setAutoSelectedIndex(-1);
@@ -908,7 +986,7 @@ export function EmailComposer({
setAutoSelectedIndex((prev) => Math.max(prev - 1, -1));
} else if (e.key === 'Enter' && autoSelectedIndex >= 0) {
e.preventDefault();
insertAutocomplete(autocompleteResults[autoSelectedIndex].email, field);
insertAutocomplete(autocompleteResults[autoSelectedIndex], field);
} else if (e.key === 'Escape') {
setAutocompleteResults([]);
setActiveAutoField(null);
@@ -933,14 +1011,14 @@ export function EmailComposer({
setSubject(filledSubject);
setBody(bodyContent);
if (template.defaultRecipients?.to?.length) {
setTo(template.defaultRecipients.to.join(', ') + ', ');
setTo(template.defaultRecipients.to.map(parseRecipient));
}
if (template.defaultRecipients?.cc?.length) {
setCc(template.defaultRecipients.cc.join(', ') + ', ');
setCc(template.defaultRecipients.cc.map(parseRecipient));
setShowCc(true);
}
if (template.defaultRecipients?.bcc?.length) {
setBcc(template.defaultRecipients.bcc.join(', ') + ', ');
setBcc(template.defaultRecipients.bcc.map(parseRecipient));
setShowBcc(true);
}
} else {
@@ -1167,9 +1245,9 @@ export function EmailComposer({
const saveDraftOnce = async (): Promise<string | null> => {
if (!client || !composerClient) return null;
const toAddresses = to.split(",").map(e => e.trim()).filter(Boolean);
const ccAddresses = cc.split(",").map(e => e.trim()).filter(Boolean);
const bccAddresses = bcc.split(",").map(e => e.trim()).filter(Boolean);
const toAddresses = withInput(to, toInput).map(r => formatRecipient(r.name, r.email));
const ccAddresses = withInput(cc, ccInput).map(r => formatRecipient(r.name, r.email));
const bccAddresses = withInput(bcc, bccInput).map(r => formatRecipient(r.name, r.email));
if (!toAddresses.length && !subject && !(plainTextMode ? body.trim() : htmlToPlainText(body).trim())) {
return null;
@@ -1292,9 +1370,9 @@ export function EmailComposer({
saveTimeoutRef.current = null;
// Plugin observers (AI assist, grammar, …) get a debounced snapshot here.
emailHooks.onDraftChange.emit({
to: to.split(',').map(s => s.trim()).filter(Boolean),
cc: cc.split(',').map(s => s.trim()).filter(Boolean),
bcc: bcc.split(',').map(s => s.trim()).filter(Boolean),
to: withInput(to, toInput).map(r => formatRecipient(r.name, r.email)),
cc: withInput(cc, ccInput).map(r => formatRecipient(r.name, r.email)),
bcc: withInput(bcc, bccInput).map(r => formatRecipient(r.name, r.email)),
subject,
htmlBody: plainTextMode ? '' : body,
textBody: plainTextMode ? body : htmlToPlainText(body),
@@ -1313,7 +1391,7 @@ export function EmailComposer({
}
};
// eslint-disable-next-line react-hooks/exhaustive-deps -- saveDraft reads current state when called, not when effect is set up
}, [to, cc, bcc, subject, body, attachments]);
}, [toStr, ccStr, bccStr, subject, body, attachments]);
useEffect(() => {
return () => {
@@ -1323,7 +1401,7 @@ export function EmailComposer({
};
}, []);
const toAddresses = to.split(",").map(e => e.trim()).filter(Boolean);
const toAddresses = withInput(to, toInput);
const bodyPlainText = plainTextMode ? body.trim() : htmlToPlainText(body).trim();
const hasContent = bodyPlainText || attachments.some(att => att.blobId && !att.uploading);
const canSend = toAddresses.length > 0 && !!subject && hasContent;
@@ -1407,8 +1485,8 @@ export function EmailComposer({
};
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);
const ccAddresses = withInput(cc, ccInput);
const bccAddresses = withInput(bcc, bccInput);
if (!canSend) {
const errors: { to?: boolean; subject?: boolean; body?: boolean } = {};
@@ -1530,9 +1608,9 @@ export function EmailComposer({
// guards, etc.). Returning false from any handler aborts before either
// the S/MIME or standard JMAP path runs.
const sendablePreview: OutgoingEmail = {
to: toAddresses,
cc: ccAddresses,
bcc: bccAddresses,
to: toAddresses.map(r => formatRecipient(r.name, r.email)),
cc: ccAddresses.map(r => formatRecipient(r.name, r.email)),
bcc: bccAddresses.map(r => formatRecipient(r.name, r.email)),
subject,
htmlBody: finalHtmlBody || '',
textBody: finalBody,
@@ -1620,9 +1698,9 @@ export function EmailComposer({
: undefined;
const mimeBytes = buildMimeMessage({
from: { name: currentIdentity.name || undefined, email: fromEmail || currentIdentity.email },
to: toAddresses.map(e => ({ email: e })),
cc: ccAddresses.length > 0 ? ccAddresses.map(e => ({ email: e })) : undefined,
bcc: bccAddresses.length > 0 ? bccAddresses.map(e => ({ email: e })) : undefined,
to: toAddresses,
cc: ccAddresses.length > 0 ? ccAddresses : undefined,
bcc: bccAddresses.length > 0 ? bccAddresses : undefined,
subject,
inReplyTo: mimeInReplyTo,
references: mimeReferences,
@@ -1635,8 +1713,8 @@ export function EmailComposer({
const smimeHeaders = {
from: { name: currentIdentity.name || undefined, email: fromEmail || currentIdentity.email },
to: toAddresses.map(e => ({ email: e })),
cc: ccAddresses.length > 0 ? ccAddresses.map(e => ({ email: e })) : undefined,
to: toAddresses,
cc: ccAddresses.length > 0 ? ccAddresses : undefined,
subject,
inReplyTo: mimeInReplyTo,
references: mimeReferences,
@@ -1658,7 +1736,7 @@ export function EmailComposer({
// 6. Encrypt if enabled
if (smimeEncrypt_ && smimeKeyRecord) {
const allRecipients = [...toAddresses, ...ccAddresses, ...bccAddresses];
const allRecipients = [...toAddresses, ...ccAddresses, ...bccAddresses].map(r => r.email);
const { found, missing } = smimeStore.getRecipientCerts(allRecipients);
if (missing.length > 0) {
throw new Error(`Missing certificates for: ${missing.join(', ')}`);
@@ -1675,7 +1753,7 @@ export function EmailComposer({
}
// 7. Send via raw email path
const result = await sendRawEmail(client, payload, currentIdentity.id, effectiveDelayedUntil, [...toAddresses, ...ccAddresses, ...bccAddresses]);
const result = await sendRawEmail(client, payload, currentIdentity.id, effectiveDelayedUntil, [...toAddresses, ...ccAddresses, ...bccAddresses].map(r => r.email));
if (effectiveDelayedUntil && finalDraftId) {
client.deleteEmail(finalDraftId).catch(err => {
debug.warn('email', 'Scheduled S/MIME send created, but plaintext draft cleanup failed:', err);
@@ -1696,9 +1774,9 @@ export function EmailComposer({
// Let plugins (signatures, link-rewriting, encryption, AI rewrite, …)
// transform the outgoing message immediately before submission.
const transformInput: OutgoingEmail = {
to: toAddresses,
cc: ccAddresses,
bcc: bccAddresses,
to: toAddresses.map(r => formatRecipient(r.name, r.email)),
cc: ccAddresses.map(r => formatRecipient(r.name, r.email)),
bcc: bccAddresses.map(r => formatRecipient(r.name, r.email)),
subject,
htmlBody: finalHtmlBody || '',
textBody: finalBody,
@@ -1751,9 +1829,12 @@ export function EmailComposer({
}
}
setTo("");
setCc("");
setBcc("");
setTo([]);
setCc([]);
setBcc([]);
setToInput("");
setCcInput("");
setBccInput("");
setSubject("");
setBody("");
draftIdRef.current = null;
@@ -2036,7 +2117,7 @@ export function EmailComposer({
? identities.find(id => id.id === selectedIdentityId)?.email
: primaryIdentity?.email) || ''
}
recipientEmails={to.split(',').map(e => e.trim()).filter(Boolean)}
recipientEmails={withInput(to, toInput).map(r => r.email)}
onSelectTag={setSubAddressTag}
/>
)}
@@ -2081,11 +2162,13 @@ export function EmailComposer({
<div className={cn("flex items-center gap-2 px-4 py-2.5 border-b border-border/50 relative", shakeField === 'to' && "animate-shake")}>
<span className="text-sm text-muted-foreground w-12 md:w-16 shrink-0">{t('to')}:</span>
<RecipientChipInput
value={to}
onChange={(v) => {
setTo(v);
chips={to}
onChipsChange={(next) => {
setTo(next);
if (validationErrors.to) setValidationErrors(prev => ({ ...prev, to: false }));
}}
inputText={toInput}
onInputChange={setToInput}
inputRef={toInputRef}
placeholder={t('to_placeholder')}
field="to"
@@ -2100,13 +2183,30 @@ export function EmailComposer({
validationError={validationErrors.to}
validationMessage={t('validation.recipient_required')}
onTab={focusSubject}
onMoveChip={handleMoveChip}
/>
<div className="flex gap-0.5 shrink-0">
<Button
variant="ghost"
size="sm"
onClick={() => setShowCc(!showCc)}
className="text-xs h-7 px-2"
className={cn("text-xs h-7 px-2", isDraggingChipOverCc && "ring-2 ring-primary/50")}
onDragOver={(e) => {
if (!e.dataTransfer.types.includes('application/x-recipient-chip')) return;
e.preventDefault();
e.dataTransfer.dropEffect = 'move';
setIsDraggingChipOverCc(true);
}}
onDragLeave={() => setIsDraggingChipOverCc(false)}
onDrop={(e) => {
e.preventDefault();
setIsDraggingChipOverCc(false);
const raw = e.dataTransfer.getData('application/x-recipient-chip');
if (!raw) return;
const { recipient, fromField } = JSON.parse(raw) as { recipient: Recipient; fromField: 'to' | 'cc' | 'bcc' };
if (fromField !== 'cc') handleMoveChip(recipient, fromField, 'cc');
setShowCc(true);
}}
>
Cc
</Button>
@@ -2114,7 +2214,23 @@ export function EmailComposer({
variant="ghost"
size="sm"
onClick={() => setShowBcc(!showBcc)}
className="text-xs h-7 px-2"
className={cn("text-xs h-7 px-2", isDraggingChipOverBcc && "ring-2 ring-primary/50")}
onDragOver={(e) => {
if (!e.dataTransfer.types.includes('application/x-recipient-chip')) return;
e.preventDefault();
e.dataTransfer.dropEffect = 'move';
setIsDraggingChipOverBcc(true);
}}
onDragLeave={() => setIsDraggingChipOverBcc(false)}
onDrop={(e) => {
e.preventDefault();
setIsDraggingChipOverBcc(false);
const raw = e.dataTransfer.getData('application/x-recipient-chip');
if (!raw) return;
const { recipient, fromField } = JSON.parse(raw) as { recipient: Recipient; fromField: 'to' | 'cc' | 'bcc' };
if (fromField !== 'bcc') handleMoveChip(recipient, fromField, 'bcc');
setShowBcc(true);
}}
>
Bcc
</Button>
@@ -2126,8 +2242,10 @@ export function EmailComposer({
<div className="flex items-center gap-2 px-4 py-2.5 border-b border-border/50 relative">
<span className="text-sm text-muted-foreground w-12 md:w-16 shrink-0">{t('cc_label')}</span>
<RecipientChipInput
value={cc}
onChange={setCc}
chips={cc}
onChipsChange={setCc}
inputText={ccInput}
onInputChange={setCcInput}
inputRef={ccInputRef}
placeholder={t('cc_placeholder')}
field="cc"
@@ -2139,6 +2257,7 @@ export function EmailComposer({
autoSelectedIndex={autoSelectedIndex}
dropdownRef={ccDropdownRef}
onInsertAutocomplete={insertAutocomplete}
onMoveChip={handleMoveChip}
/>
</div>
)}
@@ -2148,8 +2267,10 @@ export function EmailComposer({
<div className="flex items-center gap-2 px-4 py-2.5 border-b border-border/50 relative">
<span className="text-sm text-muted-foreground w-12 md:w-16 shrink-0">{t('bcc_label')}</span>
<RecipientChipInput
value={bcc}
onChange={setBcc}
chips={bcc}
onChipsChange={setBcc}
inputText={bccInput}
onInputChange={setBccInput}
inputRef={bccInputRef}
placeholder={t('bcc_placeholder')}
field="bcc"
@@ -2161,6 +2282,7 @@ export function EmailComposer({
autoSelectedIndex={autoSelectedIndex}
dropdownRef={bccDropdownRef}
onInsertAutocomplete={insertAutocomplete}
onMoveChip={handleMoveChip}
/>
</div>
)}
@@ -2483,9 +2605,9 @@ export function EmailComposer({
initialData={{
subject,
body,
to: to.split(',').map(s => s.trim()).filter(Boolean),
cc: cc.split(',').map(s => s.trim()).filter(Boolean),
bcc: bcc.split(',').map(s => s.trim()).filter(Boolean),
to: withInput(to, toInput).map(r => formatRecipient(r.name, r.email)),
cc: withInput(cc, ccInput).map(r => formatRecipient(r.name, r.email)),
bcc: withInput(bcc, bccInput).map(r => formatRecipient(r.name, r.email)),
}}
onSave={(data) => {
addTemplate(data);
@@ -2657,7 +2779,7 @@ const AutocompleteDropdown = React.forwardRef<HTMLDivElement, {
id: string;
results: Array<{ name: string; email: string }>;
selectedIndex: number;
onSelect: (email: string) => void;
onSelect: (suggestion: { name: string; email: string }) => void;
}>(function AutocompleteDropdown({ id, results, selectedIndex, onSelect }, ref) {
return (
<div ref={ref} id={id} role="listbox" className="absolute top-full left-0 right-0 z-50 mt-1 bg-background border border-border rounded-md shadow-lg max-h-48 overflow-y-auto">
@@ -2674,9 +2796,10 @@ const AutocompleteDropdown = React.forwardRef<HTMLDivElement, {
)}
onMouseDown={(e) => {
e.preventDefault();
onSelect(r.email);
onSelect(r);
}}
>
<Avatar name={r.name} email={r.email} size="sm" className="shrink-0 w-6 h-6 text-[10px]" />
<span className="font-medium truncate">{r.name || r.email}</span>
{r.name && (
<span className="text-muted-foreground truncate">&lt;{r.email}&gt;</span>
@@ -2688,8 +2811,10 @@ const AutocompleteDropdown = React.forwardRef<HTMLDivElement, {
});
function RecipientChipInput({
value,
onChange,
chips,
onChipsChange,
inputText,
onInputChange,
inputRef,
placeholder,
field,
@@ -2704,41 +2829,93 @@ function RecipientChipInput({
validationError,
validationMessage,
onTab,
onMoveChip,
}: {
value: string;
onChange: (value: string) => void;
chips: Recipient[];
onChipsChange: (chips: Recipient[]) => void;
inputText: string;
onInputChange: (text: string) => void;
inputRef: React.RefObject<HTMLInputElement | null>;
placeholder: string;
field: 'to' | 'cc' | 'bcc';
onAutocomplete: (value: string, field: 'to' | 'cc' | 'bcc') => void;
onAutocomplete: (inputText: string, field: 'to' | 'cc' | 'bcc') => void;
onAutoKeyDown: (e: React.KeyboardEvent, field: 'to' | 'cc' | 'bcc') => void;
onAutoBlur: (e: React.FocusEvent, field: 'to' | 'cc' | 'bcc') => void;
activeAutoField: 'to' | 'cc' | 'bcc' | null;
autocompleteResults: Array<{ name: string; email: string }>;
autoSelectedIndex: number;
dropdownRef: React.RefObject<HTMLDivElement | null>;
onInsertAutocomplete: (email: string, field: 'to' | 'cc' | 'bcc') => void;
onInsertAutocomplete: (suggestion: { name: string; email: string }, field: 'to' | 'cc' | 'bcc') => void;
validationError?: boolean;
validationMessage?: string;
onTab?: () => void;
onMoveChip: (recipient: Recipient, fromField: 'to' | 'cc' | 'bcc', toField: 'to' | 'cc' | 'bcc') => void;
}) {
const allParts = value.split(',').map(s => s.trim()).filter(Boolean);
const hasTrailingComma = value.trimEnd().endsWith(',');
const chips = hasTrailingComma ? allParts : allParts.slice(0, -1);
const inputText = hasTrailingComma ? '' : (allParts[allParts.length - 1] || '');
const t = useTranslations('email_composer');
const tCommon = useTranslations('common');
const { contextMenu, openContextMenu, closeContextMenu, menuRef } = useContextMenu<{ index: number; recipient: Recipient }>();
const [editingChip, setEditingChip] = useState<{ index: number; editType: 'email' | 'name' } | null>(null);
const [editValue, setEditValue] = useState('');
const [isDragOver, setIsDragOver] = useState(false);
const [draggingIndex, setDraggingIndex] = useState<number | null>(null);
const editInputRef = useRef<HTMLInputElement | null>(null);
// Focus edit input when editing starts
useEffect(() => {
if (editingChip) {
setTimeout(() => {
if (editInputRef.current) {
editInputRef.current.focus();
editInputRef.current.select();
}
}, 0);
}
}, [editingChip]);
// Format a recipient for display in a chip / context menu
const formatChipDisplay = (r: Recipient): string =>
r.name && r.name !== r.email ? `${r.name} (${r.email})` : r.email;
// Handle saving an edited chip
const handleSaveEdit = (newValue: string) => {
if (!editingChip) return;
const { index, editType } = editingChip;
const chip = chips[index];
if (!chip) {
setEditingChip(null);
return;
}
const trimmedNew = newValue.trim();
let newChip: Recipient;
if (editType === 'email') {
// Update email, keep name. Empty email is a no-op (can't drop the email).
if (!trimmedNew) {
setEditingChip(null);
return;
}
newChip = { name: chip.name, email: trimmedNew };
} else {
// Update name, keep email. Empty name clears the display name.
newChip = { name: trimmedNew || undefined, email: chip.email };
}
const newChips = [...chips];
newChips[index] = newChip;
onChipsChange(newChips);
setEditingChip(null);
};
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const newInputText = e.target.value;
const chipPart = chips.length > 0 ? chips.join(', ') + ', ' : '';
const newValue = chipPart + newInputText;
onChange(newValue);
onAutocomplete(newValue, field);
onInputChange(newInputText);
onAutocomplete(newInputText, field);
};
const commitCurrentInput = () => {
if (inputText.trim()) {
const newChips = [...chips, inputText.trim()];
onChange(newChips.join(', ') + ', ');
onChipsChange([...chips, parseRecipient(inputText)]);
onInputChange('');
}
};
@@ -2769,31 +2946,39 @@ function RecipientChipInput({
return;
}
// Backspace on an empty input pulls the last chip back into the input for
// quick editing.
if (e.key === 'Backspace' && !inputText && chips.length > 0) {
const lastChip = chips[chips.length - 1];
const remainingChips = chips.slice(0, -1);
const chipPart = remainingChips.length > 0 ? remainingChips.join(', ') + ', ' : '';
onChange(chipPart + lastChip);
onChipsChange(chips.slice(0, -1));
onInputChange(formatRecipient(lastChip.name, lastChip.email));
return;
}
};
const handleChipClick = (index: number) => {
const chipEmail = chips[index];
const remainingChips = chips.filter((_, i) => i !== index);
const chipPart = remainingChips.length > 0 ? remainingChips.join(', ') + ', ' : '';
onChange(chipPart + chipEmail);
setTimeout(() => inputRef.current?.focus(), 0);
};
const handleChipRemove = (index: number, e: React.MouseEvent) => {
e.stopPropagation();
const remainingChips = chips.filter((_, i) => i !== index);
if (remainingChips.length > 0) {
onChange(remainingChips.join(', ') + ', ' + inputText);
} else {
onChange(inputText);
}
onChipsChange(chips.filter((_, i) => i !== index));
};
const handleContextMenu = (e: React.MouseEvent, index: number, recipient: Recipient) => {
openContextMenu(e, { index, recipient });
};
const handleEditEmail = () => {
if (!contextMenu.data) return;
const { index, recipient } = contextMenu.data;
closeContextMenu();
setEditValue(recipient.email);
setEditingChip({ index, editType: 'email' });
};
const handleEditName = () => {
if (!contextMenu.data) return;
const { index, recipient } = contextMenu.data;
closeContextMenu();
setEditValue(recipient.name || '');
setEditingChip({ index, editType: 'name' });
};
const handleBlur = (e: React.FocusEvent) => {
@@ -2801,63 +2986,148 @@ function RecipientChipInput({
if (relatedTarget && dropdownRef.current?.contains(relatedTarget)) {
return;
}
if (inputText.trim()) {
const newChips = [...chips, inputText.trim()];
onChange(newChips.join(', ') + ', ');
}
commitCurrentInput();
onAutoBlur(e, field);
};
const handleContainerDragOver = (e: React.DragEvent) => {
if (!e.dataTransfer.types.includes('application/x-recipient-chip')) return;
e.preventDefault();
e.dataTransfer.dropEffect = 'move';
setIsDragOver(true);
};
const handleContainerDragLeave = (e: React.DragEvent) => {
if (!e.currentTarget.contains(e.relatedTarget as Node)) {
setIsDragOver(false);
}
};
const handleContainerDrop = (e: React.DragEvent) => {
e.preventDefault();
setIsDragOver(false);
const raw = e.dataTransfer.getData('application/x-recipient-chip');
if (!raw) return;
const { recipient, fromField } = JSON.parse(raw) as { recipient: Recipient; fromField: 'to' | 'cc' | 'bcc' };
if (fromField === field) return;
onMoveChip(recipient, fromField, field);
};
return (
<div className="flex-1 relative min-w-0">
<div
className={cn(
"flex flex-wrap items-center gap-1 min-h-[32px] cursor-text",
validationError && "ring-2 ring-red-500 dark:ring-red-400 rounded"
validationError && "ring-2 ring-red-500 dark:ring-red-400 rounded",
isDragOver && "ring-2 ring-primary/50 rounded bg-accent/20"
)}
onClick={() => inputRef.current?.focus()}
onDragOver={handleContainerDragOver}
onDragLeave={handleContainerDragLeave}
onDrop={handleContainerDrop}
>
{chips.map((chip, i) => (
<span
key={`${chip}-${i}`}
className="inline-flex items-center gap-1 px-2 py-0.5 rounded-md bg-secondary text-secondary-foreground text-sm border border-border cursor-pointer hover:bg-accent transition-colors"
onClick={(e) => {
e.stopPropagation();
handleChipClick(i);
}}
>
<span className="truncate max-w-[200px]">{chip}</span>
<button
type="button"
className="flex items-center justify-center w-4 h-4 rounded-full hover:bg-muted-foreground/20 transition-colors"
onClick={(e) => handleChipRemove(i, e)}
tabIndex={-1}
{chips.map((chip, i) => {
const isEditing = editingChip?.index === i;
const chipDisplay = formatChipDisplay(chip);
return (
<span
key={`${chip.email}-${i}`}
draggable={!isEditing}
onDragStart={(e) => {
e.stopPropagation();
e.dataTransfer.effectAllowed = 'move';
e.dataTransfer.setData('application/x-recipient-chip', JSON.stringify({ recipient: chip, fromField: field }));
// Show the address while dragging, matching the email-list drag preview.
const dragPreview = createChipDragPreview(chip.email);
e.dataTransfer.setDragImage(dragPreview, 0, 0);
requestAnimationFrame(() => dragPreview.remove());
setDraggingIndex(i);
}}
onDragEnd={() => setDraggingIndex(null)}
className={cn(
"inline-flex items-center gap-1 px-2 py-0.5 rounded-md text-sm border border-border transition-colors",
isEditing
? "bg-background ring-1 ring-ring"
: "bg-secondary text-secondary-foreground hover:bg-accent cursor-grab active:cursor-grabbing",
!isEditing && draggingIndex === i && "opacity-50"
)}
onContextMenu={isEditing ? undefined : (e) => handleContextMenu(e, i, chip)}
>
<X className="w-3 h-3" />
</button>
</span>
))}
<input
ref={inputRef}
type="text"
placeholder={chips.length === 0 ? placeholder : ''}
value={inputText}
onChange={handleInputChange}
onKeyDown={handleKeyDown}
onBlur={handleBlur}
className="flex-1 min-w-[120px] border-0 outline-none h-7 text-sm bg-transparent text-foreground placeholder:text-muted-foreground"
role="combobox"
aria-expanded={activeAutoField === field && autocompleteResults.length > 0}
aria-autocomplete="list"
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"
/>
{isEditing ? (
<input
ref={editInputRef}
type="text"
value={editValue}
onChange={(e) => setEditValue(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') {
handleSaveEdit(editValue);
} else if (e.key === 'Escape') {
setEditingChip(null);
} else if (e.key === 'Tab') {
e.preventDefault();
handleSaveEdit(editValue);
}
}}
onBlur={(e) => {
const relatedTarget = e.relatedTarget as Node | null;
if (relatedTarget && dropdownRef.current?.contains(relatedTarget)) {
return;
}
handleSaveEdit(editValue);
}}
className="flex-1 min-w-[80px] border-0 outline-none h-5 text-sm bg-transparent text-foreground placeholder:text-muted-foreground"
placeholder={editingChip?.editType === 'email' ? t('recipient_email_placeholder') : t('recipient_name_placeholder')}
data-bwignore="true"
/>
) : (
<span className="truncate max-w-[200px]">{chipDisplay}</span>
)}
<button
type="button"
className="flex items-center justify-center w-4 h-4 rounded-full hover:bg-muted-foreground/20 transition-colors"
onClick={(e) => {
e.stopPropagation();
if (isEditing) {
handleSaveEdit(editValue);
} else {
handleChipRemove(i, e);
}
}}
tabIndex={-1}
>
{isEditing ? (
<Check className="w-3 h-3" />
) : (
<X className="w-3 h-3" />
)}
</button>
</span>
);
})}
{!editingChip && (
<input
ref={inputRef}
type="text"
placeholder={chips.length === 0 ? placeholder : ''}
value={inputText}
onChange={handleInputChange}
onKeyDown={handleKeyDown}
onBlur={handleBlur}
className="flex-1 min-w-[120px] border-0 outline-none h-7 text-sm bg-transparent text-foreground placeholder:text-muted-foreground"
role="combobox"
aria-expanded={activeAutoField === field && autocompleteResults.length > 0}
aria-autocomplete="list"
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 && (
<p className="text-xs text-red-600 dark:text-red-400 mt-0.5">{validationMessage}</p>
@@ -2868,9 +3138,33 @@ function RecipientChipInput({
id={`autocomplete-${field}`}
results={autocompleteResults}
selectedIndex={autoSelectedIndex}
onSelect={(email) => onInsertAutocomplete(email, field)}
onSelect={(suggestion) => onInsertAutocomplete(suggestion, field)}
/>
)}
<ContextMenu
isOpen={contextMenu.isOpen}
position={contextMenu.position}
onClose={closeContextMenu}
ref={menuRef}
>
{contextMenu.data && (
<>
<div className="px-3 py-1.5 text-xs font-medium text-muted-foreground truncate max-w-[200px]">
{formatChipDisplay(contextMenu.data.recipient)}
</div>
<ContextMenuSeparator />
<ContextMenuItem label={t('recipient_edit_email')} onClick={handleEditEmail} />
<ContextMenuItem label={t('recipient_edit_name')} onClick={handleEditName} />
<ContextMenuSeparator />
<ContextMenuItem label={tCommon('delete')} onClick={() => {
if (contextMenu.data) {
handleChipRemove(contextMenu.data.index, { stopPropagation: () => {} } as React.MouseEvent);
}
closeContextMenu();
}} destructive />
</>
)}
</ContextMenu>
</div>
);
}
+7 -3
View File
@@ -100,6 +100,8 @@ export function EmailList({
isLoadingThread,
toggleThreadExpansion,
fetchThreadEmails,
markThreadAsRead,
threadEmailCounts,
searchFilters,
setSearchFilters,
clearSearchFilters,
@@ -110,9 +112,9 @@ export function EmailList({
const disableThreading = useSettingsStore((state) => state.disableThreading);
const threadGroups = useMemo(() => {
const groups = groupEmailsByThread(emails, disableThreading || isScheduledView);
const groups = groupEmailsByThread(emails, disableThreading || isScheduledView, threadEmailCounts);
return sortThreadGroups(groups);
}, [emails, disableThreading, isScheduledView]);
}, [emails, disableThreading, isScheduledView, threadEmailCounts]);
const { contextMenu, openContextMenu, closeContextMenu, menuRef } = useContextMenu<Email>();
const { dialogProps: confirmDialogProps, confirm: confirmDialog } = useConfirmDialog();
@@ -250,10 +252,12 @@ export function EmailList({
if (!isExpanded && client) {
toggleThreadExpansion(threadId);
await fetchThreadEmails(client, threadId);
// Mark all unread emails in this thread as read
void markThreadAsRead(client, threadId);
} else {
toggleThreadExpansion(threadId);
}
}, [client, expandedThreadIds, toggleThreadExpansion, fetchThreadEmails]);
}, [client, expandedThreadIds, toggleThreadExpansion, fetchThreadEmails, markThreadAsRead]);
// Range-based load more: trigger when last visible item is near the end.
// Debounce to prevent rapid cascade when thread grouping reduces item
+105 -43
View File
@@ -609,19 +609,23 @@ function renderClickableRecipients(
}
// Contact sidebar panel that slides in from the right on desktop
function ContactSidebarPanel({
export function ContactSidebarPanel({
email,
contact,
senderName,
onClose,
onAddToContacts,
onEditContact,
}: {
email: string;
contact: ContactCard | null;
senderName?: string;
onClose: () => void;
onAddToContacts?: (email: string, name?: string) => void;
onEditContact?: () => void;
}) {
const t = useTranslations('email_viewer');
const tCommon = useTranslations('common');
const name = contact ? getContactDisplayName(contact) : senderName || null;
const primaryEmail = contact ? getContactPrimaryEmail(contact) : email;
const emails = contact?.emails ? Object.values(contact.emails) : [];
@@ -633,9 +637,9 @@ function ContactSidebarPanel({
const handleCopy = async (text: string) => {
try {
await navigator.clipboard.writeText(text);
toast.success("Copied!");
toast.success(t('contact_sidebar.copied'));
} catch {
toast.error("Failed to copy");
toast.error(t('contact_sidebar.copy_failed'));
}
};
@@ -643,11 +647,11 @@ function ContactSidebarPanel({
<div className="w-[320px] shrink-0 border-l border-border bg-background flex flex-col h-full animate-in slide-in-from-right-5 duration-200">
{/* Header */}
<div className="flex items-center justify-between px-4 py-3 border-b border-border">
<h3 className="text-sm font-semibold text-foreground truncate">Contact</h3>
<h3 className="text-sm font-semibold text-foreground truncate">{t('contact_sidebar.title')}</h3>
<button
onClick={onClose}
className="p-1 rounded hover:bg-muted transition-colors"
aria-label="Close sidebar"
aria-label={t('contact_sidebar.close')}
>
<PanelRightClose className="w-4 h-4 text-muted-foreground" />
</button>
@@ -685,19 +689,29 @@ function ContactSidebarPanel({
<a
href={`mailto:${primaryEmail}`}
className="flex items-center gap-1.5 text-xs font-medium text-muted-foreground hover:text-foreground px-3 py-2 rounded-md hover:bg-muted transition-colors border border-border"
title="Send email"
title={t('contact_sidebar.action_email_title')}
>
<Send className="w-3.5 h-3.5" />
Email
{t('contact_sidebar.action_email')}
</a>
<button
onClick={() => handleCopy(primaryEmail)}
className="flex items-center gap-1.5 text-xs font-medium text-muted-foreground hover:text-foreground px-3 py-2 rounded-md hover:bg-muted transition-colors border border-border"
title="Copy email"
title={t('contact_sidebar.action_copy_title')}
>
<Copy className="w-3.5 h-3.5" />
Copy
{t('contact_sidebar.action_copy')}
</button>
{contact && onEditContact && (
<button
onClick={onEditContact}
className="flex items-center gap-1.5 text-xs font-medium text-muted-foreground hover:text-foreground px-3 py-2 rounded-md hover:bg-muted transition-colors border border-border"
title={t('contact_sidebar.action_edit_title')}
>
<EditIcon className="w-3.5 h-3.5" />
{tCommon('edit')}
</button>
)}
</div>
{/* Details sections */}
@@ -705,7 +719,7 @@ function ContactSidebarPanel({
<div className="px-4 pb-4 space-y-4">
{/* Emails */}
{emails.length > 0 && (
<SidebarSection icon={Mail} title="Emails">
<SidebarSection icon={Mail} title={t('contact_sidebar.section_emails')}>
{emails.map((e, i) => (
<div key={i} className="flex items-center gap-2 group">
<a href={`mailto:${e.address}`} className="text-sm text-primary hover:underline truncate">
@@ -725,7 +739,7 @@ function ContactSidebarPanel({
{/* Phones */}
{phones.length > 0 && (
<SidebarSection icon={Phone} title="Phones">
<SidebarSection icon={Phone} title={t('contact_sidebar.section_phones')}>
{phones.map((p, i) => (
<div key={i} className="flex items-center gap-2 group">
<a href={`tel:${p.number}`} className="text-sm text-primary hover:underline">
@@ -745,7 +759,7 @@ function ContactSidebarPanel({
{/* Organizations */}
{orgs.length > 1 && (
<SidebarSection icon={Building} title="Organizations">
<SidebarSection icon={Building} title={t('contact_sidebar.section_organizations')}>
{orgs.map((o, i) => (
<div key={i} className="text-sm">
{o.name}
@@ -759,7 +773,7 @@ function ContactSidebarPanel({
{/* Addresses */}
{addresses.length > 0 && (
<SidebarSection icon={MapPin} title="Addresses">
<SidebarSection icon={MapPin} title={t('contact_sidebar.section_addresses')}>
{addresses.map((a, i) => (
<div key={i} className="text-sm text-muted-foreground">
{a.full || a.fullAddress
@@ -774,7 +788,7 @@ function ContactSidebarPanel({
{/* Notes */}
{notes.length > 0 && (
<SidebarSection icon={StickyNote} title="Notes">
<SidebarSection icon={StickyNote} title={t('contact_sidebar.section_notes')}>
{notes.map((n, i) => (
<p key={i} className="text-sm text-muted-foreground whitespace-pre-wrap">{n.note}</p>
))}
@@ -787,7 +801,7 @@ function ContactSidebarPanel({
{!contact && (
<div className="px-4 pb-4 text-center space-y-3">
<p className="text-xs text-muted-foreground">
Not in your contacts
{t('contact_sidebar.not_in_contacts')}
</p>
{onAddToContacts && (
<button
@@ -795,7 +809,7 @@ function ContactSidebarPanel({
className="inline-flex items-center gap-1.5 text-xs font-medium text-primary hover:text-primary/80 px-3 py-2 rounded-md hover:bg-muted transition-colors border border-border"
>
<Mail className="w-3.5 h-3.5" />
Add to contacts
{t('contact_sidebar.add_to_contacts')}
</button>
)}
</div>
@@ -1034,12 +1048,29 @@ export function EmailViewer({
const [embeddedEmailAttachments, setEmbeddedEmailAttachments] = useState<PostalMimeAttachment[]>([]);
const [embeddedEmailUnwrapped, setEmbeddedEmailUnwrapped] = useState(false);
// Plugin detail sidebar state
// Plugin detail sidebar state. Collapsed/width persist across opens and
// sessions so the panel reopens the way the user last left it.
const detailSlots = usePluginSlotOffers('email-detail-sidebar');
const hasDetailSidebar = detailSlots.length > 0;
const [detailSidebarCollapsed, setDetailSidebarCollapsed] = useState(false);
const [detailSidebarWidth, setDetailSidebarWidth] = useState(280);
const detailSidebarWidthRef = useRef(280);
const [detailSidebarCollapsed, setDetailSidebarCollapsed] = useState<boolean>(() => {
if (typeof window === 'undefined') return false;
try { return localStorage.getItem('emailDetailSidebarCollapsed') === '1'; } catch { return false; }
});
const [detailSidebarWidth, setDetailSidebarWidth] = useState<number>(() => {
if (typeof window === 'undefined') return 280;
try {
const n = parseInt(localStorage.getItem('emailDetailSidebarWidth') ?? '', 10);
return Number.isFinite(n) ? Math.max(200, Math.min(500, n)) : 280;
} catch { return 280; }
});
const detailSidebarWidthRef = useRef(detailSidebarWidth);
useEffect(() => {
try { localStorage.setItem('emailDetailSidebarCollapsed', detailSidebarCollapsed ? '1' : '0'); } catch { /* ignore */ }
}, [detailSidebarCollapsed]);
useEffect(() => {
try { localStorage.setItem('emailDetailSidebarWidth', String(detailSidebarWidth)); } catch { /* ignore */ }
}, [detailSidebarWidth]);
// Ensure S/MIME key records are loaded from IndexedDB
useLayoutEffect(() => {
@@ -2842,31 +2873,44 @@ export function EmailViewer({
// If email has native dark mode, let it handle its own theming
// Otherwise, use CSS filter inversion for dark mode (preserves layout)
// Re-invert leaf media elements so they appear normal.
// Container selectors (bgcolor, background, etc.) use :not(:has(...)) to avoid
// double re-inverting images nested inside those containers.
// Nested bgcolor containers must NOT add another invert layer: each filter
// toggles the inversion, so an odd number of stacked filters (e.g. body +
// outer bgcolor table + inner bgcolor table) produces an inverted result -
// i.e. light-on-light. The second rule disables filter on bgcolor-like
// elements that are descendants of another bgcolor-like element.
//
// Background-COLOR containers (bgcolor, background: shorthand) are
// re-inverted so their fill flips to a dark tone, but skipped when they
// wrap media: the inner image already gets its own re-invert, and a filter
// on the container would double-invert it. Nested color containers must NOT
// stack another invert either - each filter toggles the inversion, so an
// odd number of stacked filters (body + outer + inner) lands back on
// light-on-light; the descendant rule disables filter on a color container
// nested inside another.
//
// Background-IMAGE containers are different: a real photo and whatever is
// composited over it (headline text, logos) was designed by the sender to
// read together, so the whole subtree should return to its original
// light-mode appearance - even when it wraps media. We therefore re-invert
// these unconditionally (no :has guard) and cancel the per-image re-invert
// for media inside them, otherwise those images would flip back to inverted.
const darkModeCSS = isDark && !emailHasNativeDarkMode ? `
html { background: #121212; }
body { filter: invert(1) hue-rotate(180deg); background: #ededed; }
img, video, svg, canvas, object, embed, input[type="image"] {
filter: invert(1) hue-rotate(180deg);
}
[style*="background-image"]:not(:has(img, video, svg, canvas, object, embed)),
[style*="background:"]:not(:has(img, video, svg, canvas, object, embed)),
[background]:not(:has(img, video, svg, canvas, object, embed)),
[bgcolor]:not(:has(img, video, svg, canvas, object, embed)),
td[background]:not(:has(img, video, svg, canvas, object, embed)),
table[background]:not(:has(img, video, svg, canvas, object, embed)) {
[bgcolor]:not(:has(img, video, svg, canvas, object, embed)) {
filter: invert(1) hue-rotate(180deg);
}
:where([style*="background-image"], [style*="background:"], [background], [bgcolor])
:where([style*="background-image"], [style*="background:"], [background], [bgcolor]):not(:has(img, video, svg, canvas, object, embed)) {
:where([style*="background:"], [bgcolor])
:where([style*="background:"], [bgcolor]):not(:has(img, video, svg, canvas, object, embed)) {
filter: none !important;
}
[style*="background-image"],
[background] {
filter: invert(1) hue-rotate(180deg);
}
[style*="background-image"] :where(img, video, svg, canvas, object, embed, input[type="image"]),
[background] :where(img, video, svg, canvas, object, embed, input[type="image"]) {
filter: none;
}
` : '';
const colorScheme = isDark && emailHasNativeDarkMode ? 'light dark' : 'light';
@@ -2901,18 +2945,29 @@ export function EmailViewer({
<style>
/* Force content height: some emails set html/body { height: 100% }, which -
combined with overflow:hidden and our scrollHeight-based auto-resize -
collapses the measured height and clips everything below the fold. */
html, body { overflow: hidden; height: auto !important; }
body { margin: 0; padding: ${bodyPadding}; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; font-size: 14px; line-height: 1.6; color: #1a1a1a; background: #ffffff; word-wrap: break-word; overflow-wrap: break-word; }
collapses the measured height and clips everything below the fold. The
height reset is repeated in a trailing <style> after the body content
(see below): the email's own <style> is injected inside our <body> and so
lands later in source order, where - at equal specificity and !important -
it would otherwise win the cascade. */
/* overflow-y stays hidden so our scrollHeight-based height measurement works;
overflow-x is auto on the body so an intrinsically wide table (e.g. a
20-column data table) can scroll horizontally instead of being crushed to
fit - the latter wraps header text to one character per line, which reads
as 90deg-rotated vertical headers (issue #409). */
html { overflow: hidden; height: auto !important; }
body { margin: 0; padding: ${bodyPadding}; overflow-x: auto; overflow-y: hidden; height: auto !important; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; font-size: 14px; line-height: 1.6; color: #1a1a1a; background: #ffffff; word-wrap: break-word; overflow-wrap: break-word; }
@media (max-width: 640px) { body { padding-left: ${mobileBodyPaddingX}; padding-right: ${mobileBodyPaddingX}; } }
img { max-width: 100% !important; height: auto !important; }
a { color: #1a73e8; }
table { max-width: 100% !important; table-layout: auto; overflow-wrap: break-word; }
td, th { overflow-wrap: anywhere; }
/* break-word (not anywhere): break only over-long single words, and keep each
word's min-content width so columns are not collapsed to a single char. */
td, th { overflow-wrap: break-word; }
pre { white-space: pre-wrap; word-wrap: break-word; }
${wordHtmlCSS}
${darkModeCSS}
</style></head><body>${effectiveEmailContent.html}</body></html>`;
</style></head><body>${effectiveEmailContent.html}<style>html,body{height:auto!important;min-height:0!important;max-height:none!important}</style></body></html>`;
}, [effectiveEmailContent.html, effectiveEmailContent.isHtml, isDark, emailHasNativeDarkMode]);
// Imperatively restore blocked external content inside the iframe document.
@@ -3051,10 +3106,13 @@ export function EmailViewer({
if (['IMG', 'VIDEO', 'SVG', 'CANVAS', 'OBJECT', 'EMBED'].includes(tag)) return;
const computed = win.getComputedStyle(htmlEl);
if (computed.backgroundImage && computed.backgroundImage !== 'none') {
// Only re-invert if this container doesn't have media children
if (!htmlEl.querySelector('img, video, svg, canvas, object, embed')) {
htmlEl.style.filter = 'invert(1) hue-rotate(180deg)';
}
// Re-invert so the background image returns to its original
// appearance. Matches the CSS background-image rules: re-invert
// even when media is nested, then cancel the per-image
// re-invert on that media so it isn't left double-inverted.
htmlEl.style.filter = 'invert(1) hue-rotate(180deg)';
htmlEl.querySelectorAll('img, video, svg, canvas, object, embed, input[type="image"]')
.forEach(m => { (m as HTMLElement).style.filter = 'none'; });
}
});
@@ -5796,6 +5854,10 @@ export function EmailViewer({
return allRecipients.find(r => r.email.toLowerCase() === contactSidebarEmail.toLowerCase())?.name;
})()}
onClose={() => setContactSidebarEmail(null)}
onEditContact={sidebarContact ? () => {
router.push(`/contacts?contactId=${sidebarContact.id}&view=edit`);
setContactSidebarEmail(null);
} : undefined}
onAddToContacts={(addr, name) => {
const { createContact, addLocalContact, supportsSync } = useContactStore.getState();
const client = useAuthStore.getState().client;
+84 -3
View File
@@ -11,7 +11,7 @@ import {
AlertCircle, Star, Clock, FolderUp,
FileArchive, FileSpreadsheet, Presentation, FileCode,
Box, PenTool, Terminal as TerminalIcon, Database, Type as TypeIcon,
Menu,
Menu, Users, Share2,
} from "lucide-react";
import { useIsDesktop } from "@/hooks/use-media-query";
import { Button } from "@/components/ui/button";
@@ -26,6 +26,9 @@ import { ResizeHandle } from "@/components/layout/resize-handle";
import { Avatar } from "@/components/ui/avatar";
import { getDroppedFilesAndFolders } from "@/lib/webdav/drop-utils";
import type { FileResource } from "@/stores/file-store";
import { ShareCollectionDialog } from "@/components/settings/share-collection-dialog";
import type { IJMAPClient } from "@/lib/jmap/client-interface";
import type { FileNodeRights } from "@/lib/jmap/types";
type SortKey = "name" | "size" | "modified";
type SortDir = "asc" | "desc";
@@ -95,6 +98,14 @@ interface FileBrowserProps {
accountPickerMode?: boolean;
/** Pro shell only: label of the currently-attached account, shown as a breadcrumb segment after Home. */
accountLabel?: string | null;
/** JMAP client for the browsing account, used by the share dialog to list principals. */
client?: IJMAPClient | null;
/** Files account id of the browsing account; used to exclude self from the share picker. */
ownAccountId?: string | null;
/** True when the server supports JMAP Sharing (principals); gates the Share action. */
sharingEnabled?: boolean;
/** Add/update/remove a principal's share on a node. Set null rights to revoke. */
onShare?: (id: string, principalId: string, rights: FileNodeRights | null) => Promise<void>;
}
const IMAGE_EXTENSIONS = new Set(["jpg", "jpeg", "png", "gif", "svg", "webp", "bmp", "ico", "avif"]);
@@ -230,6 +241,33 @@ function getGridIcon(resource: FileResource) {
return getFileIconByName(resource.name, "lg");
}
// Small inline indicator: a node shared out by the user (shareWith has entries)
// or a node shared *with* the user by another principal (isShared).
function ShareBadge({ resource, t }: { resource: FileResource; t: (key: string) => string }) {
const sharedOut = !!resource.shareWith && Object.keys(resource.shareWith).length > 0;
if (resource.isShared) {
return (
<Share2
className="w-3.5 h-3.5 text-primary shrink-0"
aria-label={t("shared_with_me")}
>
<title>{t("shared_with_me")}</title>
</Share2>
);
}
if (sharedOut) {
return (
<Users
className="w-3.5 h-3.5 text-primary shrink-0"
aria-label={t("shared")}
>
<title>{t("shared")}</title>
</Users>
);
}
return null;
}
function Thumbnail({ name, getImageUrl: fetchUrl, size = "sm" }: {
name: string;
getImageUrl: (n: string) => Promise<string>;
@@ -342,11 +380,25 @@ export function FileBrowser({
onSelectAccount,
accountPickerMode,
accountLabel,
client,
ownAccountId,
sharingEnabled,
onShare,
}: FileBrowserProps) {
const t = useTranslations("files");
const [showNewFolder, setShowNewFolder] = useState(false);
const [renameTarget, setRenameTarget] = useState<string | null>(null);
const [shareTargetId, setShareTargetId] = useState<string | null>(null);
const [isDraggingOver, setIsDraggingOver] = useState(false);
// The share dialog is bound to a node id (not name) so its shareWith stays
// live after a share refresh re-derives the resource list.
const shareTarget = shareTargetId ? resources.find(r => r.id === shareTargetId) ?? null : null;
// A node is shareable when the server supports JMAP Sharing, the viewer owns
// it (not a shared-with-me node), and holds the mayShare right (owned nodes
// report full rights; treat missing myRights as allowed).
const canShare = useCallback((r: FileResource | null | undefined): boolean =>
!!(sharingEnabled && onShare && client && r && !r.isShared && (r.myRights?.mayShare ?? true)),
[sharingEnabled, onShare, client]);
const [contextMenu, setContextMenu] = useState<{ x: number; y: number; name: string } | null>(null);
const [emptyContextMenu, setEmptyContextMenu] = useState<{ x: number; y: number } | null>(null);
const [showNewTextFile, setShowNewTextFile] = useState(false);
@@ -1440,8 +1492,9 @@ export function FileBrowser({
{showThumbnails && isImageFile(resource.name)
? <Thumbnail name={resource.name} getImageUrl={getImageUrl} size="lg" />
: getGridIcon(resource)}
<span className="text-xs truncate w-full text-center" title={resource.name}>
{resource.name}
<span className="text-xs truncate w-full text-center flex items-center justify-center gap-1" title={resource.name}>
<span className="truncate">{resource.name}</span>
<ShareBadge resource={resource} t={t} />
</span>
</div>
))}
@@ -1590,6 +1643,7 @@ export function FileBrowser({
? <Thumbnail name={resource.name} getImageUrl={getImageUrl} size="sm" />
: getFileIcon(resource)}
<span className="truncate">{resource.name}</span>
<ShareBadge resource={resource} t={t} />
</div>
</td>
<td className="px-4 py-2.5 text-muted-foreground hidden md:table-cell tabular-nums">
@@ -1697,6 +1751,19 @@ export function FileBrowser({
{t("duplicate")}
</button>
)}
{canShare(resources.find(r => r.name === contextMenu.name)) && (
<button
className="w-full flex items-center gap-2 px-3 py-2 text-sm hover:bg-muted transition-colors text-left"
onClick={() => {
const r = resources.find(res => res.name === contextMenu.name);
if (r) setShareTargetId(r.id);
setContextMenu(null);
}}
>
<Share2 className="w-4 h-4" />
{t("share")}
</button>
)}
<div className="h-px bg-border my-1" />
<button
className="w-full flex items-center gap-2 px-3 py-2 text-sm hover:bg-muted transition-colors text-left"
@@ -1936,6 +2003,20 @@ export function FileBrowser({
onCancel={() => setRenameTarget(null)}
/>
)}
{/* Share dialog */}
{shareTarget && client && onShare && (
<ShareCollectionDialog
client={client}
kind="file"
collectionName={shareTarget.name}
shareWith={shareTarget.shareWith}
ownAccountId={ownAccountId || ""}
onShare={(principalId, rights) =>
onShare(shareTarget.id, principalId, rights as FileNodeRights | null)}
onClose={() => setShareTargetId(null)}
/>
)}
</div>
);
}
+39
View File
@@ -8,6 +8,7 @@ import {
ChevronRight,
ChevronDown,
Home,
Share2,
} from "lucide-react";
import { cn } from "@/lib/utils";
import { useFileStore, type FileResource } from "@/stores/file-store";
@@ -29,6 +30,8 @@ interface FolderTreeSidebarProps {
export function FolderTreeSidebar({ currentPath, onNavigate, listByParentId, width = 256, isResizing }: FolderTreeSidebarProps) {
const t = useTranslations("files");
const client = useFileStore(s => s.client);
const sharedRoots = useFileStore(s => s.sharedRoots);
const loadSharedRoots = useFileStore(s => s.loadSharedRoots);
const [rootChildren, setRootChildren] = useState<FolderNode[] | null>(null);
const [expandedIds, setExpandedIds] = useState<Set<string>>(new Set(["root"]));
const [loadingIds, setLoadingIds] = useState<Set<string>>(new Set());
@@ -81,6 +84,8 @@ export function FolderTreeSidebar({ currentPath, onNavigate, listByParentId, wid
useEffect(() => {
if (client) {
loadChildren(null, "/");
// Discover folders shared with the user by other principals.
loadSharedRoots();
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [client]);
@@ -180,6 +185,40 @@ export function FolderTreeSidebar({ currentPath, onNavigate, listByParentId, wid
/>
))
)}
{/* Shared with me: folders another principal has shared with the user */}
{sharedRoots.filter(r => r.isDirectory).length > 0 && (
<div className="mt-2 pt-2 border-t border-border/60">
<div className="px-3 py-1 text-[11px] font-medium uppercase tracking-wide text-muted-foreground flex items-center gap-1.5">
<Share2 className="w-3 h-3" />
<span className="truncate">{t("shared_with_me")}</span>
</div>
{sharedRoots.filter(r => r.isDirectory).map(r => {
const path = `/${r.name}`;
const isSelected = currentPath === path;
return (
<div
key={r.id}
style={{ paddingBlock: "var(--density-sidebar-py)" }}
className={cn(
"group w-full flex items-center max-lg:min-h-[44px] text-sm transition-all duration-200 px-2",
isSelected ? "bg-accent text-accent-foreground" : "hover:bg-muted text-foreground"
)}
>
<button
onClick={() => handleFolderClick(path, r.id)}
className="flex items-center px-1 rounded transition-colors duration-150 flex-1 text-left min-w-0"
style={{ paddingBlock: "var(--density-sidebar-py)", paddingLeft: "24px" }}
title={r.ownerName ? t("shared_by", { name: r.ownerName }) : r.name}
>
<Folder className="w-4 h-4 flex-shrink-0 mr-2 text-primary" />
<span className="truncate">{r.name}</span>
</button>
</div>
);
})}
</div>
)}
</div>
</div>
);
+1 -1
View File
@@ -546,7 +546,7 @@ export function NavigationRail({
})}
{/* Manage apps button */}
{onManageApps && (
{sidebarAppsEnabled && onManageApps && (
<button
onClick={onManageApps}
className={cn(
+14
View File
@@ -33,6 +33,7 @@ import {
NotebookPen,
CalendarClock,
BellOff,
Mails,
} from "lucide-react";
import { cn, buildMailboxTree, MailboxNode } from "@/lib/utils";
import { Mailbox } from "@/lib/jmap/types";
@@ -76,6 +77,8 @@ interface SidebarProps {
onRefreshMailboxes?: () => void;
scheduledTotal?: number;
showScheduledMailbox?: boolean;
/** Gated "All Mail" virtual folder that merges all of the account's folders. */
showAllMailMailbox?: boolean;
className?: string;
/**
* Multi-account (Pro) mode props. When `multiAccountMode` is true, the
@@ -678,6 +681,7 @@ export function Sidebar({
onRefreshMailboxes,
scheduledTotal = 0,
showScheduledMailbox = false,
showAllMailMailbox = false,
className,
multiAccountMode = false,
accountMailboxes,
@@ -978,6 +982,16 @@ export function Sidebar({
{/* Mailbox List */}
<div className="flex-1 overflow-y-auto" data-tour="sidebar">
{showAllMailMailbox && (
<SidebarRow
icon={<Mails className={cn("w-4 h-4 flex-shrink-0", selectedMailbox === '__all_mail__' ? "text-foreground" : "text-muted-foreground")} />}
label={t('mailboxes.all_mail')}
depth={0}
isSelected={!selectedKeyword && selectedMailbox === '__all_mail__'}
onClick={() => onMailboxSelect?.('__all_mail__')}
isCollapsed={isCollapsed}
/>
)}
{showUnified && (
<div>
<SidebarSectionHeader
+19 -1
View File
@@ -26,6 +26,7 @@ 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 refreshCurrentMailbox = useEmailStore((s) => s.refreshCurrentMailbox);
const fetchScheduledEmails = useEmailStore((s) => s.fetchScheduledEmails);
const refreshScheduledMetadata = useEmailStore((s) => s.refreshScheduledMetadata);
const selectedMailbox = useEmailStore((s) => s.selectedMailbox);
@@ -92,7 +93,24 @@ export function ProComposeTabBody({ tabId, data }: ProComposeTabBodyProps) {
// Refresh the currently-active mail list so the new sent message /
// updated keyword status shows up.
await fetchEmails(client, selectedMailbox);
await refreshCurrentMailbox(client);
// Re-fetch the replied thread's cross-folder data so the expanded
// view shows the newly sent reply without collapsing.
if (data.sourceEmailId) {
const emailState = useEmailStore.getState();
const repliedEmail = emailState.emails.find(e => e.id === data.sourceEmailId);
if (repliedEmail?.threadId && emailState.expandedThreadIds.has(repliedEmail.threadId)) {
const accountId = client.getAccountId();
const fullEmails = await client.getThreadEmails(repliedEmail.threadId, accountId);
if (fullEmails.length > 0) {
useEmailStore.setState((state) => {
const c = new Map(state.threadEmailsCache);
c.set(repliedEmail.threadId!, fullEmails);
return { threadEmailsCache: c };
});
}
}
}
closeTab(tabIdRef.current);
} catch (error) {
console.error('Failed to send email:', error);
+2
View File
@@ -9,6 +9,7 @@ import deMessages from '@/locales/de/common.json';
import enMessages from '@/locales/en/common.json';
import esMessages from '@/locales/es/common.json';
import frMessages from '@/locales/fr/common.json';
import huMessages from '@/locales/hu/common.json';
import itMessages from '@/locales/it/common.json';
import jaMessages from '@/locales/ja/common.json';
import koMessages from '@/locales/ko/common.json';
@@ -29,6 +30,7 @@ const ALL_MESSAGES = {
en: enMessages,
es: esMessages,
fr: frMessages,
hu: huMessages,
it: itMessages,
ja: jaMessages,
ko: koMessages,
@@ -1,15 +1,18 @@
'use client';
import { useState, useEffect, useMemo } from 'react';
import { useState, useEffect, useMemo, useCallback } from 'react';
import { useParams } from 'next/navigation';
import { useTranslations } from 'next-intl';
import QRCode from 'qrcode';
import * as OTPAuth from 'otpauth';
import { Shield, Key, Smartphone, Lock, Trash2, Plus, Eye, EyeOff, Copy, Check, Loader2, Monitor, Terminal } from 'lucide-react';
import { Shield, Key, Smartphone, Lock, Trash2, Plus, Eye, EyeOff, Copy, Check, Loader2, Monitor, Terminal, QrCode } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { SettingsSection, SettingItem, ToggleSwitch } from './settings-section';
import { useAccountSecurityStore, type AppPasswordInfo, type ApiKeyInfo, type AppCredentialInput } from '@/stores/account-security-store';
import { useAuthStore } from '@/stores/auth-store';
import { useAccountStore } from '@/stores/account-store';
import { apiFetch, getPathPrefix } from '@/lib/browser-navigation';
import { toast } from '@/stores/toast-store';
import { cn } from '@/lib/utils';
import { sanitizeI18nHtml } from '@/lib/email-sanitization';
@@ -638,14 +641,156 @@ function EmailClientSection() {
);
}
// Cross-device QR login. A signed-in (OAuth/SSO) session mints a short-lived
// pairing code via /api/auth/pair/create; we render it as a QR that the mobile
// app scans to sign in without re-typing credentials. The QR payload carries
// only the server URL and the one-time code — never tokens.
function LinkDeviceSection() {
const t = useTranslations('settings.security');
const params = useParams();
const locale = params.locale as string;
const [qrDataUrl, setQrDataUrl] = useState<string | null>(null);
const [remaining, setRemaining] = useState(0);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [hasGenerated, setHasGenerated] = useState(false);
// Tick the countdown down to zero, then drop the (now useless) QR so the
// user is nudged to generate a fresh one.
useEffect(() => {
if (remaining <= 0) {
setQrDataUrl(null);
return;
}
const timer = setInterval(() => setRemaining((r) => Math.max(0, r - 1)), 1000);
return () => clearInterval(timer);
}, [remaining]);
// Send the user to the IdP for a fresh login (prompt=login). On return the
// callback page sets the pairing re-auth proof and bounces back here, where
// the resume effect below re-runs generate().
const startReauth = useCallback(async () => {
try {
sessionStorage.setItem('pair_reauth_resume', '1');
} catch { /* sessionStorage unavailable */ }
const prefix = getPathPrefix(locale);
const redirectUri = `${window.location.origin}${prefix}/${locale}/auth/callback`;
const res = await apiFetch('/api/auth/sso/start', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({ redirect_uri: redirectUri, locale, purpose: 'reauth' }),
});
if (!res.ok) {
setError(t('link_device.error'));
return;
}
const { authorize_url } = await res.json();
window.location.href = authorize_url;
}, [locale, t]);
// `fromResume` guards against a redirect loop: if we just completed re-auth
// and pair/create still demands it, surface an error instead of bouncing to
// the IdP again.
const generate = useCallback(async (fromResume = false) => {
setLoading(true);
setError(null);
try {
// Pair the account whose session cookie we'll actually refresh — the
// active account's slot.
const slot = useAccountStore.getState().getActiveAccount()?.cookieSlot ?? 0;
const res = await apiFetch('/api/auth/pair/create', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({ slot }),
});
if (!res.ok) {
const errBody = await res.json().catch(() => null);
if (res.status === 401 && errBody?.error === 'reauth_required' && !fromResume) {
await startReauth();
return;
}
setError(t('link_device.error'));
return;
}
const data = await res.json();
const code = data.pairing_code as string;
const expiresIn = typeof data.expires_in === 'number' ? data.expires_in : 120;
// The phone redeems the code against THIS webmail (where the pairing
// record lives), so the QR carries the webmail base — origin plus any
// mount prefix — not the JMAP server URL. The JMAP server_url comes back
// in the redeem response.
const webmailBase = `${window.location.origin}${getPathPrefix()}`;
const payload = `bulwarkmail://pair?server=${encodeURIComponent(webmailBase)}&code=${encodeURIComponent(code)}`;
const dataUrl = await QRCode.toDataURL(payload, { width: 240, margin: 1 });
setQrDataUrl(dataUrl);
setRemaining(expiresIn);
setHasGenerated(true);
} catch {
setError(t('link_device.error'));
} finally {
setLoading(false);
}
}, [t, startReauth]);
// Auto-resume after returning from the step-up re-auth round-trip.
useEffect(() => {
let resume = false;
try {
resume = sessionStorage.getItem('pair_reauth_done') === '1';
if (resume) sessionStorage.removeItem('pair_reauth_done');
} catch { /* sessionStorage unavailable */ }
if (resume) void generate(true);
}, [generate]);
return (
<div className="space-y-3">
<div className="flex items-center gap-2">
<QrCode className="w-4 h-4 text-muted-foreground" />
<h4 className="text-sm font-medium text-foreground">{t('link_device.title')}</h4>
</div>
<p className="text-xs text-muted-foreground">{t('link_device.description')}</p>
{qrDataUrl && remaining > 0 && (
<div className="p-3 bg-muted/70 dark:bg-muted/40 rounded-md space-y-2">
<div className="flex justify-center">
<img src={qrDataUrl} alt="Pairing QR code" className="rounded bg-white p-2" />
</div>
<p className="text-xs text-muted-foreground text-center">{t('link_device.instructions')}</p>
<p className="text-[11px] text-muted-foreground text-center">
{t('link_device.expires_in', { seconds: remaining })}
</p>
</div>
)}
{error && <p className="text-xs text-destructive">{error}</p>}
<Button variant="outline" size="sm" onClick={() => void generate()} disabled={loading}>
{loading ? (
<Loader2 className="w-3 h-3 mr-1 animate-spin" />
) : (
<QrCode className="w-3 h-3 mr-1" />
)}
{hasGenerated ? t('link_device.regenerate') : t('link_device.generate')}
</Button>
</div>
);
}
export function AccountSecuritySettings() {
const t = useTranslations('settings.security');
const { isStalwart, isProbing, probe, fetchAll, fetchAuthInfo } = useAccountSecurityStore();
const { isAuthenticated, authMode } = useAuthStore();
const { isAuthenticated, authMode, client } = useAuthStore();
const isOAuth = authMode === 'oauth';
// Wait for `client` before probing. On reload the persisted `isAuthenticated`
// flips true before the async OAuth reconnect sets `client`; probing in that
// window reads a null client, decides the server isn't Stalwart, and caches
// that wrong verdict. Gating on `client` (which is set only after connect()
// populates the session capabilities) makes the probe run with real data.
useEffect(() => {
if (isAuthenticated && isStalwart === null) {
if (isAuthenticated && client && isStalwart === null) {
probe().then((detected) => {
if (detected) {
if (isOAuth) {
@@ -656,7 +801,7 @@ export function AccountSecuritySettings() {
}
});
}
}, [isAuthenticated, isStalwart, probe, fetchAll, fetchAuthInfo, isOAuth]);
}, [isAuthenticated, client, isStalwart, probe, fetchAll, fetchAuthInfo, isOAuth]);
if (isProbing) {
return (
@@ -670,9 +815,25 @@ export function AccountSecuritySettings() {
}
if (isStalwart === false) {
// Even when Stalwart account-management isn't exposed (common for OAuth
// sessions, whose tokens may lack the management capability), the
// cross-device mobile pairing still works — it only needs the OAuth
// refresh-token cookie, not `urn:stalwart:jmap`. So surface the QR linker
// for OAuth sessions and show the "not available" note for the rest.
// Use t.raw (not t) because the message is hand-injected HTML; passing it
// through t() makes next-intl try to parse the <a> tag and throw
// INVALID_TAG.
return (
<SettingsSection title={t('title')} description={t('description')}>
<div className="text-sm text-muted-foreground py-4" dangerouslySetInnerHTML={{ __html: sanitizeI18nHtml(t('not_available')) }} />
{isOAuth ? (
<div className="space-y-6">
<LinkDeviceSection />
<div className="border-t border-border" />
<div className="text-sm text-muted-foreground" dangerouslySetInnerHTML={{ __html: sanitizeI18nHtml(t.raw('not_available')) }} />
</div>
) : (
<div className="text-sm text-muted-foreground py-4" dangerouslySetInnerHTML={{ __html: sanitizeI18nHtml(t.raw('not_available')) }} />
)}
</SettingsSection>
);
}
@@ -706,6 +867,8 @@ export function AccountSecuritySettings() {
<>
<div className="border-t border-border" />
<EmailClientSection />
<div className="border-t border-border" />
<LinkDeviceSection />
</>
)}
+71 -3
View File
@@ -1,11 +1,13 @@
"use client";
import { useState, useRef, useCallback } from 'react';
import { useState, useRef, useCallback, useMemo } from 'react';
import { useTranslations } from 'next-intl';
import { Check, GripVertical, Plus, Star, AlertCircle } from 'lucide-react';
import { Check, GripVertical, Plus, Star, AlertCircle, ChevronRight } from 'lucide-react';
import { useAuthStore } from '@/stores/auth-store';
import { useEmailStore } from '@/stores/email-store';
import { useAccountStore, type AccountEntry } from '@/stores/account-store';
import { useManagedAccountStore } from '@/stores/managed-account-store';
import type { SharedAccount } from '@/lib/jmap/types';
import { SettingsSection, SettingItem } from './settings-section';
import { Avatar } from '@/components/ui/avatar';
import { Button } from '@/components/ui/button';
@@ -17,12 +19,30 @@ function hostnameOf(serverUrl: string): string {
try { return new URL(serverUrl).hostname; } catch { return serverUrl; }
}
// First scoped settings tab to land on for a shared account, by capability.
// Mirrors the scoped-tab gating in the settings page. null = nothing editable.
function firstScopedTab(caps: SharedAccount['capabilities']): string | null {
if (caps.sieve) return 'filters';
if (caps.mail) return 'vacation';
if (caps.calendars) return 'calendar';
if (caps.contacts) return 'contacts';
return null;
}
export function AccountSettings() {
const t = useTranslations('settings.account');
const router = useRouter();
const { username, serverUrl, isDemoMode, primaryIdentity, authMode } = useAuthStore();
const { username, serverUrl, isDemoMode, primaryIdentity, authMode, client } = useAuthStore();
const activeAccountId = useAuthStore((s) => s.activeAccountId);
const switchAccount = useAuthStore((s) => s.switchAccount);
const setManagedAccount = useManagedAccountStore((s) => s.setManagedAccount);
// Shared/group accounts delegated to this session (excludes the user's own
// primary account). These can be drilled into for scoped settings editing.
const sharedAccounts = useMemo<SharedAccount[]>(
() => (client?.getSharedAccounts() ?? []).filter((a) => !a.isPrimary),
[client],
);
const { quota } = useEmailStore();
const accounts = useAccountStore((s) => s.accounts);
const setDefaultAccount = useAccountStore((s) => s.setDefaultAccount);
@@ -82,6 +102,16 @@ export function AccountSettings() {
router.push(`/login?mode=add-account` as never);
}, [router]);
// Enter scoped settings mode for a shared/group account: set the managed
// account, then steer the settings panel to its first editable tab via the
// existing 'settings-tab-change' event the page already listens for.
const handleManageShared = useCallback((account: SharedAccount) => {
const tab = firstScopedTab(account.capabilities);
if (!tab) return;
setManagedAccount(account);
window.dispatchEvent(new CustomEvent('settings-tab-change', { detail: tab }));
}, [setManagedAccount]);
return (
<div className="space-y-8">
<SettingsSection title={t('title')} description={t('description')}>
@@ -197,6 +227,44 @@ export function AccountSettings() {
</div>
</SettingsSection>
)}
{/* Shared / group accounts delegated to this session. Clicking one drills
into a scoped settings view (filters, vacation, calendars, contacts). */}
{sharedAccounts.length > 0 && (
<SettingsSection title={t('shared_accounts.title')} description={t('shared_accounts.description')}>
<div className="space-y-2">
{sharedAccounts.map((acc) => {
const editable = firstScopedTab(acc.capabilities) !== null;
return (
<button
key={acc.id}
type="button"
onClick={() => handleManageShared(acc)}
disabled={!editable}
className={cn(
'flex items-center gap-3 w-full p-3 border border-border rounded-lg text-left transition-colors',
editable ? 'hover:bg-muted/50 cursor-pointer' : 'opacity-60 cursor-not-allowed',
)}
>
<Avatar
name={acc.name}
size="sm"
className="w-9 h-9 text-sm flex-shrink-0"
disableFavicon
/>
<div className="min-w-0 flex-1">
<p className="text-sm font-medium truncate">{acc.name}</p>
<p className="text-xs text-muted-foreground truncate">
{t('shared_accounts.shared_label')}
</p>
</div>
<ChevronRight className="w-4 h-4 text-muted-foreground flex-shrink-0" />
</button>
);
})}
</div>
</SettingsSection>
)}
</div>
);
}
@@ -5,6 +5,7 @@ import { useTranslations } from "next-intl";
import { Book, Pencil, Share2, Tag, Users } from "lucide-react";
import { useContactStore } from "@/stores/contact-store";
import { useAuthStore } from "@/stores/auth-store";
import { useManagedAccountStore } from "@/stores/managed-account-store";
import { toast } from "@/stores/toast-store";
import { SettingsSection } from "./settings-section";
import { cn } from "@/lib/utils";
@@ -71,6 +72,7 @@ export function AddressBookManagementSettings() {
const tContacts = useTranslations("contacts");
const tSettings = useTranslations("settings.contacts");
const { client } = useAuthStore();
const managedAccountId = useManagedAccountStore((s) => s.managedAccountId);
const { addressBooks, contacts, supportsSync, fetchAddressBooks, renameAddressBook, shareAddressBook, renameKeyword } = useContactStore();
const [editingId, setEditingId] = useState<string | null>(null);
const [editingKeyword, setEditingKeyword] = useState<string | null>(null);
@@ -205,9 +207,13 @@ export function AddressBookManagementSettings() {
<>
<SettingsSection title={tSettings("manage_title")} description={tSettings("manage_description")}>
<div className="space-y-2">
{personal.map(renderBook)}
{/* In scoped mode (managing a shared account) hide the user's own
address books and show only the managed account's group. */}
{(managedAccountId ? [] : personal).map(renderBook)}
{Array.from(sharedGroups.entries()).map(([accountId, group]) => (
{Array.from(sharedGroups.entries())
.filter(([accountId]) => !managedAccountId || accountId === managedAccountId)
.map(([accountId, group]) => (
<div key={accountId} className="mt-4 space-y-2">
<h4 className="text-xs font-medium text-muted-foreground uppercase tracking-wider flex items-center gap-1.5">
<Share2 className="w-3 h-3" />
@@ -223,6 +229,9 @@ export function AddressBookManagementSettings() {
</div>
</SettingsSection>
{/* Contact categories come from the active account's contacts, not the
shared account, so hide them while scoped to a shared account. */}
{!managedAccountId && (
<div className="mt-8">
<SettingsSection title={tSettings("categories_title")} description={tSettings("categories_description")}>
<div className="space-y-2">
@@ -267,6 +276,7 @@ export function AddressBookManagementSettings() {
</div>
</SettingsSection>
</div>
)}
{sharingId && client && (() => {
const book = addressBooks.find((b) => b.id === sharingId);
@@ -14,6 +14,7 @@ import { cn, formatDateTime, redactUrlCredentials } from '@/lib/utils';
import { ICalImportModal } from '@/components/calendar/ical-import-modal';
import { ICalSubscriptionModal } from '@/components/calendar/ical-subscription-modal';
import { useSettingsStore } from '@/stores/settings-store';
import { useManagedAccountStore } from '@/stores/managed-account-store';
import { apiFetch } from '@/lib/browser-navigation';
import { CALENDAR_COLORS, sharedCalendarColorKey } from '@/lib/shared-calendar-colors';
@@ -137,6 +138,7 @@ export { CalendarColorPicker, CALENDAR_COLORS };
export function CalendarManagementSettings() {
const t = useTranslations('calendar.management');
const { client, serverUrl, username } = useAuthStore();
const managedAccountId = useManagedAccountStore((s) => s.managedAccountId);
const { calendars, updateCalendar, shareCalendar, createCalendar, removeCalendar, clearCalendarEvents, fetchCalendars, icalSubscriptions: allSubs, removeICalSubscription, refreshICalSubscription, isSubscriptionCalendar } = useCalendarStore();
// Subscriptions are persisted globally but scoped per JMAP account via
// accountId. Legacy entries with no accountId show in the active account.
@@ -388,7 +390,10 @@ export function CalendarManagementSettings() {
return (
<SettingsSection title={t('title')} description={t('description')}>
<div className="space-y-2">
{calendars.filter(cal => !isSubscriptionCalendar(cal.id)).map((cal) => {
{calendars
.filter(cal => !isSubscriptionCalendar(cal.id))
.filter(cal => !managedAccountId || cal.accountId === managedAccountId)
.map((cal) => {
const color = (cal.isShared && sharedCalendarColors[sharedCalendarColorKey(cal)]) || cal.color || '#3b82f6';
if (editingId === cal.id) {
@@ -22,6 +22,7 @@ export function ComposingSettings() {
const {
autoSelectReplyIdentity,
plainTextMode,
attachmentReminderEnabled,
attachmentReminderKeywords,
sendDelaySeconds,
@@ -44,6 +45,13 @@ export function ComposingSettings() {
/>
</SettingItem>
<SettingItem label={t('plain_text_mode.label')} description={t('plain_text_mode.description')}>
<ToggleSwitch
checked={plainTextMode}
onChange={(checked) => updateSetting('plainTextMode', checked)}
/>
</SettingItem>
<SettingItem label={t('send_delay.label')} description={t('send_delay.description')}>
<div className="flex flex-col items-end gap-1">
<Select
+44 -5
View File
@@ -12,7 +12,9 @@ import { useEmailStore } from "@/stores/email-store";
import { useSettingsStore } from "@/stores/settings-store";
import { toast } from "@/stores/toast-store";
import type { FilterRule } from "@/lib/jmap/sieve-types";
import type { Mailbox } from "@/lib/jmap/types";
import { useVacationStore } from "@/stores/vacation-store";
import { useManagedAccountStore } from "@/stores/managed-account-store";
import {
Plus,
GripVertical,
@@ -157,7 +159,7 @@ export function FilterSettings() {
const t = useTranslations("settings.filters");
const tNotifications = useTranslations("notifications");
const { client } = useAuthStore();
const mailboxes = useEmailStore((s) => s.mailboxes);
const storeMailboxes = useEmailStore((s) => s.mailboxes);
const expandedFilterView = useSettingsStore((s) => s.expandedFilterView);
const updateSetting = useSettingsStore((s) => s.updateSetting);
@@ -170,7 +172,7 @@ export function FilterSettings() {
isOpaque,
rawScript,
vacationSettings,
fetchFilters,
selectAccount,
saveFilters,
addRule,
updateRule,
@@ -182,7 +184,41 @@ export function FilterSettings() {
validateScript,
} = useFilterStore();
const vacationEnabled = useVacationStore((s) => s.isEnabled) || vacationSettings?.isEnabled;
// Scoped to a shared/group account when the settings panel is managing one.
const managedAccountId = useManagedAccountStore((s) => s.managedAccountId);
const isPrimaryAccount = !managedAccountId;
// Folders offered as "move to" targets in the rule editor must belong to the
// account whose filters we're editing. For a shared account, fetch that
// account's mailboxes (the email store only holds the active account's). They
// are this account's own folders within its Sieve context, so present them as
// non-shared — otherwise the rule modal filters them out (it drops isShared
// mailboxes, which are normally other accounts' folders merged into the view).
const [scopedMailboxes, setScopedMailboxes] = useState<Mailbox[]>([]);
useEffect(() => {
if (!client || !managedAccountId) {
setScopedMailboxes([]);
return;
}
let cancelled = false;
void client
.getMailboxes(managedAccountId)
.then((mbs) => {
if (!cancelled) setScopedMailboxes(mbs.map((mb) => ({ ...mb, isShared: false })));
})
.catch(() => {
if (!cancelled) setScopedMailboxes([]);
});
return () => { cancelled = true; };
}, [client, managedAccountId]);
const mailboxes = managedAccountId ? scopedMailboxes : storeMailboxes;
const vacationStoreEnabled = useVacationStore((s) => s.isEnabled);
// Vacation uses a separate per-(primary)-account mechanism (RFC 9661), so the
// "vacation active" banner only applies when editing the personal account.
const vacationEnabled =
isPrimaryAccount && (vacationStoreEnabled || vacationSettings?.isEnabled);
const [editingRule, setEditingRule] = useState<FilterRule | undefined>();
const [showRuleModal, setShowRuleModal] = useState(false);
@@ -194,9 +230,12 @@ export function FilterSettings() {
useEffect(() => {
if (client && isSupported) {
void fetchFilters(client);
// Always pass a concrete account id (managed shared account, or the
// primary Sieve account) so switching never falls back to a stale
// previously-selected account.
void selectAccount(client, managedAccountId ?? client.getSieveAccountId());
}
}, [client, isSupported, fetchFilters]);
}, [client, isSupported, managedAccountId, selectAccount]);
const handleToggle = useCallback(
async (ruleId: string) => {
+69 -2
View File
@@ -2,6 +2,7 @@
import { useMemo } from 'react';
import { useTranslations } from 'next-intl';
import { Check, Folder } from 'lucide-react';
import { useSettingsStore, type ToolbarPosition, type MailLayout } from '@/stores/settings-store';
import { SettingsSection, SettingItem, RadioGroup, ToggleSwitch } from './settings-section';
import { cn } from '@/lib/utils';
@@ -117,11 +118,27 @@ function MailLayoutPreview({
export function LayoutSettings() {
const t = useTranslations('settings.appearance');
const tEmail = useTranslations('settings.email_behavior');
const { toolbarPosition, showToolbarLabels, hideAccountSwitcher, showRailAccountList, enableUnifiedMailbox, includeGroupInUnified, colorfulSidebarIcons, mailLayout, proInterface, updateSetting } = useSettingsStore();
const { isSettingLocked, isSettingHidden } = usePolicyStore();
const { toolbarPosition, showToolbarLabels, hideAccountSwitcher, showRailAccountList, enableUnifiedMailbox, includeGroupInUnified, enableAllMailView, allMailFolderIds, colorfulSidebarIcons, mailLayout, proInterface, updateSetting } = useSettingsStore();
const { isSettingLocked, isSettingHidden, isFeatureEnabled } = usePolicyStore();
const accounts = useAccountStore(s => s.accounts);
const mailboxes = useEmailStore(s => s.mailboxes);
const hasGroupInboxes = useMemo(() => mailboxes.some(m => m.isShared), [mailboxes]);
const allMailViewAllowed = isFeatureEnabled('allMailViewEnabled');
// Own (non-shared) folders and the current All Mail selection. `null` =
// never configured, which defaults to all non-special (no-role) folders.
const ownMailboxes = useMemo(() => mailboxes.filter(m => !m.isShared), [mailboxes]);
const allMailSelected = new Set(
allMailFolderIds === null
? ownMailboxes.filter(m => !m.role).map(m => m.id)
: allMailFolderIds
);
const toggleAllMailFolder = (id: string) => {
const next = new Set(allMailSelected);
if (next.has(id)) next.delete(id);
else next.add(id);
updateSetting('allMailFolderIds', ownMailboxes.filter(m => next.has(m.id)).map(m => m.id));
};
return (
<SettingsSection title={t('title')} description={t('description')}>
@@ -209,6 +226,56 @@ export function LayoutSettings() {
</div>
)}
{allMailViewAllowed && !isSettingHidden('enableAllMailView') && (
<SettingItem
label={t('all_mail.label')}
description={t('all_mail.description')}
locked={isSettingLocked('enableAllMailView')}
>
<ToggleSwitch
checked={enableAllMailView}
onChange={(v) => updateSetting('enableAllMailView', v)}
/>
</SettingItem>
)}
{allMailViewAllowed && enableAllMailView && (
<div className="ml-4 border-l-2 border-border pl-4 -mt-2 space-y-2">
<div>
<div className="text-sm font-medium text-foreground">{t('all_mail.folders_label')}</div>
<div className="text-xs text-muted-foreground">{t('all_mail.folders_description')}</div>
</div>
{ownMailboxes.length === 0 ? (
<p className="text-xs text-muted-foreground">{t('all_mail.no_folders')}</p>
) : (
<div className="space-y-0.5">
{ownMailboxes.map((mb) => {
const checked = allMailSelected.has(mb.id);
return (
<button
key={mb.id}
type="button"
onClick={() => toggleAllMailFolder(mb.id)}
className="w-full flex items-center gap-2.5 py-1.5 px-2 rounded-md hover:bg-muted/50 text-left"
role="checkbox"
aria-checked={checked}
>
<span className={cn(
"flex items-center justify-center w-4 h-4 rounded border flex-shrink-0 transition-colors",
checked ? "bg-primary border-primary text-primary-foreground" : "border-border"
)}>
{checked && <Check className="w-3 h-3" />}
</span>
<Folder className={cn("w-4 h-4 flex-shrink-0", mb.role ? "text-primary" : "text-muted-foreground")} />
<span className="text-sm text-foreground truncate">{mb.name}</span>
</button>
);
})}
</div>
)}
</div>
)}
<SettingItem label={t('pro_interface.label')} description={t('pro_interface.description')}>
<ToggleSwitch
checked={proInterface}
-8
View File
@@ -25,7 +25,6 @@ export function ReadingSettings() {
showPreview,
mailLayout,
disableThreading,
plainTextMode,
emailsPerPage,
mailAttachmentAction,
attachmentPosition,
@@ -194,13 +193,6 @@ export function ReadingSettings() {
/>
</SettingItem>
<SettingItem label={t('plain_text_mode.label')} description={t('plain_text_mode.description')}>
<ToggleSwitch
checked={plainTextMode}
onChange={(checked) => updateSetting('plainTextMode', checked)}
/>
</SettingItem>
<SettingItem label={t('hide_inline_image_attachments.label')} description={t('hide_inline_image_attachments.description')}>
<ToggleSwitch
checked={hideInlineImageAttachments}
+45 -13
View File
@@ -6,11 +6,11 @@ import { Button } from "@/components/ui/button";
import { Avatar } from "@/components/ui/avatar";
import { X, Loader2, UserPlus, Trash2, Users, ChevronDown } from "lucide-react";
import type { IJMAPClient } from "@/lib/jmap/client-interface";
import type { Principal, CalendarRights, AddressBookRights } from "@/lib/jmap/types";
import type { Principal, CalendarRights, AddressBookRights, FileNodeRights } from "@/lib/jmap/types";
import { toast } from "@/stores/toast-store";
type ShareKind = "calendar" | "addressBook";
type AnyRights = CalendarRights | AddressBookRights;
type ShareKind = "calendar" | "addressBook" | "file";
type AnyRights = CalendarRights | AddressBookRights | FileNodeRights;
type RolePreset = "freeBusy" | "read" | "readWrite" | "manager" | "custom";
@@ -39,6 +39,21 @@ const ADDRESS_BOOK_PRESETS: Record<Exclude<RolePreset, "custom" | "freeBusy">, A
manager: { mayRead: true, mayWrite: true, mayShare: true, mayDelete: true },
};
const FILE_PRESETS: Record<Exclude<RolePreset, "custom" | "freeBusy">, FileNodeRights> = {
read: {
mayRead: true, mayAddChildren: false, mayRename: false,
mayDelete: false, mayModifyContent: false, mayShare: false,
},
readWrite: {
mayRead: true, mayAddChildren: true, mayRename: true,
mayDelete: true, mayModifyContent: true, mayShare: false,
},
manager: {
mayRead: true, mayAddChildren: true, mayRename: true,
mayDelete: true, mayModifyContent: true, mayShare: true,
},
};
function detectCalendarPreset(r: CalendarRights): RolePreset {
for (const [name, preset] of Object.entries(CALENDAR_PRESETS) as [Exclude<RolePreset, "custom">, CalendarRights][]) {
if ((Object.keys(preset) as (keyof CalendarRights)[]).every((k) => preset[k] === r[k])) {
@@ -58,6 +73,29 @@ function detectAddressBookPreset(r: AddressBookRights): RolePreset {
return "custom";
}
function detectFilePreset(r: FileNodeRights): RolePreset {
for (const [name, preset] of Object.entries(FILE_PRESETS) as [Exclude<RolePreset, "custom" | "freeBusy">, FileNodeRights][]) {
const keys = Object.keys(preset) as (keyof FileNodeRights)[];
if (keys.every((k) => preset[k] === (r[k] ?? false))) {
return name;
}
}
return "custom";
}
function presetRights(kind: ShareKind, preset: RolePreset): AnyRights | undefined {
if (preset === "custom") return undefined;
if (kind === "calendar") return CALENDAR_PRESETS[preset as keyof typeof CALENDAR_PRESETS];
if (kind === "file") return FILE_PRESETS[preset as keyof typeof FILE_PRESETS];
return ADDRESS_BOOK_PRESETS[preset as keyof typeof ADDRESS_BOOK_PRESETS];
}
function detectPreset(kind: ShareKind, rights: AnyRights): RolePreset {
if (kind === "calendar") return detectCalendarPreset(rights as CalendarRights);
if (kind === "file") return detectFilePreset(rights as FileNodeRights);
return detectAddressBookPreset(rights as AddressBookRights);
}
interface ShareCollectionDialogProps {
client: IJMAPClient;
kind: ShareKind;
@@ -126,9 +164,7 @@ export function ShareCollectionDialog({
const handleSetRights = async (principalId: string, preset: RolePreset) => {
if (preset === "custom") return; // custom is read-only here
const rights = kind === "calendar"
? CALENDAR_PRESETS[preset as keyof typeof CALENDAR_PRESETS]
: ADDRESS_BOOK_PRESETS[preset as keyof typeof ADDRESS_BOOK_PRESETS];
const rights = presetRights(kind, preset);
if (!rights) return;
setSavingId(principalId);
try {
@@ -154,10 +190,8 @@ export function ShareCollectionDialog({
};
const handleAdd = async (principal: Principal) => {
const defaultPreset: RolePreset = "read";
const rights = kind === "calendar"
? CALENDAR_PRESETS[defaultPreset]
: ADDRESS_BOOK_PRESETS[defaultPreset];
const rights = presetRights(kind, "read");
if (!rights) return;
setSavingId(principal.id);
try {
await onShare(principal.id, rights);
@@ -226,9 +260,7 @@ export function ShareCollectionDialog({
<ul className="divide-y divide-border rounded-md border border-border overflow-hidden">
{sharedEntries.map(([principalId, rights]) => {
const principal = allPrincipalsById.get(principalId);
const preset = kind === "calendar"
? detectCalendarPreset(rights as CalendarRights)
: detectAddressBookPreset(rights as AddressBookRights);
const preset = detectPreset(kind, rights);
return (
<li key={principalId} className="flex items-center gap-3 px-3 py-2.5">
<Avatar
+5 -3
View File
@@ -6,6 +6,7 @@ import { SettingsSection, SettingItem, ToggleSwitch } from './settings-section';
import { Button } from '@/components/ui/button';
import { useVacationStore } from '@/stores/vacation-store';
import { useAuthStore } from '@/stores/auth-store';
import { useManagedAccountStore } from '@/stores/managed-account-store';
import { Loader2, AlertTriangle, Eye, EyeOff } from 'lucide-react';
import { toast } from '@/stores/toast-store';
@@ -19,6 +20,7 @@ export function VacationSettings() {
const t = useTranslations('settings.vacation');
const tNotifications = useTranslations('notifications');
const { client } = useAuthStore();
const managedAccountId = useManagedAccountStore((s) => s.managedAccountId);
const {
isEnabled,
fromDate,
@@ -43,9 +45,9 @@ export function VacationSettings() {
useEffect(() => {
if (client && isSupported) {
void fetchVacationResponse(client);
void fetchVacationResponse(client, managedAccountId ?? undefined);
}
}, [client, isSupported, fetchVacationResponse]);
}, [client, isSupported, managedAccountId, fetchVacationResponse]);
useEffect(() => {
setLocalEnabled(isEnabled);
@@ -101,7 +103,7 @@ export function VacationSettings() {
toDate: localToDate || null,
subject: localSubject,
textBody: localTextBody,
});
}, managedAccountId ?? undefined);
toast.success(tNotifications('vacation_saved'));
} catch (error) {
+4 -1
View File
@@ -32,6 +32,9 @@ export const ContextMenu = forwardRef<HTMLDivElement, ContextMenuProps>(
// Measure the rendered menu and clamp it inside the viewport before the
// browser paints. We hide the element until this runs so the user never
// sees the menu jump from an unclamped position to a clamped one.
// `mounted` must be a dependency: on the very first open the menu isn't
// in the DOM yet (mounted is still false), so this effect has to re-run
// after the mount flip or the menu stays visibility:hidden.
useLayoutEffect(() => {
if (!isOpen) {
setAdjustedPosition(null);
@@ -57,7 +60,7 @@ export const ContextMenu = forwardRef<HTMLDivElement, ContextMenuProps>(
y = Math.max(VIEWPORT_MARGIN, y);
setAdjustedPosition({ x, y });
}, [isOpen, position.x, position.y]);
}, [isOpen, mounted, position.x, position.y]);
const setRefs = (node: HTMLDivElement | null) => {
localRef.current = node;
+3
View File
@@ -26,6 +26,9 @@ export default getRequestConfig(async ({ requestLocale }) => {
case 'fr':
messages = (await import('../locales/fr/common.json')).default;
break;
case 'hu':
messages = (await import('../locales/hu/common.json')).default;
break;
case 'it':
messages = (await import('../locales/it/common.json')).default;
break;
+30
View File
@@ -72,3 +72,33 @@ describe('withBasePath — asset-URL fallbacks under a subpath', () => {
expect(getPathPrefix()).toBe('/webmail');
});
});
describe('toRouterPath — router.push paths under a subpath', () => {
// With a build-time basePath, Next's router prepends the prefix itself, so
// browser-derived paths (redirect_after_login stores
// window.location.pathname) must be stripped or the redirect lands on
// /webmail/webmail/en. See #390.
it('strips the static base path from a stored browser path', async () => {
const { toRouterPath } = await loadNav('/webmail');
expect(toRouterPath('/webmail/en')).toBe('/en');
expect(toRouterPath('/webmail/en/calendar?view=day')).toBe('/en/calendar?view=day');
expect(toRouterPath('/webmail')).toBe('/');
expect(toRouterPath('/webmail?compose=1')).toBe('/?compose=1');
});
it('leaves already-stripped and unrelated paths alone', async () => {
const { toRouterPath } = await loadNav('/webmail');
expect(toRouterPath('/en')).toBe('/en');
expect(toRouterPath('/')).toBe('/');
// Shares the prefix text but is a different first segment.
expect(toRouterPath('/webmail2/en')).toBe('/webmail2/en');
});
it('passes paths through unchanged when no base path is built in', async () => {
// Legacy runtime-detected proxy mounts: Next knows nothing about the
// prefix, so router.push needs the full prefixed path.
const { toRouterPath } = await loadNav(undefined);
expect(toRouterPath('/webmail/en')).toBe('/webmail/en');
expect(toRouterPath('/en')).toBe('/en');
});
});
@@ -4,6 +4,11 @@ import {
rewriteCidImagesForEditor,
replaceInlineImagePlaceholders,
INLINE_IMAGE_PLACEHOLDER,
splitRecipients,
formatRecipient,
parseRecipient,
parseRecipientList,
formatRecipientList,
} from "../email-composer-utils";
describe("plainTextToComposerBody", () => {
@@ -112,3 +117,84 @@ describe("replaceInlineImagePlaceholders", () => {
expect(out).toBe(html);
});
});
describe("splitRecipients", () => {
it("splits a plain comma-separated list", () => {
expect(splitRecipients("alice@x.com, bob@x.com")).toEqual([
"alice@x.com",
"bob@x.com",
]);
});
it("trims whitespace and drops empty segments", () => {
expect(splitRecipients(" alice@x.com ,, bob@x.com ,")).toEqual([
"alice@x.com",
"bob@x.com",
]);
});
it("keeps a quoted display name containing a comma intact", () => {
expect(splitRecipients('"Doo, John" <john@doo.org>, alice@x.com')).toEqual([
'"Doo, John" <john@doo.org>',
"alice@x.com",
]);
});
it("does not split on a comma inside angle brackets", () => {
expect(splitRecipients("Group <a,b@x.com>, c@x.com")).toEqual([
"Group <a,b@x.com>",
"c@x.com",
]);
});
it("returns an empty array for an empty string", () => {
expect(splitRecipients("")).toEqual([]);
});
});
describe("formatRecipient / parseRecipient", () => {
it("returns a bare email when there is no name", () => {
expect(formatRecipient(undefined, "a@x.com")).toBe("a@x.com");
});
it("returns a bare email when the name equals the email", () => {
expect(formatRecipient("a@x.com", "a@x.com")).toBe("a@x.com");
});
it("formats a simple name without quoting", () => {
expect(formatRecipient("Alice", "a@x.com")).toBe("Alice <a@x.com>");
});
it("quotes a name containing a comma", () => {
expect(formatRecipient("Doo, John", "john@doo.org")).toBe(
'"Doo, John" <john@doo.org>'
);
});
it("parses a bare email", () => {
expect(parseRecipient("a@x.com")).toEqual({ email: "a@x.com" });
});
it("parses and unquotes a quoted comma name", () => {
expect(parseRecipient('"Doo, John" <john@doo.org>')).toEqual({
name: "Doo, John",
email: "john@doo.org",
});
});
});
describe("parseRecipientList / formatRecipientList", () => {
it("round-trips a comma-name recipient through serialize + parse", () => {
const list = [
{ name: "Doo, John", email: "john@doo.org" },
{ email: "alice@x.com" },
];
const serialized = formatRecipientList(list);
expect(serialized).toBe('"Doo, John" <john@doo.org>, alice@x.com');
expect(parseRecipientList(serialized)).toEqual(list);
});
it("parses an empty string to an empty array", () => {
expect(parseRecipientList("")).toEqual([]);
});
});
+57
View File
@@ -0,0 +1,57 @@
import { describe, expect, it } from 'vitest';
import { htmlToPlainText } from '../html-to-text';
describe('htmlToPlainText', () => {
it('preserves line breaks from <br> within a paragraph', () => {
expect(htmlToPlainText('<p>Line 1.<br>Line 2.</p>')).toBe('Line 1.\nLine 2.');
});
it('separates block elements with newlines instead of running them together (#421)', () => {
// The previous textContent-based implementation produced "Line 1.Line 2.Paragraph 2."
expect(htmlToPlainText('<div>Line 1.</div><div>Line 2.</div><div>Paragraph 2.</div>')).toBe(
'Line 1.\nLine 2.\nParagraph 2.'
);
});
it('with paragraphSpacing, separates paragraphs with a blank line', () => {
expect(
htmlToPlainText('<p>Line 1.<br>Line 2.</p><p>Paragraph 2.</p>', { paragraphSpacing: true })
).toBe('Line 1.\nLine 2.\n\nParagraph 2.');
});
it('without paragraphSpacing, separates paragraphs with a single newline', () => {
expect(htmlToPlainText('<p>A</p><p>B</p>')).toBe('A\nB');
});
it('renders links as text when the text equals the href', () => {
expect(
htmlToPlainText('<p><a href="mailto:alice@example.com">alice@example.com</a></p>')
).toBe('alice@example.com');
});
it('renders links as "text <href>" when they differ', () => {
expect(htmlToPlainText('<p>Visit <a href="https://example.com">our site</a></p>')).toBe(
'Visit our site <https://example.com>'
);
});
it('collapses excess whitespace and trims the result', () => {
expect(htmlToPlainText(' <p> Hello world </p> ')).toBe('Hello world');
});
it('caps consecutive blank lines at one', () => {
expect(htmlToPlainText('<p>A</p><br><br><br><p>B</p>', { paragraphSpacing: true })).toBe(
'A\n\nB'
);
});
it('returns an empty string for empty or whitespace-only HTML', () => {
expect(htmlToPlainText('')).toBe('');
expect(htmlToPlainText(' <p> </p> ')).toBe('');
});
it('handles nested lists as separate lines', () => {
expect(htmlToPlainText('<ul><li>One</li><li>Two</li></ul>')).toBe('One\nTwo');
});
});
+92
View File
@@ -0,0 +1,92 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { JMAPClient } from '../jmap/client';
import type { FileNodeRights } from '../jmap/types';
function createClient(): JMAPClient {
const client = new JMAPClient('https://jmap.example.com', 'user', 'pass');
Object.assign(client, {
apiUrl: 'https://jmap.example.com/api',
accountId: 'account-1',
capabilities: { 'urn:ietf:params:jmap:filenode': {}, 'urn:ietf:params:jmap:principals': {} },
});
return client;
}
function mockFetch(response: object, ok = true, status = 200) {
return vi.spyOn(globalThis, 'fetch').mockResolvedValue({
ok,
status,
text: () => Promise.resolve(JSON.stringify(response)),
json: () => Promise.resolve(response),
} as Response);
}
const READ: FileNodeRights = {
mayRead: true, mayAddChildren: false, mayRename: false,
mayDelete: false, mayModifyContent: false, mayShare: false,
};
function lastRequestBody(spy: ReturnType<typeof vi.spyOn>): { using: string[]; methodCalls: unknown[][] } {
const call = spy.mock.calls[spy.mock.calls.length - 1];
return JSON.parse((call[1] as RequestInit).body as string);
}
describe('JMAPClient.setFileNodeShare', () => {
beforeEach(() => {
vi.restoreAllMocks();
});
it('sends a FileNode/set shareWith patch and resolves on success', async () => {
const spy = mockFetch({
methodResponses: [['FileNode/set', { updated: { 'node-1': null } }, '0']],
});
const client = createClient();
await client.setFileNodeShare('node-1', 'principal-9', READ);
const body = lastRequestBody(spy);
expect(body.using).toContain('urn:ietf:params:jmap:filenode');
expect(body.using).toContain('urn:ietf:params:jmap:principals:owner');
const [method, args] = body.methodCalls[0] as [string, Record<string, unknown>];
expect(method).toBe('FileNode/set');
expect(args.accountId).toBe('account-1');
expect(args.update).toEqual({
'node-1': { 'shareWith/principal-9': READ },
});
});
it('sends null to revoke a principal\'s access', async () => {
const spy = mockFetch({
methodResponses: [['FileNode/set', { updated: { 'node-1': null } }, '0']],
});
const client = createClient();
await client.setFileNodeShare('node-1', 'principal-9', null);
const body = lastRequestBody(spy);
const [, args] = body.methodCalls[0] as [string, Record<string, unknown>];
expect(args.update).toEqual({ 'node-1': { 'shareWith/principal-9': null } });
});
it('throws with the server description when the update is rejected', async () => {
mockFetch({
methodResponses: [['FileNode/set', {
notUpdated: { 'node-1': { type: 'forbidden', description: 'Not allowed' } },
}, '0']],
});
const client = createClient();
await expect(client.setFileNodeShare('node-1', 'principal-9', READ))
.rejects.toThrow('Not allowed');
});
it('throws when the server does not confirm the update', async () => {
mockFetch({
methodResponses: [['FileNode/set', { updated: {} }, '0']],
});
const client = createClient();
await expect(client.setFileNodeShare('node-1', 'principal-9', READ))
.rejects.toThrow('did not confirm');
});
});
@@ -323,6 +323,89 @@ describe('expandRecurringEvents', () => {
});
});
// -----------------------------------------------------------------------
// excludedRecurrenceRules (EXRULE)
// -----------------------------------------------------------------------
describe('excludedRecurrenceRules', () => {
it('removes occurrences generated by excluded rules', () => {
const event = makeEvent({
recurrenceRules: [{ '@type': 'RecurrenceRule', frequency: 'daily' } as any],
excludedRecurrenceRules: [{
'@type': 'RecurrenceRule',
frequency: 'weekly',
byDay: [{ day: 'tu' }],
} as any],
});
// Jan 6-12 2025: Mon-Sun, Tuesday Jan 7 excluded
const result = expand(event, '2025-01-06T00:00:00', '2025-01-13T00:00:00');
const days = result.map(e => e.start.substring(0, 10));
expect(days).not.toContain('2025-01-07');
expect(days).toContain('2025-01-06');
expect(days).toContain('2025-01-08');
expect(result).toHaveLength(6);
});
});
// -----------------------------------------------------------------------
// Long-running series
// -----------------------------------------------------------------------
describe('long-running series', () => {
it('expands a daily series started years before the visible range', () => {
const event = makeEvent({
start: '2022-01-03T09:00:00',
recurrenceRules: [{ '@type': 'RecurrenceRule', frequency: 'daily' } as any],
});
const result = expand(event, '2025-06-02T00:00:00', '2025-06-05T00:00:00');
expect(starts(result)).toEqual([
'2025-06-02T09:00:00',
'2025-06-03T09:00:00',
'2025-06-04T09:00:00',
]);
});
it('expands a weekly series started years before the visible range', () => {
const event = makeEvent({
start: '2020-01-06T09:00:00', // Monday
recurrenceRules: [{ '@type': 'RecurrenceRule', frequency: 'weekly' } as any],
});
const result = expand(event, '2025-06-01T00:00:00', '2025-06-30T00:00:00');
const days = result.map(e => e.start.substring(0, 10));
expect(days).toEqual(['2025-06-02', '2025-06-09', '2025-06-16', '2025-06-23']);
});
it('still respects count for old series (no fast-forward shortcut)', () => {
const event = makeEvent({
start: '2025-01-06T09:00:00',
recurrenceRules: [{ '@type': 'RecurrenceRule', frequency: 'daily', count: 5 } as any],
});
// Range far after the 5 occurrences ran out
const result = expand(event, '2025-06-01T00:00:00', '2025-06-30T00:00:00');
expect(result).toHaveLength(0);
});
});
// -----------------------------------------------------------------------
// Event-timezone DST handling
// -----------------------------------------------------------------------
describe('event timezone DST handling', () => {
it('keeps wall time across a DST transition in the event timezone', () => {
// America/New_York: EST (UTC-5) until 2025-03-09, EDT (UTC-4) after
const event = makeEvent({
start: '2025-03-03T10:00:00',
timeZone: 'America/New_York',
utcStart: '2025-03-03T15:00:00Z',
utcEnd: '2025-03-03T16:00:00Z',
recurrenceRules: [{ '@type': 'RecurrenceRule', frequency: 'weekly' } as any],
} as Partial<CalendarEvent>);
const result = expand(event, '2025-03-03T00:00:00', '2025-03-17T00:00:00');
const utcStarts = result.map(e => (e as any).utcStart);
expect(utcStarts[0]).toBe('2025-03-03T15:00:00.000Z'); // EST: 10:00 -5
expect(utcStarts[1]).toBe('2025-03-10T14:00:00.000Z'); // EDT: 10:00 -4
const utcEnds = result.map(e => (e as any).utcEnd);
expect(utcEnds[1]).toBe('2025-03-10T15:00:00.000Z');
});
});
// -----------------------------------------------------------------------
// All-day events
// -----------------------------------------------------------------------
+2
View File
@@ -59,6 +59,7 @@ export interface FeatureGates {
hoverActionsConfigEnabled: boolean;
filesEnabled: boolean;
contactsEnabled: boolean;
allMailViewEnabled: boolean;
}
export const DEFAULT_FEATURE_GATES: FeatureGates = {
@@ -79,6 +80,7 @@ export const DEFAULT_FEATURE_GATES: FeatureGates = {
hoverActionsConfigEnabled: true,
filesEnabled: true,
contactsEnabled: true,
allMailViewEnabled: false,
};
export interface ThemePolicy {
+38
View File
@@ -0,0 +1,38 @@
import { cookies } from 'next/headers';
import { encryptPayload, decryptPayload } from '@/lib/auth/crypto';
import { getCookieOptions } from '@/lib/oauth/cookie-config';
// Step-up re-authentication proof for device pairing. Generating a pairing QR
// provisions a new long-lived login on another device, so we require the user
// to have re-authenticated at the IdP within a short window first (see the
// reauth SSO flow). This proof is a short-lived, encrypted, httpOnly cookie —
// it is set only after a fresh IdP login and checked by /api/auth/pair/create.
const PAIR_REAUTH_COOKIE = 'pair_reauth';
const PAIR_REAUTH_TTL_MS = 5 * 60 * 1000; // 5 minutes — long enough to render the QR
export async function setPairReauth(): Promise<void> {
const cookieStore = await cookies();
const value = encryptPayload({ purpose: 'pair', created_at: Date.now() });
cookieStore.set(PAIR_REAUTH_COOKIE, value, {
...getCookieOptions(),
maxAge: Math.floor(PAIR_REAUTH_TTL_MS / 1000),
});
}
export async function hasValidPairReauth(): Promise<boolean> {
const cookieStore = await cookies();
const raw = cookieStore.get(PAIR_REAUTH_COOKIE)?.value;
if (!raw) return false;
const data = decryptPayload(raw);
if (!data || data.purpose !== 'pair') return false;
const createdAt = typeof data.created_at === 'number' ? data.created_at : 0;
// Belt-and-suspenders alongside the cookie maxAge: a forged/old proof whose
// timestamp is outside the window is rejected even if the cookie survived.
return Date.now() - createdAt <= PAIR_REAUTH_TTL_MS;
}
export async function clearPairReauth(): Promise<void> {
const cookieStore = await cookies();
cookieStore.delete(PAIR_REAUTH_COOKIE);
}
+62
View File
@@ -0,0 +1,62 @@
import { randomBytes } from 'crypto';
// Cross-device login pairing. A signed-in desktop session mints a short-lived,
// single-use pairing code (see /api/auth/pair/create) which is rendered as a
// QR. The mobile app scans it and redeems the code (see /api/auth/pair/redeem)
// for the OAuth token bundle, so the phone is signed in without re-typing
// anything. The code itself carries no secrets — the tokens never leave the
// server until the matching code is redeemed exactly once.
//
// Storage is an in-process Map. That is sufficient for the single-instance
// (pm2) deployments this webmail targets; a multi-instance deployment would
// need to swap this for a shared store (Redis) keyed the same way. Records are
// tiny and expire within PAIRING_TTL_MS, so memory pressure is negligible.
export interface PairingTokens {
accessToken: string;
refreshToken?: string;
expiresIn?: number;
tokenEndpoint: string;
clientId: string;
serverUrl: string;
serverId: string | null;
}
interface PairingRecord extends PairingTokens {
expiresAt: number; // epoch ms
}
const PAIRING_TTL_MS = 2 * 60 * 1000; // 2 minutes — enough time to scan
const CODE_BYTES = 32; // 256 bits of entropy
const store = new Map<string, PairingRecord>();
// Drop any expired records. Called on every create/consume so the Map can't
// grow without bound even if codes are minted and never redeemed.
function sweep(now: number): void {
for (const [code, record] of store) {
if (record.expiresAt <= now) store.delete(code);
}
}
export function createPairing(tokens: PairingTokens): { code: string; expiresIn: number } {
const now = Date.now();
sweep(now);
const code = randomBytes(CODE_BYTES).toString('hex');
store.set(code, { ...tokens, expiresAt: now + PAIRING_TTL_MS });
return { code, expiresIn: Math.floor(PAIRING_TTL_MS / 1000) };
}
// Single-use: a successful lookup removes the record so a code can never be
// redeemed twice. Returns null for unknown, expired, or already-redeemed codes
// — the caller must not distinguish these to avoid leaking code validity.
export function consumePairing(code: string): PairingTokens | null {
const now = Date.now();
sweep(now);
const record = store.get(code);
if (!record) return null;
store.delete(code);
if (record.expiresAt <= now) return null;
const { expiresAt: _expiresAt, ...tokens } = record;
return tokens;
}
+21
View File
@@ -96,6 +96,27 @@ export function withBasePath(url: string | null | undefined): string {
}
/**
* Converts a browser-style path (as found in `window.location.pathname`,
* which always includes the mount prefix) into a path safe to hand to Next's
* client router (`router.push` / `router.replace`).
*
* When the app is built with NEXT_PUBLIC_BASE_PATH, Next's router prepends
* the basePath itself, so a stored prefixed path would get it twice (#390)
* strip it here. Legacy runtime-detected proxy mounts pass through unchanged:
* Next knows nothing about that prefix, so the router needs the full path.
*
* Accepts paths with query/hash suffixes (`/webmail/en/calendar?view=day`).
*/
export function toRouterPath(path: string): string {
if (!STATIC_BASE_PATH || !path.startsWith(STATIC_BASE_PATH)) return path;
const rest = path.slice(STATIC_BASE_PATH.length);
if (rest === '') return '/';
if (rest[0] === '/') return rest;
if (rest[0] === '?' || rest[0] === '#') return '/' + rest;
return path; // different first segment that merely shares the prefix text
}
/**
* Extracts the locale from the current URL, skipping any mount prefix.
* Falls back to 'en' when no known locale segment is found.
+48 -11
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, ScheduledEmail, SendEmailResult } from '@/lib/jmap/types';
import type { Email, Mailbox, StateChange, AccountStates, Thread, Identity, EmailAddress, ContactCard, AddressBook, VacationResponse, Calendar, CalendarEvent, CalendarEventFilter, CalendarTask, FileNode, ScheduledEmail, SendEmailResult, SharedAccount } 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';
@@ -118,7 +118,7 @@ export class DemoJMAPClient implements IJMAPClient {
// ── Mailboxes ─────────────────────────────────────────────────
async getMailboxes(): Promise<Mailbox[]> { return [...this.data.mailboxes]; }
async getMailboxes(_accountId?: string): Promise<Mailbox[]> { return [...this.data.mailboxes]; }
async getAllMailboxes(): Promise<Mailbox[]> { return [...this.data.mailboxes]; }
async createMailbox(name: string, parentId?: string): Promise<Mailbox> {
@@ -388,6 +388,16 @@ export class DemoJMAPClient implements IJMAPClient {
return { id: threadId, emailIds: emails.map(e => e.id) };
}
async getThreads(threadIds: string[]): Promise<Thread[]> {
return threadIds
.map(tid => {
const emails = this.data.emails.filter(e => e.threadId === tid);
if (emails.length === 0) return null;
return { id: tid, emailIds: emails.map(e => e.id) };
})
.filter((t): t is Thread => t !== null);
}
async getThreadEmails(threadId: string): Promise<Email[]> {
return this.data.emails
.filter(e => e.threadId === threadId)
@@ -603,9 +613,9 @@ export class DemoJMAPClient implements IJMAPClient {
// ── Vacation ──────────────────────────────────────────────────
async getVacationResponse(): Promise<VacationResponse> { return { ...this.data.vacationResponse }; }
async getVacationResponse(_accountId?: string): Promise<VacationResponse> { return { ...this.data.vacationResponse }; }
async setVacationResponse(updates: Partial<VacationResponse>): Promise<void> {
async setVacationResponse(updates: Partial<VacationResponse>, _accountId?: string): Promise<void> {
Object.assign(this.data.vacationResponse, updates);
}
@@ -701,6 +711,12 @@ export class DemoJMAPClient implements IJMAPClient {
if (cal) Object.assign(cal, updates);
}
async setDefaultCalendar(calendarId: string): Promise<void> {
for (const cal of this.data.calendars) {
cal.isDefault = cal.id === calendarId;
}
}
async deleteCalendar(calendarId: string): Promise<void> {
this.data.calendars = this.data.calendars.filter(c => c.id !== calendarId);
this.data.calendarEvents = this.data.calendarEvents.filter(e => !e.calendarIds[calendarId]);
@@ -843,19 +859,34 @@ export class DemoJMAPClient implements IJMAPClient {
// ── Sieve / Filters ──────────────────────────────────────────
getSharedAccounts(): SharedAccount[] {
return [{
id: 'demo-account',
name: 'Demo',
isPrimary: true,
capabilities: { mail: true, sieve: true, calendars: true, contacts: true, filenode: true },
}];
}
getSieveAccountId(): string { return 'demo-account'; }
getSieveCapabilities(): SieveCapabilities | null {
getSieveAccounts(): { id: string; name: string; isPrimary: boolean }[] {
return this.getSharedAccounts()
.filter((a) => a.isPrimary || a.capabilities.sieve)
.map(({ id, name, isPrimary }) => ({ id, name, isPrimary }));
}
getSieveCapabilities(_accountId?: string): SieveCapabilities | null {
return { ...this.data.sieveCapabilities };
}
async getSieveScripts(): Promise<SieveScript[]> { return [...this.data.sieveScripts]; }
async getSieveScripts(_accountId?: string): Promise<SieveScript[]> { return [...this.data.sieveScripts]; }
async getSieveScriptContent(blobId: string): Promise<string> {
async getSieveScriptContent(blobId: string, _accountId?: string): Promise<string> {
return this.data.sieveContent[blobId] ?? '';
}
async createSieveScript(name: string, content: string, activate?: boolean): Promise<SieveScript> {
async createSieveScript(name: string, content: string, activate?: boolean, _accountId?: string): Promise<SieveScript> {
const blobId = generateDemoId('sieve-blob');
const script: SieveScript = { id: generateDemoId('sieve'), name, blobId, isActive: activate ?? false };
this.data.sieveScripts.push(script);
@@ -868,7 +899,7 @@ export class DemoJMAPClient implements IJMAPClient {
return script;
}
async updateSieveScript(scriptId: string, content: string, activate?: boolean): Promise<void> {
async updateSieveScript(scriptId: string, content: string, activate?: boolean, _accountId?: string): Promise<void> {
const script = this.data.sieveScripts.find(s => s.id === scriptId);
if (!script) return;
const blobId = generateDemoId('sieve-blob');
@@ -884,11 +915,11 @@ export class DemoJMAPClient implements IJMAPClient {
}
}
async deleteSieveScript(scriptId: string): Promise<void> {
async deleteSieveScript(scriptId: string, _accountId?: string): Promise<void> {
this.data.sieveScripts = this.data.sieveScripts.filter(s => s.id !== scriptId);
}
async validateSieveScript(): Promise<{ isValid: boolean; errors?: string[] }> {
async validateSieveScript(_content?: string, _accountId?: string): Promise<{ isValid: boolean; errors?: string[] }> {
return { isValid: true };
}
@@ -906,6 +937,12 @@ export class DemoJMAPClient implements IJMAPClient {
return [...this.data.fileNodes];
}
async listAllFileNodesAcrossAccounts(): Promise<FileNode[]> {
return [...this.data.fileNodes];
}
async setFileNodeShare(): Promise<void> { /* demo: no-op */ }
async getFileNodes(ids: string[] | null): Promise<FileNode[]> {
if (ids === null) return [...this.data.fileNodes];
return this.data.fileNodes.filter(n => ids.includes(n.id));
+88
View File
@@ -52,6 +52,94 @@ export function rewriteCidImagesForEditor(html: string): string {
return touched ? doc.body.innerHTML : html;
}
/** A composer recipient. Display name is optional; email is required. */
export type Recipient = { name?: string; email: string };
/**
* Splits a comma-separated recipient string into individual entries. Commas
* inside a quoted display name (`"Doo, John" <john@doo.org>`) or angle brackets
* (`<a,b@x>`) are treated as literal, not separators. Only used at the
* (de)serialization boundary the live composer state is an array, so the UI
* never round-trips through this. Trims each part and drops empties.
*/
export function splitRecipients(value: string): string[] {
const result: string[] = [];
let current = '';
let inQuotes = false;
let inAngle = false;
for (const ch of value) {
if (ch === '"') {
inQuotes = !inQuotes;
current += ch;
} else if (ch === '<' && !inQuotes) {
inAngle = true;
current += ch;
} else if (ch === '>' && !inQuotes) {
inAngle = false;
current += ch;
} else if (ch === ',' && !inQuotes && !inAngle) {
const trimmed = current.trim();
if (trimmed) result.push(trimmed);
current = '';
} else {
current += ch;
}
}
const trimmed = current.trim();
if (trimmed) result.push(trimmed);
return result;
}
// Display names containing any of these must be wrapped in a quoted-string so
// they survive comma-splitting at the serialization boundary and round-trip.
const NAME_NEEDS_QUOTING = /[,<>"@;:]/;
/**
* Formats a recipient as a string. Bare email when there's no distinct name;
* otherwise `Name <email>`, RFC 5322 quoting the name when it contains a comma
* or other special character.
*/
export function formatRecipient(name: string | undefined, email: string): string {
const trimmedName = name?.trim();
if (!trimmedName || trimmedName === email) return email;
const quoted = NAME_NEEDS_QUOTING.test(trimmedName)
? `"${trimmedName.replace(/(["\\])/g, '\\$1')}"`
: trimmedName;
return `${quoted} <${email}>`;
}
/** Strips a surrounding quoted-string (and its escapes) from a display name. */
function unquoteName(name: string): string {
const trimmed = name.trim();
if (trimmed.length >= 2 && trimmed.startsWith('"') && trimmed.endsWith('"')) {
return trimmed.slice(1, -1).replace(/\\(["\\])/g, '$1');
}
return trimmed;
}
/**
* Parses a single recipient string (`Name <email>`, `"Quoted, Name" <email>`,
* or bare `email`) into a {@link Recipient}. The display name is unquoted.
*/
export function parseRecipient(s: string): Recipient {
const trimmed = s.trim();
const angleMatch = trimmed.match(/^(.+?)\s*<([^>]+)>$/);
if (angleMatch) {
return { name: unquoteName(angleMatch[1]), email: angleMatch[2].trim() };
}
return { email: trimmed };
}
/** Parses a serialized comma-separated recipient string into an array. */
export function parseRecipientList(value: string): Recipient[] {
return splitRecipients(value).map(parseRecipient);
}
/** Serializes a recipient array into a comma-separated string. */
export function formatRecipientList(recipients: Recipient[]): string {
return recipients.map((r) => formatRecipient(r.name, r.email)).join(', ');
}
/**
* Replaces the placeholder src on `<img data-cid="...">` elements with the
* resolved data URL once the inline blob has been fetched. Leaves images
+164
View File
@@ -0,0 +1,164 @@
import { parseHtmlSafely } from '@/lib/email-sanitization';
// Block-level tags whose boundaries become line breaks in the plain-text
// rendering. Without this, the DOM's textContent would run every block
// together on a single line (#421).
const BLOCK_TAGS = new Set([
'address',
'article',
'aside',
'blockquote',
'div',
'dd',
'dl',
'dt',
'footer',
'h1',
'h2',
'h3',
'h4',
'h5',
'h6',
'header',
'li',
'nav',
'ol',
'p',
'pre',
'section',
'table',
'tr',
'ul',
]);
// Paragraph-level blocks that, with `paragraphSpacing` enabled, are separated by
// a blank line rather than a single newline - matching how they render visually.
const PARAGRAPH_TAGS = new Set([
'blockquote',
'h1',
'h2',
'h3',
'h4',
'h5',
'h6',
'ol',
'p',
'pre',
'table',
'ul',
]);
export interface HtmlToPlainTextOptions {
/**
* Separate paragraph-level blocks (<p>, headings, lists, ...) with a blank
* line instead of a single newline. Use for email bodies where paragraphs
* are visually spaced; leave off for compact output like signatures.
*/
paragraphSpacing?: boolean;
}
function normalizeLineBreaks(value: string): string {
return value
.replace(/\r\n?/g, '\n')
.replace(new RegExp(String.fromCharCode(160), 'g'), ' ')
.replace(/[ \t]+\n/g, '\n')
.replace(/\n{3,}/g, '\n\n')
.trim();
}
/**
* Converts HTML into readable plain text, preserving line structure from block
* elements and <br> tags. Links render as `text <href>` unless the text already
* is the href. Used to derive the text/plain alternative of an HTML email.
*/
export function htmlToPlainText(html: string, options: HtmlToPlainTextOptions = {}): string {
const { paragraphSpacing = false } = options;
const document = parseHtmlSafely(html);
const chunks: string[] = [];
const appendText = (value: string) => {
if (!value) return;
const normalized = value.replace(/\s+/g, ' ');
if (!normalized.trim()) return;
const previous = chunks[chunks.length - 1];
if (previous && !previous.endsWith('\n') && !previous.endsWith(' ')) {
chunks.push(' ');
}
chunks.push(normalized);
};
// Number of newline characters already trailing the accumulated output.
const trailingNewlines = (): number => {
let count = 0;
for (let i = chunks.length - 1; i >= 0; i--) {
const chunk = chunks[i];
let j = chunk.length - 1;
let inner = 0;
while (j >= 0 && chunk[j] === '\n') {
inner++;
j--;
}
count += inner;
if (j >= 0) break; // chunk had non-newline content, stop counting
}
return count;
};
// Ensures at least `min` trailing newlines, without emitting leading ones.
const ensureNewlines = (min: number) => {
if (chunks.length === 0) return;
for (let current = trailingNewlines(); current < min; current++) {
chunks.push('\n');
}
};
const walk = (node: Node) => {
if (node.nodeType === Node.TEXT_NODE) {
appendText(node.textContent || '');
return;
}
if (node.nodeType !== Node.ELEMENT_NODE) {
return;
}
const element = node as HTMLElement;
const tagName = element.tagName.toLowerCase();
if (tagName === 'br') {
// Additive so consecutive <br> can form a blank line (capped by normalize).
if (chunks.length > 0) chunks.push('\n');
return;
}
if (tagName === 'a') {
const text = element.textContent?.replace(/\s+/g, ' ').trim() || '';
const href = element.getAttribute('href')?.trim() || '';
const normalizedHref = href.replace(/^mailto:/i, '');
if (text && normalizedHref && text === normalizedHref) {
appendText(text);
return;
}
if (text && href && text !== href) {
appendText(`${text} <${href}>`);
return;
}
}
const separator = paragraphSpacing && PARAGRAPH_TAGS.has(tagName) ? 2 : 1;
const isBlock = BLOCK_TAGS.has(tagName);
if (isBlock) {
ensureNewlines(separator);
}
Array.from(element.childNodes).forEach(walk);
if (isBlock) {
ensureNewlines(separator);
}
};
Array.from(document.body.childNodes).forEach(walk);
return normalizeLineBreaks(chunks.join(''));
}
+19 -11
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, ScheduledEmail, SendEmailResult } from "./types";
import type { Email, Mailbox, StateChange, AccountStates, Thread, Identity, EmailAddress, ContactCard, AddressBook, AddressBookRights, VacationResponse, Calendar, CalendarRights, CalendarEvent, CalendarEventFilter, CalendarTask, FileNode, FileNodeRights, Principal, PushSubscription, ScheduledEmail, SendEmailResult, SharedAccount } from "./types";
import type { SieveScript, SieveCapabilities } from "./sieve-types";
/**
@@ -71,7 +71,7 @@ export interface IJMAPClient {
getQuota(): Promise<{ used: number; total: number } | null>;
// ── Mailboxes ─────────────────────────────────────────────────
getMailboxes(): Promise<Mailbox[]>;
getMailboxes(accountId?: string): Promise<Mailbox[]>;
getAllMailboxes(): Promise<Mailbox[]>;
createMailbox(name: string, parentId?: string): Promise<Mailbox>;
updateMailbox(mailboxId: string, changes: { name?: string; parentId?: string | null; role?: string | null; sortOrder?: number }): Promise<void>;
@@ -117,6 +117,7 @@ export interface IJMAPClient {
// ── Threads ───────────────────────────────────────────────────
getThread(threadId: string, accountId?: string): Promise<Thread | null>;
getThreads(threadIds: string[], accountId?: string): Promise<Thread[]>;
getThreadEmails(threadId: string, accountId?: string): Promise<Email[]>;
// ── Compose / Send ────────────────────────────────────────────
@@ -239,8 +240,8 @@ export interface IJMAPClient {
deleteIdentity(identityId: string): Promise<void>;
// ── Vacation ──────────────────────────────────────────────────
getVacationResponse(): Promise<VacationResponse>;
setVacationResponse(updates: Partial<VacationResponse>): Promise<void>;
getVacationResponse(accountId?: string): Promise<VacationResponse>;
setVacationResponse(updates: Partial<VacationResponse>, accountId?: string): Promise<void>;
// ── Contacts ──────────────────────────────────────────────────
getContactsAccountId(): string;
@@ -263,6 +264,7 @@ export interface IJMAPClient {
getAllCalendars(): Promise<Calendar[]>;
createCalendar(calendar: Partial<Calendar>, targetAccountId?: string): Promise<Calendar>;
updateCalendar(calendarId: string, updates: Partial<Calendar>, targetAccountId?: string): Promise<void>;
setDefaultCalendar(calendarId: string, targetAccountId?: string): Promise<void>;
deleteCalendar(calendarId: string, targetAccountId?: string): Promise<void>;
getCalendarEvents(calendarIds?: string[], targetAccountId?: string): Promise<CalendarEvent[]>;
getCalendarEvent(id: string, targetAccountId?: string): Promise<CalendarEvent | null>;
@@ -291,22 +293,28 @@ export interface IJMAPClient {
getPrincipals(targetAccountId?: string): Promise<Principal[]>;
setCalendarShare(calendarId: string, principalId: string, rights: CalendarRights | null, targetAccountId?: string): Promise<void>;
setAddressBookShare(addressBookId: string, principalId: string, rights: AddressBookRights | null, targetAccountId?: string): Promise<void>;
setFileNodeShare(fileNodeId: string, principalId: string, rights: FileNodeRights | null, targetAccountId?: string): Promise<void>;
// ── Accounts (primary + shared/group) ────────────────────────
getSharedAccounts(): SharedAccount[];
// ── Sieve / Filters ──────────────────────────────────────────
getSieveAccountId(): string;
getSieveCapabilities(): SieveCapabilities | null;
getSieveScripts(): Promise<SieveScript[]>;
getSieveScriptContent(blobId: string): Promise<string>;
createSieveScript(name: string, content: string, activate?: boolean): Promise<SieveScript>;
updateSieveScript(scriptId: string, content: string, activate?: boolean): Promise<void>;
deleteSieveScript(scriptId: string): Promise<void>;
validateSieveScript(content: string): Promise<{ isValid: boolean; errors?: string[] }>;
getSieveAccounts(): { id: string; name: string; isPrimary: boolean }[];
getSieveCapabilities(accountId?: string): SieveCapabilities | null;
getSieveScripts(accountId?: string): Promise<SieveScript[]>;
getSieveScriptContent(blobId: string, accountId?: string): Promise<string>;
createSieveScript(name: string, content: string, activate?: boolean, accountId?: string): Promise<SieveScript>;
updateSieveScript(scriptId: string, content: string, activate?: boolean, accountId?: string): Promise<void>;
deleteSieveScript(scriptId: string, accountId?: string): Promise<void>;
validateSieveScript(content: string, accountId?: string): Promise<{ isValid: boolean; errors?: string[] }>;
// ── Files (WebDAV / FileNode) ─────────────────────────────────
getFilesAccountId(): string;
probeFileNodeSupport(): Promise<boolean>;
listFileNodes(parentId: string | null): Promise<FileNode[]>;
listAllFileNodes(): Promise<FileNode[]>;
listAllFileNodesAcrossAccounts(): Promise<FileNode[]>;
getFileNodes(ids: string[] | null, properties?: string[]): Promise<FileNode[]>;
createFileDirectory(name: string, parentId: string | null): Promise<FileNode>;
createFileNode(name: string, blobId: string, type: string, size: number, parentId: string | null): Promise<FileNode>;
+497 -221
View File
File diff suppressed because it is too large Load Diff
+53
View File
@@ -3,6 +3,25 @@ export interface EmailHeader {
value: string;
}
/**
* A session-visible account (the user's own primary account plus any
* shared/group accounts delegated to them), tagged with the capabilities it
* advertises. Produced by client.getSharedAccounts(); drives the settings
* "Shared with me" list and the scoped-settings tab gating.
*/
export interface SharedAccount {
id: string;
name: string;
isPrimary: boolean;
capabilities: {
mail: boolean;
sieve: boolean;
calendars: boolean;
contacts: boolean;
filenode: boolean;
};
}
export interface Email {
id: string;
threadId: string;
@@ -792,6 +811,32 @@ export interface FileNode {
size: number;
created: string;
updated: string;
// JMAP Sharing (RFC 9670). Populated only when the server advertises the
// filenode capability and the properties are explicitly requested. A node is
// shared-out when `shareWith` has entries; `myRights` describes what the
// viewer may do (always full rights on owned nodes).
myRights?: FileNodeRights;
shareWith?: Record<string, FileNodeRights> | null;
// True when this node was fetched from another principal's account that was
// shared with the logged-in user (mirrors Calendar.isShared / AddressBook.isShared).
isShared?: boolean;
// Owning account's JMAP id and display name, set when aggregating nodes
// across connected/shared accounts so mutations route to the right account.
accountId?: string;
accountName?: string;
// Local account-store id (per JMAP connection) in multi-account contexts.
// See Calendar.localAccountId.
localAccountId?: string;
}
// FileNode rights as defined by Stalwart's JmapSharedObject implementation.
export interface FileNodeRights {
mayRead: boolean;
mayAddChildren: boolean;
mayRename: boolean;
mayDelete: boolean;
mayModifyContent: boolean;
mayShare: boolean;
}
export interface FileNodeFilter {
@@ -826,3 +871,11 @@ export const UNIFIED_ROLE_BY_ID: Record<string, UnifiedMailboxRole> = Object.fro
export function isUnifiedMailboxId(id: string): boolean {
return id in UNIFIED_ROLE_BY_ID;
}
/**
* Virtual mailbox id for the gated "All Mail" view: every folder of a single
* account merged into one date-sorted list. Distinct from the unified mailbox
* ids above, which merge one role across multiple accounts. Which folders are
* included is a per-user setting (see `allMailFolderIds`).
*/
export const ALL_MAIL_MAILBOX_ID = '__all_mail__';
+192 -27
View File
@@ -11,7 +11,7 @@
* in the browser.
*/
import { parseISO, format, addDays, addWeeks, addMonths, addYears } from 'date-fns';
import { parseISO, format, addDays, addWeeks, addMonths, addYears, differenceInCalendarDays } from 'date-fns';
import type { CalendarEvent, CalendarRecurrenceRule, CalendarNDay } from '@/lib/jmap/types';
const DAY_INDEX: Record<string, number> = { su: 0, mo: 1, tu: 2, we: 3, th: 4, fr: 5, sa: 6 };
@@ -64,6 +64,10 @@ export function expandRecurringEvents(
return result;
}
function occurrenceDateKey(master: CalendarEvent, date: Date): string {
return master.showWithoutTime ? format(date, 'yyyy-MM-dd') : date.toISOString();
}
function expandEvent(
master: CalendarEvent,
rangeStart: Date,
@@ -77,13 +81,23 @@ function expandEvent(
const occurrences: CalendarEvent[] = [];
const seenDates = new Set<string>();
// RFC 8984 §4.3.3: occurrences produced by excludedRecurrenceRules
// (iCalendar EXRULE) are removed from the recurrence set.
const excludedDates = new Set<string>();
for (const exRule of master.excludedRecurrenceRules || []) {
// includeStartDate=false: "the series start is always an occurrence"
// applies to recurrence rules, not to exclusion rules.
for (const date of generateDates(eventStart, exRule, rangeStart, rangeEnd, false)) {
excludedDates.add(occurrenceDateKey(master, date));
}
}
for (const rule of rules) {
const dates = generateDates(eventStart, rule, rangeStart, rangeEnd);
for (const date of dates) {
const dateKey = master.showWithoutTime
? format(date, 'yyyy-MM-dd')
: date.toISOString();
const dateKey = occurrenceDateKey(master, date);
if (excludedDates.has(dateKey)) continue;
if (seenDates.has(dateKey)) continue;
seenDates.add(dateKey);
@@ -106,9 +120,9 @@ function expandEvent(
if (isNaN(overrideDate.getTime())) continue;
if (overrideDate < rangeStart || overrideDate >= rangeEnd) continue;
const dateKey = master.showWithoutTime
? format(overrideDate, 'yyyy-MM-dd')
: overrideDate.toISOString();
// Overrides take precedence over excludedRecurrenceRules (they re-add
// a concrete instance), so only dedupe against already-generated dates.
const dateKey = occurrenceDateKey(master, overrideDate);
if (seenDates.has(dateKey)) continue;
seenDates.add(dateKey);
@@ -118,6 +132,65 @@ function expandEvent(
return occurrences;
}
// Cache Intl formatters per IANA timezone id - constructing them is expensive
// and expansion runs over hundreds of occurrences. `null` marks ids the
// runtime rejected so we don't retry them.
const tzFormatterCache = new Map<string, Intl.DateTimeFormat | null>();
function getTzFormatter(timeZone: string): Intl.DateTimeFormat | null {
let formatter = tzFormatterCache.get(timeZone);
if (formatter === undefined) {
try {
formatter = new Intl.DateTimeFormat('en-CA', {
timeZone,
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
hour12: false,
});
} catch {
formatter = null;
}
tzFormatterCache.set(timeZone, formatter);
}
return formatter;
}
/** The wall-clock reading of `instant` in the formatter's zone, re-encoded as a UTC timestamp. */
function wallClockAsUtcTimestamp(formatter: Intl.DateTimeFormat, instant: number): number {
const map: Record<string, string> = {};
for (const part of formatter.formatToParts(new Date(instant))) {
if (part.type !== 'literal') map[part.type] = part.value;
}
const hour = map.hour === '24' ? 0 : Number(map.hour);
return Date.UTC(
Number(map.year), Number(map.month) - 1, Number(map.day),
hour, Number(map.minute), Number(map.second),
);
}
/**
* Interpret the wall-clock fields of `wall` as a local time in `timeZone`
* and return the corresponding UTC instant. Two fixup iterations converge
* for all real offsets, including across DST transitions.
*/
function zonedWallTimeToUtc(wall: Date, timeZone: string): Date | null {
const formatter = getTzFormatter(timeZone);
if (!formatter) return null;
const wallAsUtc = Date.UTC(
wall.getFullYear(), wall.getMonth(), wall.getDate(),
wall.getHours(), wall.getMinutes(), wall.getSeconds(),
);
let guess = wallAsUtc;
for (let i = 0; i < 2; i++) {
guess = wallAsUtc - (wallClockAsUtcTimestamp(formatter, guess) - guess);
}
return new Date(guess);
}
function createOccurrence(
master: CalendarEvent,
date: Date,
@@ -133,17 +206,33 @@ function createOccurrence(
// return the correct dates instead of the master's original UTC times.
let utcStart: string | undefined;
let utcEnd: string | undefined;
if (!master.showWithoutTime && master.utcStart && master.start) {
const masterLocal = parseISO(master.start);
const masterUtc = parseISO(master.utcStart);
const offsetMs = masterUtc.getTime() - masterLocal.getTime();
utcStart = new Date(date.getTime() + offsetMs).toISOString();
if (!master.showWithoutTime) {
let durationMs: number | null = null;
if (master.utcStart && master.utcEnd) {
const ms = parseISO(master.utcEnd).getTime() - parseISO(master.utcStart).getTime();
if (!isNaN(ms)) durationMs = ms;
}
// Shift utcEnd by the same amount as utcStart
if (master.utcEnd) {
const masterUtcEnd = parseISO(master.utcEnd);
const durationMs = masterUtcEnd.getTime() - masterUtc.getTime();
utcEnd = new Date(date.getTime() + offsetMs + durationMs).toISOString();
// Convert the occurrence's wall time using the event's own timezone, so
// occurrences on the other side of a DST transition in that zone keep
// their wall time. Reusing the master's fixed UTC offset (the fallback
// below) would shift them by the DST delta.
const zoned = master.timeZone ? zonedWallTimeToUtc(date, master.timeZone) : null;
if (zoned) {
utcStart = zoned.toISOString();
if (durationMs !== null) {
utcEnd = new Date(zoned.getTime() + durationMs).toISOString();
}
} else if (master.utcStart && master.start) {
// Floating events (or an unrecognized timezone id): keep the master's
// offset, which by definition doesn't vary.
const masterLocal = parseISO(master.start);
const masterUtc = parseISO(master.utcStart);
const offsetMs = masterUtc.getTime() - masterLocal.getTime();
utcStart = new Date(date.getTime() + offsetMs).toISOString();
if (durationMs !== null) {
utcEnd = new Date(date.getTime() + offsetMs + durationMs).toISOString();
}
}
}
@@ -218,6 +307,7 @@ function generateDates(
rawRule: CalendarRecurrenceRule,
rangeStart: Date,
rangeEnd: Date,
includeStartDate = true,
): Date[] {
const rule = addImplicitByX(rawRule, eventStart);
const dates: Date[] = [];
@@ -225,20 +315,34 @@ function generateDates(
const countLimit = rule.count || Infinity;
const until = rule.until ? parseISO(rule.until) : null;
let totalCount = 0;
let current = new Date(eventStart);
// Without a `count` limit we can jump straight to the period containing
// the visible range. With one, every occurrence since the series start
// must be generated so it counts against `count`.
let current = rule.count
? new Date(eventStart)
: fastForwardToRange(eventStart, rule.frequency, interval, rangeStart);
const maxIterations = 2000;
// Cap on *emitted* (in-range) dates. This must not count occurrences
// before rangeStart, otherwise a series started long ago (e.g. a daily
// event from two years back) exhausts the budget before reaching the
// visible range and silently renders nothing.
const maxOccurrences = 500;
let iterations = 0;
while (iterations++ < maxIterations) {
if (totalCount >= countLimit || totalCount >= maxOccurrences) break;
if (totalCount >= countLimit || dates.length >= maxOccurrences) break;
if (until && current > until) break;
// For frequencies that produce one candidate per iteration at a time,
// we can stop when we pass rangeEnd. But for frequencies that expand
// into multiple candidates per period, we need the candidate generation.
if (current >= rangeEnd && rule.frequency !== 'yearly' && rule.frequency !== 'monthly'
&& rule.frequency !== 'weekly') break;
// Stop once no candidate in this or a later period can fall before
// rangeEnd. Monthly/yearly candidates may precede the period anchor
// within the same month/year, so floor the anchor before comparing.
if (rule.frequency === 'monthly') {
if (new Date(current.getFullYear(), current.getMonth(), 1) >= rangeEnd) break;
} else if (rule.frequency === 'yearly') {
if (new Date(current.getFullYear(), 0, 1) >= rangeEnd) break;
} else if (current >= rangeEnd) {
break;
}
// Generate candidates for the current period, then filter
const candidates = generateCandidatesForPeriod(current, rule, eventStart);
@@ -250,7 +354,7 @@ function generateDates(
for (const d of filtered) {
if (until && d > until) break;
if (totalCount >= countLimit || totalCount >= maxOccurrences) break;
if (totalCount >= countLimit || dates.length >= maxOccurrences) break;
// Spec rule 4: eliminate dates before event start
if (d < eventStart) continue;
@@ -262,7 +366,7 @@ function generateDates(
if (d >= rangeEnd) break;
}
if (totalCount >= countLimit || totalCount >= maxOccurrences) break;
if (totalCount >= countLimit || dates.length >= maxOccurrences) break;
current = advancePeriod(current, rule.frequency, interval, rule.firstDayOfWeek || 'mo');
if (current <= eventStart && iterations === 1) {
@@ -272,7 +376,7 @@ function generateDates(
}
// Spec rule 1: the initial start date-time is ALWAYS the first occurrence
if (dates.length > 0 && dates[0].getTime() !== eventStart.getTime()) {
if (includeStartDate && dates.length > 0 && dates[0].getTime() !== eventStart.getTime()) {
if (eventStart >= rangeStart && eventStart < rangeEnd) {
// Check it's not already in the list
if (!dates.some(d => d.getTime() === eventStart.getTime())) {
@@ -535,6 +639,67 @@ function advancePeriod(
}
}
// ---------------------------------------------------------------------------
// Jump to the period just before the visible range in O(1), so long-running
// series don't burn the iteration budget on years of out-of-range periods.
// Only valid for rules without `count` (counted rules must enumerate every
// occurrence from the series start).
// ---------------------------------------------------------------------------
function fastForwardToRange(
eventStart: Date,
frequency: CalendarRecurrenceRule['frequency'],
interval: number,
rangeStart: Date,
): Date {
if (eventStart >= rangeStart) return new Date(eventStart);
let periods: number;
switch (frequency) {
case 'secondly':
periods = Math.floor((rangeStart.getTime() - eventStart.getTime()) / (1000 * interval));
break;
case 'minutely':
periods = Math.floor((rangeStart.getTime() - eventStart.getTime()) / (60000 * interval));
break;
case 'hourly':
periods = Math.floor((rangeStart.getTime() - eventStart.getTime()) / (3600000 * interval));
break;
case 'daily':
periods = Math.floor(differenceInCalendarDays(rangeStart, eventStart) / interval);
break;
case 'weekly':
periods = Math.floor(differenceInCalendarDays(rangeStart, eventStart) / (7 * interval));
break;
case 'monthly': {
const months = (rangeStart.getFullYear() - eventStart.getFullYear()) * 12
+ (rangeStart.getMonth() - eventStart.getMonth());
periods = Math.floor(months / interval);
break;
}
case 'yearly':
periods = Math.floor((rangeStart.getFullYear() - eventStart.getFullYear()) / interval);
break;
default:
return new Date(eventStart);
}
// Land one full period early so candidates inside the boundary period
// (which can precede the period anchor) are still generated.
periods = Math.max(0, periods - 1);
if (periods === 0) return new Date(eventStart);
switch (frequency) {
case 'secondly': return new Date(eventStart.getTime() + periods * interval * 1000);
case 'minutely': return new Date(eventStart.getTime() + periods * interval * 60000);
case 'hourly': return new Date(eventStart.getTime() + periods * interval * 3600000);
case 'daily': return addDays(eventStart, periods * interval);
case 'weekly': return addWeeks(eventStart, periods * interval);
case 'monthly': return addMonths(eventStart, periods * interval);
case 'yearly': return addYears(eventStart, periods * interval);
default: return new Date(eventStart);
}
}
// ---------------------------------------------------------------------------
// Helper: expand byDay within a specific month (monthly or yearly context)
// ---------------------------------------------------------------------------
+1 -84
View File
@@ -1,25 +1,11 @@
import { parseHtmlSafely, sanitizeSignatureHtml } from '@/lib/email-sanitization';
import { htmlToPlainText } from '@/lib/html-to-text';
type SignatureSource = {
textSignature?: string;
htmlSignature?: string;
};
const BLOCK_TAGS = new Set([
'address',
'article',
'aside',
'blockquote',
'div',
'footer',
'header',
'li',
'nav',
'p',
'section',
'tr',
]);
function normalizeSignatureLineBreaks(value: string): string {
return value
.replace(/\r\n?/g, '\n')
@@ -29,75 +15,6 @@ function normalizeSignatureLineBreaks(value: string): string {
.trim();
}
function htmlToPlainText(html: string): string {
const document = parseHtmlSafely(html);
const chunks: string[] = [];
const appendText = (value: string) => {
if (!value) return;
const normalized = value.replace(/\s+/g, ' ');
if (!normalized.trim()) return;
const previous = chunks[chunks.length - 1];
if (previous && !previous.endsWith('\n') && !previous.endsWith(' ')) {
chunks.push(' ');
}
chunks.push(normalized);
};
const appendNewline = () => {
const previous = chunks[chunks.length - 1];
if (previous === '\n') return;
if (previous?.endsWith('\n')) return;
chunks.push('\n');
};
const walk = (node: Node) => {
if (node.nodeType === Node.TEXT_NODE) {
appendText(node.textContent || '');
return;
}
if (node.nodeType !== Node.ELEMENT_NODE) {
return;
}
const element = node as HTMLElement;
const tagName = element.tagName.toLowerCase();
if (tagName === 'br') {
appendNewline();
return;
}
if (tagName === 'a') {
const text = element.textContent?.replace(/\s+/g, ' ').trim() || '';
const href = element.getAttribute('href')?.trim() || '';
const normalizedHref = href.replace(/^mailto:/i, '');
if (text && normalizedHref && text === normalizedHref) {
appendText(text);
return;
}
if (text && href && text !== href) {
appendText(`${text} <${href}>`);
return;
}
}
if (BLOCK_TAGS.has(tagName) && chunks.length > 0) {
appendNewline();
}
Array.from(element.childNodes).forEach(walk);
if (BLOCK_TAGS.has(tagName)) {
appendNewline();
}
};
Array.from(document.body.childNodes).forEach(walk);
return normalizeSignatureLineBreaks(chunks.join(''));
}
export function getPlainTextSignature(signature?: SignatureSource | null): string {
if (signature?.textSignature?.trim()) {
return normalizeSignatureLineBreaks(signature.textSignature);
+11 -11
View File
@@ -17,6 +17,7 @@ function idPath(): string { return path.join(getDir(), '.telemetry-id'); }
function envOverride(): ConsentState | null {
const v = (process.env.BULWARK_TELEMETRY ?? '').toLowerCase();
if (v === 'off' || v === 'false' || v === '0' || v === 'no') return 'off';
if (v === 'on' || v === 'true' || v === '1' || v === 'yes') return 'on';
if (process.env.BULWARK_TELEMETRY_DISABLED) {
const d = process.env.BULWARK_TELEMETRY_DISABLED.toLowerCase();
if (d === '1' || d === 'true' || d === 'yes') return 'off';
@@ -41,11 +42,13 @@ export async function getInstanceId(): Promise<string> {
return fresh;
}
// Default consent is 'on' - telemetry is anonymous and enabled by default.
// Admins can disable via the UI, the BULWARK_TELEMETRY env var, or by clearing
// the endpoint. See https://bulwarkmail.org/docs/legal/privacy/telemetry.
// Default consent is 'off' - telemetry is opt-in. Admins can enable it during
// install (BULWARK_TELEMETRY=on in .env.local), via the BULWARK_TELEMETRY env
// var, or with one click in the admin UI. Heartbeats are anonymous: no PII,
// just version/platform/feature toggles. Enabling helps us improve the product.
// See https://bulwarkmail.org/docs/legal/privacy/telemetry.
const DEFAULTS: TelemetryStateFile = {
consent: 'on',
consent: 'off',
endpoint: DEFAULT_ENDPOINT,
consentedAt: null,
lastSentAt: null,
@@ -64,13 +67,10 @@ export async function loadState(): Promise<TelemetryStateFile> {
error: err instanceof Error ? err.message : String(err),
});
}
// First-ever load on a fresh install: persist the default-on state with
// an autoEnabledAt stamp so the admin UI can show "telemetry was
// auto-enabled at <time>; disable here" without re-arming on restart.
const fresh: TelemetryStateFile = {
...DEFAULTS,
consentedAt: new Date().toISOString(),
};
// First-ever load on a fresh install: persist the default-off state so the
// instance id and (lack of) consent are stable across restarts. The admin
// can opt in later via the UI or the BULWARK_TELEMETRY env var.
const fresh: TelemetryStateFile = { ...DEFAULTS };
await saveState(fresh);
return fresh;
}
+10 -2
View File
@@ -5,8 +5,16 @@ import type { Email, ThreadGroup } from "./jmap/types";
* Single-email threads are still returned as ThreadGroups with emailCount=1.
* When disableThreading is true, each email is placed into its own group using
* its message ID as the key, so the list shows individual messages.
*
* @param threadEmailCounts - Optional map of threadId total email count across
* all folders (from Thread/get). When provided, emailCount reflects the full
* thread size rather than just the emails in the current folder.
*/
export function groupEmailsByThread(emails: Email[], disableThreading = false): ThreadGroup[] {
export function groupEmailsByThread(
emails: Email[],
disableThreading = false,
threadEmailCounts?: Map<string, number>,
): ThreadGroup[] {
if (!emails || emails.length === 0) {
return [];
}
@@ -53,7 +61,7 @@ export function groupEmailsByThread(emails: Email[], disableThreading = false):
hasAttachment,
hasAnswered,
hasForwarded,
emailCount: sortedEmails.length,
emailCount: threadEmailCounts?.get(threadId) ?? sortedEmails.length,
});
}
+90 -4
View File
@@ -544,7 +544,25 @@
"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í"
"undo_send": "Vrátit odeslání",
"contact_sidebar": {
"title": "Kontakt",
"close": "Zavřít postranní panel",
"action_email": "E-mail",
"action_email_title": "Odeslat e-mail",
"action_copy": "Kopírovat",
"action_copy_title": "Kopírovat e-mail",
"action_edit_title": "Upravit kontakt",
"section_emails": "E-maily",
"section_phones": "Telefony",
"section_organizations": "Organizace",
"section_addresses": "Adresy",
"section_notes": "Poznámky",
"not_in_contacts": "Není ve vašich kontaktech",
"add_to_contacts": "Přidat do kontaktů",
"copied": "Zkopírováno!",
"copy_failed": "Kopírování se nezdařilo"
}
},
"email_composer": {
"read_receipt_on": "Vyžádáno potvrzení o přečtení (kliknutím vypnete)",
@@ -649,7 +667,11 @@
"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?"
"send_delay_unsupported_confirm": "Tento účet nepodporuje prodlevu odeslání. Odeslat ihned?",
"recipient_edit_email": "Upravit e-mailovou adresu",
"recipient_edit_name": "Upravit zobrazované jméno",
"recipient_email_placeholder": "E-mailová adresa",
"recipient_name_placeholder": "Zobrazované jméno"
},
"confirm_dialog": {
"confirm": "Potvrdit",
@@ -892,6 +914,13 @@
"description": "Zahrnout do sjednoceného zobrazení také sdílené/skupinové schránky."
}
},
"all_mail": {
"label": "All Mail",
"description": "Show an \"All Mail\" entry above your folders that merges messages from across this account's folders into one list.",
"folders_label": "Folders in All Mail",
"folders_description": "Choose which folders are merged into the All Mail view.",
"no_folders": "No folders available."
},
"colorful_sidebar_icons": {
"label": "Barevné ikony postranního panelu",
"description": "Obarví ikony složek a štítků podle typu (modré Doručené, červený Spam, zelené Odeslané atd.). Vypněte pro jednobarevný postranní panel."
@@ -1282,6 +1311,11 @@
"move_down": "Posunout dolů",
"drag_handle": "Přetažením změňte pořadí",
"add": "Přidat účet"
},
"shared_accounts": {
"title": "Sdíleno se mnou",
"description": "Skupinové a sdílené účty, které můžete spravovat. Vyberte jeden pro úpravu jeho filtrů, odpovědi v nepřítomnosti, kalendářů a kontaktů.",
"shared_label": "Sdílený účet"
}
},
"security": {
@@ -1380,6 +1414,17 @@
"copy": "Kopírovat",
"copied": "Zkopírováno",
"password_instructions": "Použijte výše uvedené uživatelské jméno JMAP společně s heslem aplikace pro přihlášení v poštovním klientovi. Pokud jste tak ještě neučinili, vytvořte si heslo aplikace v sekci výše."
},
"link_device": {
"title": "Propojit mobilní aplikaci",
"description": "Přihlaste se do mobilní aplikace Bulwark Mail bez psaní. Vygenerujte zde QR kód a naskenujte jej na přihlašovací obrazovce aplikace.",
"generate": "Zobrazit QR kód",
"regenerate": "Zobrazit nový kód",
"instructions": "Otevřete aplikaci Bulwark Mail, na přihlašovací obrazovce klepněte na \"Naskenovat QR kód\" a namiřte fotoaparát sem.",
"expires_in": "Tento kód vyprší za {seconds} sekund. Lze jej použít pouze jednou.",
"expired": "Platnost tohoto kódu vypršela.",
"generating": "Generování…",
"error": "Párovací kód se nepodařilo vytvořit. Zkuste to prosím znovu."
}
},
"identities": {
@@ -1837,7 +1882,11 @@
},
"search_placeholder": "Hledat nastavení",
"search_clear": "Vymazat hledání",
"search_no_results": "Žádná odpovídající nastavení"
"search_no_results": "Žádná odpovídající nastavení",
"scoped": {
"back": "Zpět na můj účet",
"managing": "Správa: {name}"
}
},
"errors": {
"page_error_title": "Něco se pokazilo",
@@ -2490,7 +2539,37 @@
"every_n_weeks": "Každých {count} týdnů",
"every_n_months": "Každých {count} měsíců",
"until": "Do",
"occurrences": "{count} opakování"
"occurrences": "{count} opakování",
"custom": "Vlastní…",
"edit_custom": "Upravit vlastní opakování",
"every_n_years": "Každých {count} let",
"on_days": "v {days}",
"on_day_n": "v den {day}",
"on_the_nth": "{nth} {day}",
"in_month": "v měsíci {month}",
"nth_1": "první",
"nth_2": "druhý",
"nth_3": "třetí",
"nth_4": "čtvrtý",
"nth_last": "poslední",
"editor_freq_day": "Den",
"editor_freq_week": "Týden",
"editor_freq_month": "Měsíc",
"editor_freq_year": "Rok",
"editor_repeats_on": "Opakuje se",
"editor_every": "Každých",
"editor_unit_days": "dní",
"editor_unit_weeks": "týdnů",
"editor_unit_months": "měsíců",
"editor_unit_years": "let",
"editor_on_day": "v den",
"editor_on_the": "v",
"editor_in": "v měsíci",
"editor_ends": "Končí",
"editor_never": "Nikdy",
"editor_ends_on": "Dne",
"editor_ends_after": "Po",
"editor_occurrences": "opakováních"
},
"recurrence_scope": {
"edit_title": "Upravit opakující se událost",
@@ -2652,6 +2731,9 @@
"default": "Výchozí",
"confirm_delete": "Odstranit \"{name}\"? Všechny události v tomto kalendáři budou odstraněny.",
"confirm_clear": "Vymazat všechny události z \"{name}\"? Tuto akci nelze vrátit zpět.",
"set_default": "Nastavit jako výchozí",
"default_updated": "Výchozí kalendář aktualizován",
"error_default": "Nepodařilo se nastavit výchozí kalendář",
"clear_events": "Vymazat události",
"events_cleared": "Vymazáno {count} událostí",
"error_clear": "Vymazání událostí kalendáře selhalo",
@@ -2877,6 +2959,10 @@
"duplicate": "Duplikovat",
"duplicate_success": "Úspěšně duplikováno",
"duplicate_error": "Duplikování selhalo",
"share": "Share",
"shared": "Shared",
"shared_with_me": "Shared with me",
"shared_by": "Shared by {name}",
"create_file_success": "Soubor byl vytvořen",
"create_file_error": "Vytvoření souboru selhalo",
"favorites": "Oblíbené",
+89 -3
View File
@@ -544,7 +544,25 @@
"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"
"undo_send": "Fortryd afsendelse",
"contact_sidebar": {
"title": "Kontakt",
"close": "Luk sidepanel",
"action_email": "E-mail",
"action_email_title": "Send e-mail",
"action_copy": "Kopiér",
"action_copy_title": "Kopiér e-mail",
"action_edit_title": "Rediger kontakt",
"section_emails": "E-mails",
"section_phones": "Telefoner",
"section_organizations": "Organisationer",
"section_addresses": "Adresser",
"section_notes": "Noter",
"not_in_contacts": "Ikke i dine kontakter",
"add_to_contacts": "Tilføj til kontakter",
"copied": "Kopieret!",
"copy_failed": "Kunne ikke kopiere"
}
},
"email_composer": {
"read_receipt_on": "Læsekvittering anmodet (klik for at deaktivere)",
@@ -649,7 +667,11 @@
"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?"
"send_delay_unsupported_confirm": "Denne konto understøtter ikke sendeforsinkelse. Send straks i stedet?",
"recipient_edit_email": "Rediger e-mailadresse",
"recipient_edit_name": "Rediger visningsnavn",
"recipient_email_placeholder": "E-mailadresse",
"recipient_name_placeholder": "Visningsnavn"
},
"confirm_dialog": {
"confirm": "Bekræft",
@@ -895,6 +917,13 @@
"description": "Inkluder også delte/gruppepostkasser i den samlede visning."
}
},
"all_mail": {
"label": "All Mail",
"description": "Show an \"All Mail\" entry above your folders that merges messages from across this account's folders into one list.",
"folders_label": "Folders in All Mail",
"folders_description": "Choose which folders are merged into the All Mail view.",
"no_folders": "No folders available."
},
"colorful_sidebar_icons": {
"label": "Farverige sidepane-ikoner",
"description": "Farvelæg mappe- og tag-ikoner efter type (blå indbakke, rød spam, grøn sendt osv.). Deaktivér for et monokromt sidepanel."
@@ -1285,6 +1314,11 @@
"move_down": "Flyt ned",
"drag_handle": "Træk for at omarrangere",
"add": "Tilføj konto"
},
"shared_accounts": {
"title": "Delt med mig",
"description": "Gruppe- og delte konti, du kan administrere. Vælg en for at redigere dens filtre, autosvar ved fravær, kalendere og kontakter.",
"shared_label": "Delt konto"
}
},
"security": {
@@ -1383,6 +1417,17 @@
"copy": "Kopier",
"copied": "Kopieret",
"password_instructions": "Brug dit JMAP-brugernavn ovenfor sammen med en app-adgangskode for at logge ind i din e-mailklient. Opret en app-adgangskode i sektionen ovenfor, hvis du ikke allerede har gjort det."
},
"link_device": {
"title": "Tilknyt mobilapp",
"description": "Log ind i Bulwark Mail-mobilappen uden at skrive noget. Generér en QR-kode her, og scan den fra appens loginskærm.",
"generate": "Vis QR-kode",
"regenerate": "Vis en ny kode",
"instructions": "Åbn Bulwark Mail-appen, tryk på \"Scan QR-kode\" på loginskærmen, og ret dit kamera herhen.",
"expires_in": "Denne kode udløber om {seconds} sekunder. Den kan kun bruges én gang.",
"expired": "Denne kode er udløbet.",
"generating": "Genererer…",
"error": "Kunne ikke oprette en parringskode. Prøv igen."
}
},
"identities": {
@@ -1837,6 +1882,10 @@
"preview": {
"label": "Forhåndsvisning"
}
},
"scoped": {
"back": "Tilbage til min konto",
"managing": "Administrerer: {name}"
}
},
"errors": {
@@ -2490,7 +2539,37 @@
"every_n_weeks": "Hver {count} uge",
"every_n_months": "Hver {count} måned",
"until": "Indtil",
"occurrences": "{count} forekomster"
"occurrences": "{count} forekomster",
"custom": "Tilpasset…",
"edit_custom": "Rediger tilpasset gentagelse",
"every_n_years": "Hvert {count}. år",
"on_days": "på {days}",
"on_day_n": "på dag {day}",
"on_the_nth": "den {nth} {day}",
"in_month": "i {month}",
"nth_1": "første",
"nth_2": "anden",
"nth_3": "tredje",
"nth_4": "fjerde",
"nth_last": "sidste",
"editor_freq_day": "Dag",
"editor_freq_week": "Uge",
"editor_freq_month": "Måned",
"editor_freq_year": "År",
"editor_repeats_on": "Gentages",
"editor_every": "Hver",
"editor_unit_days": "dag(e)",
"editor_unit_weeks": "uge(r)",
"editor_unit_months": "måned(er)",
"editor_unit_years": "år",
"editor_on_day": "på dag",
"editor_on_the": "den",
"editor_in": "i",
"editor_ends": "Slutter",
"editor_never": "Aldrig",
"editor_ends_on": "Den",
"editor_ends_after": "Efter",
"editor_occurrences": "gentagelser"
},
"recurrence_scope": {
"edit_title": "Redigér tilbagevendende begivenhed",
@@ -2666,6 +2745,9 @@
"default": "Standard",
"confirm_delete": "Slet \"{name}\"? Alle begivenheder i denne kalender fjernes.",
"confirm_clear": "Ryd alle begivenheder fra \"{name}\"? Dette kan ikke fortrydes.",
"set_default": "Angiv som standard",
"default_updated": "Standardkalender opdateret",
"error_default": "Kunne ikke angive standardkalender",
"clear_events": "Ryd begivenheder",
"events_cleared": "{count} begivenheder ryddet",
"error_clear": "Kunne ikke rydde kalenderbegivenheder",
@@ -2900,6 +2982,10 @@
"duplicate": "Duplikér",
"duplicate_success": "Duplikeret",
"duplicate_error": "Kunne ikke duplikere",
"share": "Share",
"shared": "Shared",
"shared_with_me": "Shared with me",
"shared_by": "Shared by {name}",
"create_file_success": "Fil oprettet",
"create_file_error": "Kunne ikke oprette fil",
"favorites": "Favoritter",
+90 -4
View File
@@ -544,7 +544,25 @@
"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"
"undo_send": "Senden rückgängig",
"contact_sidebar": {
"title": "Kontakt",
"close": "Seitenleiste schließen",
"action_email": "E-Mail",
"action_email_title": "E-Mail senden",
"action_copy": "Kopieren",
"action_copy_title": "E-Mail-Adresse kopieren",
"action_edit_title": "Kontakt bearbeiten",
"section_emails": "E-Mail-Adressen",
"section_phones": "Telefonnummern",
"section_organizations": "Organisationen",
"section_addresses": "Adressen",
"section_notes": "Notizen",
"not_in_contacts": "Nicht in Ihren Kontakten",
"add_to_contacts": "Zu Kontakten hinzufügen",
"copied": "Kopiert!",
"copy_failed": "Kopieren fehlgeschlagen"
}
},
"email_composer": {
"read_receipt_on": "Lesebestätigung angefordert (klicken zum Deaktivieren)",
@@ -649,7 +667,11 @@
"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?"
"send_delay_unsupported_confirm": "Dieses Konto unterstützt keine Sendeverzögerung. Stattdessen sofort senden?",
"recipient_edit_email": "E-Mail-Adresse bearbeiten",
"recipient_edit_name": "Anzeigenamen bearbeiten",
"recipient_email_placeholder": "E-Mail-Adresse",
"recipient_name_placeholder": "Anzeigename"
},
"confirm_dialog": {
"confirm": "Bestätigen",
@@ -892,6 +914,13 @@
"description": "Gemeinsam genutzte/Gruppenpostfächer ebenfalls in die vereinheitlichte Ansicht aufnehmen."
}
},
"all_mail": {
"label": "All Mail",
"description": "Show an \"All Mail\" entry above your folders that merges messages from across this account's folders into one list.",
"folders_label": "Folders in All Mail",
"folders_description": "Choose which folders are merged into the All Mail view.",
"no_folders": "No folders available."
},
"colorful_sidebar_icons": {
"label": "Farbige Seitenleistensymbole",
"description": "Ordner- und Tag-Symbole nach Typ einfärben (blauer Posteingang, roter Spam, grüner Gesendet usw.). Für eine monochrome Seitenleiste deaktivieren."
@@ -1282,6 +1311,11 @@
"move_down": "Nach unten",
"drag_handle": "Zum Sortieren ziehen",
"add": "Konto hinzufügen"
},
"shared_accounts": {
"title": "Mit mir geteilt",
"description": "Gruppen- und freigegebene Konten, die Sie verwalten können. Wählen Sie eines aus, um dessen Filter, Abwesenheitsnotiz, Kalender und Kontakte zu bearbeiten.",
"shared_label": "Freigegebenes Konto"
}
},
"security": {
@@ -1380,6 +1414,17 @@
"copy": "Kopieren",
"copied": "Kopiert",
"password_instructions": "Verwenden Sie Ihren oben angezeigten JMAP-Benutzernamen zusammen mit einem App-Passwort, um sich bei Ihrem E-Mail-Client anzumelden. Erstellen Sie im obigen Abschnitt ein App-Passwort, falls Sie noch keines haben."
},
"link_device": {
"title": "Mobile App verknüpfen",
"description": "Melden Sie sich in der Bulwark Mail App an, ohne etwas einzutippen. Erzeugen Sie hier einen QR-Code und scannen Sie ihn auf dem Anmeldebildschirm der App.",
"generate": "QR-Code anzeigen",
"regenerate": "Neuen Code anzeigen",
"instructions": "Öffnen Sie die Bulwark Mail App, tippen Sie auf dem Anmeldebildschirm auf \"QR-Code scannen\" und richten Sie die Kamera hierauf.",
"expires_in": "Dieser Code läuft in {seconds} Sekunden ab. Er kann nur einmal verwendet werden.",
"expired": "Dieser Code ist abgelaufen.",
"generating": "Wird erstellt…",
"error": "Kopplungscode konnte nicht erstellt werden. Bitte versuchen Sie es erneut."
}
},
"identities": {
@@ -1837,7 +1882,11 @@
},
"search_placeholder": "Einstellungen durchsuchen",
"search_clear": "Suche löschen",
"search_no_results": "Keine passenden Einstellungen"
"search_no_results": "Keine passenden Einstellungen",
"scoped": {
"back": "Zurück zu meinem Konto",
"managing": "Verwaltung: {name}"
}
},
"errors": {
"page_error_title": "Etwas ist schiefgelaufen",
@@ -2490,7 +2539,37 @@
"every_n_weeks": "Alle {count} Wochen",
"every_n_months": "Alle {count} Monate",
"until": "Bis",
"occurrences": "{count} Wiederholungen"
"occurrences": "{count} Wiederholungen",
"custom": "Benutzerdefiniert…",
"edit_custom": "Benutzerdefinierte Wiederholung bearbeiten",
"every_n_years": "Alle {count} Jahre",
"on_days": "am {days}",
"on_day_n": "am Tag {day}",
"on_the_nth": "am {nth} {day}",
"in_month": "im {month}",
"nth_1": "ersten",
"nth_2": "zweiten",
"nth_3": "dritten",
"nth_4": "vierten",
"nth_last": "letzten",
"editor_freq_day": "Tag",
"editor_freq_week": "Woche",
"editor_freq_month": "Monat",
"editor_freq_year": "Jahr",
"editor_repeats_on": "Wiederholt sich",
"editor_every": "Alle",
"editor_unit_days": "Tag(e)",
"editor_unit_weeks": "Woche(n)",
"editor_unit_months": "Monat(e)",
"editor_unit_years": "Jahr(e)",
"editor_on_day": "am Tag",
"editor_on_the": "am",
"editor_in": "im",
"editor_ends": "Endet",
"editor_never": "Nie",
"editor_ends_on": "Am",
"editor_ends_after": "Nach",
"editor_occurrences": "Terminen"
},
"recurrence_scope": {
"edit_title": "Wiederkehrendes Ereignis bearbeiten",
@@ -2662,6 +2741,9 @@
"copy_url": "CalDAV-URL kopieren",
"url_copied": "CalDAV-URL in die Zwischenablage kopiert",
"confirm_clear": "Alle Ereignisse aus \"{name}\" löschen? Dies kann nicht rückgängig gemacht werden.",
"set_default": "Als Standard festlegen",
"default_updated": "Standardkalender aktualisiert",
"error_default": "Standardkalender konnte nicht festgelegt werden",
"clear_events": "Ereignisse löschen",
"events_cleared": "{count} Ereignisse gelöscht",
"error_clear": "Kalenderereignisse konnten nicht gelöscht werden",
@@ -2877,6 +2959,10 @@
"duplicate": "Duplizieren",
"duplicate_success": "Erfolgreich dupliziert",
"duplicate_error": "Duplizieren fehlgeschlagen",
"share": "Share",
"shared": "Shared",
"shared_with_me": "Shared with me",
"shared_by": "Shared by {name}",
"create_file_success": "Datei erstellt",
"create_file_error": "Datei konnte nicht erstellt werden",
"favorites": "Favoriten",
+89 -3
View File
@@ -544,7 +544,25 @@
"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"
"undo_send": "Undo send",
"contact_sidebar": {
"title": "Contact",
"close": "Close sidebar",
"action_email": "Email",
"action_email_title": "Send email",
"action_copy": "Copy",
"action_copy_title": "Copy email",
"action_edit_title": "Edit contact",
"section_emails": "Emails",
"section_phones": "Phones",
"section_organizations": "Organizations",
"section_addresses": "Addresses",
"section_notes": "Notes",
"not_in_contacts": "Not in your contacts",
"add_to_contacts": "Add to contacts",
"copied": "Copied!",
"copy_failed": "Failed to copy"
}
},
"email_composer": {
"read_receipt_on": "Read receipt requested (click to disable)",
@@ -649,7 +667,11 @@
"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?"
"send_delay_unsupported_confirm": "This account does not support send delay. Send immediately instead?",
"recipient_edit_email": "Edit email address",
"recipient_edit_name": "Edit display name",
"recipient_email_placeholder": "Email address",
"recipient_name_placeholder": "Display name"
},
"confirm_dialog": {
"confirm": "Confirm",
@@ -895,6 +917,13 @@
"description": "Also merge shared/group inboxes into the unified view."
}
},
"all_mail": {
"label": "All Mail",
"description": "Show an \"All Mail\" entry above your folders that merges messages from across this account's folders into one list.",
"folders_label": "Folders in All Mail",
"folders_description": "Choose which folders are merged into the All Mail view.",
"no_folders": "No folders available."
},
"colorful_sidebar_icons": {
"label": "Colorful Sidebar Icons",
"description": "Tint folder and tag icons by type (blue Inbox, red Junk, green Sent, etc.). Disable for a monochrome sidebar."
@@ -1285,8 +1314,17 @@
"move_down": "Move down",
"drag_handle": "Drag to reorder",
"add": "Add account"
},
"shared_accounts": {
"title": "Shared with me",
"description": "Group and shared accounts you can manage. Select one to edit its filters, vacation responder, calendars, and contacts.",
"shared_label": "Shared account"
}
},
"scoped": {
"back": "Back to my account",
"managing": "Managing: {name}"
},
"security": {
"title": "Account Security",
"description": "Manage your password, two-factor authentication, and security settings",
@@ -1383,6 +1421,17 @@
"copy": "Copy",
"copied": "Copied",
"password_instructions": "Use your JMAP username above along with an app password to sign in to your email client. Create an app password in the section above if you haven't already."
},
"link_device": {
"title": "Link Mobile App",
"description": "Sign in to the Bulwark Mail mobile app without typing anything. Generate a QR code here and scan it from the app's login screen.",
"generate": "Show QR code",
"regenerate": "Show a new code",
"instructions": "Open the Bulwark Mail app, tap \"Scan QR code\" on the login screen, and point your camera here.",
"expires_in": "This code expires in {seconds} seconds. It can only be used once.",
"expired": "This code has expired.",
"generating": "Generating…",
"error": "Couldn't create a pairing code. Please try again."
}
},
"identities": {
@@ -2490,7 +2539,37 @@
"every_n_weeks": "Every {count} weeks",
"every_n_months": "Every {count} months",
"until": "Until",
"occurrences": "{count} occurrences"
"occurrences": "{count} occurrences",
"custom": "Custom…",
"edit_custom": "Edit custom recurrence",
"every_n_years": "Every {count} years",
"on_days": "on {days}",
"on_day_n": "on day {day}",
"on_the_nth": "on the {nth} {day}",
"in_month": "in {month}",
"nth_1": "first",
"nth_2": "second",
"nth_3": "third",
"nth_4": "fourth",
"nth_last": "last",
"editor_freq_day": "Day",
"editor_freq_week": "Week",
"editor_freq_month": "Month",
"editor_freq_year": "Year",
"editor_repeats_on": "Repeats on",
"editor_every": "Every",
"editor_unit_days": "day(s)",
"editor_unit_weeks": "week(s)",
"editor_unit_months": "month(s)",
"editor_unit_years": "year(s)",
"editor_on_day": "on day",
"editor_on_the": "on the",
"editor_in": "in",
"editor_ends": "Ends",
"editor_never": "Never",
"editor_ends_on": "On",
"editor_ends_after": "After",
"editor_occurrences": "occurrences"
},
"recurrence_scope": {
"edit_title": "Edit recurring event",
@@ -2667,6 +2746,9 @@
"default": "Default",
"confirm_delete": "Delete \"{name}\"? All events in this calendar will be removed.",
"confirm_clear": "Clear all events from \"{name}\"? This cannot be undone.",
"set_default": "Set as default",
"default_updated": "Default calendar updated",
"error_default": "Failed to set default calendar",
"clear_events": "Clear events",
"events_cleared": "{count} events cleared",
"error_clear": "Failed to clear calendar events",
@@ -2902,6 +2984,10 @@
"duplicate": "Duplicate",
"duplicate_success": "Duplicated successfully",
"duplicate_error": "Failed to duplicate",
"share": "Share",
"shared": "Shared",
"shared_with_me": "Shared with me",
"shared_by": "Shared by {name}",
"create_file_success": "File created",
"create_file_error": "Failed to create file",
"favorites": "Favorites",
+90 -4
View File
@@ -544,7 +544,25 @@
"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"
"undo_send": "Deshacer envío",
"contact_sidebar": {
"title": "Contacto",
"close": "Cerrar panel",
"action_email": "Correo",
"action_email_title": "Enviar correo",
"action_copy": "Copiar",
"action_copy_title": "Copiar correo electrónico",
"action_edit_title": "Editar contacto",
"section_emails": "Correos electrónicos",
"section_phones": "Teléfonos",
"section_organizations": "Organizaciones",
"section_addresses": "Direcciones",
"section_notes": "Notas",
"not_in_contacts": "No está en sus contactos",
"add_to_contacts": "Añadir a contactos",
"copied": "¡Copiado!",
"copy_failed": "Error al copiar"
}
},
"email_composer": {
"read_receipt_on": "Confirmación de lectura solicitada (haz clic para desactivar)",
@@ -649,7 +667,11 @@
"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?"
"send_delay_unsupported_confirm": "Esta cuenta no admite demora de envío. ¿Enviar inmediatamente?",
"recipient_edit_email": "Editar dirección de correo",
"recipient_edit_name": "Editar nombre para mostrar",
"recipient_email_placeholder": "Dirección de correo",
"recipient_name_placeholder": "Nombre para mostrar"
},
"confirm_dialog": {
"confirm": "Confirmar",
@@ -892,6 +914,13 @@
"description": "Incluir también los buzones compartidos o de grupo en la vista unificada."
}
},
"all_mail": {
"label": "All Mail",
"description": "Show an \"All Mail\" entry above your folders that merges messages from across this account's folders into one list.",
"folders_label": "Folders in All Mail",
"folders_description": "Choose which folders are merged into the All Mail view.",
"no_folders": "No folders available."
},
"colorful_sidebar_icons": {
"label": "Iconos de barra lateral a color",
"description": "Colorea los iconos de carpetas y etiquetas según su tipo (azul para Bandeja de entrada, rojo para Spam, verde para Enviados, etc.). Desactívalo para una barra lateral monocroma."
@@ -1282,6 +1311,11 @@
"move_down": "Mover abajo",
"drag_handle": "Arrastra para reordenar",
"add": "Añadir cuenta"
},
"shared_accounts": {
"title": "Compartido conmigo",
"description": "Cuentas de grupo y compartidas que puedes gestionar. Selecciona una para editar sus filtros, respuesta de vacaciones, calendarios y contactos.",
"shared_label": "Cuenta compartida"
}
},
"security": {
@@ -1380,6 +1414,17 @@
"copy": "Copiar",
"copied": "Copiado",
"password_instructions": "Use su nombre de usuario JMAP indicado arriba junto con una contraseña de aplicación para iniciar sesión en su cliente de correo. Cree una contraseña de aplicación en la sección anterior si aún no lo ha hecho."
},
"link_device": {
"title": "Vincular aplicación móvil",
"description": "Inicie sesión en la aplicación móvil de Bulwark Mail sin escribir nada. Genere un código QR aquí y escanéelo desde la pantalla de inicio de sesión de la aplicación.",
"generate": "Mostrar código QR",
"regenerate": "Mostrar un código nuevo",
"instructions": "Abra la aplicación Bulwark Mail, toque \"Escanear código QR\" en la pantalla de inicio de sesión y apunte su cámara aquí.",
"expires_in": "Este código caduca en {seconds} segundos. Solo se puede usar una vez.",
"expired": "Este código ha caducado.",
"generating": "Generando…",
"error": "No se pudo crear un código de vinculación. Inténtelo de nuevo."
}
},
"identities": {
@@ -1837,7 +1882,11 @@
},
"search_placeholder": "Buscar ajustes",
"search_clear": "Borrar búsqueda",
"search_no_results": "No hay ajustes coincidentes"
"search_no_results": "No hay ajustes coincidentes",
"scoped": {
"back": "Volver a mi cuenta",
"managing": "Gestionando: {name}"
}
},
"errors": {
"page_error_title": "Algo salió mal",
@@ -2490,7 +2539,37 @@
"every_n_weeks": "Cada {count} semanas",
"every_n_months": "Cada {count} meses",
"until": "Hasta",
"occurrences": "{count} repeticiones"
"occurrences": "{count} repeticiones",
"custom": "Personalizado…",
"edit_custom": "Editar repetición personalizada",
"every_n_years": "Cada {count} años",
"on_days": "los {days}",
"on_day_n": "el día {day}",
"on_the_nth": "el {nth} {day}",
"in_month": "en {month}",
"nth_1": "primer",
"nth_2": "segundo",
"nth_3": "tercer",
"nth_4": "cuarto",
"nth_last": "último",
"editor_freq_day": "Día",
"editor_freq_week": "Semana",
"editor_freq_month": "Mes",
"editor_freq_year": "Año",
"editor_repeats_on": "Se repite",
"editor_every": "Cada",
"editor_unit_days": "día(s)",
"editor_unit_weeks": "semana(s)",
"editor_unit_months": "mes(es)",
"editor_unit_years": "año(s)",
"editor_on_day": "el día",
"editor_on_the": "el",
"editor_in": "en",
"editor_ends": "Termina",
"editor_never": "Nunca",
"editor_ends_on": "El",
"editor_ends_after": "Después de",
"editor_occurrences": "repeticiones"
},
"recurrence_scope": {
"edit_title": "Editar evento recurrente",
@@ -2662,6 +2741,9 @@
"copy_url": "Copiar URL de CalDAV",
"url_copied": "URL de CalDAV copiada al portapapeles",
"confirm_clear": "¿Borrar todos los eventos de \"{name}\"? Esta acción no se puede deshacer.",
"set_default": "Establecer como predeterminado",
"default_updated": "Calendario predeterminado actualizado",
"error_default": "Error al establecer el calendario predeterminado",
"clear_events": "Borrar eventos",
"events_cleared": "{count} eventos borrados",
"error_clear": "No se pudieron borrar los eventos del calendario",
@@ -2877,6 +2959,10 @@
"duplicate": "Duplicar",
"duplicate_success": "Duplicado correctamente",
"duplicate_error": "Error al duplicar",
"share": "Share",
"shared": "Shared",
"shared_with_me": "Shared with me",
"shared_by": "Shared by {name}",
"create_file_success": "Archivo creado",
"create_file_error": "Error al crear el archivo",
"favorites": "Favoritos",
+90 -4
View File
@@ -544,7 +544,25 @@
"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"
"undo_send": "Annuler lenvoi",
"contact_sidebar": {
"title": "Contact",
"close": "Fermer le panneau",
"action_email": "E-mail",
"action_email_title": "Envoyer un e-mail",
"action_copy": "Copier",
"action_copy_title": "Copier l'adresse e-mail",
"action_edit_title": "Modifier le contact",
"section_emails": "E-mails",
"section_phones": "Téléphones",
"section_organizations": "Organisations",
"section_addresses": "Adresses",
"section_notes": "Notes",
"not_in_contacts": "Pas dans vos contacts",
"add_to_contacts": "Ajouter aux contacts",
"copied": "Copié !",
"copy_failed": "Échec de la copie"
}
},
"email_composer": {
"read_receipt_on": "Accusé de lecture demandé (cliquez pour désactiver)",
@@ -649,7 +667,11 @@
"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 ?"
"send_delay_unsupported_confirm": "Ce compte ne prend pas en charge le délai denvoi. Envoyer immédiatement ?",
"recipient_edit_email": "Modifier l'adresse e-mail",
"recipient_edit_name": "Modifier le nom d'affichage",
"recipient_email_placeholder": "Adresse e-mail",
"recipient_name_placeholder": "Nom d'affichage"
},
"confirm_dialog": {
"confirm": "Confirmer",
@@ -892,6 +914,13 @@
"description": "Inclure également les boîtes partagées ou de groupe dans la vue unifiée."
}
},
"all_mail": {
"label": "All Mail",
"description": "Show an \"All Mail\" entry above your folders that merges messages from across this account's folders into one list.",
"folders_label": "Folders in All Mail",
"folders_description": "Choose which folders are merged into the All Mail view.",
"no_folders": "No folders available."
},
"colorful_sidebar_icons": {
"label": "Icônes colorées dans la barre latérale",
"description": "Colore les icônes de dossiers et d'étiquettes par type (Boîte de réception en bleu, Indésirable en rouge, Envoyés en vert, etc.). Désactivez pour une barre latérale monochrome."
@@ -1282,6 +1311,11 @@
"move_down": "Déplacer vers le bas",
"drag_handle": "Faire glisser pour réorganiser",
"add": "Ajouter un compte"
},
"shared_accounts": {
"title": "Partagé avec moi",
"description": "Comptes de groupe et partagés que vous pouvez gérer. Sélectionnez-en un pour modifier ses filtres, son répondeur d'absence, ses calendriers et ses contacts.",
"shared_label": "Compte partagé"
}
},
"security": {
@@ -1380,6 +1414,17 @@
"copy": "Copier",
"copied": "Copié",
"password_instructions": "Utilisez votre nom d'utilisateur JMAP ci-dessus avec un mot de passe d'application pour vous connecter à votre client de messagerie. Créez un mot de passe d'application dans la section ci-dessus si vous ne l'avez pas encore fait."
},
"link_device": {
"title": "Associer l'application mobile",
"description": "Connectez-vous à l'application mobile Bulwark Mail sans rien saisir. Générez un QR code ici et scannez-le depuis l'écran de connexion de l'application.",
"generate": "Afficher le QR code",
"regenerate": "Afficher un nouveau code",
"instructions": "Ouvrez l'application Bulwark Mail, appuyez sur \"Scanner le QR code\" sur l'écran de connexion et pointez votre caméra ici.",
"expires_in": "Ce code expire dans {seconds} secondes. Il ne peut être utilisé qu'une seule fois.",
"expired": "Ce code a expiré.",
"generating": "Génération…",
"error": "Impossible de créer un code d'association. Veuillez réessayer."
}
},
"identities": {
@@ -1837,7 +1882,11 @@
},
"search_placeholder": "Rechercher dans les paramètres",
"search_clear": "Effacer la recherche",
"search_no_results": "Aucun paramètre correspondant"
"search_no_results": "Aucun paramètre correspondant",
"scoped": {
"back": "Retour à mon compte",
"managing": "Gestion : {name}"
}
},
"errors": {
"page_error_title": "Une erreur s'est produite",
@@ -2490,7 +2539,37 @@
"every_n_weeks": "Toutes les {count} semaines",
"every_n_months": "Tous les {count} mois",
"until": "Jusqu'au",
"occurrences": "{count} occurrences"
"occurrences": "{count} occurrences",
"custom": "Personnalisé…",
"edit_custom": "Modifier la récurrence personnalisée",
"every_n_years": "Tous les {count} ans",
"on_days": "le {days}",
"on_day_n": "le jour {day}",
"on_the_nth": "le {nth} {day}",
"in_month": "en {month}",
"nth_1": "premier",
"nth_2": "deuxième",
"nth_3": "troisième",
"nth_4": "quatrième",
"nth_last": "dernier",
"editor_freq_day": "Jour",
"editor_freq_week": "Semaine",
"editor_freq_month": "Mois",
"editor_freq_year": "Année",
"editor_repeats_on": "Se répète",
"editor_every": "Tous les",
"editor_unit_days": "jour(s)",
"editor_unit_weeks": "semaine(s)",
"editor_unit_months": "mois",
"editor_unit_years": "an(s)",
"editor_on_day": "le jour",
"editor_on_the": "le",
"editor_in": "en",
"editor_ends": "Se termine",
"editor_never": "Jamais",
"editor_ends_on": "Le",
"editor_ends_after": "Après",
"editor_occurrences": "occurrences"
},
"recurrence_scope": {
"edit_title": "Modifier l'événement récurrent",
@@ -2676,6 +2755,9 @@
"copy_url": "Copier l'URL CalDAV",
"url_copied": "URL CalDAV copiée dans le presse-papiers",
"confirm_clear": "Supprimer tous les événements de \"{name}\" ? Cette action est irréversible.",
"set_default": "Définir par défaut",
"default_updated": "Calendrier par défaut mis à jour",
"error_default": "Échec de la définition du calendrier par défaut",
"clear_events": "Supprimer les événements",
"events_cleared": "{count} événements supprimés",
"error_clear": "Impossible de supprimer les événements du calendrier",
@@ -2877,6 +2959,10 @@
"duplicate": "Dupliquer",
"duplicate_success": "Dupliqué avec succès",
"duplicate_error": "Échec de la duplication",
"share": "Share",
"shared": "Shared",
"shared_with_me": "Shared with me",
"shared_by": "Shared by {name}",
"create_file_success": "Fichier créé",
"create_file_error": "Échec de la création du fichier",
"favorites": "Favoris",
+89 -3
View File
@@ -544,7 +544,25 @@
"reschedule_prompt": "Adj meg egy új dátumot/időpontot, pl. 2026-05-04T15:30",
"scheduled_actions_only": "Az ütemezett nézet csak ütemezett műveleteket támogat",
"undo_send_scheduled": "Üzenet ütemezve a küldéshez",
"undo_send": "Küldés visszavonása"
"undo_send": "Küldés visszavonása",
"contact_sidebar": {
"title": "Névjegy",
"close": "Oldalsáv bezárása",
"action_email": "E-mail",
"action_email_title": "E-mail küldése",
"action_copy": "Másolás",
"action_copy_title": "E-mail másolása",
"action_edit_title": "Névjegy szerkesztése",
"section_emails": "E-mailek",
"section_phones": "Telefonszámok",
"section_organizations": "Szervezetek",
"section_addresses": "Címek",
"section_notes": "Jegyzetek",
"not_in_contacts": "Nincs a névjegyei között",
"add_to_contacts": "Hozzáadás a névjegyekhez",
"copied": "Másolva!",
"copy_failed": "A másolás nem sikerült"
}
},
"email_composer": {
"read_receipt_on": "Olvasási visszaigazolás kérve (kattints a letiltáshoz)",
@@ -649,7 +667,11 @@
"schedule_send_unsupported": "Az ütemezett küldés nem támogatott ehhez a fiókhoz.",
"schedule_send_cleanup_warning": "Az ütemezett küldés létrejött, de a piszkozat tisztítása sikertelen volt.",
"send_delay_unsupported": "A késleltetett küldés nem támogatott ehhez a fiókhoz.",
"send_delay_unsupported_confirm": "Ez a fiók nem támogatja a késleltetett küldést. Elküldjük azonnal?"
"send_delay_unsupported_confirm": "Ez a fiók nem támogatja a késleltetett küldést. Elküldjük azonnal?",
"recipient_edit_email": "E-mail-cím szerkesztése",
"recipient_edit_name": "Megjelenített név szerkesztése",
"recipient_email_placeholder": "E-mail-cím",
"recipient_name_placeholder": "Megjelenített név"
},
"confirm_dialog": {
"confirm": "Megerősítés",
@@ -895,6 +917,13 @@
"description": "Megosztott/csoportos postafiókok egyesítése az egységes nézetbe."
}
},
"all_mail": {
"label": "All Mail",
"description": "Show an \"All Mail\" entry above your folders that merges messages from across this account's folders into one list.",
"folders_label": "Folders in All Mail",
"folders_description": "Choose which folders are merged into the All Mail view.",
"no_folders": "No folders available."
},
"colorful_sidebar_icons": {
"label": "Színes oldalsáv ikonok",
"description": "Mappa és címke ikonok színezése típus szerint (kék Beérkező, piros Spam, zöld Elküldött, stb.). Kapcsold ki az egyszínű oldalsávhoz."
@@ -1285,6 +1314,11 @@
"move_down": "Mozgatás le",
"drag_handle": "Húzd át a sorrend módosításához",
"add": "Fiók hozzáadása"
},
"shared_accounts": {
"title": "Velem megosztva",
"description": "Csoportos és megosztott fiókok, amelyeket kezelhet. Válasszon egyet a szűrői, távolléti válasza, naptárai és névjegyei szerkesztéséhez.",
"shared_label": "Megosztott fiók"
}
},
"security": {
@@ -1383,6 +1417,17 @@
"copy": "Másolás",
"copied": "Másolva",
"password_instructions": "Használd a fenti JMAP felhasználóneved egy alkalmazás jelszóval együtt az e-mail kliensbe való bejelentkezéshez. Hozz létre egy alkalmazás jelszót a fenti részben, ha még nem tetted."
},
"link_device": {
"title": "Mobilalkalmazás összekapcsolása",
"description": "Jelentkezz be a Bulwark Mail mobilalkalmazásba gépelés nélkül. Generálj itt egy QR-kódot, és olvasd be az alkalmazás bejelentkezési képernyőjén.",
"generate": "QR-kód megjelenítése",
"regenerate": "Új kód megjelenítése",
"instructions": "Nyisd meg a Bulwark Mail alkalmazást, koppints a \"QR-kód beolvasása\" lehetőségre a bejelentkezési képernyőn, és irányítsd ide a kamerát.",
"expires_in": "Ez a kód {seconds} másodperc múlva lejár. Csak egyszer használható.",
"expired": "Ez a kód lejárt.",
"generating": "Generálás…",
"error": "Nem sikerült párosítási kódot létrehozni. Próbáld újra."
}
},
"identities": {
@@ -1837,6 +1882,10 @@
"preview": {
"label": "Előnézet"
}
},
"scoped": {
"back": "Vissza a saját fiókomhoz",
"managing": "Kezelés: {name}"
}
},
"errors": {
@@ -2490,7 +2539,37 @@
"every_n_weeks": "Minden {count} hét",
"every_n_months": "Minden {count} hónap",
"until": "Amíg",
"occurrences": "{count} alkalom"
"occurrences": "{count} alkalom",
"custom": "Egyéni…",
"edit_custom": "Egyéni ismétlődés szerkesztése",
"every_n_years": "Minden {count}. évben",
"on_days": "ezeken a napokon: {days}",
"on_day_n": "a hónap {day}. napján",
"on_the_nth": "a(z) {nth} {day}",
"in_month": "{month} hónapban",
"nth_1": "első",
"nth_2": "második",
"nth_3": "harmadik",
"nth_4": "negyedik",
"nth_last": "utolsó",
"editor_freq_day": "Nap",
"editor_freq_week": "Hét",
"editor_freq_month": "Hónap",
"editor_freq_year": "Év",
"editor_repeats_on": "Ismétlődés",
"editor_every": "Minden",
"editor_unit_days": "nap",
"editor_unit_weeks": "hét",
"editor_unit_months": "hónap",
"editor_unit_years": "év",
"editor_on_day": "a hónap napján:",
"editor_on_the": "a(z)",
"editor_in": "hónap:",
"editor_ends": "Vége",
"editor_never": "Soha",
"editor_ends_on": "Ekkor",
"editor_ends_after": "Ennyi után:",
"editor_occurrences": "alkalom"
},
"recurrence_scope": {
"edit_title": "Ismétlődő esemény szerkesztése",
@@ -2667,6 +2746,9 @@
"default": "Alapértelmezett",
"confirm_delete": "Törlöd a \"{name}\" naptárat? Az összes esemény eltávolításra kerül.",
"confirm_clear": "Törlöd az összes eseményt a \"{name}\" naptárból? Ez nem vonható vissza.",
"set_default": "Beállítás alapértelmezettként",
"default_updated": "Alapértelmezett naptár frissítve",
"error_default": "Nem sikerült beállítani az alapértelmezett naptárat",
"clear_events": "Események törlése",
"events_cleared": "{count} esemény törölve",
"error_clear": "Nem sikerült törölni a naptár eseményeket",
@@ -2902,6 +2984,10 @@
"duplicate": "Duplikálás",
"duplicate_success": "Sikeresen duplikálva",
"duplicate_error": "Nem sikerült duplikálni",
"share": "Share",
"shared": "Shared",
"shared_with_me": "Shared with me",
"shared_by": "Shared by {name}",
"create_file_success": "Fájl létrehozva",
"create_file_error": "Nem sikerült létrehozni a fájlt",
"favorites": "Kedvencek",
+90 -4
View File
@@ -544,7 +544,25 @@
"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"
"undo_send": "Annulla invio",
"contact_sidebar": {
"title": "Contatto",
"close": "Chiudi barra laterale",
"action_email": "Email",
"action_email_title": "Invia email",
"action_copy": "Copia",
"action_copy_title": "Copia email",
"action_edit_title": "Modifica contatto",
"section_emails": "Email",
"section_phones": "Telefoni",
"section_organizations": "Organizzazioni",
"section_addresses": "Indirizzi",
"section_notes": "Note",
"not_in_contacts": "Non è nei tuoi contatti",
"add_to_contacts": "Aggiungi ai contatti",
"copied": "Copiato!",
"copy_failed": "Copia non riuscita"
}
},
"email_composer": {
"read_receipt_on": "Conferma di lettura richiesta (clicca per disattivare)",
@@ -649,7 +667,11 @@
"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?"
"send_delay_unsupported_confirm": "Questo account non supporta il ritardo di invio. Inviare subito?",
"recipient_edit_email": "Modifica indirizzo email",
"recipient_edit_name": "Modifica nome visualizzato",
"recipient_email_placeholder": "Indirizzo email",
"recipient_name_placeholder": "Nome visualizzato"
},
"confirm_dialog": {
"confirm": "Conferma",
@@ -892,6 +914,13 @@
"description": "Includi anche le caselle condivise o di gruppo nella vista unificata."
}
},
"all_mail": {
"label": "All Mail",
"description": "Show an \"All Mail\" entry above your folders that merges messages from across this account's folders into one list.",
"folders_label": "Folders in All Mail",
"folders_description": "Choose which folders are merged into the All Mail view.",
"no_folders": "No folders available."
},
"colorful_sidebar_icons": {
"label": "Icone colorate nella barra laterale",
"description": "Colora le icone di cartelle ed etichette per tipo (Posta in arrivo blu, Spam rosso, Inviati verde, ecc.). Disattiva per una barra laterale monocromatica."
@@ -1282,6 +1311,11 @@
"move_down": "Sposta giù",
"drag_handle": "Trascina per riordinare",
"add": "Aggiungi account"
},
"shared_accounts": {
"title": "Condivisi con me",
"description": "Account di gruppo e condivisi che puoi gestire. Selezionane uno per modificarne filtri, risponditore automatico, calendari e contatti.",
"shared_label": "Account condiviso"
}
},
"security": {
@@ -1380,6 +1414,17 @@
"copy": "Copia",
"copied": "Copiato",
"password_instructions": "Usa il tuo nome utente JMAP sopra indicato insieme a una password dell'app per accedere al tuo client e-mail. Crea una password dell'app nella sezione sopra se non l'hai ancora fatto."
},
"link_device": {
"title": "Collega l'app mobile",
"description": "Accedi all'app mobile Bulwark Mail senza digitare nulla. Genera qui un codice QR e scansionalo dalla schermata di accesso dell'app.",
"generate": "Mostra codice QR",
"regenerate": "Mostra un nuovo codice",
"instructions": "Apri l'app Bulwark Mail, tocca \"Scansiona codice QR\" nella schermata di accesso e inquadra qui con la fotocamera.",
"expires_in": "Questo codice scade tra {seconds} secondi. Può essere usato una sola volta.",
"expired": "Questo codice è scaduto.",
"generating": "Generazione…",
"error": "Impossibile creare un codice di associazione. Riprova."
}
},
"identities": {
@@ -1837,7 +1882,11 @@
},
"search_placeholder": "Cerca impostazioni",
"search_clear": "Cancella ricerca",
"search_no_results": "Nessuna impostazione corrispondente"
"search_no_results": "Nessuna impostazione corrispondente",
"scoped": {
"back": "Torna al mio account",
"managing": "Gestione: {name}"
}
},
"errors": {
"page_error_title": "Qualcosa è andato storto",
@@ -2490,7 +2539,37 @@
"every_n_weeks": "Ogni {count} settimane",
"every_n_months": "Ogni {count} mesi",
"until": "Fino al",
"occurrences": "{count} ripetizioni"
"occurrences": "{count} ripetizioni",
"custom": "Personalizzato…",
"edit_custom": "Modifica ricorrenza personalizzata",
"every_n_years": "Ogni {count} anni",
"on_days": "di {days}",
"on_day_n": "il giorno {day}",
"on_the_nth": "il {nth} {day}",
"in_month": "a {month}",
"nth_1": "primo",
"nth_2": "secondo",
"nth_3": "terzo",
"nth_4": "quarto",
"nth_last": "ultimo",
"editor_freq_day": "Giorno",
"editor_freq_week": "Settimana",
"editor_freq_month": "Mese",
"editor_freq_year": "Anno",
"editor_repeats_on": "Si ripete",
"editor_every": "Ogni",
"editor_unit_days": "giorno/i",
"editor_unit_weeks": "settimana/e",
"editor_unit_months": "mese/i",
"editor_unit_years": "anno/i",
"editor_on_day": "il giorno",
"editor_on_the": "il",
"editor_in": "a",
"editor_ends": "Termina",
"editor_never": "Mai",
"editor_ends_on": "Il",
"editor_ends_after": "Dopo",
"editor_occurrences": "occorrenze"
},
"recurrence_scope": {
"edit_title": "Modifica evento ricorrente",
@@ -2662,6 +2741,9 @@
"copy_url": "Copia URL CalDAV",
"url_copied": "URL CalDAV copiato negli appunti",
"confirm_clear": "Cancellare tutti gli eventi da \"{name}\"? Questa azione non può essere annullata.",
"set_default": "Imposta come predefinito",
"default_updated": "Calendario predefinito aggiornato",
"error_default": "Impossibile impostare il calendario predefinito",
"clear_events": "Cancella eventi",
"events_cleared": "{count} eventi cancellati",
"error_clear": "Impossibile cancellare gli eventi del calendario",
@@ -2877,6 +2959,10 @@
"duplicate": "Duplica",
"duplicate_success": "Duplicato con successo",
"duplicate_error": "Duplicazione non riuscita",
"share": "Share",
"shared": "Shared",
"shared_with_me": "Shared with me",
"shared_by": "Shared by {name}",
"create_file_success": "File creato",
"create_file_error": "Creazione del file non riuscita",
"favorites": "Preferiti",
+90 -4
View File
@@ -544,7 +544,25 @@
"reschedule_prompt": "新しい日時を入力してください。例: 2026-05-04T15:30",
"scheduled_actions_only": "予約済みビューでは予約アクションのみ利用できます",
"undo_send_scheduled": "メッセージは送信予約されています",
"undo_send": "送信を取り消す"
"undo_send": "送信を取り消す",
"contact_sidebar": {
"title": "連絡先",
"close": "サイドバーを閉じる",
"action_email": "メール",
"action_email_title": "メールを送信",
"action_copy": "コピー",
"action_copy_title": "メールアドレスをコピー",
"action_edit_title": "連絡先を編集",
"section_emails": "メールアドレス",
"section_phones": "電話番号",
"section_organizations": "組織",
"section_addresses": "住所",
"section_notes": "メモ",
"not_in_contacts": "連絡先に登録されていません",
"add_to_contacts": "連絡先に追加",
"copied": "コピーしました!",
"copy_failed": "コピーに失敗しました"
}
},
"email_composer": {
"read_receipt_on": "開封確認を要求中(クリックで無効化)",
@@ -649,7 +667,11 @@
"schedule_send_unsupported": "このアカウントでは予約送信はサポートされていません。",
"schedule_send_cleanup_warning": "予約送信は作成されましたが、下書きのクリーンアップに失敗しました。",
"send_delay_unsupported": "このアカウントでは送信遅延はサポートされていません。",
"send_delay_unsupported_confirm": "このアカウントは送信遅延に対応していません。すぐに送信しますか?"
"send_delay_unsupported_confirm": "このアカウントは送信遅延に対応していません。すぐに送信しますか?",
"recipient_edit_email": "メールアドレスを編集",
"recipient_edit_name": "表示名を編集",
"recipient_email_placeholder": "メールアドレス",
"recipient_name_placeholder": "表示名"
},
"confirm_dialog": {
"confirm": "確認",
@@ -892,6 +914,13 @@
"description": "共有/グループ受信トレイも統合ビューに含めます。"
}
},
"all_mail": {
"label": "All Mail",
"description": "Show an \"All Mail\" entry above your folders that merges messages from across this account's folders into one list.",
"folders_label": "Folders in All Mail",
"folders_description": "Choose which folders are merged into the All Mail view.",
"no_folders": "No folders available."
},
"colorful_sidebar_icons": {
"label": "カラフルなサイドバーアイコン",
"description": "フォルダーとタグのアイコンを種類別に色分けします(受信トレイは青、迷惑メールは赤、送信済みは緑など)。モノクロのサイドバーにするには無効にしてください。"
@@ -1282,6 +1311,11 @@
"move_down": "下に移動",
"drag_handle": "ドラッグして並べ替え",
"add": "アカウントを追加"
},
"shared_accounts": {
"title": "自分と共有",
"description": "管理できるグループアカウントと共有アカウントです。1つ選択して、フィルター、不在応答、カレンダー、連絡先を編集します。",
"shared_label": "共有アカウント"
}
},
"security": {
@@ -1380,6 +1414,17 @@
"copy": "コピー",
"copied": "コピー済み",
"password_instructions": "上記のJMAPユーザー名とアプリパスワードを使用してメールクライアントにサインインします。まだ作成していない場合は、上のセクションでアプリパスワードを作成してください。"
},
"link_device": {
"title": "モバイルアプリを連携",
"description": "入力なしで Bulwark Mail モバイルアプリにサインインできます。ここで QR コードを生成し、アプリのログイン画面からスキャンしてください。",
"generate": "QRコードを表示",
"regenerate": "新しいコードを表示",
"instructions": "Bulwark Mail アプリを開き、ログイン画面で「QRコードをスキャン」をタップして、カメラをここに向けてください。",
"expires_in": "このコードは{seconds}秒で期限切れになります。一度しか使用できません。",
"expired": "このコードは期限切れです。",
"generating": "生成中…",
"error": "ペアリングコードを作成できませんでした。もう一度お試しください。"
}
},
"identities": {
@@ -1837,7 +1882,11 @@
},
"search_placeholder": "設定を検索",
"search_clear": "検索をクリア",
"search_no_results": "一致する設定はありません"
"search_no_results": "一致する設定はありません",
"scoped": {
"back": "自分のアカウントに戻る",
"managing": "管理中: {name}"
}
},
"errors": {
"page_error_title": "問題が発生しました",
@@ -2490,7 +2539,37 @@
"every_n_weeks": "{count}週間ごと",
"every_n_months": "{count}か月ごと",
"until": "終了日",
"occurrences": "{count}回"
"occurrences": "{count}回",
"custom": "カスタム…",
"edit_custom": "カスタム繰り返しを編集",
"every_n_years": "{count}年ごと",
"on_days": "{days}",
"on_day_n": "{day}日",
"on_the_nth": "{nth}{day}",
"in_month": "{month}",
"nth_1": "第1",
"nth_2": "第2",
"nth_3": "第3",
"nth_4": "第4",
"nth_last": "最終",
"editor_freq_day": "日",
"editor_freq_week": "週",
"editor_freq_month": "月",
"editor_freq_year": "年",
"editor_repeats_on": "繰り返し",
"editor_every": "間隔:",
"editor_unit_days": "日ごと",
"editor_unit_weeks": "週間ごと",
"editor_unit_months": "か月ごと",
"editor_unit_years": "年ごと",
"editor_on_day": "日付:",
"editor_on_the": "曜日:",
"editor_in": "月:",
"editor_ends": "終了",
"editor_never": "なし",
"editor_ends_on": "終了日",
"editor_ends_after": "回数",
"editor_occurrences": "回"
},
"recurrence_scope": {
"edit_title": "繰り返しイベントを編集",
@@ -2662,6 +2741,9 @@
"copy_url": "CalDAV URLをコピー",
"url_copied": "CalDAV URLをクリップボードにコピーしました",
"confirm_clear": "\"{name}\"のすべてのイベントを削除しますか?この操作は元に戻せません。",
"set_default": "デフォルトに設定",
"default_updated": "デフォルトのカレンダーを更新しました",
"error_default": "デフォルトのカレンダーを設定できませんでした",
"clear_events": "イベントを削除",
"events_cleared": "{count}件のイベントを削除しました",
"error_clear": "カレンダーイベントの削除に失敗しました",
@@ -2877,6 +2959,10 @@
"duplicate": "複製",
"duplicate_success": "正常に複製しました",
"duplicate_error": "複製に失敗しました",
"share": "Share",
"shared": "Shared",
"shared_with_me": "Shared with me",
"shared_by": "Shared by {name}",
"create_file_success": "ファイルを作成しました",
"create_file_error": "ファイルの作成に失敗しました",
"favorites": "お気に入り",
+90 -4
View File
@@ -544,7 +544,25 @@
"reschedule_prompt": "새 날짜/시간을 입력하세요. 예: 2026-05-04T15:30",
"scheduled_actions_only": "예약됨 보기에서는 예약 작업만 사용할 수 있습니다",
"undo_send_scheduled": "메시지가 보내기로 예약되었습니다",
"undo_send": "보내기 실행 취소"
"undo_send": "보내기 실행 취소",
"contact_sidebar": {
"title": "연락처",
"close": "사이드바 닫기",
"action_email": "이메일",
"action_email_title": "이메일 보내기",
"action_copy": "복사",
"action_copy_title": "이메일 복사",
"action_edit_title": "연락처 편집",
"section_emails": "이메일",
"section_phones": "전화번호",
"section_organizations": "조직",
"section_addresses": "주소",
"section_notes": "메모",
"not_in_contacts": "연락처에 없음",
"add_to_contacts": "연락처에 추가",
"copied": "복사됨!",
"copy_failed": "복사하지 못했습니다"
}
},
"email_composer": {
"read_receipt_on": "읽음 확인 요청됨 (클릭하여 해제)",
@@ -649,7 +667,11 @@
"schedule_send_unsupported": "이 계정은 예약 보내기를 지원하지 않습니다.",
"schedule_send_cleanup_warning": "예약 보내기는 생성되었지만 초안 정리에 실패했습니다.",
"send_delay_unsupported": "이 계정은 보내기 지연을 지원하지 않습니다.",
"send_delay_unsupported_confirm": "이 계정은 보내기 지연을 지원하지 않습니다. 바로 보낼까요?"
"send_delay_unsupported_confirm": "이 계정은 보내기 지연을 지원하지 않습니다. 바로 보낼까요?",
"recipient_edit_email": "이메일 주소 편집",
"recipient_edit_name": "표시 이름 편집",
"recipient_email_placeholder": "이메일 주소",
"recipient_name_placeholder": "표시 이름"
},
"confirm_dialog": {
"confirm": "확인",
@@ -892,6 +914,13 @@
"description": "공유/그룹 받은편지함도 통합 보기에 포함합니다."
}
},
"all_mail": {
"label": "All Mail",
"description": "Show an \"All Mail\" entry above your folders that merges messages from across this account's folders into one list.",
"folders_label": "Folders in All Mail",
"folders_description": "Choose which folders are merged into the All Mail view.",
"no_folders": "No folders available."
},
"colorful_sidebar_icons": {
"label": "컬러풀한 사이드바 아이콘",
"description": "폴더와 태그 아이콘을 유형별로 색상 표시합니다(받은편지함 파란색, 스팸 빨간색, 보낸편지함 녹색 등). 모노크롬 사이드바를 원하면 비활성화하세요."
@@ -1282,6 +1311,11 @@
"move_down": "아래로 이동",
"drag_handle": "끌어서 순서 변경",
"add": "계정 추가"
},
"shared_accounts": {
"title": "나와 공유됨",
"description": "관리할 수 있는 그룹 및 공유 계정입니다. 하나를 선택하여 필터, 부재 중 자동 응답, 캘린더, 연락처를 편집하세요.",
"shared_label": "공유 계정"
}
},
"security": {
@@ -1380,6 +1414,17 @@
"copy": "복사",
"copied": "복사되었어요",
"password_instructions": "위의 JMAP 사용자 이름과 앱 비밀번호를 사용해서 이메일 클라이언트에 로그인하세요. 아직 만들지 않았다면 위에서 앱 비밀번호를 먼저 만들어 주세요."
},
"link_device": {
"title": "모바일 앱 연결",
"description": "아무것도 입력하지 않고 Bulwark Mail 모바일 앱에 로그인하세요. 여기에서 QR 코드를 생성하고 앱의 로그인 화면에서 스캔하세요.",
"generate": "QR 코드 표시",
"regenerate": "새 코드 표시",
"instructions": "Bulwark Mail 앱을 열고 로그인 화면에서 \"QR 코드 스캔\"을 누른 다음 카메라를 여기에 비추세요.",
"expires_in": "이 코드는 {seconds}초 후에 만료됩니다. 한 번만 사용할 수 있습니다.",
"expired": "이 코드는 만료되었습니다.",
"generating": "생성 중…",
"error": "페어링 코드를 만들지 못했습니다. 다시 시도해 주세요."
}
},
"identities": {
@@ -1837,7 +1882,11 @@
},
"search_placeholder": "설정 검색",
"search_clear": "검색 지우기",
"search_no_results": "일치하는 설정이 없습니다"
"search_no_results": "일치하는 설정이 없습니다",
"scoped": {
"back": "내 계정으로 돌아가기",
"managing": "관리 중: {name}"
}
},
"errors": {
"page_error_title": "문제가 발생했어요",
@@ -2490,7 +2539,37 @@
"every_n_weeks": "{count}주마다",
"every_n_months": "{count}개월마다",
"until": "종료일:",
"occurrences": "{count}회 반복"
"occurrences": "{count}회 반복",
"custom": "맞춤…",
"edit_custom": "맞춤 반복 수정",
"every_n_years": "{count}년마다",
"on_days": "{days}",
"on_day_n": "{day}일",
"on_the_nth": "{nth} {day}",
"in_month": "{month}",
"nth_1": "첫째",
"nth_2": "둘째",
"nth_3": "셋째",
"nth_4": "넷째",
"nth_last": "마지막",
"editor_freq_day": "일",
"editor_freq_week": "주",
"editor_freq_month": "월",
"editor_freq_year": "년",
"editor_repeats_on": "반복",
"editor_every": "매",
"editor_unit_days": "일",
"editor_unit_weeks": "주",
"editor_unit_months": "개월",
"editor_unit_years": "년",
"editor_on_day": "날짜:",
"editor_on_the": "요일:",
"editor_in": "월:",
"editor_ends": "종료",
"editor_never": "안 함",
"editor_ends_on": "종료일",
"editor_ends_after": "횟수",
"editor_occurrences": "회"
},
"recurrence_scope": {
"edit_title": "반복 일정 수정",
@@ -2652,6 +2731,9 @@
"default": "기본",
"confirm_delete": "\"{name}\" 캘린더를 삭제할까요? 캘린더 안의 모든 일정이 함께 지워져요.",
"confirm_clear": "\"{name}\" 캘린더의 모든 일정을 지울까요? 이 작업은 되돌릴 수 없어요.",
"set_default": "기본으로 설정",
"default_updated": "기본 캘린더가 업데이트되었습니다",
"error_default": "기본 캘린더를 설정하지 못했습니다",
"clear_events": "일정 모두 지우기",
"events_cleared": "{count}개의 일정을 지웠어요",
"error_clear": "일정을 지우지 못했어요",
@@ -2877,6 +2959,10 @@
"duplicate": "복제",
"duplicate_success": "성공적으로 복제되었어요",
"duplicate_error": "복제에 실패했어요",
"share": "Share",
"shared": "Shared",
"shared_with_me": "Shared with me",
"shared_by": "Shared by {name}",
"create_file_success": "파일이 만들어졌어요",
"create_file_error": "파일을 만들지 못했어요",
"favorites": "즐겨찾기",
+90 -4
View File
@@ -544,7 +544,25 @@
"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"
"undo_send": "Atsaukt sūtīšanu",
"contact_sidebar": {
"title": "Kontaktpersona",
"close": "Aizvērt sānjoslu",
"action_email": "E-pasts",
"action_email_title": "Sūtīt e-pastu",
"action_copy": "Kopēt",
"action_copy_title": "Kopēt e-pastu",
"action_edit_title": "Rediģēt kontaktpersonu",
"section_emails": "E-pasti",
"section_phones": "Tālruņi",
"section_organizations": "Organizācijas",
"section_addresses": "Adreses",
"section_notes": "Piezīmes",
"not_in_contacts": "Nav jūsu kontaktpersonās",
"add_to_contacts": "Pievienot kontaktpersonām",
"copied": "Nokopēts!",
"copy_failed": "Neizdevās nokopēt"
}
},
"email_composer": {
"read_receipt_on": "Pieprasīts lasīšanas apstiprinājums (noklikšķiniet, lai atspējotu)",
@@ -649,7 +667,11 @@
"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?"
"send_delay_unsupported_confirm": "Šis konts neatbalsta sūtīšanas aizturi. Sūtīt uzreiz?",
"recipient_edit_email": "Rediģēt e-pasta adresi",
"recipient_edit_name": "Rediģēt parādāmo vārdu",
"recipient_email_placeholder": "E-pasta adrese",
"recipient_name_placeholder": "Parādāmais vārds"
},
"confirm_dialog": {
"confirm": "Apstiprināt",
@@ -892,6 +914,13 @@
"description": "Iekļaut apvienotajā skatā arī koplietotās/grupas pastkastes."
}
},
"all_mail": {
"label": "All Mail",
"description": "Show an \"All Mail\" entry above your folders that merges messages from across this account's folders into one list.",
"folders_label": "Folders in All Mail",
"folders_description": "Choose which folders are merged into the All Mail view.",
"no_folders": "No folders available."
},
"colorful_sidebar_icons": {
"label": "Krāsainas sānjoslas ikonas",
"description": "Iekrāsojiet mapju un birku ikonas pēc to veida (zila Iesūtne, sarkana Mēstules, zaļa Nosūtītie utt.). Atspējojiet, lai iegūtu vienkrāsainu sānjoslu."
@@ -1282,6 +1311,11 @@
"move_down": "Pārvietot uz leju",
"drag_handle": "Velciet, lai pārkārtotu",
"add": "Pievienot kontu"
},
"shared_accounts": {
"title": "Kopīgots ar mani",
"description": "Grupas un kopīgotie konti, kurus varat pārvaldīt. Atlasiet vienu, lai rediģētu tā filtrus, prombūtnes atbildētāju, kalendārus un kontaktus.",
"shared_label": "Kopīgots konts"
}
},
"security": {
@@ -1380,6 +1414,17 @@
"copy": "Kopēt",
"copied": "Nokopēts",
"password_instructions": "Izmantojiet norādīto JMAP lietotājvārdu kopā ar lietotnes paroli. Izveidojiet lietotnes paroli sadaļā augstāk."
},
"link_device": {
"title": "Saistīt mobilo lietotni",
"description": "Pierakstieties Bulwark Mail mobilajā lietotnē, neko nerakstot. Šeit izveidojiet QR kodu un noskenējiet to lietotnes pierakstīšanās ekrānā.",
"generate": "Rādīt QR kodu",
"regenerate": "Rādīt jaunu kodu",
"instructions": "Atveriet Bulwark Mail lietotni, pierakstīšanās ekrānā pieskarieties \"Skenēt QR kodu\" un pavērsiet kameru šeit.",
"expires_in": "Šī koda derīgums beigsies pēc {seconds} sekundēm. To var izmantot tikai vienu reizi.",
"expired": "Šī koda derīgums ir beidzies.",
"generating": "Ģenerē…",
"error": "Neizdevās izveidot pārošanas kodu. Lūdzu, mēģiniet vēlreiz."
}
},
"identities": {
@@ -1837,7 +1882,11 @@
},
"search_placeholder": "Meklēt iestatījumos",
"search_clear": "Notīrīt meklēšanu",
"search_no_results": "Nav atbilstošu iestatījumu"
"search_no_results": "Nav atbilstošu iestatījumu",
"scoped": {
"back": "Atpakaļ uz manu kontu",
"managing": "Pārvalda: {name}"
}
},
"errors": {
"page_error_title": "Kaut kas nogāja griezi",
@@ -2489,7 +2538,37 @@
"every_n_weeks": "Ik pēc {count} nedēļām",
"every_n_months": "Ik pēc {count} mēnešiem",
"until": "Līdz",
"occurrences": "{count} reizes"
"occurrences": "{count} reizes",
"custom": "Pielāgots…",
"edit_custom": "Rediģēt pielāgotu atkārtošanos",
"every_n_years": "Ik pēc {count} gadiem",
"on_days": "šajās dienās: {days}",
"on_day_n": "mēneša {day}. dienā",
"on_the_nth": "{nth} {day}",
"in_month": "{month} mēnesī",
"nth_1": "pirmajā",
"nth_2": "otrajā",
"nth_3": "trešajā",
"nth_4": "ceturtajā",
"nth_last": "pēdējā",
"editor_freq_day": "Diena",
"editor_freq_week": "Nedēļa",
"editor_freq_month": "Mēnesis",
"editor_freq_year": "Gads",
"editor_repeats_on": "Atkārtojas",
"editor_every": "Ik pēc",
"editor_unit_days": "dienām",
"editor_unit_weeks": "nedēļām",
"editor_unit_months": "mēnešiem",
"editor_unit_years": "gadiem",
"editor_on_day": "mēneša dienā",
"editor_on_the": "šajā:",
"editor_in": "mēnesī",
"editor_ends": "Beidzas",
"editor_never": "Nekad",
"editor_ends_on": "Datumā",
"editor_ends_after": "Pēc",
"editor_occurrences": "reizēm"
},
"recurrence_scope": {
"edit_title": "Rediģēt atkārtotu pasākumu",
@@ -2651,6 +2730,9 @@
"default": "Noklusējuma",
"confirm_delete": "Dzēst «{name}»? Visi pasākumi šajā kalendārā tiks dzēsti.",
"confirm_clear": "Iztīrīt visus pasākumus no «{name}»? Šo darbību nevar atcelt.",
"set_default": "Iestatīt kā noklusējumu",
"default_updated": "Noklusējuma kalendārs atjaunināts",
"error_default": "Neizdevās iestatīt noklusējuma kalendāru",
"clear_events": "Iztīrīt pasākumus",
"events_cleared": "Iztīrīti {count} pasākumi",
"error_clear": "Neizdevās iztīrīt kalendāra pasākumus",
@@ -2877,6 +2959,10 @@
"duplicate": "Dublēt",
"duplicate_success": "Dublēšana pabeigta",
"duplicate_error": "Neizdevās dublēt",
"share": "Share",
"shared": "Shared",
"shared_with_me": "Shared with me",
"shared_by": "Shared by {name}",
"create_file_success": "Fails izveidots",
"create_file_error": "Neizdevās izveidot failu",
"favorites": "Izlase",
+90 -4
View File
@@ -544,7 +544,25 @@
"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"
"undo_send": "Verzenden ongedaan maken",
"contact_sidebar": {
"title": "Contact",
"close": "Zijbalk sluiten",
"action_email": "E-mail",
"action_email_title": "E-mail verzenden",
"action_copy": "Kopiëren",
"action_copy_title": "E-mailadres kopiëren",
"action_edit_title": "Contact bewerken",
"section_emails": "E-mailadressen",
"section_phones": "Telefoonnummers",
"section_organizations": "Organisaties",
"section_addresses": "Adressen",
"section_notes": "Notities",
"not_in_contacts": "Niet in uw contacten",
"add_to_contacts": "Aan contacten toevoegen",
"copied": "Gekopieerd!",
"copy_failed": "Kopiëren mislukt"
}
},
"email_composer": {
"read_receipt_on": "Leesbevestiging aangevraagd (klik om uit te schakelen)",
@@ -649,7 +667,11 @@
"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?"
"send_delay_unsupported_confirm": "Dit account ondersteunt geen verzendvertraging. Meteen verzenden?",
"recipient_edit_email": "E-mailadres bewerken",
"recipient_edit_name": "Weergavenaam bewerken",
"recipient_email_placeholder": "E-mailadres",
"recipient_name_placeholder": "Weergavenaam"
},
"confirm_dialog": {
"confirm": "Bevestigen",
@@ -892,6 +914,13 @@
"description": "Gedeelde/groepspostvakken ook in de gecombineerde weergave opnemen."
}
},
"all_mail": {
"label": "All Mail",
"description": "Show an \"All Mail\" entry above your folders that merges messages from across this account's folders into one list.",
"folders_label": "Folders in All Mail",
"folders_description": "Choose which folders are merged into the All Mail view.",
"no_folders": "No folders available."
},
"colorful_sidebar_icons": {
"label": "Gekleurde zijbalkpictogrammen",
"description": "Kleur map- en tagpictogrammen op type (blauw Postvak IN, rood Spam, groen Verzonden, enz.). Schakel uit voor een monochrome zijbalk."
@@ -1282,6 +1311,11 @@
"move_down": "Omlaag verplaatsen",
"drag_handle": "Sleep om volgorde aan te passen",
"add": "Account toevoegen"
},
"shared_accounts": {
"title": "Gedeeld met mij",
"description": "Groeps- en gedeelde accounts die u kunt beheren. Selecteer er een om de filters, afwezigheidsassistent, agenda's en contacten te bewerken.",
"shared_label": "Gedeeld account"
}
},
"security": {
@@ -1380,6 +1414,17 @@
"copy": "Kopiëren",
"copied": "Gekopieerd",
"password_instructions": "Gebruik uw hierboven vermelde JMAP-gebruikersnaam samen met een app-wachtwoord om in te loggen bij uw e-mailclient. Maak een app-wachtwoord aan in het bovenstaande gedeelte als u dat nog niet heeft gedaan."
},
"link_device": {
"title": "Mobiele app koppelen",
"description": "Log in bij de Bulwark Mail mobiele app zonder iets te typen. Genereer hier een QR-code en scan deze vanaf het inlogscherm van de app.",
"generate": "QR-code tonen",
"regenerate": "Nieuwe code tonen",
"instructions": "Open de Bulwark Mail app, tik op \"QR-code scannen\" op het inlogscherm en richt uw camera hierop.",
"expires_in": "Deze code verloopt over {seconds} seconden. Hij kan maar één keer worden gebruikt.",
"expired": "Deze code is verlopen.",
"generating": "Genereren…",
"error": "Kon geen koppelingscode aanmaken. Probeer het opnieuw."
}
},
"identities": {
@@ -1837,7 +1882,11 @@
},
"search_placeholder": "Instellingen zoeken",
"search_clear": "Zoekopdracht wissen",
"search_no_results": "Geen overeenkomende instellingen"
"search_no_results": "Geen overeenkomende instellingen",
"scoped": {
"back": "Terug naar mijn account",
"managing": "Beheren: {name}"
}
},
"errors": {
"page_error_title": "Er is iets misgegaan",
@@ -2490,7 +2539,37 @@
"every_n_weeks": "Elke {count} weken",
"every_n_months": "Elke {count} maanden",
"until": "Tot",
"occurrences": "{count} herhalingen"
"occurrences": "{count} herhalingen",
"custom": "Aangepast…",
"edit_custom": "Aangepaste herhaling bewerken",
"every_n_years": "Elke {count} jaar",
"on_days": "op {days}",
"on_day_n": "op dag {day}",
"on_the_nth": "op de {nth} {day}",
"in_month": "in {month}",
"nth_1": "eerste",
"nth_2": "tweede",
"nth_3": "derde",
"nth_4": "vierde",
"nth_last": "laatste",
"editor_freq_day": "Dag",
"editor_freq_week": "Week",
"editor_freq_month": "Maand",
"editor_freq_year": "Jaar",
"editor_repeats_on": "Herhaalt zich",
"editor_every": "Elke",
"editor_unit_days": "dag(en)",
"editor_unit_weeks": "week/weken",
"editor_unit_months": "maand(en)",
"editor_unit_years": "jaar/jaren",
"editor_on_day": "op dag",
"editor_on_the": "op de",
"editor_in": "in",
"editor_ends": "Eindigt",
"editor_never": "Nooit",
"editor_ends_on": "Op",
"editor_ends_after": "Na",
"editor_occurrences": "herhalingen"
},
"recurrence_scope": {
"edit_title": "Terugkerend evenement bewerken",
@@ -2662,6 +2741,9 @@
"copy_url": "CalDAV-URL kopiëren",
"url_copied": "CalDAV-URL gekopieerd naar klembord",
"confirm_clear": "Alle afspraken uit \"{name}\" verwijderen? Dit kan niet ongedaan worden gemaakt.",
"set_default": "Als standaard instellen",
"default_updated": "Standaardagenda bijgewerkt",
"error_default": "Standaardagenda instellen mislukt",
"clear_events": "Afspraken verwijderen",
"events_cleared": "{count} afspraken verwijderd",
"error_clear": "Kan agendagebeurtenissen niet verwijderen",
@@ -2877,6 +2959,10 @@
"duplicate": "Dupliceren",
"duplicate_success": "Succesvol gedupliceerd",
"duplicate_error": "Dupliceren mislukt",
"share": "Share",
"shared": "Shared",
"shared_with_me": "Shared with me",
"shared_by": "Shared by {name}",
"create_file_success": "Bestand aangemaakt",
"create_file_error": "Bestand aanmaken mislukt",
"favorites": "Favorieten",
+90 -4
View File
@@ -544,7 +544,25 @@
"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ę"
"undo_send": "Cofnij wysyłkę",
"contact_sidebar": {
"title": "Kontakt",
"close": "Zamknij panel boczny",
"action_email": "E-mail",
"action_email_title": "Wyślij e-mail",
"action_copy": "Kopiuj",
"action_copy_title": "Kopiuj adres e-mail",
"action_edit_title": "Edytuj kontakt",
"section_emails": "E-maile",
"section_phones": "Telefony",
"section_organizations": "Organizacje",
"section_addresses": "Adresy",
"section_notes": "Notatki",
"not_in_contacts": "Brak w kontaktach",
"add_to_contacts": "Dodaj do kontaktów",
"copied": "Skopiowano!",
"copy_failed": "Nie udało się skopiować"
}
},
"email_composer": {
"read_receipt_on": "Zażądano potwierdzenia przeczytania (kliknij, aby wyłączyć)",
@@ -649,7 +667,11 @@
"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?"
"send_delay_unsupported_confirm": "To konto nie obsługuje opóźnienia wysyłki. Wysłać natychmiast?",
"recipient_edit_email": "Edytuj adres e-mail",
"recipient_edit_name": "Edytuj wyświetlaną nazwę",
"recipient_email_placeholder": "Adres e-mail",
"recipient_name_placeholder": "Wyświetlana nazwa"
},
"confirm_dialog": {
"confirm": "Potwierdź",
@@ -892,6 +914,13 @@
"description": "Dodaj również udostępnione/grupowe skrzynki do widoku wspólnego."
}
},
"all_mail": {
"label": "All Mail",
"description": "Show an \"All Mail\" entry above your folders that merges messages from across this account's folders into one list.",
"folders_label": "Folders in All Mail",
"folders_description": "Choose which folders are merged into the All Mail view.",
"no_folders": "No folders available."
},
"colorful_sidebar_icons": {
"label": "Kolorowe ikony paska bocznego",
"description": "Koloruj ikony folderów i tagów według typu (niebieska Skrzynka odbiorcza, czerwona Spam, zielona Wysłane itp.). Wyłącz, aby uzyskać monochromatyczny pasek boczny."
@@ -1282,6 +1311,11 @@
"move_down": "Przesuń w dół",
"drag_handle": "Przeciągnij, aby zmienić kolejność",
"add": "Dodaj konto"
},
"shared_accounts": {
"title": "Udostępnione mnie",
"description": "Konta grupowe i udostępnione, którymi możesz zarządzać. Wybierz jedno, aby edytować jego filtry, odpowiedź wakacyjną, kalendarze i kontakty.",
"shared_label": "Konto udostępnione"
}
},
"security": {
@@ -1380,6 +1414,17 @@
"copy": "Kopiuj",
"copied": "Skopiowano",
"password_instructions": "Użyj powyższej nazwy użytkownika JMAP wraz z hasłem aplikacji, aby zalogować się w kliencie poczty. Utwórz hasło aplikacji w sekcji powyżej, jeśli jeszcze tego nie zrobiłeś."
},
"link_device": {
"title": "Połącz aplikację mobilną",
"description": "Zaloguj się do aplikacji mobilnej Bulwark Mail bez wpisywania czegokolwiek. Wygeneruj tutaj kod QR i zeskanuj go na ekranie logowania aplikacji.",
"generate": "Pokaż kod QR",
"regenerate": "Pokaż nowy kod",
"instructions": "Otwórz aplikację Bulwark Mail, na ekranie logowania stuknij \"Skanuj kod QR\" i skieruj aparat tutaj.",
"expires_in": "Ten kod wygaśnie za {seconds} sekund. Można go użyć tylko raz.",
"expired": "Ten kod wygasł.",
"generating": "Generowanie…",
"error": "Nie udało się utworzyć kodu parowania. Spróbuj ponownie."
}
},
"identities": {
@@ -1837,7 +1882,11 @@
},
"search_placeholder": "Szukaj ustawień",
"search_clear": "Wyczyść wyszukiwanie",
"search_no_results": "Brak pasujących ustawień"
"search_no_results": "Brak pasujących ustawień",
"scoped": {
"back": "Powrót do mojego konta",
"managing": "Zarządzanie: {name}"
}
},
"errors": {
"page_error_title": "Coś poszło nie tak",
@@ -2490,7 +2539,37 @@
"every_n_weeks": "Co {count} tygodni",
"every_n_months": "Co {count} miesięcy",
"until": "Do",
"occurrences": "{count} wystąpień"
"occurrences": "{count} wystąpień",
"custom": "Niestandardowe…",
"edit_custom": "Edytuj niestandardowe powtarzanie",
"every_n_years": "Co {count} lat",
"on_days": "w {days}",
"on_day_n": "{day}. dnia miesiąca",
"on_the_nth": "w {nth} {day}",
"in_month": "w {month}",
"nth_1": "pierwszy",
"nth_2": "drugi",
"nth_3": "trzeci",
"nth_4": "czwarty",
"nth_last": "ostatni",
"editor_freq_day": "Dzień",
"editor_freq_week": "Tydzień",
"editor_freq_month": "Miesiąc",
"editor_freq_year": "Rok",
"editor_repeats_on": "Powtarza się",
"editor_every": "Co",
"editor_unit_days": "dni",
"editor_unit_weeks": "tyg.",
"editor_unit_months": "mies.",
"editor_unit_years": "lat(a)",
"editor_on_day": "dnia",
"editor_on_the": "w",
"editor_in": "w miesiącu",
"editor_ends": "Kończy się",
"editor_never": "Nigdy",
"editor_ends_on": "Dnia",
"editor_ends_after": "Po",
"editor_occurrences": "wystąpieniach"
},
"recurrence_scope": {
"edit_title": "Edytuj wydarzenie cykliczne",
@@ -2652,6 +2731,9 @@
"default": "Domyślny",
"confirm_delete": "Usunąć \"{name}\"? Wszystkie wydarzenia w tym kalendarzu zostaną usunięte.",
"confirm_clear": "Wyczyścić wszystkie wydarzenia z \"{name}\"? Tej operacji nie można cofnąć.",
"set_default": "Ustaw jako domyślny",
"default_updated": "Zaktualizowano domyślny kalendarz",
"error_default": "Nie udało się ustawić domyślnego kalendarza",
"clear_events": "Wyczyść wydarzenia",
"events_cleared": "Wyczyszczono {count} wydarzeń",
"error_clear": "Nie udało się wyczyścić wydarzeń kalendarza",
@@ -2877,6 +2959,10 @@
"duplicate": "Duplikuj",
"duplicate_success": "Zduplikowano pomyślnie",
"duplicate_error": "Nie udało się zduplikować",
"share": "Share",
"shared": "Shared",
"shared_with_me": "Shared with me",
"shared_by": "Shared by {name}",
"create_file_success": "Plik utworzono",
"create_file_error": "Nie udało się utworzyć pliku",
"favorites": "Ulubione",
+90 -4
View File
@@ -544,7 +544,25 @@
"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"
"undo_send": "Desfazer envio",
"contact_sidebar": {
"title": "Contato",
"close": "Fechar barra lateral",
"action_email": "E-mail",
"action_email_title": "Enviar e-mail",
"action_copy": "Copiar",
"action_copy_title": "Copiar e-mail",
"action_edit_title": "Editar contato",
"section_emails": "E-mails",
"section_phones": "Telefones",
"section_organizations": "Organizações",
"section_addresses": "Endereços",
"section_notes": "Notas",
"not_in_contacts": "Não está nos seus contatos",
"add_to_contacts": "Adicionar aos contatos",
"copied": "Copiado!",
"copy_failed": "Falha ao copiar"
}
},
"email_composer": {
"read_receipt_on": "Confirmação de leitura solicitada (clique para desativar)",
@@ -649,7 +667,11 @@
"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?"
"send_delay_unsupported_confirm": "Esta conta não oferece atraso de envio. Enviar imediatamente?",
"recipient_edit_email": "Editar endereço de e-mail",
"recipient_edit_name": "Editar nome de exibição",
"recipient_email_placeholder": "Endereço de e-mail",
"recipient_name_placeholder": "Nome de exibição"
},
"confirm_dialog": {
"confirm": "Confirmar",
@@ -892,6 +914,13 @@
"description": "Incluir também as caixas partilhadas ou de grupo na vista unificada."
}
},
"all_mail": {
"label": "All Mail",
"description": "Show an \"All Mail\" entry above your folders that merges messages from across this account's folders into one list.",
"folders_label": "Folders in All Mail",
"folders_description": "Choose which folders are merged into the All Mail view.",
"no_folders": "No folders available."
},
"colorful_sidebar_icons": {
"label": "Ícones coloridos na barra lateral",
"description": "Colorir ícones de pastas e etiquetas por tipo (Caixa de entrada azul, Spam vermelho, Enviados verde, etc.). Desative para uma barra lateral monocromática."
@@ -1282,6 +1311,11 @@
"move_down": "Mover para baixo",
"drag_handle": "Arraste para reordenar",
"add": "Adicionar conta"
},
"shared_accounts": {
"title": "Compartilhado comigo",
"description": "Contas de grupo e compartilhadas que você pode gerenciar. Selecione uma para editar seus filtros, resposta de ausência, calendários e contatos.",
"shared_label": "Conta compartilhada"
}
},
"security": {
@@ -1380,6 +1414,17 @@
"copy": "Copiar",
"copied": "Copiado",
"password_instructions": "Use seu nome de usuário JMAP acima junto com uma senha de aplicativo para entrar no seu cliente de e-mail. Crie uma senha de aplicativo na seção acima se ainda não tiver uma."
},
"link_device": {
"title": "Vincular aplicativo móvel",
"description": "Entre no aplicativo móvel Bulwark Mail sem digitar nada. Gere um código QR aqui e escaneie-o na tela de login do aplicativo.",
"generate": "Mostrar código QR",
"regenerate": "Mostrar um novo código",
"instructions": "Abra o aplicativo Bulwark Mail, toque em \"Escanear código QR\" na tela de login e aponte sua câmera para cá.",
"expires_in": "Este código expira em {seconds} segundos. Ele só pode ser usado uma vez.",
"expired": "Este código expirou.",
"generating": "Gerando…",
"error": "Não foi possível criar um código de pareamento. Tente novamente."
}
},
"identities": {
@@ -1837,7 +1882,11 @@
},
"search_placeholder": "Pesquisar configurações",
"search_clear": "Limpar pesquisa",
"search_no_results": "Nenhuma configuração correspondente"
"search_no_results": "Nenhuma configuração correspondente",
"scoped": {
"back": "Voltar para minha conta",
"managing": "Gerenciando: {name}"
}
},
"errors": {
"page_error_title": "Algo deu errado",
@@ -2490,7 +2539,37 @@
"every_n_weeks": "A cada {count} semanas",
"every_n_months": "A cada {count} meses",
"until": "Até",
"occurrences": "{count} repetições"
"occurrences": "{count} repetições",
"custom": "Personalizado…",
"edit_custom": "Editar recorrência personalizada",
"every_n_years": "A cada {count} anos",
"on_days": "em {days}",
"on_day_n": "no dia {day}",
"on_the_nth": "no {nth} {day}",
"in_month": "em {month}",
"nth_1": "1.º",
"nth_2": "2.º",
"nth_3": "3.º",
"nth_4": "4.º",
"nth_last": "último",
"editor_freq_day": "Dia",
"editor_freq_week": "Semana",
"editor_freq_month": "Mês",
"editor_freq_year": "Ano",
"editor_repeats_on": "Repete-se",
"editor_every": "A cada",
"editor_unit_days": "dia(s)",
"editor_unit_weeks": "semana(s)",
"editor_unit_months": "mês(es)",
"editor_unit_years": "ano(s)",
"editor_on_day": "no dia",
"editor_on_the": "no",
"editor_in": "em",
"editor_ends": "Termina",
"editor_never": "Nunca",
"editor_ends_on": "Em",
"editor_ends_after": "Após",
"editor_occurrences": "repetições"
},
"recurrence_scope": {
"edit_title": "Editar evento recorrente",
@@ -2676,6 +2755,9 @@
"copy_url": "Copiar URL CalDAV",
"url_copied": "URL CalDAV copiada para a área de transferência",
"confirm_clear": "Limpar todos os eventos de \"{name}\"? Esta ação não pode ser desfeita.",
"set_default": "Definir como padrão",
"default_updated": "Calendário padrão atualizado",
"error_default": "Falha ao definir calendário padrão",
"clear_events": "Limpar eventos",
"events_cleared": "{count} eventos removidos",
"error_clear": "Falha ao limpar os eventos do calendário",
@@ -2877,6 +2959,10 @@
"duplicate": "Duplicar",
"duplicate_success": "Duplicado com sucesso",
"duplicate_error": "Falha ao duplicar",
"share": "Share",
"shared": "Shared",
"shared_with_me": "Shared with me",
"shared_by": "Shared by {name}",
"create_file_success": "Arquivo criado",
"create_file_error": "Falha ao criar arquivo",
"favorites": "Favoritos",
+90 -4
View File
@@ -544,7 +544,25 @@
"reschedule_prompt": "Введите новую дату/время, например 2026-05-04T15:30",
"scheduled_actions_only": "В представлении запланированных доступны только действия планирования",
"undo_send_scheduled": "Сообщение запланировано к отправке",
"undo_send": "Отменить отправку"
"undo_send": "Отменить отправку",
"contact_sidebar": {
"title": "Контакт",
"close": "Закрыть боковую панель",
"action_email": "Письмо",
"action_email_title": "Отправить письмо",
"action_copy": "Копировать",
"action_copy_title": "Копировать адрес",
"action_edit_title": "Редактировать контакт",
"section_emails": "Электронная почта",
"section_phones": "Телефоны",
"section_organizations": "Организации",
"section_addresses": "Адреса",
"section_notes": "Заметки",
"not_in_contacts": "Нет в ваших контактах",
"add_to_contacts": "Добавить в контакты",
"copied": "Скопировано!",
"copy_failed": "Не удалось скопировать"
}
},
"email_composer": {
"read_receipt_on": "Запрошено уведомление о прочтении (нажмите, чтобы отключить)",
@@ -649,7 +667,11 @@
"schedule_send_unsupported": "Запланированная отправка не поддерживается для этой учетной записи.",
"schedule_send_cleanup_warning": "Запланированная отправка создана, но очистка черновика не удалась.",
"send_delay_unsupported": "Задержка отправки не поддерживается для этой учетной записи.",
"send_delay_unsupported_confirm": "Эта учетная запись не поддерживает задержку отправки. Отправить сразу?"
"send_delay_unsupported_confirm": "Эта учетная запись не поддерживает задержку отправки. Отправить сразу?",
"recipient_edit_email": "Изменить адрес эл. почты",
"recipient_edit_name": "Изменить отображаемое имя",
"recipient_email_placeholder": "Адрес эл. почты",
"recipient_name_placeholder": "Отображаемое имя"
},
"confirm_dialog": {
"confirm": "Подтвердить",
@@ -892,6 +914,13 @@
"description": "Также объединять общие/групповые ящики в едином представлении."
}
},
"all_mail": {
"label": "All Mail",
"description": "Show an \"All Mail\" entry above your folders that merges messages from across this account's folders into one list.",
"folders_label": "Folders in All Mail",
"folders_description": "Choose which folders are merged into the All Mail view.",
"no_folders": "No folders available."
},
"colorful_sidebar_icons": {
"label": "Цветные значки боковой панели",
"description": "Окрашивать значки папок и тегов по типу (синий «Входящие», красный «Спам», зелёный «Отправленные» и т. д.). Отключите для монохромной боковой панели."
@@ -1282,6 +1311,11 @@
"move_down": "Переместить вниз",
"drag_handle": "Перетащите, чтобы изменить порядок",
"add": "Добавить учётную запись"
},
"shared_accounts": {
"title": "Доступные мне",
"description": "Групповые и общие учётные записи, которыми вы можете управлять. Выберите одну, чтобы изменить её фильтры, автоответчик, календари и контакты.",
"shared_label": "Общая учётная запись"
}
},
"security": {
@@ -1380,6 +1414,17 @@
"copy": "Копировать",
"copied": "Скопировано",
"password_instructions": "Используйте указанное выше имя пользователя JMAP вместе с паролем приложения для входа в почтовый клиент. Создайте пароль приложения в разделе выше, если вы ещё этого не сделали."
},
"link_device": {
"title": "Привязать мобильное приложение",
"description": "Войдите в мобильное приложение Bulwark Mail без ввода данных. Создайте здесь QR-код и отсканируйте его на экране входа в приложении.",
"generate": "Показать QR-код",
"regenerate": "Показать новый код",
"instructions": "Откройте приложение Bulwark Mail, нажмите \"Сканировать QR-код\" на экране входа и наведите камеру сюда.",
"expires_in": "Срок действия кода истечёт через {seconds} секунд. Его можно использовать только один раз.",
"expired": "Срок действия кода истёк.",
"generating": "Создание…",
"error": "Не удалось создать код привязки. Пожалуйста, попробуйте ещё раз."
}
},
"identities": {
@@ -1837,7 +1882,11 @@
},
"search_placeholder": "Поиск настроек",
"search_clear": "Очистить поиск",
"search_no_results": "Нет подходящих настроек"
"search_no_results": "Нет подходящих настроек",
"scoped": {
"back": "Назад к моей учётной записи",
"managing": "Управление: {name}"
}
},
"errors": {
"page_error_title": "Что-то пошло не так",
@@ -2490,7 +2539,37 @@
"every_n_weeks": "Каждые {count} недель",
"every_n_months": "Каждые {count} месяцев",
"until": "До",
"occurrences": "{count} повторений"
"occurrences": "{count} повторений",
"custom": "Свой вариант…",
"edit_custom": "Изменить настраиваемое повторение",
"every_n_years": "Каждые {count} лет",
"on_days": "в {days}",
"on_day_n": "{day}-го числа",
"on_the_nth": "в {nth} {day}",
"in_month": "в {month}",
"nth_1": "первый",
"nth_2": "второй",
"nth_3": "третий",
"nth_4": "четвёртый",
"nth_last": "последний",
"editor_freq_day": "День",
"editor_freq_week": "Неделя",
"editor_freq_month": "Месяц",
"editor_freq_year": "Год",
"editor_repeats_on": "Повторяется",
"editor_every": "Каждые",
"editor_unit_days": "дн.",
"editor_unit_weeks": "нед.",
"editor_unit_months": "мес.",
"editor_unit_years": "г.",
"editor_on_day": "в день",
"editor_on_the": "в",
"editor_in": "в месяце",
"editor_ends": "Заканчивается",
"editor_never": "Никогда",
"editor_ends_on": "Дата",
"editor_ends_after": "После",
"editor_occurrences": "повторений"
},
"recurrence_scope": {
"edit_title": "Редактировать повторяющееся событие",
@@ -2652,6 +2731,9 @@
"default": "По умолчанию",
"confirm_delete": "Удалить «{name}»? Все события в этом календаре будут удалены.",
"confirm_clear": "Очистить все события из «{name}»? Это действие нельзя отменить.",
"set_default": "Сделать по умолчанию",
"default_updated": "Календарь по умолчанию обновлён",
"error_default": "Не удалось установить календарь по умолчанию",
"clear_events": "Очистить события",
"events_cleared": "{count} событий очищено",
"error_clear": "Не удалось очистить события календаря",
@@ -2877,6 +2959,10 @@
"duplicate": "Дублировать",
"duplicate_success": "Дублирование выполнено успешно",
"duplicate_error": "Не удалось дублировать",
"share": "Share",
"shared": "Shared",
"shared_with_me": "Shared with me",
"shared_by": "Shared by {name}",
"create_file_success": "Файл создан",
"create_file_error": "Не удалось создать файл",
"favorites": "Избранное",
+90 -4
View File
@@ -544,7 +544,25 @@
"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"
"undo_send": "Göndermeyi geri al",
"contact_sidebar": {
"title": "Kişi",
"close": "Kenar çubuğunu kapat",
"action_email": "E-posta",
"action_email_title": "E-posta gönder",
"action_copy": "Kopyala",
"action_copy_title": "E-postayı kopyala",
"action_edit_title": "Kişiyi düzenle",
"section_emails": "E-postalar",
"section_phones": "Telefonlar",
"section_organizations": "Kuruluşlar",
"section_addresses": "Adresler",
"section_notes": "Notlar",
"not_in_contacts": "Kişilerinizde yok",
"add_to_contacts": "Kişilere ekle",
"copied": "Kopyalandı!",
"copy_failed": "Kopyalanamadı"
}
},
"email_composer": {
"read_receipt_on": "Okundu bilgisi istendi (devre dışı bırakmak için tıklayın)",
@@ -649,7 +667,11 @@
"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?"
"send_delay_unsupported_confirm": "Bu hesap gönderim gecikmesini desteklemiyor. Hemen gönderilsin mi?",
"recipient_edit_email": "E-posta adresini düzenle",
"recipient_edit_name": "Görünen adı düzenle",
"recipient_email_placeholder": "E-posta adresi",
"recipient_name_placeholder": "Görünen ad"
},
"confirm_dialog": {
"confirm": "Onayla",
@@ -892,6 +914,13 @@
"description": "Paylaşılan/grup gelen kutularını da birleşik görünüme dahil et."
}
},
"all_mail": {
"label": "All Mail",
"description": "Show an \"All Mail\" entry above your folders that merges messages from across this account's folders into one list.",
"folders_label": "Folders in All Mail",
"folders_description": "Choose which folders are merged into the All Mail view.",
"no_folders": "No folders available."
},
"colorful_sidebar_icons": {
"label": "Renkli Kenar Çubuğu Simgeleri",
"description": "Klasör ve etiket simgelerini türe göre renklendir (mavi Gelen Kutusu, kırmızı İstem Dışı vb.). Tek renkli kenar çubuğu için kapatın."
@@ -1282,6 +1311,11 @@
"move_down": "Aşağı taşı",
"drag_handle": "Sıralamak için sürükleyin",
"add": "Hesap ekle"
},
"shared_accounts": {
"title": "Benimle paylaşılanlar",
"description": "Yönetebileceğiniz grup ve paylaşılan hesaplar. Filtrelerini, tatil yanıtlayıcısını, takvimlerini ve kişilerini düzenlemek için birini seçin.",
"shared_label": "Paylaşılan hesap"
}
},
"security": {
@@ -1380,6 +1414,17 @@
"copy": "Kopyala",
"copied": "Kopyalandı",
"password_instructions": "E-posta istemcinizde oturum açmak için yukarıdaki JMAP kullanıcı adı ile bir uygulama parolasını birlikte kullanın. Henüz oluşturmadıysanız yukarıdaki bölümden bir uygulama parolası oluşturun."
},
"link_device": {
"title": "Mobil uygulamayı bağla",
"description": "Hiçbir şey yazmadan Bulwark Mail mobil uygulamasında oturum açın. Burada bir QR kodu oluşturun ve uygulamanın giriş ekranından tarayın.",
"generate": "QR kodunu göster",
"regenerate": "Yeni kod göster",
"instructions": "Bulwark Mail uygulamasını açın, giriş ekranında \"QR kodu tara\" seçeneğine dokunun ve kameranızı buraya doğrultun.",
"expires_in": "Bu kodun süresi {seconds} saniye içinde dolacak. Yalnızca bir kez kullanılabilir.",
"expired": "Bu kodun süresi doldu.",
"generating": "Oluşturuluyor…",
"error": "Eşleştirme kodu oluşturulamadı. Lütfen tekrar deneyin."
}
},
"identities": {
@@ -1837,7 +1882,11 @@
},
"search_placeholder": "Ayarlarda ara",
"search_clear": "Aramayı temizle",
"search_no_results": "Eşleşen ayar yok"
"search_no_results": "Eşleşen ayar yok",
"scoped": {
"back": "Hesabıma geri dön",
"managing": "Yönetiliyor: {name}"
}
},
"errors": {
"page_error_title": "Bir şeyler ters gitti",
@@ -2490,7 +2539,37 @@
"every_n_weeks": "Her {count} haftada bir",
"every_n_months": "Her {count} ayda bir",
"until": "Şu tarihe kadar",
"occurrences": "{count} tekrar"
"occurrences": "{count} tekrar",
"custom": "Özel…",
"edit_custom": "Özel yinelemeyi düzenle",
"every_n_years": "Her {count} yılda bir",
"on_days": "{days} günleri",
"on_day_n": "ayın {day}. günü",
"on_the_nth": "{nth} {day} günü",
"in_month": "{month} ayında",
"nth_1": "ilk",
"nth_2": "ikinci",
"nth_3": "üçüncü",
"nth_4": "dördüncü",
"nth_last": "son",
"editor_freq_day": "Gün",
"editor_freq_week": "Hafta",
"editor_freq_month": "Ay",
"editor_freq_year": "Yıl",
"editor_repeats_on": "Yinelenme",
"editor_every": "Her",
"editor_unit_days": "günde bir",
"editor_unit_weeks": "haftada bir",
"editor_unit_months": "ayda bir",
"editor_unit_years": "yılda bir",
"editor_on_day": "ayın şu günü:",
"editor_on_the": "şu gün:",
"editor_in": "ay:",
"editor_ends": "Bitiş",
"editor_never": "Asla",
"editor_ends_on": "Tarihinde",
"editor_ends_after": "Sonra:",
"editor_occurrences": "tekrar"
},
"recurrence_scope": {
"edit_title": "Yinelenen etkinliği düzenle",
@@ -2666,6 +2745,9 @@
"default": "Varsayılan",
"confirm_delete": "\"{name}\" silinsin mi? Bu takvimdeki tüm etkinlikler kaldırılacak.",
"confirm_clear": "\"{name}\" takvimdeki tüm etkinlikler temizlensin mi? Bu geri alınamaz.",
"set_default": "Varsayılan olarak ayarla",
"default_updated": "Varsayılan takvim güncellendi",
"error_default": "Varsayılan takvim ayarlanamadı",
"clear_events": "Etkinlikleri temizle",
"events_cleared": "{count} etkinlik temizlendi",
"error_clear": "Takvim etkinlikleri temizlenemedi",
@@ -2900,6 +2982,10 @@
"duplicate": "Çoğalt",
"duplicate_success": "Başarıyla çoğaltıldı",
"duplicate_error": "Çoğaltılamadı",
"share": "Share",
"shared": "Shared",
"shared_with_me": "Shared with me",
"shared_by": "Shared by {name}",
"create_file_success": "Dosya oluşturuldu",
"create_file_error": "Dosya oluşturulamadı",
"favorites": "Favoriler",
+90 -4
View File
@@ -544,7 +544,25 @@
"reschedule_prompt": "Введіть нову дату/час, напр. 2026-05-04T15:30",
"scheduled_actions_only": "У поданні Заплановано доступні лише дії планування",
"undo_send_scheduled": "Повідомлення заплановано до надсилання",
"undo_send": "Скасувати надсилання"
"undo_send": "Скасувати надсилання",
"contact_sidebar": {
"title": "Контакт",
"close": "Закрити бічну панель",
"action_email": "Лист",
"action_email_title": "Надіслати лист",
"action_copy": "Копіювати",
"action_copy_title": "Копіювати адресу",
"action_edit_title": "Редагувати контакт",
"section_emails": "Електронні адреси",
"section_phones": "Телефони",
"section_organizations": "Організації",
"section_addresses": "Адреси",
"section_notes": "Нотатки",
"not_in_contacts": "Немає у ваших контактах",
"add_to_contacts": "Додати до контактів",
"copied": "Скопійовано!",
"copy_failed": "Не вдалося скопіювати"
}
},
"email_composer": {
"read_receipt_on": "Запитано сповіщення про прочитання (натисніть, щоб вимкнути)",
@@ -649,7 +667,11 @@
"schedule_send_unsupported": "Заплановане надсилання не підтримується для цього облікового запису.",
"schedule_send_cleanup_warning": "Заплановане надсилання створено, але очищення чернетки не вдалося.",
"send_delay_unsupported": "Затримка надсилання не підтримується для цього облікового запису.",
"send_delay_unsupported_confirm": "Цей обліковий запис не підтримує затримку надсилання. Надіслати негайно?"
"send_delay_unsupported_confirm": "Цей обліковий запис не підтримує затримку надсилання. Надіслати негайно?",
"recipient_edit_email": "Змінити адресу ел. пошти",
"recipient_edit_name": "Змінити відображуване ім'я",
"recipient_email_placeholder": "Адреса ел. пошти",
"recipient_name_placeholder": "Відображуване ім'я"
},
"confirm_dialog": {
"confirm": "Підтвердити",
@@ -892,6 +914,13 @@
"description": "Також об'єднувати спільні/групові скриньки у спільному перегляді."
}
},
"all_mail": {
"label": "All Mail",
"description": "Show an \"All Mail\" entry above your folders that merges messages from across this account's folders into one list.",
"folders_label": "Folders in All Mail",
"folders_description": "Choose which folders are merged into the All Mail view.",
"no_folders": "No folders available."
},
"colorful_sidebar_icons": {
"label": "Кольорові значки бічної панелі",
"description": "Забарвлюйте значки папок і тегів за типом (синя «Вхідні», червоний «Спам», зелена «Надіслані» тощо). Вимкніть для монохромної бічної панелі."
@@ -1282,6 +1311,11 @@
"move_down": "Перемістити вниз",
"drag_handle": "Перетягніть, щоб змінити порядок",
"add": "Додати обліковий запис"
},
"shared_accounts": {
"title": "Доступні мені",
"description": "Групові та спільні облікові записи, якими ви можете керувати. Виберіть один, щоб змінити його фільтри, автовідповідач, календарі та контакти.",
"shared_label": "Спільний обліковий запис"
}
},
"security": {
@@ -1380,6 +1414,17 @@
"copy": "Копія",
"copied": "Скопійовано",
"password_instructions": "Використовуйте своє ім’я користувача JMAP вище разом із паролем програми, щоб увійти у свій поштовий клієнт. Створіть пароль програми в розділі вище, якщо ви ще цього не зробили."
},
"link_device": {
"title": "Прив'язати мобільний застосунок",
"description": "Увійдіть у мобільний застосунок Bulwark Mail без введення даних. Створіть тут QR-код і відскануйте його на екрані входу в застосунку.",
"generate": "Показати QR-код",
"regenerate": "Показати новий код",
"instructions": "Відкрийте застосунок Bulwark Mail, натисніть \"Сканувати QR-код\" на екрані входу та наведіть камеру сюди.",
"expires_in": "Термін дії коду закінчиться через {seconds} секунд. Його можна використати лише один раз.",
"expired": "Термін дії цього коду закінчився.",
"generating": "Створення…",
"error": "Не вдалося створити код прив'язки. Будь ласка, спробуйте ще раз."
}
},
"identities": {
@@ -1837,7 +1882,11 @@
},
"search_placeholder": "Пошук у налаштуваннях",
"search_clear": "Очистити пошук",
"search_no_results": "Немає відповідних налаштувань"
"search_no_results": "Немає відповідних налаштувань",
"scoped": {
"back": "Назад до мого облікового запису",
"managing": "Керування: {name}"
}
},
"errors": {
"page_error_title": "Щось пішло не так",
@@ -2490,7 +2539,37 @@
"every_n_weeks": "Кожні {count} тижнів",
"every_n_months": "Кожні {count} місяців",
"until": "Поки",
"occurrences": "{count} випадків"
"occurrences": "{count} випадків",
"custom": "Власний…",
"edit_custom": "Змінити власне повторення",
"every_n_years": "Кожні {count} років",
"on_days": "у {days}",
"on_day_n": "{day}-го числа",
"on_the_nth": "у {nth} {day}",
"in_month": "у {month}",
"nth_1": "перший",
"nth_2": "другий",
"nth_3": "третій",
"nth_4": "четвертий",
"nth_last": "останній",
"editor_freq_day": "День",
"editor_freq_week": "Тиждень",
"editor_freq_month": "Місяць",
"editor_freq_year": "Рік",
"editor_repeats_on": "Повторюється",
"editor_every": "Кожні",
"editor_unit_days": "дн.",
"editor_unit_weeks": "тиж.",
"editor_unit_months": "міс.",
"editor_unit_years": "р.",
"editor_on_day": "у день",
"editor_on_the": "у",
"editor_in": "у місяці",
"editor_ends": "Закінчується",
"editor_never": "Ніколи",
"editor_ends_on": "Дата",
"editor_ends_after": "Після",
"editor_occurrences": "повторень"
},
"recurrence_scope": {
"edit_title": "Редагувати повторювану подію",
@@ -2652,6 +2731,9 @@
"default": "За замовчуванням",
"confirm_delete": "Видалити \"{name}\"? Усі події в цьому календарі буде видалено.",
"confirm_clear": "Очистити всі події з \"{name}\"? Це неможливо скасувати.",
"set_default": "Зробити типовим",
"default_updated": "Типовий календар оновлено",
"error_default": "Не вдалося встановити типовий календар",
"clear_events": "Ясні події",
"events_cleared": "{count} подій видалено",
"error_clear": "Не вдалося очистити події календаря",
@@ -2877,6 +2959,10 @@
"duplicate": "дублікат",
"duplicate_success": "Продубльовано успішно",
"duplicate_error": "Не вдалося скопіювати",
"share": "Share",
"shared": "Shared",
"shared_with_me": "Shared with me",
"shared_by": "Shared by {name}",
"create_file_success": "Файл створено",
"create_file_error": "Не вдалося створити файл",
"favorites": "Вибране",
+90 -4
View File
@@ -544,7 +544,25 @@
"reschedule_prompt": "输入新的日期/时间,例如 2026-05-04T15:30",
"scheduled_actions_only": "计划发送视图仅支持计划操作",
"undo_send_scheduled": "邮件已计划发送",
"undo_send": "撤销发送"
"undo_send": "撤销发送",
"contact_sidebar": {
"title": "联系人",
"close": "关闭侧边栏",
"action_email": "邮件",
"action_email_title": "发送邮件",
"action_copy": "复制",
"action_copy_title": "复制邮箱地址",
"action_edit_title": "编辑联系人",
"section_emails": "邮箱",
"section_phones": "电话",
"section_organizations": "组织",
"section_addresses": "地址",
"section_notes": "备注",
"not_in_contacts": "不在您的联系人中",
"add_to_contacts": "添加到联系人",
"copied": "已复制!",
"copy_failed": "复制失败"
}
},
"email_composer": {
"read_receipt_on": "已请求已读回执(点击以关闭)",
@@ -649,7 +667,11 @@
"schedule_send_unsupported": "此账户不支持计划发送。",
"schedule_send_cleanup_warning": "已创建计划发送,但清理草稿失败。",
"send_delay_unsupported": "此账户不支持发送延迟。",
"send_delay_unsupported_confirm": "此账户不支持发送延迟。是否立即发送?"
"send_delay_unsupported_confirm": "此账户不支持发送延迟。是否立即发送?",
"recipient_edit_email": "编辑电子邮件地址",
"recipient_edit_name": "编辑显示名称",
"recipient_email_placeholder": "电子邮件地址",
"recipient_name_placeholder": "显示名称"
},
"confirm_dialog": {
"confirm": "确认",
@@ -892,6 +914,13 @@
"description": "在统一视图中也合并共享/群组收件箱。"
}
},
"all_mail": {
"label": "All Mail",
"description": "Show an \"All Mail\" entry above your folders that merges messages from across this account's folders into one list.",
"folders_label": "Folders in All Mail",
"folders_description": "Choose which folders are merged into the All Mail view.",
"no_folders": "No folders available."
},
"colorful_sidebar_icons": {
"label": "彩色侧边栏图标",
"description": "按类型为文件夹和标签图标着色(蓝色收件箱、红色垃圾邮件、绿色已发送等)。禁用以获得单色侧边栏。"
@@ -1282,6 +1311,11 @@
"move_down": "下移",
"drag_handle": "拖动以重新排序",
"add": "添加账户"
},
"shared_accounts": {
"title": "与我共享",
"description": "您可以管理的群组和共享账户。选择一个以编辑其筛选器、休假自动回复、日历和联系人。",
"shared_label": "共享账户"
}
},
"security": {
@@ -1380,6 +1414,17 @@
"copy": "复制",
"copied": "已复制",
"password_instructions": "使用上面的 JMAP 用户名和应用密码登录您的邮件客户端。如果您还没有创建应用密码,请在上面的部分中创建。"
},
"link_device": {
"title": "关联移动应用",
"description": "无需输入任何内容即可登录 Bulwark Mail 移动应用。在此生成二维码,然后在应用的登录界面扫描。",
"generate": "显示二维码",
"regenerate": "显示新的二维码",
"instructions": "打开 Bulwark Mail 应用,在登录界面点按\"扫描二维码\",然后将相机对准此处。",
"expires_in": "此二维码将在 {seconds} 秒后过期,且只能使用一次。",
"expired": "此二维码已过期。",
"generating": "生成中…",
"error": "无法创建配对码。请重试。"
}
},
"identities": {
@@ -1837,7 +1882,11 @@
},
"search_placeholder": "搜索设置",
"search_clear": "清除搜索",
"search_no_results": "没有匹配的设置"
"search_no_results": "没有匹配的设置",
"scoped": {
"back": "返回我的账户",
"managing": "管理:{name}"
}
},
"errors": {
"page_error_title": "出了点问题",
@@ -2490,7 +2539,37 @@
"every_n_weeks": "每 {count} 周",
"every_n_months": "每 {count} 个月",
"until": "直到",
"occurrences": "{count} 次出现"
"occurrences": "{count} 次出现",
"custom": "自定义…",
"edit_custom": "编辑自定义重复规则",
"every_n_years": "每 {count} 年",
"on_days": "{days}",
"on_day_n": "{day}日",
"on_the_nth": "{nth}{day}",
"in_month": "{month}",
"nth_1": "第一个",
"nth_2": "第二个",
"nth_3": "第三个",
"nth_4": "第四个",
"nth_last": "最后一个",
"editor_freq_day": "天",
"editor_freq_week": "周",
"editor_freq_month": "月",
"editor_freq_year": "年",
"editor_repeats_on": "重复于",
"editor_every": "每",
"editor_unit_days": "天",
"editor_unit_weeks": "周",
"editor_unit_months": "个月",
"editor_unit_years": "年",
"editor_on_day": "日期:",
"editor_on_the": "在",
"editor_in": "月份:",
"editor_ends": "结束",
"editor_never": "永不",
"editor_ends_on": "于",
"editor_ends_after": "次数:",
"editor_occurrences": "次"
},
"recurrence_scope": {
"edit_title": "编辑重复事件",
@@ -2652,6 +2731,9 @@
"default": "默认",
"confirm_delete": "删除\"{name}\"?此日历中的所有活动都将被删除。",
"confirm_clear": "清除\"{name}\"中的所有活动吗?此操作无法撤销。",
"set_default": "设为默认",
"default_updated": "默认日历已更新",
"error_default": "无法设置默认日历",
"clear_events": "清除活动",
"events_cleared": "{count} 个活动已清除",
"error_clear": "无法清除日历活动",
@@ -2877,6 +2959,10 @@
"duplicate": "创建副本",
"duplicate_success": "复制成功",
"duplicate_error": "复制失败",
"share": "Share",
"shared": "Shared",
"shared_with_me": "Shared with me",
"shared_by": "Shared by {name}",
"create_file_success": "文件已创建",
"create_file_error": "创建文件失败",
"favorites": "收藏夹",
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "bulwark-webmail",
"version": "1.7.3",
"version": "1.7.4",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "bulwark-webmail",
"version": "1.7.3",
"version": "1.7.4",
"license": "AGPL-3.0-only",
"dependencies": {
"@tanstack/react-virtual": "^3.13.24",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "bulwark-webmail",
"version": "1.7.3",
"version": "1.7.4",
"description": "Bulwark Webmail - a modern webmail client built for Stalwart Mail Server",
"author": "Bulwark Webmail <bulwark@rbm.systems>",
"license": "AGPL-3.0-only",
+49
View File
@@ -63,6 +63,7 @@ CFG_OAUTH_ISSUER_URL=""
CFG_SESSION_SECRET=""
CFG_SETTINGS_SYNC_ENABLED="false"
CFG_SETTINGS_DATA_DIR="./data/settings"
CFG_TELEMETRY="false"
CFG_LOG_FORMAT="text"
CFG_LOG_LEVEL="info"
CFG_APP_SHORT_NAME=""
@@ -733,6 +734,30 @@ screen_security_config() {
echo -e " ${DIM}You can add a SESSION_SECRET to .env.local at any time.${RESET}"
fi
echo ""
hr
echo ""
echo -e " ${BOLD}Anonymous Usage Stats${RESET}"
echo ""
echo -e " Bulwark can send one anonymous heartbeat per day. It helps us see how"
echo -e " many instances run, on what platforms, and which features are enabled"
echo -e " so we can ${BOLD}make the product better${RESET}."
echo ""
echo -e " ${STAR} ${BOLD}No private data${RESET} - no email addresses, hostnames, or IPs"
echo -e " ${STAR} Just version, platform, and which features are turned on"
echo -e " ${STAR} ${BOLD}Off by default${RESET} - you can change it any time in the admin UI"
echo ""
prompt_yesno "Enable anonymous telemetry to help improve Bulwark?" "$CFG_TELEMETRY" "CFG_TELEMETRY"
if [[ "$CFG_TELEMETRY" == "true" ]]; then
echo ""
echo -e " ${OK} ${GREEN}Thanks! Telemetry will be enabled. We appreciate it.${RESET}"
else
echo ""
note "Telemetry stays off. No heartbeats will be sent."
fi
draw_footer
read -r
}
@@ -948,6 +973,11 @@ screen_summary() {
else
echo -e " Session Secret ........ ${DIM}Not set${RESET}"
fi
if [[ "$CFG_TELEMETRY" == "true" ]]; then
echo -e " Anonymous Telemetry ... ${GREEN}${BOLD}Enabled${RESET} ${DIM}(thank you!)${RESET}"
else
echo -e " Anonymous Telemetry ... ${DIM}Off${RESET}"
fi
echo ""
# Logging
@@ -1093,6 +1123,20 @@ ENVEOF
cat >> "$ENV_FILE" << ENVEOF
# -- Anonymous Telemetry -------------------------------------------------------
# Opt-in, anonymous heartbeats (no PII). Off by default; helps improve Bulwark.
# Toggleable later in the admin UI unless this is set. See
# https://bulwarkmail.org/docs/legal/privacy/telemetry
ENVEOF
if [[ "$CFG_TELEMETRY" == "true" ]]; then
echo "BULWARK_TELEMETRY=on" >> "$ENV_FILE"
else
echo "# BULWARK_TELEMETRY=on" >> "$ENV_FILE"
fi
cat >> "$ENV_FILE" << ENVEOF
# -- Logging -------------------------------------------------------------------
LOG_FORMAT=${CFG_LOG_FORMAT}
LOG_LEVEL=${CFG_LOG_LEVEL}
@@ -1340,6 +1384,11 @@ load_existing_config() {
get_env_val "SESSION_SECRET"; [[ -n "$val" ]] && CFG_SESSION_SECRET="$val"
get_env_val "SETTINGS_SYNC_ENABLED"; [[ -n "$val" ]] && CFG_SETTINGS_SYNC_ENABLED="$val"
get_env_val "SETTINGS_DATA_DIR"; [[ -n "$val" ]] && CFG_SETTINGS_DATA_DIR="$val"
get_env_val "BULWARK_TELEMETRY"
case "$(echo "$val" | tr '[:upper:]' '[:lower:]')" in
on|true|1|yes) CFG_TELEMETRY="true" ;;
off|false|0|no) CFG_TELEMETRY="false" ;;
esac
get_env_val "LOG_FORMAT"; [[ -n "$val" ]] && CFG_LOG_FORMAT="$val"
get_env_val "LOG_LEVEL"; [[ -n "$val" ]] && CFG_LOG_LEVEL="$val"
get_env_val "LOGIN_COMPANY_NAME"; [[ -n "$val" ]] && CFG_LOGIN_COMPANY_NAME="$val"
+24
View File
@@ -31,6 +31,8 @@ const defaultState = {
supportsSync: false,
selectedContactIds: new Set<string>(),
activeTab: 'all' as const,
directoryPrincipals: [],
directoryLoaded: false,
};
describe('contact-store', () => {
@@ -273,6 +275,28 @@ describe('contact-store', () => {
const results = useContactStore.getState().getAutocomplete('Multi');
expect(results).toHaveLength(2);
});
it('should augment results with directory principals', () => {
useContactStore.setState({
contacts: [],
directoryPrincipals: [{ name: 'Dana Director', email: 'dana@example.com' }],
});
const results = useContactStore.getState().getAutocomplete('dana');
expect(results).toHaveLength(1);
expect(results[0]).toEqual({ name: 'Dana Director', email: 'dana@example.com' });
});
it('should not duplicate a directory principal already matched as a contact', () => {
useContactStore.setState({
contacts: [
makeContact({ id: 'c1', name: { components: [{ kind: 'given', value: 'Jane' }], isOrdered: true }, emails: { e0: { address: 'jane@example.com' } } }),
],
directoryPrincipals: [{ name: 'Jane From Directory', email: 'JANE@example.com' }],
});
const results = useContactStore.getState().getAutocomplete('jane');
expect(results).toHaveLength(1);
expect(results[0].name).toBe('Jane');
});
});
describe('getGroups', () => {
+84
View File
@@ -3,6 +3,13 @@ import { useFilterStore } from '../filter-store';
import type { FilterRule } from '@/lib/jmap/sieve-types';
import type { IJMAPClient } from '@/lib/jmap/client-interface';
// Minimal account plumbing every fetchFilters mock needs now that the store
// resolves a target Sieve account before fetching scripts.
const sieveAccountMock = {
getSieveAccountId: () => 'primary',
getSieveAccounts: () => [{ id: 'primary', name: 'Me', isPrimary: true }],
};
const makeRule = (overrides: Partial<FilterRule> = {}): FilterRule => ({
id: 'rule-1',
name: 'Test Rule',
@@ -153,6 +160,7 @@ describe('filter-store', () => {
describe('fetchFilters', () => {
it('parses external rules from scripts without metadata', async () => {
const mockClient = {
...sieveAccountMock,
getSieveCapabilities: () => null,
getSieveScripts: async () => [{ id: 's1', name: 'main', blobId: 'b1', isActive: true }],
getSieveScriptContent: async () => 'require ["fileinto"];\nif header :contains "From" "x" { fileinto "Y"; }',
@@ -165,6 +173,7 @@ describe('filter-store', () => {
it('sets isOpaque for truly unparseable content', async () => {
const mockClient = {
...sieveAccountMock,
getSieveCapabilities: () => null,
getSieveScripts: async () => [{ id: 's1', name: 'main', blobId: 'b1', isActive: true }],
getSieveScriptContent: async () => '/* @metadata:begin\n{corrupt\n@metadata:end */',
@@ -178,6 +187,7 @@ describe('filter-store', () => {
const { generateScript } = await import('@/lib/sieve/generator');
const script = generateScript(rules);
const mockClient = {
...sieveAccountMock,
getSieveCapabilities: () => null,
getSieveScripts: async () => [{ id: 's1', name: 'main', blobId: 'b1', isActive: true }],
getSieveScriptContent: async () => script,
@@ -189,6 +199,7 @@ describe('filter-store', () => {
it('should handle empty script list', async () => {
const mockClient = {
...sieveAccountMock,
getSieveCapabilities: () => null,
getSieveScripts: async () => [],
getSieveScriptContent: async () => '',
@@ -200,6 +211,7 @@ describe('filter-store', () => {
it('should set error on failure', async () => {
const mockClient = {
...sieveAccountMock,
getSieveCapabilities: () => null,
getSieveScripts: async () => { throw new Error('Network error'); },
};
@@ -313,6 +325,7 @@ describe('filter-store', () => {
const inactiveRules = [makeRule({ name: 'Inactive' })];
const mockClient = {
...sieveAccountMock,
getSieveCapabilities: () => null,
getSieveScripts: async () => [
{ id: 's1', name: 'old', blobId: 'b1', isActive: false },
@@ -331,6 +344,7 @@ describe('filter-store', () => {
it('should set sieveCapabilities from client', async () => {
const caps = { implementation: 'test', maxSizeScript: 10000, sieveExtensions: ['fileinto'], notificationMethods: [], externalLists: [] };
const mockClient = {
...sieveAccountMock,
getSieveCapabilities: () => caps,
getSieveScripts: async () => [],
};
@@ -343,6 +357,7 @@ describe('filter-store', () => {
const rules = [makeRule()];
const script = generateScript(rules);
const mockClient = {
...sieveAccountMock,
getSieveCapabilities: () => null,
getSieveScripts: async () => [{ id: 's1', name: 'main', blobId: 'b1', isActive: true }],
getSieveScriptContent: async () => script,
@@ -357,6 +372,7 @@ describe('filter-store', () => {
const script = generateScript(rules);
const mockClient = {
...sieveAccountMock,
getSieveCapabilities: () => null,
getSieveScripts: async () => [
{ id: 'vac-1', name: 'vacation', blobId: 'bv', isActive: true },
@@ -375,6 +391,7 @@ describe('filter-store', () => {
it('should handle only vacation script present (no filter scripts)', async () => {
const mockClient = {
...sieveAccountMock,
getSieveCapabilities: () => null,
getSieveScripts: async () => [
{ id: 'vac-1', name: 'vacation', blobId: 'bv', isActive: true },
@@ -384,5 +401,72 @@ describe('filter-store', () => {
expect(useFilterStore.getState().activeScriptId).toBeNull();
expect(useFilterStore.getState().rules).toEqual([]);
});
it('populates availableAccounts and defaults to the primary account', async () => {
const mockClient = {
getSieveAccountId: () => 'primary',
getSieveAccounts: () => [
{ id: 'primary', name: 'Me', isPrimary: true },
{ id: 'group', name: 'Sales', isPrimary: false },
],
getSieveCapabilities: () => null,
getSieveScripts: async () => [],
};
await useFilterStore.getState().fetchFilters(mockClient as unknown as IJMAPClient);
expect(useFilterStore.getState().availableAccounts).toHaveLength(2);
expect(useFilterStore.getState().selectedAccountId).toBe('primary');
});
});
describe('selectAccount', () => {
it('re-fetches scripts for the chosen account id', async () => {
const fetchedFor: (string | undefined)[] = [];
const mockClient = {
getSieveAccountId: () => 'primary',
getSieveAccounts: () => [
{ id: 'primary', name: 'Me', isPrimary: true },
{ id: 'group', name: 'Sales', isPrimary: false },
],
getSieveCapabilities: () => null,
getSieveScripts: async (accountId?: string) => {
fetchedFor.push(accountId);
return [];
},
};
// Seed some primary-account state to confirm it gets cleared on switch.
useFilterStore.setState({ rules: [makeRule()], activeScriptId: 'old', isOpaque: true });
await useFilterStore.getState().selectAccount(mockClient as unknown as IJMAPClient, 'group');
expect(useFilterStore.getState().selectedAccountId).toBe('group');
expect(fetchedFor).toContain('group');
expect(useFilterStore.getState().rules).toEqual([]);
expect(useFilterStore.getState().isOpaque).toBe(false);
});
});
describe('account threading', () => {
it('passes the selected account id to update and validate', async () => {
const calls: Array<{ method: string; args: unknown[] }> = [];
const mockClient = {
updateSieveScript: async (...args: unknown[]) => { calls.push({ method: 'updateSieveScript', args }); },
createSieveScript: async (...args: unknown[]) => { calls.push({ method: 'createSieveScript', args }); return { id: 'x' }; },
validateSieveScript: async (...args: unknown[]) => { calls.push({ method: 'validateSieveScript', args }); return { isValid: true }; },
};
useFilterStore.setState({
activeScriptId: 'existing-id',
rules: [makeRule()],
isOpaque: false,
selectedAccountId: 'group',
});
await useFilterStore.getState().saveFilters(mockClient as unknown as IJMAPClient);
await useFilterStore.getState().validateScript(mockClient as unknown as IJMAPClient, 'require "fileinto";');
const update = calls.find(c => c.method === 'updateSieveScript');
const validate = calls.find(c => c.method === 'validateSieveScript');
expect(update?.args[3]).toBe('group');
expect(validate?.args[1]).toBe('group');
});
});
});
@@ -0,0 +1,38 @@
import { describe, it, expect, beforeEach } from 'vitest';
import { useManagedAccountStore } from '../managed-account-store';
import type { SharedAccount } from '@/lib/jmap/types';
const sharedAccount: SharedAccount = {
id: 'group-1',
name: 'Sales',
isPrimary: false,
capabilities: { mail: true, sieve: true, calendars: true, contacts: true, filenode: false },
};
describe('managed-account-store', () => {
beforeEach(() => {
useManagedAccountStore.getState().clear();
});
it('defaults to no managed account (own account)', () => {
expect(useManagedAccountStore.getState().managedAccountId).toBeNull();
expect(useManagedAccountStore.getState().managedAccount).toBeNull();
});
it('setManagedAccount enters scoped mode for the account', () => {
useManagedAccountStore.getState().setManagedAccount(sharedAccount);
expect(useManagedAccountStore.getState().managedAccountId).toBe('group-1');
expect(useManagedAccountStore.getState().managedAccount).toEqual(sharedAccount);
});
it('setManagedAccount(null) and clear() return to the own account', () => {
useManagedAccountStore.getState().setManagedAccount(sharedAccount);
useManagedAccountStore.getState().setManagedAccount(null);
expect(useManagedAccountStore.getState().managedAccountId).toBeNull();
expect(useManagedAccountStore.getState().managedAccount).toBeNull();
useManagedAccountStore.getState().setManagedAccount(sharedAccount);
useManagedAccountStore.getState().clear();
expect(useManagedAccountStore.getState().managedAccountId).toBeNull();
});
});
+9 -1
View File
@@ -201,7 +201,15 @@ export const useAccountSecurityStore = create<AccountSecurityState>()((set, get)
set({ isProbing: true });
try {
const client = useAuthStore.getState().client;
const isStalwart = !!client?.hasAccountCapability?.('urn:stalwart:jmap');
// No live client yet (e.g. the OAuth session is still reconnecting after
// a reload). Don't record a verdict — leave isStalwart null so the caller
// re-probes once the client is ready, instead of caching a false "not a
// Stalwart server" from a session that hasn't loaded its capabilities.
if (!client) {
set({ isProbing: false });
return false;
}
const isStalwart = !!client.hasAccountCapability?.('urn:stalwart:jmap');
set({ isStalwart, isProbing: false });
return isStalwart;
} catch (error) {
+7
View File
@@ -239,6 +239,13 @@ function initializeFeatureStores(client: IJMAPClient): void {
useContactStore.getState().setSupportsSync(false);
}
// Directory (RFC 9670 principals) is independent of contacts support and only
// works when the server allows directory queries; populates recipient
// autocomplete with other users on the server.
if (client.supportsPrincipals()) {
useContactStore.getState().fetchDirectory(client).catch((err) => debug.error('Failed to fetch directory:', err));
}
const vacationStore = useVacationStore.getState();
if (client.supportsVacationResponse()) {
vacationStore.setSupported(true);
+44 -36
View File
@@ -232,6 +232,7 @@ interface CalendarStore {
rsvpEvent: (client: IJMAPClient, eventId: string, participantId: string, status: string, replyTo?: Record<string, string> | null) => Promise<void>;
importEvents: (client: IJMAPClient, events: Partial<CalendarEvent>[], calendarId: string) => Promise<number>;
updateCalendar: (client: IJMAPClient, calendarId: string, updates: Partial<Calendar>) => Promise<void>;
setDefaultCalendar: (client: IJMAPClient, calendarId: string) => Promise<void>;
shareCalendar: (client: IJMAPClient, calendarId: string, principalId: string, rights: CalendarRights | null) => Promise<void>;
createCalendar: (client: IJMAPClient, calendar: Partial<Calendar>) => Promise<Calendar | null>;
removeCalendar: (client: IJMAPClient, calendarId: string) => Promise<void>;
@@ -471,13 +472,9 @@ export const useCalendarStore = create<CalendarStore>()(
}
set((state) => ({ events: [...state.events, mappedCreated] }));
if (sendSchedulingMessages && created.participants) {
try {
await client.sendImipInvitation(created);
} catch (e) {
debug.error('Failed to send invitation emails:', e);
}
}
// Invitation emails are sent by the server: `sendSchedulingMessages`
// on CalendarEvent/set makes Stalwart queue the iTIP REQUEST itself.
// Sending a client-side iMIP copy here produced duplicate emails.
return mappedCreated;
} catch (error) {
debug.error('Failed to create event:', error);
@@ -541,22 +538,9 @@ export const useCalendarStore = create<CalendarStore>()(
return merged;
}),
}));
if (sendSchedulingMessages) {
const mergedParticipants = cleanUpdates.participants ?? storeEvent?.participants;
if (mergedParticipants) {
const eventForInvitation = {
...(storeEvent ?? {}),
...cleanUpdates,
id: realId,
participants: mergedParticipants,
} as import('@/lib/jmap/types').CalendarEvent;
try {
await client.sendImipInvitation(eventForInvitation);
} catch (e) {
debug.error('Failed to send invitation emails:', e);
}
}
}
// Update emails (iTIP REQUEST/REPLY) are sent by the server via the
// `sendSchedulingMessages` argument already passed above - a manual
// iMIP send here produced duplicate emails.
} catch (error) {
debug.error('Failed to update event:', error);
set({ error: 'Failed to update event' });
@@ -566,9 +550,10 @@ export const useCalendarStore = create<CalendarStore>()(
rsvpEvent: async (client, eventId, participantId, status, replyTo) => {
set({ error: null });
// JMAP participant IDs are opaque strings - they can contain @, ., :, / etc.
// Only reject empty or obviously malicious values (path traversal).
if (!participantId || participantId.includes('..')) {
// JMAP participant IDs are opaque strings - they can contain @, ., :,
// / etc. The id is RFC 6901-escaped below before being embedded in the
// patch pointer, so any character is safe; only reject empty values.
if (!participantId) {
set({ error: 'Invalid participant ID' });
throw new Error('Invalid participant ID');
}
@@ -787,16 +772,9 @@ export const useCalendarStore = create<CalendarStore>()(
const realId = storeEvent?.originalId || stripLocalAccountPrefix(id, storeEvent?.localAccountId);
const targetAccountId = storeEvent?.accountId;
client = resolveAccountClient(client, storeEvent?.localAccountId);
if (sendSchedulingMessages) {
try {
const event = await client.getCalendarEvent(realId, targetAccountId);
if (event?.participants) {
await client.sendImipCancellation(event);
}
} catch (e) {
debug.error('Failed to send cancellation emails:', e);
}
}
// Cancellation emails (iTIP CANCEL) are sent by the server via the
// `sendSchedulingMessages` argument on the destroy below - a manual
// iMIP send here produced duplicate emails.
debug.log('calendar', 'Calendar deleteEvent', {
storeId: id,
realId,
@@ -839,6 +817,36 @@ export const useCalendarStore = create<CalendarStore>()(
}
},
setDefaultCalendar: async (client, calendarId) => {
set({ error: null });
try {
const cal = get().calendars.find(c => c.id === calendarId);
const realId = cal?.originalId || stripLocalAccountPrefix(calendarId, cal?.localAccountId);
const targetAccountId = cal?.accountId;
client = resolveAccountClient(client, cal?.localAccountId);
await client.setDefaultCalendar(realId, targetAccountId);
set((state) => ({
calendars: state.calendars.map(c => {
if (c.id === calendarId) return { ...c, isDefault: true };
// Only one default per account - clear the flag on siblings of
// the same local account / shared-account scope.
if (
c.isDefault
&& (c.localAccountId ?? null) === (cal?.localAccountId ?? null)
&& (c.accountId ?? null) === (cal?.accountId ?? null)
) {
return { ...c, isDefault: false };
}
return c;
}),
}));
} catch (error) {
debug.error('Failed to set default calendar:', error);
set({ error: 'Failed to set default calendar' });
throw error;
}
},
shareCalendar: async (client, calendarId, principalId, rights) => {
set({ error: null });
try {
+66 -7
View File
@@ -159,7 +159,13 @@ interface ContactStore {
lastSelectedContactId: string | null;
activeTab: 'all' | 'groups';
// Directory (RFC 9670 principals) — other users/groups on the server, used to
// augment recipient autocomplete. Runtime only, not persisted.
directoryPrincipals: Array<{ name: string; email: string; description?: string }>;
directoryLoaded: boolean;
fetchContacts: (client: IJMAPClient) => Promise<void>;
fetchDirectory: (client: IJMAPClient) => Promise<void>;
fetchAddressBooks: (client: IJMAPClient) => Promise<void>;
fetchAllAccountsContacts: (accounts: ContactAccountClient[], activeLocalAccountId: string) => Promise<void>;
fetchAllAccountsAddressBooks: (accounts: ContactAccountClient[], activeLocalAccountId: string) => Promise<void>;
@@ -258,6 +264,8 @@ export const useContactStore = create<ContactStore>()(
selectedContactIds: new Set<string>(),
lastSelectedContactId: null,
activeTab: 'all' as const,
directoryPrincipals: [],
directoryLoaded: false,
fetchContacts: async (client) => {
set({ isLoading: true, error: null });
@@ -270,6 +278,28 @@ export const useContactStore = create<ContactStore>()(
}
},
fetchDirectory: async (client) => {
if (!client.supportsPrincipals()) return;
try {
const principals = await client.getPrincipals();
const entries: Array<{ name: string; email: string; description?: string }> = [];
for (const p of principals) {
// Stalwart reports a principal's account name in `email`; only those
// with an address are usable as recipients.
const email = p.email?.trim();
if (!email) continue;
entries.push({
name: p.name || email,
email,
description: p.description ?? undefined,
});
}
set({ directoryPrincipals: entries, directoryLoaded: true });
} catch (error) {
debug.error('Failed to fetch directory principals:', error);
}
},
fetchAddressBooks: async (client) => {
try {
const addressBooks = await client.getAllAddressBooks();
@@ -474,6 +504,8 @@ export const useContactStore = create<ContactStore>()(
error: null,
selectedContactIds: new Set<string>(),
activeTab: 'all',
directoryPrincipals: [],
directoryLoaded: false,
}),
getAutocomplete: (query) => {
@@ -516,6 +548,22 @@ export const useContactStore = create<ContactStore>()(
if (results.length >= 10) break;
}
// Augment with directory principals (other users on the server, RFC 9670).
// Contacts take precedence, so skip any address already suggested.
const { directoryPrincipals } = get();
if (directoryPrincipals.length > 0) {
const seen = new Set(results.map(r => r.email.toLowerCase()));
for (const p of directoryPrincipals) {
if (results.length >= 10) break;
const addr = p.email.toLowerCase();
if (seen.has(addr)) continue;
if (p.name.toLowerCase().includes(lower) || addr.includes(lower)) {
results.push({ name: p.name, email: p.email });
seen.add(addr);
}
}
}
return results;
},
@@ -909,9 +957,13 @@ export const useContactStore = create<ContactStore>()(
},
addToTrustedSendersBook: async (client, email) => {
const normalizedEmail = email.toLowerCase().trim();
// Parse "Name <email>" format to extract display name and email
const trimmed = email.trim();
const angleMatch = trimmed.match(/^(.+?)\s*<([^>]+)>$/);
const displayName = angleMatch ? angleMatch[1].trim() : undefined;
const emailAddress = (angleMatch ? angleMatch[2] : trimmed).toLowerCase().trim();
const { trustedSenderEmails } = get();
if (trustedSenderEmails.includes(normalizedEmail)) return;
if (trustedSenderEmails.includes(emailAddress)) return;
let bookId = get().trustedSendersBookId;
if (!bookId) {
@@ -920,17 +972,21 @@ export const useContactStore = create<ContactStore>()(
}
if (!bookId) throw new Error('Could not find or create trusted senders address book');
debug.log('contacts', 'Adding trusted sender:', normalizedEmail, 'to book:', bookId);
debug.log('contacts', 'Adding trusted sender:', emailAddress, 'to book:', bookId);
await client.createContact({
addressBookIds: { [bookId]: true },
emails: { email: { address: normalizedEmail } },
...(displayName ? { name: { full: displayName } } : {}),
emails: { email: { address: emailAddress } },
});
set((state) => ({ trustedSenderEmails: [...state.trustedSenderEmails, normalizedEmail] }));
set((state) => ({ trustedSenderEmails: [...state.trustedSenderEmails, emailAddress] }));
debug.log('contacts', 'Trusted sender added successfully');
},
removeFromTrustedSendersBook: async (client, email) => {
const normalizedEmail = email.toLowerCase().trim();
// Parse "Name <email>" format to extract just the email address
const trimmed = email.trim();
const angleMatch = trimmed.match(/^(.+?)\s*<([^>]+)>$/);
const normalizedEmail = (angleMatch ? angleMatch[2] : trimmed).toLowerCase().trim();
const { trustedSendersBookId } = get();
if (!trustedSendersBookId) return;
@@ -947,7 +1003,10 @@ export const useContactStore = create<ContactStore>()(
},
isTrustedAddressBookSender: (email) => {
const normalizedEmail = email.toLowerCase().trim();
// Parse "Name <email>" format to extract just the email address
const trimmed = email.trim();
const angleMatch = trimmed.match(/^(.+?)\s*<([^>]+)>$/);
const normalizedEmail = (angleMatch ? angleMatch[2] : trimmed).toLowerCase().trim();
return get().trustedSenderEmails.includes(normalizedEmail);
},
+285 -12
View File
@@ -1,5 +1,5 @@
import { create } from "zustand";
import { Email, Mailbox, StateChange, ScheduledEmail, SendEmailResult } from "@/lib/jmap/types";
import { Email, Mailbox, StateChange, ScheduledEmail, SendEmailResult, ALL_MAIL_MAILBOX_ID } from "@/lib/jmap/types";
import type { UnifiedMailboxRole } from "@/lib/jmap/types";
import type { IJMAPClient } from "@/lib/jmap/client-interface";
import { useSettingsStore } from "@/stores/settings-store";
@@ -60,6 +60,8 @@ interface EmailStore {
expandedThreadIds: Set<string>;
threadEmailsCache: Map<string, Email[]>;
isLoadingThread: string | null;
// Full thread email counts (from Thread/get across all folders)
threadEmailCounts: Map<string, number>;
// Keyword/tag filter
selectedKeyword: string | null;
@@ -199,6 +201,8 @@ interface EmailStore {
fetchThreadEmails: (client: IJMAPClient, threadId: string) => Promise<Email[]>;
collapseAllThreads: () => void;
updateThreadCache: (threadId: string, emails: Email[]) => void;
fetchThreadEmailCounts: (client: IJMAPClient) => Promise<void>;
markThreadAsRead: (client: IJMAPClient, threadId: string) => Promise<void>;
// Mailbox management
createMailbox: (client: IJMAPClient, name: string, parentId?: string) => Promise<void>;
@@ -315,6 +319,33 @@ function resolveActionMailboxes(): Mailbox[] {
return state.mailboxes;
}
/**
* Resolves the JMAP mailbox ids that make up the gated "All Mail" view for the
* active/viewing account. Honors the per-user `allMailFolderIds` setting; when
* unset (null) it defaults to every non-special (no-role) folder. Shared
* folders are excluded - All Mail is scoped to a single account. Returns
* JMAP-side ids (originalId for namespaced mailboxes).
*/
function resolveAllMailJmapIds(): string[] {
const mailboxes = resolveActionMailboxes().filter((mb) => !mb.isShared);
const configured = useSettingsStore.getState().allMailFolderIds;
const selected = configured === null
? mailboxes.filter((mb) => !mb.role)
: mailboxes.filter((mb) => configured.includes(mb.id));
return selected.map((mb) => mb.originalId || mb.id);
}
/**
* Builds the JMAP Email/query filter for the All Mail view from a set of
* mailbox ids - an OR of `inMailbox` conditions (or a single condition).
*/
function buildAllMailFilter(jmapMailboxIds: string[]): Record<string, unknown> {
if (jmapMailboxIds.length === 1) {
return { inMailbox: jmapMailboxIds[0] };
}
return { operator: 'OR', conditions: jmapMailboxIds.map((id) => ({ inMailbox: id })) };
}
/**
* Resolves the JMAP client, mailbox list, and JMAP accountId to use for a
* single-email action.
@@ -492,6 +523,7 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
expandedThreadIds: new Set(),
threadEmailsCache: new Map(),
isLoadingThread: null,
threadEmailCounts: new Map(),
// Keyword/tag filter
selectedKeyword: null,
@@ -538,6 +570,7 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
selectedKeyword: null,
expandedThreadIds: new Set(),
threadEmailsCache: new Map(),
threadEmailCounts: new Map(),
isLoadingThread: null,
}),
fetchAccountMailboxes: async (client, accountId) => {
@@ -568,6 +601,7 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
selectedEmailIds: new Set(),
expandedThreadIds: new Set(),
threadEmailsCache: new Map(),
threadEmailCounts: new Map(),
}),
fetchTagCounts: async (client) => {
try {
@@ -590,6 +624,7 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
selectedKeyword: null,
expandedThreadIds: new Set(),
threadEmailsCache: new Map(),
threadEmailCounts: new Map(),
isLoadingThread: null,
}),
setLoading: (loading) => set({ isLoading: loading }),
@@ -646,10 +681,19 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
try {
const mailboxes = await client.getAllMailboxes();
// Guard against a transient fetch returning an empty list (e.g. a server
// concurrent-request limit hit during a burst of deletes). Replacing a
// populated folder list with [] leaves the sidebar stuck on "Loading
// mailboxes..." until the next successful refetch. Keep what we have.
if (mailboxes.length === 0 && !isInitialLoad) {
return;
}
// Auto-select inbox if no mailbox is selected or the current selection
// doesn't exist in the fetched list (e.g. after an account switch)
const currentSelectedMailbox = get().selectedMailbox;
const selectionValid = currentSelectedMailbox === VIRTUAL_SCHEDULED_MAILBOX_ID
|| currentSelectedMailbox === ALL_MAIL_MAILBOX_ID
|| (currentSelectedMailbox && mailboxes.some(m => m.id === currentSelectedMailbox));
const loadingPatch = isInitialLoad ? { isLoading: false } : {};
if (!selectionValid) {
@@ -707,6 +751,24 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
await get().fetchScheduledEmails(client);
return;
}
if (targetMailboxId === ALL_MAIL_MAILBOX_ID) {
const jmapIds = resolveAllMailJmapIds();
if (jmapIds.length === 0) {
set({ emails: [], hasMoreEmails: false, totalEmails: 0, isLoading: false });
return;
}
const emailsPerPage = useSettingsStore.getState().emailsPerPage;
const result = await resolveActionClient(client).advancedSearchEmails(
buildAllMailFilter(jmapIds), undefined, emailsPerPage, 0,
);
set({
emails: annotateScheduledEmails(result.emails, get().scheduledSubmissionByEmailId),
hasMoreEmails: result.hasMore,
totalEmails: result.total,
isLoading: false,
});
return;
}
const effectiveClient = resolveActionClient(client);
// Find the mailbox to get its accountId (for shared folder support)
@@ -731,8 +793,14 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
emails: annotateScheduledEmails(result.emails, get().scheduledSubmissionByEmailId),
hasMoreEmails: result.hasMore,
totalEmails: result.total,
// Clear thread caches since the email list was fully replaced
threadEmailsCache: new Map(),
expandedThreadIds: new Set(),
isLoadingThread: null,
isLoading: false
});
// Fetch full thread counts in the background (non-blocking)
void get().fetchThreadEmailCounts(client);
} catch (error) {
console.error('Failed to fetch emails:', error);
set({
@@ -814,7 +882,21 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
const { searchFilters } = get();
const hasFilters = !isFilterEmpty(searchFilters);
if (searchQuery || hasFilters) {
if (selectedMailbox === ALL_MAIL_MAILBOX_ID) {
if (searchQuery || hasFilters) {
// Search within All Mail spans the whole account (no inMailbox).
result = hasFilters
? await effectiveClient.advancedSearchEmails(buildJMAPFilter(searchQuery, searchFilters, undefined), undefined, emailsPerPage, position)
: await effectiveClient.searchEmails(searchQuery, undefined, undefined, emailsPerPage, position);
} else {
const jmapIds = resolveAllMailJmapIds();
if (jmapIds.length === 0) {
set({ hasMoreEmails: false, isLoadingMore: false });
return;
}
result = await effectiveClient.advancedSearchEmails(buildAllMailFilter(jmapIds), undefined, emailsPerPage, position);
}
} else if (searchQuery || hasFilters) {
const mailboxes = resolveActionMailboxes();
const mailbox = mailboxes.find(mb => mb.id === selectedMailbox);
const jmapMailboxId = mailbox?.originalId || selectedMailbox;
@@ -855,6 +937,10 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
totalEmails: result.total,
isLoadingMore: false
});
// Fetch full thread counts for newly loaded threads in the background
if (newEmails.length > 0) {
void get().fetchThreadEmailCounts(client);
}
} catch (error) {
console.error('Failed to load more emails:', error);
set({
@@ -1146,7 +1232,23 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
? { ...state.selectedEmail, keywords: { ...state.selectedEmail.keywords, $seen: read } }
: state.selectedEmail,
mailboxes: updatedMailboxes,
processingReadStatus: newProcessingSet
processingReadStatus: newProcessingSet,
// Also update threadEmailsCache so expanded dropdowns reflect the change
threadEmailsCache: (() => {
let updated = false;
const newCache = new Map(state.threadEmailsCache);
for (const [tid, cachedEmails] of newCache) {
const idx = cachedEmails.findIndex(e => e.id === emailId);
if (idx !== -1) {
newCache.set(tid, cachedEmails.map((e, i) =>
i === idx ? { ...e, keywords: { ...e.keywords, $seen: read } } : e
));
updated = true;
break;
}
}
return updated ? newCache : state.threadEmailsCache;
})(),
};
});
} catch (error) {
@@ -1476,14 +1578,16 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
return;
}
// Get the current mailbox to scope the search
// Get the current mailbox to scope the search. In the All Mail view the
// search spans every folder of the account (no inMailbox constraint).
const selectedMailbox = get().selectedMailbox;
const isAllMail = selectedMailbox === ALL_MAIL_MAILBOX_ID;
const mailboxes = resolveActionMailboxes();
const mailbox = mailboxes.find(mb => mb.id === selectedMailbox);
// Use originalId for shared mailboxes
const jmapMailboxId = mailbox?.originalId || selectedMailbox;
const jmapMailboxId = isAllMail ? undefined : (mailbox?.originalId || selectedMailbox);
// Only pass accountId for shared mailboxes, not for primary account
const accountId = mailbox?.isShared ? mailbox.accountId : undefined;
const accountId = isAllMail ? undefined : (mailbox?.isShared ? mailbox.accountId : undefined);
const result = await resolveActionClient(client).searchEmails(query, jmapMailboxId, accountId, emailsPerPage, 0);
const externals = await emailHooks.onProvideSearchResults.transform([] as ExternalSearchResult[], { query, filters: get().searchFilters });
@@ -1551,9 +1655,10 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
return;
}
const isAllMail = selectedMailbox === ALL_MAIL_MAILBOX_ID;
const mailbox = mailboxes.find(mb => mb.id === selectedMailbox);
const jmapMailboxId = mailbox?.originalId || selectedMailbox;
const accountId = mailbox?.isShared ? mailbox.accountId : undefined;
const jmapMailboxId = isAllMail ? undefined : (mailbox?.originalId || selectedMailbox);
const accountId = isAllMail ? undefined : (mailbox?.isShared ? mailbox.accountId : undefined);
const filter = buildJMAPFilter(searchQuery, searchFilters, jmapMailboxId);
const result = await resolveActionClient(client).advancedSearchEmails(filter, accountId, emailsPerPage, 0);
@@ -2256,11 +2361,74 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
// hasMore should reflect whether there are still more emails beyond
// what we have loaded, using the fresh total from the server.
const hasMore = merged.length < (result.total || 0);
set({
emails: merged,
hasMoreEmails: hasMore,
totalEmails: result.total,
// Invalidate thread email caches for threads whose composition changed
// so expanded threads pick up new/removed emails.
const prevThreadIds = new Set(currentEmails.map(e => e.threadId));
const nextThreadIds = new Set(merged.map(e => e.threadId));
const changedThreadIds = new Set<string>();
for (const tid of prevThreadIds) {
if (!nextThreadIds.has(tid)) changedThreadIds.add(tid);
}
for (const tid of nextThreadIds) {
if (!prevThreadIds.has(tid)) changedThreadIds.add(tid);
}
// Also check threads where the set of email IDs changed
const prevEmailsByThread = new Map<string, Set<string>>();
for (const e of currentEmails) {
if (!prevEmailsByThread.has(e.threadId)) prevEmailsByThread.set(e.threadId, new Set());
prevEmailsByThread.get(e.threadId)!.add(e.id);
}
const nextEmailsByThread = new Map<string, Set<string>>();
for (const e of merged) {
if (!nextEmailsByThread.has(e.threadId)) nextEmailsByThread.set(e.threadId, new Set());
nextEmailsByThread.get(e.threadId)!.add(e.id);
}
for (const [tid, nextIds] of nextEmailsByThread) {
const prevIds = prevEmailsByThread.get(tid);
if (!prevIds || prevIds.size !== nextIds.size) {
changedThreadIds.add(tid);
} else {
for (const id of nextIds) {
if (!prevIds.has(id)) { changedThreadIds.add(tid); break; }
}
}
}
set((state) => {
const newCache = new Map(state.threadEmailsCache);
for (const tid of changedThreadIds) {
newCache.delete(tid);
}
return {
emails: merged,
hasMoreEmails: hasMore,
totalEmails: result.total,
threadEmailsCache: newCache,
};
});
// Re-fetch cross-folder thread data for any currently expanded threads
// so they show the complete conversation (not just current-folder emails).
const expandedNow = get().expandedThreadIds;
if (expandedNow.size > 0) {
const effectiveClient2 = resolveActionClient(client);
const accountId = effectiveClient2.getAccountId();
for (const tid of expandedNow) {
void effectiveClient2.getThreadEmails(tid, accountId).then((fullEmails) => {
if (fullEmails.length > 0) {
set((state) => {
const c = new Map(state.threadEmailsCache);
c.set(tid, fullEmails);
return { threadEmailsCache: c };
});
}
});
}
}
// Fetch full thread counts in the background (non-blocking)
void get().fetchThreadEmailCounts(client);
}
} catch (error) {
console.error('Failed to refresh current mailbox:', error);
@@ -2330,6 +2498,90 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
}
},
markThreadAsRead: async (client, threadId) => {
const state = get();
const threadEmails = state.threadEmailsCache.get(threadId) ?? [];
const mainEmails = state.emails.filter(e => e.threadId === threadId);
// Combine unique emails from both sources
const allEmailMap = new Map<string, Email>();
for (const e of mainEmails) allEmailMap.set(e.id, e);
for (const e of threadEmails) allEmailMap.set(e.id, e);
const unreadIds = Array.from(allEmailMap.values())
.filter(e => !e.keywords?.$seen)
.map(e => e.id);
if (unreadIds.length === 0) return;
// Group by account for unified view support
const emailsById = new Map<string, Email>();
for (const e of allEmailMap.values()) emailsById.set(e.id, e);
// Group unread IDs by account client
const groups = new Map<IJMAPClient, string[]>();
for (const id of unreadIds) {
const email = emailsById.get(id)!;
const { client: actionClient } = resolveEmailActionContext(email, client);
if (!groups.has(actionClient)) groups.set(actionClient, []);
groups.get(actionClient)!.push(id);
}
// Mark all as read on the server
try {
await Promise.all(
Array.from(groups.entries()).map(([actionClient, emailIds]) =>
actionClient.batchMarkAsRead(emailIds, true)
)
);
} catch (error) {
console.error('Failed to mark thread as read:', error);
return;
}
// Update local state
set((state) => {
const unreadSet = new Set(unreadIds);
const updatedEmails = state.emails.map(e =>
unreadSet.has(e.id) ? { ...e, keywords: { ...e.keywords, $seen: true } } : e
);
// Update threadEmailsCache
const newCache = new Map(state.threadEmailsCache);
const cached = newCache.get(threadId);
if (cached) {
newCache.set(threadId, cached.map(e =>
unreadSet.has(e.id) ? { ...e, keywords: { ...e.keywords, $seen: true } } : e
));
}
// Update mailbox unread counters
const affectedEmails = state.emails.filter(e => unreadSet.has(e.id));
const updatedMailboxes = state.mailboxes.map(mailbox => {
let delta = 0;
for (const email of affectedEmails) {
if (email.mailboxIds?.[mailbox.id]) delta -= 1;
}
if (delta === 0) return mailbox;
return {
...mailbox,
unreadEmails: Math.max(0, mailbox.unreadEmails + delta),
unreadThreads: Math.max(0, mailbox.unreadThreads + delta),
};
});
return {
emails: updatedEmails,
threadEmailsCache: newCache,
mailboxes: updatedMailboxes,
selectedEmail: state.selectedEmail && unreadSet.has(state.selectedEmail.id)
? { ...state.selectedEmail, keywords: { ...state.selectedEmail.keywords, $seen: true } }
: state.selectedEmail,
};
});
},
collapseAllThreads: () => {
set({
expandedThreadIds: new Set(),
@@ -2343,6 +2595,27 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
set({ threadEmailsCache: newCache });
},
fetchThreadEmailCounts: async (client) => {
const { emails } = get();
if (emails.length === 0) return;
const uniqueThreadIds = [...new Set(emails.map(e => e.threadId).filter(Boolean))];
if (uniqueThreadIds.length === 0) return;
try {
const effectiveClient = resolveActionClient(client);
const threads = await effectiveClient.getThreads(uniqueThreadIds);
const newCounts = new Map(get().threadEmailCounts);
for (const thread of threads) {
newCounts.set(thread.id, thread.emailIds?.length ?? 0);
}
set({ threadEmailCounts: newCounts });
} catch {
// Non-critical — fall back to inbox-only counts
}
},
// Mailbox management
createMailbox: async (client, name, parentId) => {
try {

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