Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2f5b133000 | ||
|
|
1f21a5213f | ||
|
|
d4c066622b | ||
|
|
e141abc849 | ||
|
|
1f4afe082b | ||
|
|
e0747c12ee | ||
|
|
f90cd6abc4 | ||
|
|
63e087f3ef | ||
|
|
f8b8e0b108 | ||
|
|
512adab7e3 | ||
|
|
4cdc15fc3c | ||
|
|
5e67671f57 | ||
|
|
155d99a069 | ||
|
|
d863b1fd4b | ||
|
|
70aaf0aac1 | ||
|
|
de56229ef2 | ||
|
|
1ff23790ae | ||
|
|
198407ebf5 | ||
|
|
de68d68fb7 | ||
|
|
f285a2bd64 | ||
|
|
84b6d0fd8b | ||
|
|
f972068143 | ||
|
|
7882b254a0 | ||
|
|
ae5d397512 | ||
|
|
7a022596c3 | ||
|
|
8c575e8ed8 | ||
|
|
85fbab9eb4 | ||
|
|
1119d8ed73 | ||
|
|
32f0a67dbc | ||
|
|
baf094d026 | ||
|
|
f1b9f4ba50 | ||
|
|
cc20d29636 | ||
|
|
5f713a033b | ||
|
|
079ec57204 | ||
|
|
cd247e9c47 | ||
|
|
8af6694152 | ||
|
|
751f3c1685 | ||
|
|
5c2f206c74 | ||
|
|
acc61db6f2 | ||
|
|
d671c606a1 | ||
|
|
fa3c57467b | ||
|
|
befee332d2 | ||
|
|
a29c33b50a | ||
|
|
3dd596ba50 | ||
|
|
e50fe0d4db | ||
|
|
3d3ad8f0ef | ||
|
|
d0ed4b4dfe | ||
|
|
5306f7c548 | ||
|
|
ddb596affc | ||
|
|
bfc8ba851a | ||
|
|
3f9e60843d | ||
|
|
2fac6ebfb8 | ||
|
|
dda9fd1433 | ||
|
|
3516d3c727 | ||
|
|
0b7203df0f | ||
|
|
344795a8d9 | ||
|
|
638fc7db4e | ||
|
|
ab3e0e717a | ||
|
|
c9eae3b3a1 | ||
|
|
6f615f4c32 | ||
|
|
52eacf87b9 | ||
|
|
c4f1cc23d7 | ||
|
|
4f1390fdeb | ||
|
|
c0ca6d3102 |
@@ -49,6 +49,11 @@ JMAP_SERVER_URL=https://your-jmap-server.com
|
||||
# OpenID Connect issuer URL for discovery
|
||||
# OAUTH_ISSUER_URL=https://your-idp.example.com
|
||||
|
||||
# Overrides only the user-facing authorize endpoint (e.g. a per-brand login
|
||||
# host). Discovery, token exchange and refresh keep using OAUTH_ISSUER_URL.
|
||||
# Leave unset to use the authorization_endpoint from discovery.
|
||||
# OAUTH_AUTHORIZE_URL=https://login.your-brand.example.com/application/o/authorize/
|
||||
|
||||
# Allow OAuth discovery to resolve to private (RFC-1918 / loopback) addresses.
|
||||
# Off by default as an SSRF guard. Enable for split-DNS deployments where the
|
||||
# OAuth issuer's public hostname resolves to an internal IP from this server.
|
||||
|
||||
@@ -1,5 +1,71 @@
|
||||
# Changelog
|
||||
|
||||
## 1.7.6 (2026-06-28)
|
||||
|
||||
### Breaking Changes
|
||||
|
||||
- **S/MIME**: The built-in S/MIME implementation has been removed from core and re-delivered through the new generic crypto plugin hooks (privileged same-origin plugin tier). S/MIME signing, encryption, decryption, certificate management, and the related settings UI now live in a plugin rather than the main app. Deployments that relied on built-in S/MIME must install the S/MIME crypto plugin to retain those features.
|
||||
|
||||
### Features
|
||||
|
||||
- **Plugins**: Privileged same-origin plugin tier with a crypto API surface
|
||||
- **Plugins**: Plugin hooks for email details, headers, and source
|
||||
- **Mail**: Option to hide the total message count on folders (#498)
|
||||
|
||||
### Fixes
|
||||
|
||||
- **Mail**: Hide the server scheduled folder when the virtual one is shown (#495)
|
||||
- **Mail**: Stop the unified mailbox from mutating client-returned email objects
|
||||
- **Composer**: HTML-escape sender and subject in the reply/forward quote header (#482)
|
||||
- **Calendar**: Send calendar invites by setting `organizerCalendarAddress`
|
||||
- **Identity**: Sync the default identity (`preferredPrimaryId`) to server settings (#507)
|
||||
- **Auth**: Support MFA login via the structured auth endpoint
|
||||
- **Admin**: Show all built-in themes in the admin theme controls (#496)
|
||||
- **i18n**: Add missing translation keys across 19 locales
|
||||
|
||||
## 1.7.5 (2026-06-24)
|
||||
|
||||
### Features
|
||||
|
||||
- **Mail**: Cross-account "All accounts" views with full group/shared-account support
|
||||
- **Mail**: Per-account "All Mail" folder selection
|
||||
- **Mail**: "Download all" button to bundle attachments into a zip (#466)
|
||||
- **Mail**: Return to the list after deleting or marking the open message unread — configurable (default on)
|
||||
- **Mail**: Collapse-all-threads action in thread-list selection
|
||||
- **Calendar**: Option to disable the calendar
|
||||
- **Composer**: Send-now button on scheduled/delayed messages
|
||||
- **Composer**: Email a contact or group via the in-app composer
|
||||
- **Composer**: Split a pasted address list into recipient chips
|
||||
- **Contacts**: "New address book" creation UI (#415)
|
||||
- **OAuth**: `OAUTH_AUTHORIZE_URL` to override the authorize endpoint
|
||||
- **i18n**: Farsi (fa) locale — complete (2654 strings)
|
||||
- **i18n**: Romanian (ro) locale
|
||||
|
||||
### Fixes
|
||||
|
||||
- **Composer**: Keep HTML signature styling in the editor and on send
|
||||
- **Composer**: Guard Send against double-submit
|
||||
- **Composer**: Strip display names from the `EmailSubmission` envelope addresses
|
||||
- **Calendar**: Disable iMIP scheduling on calendar import (#411)
|
||||
- **Mail**: Localize special-folder names by JMAP role (#404)
|
||||
- **Mail**: Block remaining email tracking vectors (#457)
|
||||
- **Mail**: Route counter and unread updates to the email's own account in aggregate views
|
||||
- **Mail**: Fix blank space in plain-text emails
|
||||
- **Mail**: Fix toolbar re-render when opening emails
|
||||
- **Mail**: Truncate long subjects so they don't overlap the timestamp
|
||||
- **Mail**: Strip reply/forward prefixes followed by a full-width colon
|
||||
- **Mail**: Add breathing room between the unread dot and the avatar
|
||||
- **Mail**: Isolate per-account state snapshots from leakage and mutation
|
||||
- **Mail**: Cap filename tokens at the full 200-char limit
|
||||
- **Spam**: Fetch mailboxes with `accountId` in `markAsSpam`
|
||||
- **Filters**: Load mailboxes when opened directly (#485)
|
||||
- **Settings**: Surface server errors on password change and TOTP toggle
|
||||
- **Send now**: Gate the toolbar label and translate `send_now` across locales
|
||||
- **Directory**: Fix fetching display names
|
||||
- **Push**: Reap only relay-confirmed-dead leftover subscriptions
|
||||
- **i18n**: Add the missing fa locale to the client `IntlProvider` messages map
|
||||
- **i18n**: Add missing translation keys across 19 locales
|
||||
|
||||
## 1.7.4 (2026-06-15)
|
||||
|
||||
### Features
|
||||
|
||||
+6
-2
@@ -4,7 +4,9 @@
|
||||
|
||||
- Read, compose, reply, reply-all, and forward with a Tiptap rich text editor (inline images, drag-and-drop embedding, tables)
|
||||
- Gmail-style threading with inline expansion and an optional conversation toggle
|
||||
- Unified mailbox view across all connected accounts
|
||||
- Unified mailbox view across all connected accounts – combined Inbox, Sent, Drafts, Junk, Archive, and Trash, with group/shared accounts optionally merged in
|
||||
- Cross-account "All accounts" views – All unread, All starred, and All mail spanning every account (including shared/group folders); each aggregate list labels the source folder of every message
|
||||
- "All Mail" view that merges an account's folders (with a configurable folder selection) into a single list
|
||||
- Three selectable mail layouts: split (three-pane), focused list, and reading pane at bottom
|
||||
- Draft auto-save with identity preservation, persisted HTML body, and proper `In-Reply-To` / `References` headers on replies
|
||||
- Attachment upload, download, drag-out to local file system, and inline preview – images, inline PDF on desktop and mobile, composer attachments (click to open), and `.eml` (`message/rfc822`) attachments rendered like an email; image thumbnails and forgotten-attachment warning
|
||||
@@ -103,7 +105,7 @@
|
||||
|
||||
## Internationalization
|
||||
|
||||
18 languages: Česky · Dansk · Deutsch · English · Español · Français · Italiano · Latviešu · Magyar · Nederlands · Polski · Português · Türkçe · Русский · Українська · 한국어 · 日本語 · 简体中文
|
||||
19 languages: Česky · Dansk · Deutsch · English · Español · Français · Italiano · Latviešu · Magyar · Nederlands · Polski · Português · Română · Türkçe · Русский · Українська · 한국어 · 日本語 · 简体中文
|
||||
|
||||
Automatic browser detection with persistent preference. Configurable locale URL prefix via `NEXT_PUBLIC_LOCALE_PREFIX`.
|
||||
|
||||
@@ -115,6 +117,7 @@ Automatic browser detection with persistent preference. Configurable locale URL
|
||||
- Configurable signature position (above or below quoted text)
|
||||
- Sub-addressing (`user+tag@domain.com`) with configurable delimiter and contextual tag suggestions
|
||||
- Shared folders across accounts
|
||||
- Shared / group (delegated) accounts: their folders appear alongside your own and can be merged into the unified and "All accounts" views ("Include group inboxes"); their messages are fully actionable there – open, mark read, spam / not-spam, move, delete, and archive – with folder unread counts kept in sync
|
||||
- Multiple JMAP servers per deployment with optional auto-pick by email domain
|
||||
- Optional custom JMAP endpoints on the login form (`ALLOW_CUSTOM_JMAP_ENDPOINT`)
|
||||
|
||||
@@ -122,6 +125,7 @@ Automatic browser detection with persistent preference. Configurable locale URL
|
||||
|
||||
- Web setup wizard for first launch – guides through JMAP server(s), OAuth/OIDC, session secret, logging, branding (with file upload), and admin password; persists to the admin config dir, no `.env.local` editing required
|
||||
- Stalwart admin dashboard with dedicated policy sections, collapsed into a single tabbed page
|
||||
- Admin policy gates for the aggregate mail views – enable or disable the "All Mail" and the cross-account "All unread / starred / all" entries org-wide; each gated view still respects the user's own toggle
|
||||
- Split admin storage: `ADMIN_CONFIG_DIR` (operator-authored, mountable read-only after setup) and `ADMIN_STATE_DIR` (runtime audit log and login timestamps)
|
||||
- File-based secrets for JSON config: `passwordHashFile` (admin password), `sessionSecretFile`, and `oauthClientSecretFile` for Docker/Kubernetes secret mounts
|
||||
- Admin toggle for search-engine indexing (`robots.txt` / `noindex`)
|
||||
|
||||
@@ -12,7 +12,7 @@ A modern, self-hosted webmail client for [Stalwart Mail Server](https://stalw.ar
|
||||
|
||||
[](LICENSE)
|
||||
[](https://discord.gg/tYCujymGrT)
|
||||
[](CHANGELOG.md)
|
||||
[](CHANGELOG.md)
|
||||
[](https://ghcr.io/bulwarkmail/webmail)
|
||||
[](https://grafana.external.bulwarkmail.org/)
|
||||
|
||||
@@ -81,7 +81,7 @@ The wizard writes to `ADMIN_CONFIG_DIR` (`./data/admin` by default). Setting `JM
|
||||
|
||||
Bulwark is a full webmail suite, not just an inbox. It bundles the four apps most self-hosters end up wanting on the same login:
|
||||
|
||||
- **Mail** – threading, unified inbox, full-text search, Sieve filters, S/MIME, templates
|
||||
- **Mail** – threading, unified inbox, cross-account "All accounts" views, full-text search, Sieve filters, S/MIME, templates
|
||||
- **Calendar** – month/week/day/agenda, recurring events, iMIP invitations, CalDAV subscriptions
|
||||
- **Contacts** – multiple address books, groups, vCard import/export
|
||||
- **Files** – Stalwart's JMAP FileNode storage with previews and folder upload
|
||||
|
||||
@@ -16,6 +16,7 @@ import { useEmailStore } from "@/stores/email-store";
|
||||
import { useSettingsStore } from "@/stores/settings-store";
|
||||
import { useIdentityStore } from "@/stores/identity-store";
|
||||
import { useAccountStore } from "@/stores/account-store";
|
||||
import { usePolicyStore } from "@/stores/policy-store";
|
||||
import { toast } from "@/stores/toast-store";
|
||||
import { useIsDesktop, useIsMobile } from "@/hooks/use-media-query";
|
||||
import { Button } from "@/components/ui/button";
|
||||
@@ -97,6 +98,7 @@ export default function CalendarPage() {
|
||||
removeCalendar, clearCalendarEvents,
|
||||
refreshAllSubscriptions, icalSubscriptions,
|
||||
} = useCalendarStore();
|
||||
const calendarEnabled = usePolicyStore((s) => s.isFeatureEnabled('calendarEnabled'));
|
||||
const { firstDayOfWeek, timeFormat, showWeekNumbers, enableCalendarTasks, showTasksOnCalendar, calendarHoverPreview, showBirthdayCalendar, birthdayCalendarColor, updateSetting } = useSettingsStore();
|
||||
const sharedCalendarColors = useSettingsStore((s) => s.sharedCalendarColors);
|
||||
const setSharedCalendarColor = useSettingsStore((s) => s.setSharedCalendarColor);
|
||||
@@ -179,10 +181,13 @@ export default function CalendarPage() {
|
||||
if (initialCheckDone && !isAuthenticated && !authLoading) {
|
||||
try { sessionStorage.setItem('redirect_after_login', window.location.pathname); } catch { /* ignore */ }
|
||||
redirectToLogin();
|
||||
} else if (client && !calendarEnabled) {
|
||||
// Calendar disabled by admin policy - send the user back to mail.
|
||||
router.push("/");
|
||||
} else if (client && !supportsCalendar && !pendingWebcalAccountChoice && !isProtocolAccountSwitching && !pendingSubscription && !showWebcalActionChoice && !hasPendingWebcal()) {
|
||||
router.push("/");
|
||||
}
|
||||
}, [initialCheckDone, isAuthenticated, authLoading, client, supportsCalendar, pendingWebcalAccountChoice, isProtocolAccountSwitching, pendingSubscription, showWebcalActionChoice, router]);
|
||||
}, [initialCheckDone, isAuthenticated, authLoading, client, calendarEnabled, supportsCalendar, pendingWebcalAccountChoice, isProtocolAccountSwitching, pendingSubscription, showWebcalActionChoice, router]);
|
||||
|
||||
useEffect(() => {
|
||||
if (error) {
|
||||
@@ -1164,6 +1169,7 @@ export default function CalendarPage() {
|
||||
) : null;
|
||||
|
||||
if (!isAuthenticated) return null;
|
||||
if (!calendarEnabled) return null;
|
||||
if (!supportsCalendar) return renderWebcalAccountPicker();
|
||||
|
||||
const renderView = () => {
|
||||
|
||||
@@ -18,7 +18,9 @@ import { ContactImportDialog } from "@/components/contacts/contact-import-dialog
|
||||
import { RenameDialog } from "@/components/files/rename-dialog";
|
||||
import { exportContacts } from "@/components/contacts/contact-export";
|
||||
import { AppTopBannerSlot } from "@/components/plugins/app-top-banner-slot";
|
||||
import { useContactStore, getContactDisplayName } from "@/stores/contact-store";
|
||||
import { useContactStore, getContactDisplayName, getContactPrimaryEmail } from "@/stores/contact-store";
|
||||
import { savePendingMailto } from "@/lib/protocol-handlers/session";
|
||||
import { formatRecipient } from "@/lib/email-composer-utils";
|
||||
import { useAuthStore, redirectToLogin } from "@/stores/auth-store";
|
||||
import { useEmailStore } from "@/stores/email-store";
|
||||
import { usePolicyStore } from "@/stores/policy-store";
|
||||
@@ -82,6 +84,7 @@ export default function ContactsPage() {
|
||||
bulkDeleteContacts,
|
||||
bulkAddToGroup,
|
||||
moveContactToAddressBook,
|
||||
createAddressBook,
|
||||
renameAddressBook,
|
||||
removeAddressBook,
|
||||
shareAddressBook,
|
||||
@@ -93,6 +96,7 @@ export default function ContactsPage() {
|
||||
const [activeCategory, setActiveCategory] = useState<ContactCategory>("all");
|
||||
const [showImportDialog, setShowImportDialog] = useState(false);
|
||||
const [renamingAddressBook, setRenamingAddressBook] = useState<AddressBook | null>(null);
|
||||
const [creatingAddressBook, setCreatingAddressBook] = useState(false);
|
||||
const [sharingAddressBookId, setSharingAddressBookId] = useState<string | null>(null);
|
||||
const [defaultBookIdForCreate, setDefaultBookIdForCreate] = useState<string | undefined>(undefined);
|
||||
const [createPrefill, setCreatePrefill] = useState<{ email?: string; name?: string } | undefined>(undefined);
|
||||
@@ -298,6 +302,34 @@ export default function ContactsPage() {
|
||||
}
|
||||
}, [client, supportsSync, contacts, updateContact, updateLocalContact, t]);
|
||||
|
||||
// Refresh address books (and contacts) after a structural change, staying
|
||||
// multi-account aware so a freshly created book lands in the sidebar.
|
||||
const refreshAddressBooks = useCallback(async () => {
|
||||
if (!client) return;
|
||||
if (multiAccountEnabled && accountClients.length > 0) {
|
||||
const activeId = useAuthStore.getState().activeAccountId;
|
||||
if (activeId) {
|
||||
const { fetchAllAccountsAddressBooks } = useContactStore.getState();
|
||||
await fetchAllAccountsAddressBooks(accountClients, activeId);
|
||||
return;
|
||||
}
|
||||
}
|
||||
await useContactStore.getState().fetchAddressBooks(client);
|
||||
}, [client, multiAccountEnabled, accountClients]);
|
||||
|
||||
const handleCreateAddressBook = useCallback(async (name: string) => {
|
||||
if (!client) return;
|
||||
try {
|
||||
await createAddressBook(client, name);
|
||||
await refreshAddressBooks();
|
||||
toast.success(t("address_books.created"));
|
||||
setCreatingAddressBook(false);
|
||||
} catch (error) {
|
||||
console.error('Failed to create address book:', error);
|
||||
toast.error(t("address_books.create_failed"));
|
||||
}
|
||||
}, [client, createAddressBook, refreshAddressBooks, t]);
|
||||
|
||||
const handleImportContacts = useCallback(async (importedContacts: ContactCard[]) => {
|
||||
return importContacts(
|
||||
supportsSync && client ? client : null,
|
||||
@@ -461,6 +493,51 @@ export default function ContactsPage() {
|
||||
setView("group-edit");
|
||||
}, []);
|
||||
|
||||
// Open the in-app composer in the current session rather than routing through
|
||||
// a mailto: URL. `window.location='mailto:'` hands off to the OS handler
|
||||
// (which may open a different mail app), and the mailto protocol round-trip
|
||||
// reloads the app - dropping the in-memory per-account JMAP clients of a
|
||||
// multi-account session, which reads as a logout. Stashing the recipients and
|
||||
// doing a client-side router.push keeps the session and the active account
|
||||
// intact; the main route consumes the pending compose and opens the composer
|
||||
// (see consumePendingMailto in page.tsx).
|
||||
const openComposeInApp = useCallback((recipients: string[], field: "to" | "cc" | "bcc") => {
|
||||
savePendingMailto({
|
||||
to: field === "to" ? recipients : [],
|
||||
cc: field === "cc" ? recipients : [],
|
||||
bcc: field === "bcc" ? recipients : [],
|
||||
subject: "",
|
||||
body: "",
|
||||
});
|
||||
router.push("/");
|
||||
}, [router]);
|
||||
|
||||
const handleComposeGroupFromSidebar = useCallback((groupId: string, field: "to" | "cc" | "bcc") => {
|
||||
// Format each member as "Name <email>" so the composer keeps the display
|
||||
// name (round-trips via formatRecipient -> parseRecipientList). Dedupe by
|
||||
// email, case-insensitively; members without an email are skipped.
|
||||
const seen = new Set<string>();
|
||||
const recipients: string[] = [];
|
||||
for (const member of getGroupMembers(groupId)) {
|
||||
const email = getContactPrimaryEmail(member).trim();
|
||||
const key = email.toLowerCase();
|
||||
if (!email || seen.has(key)) continue;
|
||||
seen.add(key);
|
||||
recipients.push(formatRecipient(getContactDisplayName(member), email));
|
||||
}
|
||||
if (recipients.length === 0) {
|
||||
toast.error(t("groups.no_member_emails"));
|
||||
return;
|
||||
}
|
||||
openComposeInApp(recipients, field);
|
||||
}, [getGroupMembers, t, openComposeInApp]);
|
||||
|
||||
const handleComposeContact = useCallback((contact: ContactCard) => {
|
||||
const email = getContactPrimaryEmail(contact).trim();
|
||||
if (!email) return;
|
||||
openComposeInApp([formatRecipient(getContactDisplayName(contact), email)], "to");
|
||||
}, [openComposeInApp]);
|
||||
|
||||
const handleDeleteGroupFromSidebar = useCallback(async (groupId: string) => {
|
||||
const confirmed = await confirmDialog({
|
||||
title: t("groups.delete_confirm_title"),
|
||||
@@ -625,6 +702,7 @@ export default function ContactsPage() {
|
||||
onEdit={handleEditGroup}
|
||||
onDelete={handleDeleteGroup}
|
||||
onRemoveMember={handleRemoveGroupMember}
|
||||
onComposeGroup={(field) => handleComposeGroupFromSidebar(selectedGroup.id, field)}
|
||||
isMobile={isMobile}
|
||||
onSelectMember={(id) => {
|
||||
setSelectedContact(id);
|
||||
@@ -702,6 +780,11 @@ export default function ContactsPage() {
|
||||
contact={selectedContact}
|
||||
onEdit={handleEdit}
|
||||
onDelete={handleDelete}
|
||||
onCompose={
|
||||
selectedContact
|
||||
? () => handleComposeContact(selectedContact)
|
||||
: undefined
|
||||
}
|
||||
onAddToGroup={
|
||||
selectedContact
|
||||
? () => handleAddContactToGroup(selectedContact.id)
|
||||
@@ -804,9 +887,11 @@ export default function ContactsPage() {
|
||||
onSelectCategory={handleSelectCategory}
|
||||
onCreateGroup={handleCreateGroup}
|
||||
onCreateContact={handleCreateNew}
|
||||
onCreateAddressBook={client ? () => setCreatingAddressBook(true) : undefined}
|
||||
onImport={() => setShowImportDialog(true)}
|
||||
onEditGroup={handleEditGroupFromSidebar}
|
||||
onDeleteGroup={handleDeleteGroupFromSidebar}
|
||||
onComposeGroup={handleComposeGroupFromSidebar}
|
||||
onDropContacts={handleDropContacts}
|
||||
onDropContactsToCategory={handleDropContactsToCategory}
|
||||
onRenameAddressBook={client ? (book) => setRenamingAddressBook(book) : undefined}
|
||||
@@ -952,6 +1037,15 @@ export default function ContactsPage() {
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{creatingAddressBook && (
|
||||
<RenameDialog
|
||||
currentName=""
|
||||
title={t("address_books.create")}
|
||||
label={t("address_books.name_label")}
|
||||
onCancel={() => setCreatingAddressBook(false)}
|
||||
onConfirm={handleCreateAddressBook}
|
||||
/>
|
||||
)}
|
||||
{renamingAddressBook && (
|
||||
<RenameDialog
|
||||
currentName={renamingAddressBook.name}
|
||||
|
||||
+166
-50
@@ -11,7 +11,7 @@ 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, ALL_MAIL_MAILBOX_ID } from "@/lib/jmap/types";
|
||||
import { ThreadGroup, Email, Mailbox, isUnifiedMailboxId, UNIFIED_ROLE_BY_ID, ALL_MAIL_MAILBOX_ID, CROSS_VIEW_BY_ID, isCrossViewId } from "@/lib/jmap/types";
|
||||
import { useAccountStore } from "@/stores/account-store";
|
||||
import { usePolicyStore } from "@/stores/policy-store";
|
||||
import type { UnifiedAccountClient } from "@/lib/unified-mailbox";
|
||||
@@ -32,6 +32,7 @@ import { useBrowserNavigation, type NavSnapshot } from "@/hooks/use-browser-navi
|
||||
import { debug } from "@/lib/debug";
|
||||
import { playNotificationSound } from "@/lib/notification-sound";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { localizeMailboxName } from "@/lib/mailbox-label";
|
||||
import {
|
||||
ErrorBoundary,
|
||||
SidebarErrorFallback,
|
||||
@@ -319,7 +320,10 @@ export default function Home() {
|
||||
clearPendingUndoSend,
|
||||
pendingUndoSend,
|
||||
fetchUnifiedEmails: fetchUnifiedEmailsAction,
|
||||
fetchCrossView: fetchCrossViewAction,
|
||||
refreshUnifiedCounts,
|
||||
refreshCrossCounts,
|
||||
crossUnreadCount,
|
||||
exitUnifiedView,
|
||||
emptyMailbox,
|
||||
markMailboxAsRead,
|
||||
@@ -347,6 +351,19 @@ export default function Home() {
|
||||
const delayedSendSupported = client?.hasDelayedSend() ?? true;
|
||||
const allMailViewEnabled = usePolicyStore((s) => s.isFeatureEnabled('allMailViewEnabled'));
|
||||
const showAllMailMailbox = allMailViewEnabled && enableAllMailView;
|
||||
|
||||
// Cross-account "All accounts" views: a sub-feature of the unified mailbox, so
|
||||
// they require Unified Mailbox to be enabled, plus the admin gate and the
|
||||
// per-user setting. Hooks are called unconditionally.
|
||||
const crossUnreadGate = usePolicyStore((s) => s.isFeatureEnabled('crossUnreadViewEnabled'));
|
||||
const crossStarredGate = usePolicyStore((s) => s.isFeatureEnabled('crossStarredViewEnabled'));
|
||||
const crossAllGate = usePolicyStore((s) => s.isFeatureEnabled('crossAllViewEnabled'));
|
||||
const enableCrossUnreadView = useSettingsStore((s) => s.enableCrossUnreadView);
|
||||
const enableCrossStarredView = useSettingsStore((s) => s.enableCrossStarredView);
|
||||
const enableCrossAllView = useSettingsStore((s) => s.enableCrossAllView);
|
||||
const showCrossUnread = enableUnifiedMailbox && crossUnreadGate && enableCrossUnreadView;
|
||||
const showCrossStarred = enableUnifiedMailbox && crossStarredGate && enableCrossStarredView;
|
||||
const showCrossAll = enableUnifiedMailbox && crossAllGate && enableCrossAllView;
|
||||
const activeEmails = isScheduledView ? scheduledEmails : emails;
|
||||
const activeHasMore = isScheduledView ? scheduledHasMore : hasMoreEmails;
|
||||
const activeIsLoading = isScheduledView ? isLoadingScheduled : isLoading;
|
||||
@@ -718,7 +735,7 @@ export default function Home() {
|
||||
// Mailbox view
|
||||
const mailbox = mailboxes.find(mb => mb.id === selectedMailbox);
|
||||
if (mailbox) {
|
||||
const mailboxName = mailbox.name;
|
||||
const mailboxName = localizeMailboxName(mailbox.role, mailbox.name, (k) => t(`sidebar.mailboxes.${k}`));
|
||||
const unreadCount = mailbox.unreadEmails || 0;
|
||||
title = unreadCount > 0
|
||||
? `${mailboxName} (${unreadCount}) - ${appName}`
|
||||
@@ -986,14 +1003,16 @@ export default function Home() {
|
||||
// recounting happened"). The Pro shell always renders the unified mailbox
|
||||
// regardless of the user setting, so refresh when embedded too.
|
||||
useEffect(() => {
|
||||
if (!enableUnifiedMailbox && !isEmbedded) return;
|
||||
const anyCross = showCrossUnread || showCrossStarred || showCrossAll;
|
||||
if (!enableUnifiedMailbox && !isEmbedded && !anyCross) return;
|
||||
if (!isAuthenticated || !client) return;
|
||||
buildPopulatedUnifiedAccounts().then((built) => {
|
||||
const hasGroupEntry = built.some((b) => b.isShared);
|
||||
if (anyCross) refreshCrossCounts(built);
|
||||
if (built.length < 2 && !hasGroupEntry && !isEmbedded) return;
|
||||
refreshUnifiedCounts(built);
|
||||
});
|
||||
}, [enableUnifiedMailbox, includeGroupInUnified, isEmbedded, isAuthenticated, client, mailboxes, connectedAccountsSignature, buildPopulatedUnifiedAccounts, refreshUnifiedCounts]);
|
||||
}, [enableUnifiedMailbox, includeGroupInUnified, isEmbedded, isAuthenticated, client, mailboxes, connectedAccountsSignature, buildPopulatedUnifiedAccounts, refreshUnifiedCounts, refreshCrossCounts, showCrossUnread, showCrossStarred, showCrossAll]);
|
||||
|
||||
// System-notification click handler. The push SW navigates the user back
|
||||
// here with `?email=<id>` (specific email it built the toast from) or
|
||||
@@ -1039,8 +1058,8 @@ export default function Home() {
|
||||
// Skip when handleEmailSelect already started a fetch (it sets isLoadingEmail before
|
||||
// calling selectEmail on the stub), to avoid a duplicate request.
|
||||
if (!selectedEmail.bodyValues && !isLoadingEmail) {
|
||||
const perAccountClient = isUnifiedView && selectedEmail.accountId
|
||||
? useAuthStore.getState().getClientForAccount(selectedEmail.accountId)
|
||||
const perAccountClient = isUnifiedView && selectedEmail.sourceClientAccountId
|
||||
? useAuthStore.getState().getClientForAccount(selectedEmail.sourceClientAccountId)
|
||||
: undefined;
|
||||
const fetchClient = perAccountClient ?? client;
|
||||
setLoadingEmail(true);
|
||||
@@ -1177,8 +1196,15 @@ export default function Home() {
|
||||
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);
|
||||
// Route to the email's own account so shared/group threads refresh
|
||||
// from the right server, not the active one. (#281)
|
||||
const threadClient = emailState.isUnifiedView && repliedEmail.sourceClientAccountId
|
||||
? (useAuthStore.getState().getClientForAccount(repliedEmail.sourceClientAccountId) ?? client)
|
||||
: client;
|
||||
const accountId = emailState.isUnifiedView && repliedEmail.sourceAccountId
|
||||
? repliedEmail.sourceAccountId
|
||||
: client.getAccountId();
|
||||
const fullEmails = await threadClient.getThreadEmails(repliedEmail.threadId, accountId);
|
||||
if (fullEmails.length > 0) {
|
||||
useEmailStore.setState((state) => {
|
||||
const c = new Map(state.threadEmailsCache);
|
||||
@@ -1414,10 +1440,11 @@ export default function Home() {
|
||||
if (!client || !emailToDelete) return;
|
||||
|
||||
// In unified view the trash destination and current-folder check must come
|
||||
// from the email's own account, not the active one. (#281)
|
||||
// from the email's own account, not the active one. The owning account's
|
||||
// mailbox list is cached under its JMAP id (`sourceAccountId`). (#281)
|
||||
const actionMailboxes =
|
||||
isUnifiedView && emailToDelete.accountId
|
||||
? (accountMailboxes[emailToDelete.accountId] ?? mailboxes)
|
||||
isUnifiedView && emailToDelete.sourceAccountId
|
||||
? (accountMailboxes[emailToDelete.sourceAccountId] ?? mailboxes)
|
||||
: mailboxes;
|
||||
|
||||
// Check if we're currently in the trash or junk folder. In unified view the
|
||||
@@ -1446,11 +1473,17 @@ export default function Home() {
|
||||
console.error("Failed to permanently delete email:", error);
|
||||
}
|
||||
} else {
|
||||
// Not in trash: always move to trash (in the email's own account).
|
||||
// Not in trash: always move to trash (in the email's own account). Scope
|
||||
// the trash lookup to the email's account: for a shared/group source every
|
||||
// mailbox in the list is `isShared`, so we match by accountId instead of
|
||||
// excluding shared (otherwise no trash is found and the delete fails). (#281)
|
||||
const sourceAccountId = isUnifiedView ? emailToDelete.sourceAccountId : undefined;
|
||||
const matchesScope = (m: Mailbox) =>
|
||||
sourceAccountId ? m.accountId === sourceAccountId : !m.isShared;
|
||||
const trashMailbox =
|
||||
actionMailboxes.find(m => m.role === 'trash' && !m.isShared) ??
|
||||
actionMailboxes.find(m => m.role === 'trash' && matchesScope(m)) ??
|
||||
actionMailboxes.find(m => {
|
||||
if (m.isShared) return false;
|
||||
if (!matchesScope(m)) return false;
|
||||
const lower = m.name.toLowerCase();
|
||||
return lower.includes('trash') || lower.includes('deleted');
|
||||
});
|
||||
@@ -1473,11 +1506,14 @@ export default function Home() {
|
||||
if (!client || !emailToArchive) return;
|
||||
|
||||
// In unified view the archive folder (and any year/month subfolders we
|
||||
// create) must live in the email's own account, reached through that
|
||||
// account's client. (#281)
|
||||
const archiveAccountId = isUnifiedView ? emailToArchive.accountId : undefined;
|
||||
const archiveClient = archiveAccountId
|
||||
? (useAuthStore.getState().getClientForAccount(archiveAccountId) ?? client)
|
||||
// create) must live in the email's own account, reached through the login it
|
||||
// is reachable via (`sourceClientAccountId`) and routed to its owning JMAP
|
||||
// account (`sourceAccountId`). For personal sources these resolve to the
|
||||
// account itself, so behavior is unchanged. (#281)
|
||||
const archiveClientId = isUnifiedView ? emailToArchive.sourceClientAccountId : undefined;
|
||||
const archiveAccountId = isUnifiedView ? emailToArchive.sourceAccountId : undefined;
|
||||
const archiveClient = archiveClientId
|
||||
? (useAuthStore.getState().getClientForAccount(archiveClientId) ?? client)
|
||||
: client;
|
||||
// Read fresh mailboxes from the store – batch archive calls this in a loop,
|
||||
// and each iteration needs to see folders created by prior iterations.
|
||||
@@ -1512,7 +1548,7 @@ export default function Home() {
|
||||
m => m.name === year && m.parentId === archiveId
|
||||
);
|
||||
if (!yearMailbox) {
|
||||
yearMailbox = await archiveClient.createMailbox(year, archiveId);
|
||||
yearMailbox = await archiveClient.createMailbox(year, archiveId, archiveAccountId);
|
||||
await refreshMailboxes();
|
||||
}
|
||||
|
||||
@@ -1525,7 +1561,7 @@ export default function Home() {
|
||||
m => m.name === month && m.parentId === yearId
|
||||
);
|
||||
if (!monthMailbox) {
|
||||
monthMailbox = await archiveClient.createMailbox(month, yearId);
|
||||
monthMailbox = await archiveClient.createMailbox(month, yearId, archiveAccountId);
|
||||
await refreshMailboxes();
|
||||
}
|
||||
await moveThreadToMailbox(client, emailToArchive.id, monthMailbox.id);
|
||||
@@ -1618,7 +1654,7 @@ export default function Home() {
|
||||
});
|
||||
} else {
|
||||
const jmapKey = `$label:${color}`;
|
||||
if (keywords[jmapKey] === true) {
|
||||
if (keywords[jmapKey]) {
|
||||
// Toggle off if already active
|
||||
keywords[jmapKey] = false;
|
||||
} else {
|
||||
@@ -1714,6 +1750,28 @@ export default function Home() {
|
||||
return;
|
||||
}
|
||||
|
||||
if (isCrossViewId(mailboxId)) {
|
||||
setScheduledView(false);
|
||||
const view = CROSS_VIEW_BY_ID[mailboxId];
|
||||
if (!view) return;
|
||||
|
||||
selectMailbox(mailboxId);
|
||||
selectEmail(null);
|
||||
|
||||
if (isMobile) {
|
||||
setSidebarOpen(false);
|
||||
setActiveView("list");
|
||||
}
|
||||
if (isTablet) {
|
||||
setTabletListVisible(true);
|
||||
}
|
||||
|
||||
const populated = await buildPopulatedUnifiedAccounts();
|
||||
await fetchCrossViewAction(populated, view);
|
||||
refreshCrossCounts(populated);
|
||||
return;
|
||||
}
|
||||
|
||||
if (isUnifiedView) {
|
||||
exitUnifiedView();
|
||||
}
|
||||
@@ -2227,8 +2285,15 @@ export default function Home() {
|
||||
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);
|
||||
// Route to the email's own account so shared/group threads refresh from
|
||||
// the right server, not the active one. (#281)
|
||||
const threadClient = emailState.isUnifiedView && repliedEmail.sourceClientAccountId
|
||||
? (useAuthStore.getState().getClientForAccount(repliedEmail.sourceClientAccountId) ?? client)
|
||||
: client;
|
||||
const accountId = emailState.isUnifiedView && repliedEmail.sourceAccountId
|
||||
? repliedEmail.sourceAccountId
|
||||
: client.getAccountId();
|
||||
const fullEmails = await threadClient.getThreadEmails(repliedEmail.threadId, accountId);
|
||||
if (fullEmails.length > 0) {
|
||||
useEmailStore.setState((state) => {
|
||||
const c = new Map(state.threadEmailsCache);
|
||||
@@ -2256,7 +2321,12 @@ export default function Home() {
|
||||
? t('sidebar.scheduled')
|
||||
: selectedMailbox === ALL_MAIL_MAILBOX_ID
|
||||
? t('sidebar.mailboxes.all_mail')
|
||||
: mailboxes.find(m => m.id === selectedMailbox)?.name || "Inbox";
|
||||
: (() => {
|
||||
const mb = mailboxes.find(m => m.id === selectedMailbox);
|
||||
return mb
|
||||
? localizeMailboxName(mb.role, mb.name, (k) => t(`sidebar.mailboxes.${k}`))
|
||||
: "Inbox";
|
||||
})();
|
||||
const isFocusedMailLayout = mailLayout === 'focus';
|
||||
const isHorizontalMailLayout = mailLayout === 'horizontal' && !isMobile && !isTablet;
|
||||
const hasViewerContent = showComposer || Boolean(conversationThread) || Boolean(selectedEmail);
|
||||
@@ -2273,11 +2343,10 @@ export default function Home() {
|
||||
setShowComposer(false);
|
||||
}
|
||||
|
||||
// waiting for the body fetch - avoids the loading flicker.
|
||||
// Find the list-level email for metadata (accountId, scheduled flags, etc.)
|
||||
// but don't select it yet — wait for the full fetch to avoid a toolbar flash
|
||||
// caused by rendering with the stub (no bodyValues) then re-rendering with the full email.
|
||||
const listEmail = activeEmails.find(e => e.id === email.id);
|
||||
if (listEmail) {
|
||||
selectEmail(listEmail);
|
||||
}
|
||||
|
||||
setLoadingEmail(true);
|
||||
|
||||
@@ -2293,21 +2362,25 @@ export default function Home() {
|
||||
|
||||
// Fetch the full content
|
||||
try {
|
||||
// In unified view each email carries its own accountId. Use that
|
||||
// account's client so we fetch from the server that actually owns it.
|
||||
const emailAccountId = isUnifiedView ? listEmail?.accountId : undefined;
|
||||
const perAccountClient = emailAccountId
|
||||
? useAuthStore.getState().getClientForAccount(emailAccountId)
|
||||
// In unified view each email carries its source reference: the login it is
|
||||
// reachable through (`sourceClientAccountId`) and its owning JMAP account
|
||||
// (`sourceAccountId`). Resolve both so we fetch from the server that actually
|
||||
// owns it — works uniformly for personal and shared/group sources, since for
|
||||
// personal the owning account equals the client's primary (no-op). (#281)
|
||||
const sourceClientId = isUnifiedView ? listEmail?.sourceClientAccountId : undefined;
|
||||
const perAccountClient = sourceClientId
|
||||
? useAuthStore.getState().getClientForAccount(sourceClientId)
|
||||
: undefined;
|
||||
const fetchClient = perAccountClient ?? client;
|
||||
|
||||
// For shared folders on the primary client, we still need to pass the
|
||||
// shared account's id. In unified view we use the per-account client
|
||||
// directly, so no explicit accountId is needed.
|
||||
const accountId = isUnifiedView
|
||||
? listEmail?.sourceAccountId
|
||||
: (() => {
|
||||
// Non-unified: shared folders on the active client still need their
|
||||
// owner accountId passed explicitly.
|
||||
const mailbox = mailboxes.find(mb => mb.id === selectedMailbox);
|
||||
const accountId = perAccountClient
|
||||
? undefined
|
||||
: mailbox?.isShared ? mailbox.accountId : undefined;
|
||||
return mailbox?.isShared ? mailbox.accountId : undefined;
|
||||
})();
|
||||
|
||||
const fullEmail = await fetchClient.getEmail(email.id, accountId);
|
||||
if (fullEmail) {
|
||||
@@ -2319,9 +2392,13 @@ export default function Home() {
|
||||
fullEmail.isScheduled = true;
|
||||
fullEmail.isSmimeScheduled = listEmail.isSmimeScheduled;
|
||||
}
|
||||
if (emailAccountId) {
|
||||
fullEmail.accountId = emailAccountId;
|
||||
fullEmail.accountLabel = listEmail?.accountLabel;
|
||||
// Re-stamp the source reference so later actions on the open email
|
||||
// resolve to the right account (the fetched object lacks these).
|
||||
if (isUnifiedView && listEmail) {
|
||||
fullEmail.accountId = listEmail.accountId;
|
||||
fullEmail.accountLabel = listEmail.accountLabel;
|
||||
fullEmail.sourceClientAccountId = listEmail.sourceClientAccountId;
|
||||
fullEmail.sourceAccountId = listEmail.sourceAccountId;
|
||||
}
|
||||
selectEmail(fullEmail);
|
||||
// Mark-as-read logic is now handled by useEffect
|
||||
@@ -2370,8 +2447,27 @@ export default function Home() {
|
||||
setActiveView("viewer");
|
||||
|
||||
try {
|
||||
// Fetch complete thread emails
|
||||
const emails = await client.getThreadEmails(thread.threadId);
|
||||
// In unified/aggregate views the thread may belong to another (possibly
|
||||
// shared/group) account. Route the fetch to the login it's reachable
|
||||
// through (`sourceClientAccountId`) and pass its owning JMAP account
|
||||
// (`sourceAccountId`) so the thread loads from the right server instead of
|
||||
// the active one (which doesn't have it → empty/body-less). (#281)
|
||||
const ref = thread.emails?.[0];
|
||||
const threadClient = isUnifiedView && ref?.sourceClientAccountId
|
||||
? (useAuthStore.getState().getClientForAccount(ref.sourceClientAccountId) ?? client)
|
||||
: client;
|
||||
const threadAccountId = isUnifiedView ? ref?.sourceAccountId : undefined;
|
||||
const emails = await threadClient.getThreadEmails(thread.threadId, threadAccountId);
|
||||
// Re-stamp the source reference so conversation actions (reply/move/…)
|
||||
// resolve to the right account; the fetched objects don't carry it.
|
||||
if (isUnifiedView && ref) {
|
||||
for (const e of emails) {
|
||||
e.accountId = ref.accountId;
|
||||
e.accountLabel = ref.accountLabel;
|
||||
e.sourceClientAccountId = ref.sourceClientAccountId;
|
||||
e.sourceAccountId = ref.sourceAccountId;
|
||||
}
|
||||
}
|
||||
setConversationEmails(emails);
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch thread emails:', error);
|
||||
@@ -2519,6 +2615,10 @@ export default function Home() {
|
||||
scheduledTotal={scheduledTotal}
|
||||
showScheduledMailbox={delayedSendSupported}
|
||||
showAllMailMailbox={showAllMailMailbox}
|
||||
showCrossUnread={showCrossUnread}
|
||||
showCrossStarred={showCrossStarred}
|
||||
showCrossAll={showCrossAll}
|
||||
crossUnreadCount={crossUnreadCount}
|
||||
onMailboxSelect={handleMailboxSelect}
|
||||
onTagSelect={handleTagSelect}
|
||||
onUnreadFilterClick={handleUnreadFilterClick}
|
||||
@@ -2686,19 +2786,19 @@ export default function Home() {
|
||||
icon={<Paperclip className="w-3.5 h-3.5" />}
|
||||
label={t("advanced_search.has_attachment")}
|
||||
value={searchFilters.hasAttachment}
|
||||
onClick={() => { const next = searchFilters.hasAttachment === null ? true : searchFilters.hasAttachment === true ? false : null; setSearchFilters({ hasAttachment: next }); handleAdvancedSearch(); }}
|
||||
onClick={() => { const next = searchFilters.hasAttachment === null ? true : searchFilters.hasAttachment ? false : null; setSearchFilters({ hasAttachment: next }); handleAdvancedSearch(); }}
|
||||
/>
|
||||
<ToggleChip
|
||||
icon={<Star className="w-3.5 h-3.5" />}
|
||||
label={t("advanced_search.starred")}
|
||||
value={searchFilters.isStarred}
|
||||
onClick={() => { const next = searchFilters.isStarred === null ? true : searchFilters.isStarred === true ? false : null; setSearchFilters({ isStarred: next }); handleAdvancedSearch(); }}
|
||||
onClick={() => { const next = searchFilters.isStarred === null ? true : searchFilters.isStarred ? false : null; setSearchFilters({ isStarred: next }); handleAdvancedSearch(); }}
|
||||
/>
|
||||
<ToggleChip
|
||||
icon={searchFilters.isUnread === false ? <MailOpen className="w-3.5 h-3.5" /> : <Mail className="w-3.5 h-3.5" />}
|
||||
label={searchFilters.isUnread === false ? t("advanced_search.read") : t("advanced_search.unread")}
|
||||
value={searchFilters.isUnread}
|
||||
onClick={() => { const next = searchFilters.isUnread === null ? true : searchFilters.isUnread === true ? false : null; setSearchFilters({ isUnread: next }); handleAdvancedSearch(); }}
|
||||
onClick={() => { const next = searchFilters.isUnread === null ? true : searchFilters.isUnread ? false : null; setSearchFilters({ isUnread: next }); handleAdvancedSearch(); }}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
@@ -3100,7 +3200,17 @@ export default function Home() {
|
||||
onReply={handleReply}
|
||||
onReplyAll={handleReplyAll}
|
||||
onForward={handleForward}
|
||||
onDelete={() => handleDelete()}
|
||||
onDelete={() => {
|
||||
// Deleting the open message returns to the list (Gmail-style),
|
||||
// not the next email — unless the user turned the setting off.
|
||||
// Deselect first so the store's remove-and-advance sees no
|
||||
// selection and doesn't auto-open the next message.
|
||||
const target = selectedEmail;
|
||||
if (useSettingsStore.getState().returnToListAfterAction) {
|
||||
handleMobileBack();
|
||||
}
|
||||
handleDelete(target);
|
||||
}}
|
||||
onArchive={() => handleArchive()}
|
||||
onToggleStar={handleToggleStar}
|
||||
onSetColorTag={handleSetColorTag}
|
||||
@@ -3109,6 +3219,12 @@ export default function Home() {
|
||||
onMarkAsRead={async (emailId, read) => {
|
||||
if (client) {
|
||||
await markAsRead(client, emailId, read);
|
||||
// Marking the open message unread returns to the list
|
||||
// (Gmail-style, gated on returnToListAfterAction). Staying
|
||||
// in the reading pane would just re-mark it read on view.
|
||||
if (!read && useSettingsStore.getState().returnToListAfterAction) {
|
||||
handleMobileBack();
|
||||
}
|
||||
}
|
||||
}}
|
||||
onDownloadAttachment={handleDownloadAttachment}
|
||||
@@ -3143,7 +3259,7 @@ export default function Home() {
|
||||
}}
|
||||
currentUserEmail={client?.getUsername()}
|
||||
currentUserName={client?.getUsername()?.split("@")[0]}
|
||||
currentMailboxRole={mailboxes.find(m => m.id === selectedMailbox)?.role}
|
||||
currentMailboxRole={mailboxes.find(m => m.id === selectedMailbox)?.role ?? (isUnifiedView ? (unifiedRole ?? undefined) : undefined)}
|
||||
mailboxes={mailboxes}
|
||||
selectedMailbox={selectedMailbox}
|
||||
onMoveToMailbox={async (mailboxId) => {
|
||||
|
||||
@@ -21,7 +21,6 @@ import {
|
||||
Tags,
|
||||
HardDrive,
|
||||
BookUser,
|
||||
KeyRound,
|
||||
PanelLeftClose,
|
||||
Bell,
|
||||
Puzzle,
|
||||
@@ -63,7 +62,6 @@ import { AccountSecuritySettings } from '@/components/settings/account-security-
|
||||
import { FilesSettingsComponent } from '@/components/settings/files-settings';
|
||||
import { DownloadsSettings } from '@/components/settings/downloads-settings';
|
||||
import { ContactsSettings } from '@/components/settings/contacts-settings';
|
||||
import { SmimeSettings } from '@/components/settings/smime-settings';
|
||||
import { SidebarAppsSettings } from '@/components/settings/sidebar-apps-settings';
|
||||
import { NotificationSettings } from '@/components/settings/notification-settings';
|
||||
import { ThemesSettings } from '@/components/settings/themes-settings';
|
||||
@@ -102,7 +100,6 @@ type Tab =
|
||||
| 'folders'
|
||||
| 'keywords'
|
||||
| 'security'
|
||||
| 'encryption'
|
||||
| 'content_senders'
|
||||
| 'calendar'
|
||||
| 'contacts'
|
||||
@@ -139,7 +136,6 @@ const tabIcons: Record<Tab, LucideIcon> = {
|
||||
folders: FolderOpen,
|
||||
keywords: Tags,
|
||||
security: Shield,
|
||||
encryption: KeyRound,
|
||||
content_senders: EyeOff,
|
||||
calendar: Calendar,
|
||||
contacts: BookUser,
|
||||
@@ -217,7 +213,6 @@ const tabSearchPaths: Record<Tab, string[]> = {
|
||||
folders: ['settings.folders'],
|
||||
keywords: ['settings.keywords'],
|
||||
security: ['settings.security'],
|
||||
encryption: ['smime'],
|
||||
content_senders: [
|
||||
'settings.email_behavior.always_light_mode',
|
||||
'settings.email_behavior.external_content',
|
||||
@@ -252,7 +247,6 @@ const tabKeywords: Record<Tab, string> = {
|
||||
folders: 'mailbox subscribe',
|
||||
keywords: 'tags labels colors',
|
||||
security: 'password 2fa two-factor passkey app password mfa',
|
||||
encryption: 's/mime smime certificate pgp gpg',
|
||||
content_senders: 'block sender remote images privacy tracking',
|
||||
calendar: 'event schedule appointment meeting timezone',
|
||||
contacts: 'address book contact',
|
||||
@@ -623,11 +617,10 @@ export default function SettingsPage() {
|
||||
|
||||
// Privacy & Security
|
||||
...(stalwartFeaturesEnabled ? [{ id: 'security' as Tab, label: t('tabs.security'), icon: tabIcons.security, group: 'privacy' as TabGroup }] : []),
|
||||
...(isFeatureEnabled('smimeEnabled') ? [{ id: 'encryption' as Tab, label: t('tabs.encryption'), icon: tabIcons.encryption, group: 'privacy' as TabGroup }] : []),
|
||||
{ id: 'content_senders', label: t('tabs.content_senders'), icon: tabIcons.content_senders, group: 'privacy' },
|
||||
|
||||
// Apps
|
||||
...(supportsCalendar ? [{ id: 'calendar' as Tab, label: t('tabs.calendar'), icon: tabIcons.calendar, group: 'apps' as TabGroup }] : []),
|
||||
...(supportsCalendar && isFeatureEnabled('calendarEnabled') ? [{ id: 'calendar' as Tab, label: t('tabs.calendar'), icon: tabIcons.calendar, group: 'apps' as TabGroup }] : []),
|
||||
...(isFeatureEnabled('contactsEnabled') ? [{ id: 'contacts' as Tab, label: t('tabs.contacts'), icon: tabIcons.contacts, group: 'apps' as TabGroup }] : []),
|
||||
...(supportsFiles && isFeatureEnabled('filesEnabled') ? [{ id: 'files' as Tab, label: t('tabs.files'), icon: tabIcons.files, group: 'apps' as TabGroup }] : []),
|
||||
...(isFeatureEnabled('sidebarAppsEnabled') ? [{ id: 'sidebar_apps' as Tab, label: t('tabs.sidebar_apps'), icon: tabIcons.sidebar_apps, group: 'apps' as TabGroup }] : []),
|
||||
@@ -646,7 +639,7 @@ export default function SettingsPage() {
|
||||
? ([
|
||||
managedAccount.capabilities.sieve && supportsSieve ? 'filters' : null,
|
||||
managedAccount.capabilities.mail && supportsVacation ? 'vacation' : null,
|
||||
managedAccount.capabilities.calendars && supportsCalendar ? 'calendar' : null,
|
||||
managedAccount.capabilities.calendars && supportsCalendar && isFeatureEnabled('calendarEnabled') ? 'calendar' : null,
|
||||
managedAccount.capabilities.contacts && isFeatureEnabled('contactsEnabled') ? 'contacts' : null,
|
||||
].filter(Boolean) as Tab[])
|
||||
: [];
|
||||
@@ -743,7 +736,6 @@ export default function SettingsPage() {
|
||||
{effectiveActiveTab === 'folders' && <FolderSettings />}
|
||||
{effectiveActiveTab === 'keywords' && <KeywordSettings />}
|
||||
{effectiveActiveTab === 'security' && <AccountSecuritySettings />}
|
||||
{effectiveActiveTab === 'encryption' && <SmimeSettings />}
|
||||
{effectiveActiveTab === 'content_senders' && <ContentSendersSettings />}
|
||||
{effectiveActiveTab === 'calendar' && (
|
||||
managedAccountId
|
||||
|
||||
@@ -13,6 +13,7 @@ const FEATURE_GATE_LABELS: Partial<Record<keyof FeatureGates, { label: string; d
|
||||
settingsExportEnabled: { label: 'Settings Export/Import', description: 'Allow users to export and import settings JSON' },
|
||||
customKeywordsEnabled: { label: 'Custom Keywords', description: 'Allow user-created labels and tags' },
|
||||
templatesEnabled: { label: 'Email Templates', description: 'Allow email template creation and library' },
|
||||
calendarEnabled: { label: 'Calendar', description: 'Enable calendar features and views' },
|
||||
calendarTasksEnabled: { label: 'Calendar Tasks', description: 'Show task panel in calendar view' },
|
||||
contactsEnabled: { label: 'Contacts', description: 'Enable contacts/address book features' },
|
||||
smimeEnabled: { label: 'S/MIME', description: 'Enable certificate management and email signing' },
|
||||
@@ -21,7 +22,10 @@ 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 account’s folders into one list. Users choose which folders are included.' },
|
||||
allMailViewEnabled: { label: 'All Mail View', description: 'Show a virtual "All Mail" folder that merges messages from across an account’s folders into one list. Users choose which folders are included. Requires the per-user toggle in Settings → Appearance.' },
|
||||
crossUnreadViewEnabled: { label: 'All Accounts: Unread', description: 'Allow an "All unread" entry in the All accounts section that lists unread mail across every account (incl. shared folders), spanning all folders except junk, sent, archive, trash and drafts. Requires the matching per-user toggle in Settings → Appearance.' },
|
||||
crossStarredViewEnabled: { label: 'All Accounts: Starred', description: 'Allow an "All starred" entry in the All accounts section that lists flagged/starred mail across every account (incl. shared folders), spanning all folders except junk, sent, archive, trash and drafts. Requires the matching per-user toggle in Settings → Appearance.' },
|
||||
crossAllViewEnabled: { label: 'All Accounts: All Mail', description: 'Allow an "All mail" entry in the All accounts section that lists all mail across every account (incl. shared folders), spanning all folders except junk, sent, archive, trash and drafts. Requires the matching per-user toggle in Settings → Appearance.' },
|
||||
};
|
||||
|
||||
const RESTRICTABLE_SETTINGS = [
|
||||
|
||||
@@ -5,12 +5,11 @@ import { Upload, Trash2, Power, PowerOff, Loader2, Palette, Save, Shield, Lock,
|
||||
import type { SettingsPolicy } from '@/lib/admin/types';
|
||||
import { DEFAULT_POLICY, DEFAULT_THEME_POLICY } from '@/lib/admin/types';
|
||||
import { apiFetch } from '@/lib/browser-navigation';
|
||||
import { BUILTIN_THEMES } from '@/lib/builtin-themes';
|
||||
|
||||
const BUILTIN_THEME_OPTIONS = [
|
||||
{ id: 'builtin-nord', name: 'Nord' },
|
||||
{ id: 'builtin-catppuccin', name: 'Catppuccin' },
|
||||
{ id: 'builtin-solarized', name: 'Solarized' },
|
||||
];
|
||||
// Derive from the single source of truth so newly added built-in themes show
|
||||
// up here automatically (was previously a hardcoded subset — see #496).
|
||||
const BUILTIN_THEME_OPTIONS = BUILTIN_THEMES.map(t => ({ id: t.id, name: t.name }));
|
||||
|
||||
interface ThemeEntry {
|
||||
id: string;
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
import { SandboxRuntime } from '@/lib/plugin-sandbox/runtime';
|
||||
|
||||
// Privileged-tier sandbox route. Identical runtime to /plugin-sandbox, but the
|
||||
// host loads it into a same-origin (`allow-same-origin`) iframe so the bundle
|
||||
// gets real `crypto.subtle` + IndexedDB. The trust gate (signature + admin
|
||||
// approval) is enforced host-side before this route is ever framed; the page
|
||||
// itself carries no extra privilege.
|
||||
//
|
||||
// Must be dynamic so the per-request CSP nonce from proxy.ts is embedded in
|
||||
// Next's injected hydration/chunk scripts.
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
export default function PrivilegedPluginSandboxPage() {
|
||||
return <SandboxRuntime />;
|
||||
}
|
||||
@@ -19,7 +19,7 @@ import {
|
||||
invalidateFrameOriginsCache,
|
||||
} from '@/lib/admin/csp-frame-origins';
|
||||
import JSZip from 'jszip';
|
||||
import { MAX_PLUGIN_SIZE, MAX_THEME_SIZE, ALL_PERMISSIONS, ALLOWED_PLUGIN_FILES } from '@/lib/plugin-types';
|
||||
import { MAX_PLUGIN_SIZE, MAX_THEME_SIZE, ALL_PERMISSIONS } from '@/lib/plugin-types';
|
||||
import { sanitizeThemeCSS, validateThemeCSSSafety } from '@/lib/theme-loader';
|
||||
import { configManager } from '@/lib/admin/config-manager';
|
||||
|
||||
|
||||
@@ -183,6 +183,7 @@ export async function POST(request: NextRequest) {
|
||||
author: manifest.author as string,
|
||||
description: (manifest.description as string) || '',
|
||||
type: manifest.type as string,
|
||||
...(manifest.tier === 'privileged' ? { tier: 'privileged' } : {}),
|
||||
permissions: (manifest.permissions as string[]) || [],
|
||||
entrypoint: manifest.entrypoint as string,
|
||||
enabled: true,
|
||||
|
||||
@@ -8,6 +8,7 @@ import { discoverOAuth } from '@/lib/oauth/discovery';
|
||||
import { getOauthScopes } from '@/lib/oauth/tokens';
|
||||
import { getCookieOptions } from '@/lib/oauth/cookie-config';
|
||||
import { hasSessionSecret } from '@/lib/auth/session-secret';
|
||||
import { configManager } from '@/lib/admin/config-manager';
|
||||
|
||||
const SSO_PENDING_COOKIE = 'sso_pending';
|
||||
const SSO_PENDING_MAX_AGE = 300; // 5 minutes
|
||||
@@ -102,8 +103,12 @@ export async function POST(request: NextRequest) {
|
||||
maxAge: SSO_PENDING_MAX_AGE,
|
||||
});
|
||||
|
||||
// Build authorize URL
|
||||
const authUrl = new URL(metadata.authorization_endpoint);
|
||||
// Build authorize URL. OAUTH_AUTHORIZE_URL, when set, overrides only the
|
||||
// user-facing authorize endpoint (e.g. a per-brand login host). Discovery,
|
||||
// token exchange and refresh keep using the canonical discovered endpoints.
|
||||
const authorizeOverride =
|
||||
configManager.get<string>('oauthAuthorizeUrl', '') || process.env.OAUTH_AUTHORIZE_URL;
|
||||
const authUrl = new URL(authorizeOverride?.trim() || metadata.authorization_endpoint);
|
||||
authUrl.searchParams.set('response_type', 'code');
|
||||
authUrl.searchParams.set('client_id', clientId);
|
||||
authUrl.searchParams.set('redirect_uri', redirect_uri);
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { cookies } from 'next/headers';
|
||||
import { logger } from '@/lib/logger';
|
||||
import { discoverOAuth } from '@/lib/oauth/discovery';
|
||||
import { getDiscoveryValidator } from '@/lib/oauth/token-exchange';
|
||||
import { refreshTokenCookieName, refreshTokenServerCookieName } from '@/lib/oauth/tokens';
|
||||
import { getCookieOptions } from '@/lib/oauth/cookie-config';
|
||||
import { readFileEnv } from '@/lib/read-file-env';
|
||||
@@ -11,81 +9,173 @@ import { isPublicHttpUrl } from '@/lib/security/url-guard';
|
||||
import { recordLogin } from '@/lib/telemetry/login-tracker';
|
||||
import { parseJmapServers, findServerByUrl, findServerById } from '@/lib/admin/jmap-servers';
|
||||
import { MAX_ACCOUNT_SLOTS } from '@/lib/account-utils';
|
||||
import { generateCodeVerifier, generateCodeChallenge } from '@/lib/oauth/pkce';
|
||||
|
||||
/**
|
||||
* Exchange basic auth credentials (with TOTP appended) for OAuth tokens.
|
||||
* Exchange a password + (optional) TOTP code for OAuth tokens.
|
||||
*
|
||||
* This allows 2FA users who log in with basic auth + TOTP to upgrade
|
||||
* to token-based auth, avoiding session expiry when the TOTP rotates.
|
||||
* Stalwart 0.16+ no longer accepts the legacy `password$totp` convention over
|
||||
* HTTP Basic auth: its Basic decoder hardcodes `mfa_token: None` and never
|
||||
* splits the secret on `$`, so any TOTP appended to the password is verified
|
||||
* verbatim against the password hash and fails. The MFA token must instead be
|
||||
* supplied as a distinct field through the structured login endpoint.
|
||||
*
|
||||
* Tries three strategies:
|
||||
* 1. ROPC grant with client_id (if OAUTH_CLIENT_ID is set)
|
||||
* 2. ROPC grant without client_id
|
||||
* 3. ROPC grant authenticated via Basic Auth header (Stalwart-style)
|
||||
* This route drives that flow server-side (avoiding browser CORS against the
|
||||
* mail server, same as OAuth discovery):
|
||||
* 1. POST {serverUrl}/api/auth -> authenticate with a separate `mfaToken`,
|
||||
* receiving a short-lived authorization `clientCode`.
|
||||
* 2. POST {serverUrl}/auth/token (grant_type=authorization_code) -> exchange
|
||||
* the code (with PKCE) for access/refresh tokens.
|
||||
*
|
||||
* Token-based auth also survives TOTP rotation, unlike basic auth which embeds
|
||||
* the (≈30s) code in every request.
|
||||
*/
|
||||
|
||||
async function tryTokenRequest(
|
||||
tokenEndpoint: string,
|
||||
params: URLSearchParams,
|
||||
extraHeaders?: Record<string, string>,
|
||||
): Promise<{ ok: true; tokens: { access_token: string; expires_in?: number; refresh_token?: string } } | { ok: false; status: number; error: string }> {
|
||||
// Fallback OAuth client id used when no client is configured. Stalwart accepts
|
||||
// any client id unless `require_client_registration` is enabled (default off);
|
||||
// when it is enabled the admin must configure `oauthClientId` with this
|
||||
// redirect URI registered.
|
||||
const DEFAULT_CLIENT_ID = 'bulwark-webmail';
|
||||
|
||||
interface LoginResult {
|
||||
type?: string;
|
||||
// The response keeps snake_case: only the LoginResponse variant *tags* are
|
||||
// camelCased server-side, not the struct fields (the request fields are).
|
||||
client_code?: string;
|
||||
}
|
||||
|
||||
function trimUrl(url: string): string {
|
||||
return url.replace(/\/+$/, '');
|
||||
}
|
||||
|
||||
async function attemptLogin(
|
||||
upstreamUrl: string,
|
||||
username: string,
|
||||
password: string,
|
||||
totp: string | undefined,
|
||||
redirectUri: string,
|
||||
slot: number,
|
||||
serverId: string | null,
|
||||
): Promise<NextResponse> {
|
||||
const base = trimUrl(upstreamUrl);
|
||||
|
||||
// Per-server OAuth credentials override the global ones when the requested
|
||||
// server entry has its own oauth block configured.
|
||||
const serverList = parseJmapServers(configManager.get<unknown>('jmapServers', []));
|
||||
const entry = findServerById(serverList, serverId);
|
||||
const clientId = entry?.oauth?.clientId
|
||||
|| configManager.get<string>('oauthClientId', '')
|
||||
|| process.env.OAUTH_CLIENT_ID
|
||||
|| DEFAULT_CLIENT_ID;
|
||||
const clientSecret = entry?.oauth?.clientSecret
|
||||
|| configManager.get<string>('oauthClientSecret', '')
|
||||
|| process.env.OAUTH_CLIENT_SECRET
|
||||
|| readFileEnv(process.env.OAUTH_CLIENT_SECRET_FILE)
|
||||
|| '';
|
||||
|
||||
// PKCE proves the token exchange originates from the same client that
|
||||
// initiated the login, so no client secret is required for public clients.
|
||||
const verifier = generateCodeVerifier();
|
||||
const challenge = await generateCodeChallenge(verifier);
|
||||
|
||||
// Step 1: structured login with a separate MFA token.
|
||||
let login: LoginResult;
|
||||
try {
|
||||
const headers: Record<string, string> = { 'Content-Type': 'application/x-www-form-urlencoded', ...extraHeaders };
|
||||
const response = await fetch(tokenEndpoint, {
|
||||
const loginResponse = await fetch(`${base}/api/auth`, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: params.toString(),
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
type: 'authCode',
|
||||
accountName: username,
|
||||
accountSecret: password,
|
||||
...(totp ? { mfaToken: totp } : {}),
|
||||
clientId,
|
||||
redirectUri,
|
||||
codeChallenge: challenge,
|
||||
codeChallengeMethod: 'S256',
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
return { ok: false, status: response.status, error: errorText.substring(0, 500) };
|
||||
if (!loginResponse.ok) {
|
||||
const detail = (await loginResponse.text()).substring(0, 500);
|
||||
logger.warn('TOTP login: /api/auth rejected request', { status: loginResponse.status });
|
||||
// A 404 means the server predates the structured login endpoint; let the
|
||||
// caller fall back to the legacy basic-auth path.
|
||||
return NextResponse.json(
|
||||
{ error: loginResponse.status === 404 ? 'login_endpoint_missing' : 'login_failed', detail },
|
||||
{ status: loginResponse.status === 404 ? 404 : 502 },
|
||||
);
|
||||
}
|
||||
|
||||
const tokens = await response.json();
|
||||
if (!tokens.access_token) {
|
||||
return { ok: false, status: 502, error: 'Response missing access_token' };
|
||||
}
|
||||
|
||||
return { ok: true, tokens };
|
||||
login = await loginResponse.json();
|
||||
} catch (err) {
|
||||
return { ok: false, status: 0, error: err instanceof Error ? err.message : String(err) };
|
||||
}
|
||||
logger.warn('TOTP login: /api/auth request failed', { error: err instanceof Error ? err.message : String(err) });
|
||||
return NextResponse.json({ error: 'login_unreachable' }, { status: 502 });
|
||||
}
|
||||
|
||||
async function findTokenEndpoint(serverUrl: string, adminTrusted: boolean): Promise<string | null> {
|
||||
// Admin-trusted callers (matched server entry or configured JMAP server URL)
|
||||
// honor the `oauthAllowPrivateEndpoints` opt-in. User-supplied URLs always
|
||||
// go through the SSRF validator regardless of the setting.
|
||||
const validateEndpoint = adminTrusted ? getDiscoveryValidator() : isPublicHttpUrl;
|
||||
// 1. Try OAuth discovery
|
||||
const metadata = await discoverOAuth(serverUrl, { validateEndpoint });
|
||||
if (metadata?.token_endpoint) return metadata.token_endpoint;
|
||||
switch (login.type) {
|
||||
case 'authenticated':
|
||||
break;
|
||||
case 'mfaRequired':
|
||||
return NextResponse.json({ error: 'totp_required' }, { status: 401 });
|
||||
case 'failure':
|
||||
default:
|
||||
return NextResponse.json({ error: 'invalid_credentials' }, { status: 401 });
|
||||
}
|
||||
|
||||
// 2. Try common Stalwart token endpoint paths directly
|
||||
const candidates = [
|
||||
`${serverUrl}/auth/token`,
|
||||
`${serverUrl}/api/oauth/token`,
|
||||
];
|
||||
if (!login.client_code) {
|
||||
logger.warn('TOTP login: authenticated response missing client_code');
|
||||
return NextResponse.json({ error: 'login_failed' }, { status: 502 });
|
||||
}
|
||||
|
||||
for (const url of candidates) {
|
||||
// Step 2: exchange the authorization code for tokens.
|
||||
const tokenParams = new URLSearchParams({
|
||||
grant_type: 'authorization_code',
|
||||
code: login.client_code,
|
||||
client_id: clientId,
|
||||
redirect_uri: redirectUri,
|
||||
code_verifier: verifier,
|
||||
});
|
||||
// Confidential clients still send their secret; harmless for public clients.
|
||||
if (clientSecret) tokenParams.set('client_secret', clientSecret);
|
||||
|
||||
let tokens: { access_token?: string; expires_in?: number; refresh_token?: string };
|
||||
try {
|
||||
// A POST with no body should return 400 (bad request) rather than 404 if the endpoint exists
|
||||
const probe = await fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body: 'grant_type=probe' });
|
||||
if (probe.status !== 404 && probe.status !== 405) {
|
||||
return url;
|
||||
}
|
||||
} catch {
|
||||
// Network error - endpoint not reachable
|
||||
}
|
||||
const tokenResponse = await fetch(`${base}/auth/token`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body: tokenParams.toString(),
|
||||
});
|
||||
|
||||
if (!tokenResponse.ok) {
|
||||
const detail = (await tokenResponse.text()).substring(0, 500);
|
||||
logger.warn('TOTP login: token exchange failed', { status: tokenResponse.status, detail });
|
||||
return NextResponse.json({ error: 'token_exchange_failed', detail }, { status: 502 });
|
||||
}
|
||||
|
||||
return null;
|
||||
tokens = await tokenResponse.json();
|
||||
} catch (err) {
|
||||
logger.warn('TOTP login: token endpoint failed', { error: err instanceof Error ? err.message : String(err) });
|
||||
return NextResponse.json({ error: 'token_exchange_failed' }, { status: 502 });
|
||||
}
|
||||
|
||||
if (!tokens.access_token) {
|
||||
return NextResponse.json({ error: 'token_exchange_failed', detail: 'Response missing access_token' }, { status: 502 });
|
||||
}
|
||||
|
||||
logger.info('TOTP login succeeded');
|
||||
void recordLogin(username, base);
|
||||
return await storeAndRespond(
|
||||
{ access_token: tokens.access_token, expires_in: tokens.expires_in, refresh_token: tokens.refresh_token },
|
||||
slot,
|
||||
serverId,
|
||||
);
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const { serverUrl, username, password, slot: bodySlot, server_id: bodyServerId } = await request.json();
|
||||
const { serverUrl, username, password, totp, slot: bodySlot, server_id: bodyServerId, redirectUri: bodyRedirectUri } =
|
||||
await request.json();
|
||||
|
||||
if (!serverUrl || !username || !password) {
|
||||
return NextResponse.json({ error: 'Missing required parameters' }, { status: 400 });
|
||||
@@ -93,6 +183,7 @@ export async function POST(request: NextRequest) {
|
||||
|
||||
const slot = typeof bodySlot === 'number' && bodySlot >= 0 && bodySlot < MAX_ACCOUNT_SLOTS ? bodySlot : 0;
|
||||
const requestedServerId = typeof bodyServerId === 'string' && bodyServerId ? bodyServerId : null;
|
||||
const totpCode = typeof totp === 'string' && totp ? totp : undefined;
|
||||
|
||||
// Pin the upstream URL to a configured JMAP server. The list of allowed
|
||||
// servers is `jmapServerUrl` plus any entry from `jmapServers`. Only when
|
||||
@@ -110,20 +201,17 @@ export async function POST(request: NextRequest) {
|
||||
|
||||
let upstreamUrl: string;
|
||||
let resolvedServerId: string | null = null;
|
||||
let adminTrusted = false;
|
||||
const requestedEntry = findServerById(serverList, requestedServerId);
|
||||
const matchedEntry = requestedEntry || findServerByUrl(serverList, serverUrl);
|
||||
|
||||
if (matchedEntry) {
|
||||
upstreamUrl = matchedEntry.url;
|
||||
resolvedServerId = matchedEntry.id;
|
||||
adminTrusted = true;
|
||||
} else if (configuredServerUrl) {
|
||||
upstreamUrl = configuredServerUrl;
|
||||
adminTrusted = true;
|
||||
} else if (allowCustomEndpoint) {
|
||||
if (!(await isPublicHttpUrl(serverUrl))) {
|
||||
logger.warn('TOTP token exchange: rejected non-public server URL');
|
||||
logger.warn('TOTP login: rejected non-public server URL');
|
||||
return NextResponse.json({ error: 'invalid_server_url' }, { status: 400 });
|
||||
}
|
||||
upstreamUrl = serverUrl;
|
||||
@@ -131,100 +219,22 @@ export async function POST(request: NextRequest) {
|
||||
return NextResponse.json({ error: 'jmap_server_not_configured' }, { status: 500 });
|
||||
}
|
||||
|
||||
const tokenEndpoint = await findTokenEndpoint(upstreamUrl, adminTrusted);
|
||||
if (!tokenEndpoint) {
|
||||
logger.warn('TOTP token exchange: no token endpoint found');
|
||||
return NextResponse.json({ error: 'no_token_endpoint', detail: 'Could not discover OAuth token endpoint on the mail server' }, { status: 404 });
|
||||
}
|
||||
// The redirect URI must be identical in the login and token-exchange steps,
|
||||
// and (when require_client_registration is on) registered for the client.
|
||||
// Prefer the browser-supplied callback URL the OAuth client already uses;
|
||||
// fall back to the upstream URL so the two steps still agree.
|
||||
const redirectUri =
|
||||
typeof bodyRedirectUri === 'string' && /^https?:\/\//.test(bodyRedirectUri)
|
||||
? bodyRedirectUri
|
||||
: trimUrl(upstreamUrl);
|
||||
|
||||
return await attemptAllStrategies(tokenEndpoint, upstreamUrl, username, password, slot, resolvedServerId);
|
||||
return await attemptLogin(upstreamUrl, username, password, totpCode, redirectUri, slot, resolvedServerId);
|
||||
} catch (error) {
|
||||
logger.error('TOTP token exchange error', { error: error instanceof Error ? error.message : 'Unknown error' });
|
||||
logger.error('TOTP login error', { error: error instanceof Error ? error.message : 'Unknown error' });
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
async function attemptAllStrategies(
|
||||
tokenEndpoint: string,
|
||||
serverUrl: string,
|
||||
username: string,
|
||||
password: string,
|
||||
slot: number,
|
||||
serverId: string | null,
|
||||
): Promise<NextResponse> {
|
||||
logger.info('TOTP token exchange: found token endpoint', { tokenEndpoint });
|
||||
|
||||
// Per-server OAuth credentials override the global ones when the requested
|
||||
// server entry has its own oauth block configured.
|
||||
const serverList = parseJmapServers(configManager.get<unknown>('jmapServers', []));
|
||||
const entry = findServerById(serverList, serverId);
|
||||
const clientId = entry?.oauth?.clientId
|
||||
|| configManager.get<string>('oauthClientId', '')
|
||||
|| process.env.OAUTH_CLIENT_ID;
|
||||
const clientSecret = entry?.oauth?.clientSecret
|
||||
|| configManager.get<string>('oauthClientSecret', '')
|
||||
|| process.env.OAUTH_CLIENT_SECRET
|
||||
|| readFileEnv(process.env.OAUTH_CLIENT_SECRET_FILE);
|
||||
const basicAuth = `Basic ${Buffer.from(`${username}:${password}`).toString('base64')}`;
|
||||
const attempts: Array<{ strategy: string; error: string }> = [];
|
||||
|
||||
// Strategy 1: ROPC with client_id (if configured)
|
||||
if (clientId) {
|
||||
const params = new URLSearchParams({ grant_type: 'password', username, password, client_id: clientId });
|
||||
if (clientSecret) params.set('client_secret', clientSecret);
|
||||
const result = await tryTokenRequest(tokenEndpoint, params);
|
||||
if (result.ok) {
|
||||
logger.info('TOTP token exchange succeeded (ROPC with client_id)');
|
||||
void recordLogin(username, serverUrl);
|
||||
return await storeAndRespond(result.tokens, slot, serverId);
|
||||
}
|
||||
attempts.push({ strategy: 'ROPC with client_id', error: result.error });
|
||||
}
|
||||
|
||||
// Strategy 2: ROPC without client_id
|
||||
{
|
||||
const params = new URLSearchParams({ grant_type: 'password', username, password });
|
||||
const result = await tryTokenRequest(tokenEndpoint, params);
|
||||
if (result.ok) {
|
||||
logger.info('TOTP token exchange succeeded (ROPC without client_id)');
|
||||
void recordLogin(username, serverUrl);
|
||||
return await storeAndRespond(result.tokens, slot, serverId);
|
||||
}
|
||||
attempts.push({ strategy: 'ROPC without client_id', error: result.error });
|
||||
}
|
||||
|
||||
// Strategy 3: Basic Auth header on token endpoint (some servers accept this)
|
||||
{
|
||||
const params = new URLSearchParams({ grant_type: 'password' });
|
||||
const result = await tryTokenRequest(tokenEndpoint, params, { 'Authorization': basicAuth });
|
||||
if (result.ok) {
|
||||
logger.info('TOTP token exchange succeeded (Basic Auth header)');
|
||||
void recordLogin(username, serverUrl);
|
||||
return await storeAndRespond(result.tokens, slot, serverId);
|
||||
}
|
||||
attempts.push({ strategy: 'Basic Auth header', error: result.error });
|
||||
}
|
||||
|
||||
// Strategy 4: client_credentials with Basic Auth (last resort)
|
||||
{
|
||||
const params = new URLSearchParams({ grant_type: 'client_credentials' });
|
||||
const result = await tryTokenRequest(tokenEndpoint, params, { 'Authorization': basicAuth });
|
||||
if (result.ok) {
|
||||
logger.info('TOTP token exchange succeeded (client_credentials + Basic Auth)');
|
||||
void recordLogin(username, serverUrl);
|
||||
return await storeAndRespond(result.tokens, slot, serverId);
|
||||
}
|
||||
attempts.push({ strategy: 'client_credentials + Basic Auth', error: result.error });
|
||||
}
|
||||
|
||||
logger.warn('TOTP token exchange: all strategies failed', { attempts });
|
||||
return NextResponse.json({
|
||||
error: 'token_exchange_failed',
|
||||
detail: 'All token exchange strategies failed',
|
||||
attempts,
|
||||
}, { status: 502 });
|
||||
}
|
||||
|
||||
async function storeAndRespond(
|
||||
tokens: { access_token: string; expires_in?: number; refresh_token?: string },
|
||||
slot: number,
|
||||
|
||||
@@ -37,6 +37,9 @@ export async function GET() {
|
||||
author: p.author,
|
||||
description: p.description,
|
||||
type: p.type,
|
||||
// Requested execution tier; clients gate the same-origin privileged
|
||||
// sandbox on this (plus signature + approval + consent).
|
||||
tier: p.tier,
|
||||
permissions: p.permissions,
|
||||
entrypoint: p.entrypoint,
|
||||
// Policy is the canonical source for force-enable. The per-plugin field
|
||||
|
||||
@@ -5,7 +5,7 @@ import { useTranslations, useFormatter } from "next-intl";
|
||||
import { format, isToday, isTomorrow, startOfDay } from "date-fns";
|
||||
import { MapPin, Users } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { parseDuration, getEventColor } from "./event-card";
|
||||
import { getEventColor } from "./event-card";
|
||||
import { getEventDayBounds, getEventEndDate, getEventStartDate, getPrimaryCalendarId } from "@/lib/calendar-utils";
|
||||
import { getParticipantCount } from "@/lib/calendar-participants";
|
||||
import type { CalendarEvent, Calendar } from "@/lib/jmap/types";
|
||||
@@ -147,7 +147,6 @@ export function CalendarAgendaView({
|
||||
const calendar = calId ? calendarMap.get(calId) : undefined;
|
||||
const color = getEventColor(ev, calendar);
|
||||
const start = getEventStartDate(ev);
|
||||
const durMin = parseDuration(ev.duration);
|
||||
const end = getEventEndDate(ev);
|
||||
const locationName = ev.locations
|
||||
? Object.values(ev.locations)[0]?.name
|
||||
|
||||
@@ -71,7 +71,7 @@ function createEventDragPreview(title: string, timeRange: string, color: string)
|
||||
return el;
|
||||
}
|
||||
|
||||
export function EventCard({ event, calendar, variant, onClick, onMouseEnter, onMouseLeave, onContextMenu, isSelected, draggable: isDraggable, continuesBefore = false, continuesAfter = false, className, style }: EventCardProps) {
|
||||
export function EventCard({ event, calendar, variant, onClick, onMouseEnter, onMouseLeave, onContextMenu, isSelected, draggable: isDraggable, continuesAfter = false, className, style }: EventCardProps) {
|
||||
const t = useTranslations("calendar");
|
||||
const [isBeingDragged, setIsBeingDragged] = useState(false);
|
||||
const color = getEventColor(event, calendar);
|
||||
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
X, Clock, MapPin, Video, Users, Repeat, Bell, AlignLeft,
|
||||
Pencil, Trash2, Copy, Send, Check,
|
||||
} from "lucide-react";
|
||||
import { format, isSameDay, parseISO } from "date-fns";
|
||||
import { format, isSameDay } from "date-fns";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { CalendarEvent, Calendar, CalendarParticipant } from "@/lib/jmap/types";
|
||||
import { parseDuration, getEventColor } from "./event-card";
|
||||
|
||||
@@ -523,9 +523,14 @@ export function EventModal({
|
||||
effectiveAttendees
|
||||
) as Record<string, CalendarParticipant>;
|
||||
data.replyTo = { imip: `mailto:${organizerEmail}` };
|
||||
// Stalwart (calcard) derives the iCalendar ORGANIZER property solely from
|
||||
// organizerCalendarAddress; without it no ORGANIZER is emitted and iTIP
|
||||
// scheduling is silently skipped (NoSchedulingInfo), so no invites are sent.
|
||||
data.organizerCalendarAddress = `mailto:${organizerEmail}`;
|
||||
} else if (effectiveAttendees.length === 0 && event?.participants) {
|
||||
data.participants = null;
|
||||
data.replyTo = null;
|
||||
data.organizerCalendarAddress = null;
|
||||
}
|
||||
|
||||
const shouldSendScheduling = effectiveAttendees.length > 0 && sendInvitations;
|
||||
@@ -617,7 +622,6 @@ export function EventModal({
|
||||
|
||||
if (isAttendeeMode && event) {
|
||||
const startD = getEventStartDate(event);
|
||||
const durMin = parseDuration(event.duration);
|
||||
const endD = getEventEndDate(event);
|
||||
const locationName = event.locations ? Object.values(event.locations)[0]?.name : null;
|
||||
const participants = getParticipantList(event);
|
||||
|
||||
@@ -4,7 +4,7 @@ import { useState, useCallback, useRef, useEffect } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { X, Upload, Check, Loader2, RefreshCw, Globe } from "lucide-react";
|
||||
import { format, parseISO } from "date-fns";
|
||||
import { format } from "date-fns";
|
||||
import type { CalendarEvent, Calendar } from "@/lib/jmap/types";
|
||||
import type { IJMAPClient } from '@/lib/jmap/client-interface';
|
||||
import { getEventStartDate } from "@/lib/calendar-utils";
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { fireEvent } from '@testing-library/dom';
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { ContactsSidebar } from '../contacts-sidebar';
|
||||
import type { ContactCard } from '@/lib/jmap/types';
|
||||
|
||||
// next-intl + next/navigation are mocked globally in vitest.setup (t returns the key).
|
||||
vi.mock('@/stores/account-store', () => {
|
||||
const state = { accounts: [], activeAccountId: null };
|
||||
const hook = (sel?: (s: typeof state) => unknown) =>
|
||||
typeof sel === 'function' ? sel(state) : state;
|
||||
hook.getState = () => state;
|
||||
return { useAccountStore: hook };
|
||||
});
|
||||
|
||||
const group = {
|
||||
id: 'g1',
|
||||
kind: 'group',
|
||||
name: { full: 'Team' },
|
||||
members: { '1': true },
|
||||
} as unknown as ContactCard;
|
||||
|
||||
function renderSidebar(onComposeGroup = vi.fn()) {
|
||||
render(
|
||||
<ContactsSidebar
|
||||
groups={[group]}
|
||||
individuals={[]}
|
||||
addressBooks={[]}
|
||||
activeCategory="all"
|
||||
onSelectCategory={vi.fn()}
|
||||
onCreateGroup={vi.fn()}
|
||||
onCreateContact={vi.fn()}
|
||||
onEditGroup={vi.fn()}
|
||||
onDeleteGroup={vi.fn()}
|
||||
onComposeGroup={onComposeGroup}
|
||||
/>,
|
||||
);
|
||||
return onComposeGroup;
|
||||
}
|
||||
|
||||
describe('ContactsSidebar — compose to group', () => {
|
||||
beforeEach(() => vi.clearAllMocks());
|
||||
|
||||
it('shows a "Send email to group" submenu in the group context menu', () => {
|
||||
renderSidebar();
|
||||
fireEvent.contextMenu(screen.getByText('Team'));
|
||||
expect(screen.getByText('groups.send_email')).toBeInTheDocument();
|
||||
// and the existing Edit/Delete entries still render
|
||||
expect(screen.getByText('groups.edit')).toBeInTheDocument();
|
||||
expect(screen.getByText('form.delete')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('calls onComposeGroup(groupId, field) when a To/Cc/Bcc item is clicked', () => {
|
||||
const onComposeGroup = renderSidebar();
|
||||
fireEvent.contextMenu(screen.getByText('Team'));
|
||||
|
||||
// Open the submenu (hover) then click "Cc".
|
||||
const trigger = screen.getByText('groups.send_email').closest('.relative')!;
|
||||
fireEvent.mouseOver(trigger);
|
||||
fireEvent.mouseEnter(trigger);
|
||||
|
||||
fireEvent.click(screen.getByText('groups.send_email_cc'));
|
||||
expect(onComposeGroup).toHaveBeenCalledWith('g1', 'cc');
|
||||
});
|
||||
});
|
||||
@@ -2,16 +2,13 @@
|
||||
|
||||
import { useState, useEffect, useRef } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { Mail, Phone, Building, MapPin, StickyNote, Pencil, Trash2, BookUser, Copy, Send, Globe, Cake, KeyRound, Users, Briefcase, Heart, Languages, Calendar, UserCircle, ShieldCheck, ShieldAlert, Download, MoreHorizontal, Printer } from "lucide-react";
|
||||
import { Mail, Phone, Building, MapPin, StickyNote, Pencil, Trash2, BookUser, Copy, Send, Globe, Cake, KeyRound, Users, Briefcase, Heart, Languages, Calendar, UserCircle, Download, MoreHorizontal, Printer } from "lucide-react";
|
||||
import { Avatar } from "@/components/ui/avatar";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { ContactCard, AnniversaryDate, PartialDate } from "@/lib/jmap/types";
|
||||
import { getContactDisplayName, getContactPrimaryEmail, getContactPhotoUri } from "@/stores/contact-store";
|
||||
import { ContactActivity } from "./contact-activity";
|
||||
import { useSmimeStore } from "@/stores/smime-store";
|
||||
import { parseCertificatePemOrDer, extractCertificateInfo } from "@/lib/smime/certificate-utils";
|
||||
import type { CertificateInfo } from "@/lib/smime/types";
|
||||
import { toast } from "@/stores/toast-store";
|
||||
import { exportContact } from "./contact-export";
|
||||
import { printContact } from "./contact-print";
|
||||
@@ -32,6 +29,8 @@ interface ContactDetailProps {
|
||||
onDelete: () => void;
|
||||
onAddToGroup?: () => void;
|
||||
onDuplicate?: () => void;
|
||||
/** Compose an email to this contact in the app (no OS mailto handoff). */
|
||||
onCompose?: () => void;
|
||||
isMobile?: boolean;
|
||||
className?: string;
|
||||
}
|
||||
@@ -115,51 +114,11 @@ function formatDate(dateInput: AnniversaryDate): string {
|
||||
return dateStr;
|
||||
}
|
||||
|
||||
export function ContactDetail({ contact, onEdit, onDelete, onAddToGroup, onDuplicate, isMobile, className }: ContactDetailProps) {
|
||||
export function ContactDetail({ contact, onEdit, onDelete, onAddToGroup, onDuplicate, onCompose, isMobile, className }: ContactDetailProps) {
|
||||
const t = useTranslations("contacts");
|
||||
const smimeStore = useSmimeStore();
|
||||
const [parsedCerts, setParsedCerts] = useState<Map<number, CertificateInfo>>(new Map());
|
||||
|
||||
const cryptoKeys = contact?.cryptoKeys ? Object.values(contact.cryptoKeys) : [];
|
||||
|
||||
useEffect(() => {
|
||||
if (!contact) return;
|
||||
let cancelled = false;
|
||||
const parseCerts = async () => {
|
||||
const results = new Map<number, CertificateInfo>();
|
||||
for (let i = 0; i < cryptoKeys.length; i++) {
|
||||
const key = cryptoKeys[i];
|
||||
if (typeof key.uri !== 'string') continue;
|
||||
try {
|
||||
let derBytes: ArrayBuffer | string | null = null;
|
||||
if (key.uri.startsWith('data:')) {
|
||||
const commaIdx = key.uri.indexOf(',');
|
||||
if (commaIdx === -1) continue;
|
||||
const b64 = key.uri.substring(commaIdx + 1);
|
||||
const binary = atob(b64);
|
||||
const bytes = new Uint8Array(binary.length);
|
||||
for (let j = 0; j < binary.length; j++) bytes[j] = binary.charCodeAt(j);
|
||||
derBytes = bytes.buffer;
|
||||
} else if (key.uri.startsWith('-----BEGIN')) {
|
||||
derBytes = key.uri;
|
||||
}
|
||||
if (!derBytes) continue;
|
||||
const cert = parseCertificatePemOrDer(derBytes);
|
||||
const der = typeof derBytes === 'string' ? cert.toSchema(true).toBER(false) : derBytes;
|
||||
const info = await extractCertificateInfo(cert, der);
|
||||
if (!cancelled) results.set(i, info);
|
||||
} catch { /* skip unparseable keys */ }
|
||||
}
|
||||
if (!cancelled) setParsedCerts(results);
|
||||
};
|
||||
if (cryptoKeys.length > 0) {
|
||||
parseCerts();
|
||||
} else {
|
||||
setParsedCerts(new Map());
|
||||
}
|
||||
return () => { cancelled = true; };
|
||||
}, [contact?.id]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
if (!contact) {
|
||||
return (
|
||||
<div className={cn("flex flex-col items-center justify-center h-full text-muted-foreground", className)}>
|
||||
@@ -201,35 +160,10 @@ export function ContactDetail({ contact, onEdit, onDelete, onAddToGroup, onDupli
|
||||
const notes = contact.notes ? Object.values(contact.notes) : [];
|
||||
const titles = contact.titles ? Object.values(contact.titles) : [];
|
||||
const jobTitles = titles.filter(t => t.kind !== "role");
|
||||
const roles = titles.filter(t => t.kind === "role");
|
||||
const onlineServices = contact.onlineServices ? Object.values(contact.onlineServices) : [];
|
||||
const anniversaries = contact.anniversaries ? Object.values(contact.anniversaries) : [];
|
||||
const keywords = contact.keywords ? Object.keys(contact.keywords).filter(k => contact.keywords![k]) : [];
|
||||
|
||||
const handleImportContactCert = async (keyIndex: number) => {
|
||||
const key = cryptoKeys[keyIndex];
|
||||
if (!key?.uri || typeof key.uri !== 'string') return;
|
||||
try {
|
||||
let derBytes: ArrayBuffer | string;
|
||||
if (key.uri.startsWith('data:')) {
|
||||
const commaIdx = key.uri.indexOf(',');
|
||||
if (commaIdx === -1) return;
|
||||
const b64 = key.uri.substring(commaIdx + 1);
|
||||
const binary = atob(b64);
|
||||
const bytes = new Uint8Array(binary.length);
|
||||
for (let j = 0; j < binary.length; j++) bytes[j] = binary.charCodeAt(j);
|
||||
derBytes = bytes.buffer;
|
||||
} else if (key.uri.startsWith('-----BEGIN')) {
|
||||
derBytes = key.uri;
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
await smimeStore.importPublicCert(derBytes, 'contact', contact.id);
|
||||
toast.success(t("detail.cert_imported"));
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : t("detail.cert_import_failed"));
|
||||
}
|
||||
};
|
||||
const relatedTo = contact.relatedTo ? Object.entries(contact.relatedTo) : [];
|
||||
const preferredLanguages = contact.preferredLanguages ? Object.values(contact.preferredLanguages) : [];
|
||||
const personalInfo = contact.personalInfo ? Object.values(contact.personalInfo) : [];
|
||||
@@ -260,14 +194,16 @@ export function ContactDetail({ contact, onEdit, onDelete, onAddToGroup, onDupli
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-2 flex-shrink-0 flex-wrap">
|
||||
{email && (
|
||||
<a
|
||||
href={`mailto:${email}`}
|
||||
className="inline-flex items-center justify-center rounded-md font-medium h-9 px-3 text-sm border border-input bg-background hover:bg-accent hover:text-accent-foreground transition-colors touch-manipulation"
|
||||
{email && onCompose && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={onCompose}
|
||||
className="touch-manipulation"
|
||||
>
|
||||
<Send className="w-4 h-4 mr-1" />
|
||||
{t("detail.compose_email")}
|
||||
</a>
|
||||
</Button>
|
||||
)}
|
||||
{phone && (
|
||||
<a
|
||||
@@ -490,45 +426,8 @@ export function ContactDetail({ contact, onEdit, onDelete, onAddToGroup, onDupli
|
||||
{cryptoKeys.length > 0 && (
|
||||
<Section title={t("detail.crypto_keys")}>
|
||||
<div className="space-y-3">
|
||||
{cryptoKeys.map((key, i) => {
|
||||
const certInfo = parsedCerts.get(i);
|
||||
const isExpired = certInfo ? new Date(certInfo.notAfter) < new Date() : false;
|
||||
const alreadyImported = certInfo?.emailAddresses?.[0]
|
||||
? !!smimeStore.getPublicCertForEmail(certInfo.emailAddresses[0])
|
||||
: false;
|
||||
|
||||
return (
|
||||
{cryptoKeys.map((key, i) => (
|
||||
<div key={i} className="rounded-md border border-border/60 bg-muted/30 p-3 space-y-1">
|
||||
{certInfo ? (
|
||||
<>
|
||||
<div className="flex items-center gap-2">
|
||||
{isExpired ? (
|
||||
<ShieldAlert className="w-4 h-4 text-destructive flex-shrink-0" />
|
||||
) : (
|
||||
<ShieldCheck className="w-4 h-4 text-primary flex-shrink-0" />
|
||||
)}
|
||||
<span className="text-sm font-medium truncate">{certInfo.subject}</span>
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground space-y-0.5 pl-6">
|
||||
<p>{t("detail.cert_issuer")}: {certInfo.issuer}</p>
|
||||
<p>
|
||||
{t("detail.cert_expires")}: {new Date(certInfo.notAfter).toLocaleDateString()}
|
||||
{isExpired && <span className="text-destructive ml-1">({t("detail.cert_expired")})</span>}
|
||||
</p>
|
||||
<p>{t("detail.cert_fingerprint")}: {certInfo.fingerprint.substring(0, 20)}...</p>
|
||||
{certInfo.algorithm && <p>{t("detail.cert_algorithm")}: {certInfo.algorithm}</p>}
|
||||
</div>
|
||||
{!alreadyImported && (
|
||||
<Button variant="ghost" size="sm" className="ml-4 mt-1" onClick={() => handleImportContactCert(i)}>
|
||||
<Download className="w-3 h-3 mr-1" />
|
||||
{t("detail.import_to_smime")}
|
||||
</Button>
|
||||
)}
|
||||
{alreadyImported && (
|
||||
<p className="text-xs text-green-600 pl-6 mt-1">{t("detail.cert_already_imported")}</p>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<div className="flex items-start gap-2 text-sm break-all">
|
||||
<KeyRound className="w-4 h-4 text-muted-foreground mt-0.5 flex-shrink-0" />
|
||||
{typeof key.uri === 'string' && key.uri.startsWith("http") ? (
|
||||
@@ -539,10 +438,8 @@ export function ContactDetail({ contact, onEdit, onDelete, onAddToGroup, onDupli
|
||||
<span className="text-muted-foreground">{typeof key.uri === 'string' ? `${key.uri.substring(0, 80)}${key.uri.length > 80 ? "…" : ""}` : String(key.uri ?? '')}</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
))}
|
||||
</div>
|
||||
</Section>
|
||||
)}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useMemo, useCallback, useEffect, useRef } from "react";
|
||||
import { useState, useMemo, useCallback, useRef } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { X, Plus, ChevronDown, ChevronRight, User, Building, MapPin, Globe, Cake, Heart, Tag, StickyNote, Mail, Phone, Calendar, UserCircle, Book, Camera, Trash2 } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useTranslations } from "next-intl";
|
||||
import { Users, Pencil, Trash2, UserMinus } from "lucide-react";
|
||||
import { Users, Pencil, Trash2, UserMinus, Mail } from "lucide-react";
|
||||
import { Avatar } from "@/components/ui/avatar";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { cn } from "@/lib/utils";
|
||||
@@ -15,6 +15,8 @@ interface ContactGroupDetailProps {
|
||||
onDelete: () => void;
|
||||
onRemoveMember: (memberId: string) => void;
|
||||
onSelectMember: (id: string) => void;
|
||||
/** Compose an email to every member with the recipients placed in `field`. */
|
||||
onComposeGroup?: (field: "to" | "cc" | "bcc") => void;
|
||||
isMobile?: boolean;
|
||||
className?: string;
|
||||
}
|
||||
@@ -26,11 +28,13 @@ export function ContactGroupDetail({
|
||||
onDelete,
|
||||
onRemoveMember,
|
||||
onSelectMember,
|
||||
onComposeGroup,
|
||||
isMobile,
|
||||
className,
|
||||
}: ContactGroupDetailProps) {
|
||||
const t = useTranslations("contacts");
|
||||
const groupName = getContactDisplayName(group);
|
||||
const hasEmailMembers = members.some((m) => getContactPrimaryEmail(m).trim());
|
||||
|
||||
return (
|
||||
<div className={cn("flex flex-col h-full overflow-y-auto", className)}>
|
||||
@@ -62,6 +66,25 @@ export function ContactGroupDetail({
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
{onComposeGroup && hasEmailMembers && (
|
||||
<div className="flex flex-wrap items-center gap-2 mt-4">
|
||||
<Mail className="w-4 h-4 text-muted-foreground" aria-hidden />
|
||||
<span className="text-sm text-muted-foreground">{t("groups.send_email")}</span>
|
||||
<div className="inline-flex gap-1">
|
||||
{(["to", "cc", "bcc"] as const).map((field) => (
|
||||
<Button
|
||||
key={field}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => onComposeGroup(field)}
|
||||
className="touch-manipulation"
|
||||
>
|
||||
{t(`groups.send_email_${field}`)}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="px-6 py-4">
|
||||
|
||||
@@ -2,10 +2,10 @@
|
||||
|
||||
import { useMemo, useState, useCallback, useEffect, useRef, type DragEvent } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { BookUser, User, Users, Plus, Share2, Book, ChevronRight, ChevronDown, UserPlus, UsersRound, Upload, Tag, Pencil, Trash2, Settings } from "lucide-react";
|
||||
import { BookUser, User, Users, Plus, Share2, Book, BookPlus, ChevronRight, ChevronDown, UserPlus, UsersRound, Upload, Tag, Pencil, Trash2, Settings, Mail } from "lucide-react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ContextMenu, ContextMenuItem, ContextMenuSeparator } from "@/components/ui/context-menu";
|
||||
import { ContextMenu, ContextMenuItem, ContextMenuSeparator, ContextMenuSubMenu } from "@/components/ui/context-menu";
|
||||
import { useContextMenu } from "@/hooks/use-context-menu";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { ContactCard, AddressBook } from "@/lib/jmap/types";
|
||||
@@ -22,9 +22,11 @@ interface ContactsSidebarProps {
|
||||
onSelectCategory: (category: ContactCategory) => void;
|
||||
onCreateGroup: () => void;
|
||||
onCreateContact: () => void;
|
||||
onCreateAddressBook?: () => void;
|
||||
onImport?: () => void;
|
||||
onEditGroup?: (groupId: string) => void;
|
||||
onDeleteGroup?: (groupId: string) => void;
|
||||
onComposeGroup?: (groupId: string, field: "to" | "cc" | "bcc") => void;
|
||||
onDropContacts?: (contactIds: string[], addressBook: AddressBook) => void;
|
||||
onDropContactsToCategory?: (contactIds: string[], keyword: string) => void;
|
||||
onRenameAddressBook?: (addressBook: AddressBook) => void;
|
||||
@@ -90,9 +92,11 @@ export function ContactsSidebar({
|
||||
onSelectCategory,
|
||||
onCreateGroup,
|
||||
onCreateContact,
|
||||
onCreateAddressBook,
|
||||
onImport,
|
||||
onEditGroup,
|
||||
onDeleteGroup,
|
||||
onComposeGroup,
|
||||
onDropContacts,
|
||||
onDropContactsToCategory,
|
||||
onRenameAddressBook,
|
||||
@@ -296,6 +300,15 @@ export function ContactsSidebar({
|
||||
<UsersRound className="w-4 h-4" />
|
||||
{t("groups.create")}
|
||||
</button>
|
||||
{onCreateAddressBook && (
|
||||
<button
|
||||
className="w-full flex items-center gap-2 px-3 py-1.5 text-sm hover:bg-accent transition-colors text-left"
|
||||
onClick={() => { setShowMenu(false); onCreateAddressBook(); }}
|
||||
>
|
||||
<BookPlus className="w-4 h-4" />
|
||||
{t("address_books.create")}
|
||||
</button>
|
||||
)}
|
||||
{onImport && (
|
||||
<button
|
||||
className="w-full flex items-center gap-2 px-3 py-1.5 text-sm hover:bg-accent transition-colors text-left"
|
||||
@@ -684,6 +697,21 @@ export function ContactsSidebar({
|
||||
onEditGroup?.(groupContextMenu.data!.id);
|
||||
}}
|
||||
/>
|
||||
{onComposeGroup && (
|
||||
<ContextMenuSubMenu icon={Mail} label={t("groups.send_email")}>
|
||||
{(["to", "cc", "bcc"] as const).map((field) => (
|
||||
<ContextMenuItem
|
||||
key={field}
|
||||
label={t(`groups.send_email_${field}`)}
|
||||
onClick={() => {
|
||||
const groupId = groupContextMenu.data!.id;
|
||||
closeGroupContextMenu();
|
||||
onComposeGroup(groupId, field);
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</ContextMenuSubMenu>
|
||||
)}
|
||||
<ContextMenuSeparator />
|
||||
<ContextMenuItem
|
||||
icon={Trash2}
|
||||
|
||||
@@ -68,10 +68,12 @@ describe('EmailListItem tag badge', () => {
|
||||
expect(screen.getByText('Blue')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('does not show badge when keyword id not in settings', () => {
|
||||
it('shows a gray fallback badge when keyword id is not in settings', () => {
|
||||
const email = makeEmail({ keywords: { $seen: true, '$label:unknown-tag': true } });
|
||||
render(<EmailListItem email={email} />);
|
||||
expect(screen.queryByText('unknown-tag')).not.toBeInTheDocument();
|
||||
// Unknown tags fall back to the raw id as label with a gray dot
|
||||
// (see email-list-item.tsx: keywordDefs fallback).
|
||||
expect(screen.getByText('unknown-tag')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows custom keyword label', () => {
|
||||
|
||||
@@ -67,21 +67,6 @@ vi.mock('@/stores/account-store', () => {
|
||||
return { useAccountStore: hook };
|
||||
});
|
||||
|
||||
vi.mock('@/stores/smime-store', () => {
|
||||
const state = {
|
||||
certs: [],
|
||||
signingEnabled: false,
|
||||
encryptionEnabled: false,
|
||||
defaultSigningCertId: null,
|
||||
defaultEncryptionCertId: null,
|
||||
};
|
||||
const hook = (sel?: (s: typeof state) => unknown) =>
|
||||
typeof sel === 'function' ? sel(state) : state;
|
||||
hook.getState = () => state;
|
||||
hook.setState = (p: Partial<typeof state>) => Object.assign(state, p);
|
||||
return { useSmimeStore: hook };
|
||||
});
|
||||
|
||||
vi.mock('@/stores/email-store', () => {
|
||||
const state = {
|
||||
draftSaveEnabled: false,
|
||||
@@ -160,6 +145,7 @@ vi.mock('@/lib/plugin-hooks', () => ({
|
||||
vi.mock('@/lib/email-sanitization', () => ({
|
||||
sanitizeSignatureHtml: (v: string) => v,
|
||||
sanitizeEmailHtml: (v: string) => v,
|
||||
parseHtmlSafely: (html: string) => new DOMParser().parseFromString(html, 'text/html'),
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/reply-identity', () => ({ resolveReplyFrom: () => null }));
|
||||
@@ -171,12 +157,6 @@ vi.mock('@/lib/signature-utils', () => ({
|
||||
getPlainTextSignature: () => '',
|
||||
}));
|
||||
vi.mock('@/lib/sub-addressing', () => ({ generateSubAddress: () => '' }));
|
||||
vi.mock('@/lib/smime/smime-sign', () => ({ smimeSign: async () => null }));
|
||||
vi.mock('@/lib/smime/smime-encrypt', () => ({ smimeEncrypt: async () => null }));
|
||||
vi.mock('@/lib/smime/mime-builder', () => ({
|
||||
buildMimeMessage: () => null,
|
||||
wrapCmsAsSmimeMessage: () => null,
|
||||
}));
|
||||
vi.mock('@/lib/debug', () => ({ debug: () => {} }));
|
||||
vi.mock('@/components/email/quoted-html', () => ({
|
||||
buildQuotedHtmlBlock: () => '',
|
||||
|
||||
@@ -0,0 +1,296 @@
|
||||
import { render, screen } 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 (mirrors recipient-chip-drag.test.tsx) ──────────────
|
||||
|
||||
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('@/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/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,
|
||||
parseHtmlSafely: (html: string) => new DOMParser().parseFromString(html, 'text/html'),
|
||||
}));
|
||||
|
||||
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/debug', () => ({ debug: () => {} }));
|
||||
vi.mock('@/components/email/quoted-html', () => ({
|
||||
buildQuotedHtmlBlock: () => '',
|
||||
serializeEditorContent: () => '',
|
||||
}));
|
||||
vi.mock('@/lib/template-utils', () => ({ substitutePlaceholders: (s: string) => s }));
|
||||
|
||||
// ─── Shared test data ─────────────────────────────────────────────────────────
|
||||
|
||||
const EMPTY_DATA = {
|
||||
to: '',
|
||||
cc: '',
|
||||
bcc: '',
|
||||
subject: '',
|
||||
body: '',
|
||||
showCc: true,
|
||||
showBcc: true,
|
||||
selectedIdentityId: null,
|
||||
subAddressTag: '',
|
||||
mode: 'compose' as const,
|
||||
draftId: null,
|
||||
};
|
||||
|
||||
/** next-intl is mocked to return the key, so the To placeholder is "to_placeholder". */
|
||||
const toInput = () => screen.getByPlaceholderText('to_placeholder') as HTMLInputElement;
|
||||
const ccInput = () => screen.getByPlaceholderText('cc_placeholder') as HTMLInputElement;
|
||||
|
||||
const paste = (input: HTMLElement, text: string) =>
|
||||
fireEvent.paste(input, { clipboardData: { getData: () => text } });
|
||||
|
||||
// ─── Tests ────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('RecipientChipInput paste', () => {
|
||||
beforeEach(() => { vi.clearAllMocks(); });
|
||||
|
||||
it('splits a pasted list (comma / semicolon / whitespace) into one chip per address', () => {
|
||||
render(<EmailComposer initialData={EMPTY_DATA} />);
|
||||
const input = toInput(); // capture once — placeholder disappears once chips exist
|
||||
paste(input, 'a@x.com b@y.com; c@z.com');
|
||||
|
||||
expect(screen.getByText('a@x.com')).toBeInTheDocument();
|
||||
expect(screen.getByText('b@y.com')).toBeInTheDocument();
|
||||
expect(screen.getByText('c@z.com')).toBeInTheDocument();
|
||||
// all consumed → input cleared
|
||||
expect(input.value).toBe('');
|
||||
});
|
||||
|
||||
it('chips the valid addresses and leaves invalid text in the input', () => {
|
||||
render(<EmailComposer initialData={EMPTY_DATA} />);
|
||||
const input = toInput();
|
||||
paste(input, 'foo bar a@x.com');
|
||||
|
||||
expect(screen.getByText('a@x.com')).toBeInTheDocument();
|
||||
expect(input.value).toBe('foo bar');
|
||||
});
|
||||
|
||||
it('does not pre-empt a single-address paste (no delimiter → normal editing)', () => {
|
||||
render(<EmailComposer initialData={EMPTY_DATA} />);
|
||||
paste(toInput(), 'single@x.com');
|
||||
|
||||
// The handler bailed out, so no chip was created from the single token.
|
||||
expect(screen.queryByText('single@x.com')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('keeps display names from a fully-quoted "Name <email>" list', () => {
|
||||
render(<EmailComposer initialData={EMPTY_DATA} />);
|
||||
const input = toInput();
|
||||
paste(input, '"Alice Smith <alice@x.com>", "Alex Smith <alex@x.com>"');
|
||||
|
||||
// Chips render as "Name (email)" when a display name is present.
|
||||
expect(screen.getByText('Alice Smith (alice@x.com)')).toBeInTheDocument();
|
||||
expect(screen.getByText('Alex Smith (alex@x.com)')).toBeInTheDocument();
|
||||
expect(input.value).toBe('');
|
||||
});
|
||||
|
||||
it('keeps a `Name <email>` pair as a single named chip', () => {
|
||||
render(<EmailComposer initialData={EMPTY_DATA} />);
|
||||
const input = toInput();
|
||||
paste(input, 'John Doe <j@x.com>, jane@y.com');
|
||||
|
||||
expect(screen.getByText('John Doe (j@x.com)')).toBeInTheDocument();
|
||||
expect(screen.getByText('jane@y.com')).toBeInTheDocument();
|
||||
expect(input.value).toBe('');
|
||||
});
|
||||
|
||||
it('keeps a comma inside a quoted display name intact', () => {
|
||||
render(<EmailComposer initialData={EMPTY_DATA} />);
|
||||
const input = toInput();
|
||||
paste(input, '"Doe, John" <j@x.com>; bob@z.com');
|
||||
|
||||
expect(screen.getByText('Doe, John (j@x.com)')).toBeInTheDocument();
|
||||
expect(screen.getByText('bob@z.com')).toBeInTheDocument();
|
||||
expect(input.value).toBe('');
|
||||
});
|
||||
|
||||
it('splits a newline-separated block (e.g. a spreadsheet column)', () => {
|
||||
render(<EmailComposer initialData={EMPTY_DATA} />);
|
||||
const input = toInput();
|
||||
paste(input, 'a@x.com\nb@y.com\nc@z.com');
|
||||
|
||||
expect(screen.getByText('a@x.com')).toBeInTheDocument();
|
||||
expect(screen.getByText('b@y.com')).toBeInTheDocument();
|
||||
expect(screen.getByText('c@z.com')).toBeInTheDocument();
|
||||
expect(input.value).toBe('');
|
||||
});
|
||||
|
||||
it('dedupes case-insensitively within the paste and against existing chips', () => {
|
||||
render(<EmailComposer initialData={EMPTY_DATA} />);
|
||||
const input = toInput(); // DOM node persists across pastes (placeholder just clears)
|
||||
paste(input, 'a@x.com, A@X.com, b@y.com'); // within-paste dup collapses
|
||||
paste(input, 'A@X.COM, c@z.com'); // dup of an existing chip is dropped
|
||||
|
||||
expect(screen.getByText('b@y.com')).toBeInTheDocument();
|
||||
expect(screen.getByText('c@z.com')).toBeInTheDocument();
|
||||
// a@x.com appears exactly once despite three case variants across two pastes.
|
||||
expect(screen.getAllByText('a@x.com')).toHaveLength(1);
|
||||
expect(screen.queryByText('A@X.COM')).not.toBeInTheDocument();
|
||||
expect(input.value).toBe('');
|
||||
});
|
||||
|
||||
it('chips the valid (named) entries and leaves a non-address token behind', () => {
|
||||
render(<EmailComposer initialData={EMPTY_DATA} />);
|
||||
const input = toInput();
|
||||
paste(input, '"VIP" <vip@x.com>, not-an-email, b@y.com');
|
||||
|
||||
expect(screen.getByText('VIP (vip@x.com)')).toBeInTheDocument();
|
||||
expect(screen.getByText('b@y.com')).toBeInTheDocument();
|
||||
expect(input.value).toBe('not-an-email');
|
||||
});
|
||||
|
||||
it('works on the Cc field (shared handler)', () => {
|
||||
render(<EmailComposer initialData={EMPTY_DATA} />);
|
||||
paste(ccInput(), 'x@a.com; y@b.com');
|
||||
|
||||
expect(screen.getByText('x@a.com')).toBeInTheDocument();
|
||||
expect(screen.getByText('y@b.com')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,38 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { Editor } from '@tiptap/core';
|
||||
import StarterKit from '@tiptap/starter-kit';
|
||||
|
||||
import { SignatureBlock, buildSignatureBlock, SIGNATURE_BLOCK_MARKER } from '../signature-block';
|
||||
import { serializeEditorContent } from '../quoted-html';
|
||||
|
||||
describe('signature-block', () => {
|
||||
it('buildSignatureBlock wraps html in the marker div', () => {
|
||||
expect(buildSignatureBlock('<b>x</b>')).toBe(`<div ${SIGNATURE_BLOCK_MARKER}><b>x</b></div>`);
|
||||
});
|
||||
|
||||
it('preserves an inline-styled signature through parse + serialize (no schema flattening)', () => {
|
||||
const styled =
|
||||
'<table style="background:#0a0e16;border-radius:8px"><tbody><tr>' +
|
||||
'<td style="color:#c6f24e;font-family:\'Courier New\'">MV</td>' +
|
||||
'</tr></tbody></table>';
|
||||
const editor = new Editor({
|
||||
element: document.createElement('div'),
|
||||
extensions: [StarterKit, SignatureBlock],
|
||||
content: `<p>Hello</p>${buildSignatureBlock(styled)}`,
|
||||
});
|
||||
try {
|
||||
const out = serializeEditorContent(editor);
|
||||
// Original inline styling survives - it is NOT re-parsed into the schema.
|
||||
expect(out).toContain('background:#0a0e16');
|
||||
expect(out).toContain('border-radius:8px');
|
||||
expect(out).toContain('color:#c6f24e');
|
||||
expect(out).toContain(SIGNATURE_BLOCK_MARKER);
|
||||
// Surrounding body is preserved.
|
||||
expect(out).toContain('Hello');
|
||||
// The signature did not get the editor's generic table styling.
|
||||
expect(out).not.toContain('rgb(204, 204, 204)');
|
||||
} finally {
|
||||
editor.destroy();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -5,32 +5,27 @@ import { useFocusTrap } from "@/hooks/use-focus-trap";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { X, Paperclip, Send, Save, Check, Loader2, AlertCircle, FileText, BookmarkPlus, ShieldCheck, Lock, CalendarClock, ChevronDown, MailCheck } from "lucide-react";
|
||||
import { X, Paperclip, Send, Save, Check, Loader2, AlertCircle, FileText, BookmarkPlus, CalendarClock, ChevronDown, MailCheck } from "lucide-react";
|
||||
import { cn, formatFileSize, formatDateTime, generateUUID } from "@/lib/utils";
|
||||
import { debug } from "@/lib/debug";
|
||||
import { toast } from "@/stores/toast-store";
|
||||
import { useContextMenu } from "@/hooks/use-context-menu";
|
||||
import { ContextMenu, ContextMenuItem, ContextMenuSeparator } from "@/components/ui/context-menu";
|
||||
import { sanitizeSignatureHtml, sanitizeEmailHtml } from "@/lib/email-sanitization";
|
||||
import { sanitizeSignatureHtml, sanitizeEmailHtml, escapeHtml } from "@/lib/email-sanitization";
|
||||
import { buildReplySubject, buildForwardSubject } from "@/lib/subject-prefix";
|
||||
import { isFilePreviewable } from "@/lib/file-preview";
|
||||
import { buildQuotedHtmlBlock, serializeEditorContent } from "@/components/email/quoted-html";
|
||||
import { buildSignatureBlock } from "@/components/email/signature-block";
|
||||
import { emailHooks, contactHooks } from "@/lib/plugin-hooks";
|
||||
import type { OutgoingEmail, RecipientSuggestion } from "@/lib/plugin-types";
|
||||
import { useAuthStore } from "@/stores/auth-store";
|
||||
import { useIdentityStore } from "@/stores/identity-store";
|
||||
import { useProMultiAccountIdentities, stripCrossAccountIdentityPrefix } from "@/hooks/use-pro-multi-account-identities";
|
||||
import { useAccountStore } from "@/stores/account-store";
|
||||
import { useSmimeStore } from "@/stores/smime-store";
|
||||
import { useEmailStore } from "@/stores/email-store";
|
||||
import { useSettingsStore } from "@/stores/settings-store";
|
||||
import { buildMimeMessage, wrapCmsAsSmimeMessage } from "@/lib/smime/mime-builder";
|
||||
import type { MimeAttachment } from "@/lib/smime/mime-builder";
|
||||
import { smimeSign } from "@/lib/smime/smime-sign";
|
||||
import { PluginSlot } from "@/components/plugins/plugin-slot";
|
||||
import { Avatar } from "@/components/ui/avatar";
|
||||
import { FilePreviewModal } from "@/components/files/file-preview-modal";
|
||||
import { smimeEncrypt } from "@/lib/smime/smime-encrypt";
|
||||
import { useContactStore } from "@/stores/contact-store";
|
||||
import { useTemplateStore } from "@/stores/template-store";
|
||||
import { SubAddressHelper } from "@/components/identity/sub-address-helper";
|
||||
@@ -49,6 +44,7 @@ import {
|
||||
parseRecipient,
|
||||
parseRecipientList,
|
||||
formatRecipientList,
|
||||
splitPastedRecipients,
|
||||
type Recipient,
|
||||
} from "@/lib/email-composer-utils";
|
||||
import { RichTextEditor } from "@/components/email/rich-text-editor";
|
||||
@@ -187,11 +183,13 @@ type SignatureIdentityLike = {
|
||||
textSignature?: string;
|
||||
} | null | undefined;
|
||||
|
||||
// Render the embedded signature for "above quote" mode. Bracketed with
|
||||
// `data-signature-block` marker paragraphs so we can swap the inner content
|
||||
// when the user switches identity without losing the surrounding draft or
|
||||
// quoted message. The markers are preserved through TipTap by the
|
||||
// StyledParagraph extension.
|
||||
// Render the embedded signature. Bracketed with `data-signature-block` marker
|
||||
// paragraphs so we can swap the inner content when the user switches identity
|
||||
// without losing the surrounding draft or quoted message. The markers are
|
||||
// preserved through TipTap by the StyledParagraph extension. The HTML
|
||||
// signature itself is wrapped in a SignatureBlock atom node so its inline
|
||||
// styling survives the editor (see signature-block.ts) instead of being
|
||||
// flattened by the schema.
|
||||
function buildEmbeddedSignatureHtml(
|
||||
identity: SignatureIdentityLike,
|
||||
options: { embed: boolean; separator: boolean }
|
||||
@@ -202,7 +200,7 @@ function buildEmbeddedSignatureHtml(
|
||||
: `<p data-signature-block="start"></p>`;
|
||||
const endMarker = `<p data-signature-block="end"></p>`;
|
||||
if (identity?.htmlSignature) {
|
||||
return `${startMarker}${sanitizeSignatureHtml(identity.htmlSignature)}${endMarker}`;
|
||||
return `${startMarker}${buildSignatureBlock(sanitizeSignatureHtml(identity.htmlSignature))}${endMarker}`;
|
||||
}
|
||||
if (identity?.textSignature) {
|
||||
const escaped = identity.textSignature
|
||||
@@ -343,9 +341,8 @@ export function EmailComposer({
|
||||
|
||||
const date = replyTo.receivedAt ? formatDateTime(replyTo.receivedAt, timeFormat, { weekday: 'short', year: 'numeric', month: 'short', day: 'numeric' }) : "";
|
||||
const from = replyTo.from?.[0];
|
||||
const fromStr = from ? `${from.name || from.email}` : tCommon('unknown');
|
||||
// Forward "From:" shows the full sender incl. address; reply line keeps
|
||||
// the bare name (reads naturally in the localized "On … wrote:" line).
|
||||
// Forward "From:" and the reply "On … wrote:" line both show the full
|
||||
// sender incl. address ("Name <email>"), like Gmail/Outlook (#482).
|
||||
const fromStrFull = from
|
||||
? (from.name && from.email && from.name !== from.email
|
||||
? `${from.name} <${from.email}>`
|
||||
@@ -373,7 +370,7 @@ export function EmailComposer({
|
||||
if (mode === 'forward') {
|
||||
return `${prefix}${signatureBlock}\n\n${tQuote('forwarded_separator')}\n${tQuote('from_label')}: ${fromStrFull}\n${tQuote('date_label')}: ${date}\n${tQuote('subject_label')}: ${replyTo.subject || ''}\n\n${originalText}`;
|
||||
} else if (mode === 'reply' || mode === 'replyAll') {
|
||||
return `${prefix}${signatureBlock}\n\n${tQuote('reply_line', { date, from: fromStr })}\n${quotedText}`;
|
||||
return `${prefix}${signatureBlock}\n\n${tQuote('reply_line', { date, from: fromStrFull })}\n${quotedText}`;
|
||||
}
|
||||
return prefix;
|
||||
}
|
||||
@@ -395,7 +392,7 @@ export function EmailComposer({
|
||||
|
||||
const date = replyTo.receivedAt ? formatDateTime(replyTo.receivedAt, timeFormat, { weekday: 'short', year: 'numeric', month: 'short', day: 'numeric' }) : "";
|
||||
const from = replyTo.from?.[0];
|
||||
const fromStr = from ? `${from.name || from.email}` : tCommon('unknown');
|
||||
// Forward and reply quote lines both show the full "Name <email>" sender (#482).
|
||||
const fromStrFull = from
|
||||
? (from.name && from.email && from.name !== from.email
|
||||
? `${from.name} <${from.email}>`
|
||||
@@ -430,9 +427,11 @@ export function EmailComposer({
|
||||
|
||||
// Build quoted content as HTML
|
||||
if (replyTo.htmlBody && (mode === 'reply' || mode === 'replyAll' || mode === 'forward')) {
|
||||
// HTML-escape user-controlled values: an unescaped sender "Name <email>"
|
||||
// has its "<email>" eaten as a bogus HTML tag by the rich-text editor (#482).
|
||||
const quoteHeader = mode === 'forward'
|
||||
? `${tQuote('forwarded_separator')}<br>${tQuote('from_label')}: ${fromStrFull}<br>${tQuote('date_label')}: ${date}<br>${tQuote('subject_label')}: ${replyTo.subject || ''}<br><br>`
|
||||
: `${tQuote('reply_line', { date, from: fromStr })}<br>`;
|
||||
? `${tQuote('forwarded_separator')}<br>${tQuote('from_label')}: ${escapeHtml(fromStrFull)}<br>${tQuote('date_label')}: ${escapeHtml(date)}<br>${tQuote('subject_label')}: ${escapeHtml(replyTo.subject || '')}<br><br>`
|
||||
: `${tQuote('reply_line', { date: escapeHtml(date), from: escapeHtml(fromStrFull) })}<br>`;
|
||||
// Embed the original as a QuotedHtml island (verbatim, schema-free) so
|
||||
// its layout survives the editor round-trip. Sanitize first to strip
|
||||
// scripts/styles/head; cid rewrite afterwards so data-cid markers
|
||||
@@ -446,9 +445,9 @@ export function EmailComposer({
|
||||
if (replyTo.body) {
|
||||
const escapedOriginal = replyTo.body.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/\n/g, '<br>');
|
||||
if (mode === 'forward') {
|
||||
return `${prefix}${signatureBlock}<br><br>${tQuote('forwarded_separator')}<br>${tQuote('from_label')}: ${fromStrFull}<br>${tQuote('date_label')}: ${date}<br>${tQuote('subject_label')}: ${replyTo.subject || ''}<br><br>${escapedOriginal}`;
|
||||
return `${prefix}${signatureBlock}<br><br>${tQuote('forwarded_separator')}<br>${tQuote('from_label')}: ${escapeHtml(fromStrFull)}<br>${tQuote('date_label')}: ${escapeHtml(date)}<br>${tQuote('subject_label')}: ${escapeHtml(replyTo.subject || '')}<br><br>${escapedOriginal}`;
|
||||
} else if (mode === 'reply' || mode === 'replyAll') {
|
||||
return `${prefix}${signatureBlock}<br><br>${tQuote('reply_line', { date, from: fromStr })}<br><blockquote style="margin:0 0 0 0.8ex;border-left:2px solid #ccc;padding-left:1ex">${escapedOriginal}</blockquote>`;
|
||||
return `${prefix}${signatureBlock}<br><br>${tQuote('reply_line', { date: escapeHtml(date), from: escapeHtml(fromStrFull) })}<br><blockquote style="margin:0 0 0 0.8ex;border-left:2px solid #ccc;padding-left:1ex">${escapedOriginal}</blockquote>`;
|
||||
}
|
||||
}
|
||||
return prefix;
|
||||
@@ -517,11 +516,6 @@ export function EmailComposer({
|
||||
const [showCloseDialog, setShowCloseDialog] = useState(false);
|
||||
const [showAllAttachments, setShowAllAttachments] = useState(false);
|
||||
const [previewAttachment, setPreviewAttachment] = useState<ComposerAttachment | null>(null);
|
||||
const [smimeSign_, setSmimeSign] = useState(false);
|
||||
const [smimeEncrypt_, setSmimeEncrypt] = useState(false);
|
||||
const [smimePassphrasePrompt, setSmimePassphrasePrompt] = useState<{ keyId: string; resolve: (passphrase: string) => void; reject: () => void } | null>(null);
|
||||
const [smimePassphraseInput, setSmimePassphraseInput] = useState('');
|
||||
const [smimePassphraseError, setSmimePassphraseError] = useState('');
|
||||
const [showAttachmentWarning, setShowAttachmentWarning] = useState(false);
|
||||
const [attachmentWarningKeyword, setAttachmentWarningKeyword] = useState('');
|
||||
const [attachmentWarningDelayedUntil, setAttachmentWarningDelayedUntil] = useState<string | undefined>();
|
||||
@@ -646,6 +640,11 @@ export function EmailComposer({
|
||||
if (nextHtml !== currentHtml) {
|
||||
editor.commands.setContent(nextHtml, { emitUpdate: true });
|
||||
}
|
||||
// Intentionally keyed to the signature-relevant fields plus the internal
|
||||
// prev*Ref guards above; depending on the whole `signatureIdentity` object
|
||||
// would re-run on unrelated identity-field changes and re-splice the
|
||||
// signature into the live editor.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [signatureIdentity?.id, signatureIdentity?.htmlSignature, signatureIdentity?.textSignature, signatureSeparatorEnabled, signaturePosition, mode, plainTextMode]);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -792,34 +791,9 @@ export function EmailComposer({
|
||||
const addTrustedSender = useSettingsStore((s) => s.addTrustedSender);
|
||||
const trustedSendersAddressBook = useSettingsStore((s) => s.trustedSendersAddressBook);
|
||||
const addTemplate = useTemplateStore((s) => s.addTemplate);
|
||||
const sendRawEmail = useEmailStore((s) => s.sendRawEmail);
|
||||
const smimeStore = useSmimeStore();
|
||||
|
||||
// Determine S/MIME availability for the selected identity
|
||||
const currentSmimeIdentityId = selectedIdentityId || primaryIdentity?.id;
|
||||
const smimeKeyRecord = currentSmimeIdentityId ? smimeStore.getKeyRecordForIdentity(currentSmimeIdentityId) : undefined;
|
||||
const canSmimeSign = !!smimeKeyRecord;
|
||||
const canSmimeEncrypt = (() => {
|
||||
if (!smimeKeyRecord) return false;
|
||||
const allRecipients = [
|
||||
...withInput(to, toInput),
|
||||
...withInput(cc, ccInput),
|
||||
...withInput(bcc, bccInput),
|
||||
].map(r => r.email);
|
||||
if (allRecipients.length === 0) return false;
|
||||
const { missing } = smimeStore.getRecipientCerts(allRecipients);
|
||||
return missing.length === 0;
|
||||
})();
|
||||
|
||||
// Initialize S/MIME defaults from store when identity changes
|
||||
useEffect(() => {
|
||||
if (currentSmimeIdentityId) {
|
||||
setSmimeSign(!!smimeStore.defaultSignIdentity[currentSmimeIdentityId] && canSmimeSign);
|
||||
}
|
||||
setSmimeEncrypt(smimeStore.defaultEncrypt && canSmimeEncrypt);
|
||||
// Only run when identity changes, not on every recipient edit
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [currentSmimeIdentityId]);
|
||||
// Sign/encrypt is provided by crypto plugins (S/MIME, PGP) via the
|
||||
// composer-toolbar slot + the onComposeSend hook — the host stays
|
||||
// crypto-agnostic.
|
||||
|
||||
// Serialized recipient strings for ComposerDraftData (string-shaped) and for
|
||||
// by-value dirty comparison. Folds in any uncommitted typed text.
|
||||
@@ -1484,7 +1458,16 @@ export function EmailComposer({
|
||||
};
|
||||
};
|
||||
|
||||
// Guard against double-submit. Rapid Send clicks (or a click racing the
|
||||
// keyboard shortcut) used to invoke handleSend once per click before the
|
||||
// first submission resolved, sending the message multiple times. The ref is
|
||||
// a synchronous re-entry guard - state updates are async and wouldn't block a
|
||||
// second click in the same tick - and isSending drives button disabling.
|
||||
const [isSending, setIsSending] = useState(false);
|
||||
const isSendingRef = useRef(false);
|
||||
|
||||
const handleSend = async (skipAttachmentCheck = false, delayedUntil?: string) => {
|
||||
if (isSendingRef.current) return;
|
||||
const ccAddresses = withInput(cc, ccInput);
|
||||
const bccAddresses = withInput(bcc, bccInput);
|
||||
|
||||
@@ -1519,6 +1502,11 @@ export function EmailComposer({
|
||||
}
|
||||
}
|
||||
|
||||
// Past every "don't send" early return - mark the send in flight so a
|
||||
// second click is a no-op until this resolves (reset in the finally below).
|
||||
isSendingRef.current = true;
|
||||
setIsSending(true);
|
||||
|
||||
// Resolve the freshest draftId we can. Two cases:
|
||||
// 1. An autosave is currently in flight - wait for it; don't issue a
|
||||
// parallel destroy/create that would race with it on the same id.
|
||||
@@ -1624,145 +1612,39 @@ export function EmailComposer({
|
||||
const sendAllowed = await emailHooks.onBeforeEmailSend.intercept(sendablePreview);
|
||||
if (!sendAllowed) return;
|
||||
|
||||
// S/MIME send pipeline: build raw MIME → sign → encrypt → sendRawEmail
|
||||
if ((smimeSign_ || smimeEncrypt_) && client && currentIdentity?.id) {
|
||||
// S/MIME keys are scoped to one JMAP account's identity - sending
|
||||
// from a cross-account identity via S/MIME would mix accounts'
|
||||
// certs/clients. Refuse upfront and tell the user to switch.
|
||||
const crossAccount = stripCrossAccountIdentityPrefix(currentIdentity.id);
|
||||
if (crossAccount.localAccountId) {
|
||||
throw new Error('S/MIME sending from another account’s identity is not supported. Switch to that account first.');
|
||||
}
|
||||
// 1. Resolve S/MIME key
|
||||
if (smimeSign_ && !smimeKeyRecord) {
|
||||
throw new Error('No S/MIME key bound to this identity');
|
||||
}
|
||||
// S/MIME binds to the identity's key; sending from an override address
|
||||
// would produce a signature whose Subject differs from the visible
|
||||
// From, which most clients reject or flag. Refuse up front.
|
||||
if (overrideActive) {
|
||||
throw new Error('Cannot use From override with S/MIME - disable one to send.');
|
||||
}
|
||||
|
||||
// 2. Ensure key is unlocked for signing
|
||||
if (smimeSign_ && smimeKeyRecord && !smimeStore.isKeyUnlocked(smimeKeyRecord.id)) {
|
||||
const passphrase = await new Promise<string>((resolve, reject) => {
|
||||
setSmimePassphrasePrompt({ keyId: smimeKeyRecord.id, resolve, reject });
|
||||
});
|
||||
try {
|
||||
await smimeStore.unlockKey(smimeKeyRecord.id, passphrase);
|
||||
} finally {
|
||||
setSmimePassphrasePrompt(null);
|
||||
setSmimePassphraseInput('');
|
||||
setSmimePassphraseError('');
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Resolve attachments as ArrayBuffers
|
||||
const mimeAttachments: MimeAttachment[] = [];
|
||||
for (const att of attachments) {
|
||||
if (att.error || att.uploading) continue;
|
||||
let content: ArrayBuffer;
|
||||
if (att.file && att.file.size > 0) {
|
||||
content = await att.file.arrayBuffer();
|
||||
} else if (att.blobId && client) {
|
||||
content = await client.fetchBlobArrayBuffer(att.blobId, att.name, att.type);
|
||||
} else {
|
||||
continue;
|
||||
}
|
||||
mimeAttachments.push({
|
||||
filename: att.name,
|
||||
contentType: att.type || 'application/octet-stream',
|
||||
content,
|
||||
});
|
||||
}
|
||||
for (const inline of inlineAttachments) {
|
||||
if (!client) break;
|
||||
const content = await client.fetchBlobArrayBuffer(inline.blobId, inline.name, inline.type);
|
||||
mimeAttachments.push({
|
||||
filename: inline.name,
|
||||
contentType: inline.type,
|
||||
content,
|
||||
cid: inline.cid,
|
||||
});
|
||||
}
|
||||
|
||||
// 4. Build canonical MIME
|
||||
// mime-builder takes inReplyTo as a single ref-form msg-id (with brackets);
|
||||
// references stays an array. threadingHeaders contains bare msg-ids.
|
||||
const mimeInReplyTo = threadingHeaders?.inReplyTo[0]
|
||||
? `<${threadingHeaders.inReplyTo[0]}>`
|
||||
: undefined;
|
||||
const mimeReferences = threadingHeaders?.references.length
|
||||
? threadingHeaders.references.map(id => `<${id}>`)
|
||||
: undefined;
|
||||
const mimeBytes = buildMimeMessage({
|
||||
from: { name: currentIdentity.name || undefined, email: fromEmail || currentIdentity.email },
|
||||
to: toAddresses,
|
||||
cc: ccAddresses.length > 0 ? ccAddresses : undefined,
|
||||
bcc: bccAddresses.length > 0 ? bccAddresses : undefined,
|
||||
// Hand off to a crypto plugin (S/MIME, PGP, …) if one wants to take over
|
||||
// the send: it builds raw MIME, signs/encrypts, and submits via
|
||||
// api.jmap.sendRaw. A handler returning false means "I sent it" — the
|
||||
// host then skips its own plaintext submission but still cleans up the
|
||||
// draft (and fires the scheduled-send callback for a delayed send).
|
||||
const composeSendRequest = {
|
||||
to: toAddresses.map(r => formatRecipient(r.name, r.email)),
|
||||
cc: ccAddresses.map(r => formatRecipient(r.name, r.email)),
|
||||
bcc: bccAddresses.map(r => formatRecipient(r.name, r.email)),
|
||||
subject,
|
||||
inReplyTo: mimeInReplyTo,
|
||||
references: mimeReferences,
|
||||
htmlBody: finalHtmlBody || '',
|
||||
textBody: finalBody,
|
||||
htmlBody: finalHtmlBody,
|
||||
attachments: mimeAttachments.length > 0 ? mimeAttachments : undefined,
|
||||
});
|
||||
|
||||
let payload: Blob = new Blob([mimeBytes.buffer as ArrayBuffer], { type: 'message/rfc822' });
|
||||
|
||||
const smimeHeaders = {
|
||||
from: { name: currentIdentity.name || undefined, email: fromEmail || currentIdentity.email },
|
||||
to: toAddresses,
|
||||
cc: ccAddresses.length > 0 ? ccAddresses : undefined,
|
||||
subject,
|
||||
inReplyTo: mimeInReplyTo,
|
||||
references: mimeReferences,
|
||||
identityId: currentIdentity?.id || '',
|
||||
fromEmail,
|
||||
fromName,
|
||||
inReplyTo: threadingHeaders?.inReplyTo?.[0],
|
||||
references: threadingHeaders?.references,
|
||||
delayedUntil: effectiveDelayedUntil,
|
||||
attachments: [
|
||||
...attachments
|
||||
.filter(att => att.blobId && !att.uploading && !att.error)
|
||||
.map(a => ({ name: a.name, type: a.type || 'application/octet-stream', size: a.size, blobId: a.blobId })),
|
||||
...inlineAttachments.map(a => ({ name: a.name, type: a.type, size: a.size, blobId: a.blobId, cid: a.cid })),
|
||||
],
|
||||
};
|
||||
|
||||
// 5. Sign if enabled
|
||||
if (smimeSign_ && smimeKeyRecord) {
|
||||
const privateKey = smimeStore.getUnlockedKey(smimeKeyRecord.id);
|
||||
if (!privateKey) throw new Error('S/MIME key is not unlocked');
|
||||
const cmsBlob = await smimeSign(
|
||||
mimeBytes,
|
||||
privateKey,
|
||||
smimeKeyRecord.certificate,
|
||||
smimeKeyRecord.certificateChain || [],
|
||||
);
|
||||
const cmsBytes = new Uint8Array(await cmsBlob.arrayBuffer());
|
||||
payload = wrapCmsAsSmimeMessage(cmsBytes, { ...smimeHeaders, smimeType: 'signed-data' });
|
||||
}
|
||||
|
||||
// 6. Encrypt if enabled
|
||||
if (smimeEncrypt_ && smimeKeyRecord) {
|
||||
const allRecipients = [...toAddresses, ...ccAddresses, ...bccAddresses].map(r => r.email);
|
||||
const { found, missing } = smimeStore.getRecipientCerts(allRecipients);
|
||||
if (missing.length > 0) {
|
||||
throw new Error(`Missing certificates for: ${missing.join(', ')}`);
|
||||
}
|
||||
const recipientCertsDer = found.map(c => c.certificate instanceof ArrayBuffer ? c.certificate : new Uint8Array(c.certificate as ArrayBuffer).buffer);
|
||||
const payloadBytes = new Uint8Array(await payload.arrayBuffer());
|
||||
const cmsBlob = await smimeEncrypt(
|
||||
payloadBytes,
|
||||
recipientCertsDer,
|
||||
smimeKeyRecord.certificate,
|
||||
);
|
||||
const cmsBytes = new Uint8Array(await cmsBlob.arrayBuffer());
|
||||
payload = wrapCmsAsSmimeMessage(cmsBytes, { ...smimeHeaders, smimeType: 'enveloped-data' });
|
||||
}
|
||||
|
||||
// 7. Send via raw email path
|
||||
const result = await sendRawEmail(client, payload, currentIdentity.id, effectiveDelayedUntil, [...toAddresses, ...ccAddresses, ...bccAddresses].map(r => r.email));
|
||||
if (effectiveDelayedUntil && finalDraftId) {
|
||||
client.deleteEmail(finalDraftId).catch(err => {
|
||||
debug.warn('email', 'Scheduled S/MIME send created, but plaintext draft cleanup failed:', err);
|
||||
toast.warning(t('schedule_send_cleanup_warning'));
|
||||
const sendHandledByPlugin = (await emailHooks.onComposeSend.intercept(composeSendRequest)) === false;
|
||||
if (sendHandledByPlugin) {
|
||||
if (finalDraftId) {
|
||||
client?.deleteEmail(finalDraftId).catch((err) => {
|
||||
debug.warn('email', 'Plugin handled the send, but draft cleanup failed:', err);
|
||||
});
|
||||
}
|
||||
if (result.scheduled) {
|
||||
await onScheduledSendCreated?.();
|
||||
}
|
||||
if (effectiveDelayedUntil) await onScheduledSendCreated?.();
|
||||
} else {
|
||||
// Standard JMAP send path
|
||||
// Collect uploaded attachment blobIds for the send request
|
||||
@@ -1849,6 +1731,9 @@ export function EmailComposer({
|
||||
} catch (err) {
|
||||
debug.error('Failed to send email:', err);
|
||||
toast.error(err instanceof Error ? err.message : t('send_failed'));
|
||||
} finally {
|
||||
isSendingRef.current = false;
|
||||
setIsSending(false);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1943,7 +1828,6 @@ export function EmailComposer({
|
||||
showTemplatePicker ||
|
||||
showSaveAsTemplate ||
|
||||
showScheduleDialog ||
|
||||
smimePassphrasePrompt ||
|
||||
showAttachmentWarning ||
|
||||
showCloseDialog
|
||||
) return;
|
||||
@@ -2028,7 +1912,7 @@ export function EmailComposer({
|
||||
{/* Mobile: send button in header */}
|
||||
<Button
|
||||
onClick={() => handleSend()}
|
||||
disabled={!canSend}
|
||||
disabled={!canSend || isSending}
|
||||
title={getSendTooltip()}
|
||||
size="sm"
|
||||
className="md:hidden h-9 px-4"
|
||||
@@ -2479,31 +2363,8 @@ export function EmailComposer({
|
||||
>
|
||||
<BookmarkPlus className="w-4 h-4" />
|
||||
</Button>
|
||||
{/* S/MIME toggles */}
|
||||
{canSmimeSign && (
|
||||
<>
|
||||
<div className="w-px h-5 bg-border mx-1" />
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => setSmimeSign(v => !v)}
|
||||
className={cn("h-9 w-9", smimeSign_ && "bg-primary/10 text-primary")}
|
||||
title={smimeSign_ ? t('smime_sign_on') : t('smime_sign_off')}
|
||||
>
|
||||
<ShieldCheck className="w-4 h-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => setSmimeEncrypt(v => !v)}
|
||||
disabled={!canSmimeEncrypt}
|
||||
className={cn("h-9 w-9", smimeEncrypt_ && "bg-primary/10 text-primary")}
|
||||
title={smimeEncrypt_ ? t('smime_encrypt_on') : canSmimeEncrypt ? t('smime_encrypt_off') : t('smime_encrypt_unavailable')}
|
||||
>
|
||||
<Lock className="w-4 h-4" />
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
{/* Sign/encrypt controls are contributed by crypto plugins via the
|
||||
composer-toolbar slot (rendered below). */}
|
||||
|
||||
{/* Read-receipt request toggle */}
|
||||
<Button
|
||||
@@ -2535,7 +2396,7 @@ export function EmailComposer({
|
||||
<div ref={sendMenuRef} className="relative hidden md:inline-flex">
|
||||
<Button
|
||||
onClick={() => handleSend()}
|
||||
disabled={!canSend}
|
||||
disabled={!canSend || isSending}
|
||||
title={getSendTooltip()}
|
||||
className="rounded-r-none border-r border-primary-foreground/20"
|
||||
>
|
||||
@@ -2545,7 +2406,7 @@ export function EmailComposer({
|
||||
<Button
|
||||
type="button"
|
||||
onClick={() => setShowSendMenu((open) => !open)}
|
||||
disabled={!canSend}
|
||||
disabled={!canSend || isSending}
|
||||
title={t('schedule_send')}
|
||||
className="rounded-l-none px-2"
|
||||
aria-haspopup="menu"
|
||||
@@ -2573,7 +2434,7 @@ export function EmailComposer({
|
||||
) : (
|
||||
<Button
|
||||
onClick={() => handleSend()}
|
||||
disabled={!canSend}
|
||||
disabled={!canSend || isSending}
|
||||
title={getSendTooltip()}
|
||||
className="hidden md:inline-flex"
|
||||
>
|
||||
@@ -2636,61 +2497,7 @@ export function EmailComposer({
|
||||
{scheduleError && <p className="mt-2 text-sm text-destructive">{scheduleError}</p>}
|
||||
<div className="mt-5 flex justify-end gap-2">
|
||||
<Button variant="ghost" onClick={() => setShowScheduleDialog(false)}>{tCommon('cancel')}</Button>
|
||||
<Button onClick={handleScheduleSend} disabled={!canSend}>{t('schedule_send')}</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* S/MIME passphrase prompt */}
|
||||
{smimePassphrasePrompt && (
|
||||
<div
|
||||
className="fixed inset-0 bg-black/50 backdrop-blur-[1px] flex items-center justify-center z-[60] p-4 animate-in fade-in duration-150"
|
||||
>
|
||||
<div
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="bg-background border border-border rounded-lg shadow-xl w-full max-w-sm animate-in zoom-in-95 duration-200"
|
||||
>
|
||||
<div className="p-6">
|
||||
<h2 className="text-lg font-semibold text-foreground">{t('smime_unlock_title')}</h2>
|
||||
<p className="mt-2 text-sm text-muted-foreground">{t('smime_unlock_message')}</p>
|
||||
<input
|
||||
type="password"
|
||||
autoFocus
|
||||
value={smimePassphraseInput}
|
||||
onChange={(e) => {
|
||||
setSmimePassphraseInput(e.target.value);
|
||||
setSmimePassphraseError('');
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' && smimePassphraseInput) {
|
||||
smimePassphrasePrompt.resolve(smimePassphraseInput);
|
||||
}
|
||||
}}
|
||||
placeholder={t('smime_passphrase_placeholder')}
|
||||
className="mt-3 w-full px-3 py-2 border border-border rounded-md text-sm bg-background text-foreground outline-none focus:ring-2 focus:ring-primary"
|
||||
/>
|
||||
{smimePassphraseError && (
|
||||
<p className="mt-1 text-xs text-red-500">{smimePassphraseError}</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center justify-end gap-3 px-6 pb-6">
|
||||
<Button variant="outline" onClick={() => {
|
||||
smimePassphrasePrompt.reject();
|
||||
setSmimePassphrasePrompt(null);
|
||||
setSmimePassphraseInput('');
|
||||
setSmimePassphraseError('');
|
||||
}}>
|
||||
{t('cancel')}
|
||||
</Button>
|
||||
<Button
|
||||
disabled={!smimePassphraseInput}
|
||||
onClick={() => smimePassphrasePrompt.resolve(smimePassphraseInput)}
|
||||
>
|
||||
{t('smime_unlock_button')}
|
||||
</Button>
|
||||
<Button onClick={handleScheduleSend} disabled={!canSend || isSending}>{t('schedule_send')}</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -2919,6 +2726,20 @@ function RecipientChipInput({
|
||||
}
|
||||
};
|
||||
|
||||
// Pasting a list of addresses (comma/semicolon/whitespace separated) splits
|
||||
// into one chip per valid address; anything that isn't a valid address is
|
||||
// left in the input for the user to fix. A single address with no separator
|
||||
// falls through to the browser's normal paste so it stays editable.
|
||||
const handlePaste = (e: React.ClipboardEvent<HTMLInputElement>) => {
|
||||
const text = e.clipboardData.getData('text');
|
||||
if (!/[\s,;]/.test(text.trim())) return;
|
||||
const { valid, invalid } = splitPastedRecipients(text, chips.map(c => c.email));
|
||||
if (valid.length === 0) return;
|
||||
e.preventDefault();
|
||||
onChipsChange([...chips, ...valid]);
|
||||
onInputChange([inputText.trim(), invalid.join(' ')].filter(Boolean).join(' '));
|
||||
};
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (activeAutoField === field && autocompleteResults.length > 0) {
|
||||
if (e.key === 'ArrowDown' || e.key === 'ArrowUp' || e.key === 'Escape' ||
|
||||
@@ -3113,6 +2934,7 @@ function RecipientChipInput({
|
||||
value={inputText}
|
||||
onChange={handleInputChange}
|
||||
onKeyDown={handleKeyDown}
|
||||
onPaste={handlePaste}
|
||||
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"
|
||||
|
||||
@@ -34,6 +34,7 @@ import {
|
||||
XCircle,
|
||||
} from "lucide-react";
|
||||
import { cn, buildMailboxTree, MailboxNode } from "@/lib/utils";
|
||||
import { localizeMailboxName } from "@/lib/mailbox-label";
|
||||
import { useSettingsStore, KEYWORD_PALETTE } from "@/stores/settings-store";
|
||||
|
||||
interface Position {
|
||||
@@ -143,6 +144,7 @@ export function EmailContextMenu({
|
||||
onRescheduleScheduled,
|
||||
}: EmailContextMenuProps) {
|
||||
const t = useTranslations("context_menu");
|
||||
const tSidebar = useTranslations("sidebar");
|
||||
const _tColor = useTranslations("email_viewer.color_tag");
|
||||
const emailKeywords = useSettingsStore((state) => state.emailKeywords);
|
||||
const isUnread = !email.keywords?.$seen;
|
||||
@@ -302,12 +304,13 @@ export function EmailContextMenu({
|
||||
return nodes.map((node) => {
|
||||
const Icon = getMailboxIcon(node.role);
|
||||
const isTarget = moveTargetIds.has(node.id);
|
||||
const nodeLabel = localizeMailboxName(node.role, node.name, (k) => tSidebar(`mailboxes.${k}`));
|
||||
return (
|
||||
<div key={node.id}>
|
||||
{isTarget ? (
|
||||
<ContextMenuItem
|
||||
icon={Icon}
|
||||
label={node.name}
|
||||
label={nodeLabel}
|
||||
onClick={() =>
|
||||
handleAction(() =>
|
||||
showBatchActions
|
||||
@@ -319,7 +322,7 @@ export function EmailContextMenu({
|
||||
) : (
|
||||
<div className="px-3 py-1.5 text-sm flex items-center gap-2 text-muted-foreground">
|
||||
<Icon className="w-4 h-4 flex-shrink-0" />
|
||||
<span>{node.name}</span>
|
||||
<span>{nodeLabel}</span>
|
||||
</div>
|
||||
)}
|
||||
{node.children.length > 0 && (
|
||||
|
||||
@@ -4,7 +4,7 @@ import { Email } from "@/lib/jmap/types";
|
||||
import { useSettingsStore } from "@/stores/settings-store";
|
||||
import type { HoverAction } from "@/stores/settings-store";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Trash2, Star, Mail, MailOpen, Archive, Tag, ShieldAlert } from "lucide-react";
|
||||
import { Trash2, Star, Mail, MailOpen, Archive, Tag, ShieldAlert, ShieldCheck } from "lucide-react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useIsMobile } from "@/hooks/use-media-query";
|
||||
|
||||
@@ -17,6 +17,10 @@ interface EmailHoverActionsProps {
|
||||
onArchive?: () => void;
|
||||
onSetColorTag?: (color: string | null) => void;
|
||||
onMarkAsSpam?: () => void;
|
||||
// When the email lives in a junk folder (incl. the aggregate "All Junk" view)
|
||||
// the spam quick-action flips to "not spam".
|
||||
isInJunk?: boolean;
|
||||
onUndoSpam?: () => void;
|
||||
}
|
||||
|
||||
const ACTION_CONFIG: Record<HoverAction, {
|
||||
@@ -72,6 +76,8 @@ export function EmailHoverActions({
|
||||
onArchive,
|
||||
onSetColorTag,
|
||||
onMarkAsSpam,
|
||||
isInJunk = false,
|
||||
onUndoSpam,
|
||||
}: EmailHoverActionsProps) {
|
||||
const hoverActions = useSettingsStore((state) => state.hoverActions);
|
||||
const hoverActionsMode = useSettingsStore((state) => state.hoverActionsMode);
|
||||
@@ -106,7 +112,8 @@ export function EmailHoverActions({
|
||||
onSetColorTag?.(null);
|
||||
break;
|
||||
case "spam":
|
||||
onMarkAsSpam?.();
|
||||
if (isInJunk) onUndoSpam?.();
|
||||
else onMarkAsSpam?.();
|
||||
break;
|
||||
}
|
||||
};
|
||||
@@ -116,20 +123,28 @@ export function EmailHoverActions({
|
||||
if (!config) return null;
|
||||
const Icon = config.icon;
|
||||
|
||||
// In a junk context the spam action becomes "not spam".
|
||||
const isNotSpam = actionId === "spam" && isInJunk;
|
||||
const DisplayIcon = actionId === "markRead"
|
||||
? (isUnread ? MailOpen : Mail)
|
||||
: actionId === "star" && isStarred
|
||||
? Star
|
||||
: isNotSpam
|
||||
? ShieldCheck
|
||||
: Icon;
|
||||
const title = isNotSpam ? t("not_spam") : t(config.titleKey);
|
||||
const className = isNotSpam
|
||||
? "hover:text-green-600 dark:hover:text-green-400"
|
||||
: config.className;
|
||||
|
||||
return (
|
||||
<button
|
||||
key={actionId}
|
||||
onClick={(e) => handleAction(e, actionId)}
|
||||
title={t(config.titleKey)}
|
||||
title={title}
|
||||
className={cn(
|
||||
"p-1.5 rounded-md transition-colors duration-100 text-muted-foreground hover:bg-black/5 dark:hover:bg-white/10",
|
||||
config.className,
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<DisplayIcon
|
||||
|
||||
@@ -29,11 +29,12 @@ interface EmailListItemProps {
|
||||
onArchive?: () => void;
|
||||
onSetColorTag?: (color: string | null) => void;
|
||||
onMarkAsSpam?: () => void;
|
||||
onUndoSpam?: () => void;
|
||||
}
|
||||
|
||||
export function EmailListItem({ email, selected, onClick, onDoubleClick, onContextMenu, onToggleStar, onMarkAsRead, onDelete, onArchive, onSetColorTag, onMarkAsSpam }: EmailListItemProps) {
|
||||
export function EmailListItem({ email, selected, onClick, onDoubleClick, onContextMenu, onToggleStar, onMarkAsRead, onDelete, onArchive, onSetColorTag, onMarkAsSpam, onUndoSpam }: EmailListItemProps) {
|
||||
const t = useTranslations('email_viewer');
|
||||
const { selectedEmailIds, toggleEmailSelection, selectRangeEmails, selectedMailbox, mailboxes, clearSelection } = useEmailStore();
|
||||
const { selectedEmailIds, toggleEmailSelection, selectRangeEmails, selectedMailbox, mailboxes, clearSelection, isUnifiedView, unifiedRole } = useEmailStore();
|
||||
const showPreview = useSettingsStore((state) => state.showPreview);
|
||||
const density = useSettingsStore((state) => state.density);
|
||||
const mailLayout = useSettingsStore((state) => state.mailLayout);
|
||||
@@ -46,8 +47,11 @@ export function EmailListItem({ email, selected, onClick, onDoubleClick, onConte
|
||||
const isImportant = email.keywords?.["$important"];
|
||||
const isAnswered = email.keywords?.$answered;
|
||||
const isForwarded = email.keywords?.$forwarded;
|
||||
// In Sent/Drafts folders, show recipient instead of sender (which is always "me")
|
||||
const currentMailboxRole = mailboxes.find(mb => mb.id === selectedMailbox)?.role;
|
||||
// In Sent/Drafts folders, show recipient instead of sender (which is always "me").
|
||||
// In aggregate role-views the selected mailbox is virtual → fall back to the
|
||||
// unified role so junk-contextual UI (spam ↔ not-spam) and avatar hiding work.
|
||||
const currentMailboxRole = mailboxes.find(mb => mb.id === selectedMailbox)?.role
|
||||
?? (isUnifiedView ? (unifiedRole ?? undefined) : undefined);
|
||||
const showRecipient = currentMailboxRole === 'sent' || currentMailboxRole === 'drafts';
|
||||
const sender = showRecipient ? (email.to?.[0] ?? email.from?.[0]) : email.from?.[0];
|
||||
const isMobile = useUIStore((state) => state.isMobile);
|
||||
@@ -162,7 +166,7 @@ export function EmailListItem({ email, selected, onClick, onDoubleClick, onConte
|
||||
|
||||
{/* Unread indicator */}
|
||||
{isUnread && (
|
||||
<div className="absolute left-1 top-1/2 -translate-y-1/2">
|
||||
<div className="absolute left-0.5 top-1/2 -translate-y-1/2">
|
||||
<Circle className="w-2 h-2 fill-unread text-unread" />
|
||||
</div>
|
||||
)}
|
||||
@@ -191,13 +195,13 @@ export function EmailListItem({ email, selected, onClick, onDoubleClick, onConte
|
||||
</span>
|
||||
<div className="flex min-w-0 flex-1 items-center gap-2 text-sm">
|
||||
<span className={cn(
|
||||
'shrink-0 truncate',
|
||||
'min-w-0 truncate',
|
||||
isUnread ? 'font-semibold text-foreground' : 'text-foreground/90'
|
||||
)}>
|
||||
{email.subject || t('no_subject')}
|
||||
</span>
|
||||
{inlinePreview && (
|
||||
<span className="min-w-0 truncate text-muted-foreground">{inlinePreview}</span>
|
||||
<span className="min-w-0 shrink-[9999] truncate text-muted-foreground">{inlinePreview}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
@@ -321,6 +325,8 @@ export function EmailListItem({ email, selected, onClick, onDoubleClick, onConte
|
||||
onArchive={onArchive}
|
||||
onSetColorTag={onSetColorTag}
|
||||
onMarkAsSpam={onMarkAsSpam}
|
||||
onUndoSpam={onUndoSpam}
|
||||
isInJunk={currentMailboxRole === 'junk'}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -101,14 +101,25 @@ export function EmailList({
|
||||
toggleThreadExpansion,
|
||||
fetchThreadEmails,
|
||||
markThreadAsRead,
|
||||
collapseAllThreads,
|
||||
threadEmailCounts,
|
||||
searchFilters,
|
||||
setSearchFilters,
|
||||
clearSearchFilters,
|
||||
advancedSearch,
|
||||
searchQuery,
|
||||
isUnifiedView,
|
||||
unifiedRole,
|
||||
} = useEmailStore();
|
||||
|
||||
// In aggregate role-views (e.g. "All Junk") the selected mailbox is virtual, so
|
||||
// there is no concrete mailbox to read the role from. Fall back to the unified
|
||||
// role so contextual actions (e.g. mark-as-spam ↔ not-spam) behave as if inside
|
||||
// that role's folder.
|
||||
const effectiveMailboxRole =
|
||||
mailboxes.find(m => m.id === selectedMailbox)?.role
|
||||
?? (isUnifiedView ? (unifiedRole ?? undefined) : undefined);
|
||||
|
||||
const disableThreading = useSettingsStore((state) => state.disableThreading);
|
||||
|
||||
const threadGroups = useMemo(() => {
|
||||
@@ -477,7 +488,18 @@ export function EmailList({
|
||||
isLoading={isLoadingThread === thread.threadId}
|
||||
expandedEmails={threadEmailsCache.get(thread.threadId)}
|
||||
onToggleExpand={() => handleToggleThreadExpansion(thread.threadId)}
|
||||
onEmailSelect={(email) => onEmailSelect?.(email)}
|
||||
onCollapseAllThreads={collapseAllThreads}
|
||||
onEmailSelect={(email) => {
|
||||
// Collapse expanded threads when selecting an email outside the expanded thread
|
||||
const currentExpanded = useEmailStore.getState().expandedThreadIds;
|
||||
if (currentExpanded.size > 0) {
|
||||
// Check if the selected email belongs to any expanded thread via its threadId
|
||||
if (!email.threadId || !currentExpanded.has(email.threadId)) {
|
||||
collapseAllThreads();
|
||||
}
|
||||
}
|
||||
onEmailSelect?.(email);
|
||||
}}
|
||||
onEmailDoubleClick={onEmailDoubleClick ? (email) => onEmailDoubleClick(email) : undefined}
|
||||
onContextMenu={openContextMenu}
|
||||
onOpenConversation={onOpenConversation}
|
||||
@@ -487,6 +509,7 @@ export function EmailList({
|
||||
onArchive={onArchive ? (email) => onArchive(email) : undefined}
|
||||
onSetColorTag={onSetColorTag}
|
||||
onMarkAsSpam={onMarkAsSpam ? (email) => onMarkAsSpam(email) : undefined}
|
||||
onUndoSpam={onUndoSpam ? (email) => onUndoSpam(email) : undefined}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
@@ -520,7 +543,7 @@ export function EmailList({
|
||||
menuRef={menuRef}
|
||||
mailboxes={mailboxes}
|
||||
selectedMailbox={selectedMailbox}
|
||||
currentMailboxRole={mailboxes.find(m => m.id === selectedMailbox)?.role}
|
||||
currentMailboxRole={effectiveMailboxRole}
|
||||
isMultiSelect={selectedEmailIds.has(contextMenu.data.id)}
|
||||
selectedCount={selectedEmailIds.size}
|
||||
onReply={() => onReply?.(contextMenu.data!)}
|
||||
|
||||
+276
-1157
File diff suppressed because it is too large
Load Diff
@@ -4,6 +4,8 @@ import { Node as TiptapNode, mergeAttributes } from "@tiptap/core";
|
||||
import { DOMSerializer } from "@tiptap/pm/model";
|
||||
import type { Editor } from "@tiptap/react";
|
||||
|
||||
import { buildSignatureBlock } from "@/components/email/signature-block";
|
||||
|
||||
// Marker attribute that identifies the quoted-original wrapper in serialized
|
||||
// HTML, so parseHTML can recognise it on the way back in.
|
||||
export const QUOTED_HTML_MARKER = "data-quoted-html";
|
||||
@@ -166,6 +168,13 @@ export function serializeEditorContent(editor: Editor): string {
|
||||
parts.push(buildQuotedHtmlBlock((node.attrs.html as string) || ""));
|
||||
return;
|
||||
}
|
||||
if (node.type.name === "signatureBlock") {
|
||||
// Same rationale as quotedHtml: inline the verbatim signature HTML so the
|
||||
// styled signature reaches the recipient (and a saved draft round-trips)
|
||||
// instead of the schema-flattened version.
|
||||
parts.push(buildSignatureBlock((node.attrs.html as string) || ""));
|
||||
return;
|
||||
}
|
||||
const fragment = serializer.serializeNode(node);
|
||||
const tmp = document.createElement("div");
|
||||
tmp.appendChild(fragment);
|
||||
|
||||
@@ -17,6 +17,7 @@ import { TableRow } from "@tiptap/extension-table-row";
|
||||
import { TableHeader } from "@tiptap/extension-table-header";
|
||||
import { TableCell } from "@tiptap/extension-table-cell";
|
||||
import { QuotedHtml, serializeEditorContent } from "@/components/email/quoted-html";
|
||||
import { SignatureBlock } from "@/components/email/signature-block";
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
Bold,
|
||||
@@ -235,6 +236,10 @@ export function RichTextEditor({
|
||||
// Quoted/forwarded original email body - held verbatim as an atomic
|
||||
// node so layout-heavy HTML survives 1:1 (see quoted-html.ts).
|
||||
QuotedHtml,
|
||||
// Identity signature - held verbatim as a non-editable atomic node so
|
||||
// rich/branded signatures keep their inline styling in the editor and
|
||||
// in the sent mail (see signature-block.ts).
|
||||
SignatureBlock,
|
||||
],
|
||||
content,
|
||||
editorProps: {
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
"use client";
|
||||
|
||||
import { Node as TiptapNode, mergeAttributes } from "@tiptap/core";
|
||||
|
||||
// Marker attribute that identifies the signature wrapper in serialized HTML,
|
||||
// so parseHTML can recognise it on the way back in (initial content, drafts).
|
||||
export const SIGNATURE_BLOCK_MARKER = "data-signature-block-node";
|
||||
|
||||
/**
|
||||
* SignatureBlock — an atomic, NON-editable block node that carries the
|
||||
* *verbatim* HTML of the user's identity signature in its `html` attribute.
|
||||
*
|
||||
* Why: the signature is embedded into the composer so it stays in the body,
|
||||
* but parsing rich, table-based "brand" signatures into the ProseMirror schema
|
||||
* strips their inline CSS (background/text colors, fonts, border-radius). By
|
||||
* holding the signature as an atom it is never parsed into the schema, so the
|
||||
* styling survives 1:1 — both in the editor (rendered by the NodeView below)
|
||||
* and in the sent mail (emitted by serializeEditorContent in quoted-html.ts).
|
||||
*
|
||||
* Mirrors QuotedHtml, but the inner region is read-only: a signature is meant
|
||||
* to be inserted/removed as a unit, not edited inline. Select the node and
|
||||
* press Backspace/Delete to drop the whole signature.
|
||||
*/
|
||||
export const SignatureBlock = TiptapNode.create({
|
||||
name: "signatureBlock",
|
||||
group: "block",
|
||||
atom: true,
|
||||
selectable: true,
|
||||
draggable: false,
|
||||
// Isolating keeps selection/gapcursor behaviour sane at the boundary.
|
||||
isolating: true,
|
||||
|
||||
addAttributes() {
|
||||
return {
|
||||
html: {
|
||||
default: "",
|
||||
// Capture the verbatim inner HTML when parsing. Because the node is an
|
||||
// atom, ProseMirror does NOT descend into the children, so the rich
|
||||
// signature markup never hits (and is never mangled by) the schema.
|
||||
parseHTML: (el) => el.innerHTML,
|
||||
// The real content round-trips via the custom serializer
|
||||
// (serializeEditorContent); renderHTML below only needs the wrapper.
|
||||
renderHTML: () => ({}),
|
||||
},
|
||||
};
|
||||
},
|
||||
|
||||
parseHTML() {
|
||||
return [{ tag: `div[${SIGNATURE_BLOCK_MARKER}]` }];
|
||||
},
|
||||
|
||||
renderHTML({ HTMLAttributes }) {
|
||||
// Only used for ProseMirror's internal/clipboard round-trip. The send /
|
||||
// draft path uses serializeEditorContent() which inlines attrs.html.
|
||||
return ["div", mergeAttributes(HTMLAttributes, { [SIGNATURE_BLOCK_MARKER]: "" })];
|
||||
},
|
||||
|
||||
addNodeView() {
|
||||
return ({ node }) => {
|
||||
const dom = document.createElement("div");
|
||||
dom.setAttribute(SIGNATURE_BLOCK_MARKER, "");
|
||||
dom.className = "signature-block-island";
|
||||
|
||||
// CRITICAL: render the signature inside a Shadow Root. The app's global
|
||||
// CSS (Tailwind preflight, .tiptap table/td rules, box-sizing resets)
|
||||
// would otherwise cascade INTO the signature and destroy its layout -
|
||||
// exactly the corruption we are fixing. Shadow DOM isolates both
|
||||
// directions, so only the browser's UA defaults + the signature's own
|
||||
// inline styles apply and the in-editor preview matches the sent mail.
|
||||
const shadow = dom.attachShadow({ mode: "open" });
|
||||
const inner = document.createElement("div");
|
||||
// Read-only: a signature is inserted/removed as a unit, not edited inline.
|
||||
inner.contentEditable = "false";
|
||||
inner.innerHTML = node.attrs.html || "";
|
||||
shadow.appendChild(inner);
|
||||
|
||||
return {
|
||||
dom,
|
||||
// ProseMirror must not try to reconcile the foreign shadow content.
|
||||
ignoreMutation: () => true,
|
||||
// Let ProseMirror handle all events so clicking selects the atom and
|
||||
// Backspace/Delete removes the whole signature.
|
||||
stopEvent: () => false,
|
||||
update: (updatedNode) => {
|
||||
if (updatedNode.type.name !== "signatureBlock") return false;
|
||||
if (inner.innerHTML !== (updatedNode.attrs.html || "")) {
|
||||
inner.innerHTML = updatedNode.attrs.html || "";
|
||||
}
|
||||
return true;
|
||||
},
|
||||
};
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* Build the editor-content wrapper that embeds the signature as a single
|
||||
* SignatureBlock node. The inner HTML must be pre-sanitized
|
||||
* (sanitizeSignatureHtml). The `data-signature-block-node` marker is what
|
||||
* parseHTML keys on, so this exact form must be what serializeEditorContent
|
||||
* emits too (round-trip consistency).
|
||||
*/
|
||||
export function buildSignatureBlock(sanitizedInnerHtml: string): string {
|
||||
return `<div ${SIGNATURE_BLOCK_MARKER}>${sanitizedInnerHtml}</div>`;
|
||||
}
|
||||
@@ -1,138 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { ShieldCheck, ShieldAlert, ShieldX, Lock, LockOpen, AlertTriangle, Info } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useTranslations } from "next-intl";
|
||||
import type { SmimeStatus } from "@/lib/smime/types";
|
||||
|
||||
interface SmimeStatusBannerProps {
|
||||
status: SmimeStatus;
|
||||
onUnlockKey?: () => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
type SmimeVariant = 'success' | 'warning' | 'error' | 'info';
|
||||
|
||||
const variantTone: Record<SmimeVariant, string> = {
|
||||
success: 'bg-success/15 text-success',
|
||||
warning: 'bg-warning/15 text-warning',
|
||||
error: 'bg-destructive/15 text-destructive',
|
||||
info: 'bg-info/15 text-info',
|
||||
};
|
||||
|
||||
export function SmimeStatusBanner({ status, onUnlockKey, className }: SmimeStatusBannerProps) {
|
||||
const t = useTranslations('smime');
|
||||
|
||||
const items: Array<{
|
||||
icon: React.ReactNode;
|
||||
text: string;
|
||||
variant: SmimeVariant;
|
||||
}> = [];
|
||||
|
||||
// Encryption status
|
||||
if (status.isEncrypted) {
|
||||
if (status.decryptionError) {
|
||||
if (status.decryptionError === 'locked') {
|
||||
items.push({
|
||||
icon: <Lock className="w-5 h-5" />,
|
||||
text: t('unlock_key_desc'),
|
||||
variant: 'warning',
|
||||
});
|
||||
} else if (status.decryptionError === 'no-key') {
|
||||
items.push({
|
||||
icon: <Lock className="w-5 h-5" />,
|
||||
text: t('status_encrypted_no_key'),
|
||||
variant: 'warning',
|
||||
});
|
||||
} else {
|
||||
items.push({
|
||||
icon: <ShieldX className="w-5 h-5" />,
|
||||
text: t('status_encrypted_failed'),
|
||||
variant: 'error',
|
||||
});
|
||||
}
|
||||
} else {
|
||||
items.push({
|
||||
icon: <LockOpen className="w-5 h-5" />,
|
||||
text: t('status_encrypted_ok'),
|
||||
variant: 'success',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Signature status
|
||||
if (status.isSigned) {
|
||||
if (status.signatureValid === true) {
|
||||
if (status.selfSigned) {
|
||||
items.push({
|
||||
icon: <AlertTriangle className="w-5 h-5" />,
|
||||
text: t('status_signed_self_signed'),
|
||||
variant: 'warning',
|
||||
});
|
||||
} else if (status.signerEmailMatch === false) {
|
||||
items.push({
|
||||
icon: <AlertTriangle className="w-5 h-5" />,
|
||||
text: t('status_signed_mismatch'),
|
||||
variant: 'warning',
|
||||
});
|
||||
} else {
|
||||
items.push({
|
||||
icon: <ShieldCheck className="w-5 h-5" />,
|
||||
text: t('status_signed_valid'),
|
||||
variant: 'success',
|
||||
});
|
||||
}
|
||||
} else if (status.signatureValid === false) {
|
||||
items.push({
|
||||
icon: <ShieldAlert className="w-5 h-5" />,
|
||||
text: status.signatureError || t('status_signed_invalid'),
|
||||
variant: 'error',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Unsupported S/MIME
|
||||
if (status.unsupportedReason) {
|
||||
items.push({
|
||||
icon: <Info className="w-5 h-5" />,
|
||||
text: t('status_unsupported'),
|
||||
variant: 'info',
|
||||
});
|
||||
}
|
||||
|
||||
if (items.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div className={cn("flex flex-col gap-3 py-1", className)}>
|
||||
{items.map((item, i) => (
|
||||
<div key={i} className="flex items-start gap-3">
|
||||
<div className={cn(
|
||||
"w-10 h-10 rounded-full flex items-center justify-center flex-shrink-0 shadow-sm",
|
||||
variantTone[item.variant],
|
||||
)}>
|
||||
{item.icon}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0 flex items-center justify-between gap-2">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="text-[10px] font-semibold uppercase tracking-wider text-muted-foreground">
|
||||
S/MIME
|
||||
</div>
|
||||
<div className="text-sm font-medium text-foreground break-words">
|
||||
{item.text}
|
||||
</div>
|
||||
</div>
|
||||
{item.variant === 'warning' && status.decryptionError === 'locked' && onUnlockKey && (
|
||||
<button
|
||||
onClick={onUnlockKey}
|
||||
className="text-xs font-medium underline hover:no-underline flex-shrink-0"
|
||||
>
|
||||
{t('unlock_key')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -2,10 +2,10 @@
|
||||
|
||||
import React, { useCallback } from "react";
|
||||
import { formatDate, formatDateTime, stripInvisibleLeading } from "@/lib/utils";
|
||||
import { Email, ThreadGroup } from "@/lib/jmap/types";
|
||||
import { Email, ThreadGroup, ALL_MAIL_MAILBOX_ID } from "@/lib/jmap/types";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Avatar } from "@/components/ui/avatar";
|
||||
import { Paperclip, Star, Circle, ChevronRight, ChevronDown, Loader2, MessageSquare, CheckSquare, Square, Reply, Forward, CalendarClock } from "lucide-react";
|
||||
import { Paperclip, Star, Circle, ChevronRight, ChevronDown, Loader2, MessageSquare, CheckSquare, Square, Reply, Forward, CalendarClock, Folder } from "lucide-react";
|
||||
import { useSettingsStore, KEYWORD_PALETTE } from "@/stores/settings-store";
|
||||
import { useUIStore } from "@/stores/ui-store";
|
||||
import { useEmailStore } from "@/stores/email-store";
|
||||
@@ -17,6 +17,23 @@ import { ThreadEmailItem } from "./thread-email-item";
|
||||
import { EmailHoverActions } from "./email-hover-actions";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
/**
|
||||
* Small chip showing the originating folder of a message, rendered in the
|
||||
* aggregate "All …" views (All Mail / unified / cross-account) where rows come
|
||||
* from different folders. `email.sourceFolder` is stamped at fetch time.
|
||||
*/
|
||||
function SourceFolderTag({ name }: { name: string }) {
|
||||
return (
|
||||
<span
|
||||
className="inline-flex max-w-[8rem] shrink-0 items-center gap-1 truncate rounded-full border border-border bg-muted/40 px-1.5 py-0.5 text-[11px] text-muted-foreground"
|
||||
title={name}
|
||||
>
|
||||
<Folder className="h-3 w-3 shrink-0" />
|
||||
<span className="truncate">{name}</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
interface ThreadListItemProps {
|
||||
thread: ThreadGroup;
|
||||
isExpanded: boolean;
|
||||
@@ -24,6 +41,7 @@ interface ThreadListItemProps {
|
||||
isLoading?: boolean;
|
||||
expandedEmails?: Email[];
|
||||
onToggleExpand: () => void;
|
||||
onCollapseAllThreads?: () => void;
|
||||
onEmailSelect: (email: Email) => void;
|
||||
onEmailDoubleClick?: (email: Email) => void;
|
||||
onContextMenu?: (e: React.MouseEvent, email: Email) => void;
|
||||
@@ -34,6 +52,7 @@ interface ThreadListItemProps {
|
||||
onArchive?: (email: Email) => void;
|
||||
onSetColorTag?: (emailId: string, color: string | null) => void;
|
||||
onMarkAsSpam?: (email: Email) => void;
|
||||
onUndoSpam?: (email: Email) => void;
|
||||
}
|
||||
|
||||
interface SingleEmailItemProps {
|
||||
@@ -50,18 +69,22 @@ interface SingleEmailItemProps {
|
||||
onArchive?: () => void;
|
||||
onSetColorTag?: (color: string | null) => void;
|
||||
onMarkAsSpam?: () => void;
|
||||
onUndoSpam?: () => void;
|
||||
}
|
||||
|
||||
const SingleEmailItem = React.forwardRef<HTMLDivElement, SingleEmailItemProps>(
|
||||
function SingleEmailItem({ email, selected, onClick, onDoubleClick, onContextMenu, showPreview, colorTag, onToggleStar, onMarkAsRead, onDelete, onArchive, onSetColorTag, onMarkAsSpam }, ref) {
|
||||
function SingleEmailItem({ email, selected, onClick, onDoubleClick, onContextMenu, showPreview, colorTag, onToggleStar, onMarkAsRead, onDelete, onArchive, onSetColorTag, onMarkAsSpam, onUndoSpam }, ref) {
|
||||
const t = useTranslations('email_viewer');
|
||||
const isUnread = !email.keywords?.$seen;
|
||||
const isStarred = email.keywords?.$flagged;
|
||||
const isAnswered = email.keywords?.$answered;
|
||||
const isForwarded = email.keywords?.$forwarded;
|
||||
const { selectedMailbox, mailboxes, selectedEmailIds, toggleEmailSelection, selectRangeEmails, clearSelection } = useEmailStore();
|
||||
// In Sent/Drafts folders, show recipient instead of sender (which is always "me")
|
||||
const currentMailboxRole = mailboxes.find(mb => mb.id === selectedMailbox)?.role;
|
||||
const { selectedMailbox, mailboxes, selectedEmailIds, toggleEmailSelection, selectRangeEmails, clearSelection, isUnifiedView, unifiedRole } = useEmailStore();
|
||||
// In Sent/Drafts folders, show recipient instead of sender (which is always
|
||||
// "me"). In aggregate role-views the selected mailbox is virtual → fall back
|
||||
// to the unified role so junk-contextual UI and avatar hiding work.
|
||||
const currentMailboxRole = mailboxes.find(mb => mb.id === selectedMailbox)?.role
|
||||
?? (isUnifiedView ? (unifiedRole ?? undefined) : undefined);
|
||||
const showRecipient = currentMailboxRole === 'sent' || currentMailboxRole === 'drafts';
|
||||
const sender = showRecipient ? (email.to?.[0] ?? email.from?.[0]) : email.from?.[0];
|
||||
const emailKeywords = useSettingsStore((state) => state.emailKeywords);
|
||||
@@ -70,7 +93,8 @@ const SingleEmailItem = React.forwardRef<HTMLDivElement, SingleEmailItemProps>(
|
||||
const timeFormat = useSettingsStore((state) => state.timeFormat);
|
||||
const showAvatarsInJunk = useSettingsStore((state) => state.showAvatarsInJunk);
|
||||
const hideJunkAvatarImages = currentMailboxRole === 'junk' && !showAvatarsInJunk;
|
||||
const isUnifiedView = useEmailStore((state) => state.isUnifiedView);
|
||||
// Show the originating folder in the aggregate "All …" views.
|
||||
const showSourceFolder = (isUnifiedView || selectedMailbox === ALL_MAIL_MAILBOX_ID) && !!email.sourceFolder;
|
||||
const getAccountById = useAccountStore((state) => state.getAccountById);
|
||||
const accountColor = email.accountId ? getAccountById(email.accountId)?.avatarColor : undefined;
|
||||
const isChecked = selectedEmailIds.has(email.id);
|
||||
@@ -187,7 +211,7 @@ const SingleEmailItem = React.forwardRef<HTMLDivElement, SingleEmailItemProps>(
|
||||
)}
|
||||
|
||||
{isUnread && (
|
||||
<div className="absolute left-1 top-1/2 -translate-y-1/2">
|
||||
<div className="absolute left-0.5 top-1/2 -translate-y-1/2">
|
||||
<Circle className="w-2 h-2 fill-unread text-unread" />
|
||||
</div>
|
||||
)}
|
||||
@@ -221,13 +245,13 @@ const SingleEmailItem = React.forwardRef<HTMLDivElement, SingleEmailItemProps>(
|
||||
</span>
|
||||
<div className="flex min-w-0 flex-1 items-center gap-2 text-sm">
|
||||
<span className={cn(
|
||||
'shrink-0 truncate',
|
||||
'min-w-0 truncate',
|
||||
isUnread ? 'font-semibold text-foreground' : 'text-foreground/90'
|
||||
)}>
|
||||
{email.subject || '(no subject)'}
|
||||
</span>
|
||||
{inlinePreview && (
|
||||
<span className="min-w-0 truncate text-muted-foreground">{inlinePreview}</span>
|
||||
<span className="min-w-0 shrink-[9999] truncate text-muted-foreground">{inlinePreview}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
@@ -245,6 +269,7 @@ const SingleEmailItem = React.forwardRef<HTMLDivElement, SingleEmailItemProps>(
|
||||
{resolvedKeywordDefs.map((kd) => (
|
||||
<span key={kd.id} className={cn('h-2.5 w-2.5 rounded-full', KEYWORD_PALETTE[kd.color]?.dot || 'bg-gray-400')} />
|
||||
))}
|
||||
{showSourceFolder && <SourceFolderTag name={email.sourceFolder!} />}
|
||||
{scheduledSendLabel ? (
|
||||
<span
|
||||
className="inline-flex max-w-[11rem] shrink-0 items-center gap-1 truncate rounded-full border border-sky-500/20 bg-sky-500/10 px-2 py-0.5 text-xs font-medium tabular-nums text-sky-700 dark:text-sky-300"
|
||||
@@ -313,6 +338,7 @@ const SingleEmailItem = React.forwardRef<HTMLDivElement, SingleEmailItemProps>(
|
||||
{kd.label}
|
||||
</span>
|
||||
))}
|
||||
{showSourceFolder && <SourceFolderTag name={email.sourceFolder!} />}
|
||||
{scheduledSendLabel ? (
|
||||
<span
|
||||
className="inline-flex max-w-[11rem] shrink-0 items-center gap-1 truncate rounded-full border border-sky-500/20 bg-sky-500/10 px-2 py-0.5 text-[11px] font-medium tabular-nums text-sky-700 dark:text-sky-300"
|
||||
@@ -369,6 +395,8 @@ const SingleEmailItem = React.forwardRef<HTMLDivElement, SingleEmailItemProps>(
|
||||
onArchive={onArchive}
|
||||
onSetColorTag={onSetColorTag}
|
||||
onMarkAsSpam={onMarkAsSpam}
|
||||
onUndoSpam={onUndoSpam}
|
||||
isInJunk={currentMailboxRole === 'junk'}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
@@ -384,6 +412,7 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
|
||||
isLoading = false,
|
||||
expandedEmails,
|
||||
onToggleExpand,
|
||||
onCollapseAllThreads,
|
||||
onEmailSelect,
|
||||
onEmailDoubleClick,
|
||||
onContextMenu,
|
||||
@@ -394,6 +423,7 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
|
||||
onArchive,
|
||||
onSetColorTag,
|
||||
onMarkAsSpam,
|
||||
onUndoSpam,
|
||||
}, ref) {
|
||||
const t = useTranslations('threads');
|
||||
const tEmailViewer = useTranslations('email_viewer');
|
||||
@@ -412,11 +442,15 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
|
||||
? formatDateTime(latestEmail.scheduledSendAt, timeFormat)
|
||||
: null;
|
||||
|
||||
const { selectedMailbox, mailboxes, selectedEmailIds, toggleEmailSelection, selectRangeEmails, clearSelection, isUnifiedView } = useEmailStore();
|
||||
const { selectedMailbox, mailboxes, selectedEmailIds, toggleEmailSelection, selectRangeEmails, clearSelection, isUnifiedView, unifiedRole } = useEmailStore();
|
||||
const showSourceFolder = (isUnifiedView || selectedMailbox === ALL_MAIL_MAILBOX_ID) && !!latestEmail.sourceFolder;
|
||||
const getAccountById = useAccountStore((state) => state.getAccountById);
|
||||
const threadAccountColor = latestEmail.accountId ? getAccountById(latestEmail.accountId)?.avatarColor : undefined;
|
||||
// In Sent/Drafts folders, show recipient instead of sender (which is always "me")
|
||||
const currentMailboxRole = mailboxes.find(mb => mb.id === selectedMailbox)?.role;
|
||||
// In Sent/Drafts folders, show recipient instead of sender (which is always
|
||||
// "me"). Aggregate role-views use a virtual selected mailbox → fall back to
|
||||
// the unified role so junk-contextual UI and avatar hiding work.
|
||||
const currentMailboxRole = mailboxes.find(mb => mb.id === selectedMailbox)?.role
|
||||
?? (isUnifiedView ? (unifiedRole ?? undefined) : undefined);
|
||||
const showRecipient = currentMailboxRole === 'sent' || currentMailboxRole === 'drafts';
|
||||
const displayNames = showRecipient
|
||||
? Array.from(new Set(
|
||||
@@ -470,6 +504,7 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
|
||||
onArchive={onArchive ? () => onArchive(latestEmail) : undefined}
|
||||
onSetColorTag={onSetColorTag ? (color) => onSetColorTag(latestEmail.id, color) : undefined}
|
||||
onMarkAsSpam={onMarkAsSpam ? () => onMarkAsSpam(latestEmail) : undefined}
|
||||
onUndoSpam={onUndoSpam ? () => onUndoSpam(latestEmail) : undefined}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -515,6 +550,7 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
|
||||
} else {
|
||||
if (selectedEmailIds.size > 0) clearSelection();
|
||||
if (!isExpanded) {
|
||||
onCollapseAllThreads?.();
|
||||
onToggleExpand();
|
||||
}
|
||||
onEmailSelect(latestEmail);
|
||||
@@ -581,6 +617,21 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
|
||||
</button>
|
||||
)}
|
||||
|
||||
{hasUnread && (
|
||||
<div className="absolute left-0.5 top-1/2 -translate-y-1/2">
|
||||
<Circle className="w-2 h-2 fill-unread text-unread" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{density !== 'extra-compact' && (
|
||||
<div className="relative flex-shrink-0">
|
||||
<Avatar
|
||||
name={avatarPerson?.name}
|
||||
email={avatarPerson?.email}
|
||||
size={isFocusedMailLayout ? "sm" : "md"}
|
||||
className="shadow-sm"
|
||||
disableImages={hideJunkAvatarImages}
|
||||
/>
|
||||
{!isMobile && !isFocusedMailLayout && (
|
||||
<button
|
||||
data-expand-toggle
|
||||
@@ -589,40 +640,28 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
|
||||
onToggleExpand();
|
||||
}}
|
||||
className={cn(
|
||||
"p-1 rounded mt-2 flex-shrink-0 transition-all duration-200",
|
||||
"absolute -bottom-2.5 left-1/2 -translate-x-1/2 p-0.5 rounded-full",
|
||||
"transition-all duration-200",
|
||||
"hover:bg-muted/50 hover:scale-110",
|
||||
"active:scale-95",
|
||||
"text-muted-foreground hover:text-foreground"
|
||||
"text-muted-foreground hover:text-foreground",
|
||||
"bg-background border border-border"
|
||||
)}
|
||||
aria-expanded={isExpanded}
|
||||
aria-label={t('toggle_thread')}
|
||||
>
|
||||
{isLoading ? (
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
<Loader2 className="w-3 h-3 animate-spin" />
|
||||
) : isExpanded ? (
|
||||
<ChevronDown className="w-4 h-4" />
|
||||
<ChevronDown className="w-3 h-3" />
|
||||
) : (
|
||||
<ChevronRight className="w-4 h-4" />
|
||||
<ChevronRight className="w-3 h-3" />
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
|
||||
{hasUnread && (
|
||||
<div className="absolute left-1 top-1/2 -translate-y-1/2">
|
||||
<Circle className="w-2 h-2 fill-unread text-unread" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{density !== 'extra-compact' && (
|
||||
<Avatar
|
||||
name={avatarPerson?.name}
|
||||
email={avatarPerson?.email}
|
||||
size={isFocusedMailLayout ? "sm" : "md"}
|
||||
className="flex-shrink-0 shadow-sm"
|
||||
disableImages={hideJunkAvatarImages}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="flex-1 min-w-0">
|
||||
{isFocusedMailLayout ? (
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
@@ -652,13 +691,13 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
|
||||
</span>
|
||||
<div className="flex min-w-0 flex-1 items-center gap-2 text-sm">
|
||||
<span className={cn(
|
||||
'shrink-0 truncate',
|
||||
'min-w-0 truncate',
|
||||
hasUnread ? 'font-semibold text-foreground' : 'text-foreground/90'
|
||||
)}>
|
||||
{latestEmail.subject || '(no subject)'}
|
||||
</span>
|
||||
{inlinePreview && (
|
||||
<span className="min-w-0 truncate text-muted-foreground">{inlinePreview}</span>
|
||||
<span className="min-w-0 shrink-[9999] truncate text-muted-foreground">{inlinePreview}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
@@ -676,6 +715,7 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
|
||||
{keywordDef && (
|
||||
<span className={cn('h-2.5 w-2.5 rounded-full', KEYWORD_PALETTE[keywordDef.color]?.dot || 'bg-gray-400')} />
|
||||
)}
|
||||
{showSourceFolder && <SourceFolderTag name={latestEmail.sourceFolder!} />}
|
||||
{scheduledSendLabel ? (
|
||||
<span
|
||||
className="inline-flex max-w-[11rem] shrink-0 items-center gap-1 truncate rounded-full border border-sky-500/20 bg-sky-500/10 px-2 py-0.5 text-xs font-medium tabular-nums text-sky-700 dark:text-sky-300"
|
||||
@@ -756,6 +796,7 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
|
||||
{keywordDef.label}
|
||||
</span>
|
||||
)}
|
||||
{showSourceFolder && <SourceFolderTag name={latestEmail.sourceFolder!} />}
|
||||
{scheduledSendLabel ? (
|
||||
<span
|
||||
className="inline-flex max-w-[11rem] shrink-0 items-center gap-1 truncate rounded-full border border-sky-500/20 bg-sky-500/10 px-2 py-0.5 text-[11px] font-medium tabular-nums text-sky-700 dark:text-sky-300"
|
||||
@@ -812,6 +853,8 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
|
||||
onArchive={onArchive ? () => onArchive(latestEmail) : undefined}
|
||||
onSetColorTag={onSetColorTag ? (color) => onSetColorTag(latestEmail.id, color) : undefined}
|
||||
onMarkAsSpam={onMarkAsSpam ? () => onMarkAsSpam(latestEmail) : undefined}
|
||||
onUndoSpam={onUndoSpam ? () => onUndoSpam(latestEmail) : undefined}
|
||||
isInJunk={currentMailboxRole === 'junk'}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -9,6 +9,7 @@ import { ConfirmDialog } from '@/components/ui/confirm-dialog';
|
||||
import { IdentityForm } from './identity-form';
|
||||
import { useIdentityStore } from '@/stores/identity-store';
|
||||
import { useAuthStore } from '@/stores/auth-store';
|
||||
import { useSettingsStore } from '@/stores/settings-store';
|
||||
|
||||
function useSyncIdentities() {
|
||||
const syncIdentities = useAuthStore((state) => state.syncIdentities);
|
||||
@@ -206,6 +207,17 @@ export function IdentityManagerModal({ isOpen, onClose }: IdentityManagerModalPr
|
||||
|
||||
const handleSetPrimary = useCallback((identity: Identity) => {
|
||||
setPreferredPrimary(identity.id);
|
||||
// Persist to the synced settings (keyed by username, matching how
|
||||
// loadIdentities reads it back) so the choice survives a new browser /
|
||||
// cleared site data and reaches other devices (#507).
|
||||
const username = useAuthStore.getState().username || '';
|
||||
if (username) {
|
||||
const current = useSettingsStore.getState().preferredIdentityIds;
|
||||
useSettingsStore.getState().updateSetting('preferredIdentityIds', {
|
||||
...current,
|
||||
[username]: identity.id,
|
||||
});
|
||||
}
|
||||
// Re-sort: move the preferred identity to the front
|
||||
const reordered = [identity, ...identities.filter((id) => id.id !== identity.id)];
|
||||
useIdentityStore.getState().setIdentities(reordered);
|
||||
|
||||
@@ -166,7 +166,6 @@ export function NavigationRail({
|
||||
collapsed = false,
|
||||
className,
|
||||
quota,
|
||||
isPushConnected,
|
||||
onLogout,
|
||||
onShowShortcuts,
|
||||
onManageApps,
|
||||
@@ -191,6 +190,7 @@ export function NavigationRail({
|
||||
const sidebarAppsEnabled = usePolicyStore((s) => s.isFeatureEnabled('sidebarAppsEnabled'));
|
||||
const filesEnabled = usePolicyStore((s) => s.isFeatureEnabled('filesEnabled'));
|
||||
const contactsEnabled = usePolicyStore((s) => s.isFeatureEnabled('contactsEnabled'));
|
||||
const calendarEnabled = usePolicyStore((s) => s.isFeatureEnabled('calendarEnabled'));
|
||||
const visibleSidebarApps = sidebarAppsEnabled ? sidebarApps : [];
|
||||
const inboxUnread = mailboxes.find(m => m.role === "inbox")?.unreadEmails || 0;
|
||||
const [isStalwartAdmin, setIsStalwartAdmin] = useState(false);
|
||||
@@ -269,7 +269,7 @@ export function NavigationRail({
|
||||
|
||||
const navItems: NavItem[] = [
|
||||
{ id: "mail", icon: Mail, labelKey: "mail", href: "/", badge: inboxUnread },
|
||||
{ id: "calendar", icon: Calendar, labelKey: "calendar", href: "/calendar", hidden: !supportsCalendar },
|
||||
{ id: "calendar", icon: Calendar, labelKey: "calendar", href: "/calendar", hidden: !supportsCalendar || !calendarEnabled },
|
||||
{ id: "contacts", icon: BookUser, labelKey: "contacts", href: "/contacts", hidden: !supportsContacts || !contactsEnabled },
|
||||
{ id: "files", icon: HardDrive, labelKey: "files", href: "/files", hidden: !supportsFiles || !filesEnabled },
|
||||
];
|
||||
|
||||
@@ -34,13 +34,15 @@ import {
|
||||
CalendarClock,
|
||||
BellOff,
|
||||
Mails,
|
||||
MailOpen,
|
||||
} from "lucide-react";
|
||||
import { cn, buildMailboxTree, MailboxNode } from "@/lib/utils";
|
||||
import { localizeMailboxName } from "@/lib/mailbox-label";
|
||||
import { Mailbox } from "@/lib/jmap/types";
|
||||
import { useContextMenu } from "@/hooks/use-context-menu";
|
||||
import { MailboxContextMenu, type MailboxContextTarget } from "./mailbox-context-menu";
|
||||
import { useAccountStore } from '@/stores/account-store';
|
||||
import { UNIFIED_MAILBOX_IDS } from '@/lib/jmap/types';
|
||||
import { UNIFIED_MAILBOX_IDS, CROSS_VIEW_IDS } from '@/lib/jmap/types';
|
||||
import type { UnifiedMailboxRole } from '@/lib/jmap/types';
|
||||
import { useDragDropContext } from "@/contexts/drag-drop-context";
|
||||
import { useMailboxDrop } from "@/hooks/use-mailbox-drop";
|
||||
@@ -79,6 +81,12 @@ interface SidebarProps {
|
||||
showScheduledMailbox?: boolean;
|
||||
/** Gated "All Mail" virtual folder that merges all of the account's folders. */
|
||||
showAllMailMailbox?: boolean;
|
||||
/** Gated cross-account views in the "All accounts" section. */
|
||||
showCrossUnread?: boolean;
|
||||
showCrossStarred?: boolean;
|
||||
showCrossAll?: boolean;
|
||||
/** Unread total across all cross-view folders (badge for unread/all). */
|
||||
crossUnreadCount?: number;
|
||||
className?: string;
|
||||
/**
|
||||
* Multi-account (Pro) mode props. When `multiAccountMode` is true, the
|
||||
@@ -169,7 +177,6 @@ function getIconClass(isSelected: boolean, isVirtual: boolean, colorful: boolean
|
||||
function SidebarRowCounts({
|
||||
unread,
|
||||
total,
|
||||
isSelected,
|
||||
onUnreadClick,
|
||||
}: {
|
||||
unread?: number;
|
||||
@@ -177,8 +184,9 @@ function SidebarRowCounts({
|
||||
isSelected: boolean;
|
||||
onUnreadClick?: () => void;
|
||||
}) {
|
||||
const showFolderTotalCount = useSettingsStore(s => s.showFolderTotalCount);
|
||||
const unreadCount = unread ?? 0;
|
||||
const totalCount = total ?? 0;
|
||||
const totalCount = showFolderTotalCount ? (total ?? 0) : 0;
|
||||
|
||||
if (unreadCount === 0 && totalCount === 0) return null;
|
||||
|
||||
@@ -212,7 +220,7 @@ function SidebarRowCounts({
|
||||
) : null;
|
||||
|
||||
return (
|
||||
<span className="ml-2 flex-shrink-0 flex items-baseline gap-1" title={`${unreadCount} unread / ${totalCount} total`}>
|
||||
<span className="ml-2 flex-shrink-0 flex items-baseline gap-1" title={totalCount > 0 ? `${unreadCount} unread / ${totalCount} total` : `${unreadCount} unread`}>
|
||||
{unreadNode}
|
||||
{unreadCount > 0 && totalCount > 0 && (
|
||||
<span className="text-xs text-muted-foreground/60">/</span>
|
||||
@@ -433,12 +441,14 @@ function MailboxTreeItem({
|
||||
onContextMenu?: (e: React.MouseEvent, node: MailboxNode) => void;
|
||||
}) {
|
||||
const tNotifications = useTranslations('notifications');
|
||||
const tSidebar = useTranslations('sidebar');
|
||||
const hasChildren = node.children.length > 0;
|
||||
const isExpanded = expandedFolders.has(node.id);
|
||||
const Icon = getIconForMailbox(node.role, node.name, hasChildren, isExpanded, node.isShared, node.id);
|
||||
const isVirtualNode = node.id.startsWith('shared-');
|
||||
const isSelected = selectedMailbox === node.id;
|
||||
const roleKey = resolveRoleKey(node.role, node.name);
|
||||
const label = localizeMailboxName(node.role, node.name, (k) => tSidebar(`mailboxes.${k}`));
|
||||
|
||||
const { isDragging: globalDragging } = useDragDropContext();
|
||||
const { dropHandlers, isValidDropTarget, isInvalidDropTarget } = useMailboxDrop({
|
||||
@@ -465,7 +475,7 @@ function MailboxTreeItem({
|
||||
<>
|
||||
<SidebarRow
|
||||
icon={<Icon className={getIconClass(isSelected, isVirtualNode, colorful, roleKey)} />}
|
||||
label={node.name}
|
||||
label={label}
|
||||
depth={node.depth}
|
||||
isSelected={isSelected}
|
||||
isVirtual={isVirtualNode}
|
||||
@@ -682,6 +692,10 @@ export function Sidebar({
|
||||
scheduledTotal = 0,
|
||||
showScheduledMailbox = false,
|
||||
showAllMailMailbox = false,
|
||||
showCrossUnread = false,
|
||||
showCrossStarred = false,
|
||||
showCrossAll = false,
|
||||
crossUnreadCount = 0,
|
||||
className,
|
||||
multiAccountMode = false,
|
||||
accountMailboxes,
|
||||
@@ -797,8 +811,14 @@ export function Sidebar({
|
||||
});
|
||||
};
|
||||
|
||||
// When the app renders its own virtual "Scheduled" folder (for delayed
|
||||
// sends, driven by EmailSubmission), hide the server-provided scheduled
|
||||
// mailbox (e.g. Stalwart's auto-created Scheduled folder, role === 'scheduled')
|
||||
// so it does not appear twice. (#495)
|
||||
const isServerScheduledNode = (n: MailboxNode) => showScheduledMailbox && n.role === 'scheduled';
|
||||
|
||||
const mailboxTree = buildMailboxTree(mailboxes);
|
||||
const ownTree = mailboxTree.filter(n => !n.id.startsWith('shared-account-'));
|
||||
const ownTree = mailboxTree.filter(n => !n.id.startsWith('shared-account-') && !isServerScheduledNode(n));
|
||||
const sharedAccounts = mailboxTree.filter(n => n.id.startsWith('shared-account-'));
|
||||
|
||||
// Multi-account mode (Pro shell): render every connected account as its
|
||||
@@ -813,7 +833,7 @@ export function Sidebar({
|
||||
? mailboxes
|
||||
: (accountMailboxes?.[account.id] ?? []);
|
||||
const tree = buildMailboxTree(accountMailboxList).filter(
|
||||
(n) => !n.id.startsWith('shared-account-')
|
||||
(n) => !n.id.startsWith('shared-account-') && !(isActive && isServerScheduledNode(n))
|
||||
);
|
||||
return { account, isActive, tree };
|
||||
})
|
||||
@@ -992,7 +1012,7 @@ export function Sidebar({
|
||||
isCollapsed={isCollapsed}
|
||||
/>
|
||||
)}
|
||||
{showUnified && (
|
||||
{(showUnified || showCrossUnread || showCrossStarred || showCrossAll) && (
|
||||
<div>
|
||||
<SidebarSectionHeader
|
||||
label={t("all_accounts")}
|
||||
@@ -1003,7 +1023,7 @@ export function Sidebar({
|
||||
/>
|
||||
{((unifiedExpanded && !isCollapsed) || isCollapsed) && (
|
||||
<>
|
||||
{unifiedCounts.map((count) => {
|
||||
{showUnified && unifiedCounts.map((count) => {
|
||||
const unifiedId = UNIFIED_MAILBOX_IDS[count.role];
|
||||
const Icon = getUnifiedIcon(count.role);
|
||||
const isSelected = !selectedKeyword && selectedMailbox === unifiedId;
|
||||
@@ -1021,6 +1041,26 @@ export function Sidebar({
|
||||
/>
|
||||
);
|
||||
})}
|
||||
{[
|
||||
{ show: showCrossUnread, id: CROSS_VIEW_IDS.unread, Icon: MailOpen, label: t('unified_all_unread'), unread: crossUnreadCount },
|
||||
{ show: showCrossStarred, id: CROSS_VIEW_IDS.starred, Icon: Star, label: t('unified_all_starred'), unread: undefined as number | undefined },
|
||||
{ show: showCrossAll, id: CROSS_VIEW_IDS.all, Icon: Mails, label: t('unified_all_mail'), unread: crossUnreadCount },
|
||||
].map(({ show, id, Icon, label, unread }) => {
|
||||
if (!show) return null;
|
||||
const isSelected = !selectedKeyword && selectedMailbox === id;
|
||||
return (
|
||||
<SidebarRow
|
||||
key={id}
|
||||
icon={<Icon className={getIconClass(isSelected, false, colorfulSidebarIcons)} />}
|
||||
label={label}
|
||||
depth={0}
|
||||
isSelected={isSelected}
|
||||
unread={unread}
|
||||
onClick={() => onMailboxSelect?.(id)}
|
||||
isCollapsed={isCollapsed}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -52,6 +52,7 @@ export function PluginIframeSlot({ pluginId, slot, extraProps }: Props) {
|
||||
slot,
|
||||
code: active.code,
|
||||
locale,
|
||||
tier: active.tier,
|
||||
extraProps: extraProps ?? {},
|
||||
hostContainer: wrapperRef.current,
|
||||
onResize: (h) => setHeight(h),
|
||||
|
||||
@@ -25,11 +25,9 @@ export function ProComposeTabBody({ tabId, data }: ProComposeTabBodyProps) {
|
||||
const t = useTranslations();
|
||||
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);
|
||||
const isScheduledView = useEmailStore((s) => s.isScheduledView);
|
||||
const closeTab = useProTabStore((s) => s.closeTab);
|
||||
const updateTabTitle = useProTabStore((s) => s.updateTabTitle);
|
||||
@@ -116,7 +114,7 @@ export function ProComposeTabBody({ tabId, data }: ProComposeTabBodyProps) {
|
||||
console.error('Failed to send email:', error);
|
||||
toast.error(t('notifications.error_sending'));
|
||||
}
|
||||
}, [client, sendEmail, fetchEmails, selectedMailbox, closeTab, data.sourceEmailId, data.mode, t, handleScheduledSendCreated]);
|
||||
}, [client, sendEmail, closeTab, data.sourceEmailId, data.mode, t, handleScheduledSendCreated, refreshCurrentMailbox]);
|
||||
|
||||
const handleClose = useCallback(() => {
|
||||
closeTab(tabIdRef.current);
|
||||
|
||||
@@ -8,6 +8,7 @@ import daMessages from '@/locales/da/common.json';
|
||||
import deMessages from '@/locales/de/common.json';
|
||||
import enMessages from '@/locales/en/common.json';
|
||||
import esMessages from '@/locales/es/common.json';
|
||||
import faMessages from '@/locales/fa/common.json';
|
||||
import frMessages from '@/locales/fr/common.json';
|
||||
import huMessages from '@/locales/hu/common.json';
|
||||
import itMessages from '@/locales/it/common.json';
|
||||
@@ -17,6 +18,7 @@ import lvMessages from '@/locales/lv/common.json';
|
||||
import nlMessages from '@/locales/nl/common.json';
|
||||
import plMessages from '@/locales/pl/common.json';
|
||||
import ptMessages from '@/locales/pt/common.json';
|
||||
import roMessages from '@/locales/ro/common.json';
|
||||
import ruMessages from '@/locales/ru/common.json';
|
||||
import trMessages from '@/locales/tr/common.json';
|
||||
import ukMessages from '@/locales/uk/common.json';
|
||||
@@ -29,6 +31,7 @@ const ALL_MESSAGES = {
|
||||
de: deMessages,
|
||||
en: enMessages,
|
||||
es: esMessages,
|
||||
fa: faMessages,
|
||||
fr: frMessages,
|
||||
hu: huMessages,
|
||||
it: itMessages,
|
||||
@@ -38,6 +41,7 @@ const ALL_MESSAGES = {
|
||||
nl: nlMessages,
|
||||
pl: plMessages,
|
||||
pt: ptMessages,
|
||||
ro: roMessages,
|
||||
ru: ruMessages,
|
||||
tr: trMessages,
|
||||
uk: ukMessages,
|
||||
@@ -88,7 +92,7 @@ export function IntlProvider({ locale: initialLocale, children }: IntlProviderPr
|
||||
return (
|
||||
<NextIntlClientProvider
|
||||
locale={activeLocale}
|
||||
messages={ALL_MESSAGES[activeLocale as keyof typeof ALL_MESSAGES]}
|
||||
messages={ALL_MESSAGES[activeLocale as keyof typeof ALL_MESSAGES] ?? ALL_MESSAGES.en}
|
||||
timeZone={timeZone}
|
||||
>
|
||||
{children}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { Book, Pencil, Share2, Tag, Users } from "lucide-react";
|
||||
import { Book, BookPlus, 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";
|
||||
@@ -11,6 +11,7 @@ import { SettingsSection } from "./settings-section";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { AddressBook, AddressBookRights } from "@/lib/jmap/types";
|
||||
import { ShareCollectionDialog } from "./share-collection-dialog";
|
||||
import { RenameDialog } from "@/components/files/rename-dialog";
|
||||
|
||||
function AddressBookEditRow({
|
||||
initial,
|
||||
@@ -73,10 +74,11 @@ export function AddressBookManagementSettings() {
|
||||
const tSettings = useTranslations("settings.contacts");
|
||||
const { client } = useAuthStore();
|
||||
const managedAccountId = useManagedAccountStore((s) => s.managedAccountId);
|
||||
const { addressBooks, contacts, supportsSync, fetchAddressBooks, renameAddressBook, shareAddressBook, renameKeyword } = useContactStore();
|
||||
const { addressBooks, contacts, supportsSync, fetchAddressBooks, createAddressBook, renameAddressBook, shareAddressBook, renameKeyword } = useContactStore();
|
||||
const [editingId, setEditingId] = useState<string | null>(null);
|
||||
const [editingKeyword, setEditingKeyword] = useState<string | null>(null);
|
||||
const [sharingId, setSharingId] = useState<string | null>(null);
|
||||
const [creating, setCreating] = useState(false);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -85,6 +87,21 @@ export function AddressBookManagementSettings() {
|
||||
}
|
||||
}, [client, addressBooks.length, fetchAddressBooks]);
|
||||
|
||||
const handleCreate = async (name: string) => {
|
||||
if (!client) return;
|
||||
setIsLoading(true);
|
||||
try {
|
||||
await createAddressBook(client, name);
|
||||
await fetchAddressBooks(client);
|
||||
setCreating(false);
|
||||
toast.success(t("created"));
|
||||
} catch {
|
||||
toast.error(t("create_failed"));
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleUpdate = async (book: AddressBook, newName: string) => {
|
||||
if (!client) return;
|
||||
setIsLoading(true);
|
||||
@@ -226,6 +243,19 @@ export function AddressBookManagementSettings() {
|
||||
{addressBooks.length === 0 && (
|
||||
<p className="text-sm text-muted-foreground py-2">{tSettings("no_address_books")}</p>
|
||||
)}
|
||||
|
||||
{/* Creating targets the user's own account, so hide it while scoped to a
|
||||
managed (shared) account. */}
|
||||
{!managedAccountId && client && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setCreating(true)}
|
||||
className="flex items-center gap-2 py-2.5 px-3 w-full rounded-md border border-dashed border-border text-sm text-muted-foreground hover:text-foreground hover:bg-muted transition-colors"
|
||||
>
|
||||
<BookPlus className="w-4 h-4 flex-shrink-0" />
|
||||
{t("create")}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</SettingsSection>
|
||||
|
||||
@@ -278,6 +308,16 @@ export function AddressBookManagementSettings() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{creating && (
|
||||
<RenameDialog
|
||||
currentName=""
|
||||
title={t("create")}
|
||||
label={t("name_label")}
|
||||
onCancel={() => setCreating(false)}
|
||||
onConfirm={handleCreate}
|
||||
/>
|
||||
)}
|
||||
|
||||
{sharingId && client && (() => {
|
||||
const book = addressBooks.find((b) => b.id === sharingId);
|
||||
if (!book) return null;
|
||||
|
||||
@@ -160,6 +160,7 @@ export function FilterSettings() {
|
||||
const tNotifications = useTranslations("notifications");
|
||||
const { client } = useAuthStore();
|
||||
const storeMailboxes = useEmailStore((s) => s.mailboxes);
|
||||
const fetchMailboxes = useEmailStore((s) => s.fetchMailboxes);
|
||||
const expandedFilterView = useSettingsStore((s) => s.expandedFilterView);
|
||||
const updateSetting = useSettingsStore((s) => s.updateSetting);
|
||||
|
||||
@@ -212,6 +213,22 @@ export function FilterSettings() {
|
||||
return () => { cancelled = true; };
|
||||
}, [client, managedAccountId]);
|
||||
|
||||
// The "move to" folder list for the primary account comes from the email
|
||||
// store, which is normally populated when the mail view mounts. When the app
|
||||
// is opened or refreshed directly on Settings (the mail view never mounted),
|
||||
// that store is empty, leaving the rule editor's folder dropdown blank. Fetch
|
||||
// mailboxes on demand here so Filters never depends on having visited Inbox
|
||||
// first. fetchMailboxes guards against transient empty results and selecting
|
||||
// an inbox, so it's safe to call independently; a ref keeps it to one attempt
|
||||
// per client.
|
||||
const primaryFetchClientRef = useRef<typeof client | null>(null);
|
||||
useEffect(() => {
|
||||
if (managedAccountId || !client || storeMailboxes.length > 0) return;
|
||||
if (primaryFetchClientRef.current === client) return;
|
||||
primaryFetchClientRef.current = client;
|
||||
void fetchMailboxes(client);
|
||||
}, [client, managedAccountId, storeMailboxes.length, fetchMailboxes]);
|
||||
|
||||
const mailboxes = managedAccountId ? scopedMailboxes : storeMailboxes;
|
||||
|
||||
const vacationStoreEnabled = useVacationStore((s) => s.isEnabled);
|
||||
|
||||
@@ -118,27 +118,46 @@ function MailLayoutPreview({
|
||||
export function LayoutSettings() {
|
||||
const t = useTranslations('settings.appearance');
|
||||
const tEmail = useTranslations('settings.email_behavior');
|
||||
const { toolbarPosition, showToolbarLabels, hideAccountSwitcher, showRailAccountList, enableUnifiedMailbox, includeGroupInUnified, enableAllMailView, allMailFolderIds, colorfulSidebarIcons, mailLayout, proInterface, updateSetting } = useSettingsStore();
|
||||
const { toolbarPosition, showToolbarLabels, hideAccountSwitcher, showRailAccountList, enableUnifiedMailbox, includeGroupInUnified, enableAllMailView, allMailFolderIds, enableCrossUnreadView, enableCrossStarredView, enableCrossAllView, colorfulSidebarIcons, showFolderTotalCount, mailLayout, proInterface, updateSetting } = useSettingsStore();
|
||||
const { isSettingLocked, isSettingHidden, isFeatureEnabled } = usePolicyStore();
|
||||
const accounts = useAccountStore(s => s.accounts);
|
||||
const activeAccountId = useAccountStore(s => s.activeAccountId);
|
||||
const mailboxes = useEmailStore(s => s.mailboxes);
|
||||
const hasGroupInboxes = useMemo(() => mailboxes.some(m => m.isShared), [mailboxes]);
|
||||
const allMailViewAllowed = isFeatureEnabled('allMailViewEnabled');
|
||||
// Cross-account "All accounts" views, each gated independently by the admin.
|
||||
const crossViews = [
|
||||
{ setting: 'enableCrossUnreadView', value: enableCrossUnreadView, allowed: isFeatureEnabled('crossUnreadViewEnabled'), labelKey: 'cross_unread.label', descKey: 'cross_unread.description' },
|
||||
{ setting: 'enableCrossStarredView', value: enableCrossStarredView, allowed: isFeatureEnabled('crossStarredViewEnabled'), labelKey: 'cross_starred.label', descKey: 'cross_starred.description' },
|
||||
{ setting: 'enableCrossAllView', value: enableCrossAllView, allowed: isFeatureEnabled('crossAllViewEnabled'), labelKey: 'cross_all.label', descKey: 'cross_all.description' },
|
||||
] as const;
|
||||
|
||||
// Own (non-shared) folders and the current All Mail selection. `null` =
|
||||
// never configured, which defaults to all non-special (no-role) folders.
|
||||
// Own (non-shared) folders and the active account's All Mail selection. The
|
||||
// selection is per account: a missing entry = never configured, which
|
||||
// defaults to all no-role folders; an explicit [] = no folders.
|
||||
const ownMailboxes = useMemo(() => mailboxes.filter(m => !m.isShared), [mailboxes]);
|
||||
const currentAllMailEntry = activeAccountId ? allMailFolderIds[activeAccountId] : undefined;
|
||||
const allMailSelected = new Set(
|
||||
allMailFolderIds === null
|
||||
currentAllMailEntry === undefined
|
||||
? ownMailboxes.filter(m => !m.role).map(m => m.id)
|
||||
: allMailFolderIds
|
||||
: currentAllMailEntry
|
||||
);
|
||||
const toggleAllMailFolder = (id: string) => {
|
||||
if (!activeAccountId) return;
|
||||
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));
|
||||
updateSetting('allMailFolderIds', {
|
||||
...allMailFolderIds,
|
||||
[activeAccountId]: ownMailboxes.filter(m => next.has(m.id)).map(m => m.id),
|
||||
});
|
||||
};
|
||||
// Name the account the selection applies to, but only when more than one is
|
||||
// logged in (otherwise it's unambiguous).
|
||||
const activeAccount = accounts.find(a => a.id === activeAccountId);
|
||||
const allMailAccountHint = accounts.length > 1 && activeAccount
|
||||
? t('all_mail.account_hint', { account: activeAccount.displayName || activeAccount.email })
|
||||
: null;
|
||||
|
||||
return (
|
||||
<SettingsSection title={t('title')} description={t('description')}>
|
||||
@@ -198,6 +217,13 @@ export function LayoutSettings() {
|
||||
/>
|
||||
</SettingItem>
|
||||
|
||||
<SettingItem label={t('show_folder_total_count.label')} description={t('show_folder_total_count.description')}>
|
||||
<ToggleSwitch
|
||||
checked={showFolderTotalCount}
|
||||
onChange={(checked) => updateSetting('showFolderTotalCount', checked)}
|
||||
/>
|
||||
</SettingItem>
|
||||
|
||||
{(accounts.length > 1 || hasGroupInboxes) && !isSettingHidden('enableUnifiedMailbox') && (
|
||||
<SettingItem
|
||||
label={t('unified_mailbox.label')}
|
||||
@@ -226,6 +252,26 @@ export function LayoutSettings() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{enableUnifiedMailbox && crossViews.some(c => c.allowed) && (
|
||||
<div className="ml-4 border-l-2 border-border pl-4 -mt-2 space-y-2">
|
||||
{crossViews.map(({ setting, value, allowed, labelKey, descKey }) => (
|
||||
allowed && !isSettingHidden(setting) && (
|
||||
<SettingItem
|
||||
key={setting}
|
||||
label={t(labelKey)}
|
||||
description={t(descKey)}
|
||||
locked={isSettingLocked(setting)}
|
||||
>
|
||||
<ToggleSwitch
|
||||
checked={value}
|
||||
onChange={(v) => updateSetting(setting, v)}
|
||||
/>
|
||||
</SettingItem>
|
||||
)
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{allMailViewAllowed && !isSettingHidden('enableAllMailView') && (
|
||||
<SettingItem
|
||||
label={t('all_mail.label')}
|
||||
@@ -244,6 +290,9 @@ export function LayoutSettings() {
|
||||
<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>
|
||||
{allMailAccountHint && (
|
||||
<div className="text-xs italic text-muted-foreground mt-0.5">{allMailAccountHint}</div>
|
||||
)}
|
||||
</div>
|
||||
{ownMailboxes.length === 0 ? (
|
||||
<p className="text-xs text-muted-foreground">{t('all_mail.no_folders')}</p>
|
||||
|
||||
@@ -22,6 +22,7 @@ export function ReadingSettings() {
|
||||
markAsReadDelay,
|
||||
deleteAction,
|
||||
permanentlyDeleteJunk,
|
||||
returnToListAfterAction,
|
||||
showPreview,
|
||||
mailLayout,
|
||||
disableThreading,
|
||||
@@ -176,6 +177,13 @@ export function ReadingSettings() {
|
||||
/>
|
||||
</SettingItem>
|
||||
|
||||
<SettingItem label={t('return_to_list_after_action.label')} description={t('return_to_list_after_action.description')}>
|
||||
<ToggleSwitch
|
||||
checked={returnToListAfterAction}
|
||||
onChange={(checked) => updateSetting('returnToListAfterAction', checked)}
|
||||
/>
|
||||
</SettingItem>
|
||||
|
||||
{!isSettingHidden('showPreview') && (
|
||||
<SettingItem
|
||||
label={t('show_preview.label')}
|
||||
|
||||
@@ -1,117 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useId } from "react";
|
||||
import { useFocusTrap } from "@/hooks/use-focus-trap";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ShieldCheck, X } from "lucide-react";
|
||||
import type { SmimeKeyRecord, SmimePublicCert } from "@/lib/smime/types";
|
||||
|
||||
interface SmimeCertificateModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
record: SmimeKeyRecord | SmimePublicCert | null;
|
||||
type: "private" | "public";
|
||||
}
|
||||
|
||||
export function SmimeCertificateModal({
|
||||
isOpen,
|
||||
onClose,
|
||||
record,
|
||||
type: _type,
|
||||
}: SmimeCertificateModalProps) {
|
||||
const t = useTranslations("smime");
|
||||
const id = useId();
|
||||
|
||||
const dialogRef = useFocusTrap({
|
||||
isActive: isOpen,
|
||||
onEscape: onClose,
|
||||
restoreFocus: true,
|
||||
});
|
||||
|
||||
if (!isOpen || !record) return null;
|
||||
|
||||
const isExpired = new Date(record.notAfter) < new Date();
|
||||
const isNotYetValid = new Date(record.notBefore) > new Date();
|
||||
|
||||
const rows: { label: string; value: string }[] = [
|
||||
{ label: t("cert_subject"), value: record.subject ?? "" },
|
||||
{ label: t("cert_issuer"), value: record.issuer ?? "" },
|
||||
{ label: t("cert_email"), value: record.email },
|
||||
{
|
||||
label: t("cert_validity"),
|
||||
value: `${new Date(record.notBefore).toLocaleDateString()} - ${new Date(record.notAfter).toLocaleDateString()}`,
|
||||
},
|
||||
{ label: t("cert_fingerprint"), value: record.fingerprint },
|
||||
];
|
||||
|
||||
if ("serialNumber" in record) {
|
||||
rows.splice(2, 0, { label: t("cert_serial"), value: record.serialNumber });
|
||||
}
|
||||
|
||||
if ("algorithm" in record) {
|
||||
rows.push({ label: t("cert_algorithm"), value: record.algorithm });
|
||||
}
|
||||
|
||||
if ("capabilities" in record) {
|
||||
const caps: string[] = [];
|
||||
if (record.capabilities.canSign) caps.push(t("cap_sign"));
|
||||
if (record.capabilities.canEncrypt) caps.push(t("cap_encrypt"));
|
||||
rows.push({ label: t("cert_capabilities"), value: caps.join(", ") || t("cap_none") });
|
||||
}
|
||||
|
||||
if ("source" in record) {
|
||||
rows.push({ label: t("cert_source"), value: record.source });
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black/50 backdrop-blur-[1px] flex items-center justify-center z-[60] p-4 animate-in fade-in duration-150">
|
||||
<div
|
||||
ref={dialogRef}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby={`${id}-title`}
|
||||
className="bg-background border border-border rounded-lg shadow-xl w-full max-w-lg animate-in zoom-in-95 duration-200"
|
||||
>
|
||||
<div className="flex items-center justify-between p-6 pb-4 border-b border-border">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-9 h-9 rounded-full bg-primary/10 flex items-center justify-center">
|
||||
<ShieldCheck className="w-5 h-5 text-primary" />
|
||||
</div>
|
||||
<h2 id={`${id}-title`} className="text-lg font-semibold text-foreground">
|
||||
{t("certificate_details")}
|
||||
</h2>
|
||||
</div>
|
||||
<Button variant="ghost" size="icon" onClick={onClose}>
|
||||
<X className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="p-6 space-y-3 max-h-[60vh] overflow-y-auto">
|
||||
{(isExpired || isNotYetValid) && (
|
||||
<div className="px-3 py-2 rounded-md bg-destructive/10 text-destructive text-sm">
|
||||
{isExpired ? t("cert_expired") : t("cert_not_yet_valid")}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{rows.map(({ label, value }) => (
|
||||
<div key={label}>
|
||||
<dt className="text-xs font-medium text-muted-foreground uppercase tracking-wide">
|
||||
{label}
|
||||
</dt>
|
||||
<dd className="text-sm text-foreground mt-0.5 break-all font-mono">
|
||||
{value}
|
||||
</dd>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end px-6 pb-6">
|
||||
<Button variant="ghost" onClick={onClose}>
|
||||
{t("close")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,169 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useId } from "react";
|
||||
import { useFocusTrap } from "@/hooks/use-focus-trap";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { KeyRound, Eye, EyeOff } from "lucide-react";
|
||||
|
||||
interface SmimePassphraseDialogProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onSubmit: (passphrase: string) => void | Promise<void>;
|
||||
title: string;
|
||||
description?: string;
|
||||
submitText?: string;
|
||||
error?: string | null;
|
||||
/** Show a second passphrase field for import/export confirmation. */
|
||||
showConfirm?: boolean;
|
||||
}
|
||||
|
||||
export function SmimePassphraseDialog({
|
||||
isOpen,
|
||||
onClose,
|
||||
onSubmit,
|
||||
title,
|
||||
description,
|
||||
submitText,
|
||||
error,
|
||||
showConfirm = false,
|
||||
}: SmimePassphraseDialogProps) {
|
||||
const t = useTranslations("smime");
|
||||
const id = useId();
|
||||
const [passphrase, setPassphrase] = useState("");
|
||||
const [confirm, setConfirm] = useState("");
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
|
||||
const dialogRef = useFocusTrap({
|
||||
isActive: isOpen,
|
||||
onEscape: onClose,
|
||||
restoreFocus: true,
|
||||
});
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
const mismatch = showConfirm && passphrase !== confirm && confirm.length > 0;
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!passphrase || (showConfirm && passphrase !== confirm)) return;
|
||||
setIsSubmitting(true);
|
||||
try {
|
||||
await onSubmit(passphrase);
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
setPassphrase("");
|
||||
setConfirm("");
|
||||
setShowPassword(false);
|
||||
onClose();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black/50 backdrop-blur-[1px] flex items-center justify-center z-[60] p-4 animate-in fade-in duration-150">
|
||||
<div
|
||||
ref={dialogRef}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby={`${id}-title`}
|
||||
aria-describedby={description ? `${id}-desc` : undefined}
|
||||
className="bg-background border border-border rounded-lg shadow-xl w-full max-w-md animate-in zoom-in-95 duration-200"
|
||||
>
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div className="p-6">
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="flex-shrink-0 w-10 h-10 rounded-full bg-primary/10 flex items-center justify-center">
|
||||
<KeyRound className="w-5 h-5 text-primary" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<h2
|
||||
id={`${id}-title`}
|
||||
className="text-lg font-semibold text-foreground"
|
||||
>
|
||||
{title}
|
||||
</h2>
|
||||
{description && (
|
||||
<p
|
||||
id={`${id}-desc`}
|
||||
className="text-sm text-muted-foreground mt-1"
|
||||
>
|
||||
{description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 space-y-3">
|
||||
<div className="relative">
|
||||
<Input
|
||||
type={showPassword ? "text" : "password"}
|
||||
value={passphrase}
|
||||
onChange={(e) => setPassphrase(e.target.value)}
|
||||
placeholder={t("passphrase_placeholder")}
|
||||
autoFocus
|
||||
className="pr-10"
|
||||
autoComplete="off"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowPassword(!showPassword)}
|
||||
className="absolute right-2 top-1/2 -translate-y-1/2 p-1 text-muted-foreground hover:text-foreground"
|
||||
aria-label={showPassword ? t("hide_passphrase") : t("show_passphrase")}
|
||||
>
|
||||
{showPassword ? (
|
||||
<EyeOff className="w-4 h-4" />
|
||||
) : (
|
||||
<Eye className="w-4 h-4" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{showConfirm && (
|
||||
<div>
|
||||
<Input
|
||||
type={showPassword ? "text" : "password"}
|
||||
value={confirm}
|
||||
onChange={(e) => setConfirm(e.target.value)}
|
||||
placeholder={t("confirm_passphrase_placeholder")}
|
||||
autoComplete="off"
|
||||
/>
|
||||
{mismatch && (
|
||||
<p className="text-xs text-destructive mt-1">
|
||||
{t("passphrase_mismatch")}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<p className="text-sm text-destructive">{error}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-2 px-6 pb-6">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
onClick={handleClose}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
{t("cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={!passphrase || isSubmitting || (showConfirm && passphrase !== confirm)}
|
||||
>
|
||||
{isSubmitting ? t("processing") : (submitText ?? t("unlock"))}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,549 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useRef } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import {
|
||||
Upload,
|
||||
Trash2,
|
||||
Eye,
|
||||
Lock,
|
||||
Unlock,
|
||||
Download,
|
||||
ShieldCheck,
|
||||
ShieldAlert,
|
||||
Users,
|
||||
} from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { SettingsSection, SettingItem, ToggleSwitch } from "@/components/settings/settings-section";
|
||||
import { SmimePassphraseDialog } from "@/components/settings/smime-passphrase-dialog";
|
||||
import { SmimeCertificateModal } from "@/components/settings/smime-certificate-modal";
|
||||
import { useSmimeStore } from "@/stores/smime-store";
|
||||
import { useIdentityStore } from "@/stores/identity-store";
|
||||
import { useAuthStore } from "@/stores/auth-store";
|
||||
import { exportPkcs12, downloadPkcs12 } from "@/lib/smime/pkcs12-export";
|
||||
import type { SmimeKeyRecord, SmimePublicCert } from "@/lib/smime/types";
|
||||
|
||||
export function SmimeSettings() {
|
||||
const t = useTranslations("smime");
|
||||
const {
|
||||
keyRecords,
|
||||
publicCerts,
|
||||
identityKeyBindings,
|
||||
defaultSignIdentity,
|
||||
defaultEncrypt,
|
||||
autoImportSignerCerts,
|
||||
isLoading,
|
||||
error,
|
||||
load,
|
||||
importPKCS12,
|
||||
removeKeyRecord,
|
||||
removePublicCert,
|
||||
bindIdentityToKey,
|
||||
unlockKey,
|
||||
lockKey,
|
||||
setSignDefault,
|
||||
setEncryptDefault,
|
||||
setAutoImportSignerCerts,
|
||||
isKeyUnlocked,
|
||||
setError,
|
||||
} = useSmimeStore();
|
||||
|
||||
const { identities } = useIdentityStore();
|
||||
const activeAccountId = useAuthStore((s) => s.activeAccountId);
|
||||
|
||||
// Local UI state
|
||||
const [importDialogOpen, setImportDialogOpen] = useState(false);
|
||||
const [unlockDialogOpen, setUnlockDialogOpen] = useState(false);
|
||||
const [unlockTargetId, setUnlockTargetId] = useState<string | null>(null);
|
||||
const [certModalRecord, setCertModalRecord] = useState<SmimeKeyRecord | SmimePublicCert | null>(null);
|
||||
const [certModalType, setCertModalType] = useState<"private" | "public">("private");
|
||||
const [importError, setImportError] = useState<string | null>(null);
|
||||
const [unlockError, setUnlockError] = useState<string | null>(null);
|
||||
const [pendingFile, setPendingFile] = useState<ArrayBuffer | null>(null);
|
||||
const [pendingP12Pass, setPendingP12Pass] = useState("");
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const pubCertInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
// State for the two-step PKCS#12 flow
|
||||
const [importStep, setImportStep] = useState<"p12" | "storage">("p12");
|
||||
|
||||
// Export flow state
|
||||
const [exportDialogOpen, setExportDialogOpen] = useState(false);
|
||||
const [exportTargetRecord, setExportTargetRecord] = useState<SmimeKeyRecord | null>(null);
|
||||
const [exportStep, setExportStep] = useState<"storage" | "export">("storage");
|
||||
const [exportStoragePass, setExportStoragePass] = useState("");
|
||||
const [exportError, setExportError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
load(activeAccountId ?? undefined);
|
||||
}, [load, activeAccountId]);
|
||||
|
||||
// ── PKCS#12 import flow ────────────────────────────────────────
|
||||
|
||||
const handleFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => {
|
||||
setPendingFile(reader.result as ArrayBuffer);
|
||||
setImportStep("p12");
|
||||
setImportError(null);
|
||||
setImportDialogOpen(true);
|
||||
};
|
||||
reader.readAsArrayBuffer(file);
|
||||
// Reset so same file can be re-selected
|
||||
e.target.value = "";
|
||||
};
|
||||
|
||||
const handleImportSubmit = async (passphrase: string) => {
|
||||
if (importStep === "p12") {
|
||||
setPendingP12Pass(passphrase);
|
||||
setImportStep("storage");
|
||||
setImportError(null);
|
||||
return;
|
||||
}
|
||||
|
||||
// Storage passphrase step
|
||||
if (!pendingFile) return;
|
||||
try {
|
||||
await importPKCS12(pendingFile, pendingP12Pass, passphrase);
|
||||
setImportDialogOpen(false);
|
||||
setPendingFile(null);
|
||||
setPendingP12Pass("");
|
||||
setImportError(null);
|
||||
} catch (err) {
|
||||
setImportError(err instanceof Error ? err.message : "Import failed");
|
||||
}
|
||||
};
|
||||
|
||||
// ── Public cert import ─────────────────────────────────────────
|
||||
|
||||
const handlePublicCertFile = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
const reader = new FileReader();
|
||||
reader.onload = async () => {
|
||||
try {
|
||||
const store = useSmimeStore.getState();
|
||||
await store.importPublicCert(reader.result as ArrayBuffer, "manual");
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Failed to import certificate");
|
||||
}
|
||||
};
|
||||
reader.readAsArrayBuffer(file);
|
||||
e.target.value = "";
|
||||
};
|
||||
|
||||
// ── Unlock ─────────────────────────────────────────────────────
|
||||
|
||||
const handleUnlockRequest = (id: string) => {
|
||||
setUnlockTargetId(id);
|
||||
setUnlockError(null);
|
||||
setUnlockDialogOpen(true);
|
||||
};
|
||||
|
||||
const handleUnlockSubmit = async (passphrase: string) => {
|
||||
if (!unlockTargetId) return;
|
||||
try {
|
||||
await unlockKey(unlockTargetId, passphrase);
|
||||
setUnlockDialogOpen(false);
|
||||
setUnlockTargetId(null);
|
||||
setUnlockError(null);
|
||||
} catch (err) {
|
||||
setUnlockError(err instanceof Error ? err.message : "Unlock failed");
|
||||
}
|
||||
};
|
||||
|
||||
// ── Export flow ────────────────────────────────────────────────
|
||||
|
||||
const handleExportRequest = (record: SmimeKeyRecord) => {
|
||||
setExportTargetRecord(record);
|
||||
setExportStep("storage");
|
||||
setExportStoragePass("");
|
||||
setExportError(null);
|
||||
setExportDialogOpen(true);
|
||||
};
|
||||
|
||||
const handleExportSubmit = async (passphrase: string) => {
|
||||
if (!exportTargetRecord) return;
|
||||
|
||||
if (exportStep === "storage") {
|
||||
// Verify storage passphrase by attempting to decrypt
|
||||
try {
|
||||
const { decryptPrivateKeyBytes } = await import("@/lib/smime/pkcs12-import");
|
||||
await decryptPrivateKeyBytes(exportTargetRecord, passphrase);
|
||||
setExportStoragePass(passphrase);
|
||||
setExportStep("export");
|
||||
setExportError(null);
|
||||
} catch {
|
||||
setExportError(t("incorrect_passphrase"));
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Export passphrase step
|
||||
try {
|
||||
const p12Bytes = await exportPkcs12(exportTargetRecord, exportStoragePass, passphrase);
|
||||
const filename = `${exportTargetRecord.email.replace(/[^a-zA-Z0-9.-]/g, '_')}.p12`;
|
||||
downloadPkcs12(p12Bytes, filename);
|
||||
setExportDialogOpen(false);
|
||||
setExportTargetRecord(null);
|
||||
setExportStoragePass("");
|
||||
setExportError(null);
|
||||
} catch (err) {
|
||||
setExportError(err instanceof Error ? err.message : "Export failed");
|
||||
}
|
||||
};
|
||||
|
||||
// ── Helpers ────────────────────────────────────────────────────
|
||||
|
||||
const isExpired = (dateStr: string) => new Date(dateStr) < new Date();
|
||||
|
||||
const formatDate = (dateStr: string) => {
|
||||
try {
|
||||
return new Date(dateStr).toLocaleDateString();
|
||||
} catch {
|
||||
return dateStr;
|
||||
}
|
||||
};
|
||||
|
||||
const getBoundIdentityNames = (keyId: string): string[] => {
|
||||
return Object.entries(identityKeyBindings)
|
||||
.filter(([, kId]) => kId === keyId)
|
||||
.map(([identityId]) => {
|
||||
const identity = identities.find((i) => i.id === identityId);
|
||||
return identity?.email ?? identityId;
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
{error && (
|
||||
<div className="px-4 py-3 rounded-md bg-destructive/10 text-destructive text-sm">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Your Certificates ──────────────────────────────────── */}
|
||||
<SettingsSection
|
||||
title={t("your_certificates")}
|
||||
description={t("your_certificates_desc")}
|
||||
>
|
||||
<div className="space-y-2">
|
||||
{keyRecords.map((record) => {
|
||||
const expired = isExpired(record.notAfter);
|
||||
const unlocked = isKeyUnlocked(record.id);
|
||||
const boundIdentities = getBoundIdentityNames(record.id);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={record.id}
|
||||
className="flex items-center justify-between p-3 rounded-lg border border-border"
|
||||
>
|
||||
<div className="flex items-center gap-3 min-w-0 flex-1">
|
||||
<div className={`w-8 h-8 rounded-full flex items-center justify-center ${expired ? "bg-destructive/10" : "bg-primary/10"}`}>
|
||||
{expired ? (
|
||||
<ShieldAlert className="w-4 h-4 text-destructive" />
|
||||
) : (
|
||||
<ShieldCheck className="w-4 h-4 text-primary" />
|
||||
)}
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm font-medium text-foreground truncate">
|
||||
{record.email || record.subject}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{record.issuer} · {t("expires")} {formatDate(record.notAfter)}
|
||||
{expired && <span className="text-destructive ml-1">({t("expired")})</span>}
|
||||
</p>
|
||||
{boundIdentities.length > 0 && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("bound_to")}: {boundIdentities.join(", ")}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
{unlocked ? (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => lockKey(record.id)}
|
||||
title={t("lock")}
|
||||
>
|
||||
<Unlock className="w-4 h-4 text-green-600" />
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => handleUnlockRequest(record.id)}
|
||||
title={t("unlock")}
|
||||
>
|
||||
<Lock className="w-4 h-4" />
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => {
|
||||
setCertModalRecord(record);
|
||||
setCertModalType("private");
|
||||
}}
|
||||
title={t("details")}
|
||||
>
|
||||
<Eye className="w-4 h-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => handleExportRequest(record)}
|
||||
title={t("export")}
|
||||
>
|
||||
<Download className="w-4 h-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => removeKeyRecord(record.id)}
|
||||
title={t("delete")}
|
||||
>
|
||||
<Trash2 className="w-4 h-4 text-destructive" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
{keyRecords.length === 0 && !isLoading && (
|
||||
<p className="text-sm text-muted-foreground py-4 text-center">
|
||||
{t("no_certificates")}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept=".p12,.pfx"
|
||||
className="hidden"
|
||||
onChange={handleFileSelect}
|
||||
/>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
disabled={isLoading}
|
||||
className="mt-2"
|
||||
>
|
||||
<Upload className="w-4 h-4 mr-2" />
|
||||
{t("import_pkcs12")}
|
||||
</Button>
|
||||
</SettingsSection>
|
||||
|
||||
{/* ── Recipient Certificates ─────────────────────────────── */}
|
||||
<SettingsSection
|
||||
title={t("recipient_certificates")}
|
||||
description={t("recipient_certificates_desc")}
|
||||
>
|
||||
<div className="space-y-2">
|
||||
{publicCerts.map((cert) => {
|
||||
const expired = isExpired(cert.notAfter);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={cert.id}
|
||||
className="flex items-center justify-between p-3 rounded-lg border border-border"
|
||||
>
|
||||
<div className="flex items-center gap-3 min-w-0 flex-1">
|
||||
<div className="w-8 h-8 rounded-full bg-muted flex items-center justify-center">
|
||||
<Users className="w-4 h-4 text-muted-foreground" />
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm font-medium text-foreground truncate">
|
||||
{cert.email || cert.subject}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{cert.issuer} · {cert.source}
|
||||
{expired && <span className="text-destructive ml-1">({t("expired")})</span>}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => {
|
||||
setCertModalRecord(cert);
|
||||
setCertModalType("public");
|
||||
}}
|
||||
title={t("details")}
|
||||
>
|
||||
<Eye className="w-4 h-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => removePublicCert(cert.id)}
|
||||
title={t("delete")}
|
||||
>
|
||||
<Trash2 className="w-4 h-4 text-destructive" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
{publicCerts.length === 0 && !isLoading && (
|
||||
<p className="text-sm text-muted-foreground py-4 text-center">
|
||||
{t("no_recipient_certs")}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<input
|
||||
ref={pubCertInputRef}
|
||||
type="file"
|
||||
accept=".pem,.cer,.crt,.der"
|
||||
className="hidden"
|
||||
onChange={handlePublicCertFile}
|
||||
/>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => pubCertInputRef.current?.click()}
|
||||
disabled={isLoading}
|
||||
className="mt-2"
|
||||
>
|
||||
<Upload className="w-4 h-4 mr-2" />
|
||||
{t("import_public_cert")}
|
||||
</Button>
|
||||
</SettingsSection>
|
||||
|
||||
{/* ── Identity Bindings ──────────────────────────────────── */}
|
||||
{identities.length > 0 && keyRecords.length > 0 && (
|
||||
<SettingsSection
|
||||
title={t("identity_bindings")}
|
||||
description={t("identity_bindings_desc")}
|
||||
>
|
||||
{identities.map((identity) => {
|
||||
const boundKeyId = identityKeyBindings[identity.id];
|
||||
return (
|
||||
<SettingItem key={identity.id} label={identity.email}>
|
||||
<select
|
||||
value={boundKeyId ?? ""}
|
||||
onChange={(e) =>
|
||||
bindIdentityToKey(identity.id, e.target.value || null)
|
||||
}
|
||||
className="text-sm bg-background border border-border rounded-md px-2 py-1"
|
||||
>
|
||||
<option value="">{t("no_key_bound")}</option>
|
||||
{keyRecords.map((kr) => (
|
||||
<option key={kr.id} value={kr.id}>
|
||||
{kr.email} ({kr.algorithm})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</SettingItem>
|
||||
);
|
||||
})}
|
||||
</SettingsSection>
|
||||
)}
|
||||
|
||||
{/* ── Defaults ───────────────────────────────────────────── */}
|
||||
<SettingsSection
|
||||
title={t("defaults_title")}
|
||||
description={t("defaults_desc")}
|
||||
>
|
||||
<SettingItem
|
||||
label={t("encrypt_by_default")}
|
||||
description={t("encrypt_by_default_desc")}
|
||||
>
|
||||
<ToggleSwitch
|
||||
checked={defaultEncrypt}
|
||||
onChange={setEncryptDefault}
|
||||
/>
|
||||
</SettingItem>
|
||||
|
||||
<SettingItem
|
||||
label={t("auto_import_signer_certs")}
|
||||
description={t("auto_import_signer_certs_desc")}
|
||||
>
|
||||
<ToggleSwitch
|
||||
checked={autoImportSignerCerts}
|
||||
onChange={setAutoImportSignerCerts}
|
||||
/>
|
||||
</SettingItem>
|
||||
|
||||
{identities.map((identity) => {
|
||||
const bound = identityKeyBindings[identity.id];
|
||||
if (!bound) return null;
|
||||
return (
|
||||
<SettingItem
|
||||
key={identity.id}
|
||||
label={`${t("sign_default_for")} ${identity.email}`}
|
||||
>
|
||||
<ToggleSwitch
|
||||
checked={defaultSignIdentity[identity.id] ?? false}
|
||||
onChange={(v) => setSignDefault(identity.id, v)}
|
||||
/>
|
||||
</SettingItem>
|
||||
);
|
||||
})}
|
||||
</SettingsSection>
|
||||
|
||||
{/* ── Dialogs ────────────────────────────────────────────── */}
|
||||
<SmimePassphraseDialog
|
||||
isOpen={importDialogOpen}
|
||||
onClose={() => {
|
||||
setImportDialogOpen(false);
|
||||
setPendingFile(null);
|
||||
setPendingP12Pass("");
|
||||
setImportError(null);
|
||||
setImportStep("p12");
|
||||
}}
|
||||
onSubmit={handleImportSubmit}
|
||||
title={importStep === "p12" ? t("enter_p12_passphrase") : t("enter_storage_passphrase")}
|
||||
description={importStep === "p12" ? t("p12_passphrase_desc") : t("storage_passphrase_desc")}
|
||||
submitText={importStep === "p12" ? t("next") : t("import")}
|
||||
error={importError}
|
||||
showConfirm={importStep === "storage"}
|
||||
/>
|
||||
|
||||
<SmimePassphraseDialog
|
||||
isOpen={unlockDialogOpen}
|
||||
onClose={() => {
|
||||
setUnlockDialogOpen(false);
|
||||
setUnlockTargetId(null);
|
||||
setUnlockError(null);
|
||||
}}
|
||||
onSubmit={handleUnlockSubmit}
|
||||
title={t("unlock_key")}
|
||||
description={t("unlock_key_desc")}
|
||||
error={unlockError}
|
||||
/>
|
||||
|
||||
<SmimeCertificateModal
|
||||
isOpen={!!certModalRecord}
|
||||
onClose={() => setCertModalRecord(null)}
|
||||
record={certModalRecord}
|
||||
type={certModalType}
|
||||
/>
|
||||
|
||||
<SmimePassphraseDialog
|
||||
isOpen={exportDialogOpen}
|
||||
onClose={() => {
|
||||
setExportDialogOpen(false);
|
||||
setExportTargetRecord(null);
|
||||
setExportStoragePass("");
|
||||
setExportError(null);
|
||||
setExportStep("storage");
|
||||
}}
|
||||
onSubmit={handleExportSubmit}
|
||||
title={exportStep === "storage" ? t("enter_storage_passphrase") : t("enter_export_passphrase")}
|
||||
description={exportStep === "storage" ? t("export_storage_desc") : t("export_passphrase_desc")}
|
||||
submitText={exportStep === "storage" ? t("next") : t("export")}
|
||||
error={exportError}
|
||||
showConfirm={exportStep === "export"}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import { useAuthStore } from "@/stores/auth-store";
|
||||
import { useCalendarStore } from "@/stores/calendar-store";
|
||||
import { useWebDAVStore } from "@/stores/webdav-store";
|
||||
import { useSettingsStore } from "@/stores/settings-store";
|
||||
import { usePolicyStore } from "@/stores/policy-store";
|
||||
import { getTourSteps, type TourStep } from "./tour-steps";
|
||||
import { TourOverlay } from "./tour-overlay";
|
||||
|
||||
@@ -38,6 +39,7 @@ export function TourProvider({ children }: { children: ReactNode }) {
|
||||
const pathname = usePathname();
|
||||
const { isDemoMode } = useAuthStore();
|
||||
const { supportsCalendar } = useCalendarStore();
|
||||
const calendarEnabled = usePolicyStore((s) => s.isFeatureEnabled('calendarEnabled'));
|
||||
const { supportsWebDAV } = useWebDAVStore();
|
||||
const tourCompleted = useSettingsStore((s) => s.tourCompleted);
|
||||
const showOnboardingOnNewDevices = useSettingsStore((s) => s.showOnboardingOnNewDevices);
|
||||
@@ -47,7 +49,7 @@ export function TourProvider({ children }: { children: ReactNode }) {
|
||||
const [currentStep, setCurrentStep] = useState(0);
|
||||
const [hasCompletedTour, setHasCompletedTour] = useState(false);
|
||||
|
||||
const steps = getTourSteps({ isDemoMode, supportsCalendar, supportsWebDAV: supportsWebDAV !== false });
|
||||
const steps = getTourSteps({ isDemoMode, supportsCalendar: supportsCalendar && calendarEnabled, supportsWebDAV: supportsWebDAV !== false });
|
||||
|
||||
useEffect(() => {
|
||||
// One-time migration: if the legacy per-device flag is set but the synced
|
||||
|
||||
@@ -222,6 +222,29 @@ export function FlagDK(props: FlagProps) {
|
||||
);
|
||||
}
|
||||
|
||||
/** Romania – Vertical blue, yellow, red tricolour */
|
||||
export function FlagRO(props: FlagProps) {
|
||||
return (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 3 2" width={W} height={H} className={flagClass} {...props}>
|
||||
<rect width="1" height="2" fill="#002B7F" />
|
||||
<rect x="1" width="1" height="2" fill="#FCD116" />
|
||||
<rect x="2" width="1" height="2" fill="#CE1126" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
/** Iran – Green, White, Red horizontal with emblem (simplified) */
|
||||
export function FlagIR(props: FlagProps) {
|
||||
return (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 4 3" width={W} height={H} className={flagClass} {...props}>
|
||||
<rect width="4" height="1" fill="#239F40" />
|
||||
<rect y="1" width="4" height="1" fill="#fff" />
|
||||
<rect y="2" width="4" height="1" fill="#DA0000" />
|
||||
<circle cx="2" cy="1.5" r="0.3" fill="#DA0000" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
/** Map locale codes to flag components */
|
||||
export const flagComponents: Record<string, (props: FlagProps) => ReactElement> = {
|
||||
cs: FlagCS,
|
||||
@@ -238,8 +261,10 @@ export const flagComponents: Record<string, (props: FlagProps) => ReactElement>
|
||||
nl: FlagNL,
|
||||
pl: FlagPL,
|
||||
pt: FlagBR,
|
||||
ro: FlagRO,
|
||||
ru: FlagRU,
|
||||
tr: FlagTR,
|
||||
uk: FlagUA,
|
||||
zh: FlagCN,
|
||||
fa: FlagIR,
|
||||
};
|
||||
|
||||
@@ -12,6 +12,7 @@ const languages = [
|
||||
{ value: 'da', label: 'Dansk' },
|
||||
{ value: 'de', label: 'Deutsch' },
|
||||
{ value: 'en', label: 'English' },
|
||||
{ value: 'fa', label: 'فارسی' },
|
||||
{ value: 'es', label: 'Español' },
|
||||
{ value: 'fr', label: 'Français' },
|
||||
{ value: 'it', label: 'Italiano' },
|
||||
@@ -20,6 +21,7 @@ const languages = [
|
||||
{ value: 'nl', label: 'Nederlands' },
|
||||
{ value: 'pl', label: 'Polski' },
|
||||
{ value: 'pt', label: 'Português' },
|
||||
{ value: 'ro', label: 'Română' },
|
||||
{ value: 'tr', label: 'Türkçe' },
|
||||
{ value: 'ru', label: 'Русский' },
|
||||
{ value: 'uk', label: 'Українська' },
|
||||
|
||||
@@ -222,7 +222,7 @@ export function useMailboxDrop({ mailbox, onDropComplete, onSuccess, onError }:
|
||||
} finally {
|
||||
endDrag();
|
||||
}
|
||||
}, [client, mailbox, mailboxes, isValidTarget, moveEmailsToMailbox, crossAccountMoveEmails, draggedEmails, selectedEmailIds, clearSelection, refreshCurrentMailbox, endDrag, onDropComplete, onSuccess, onError]);
|
||||
}, [client, mailbox, mailboxes, isValidTarget, moveEmailsToMailbox, crossAccountMoveEmails, draggedEmails, sourceMailboxId, selectedEmailIds, clearSelection, refreshCurrentMailbox, endDrag, onDropComplete, onSuccess, onError]);
|
||||
|
||||
const valid = isValidTarget();
|
||||
|
||||
|
||||
@@ -23,6 +23,9 @@ export default getRequestConfig(async ({ requestLocale }) => {
|
||||
case 'es':
|
||||
messages = (await import('../locales/es/common.json')).default;
|
||||
break;
|
||||
case 'fa':
|
||||
messages = (await import('../locales/fa/common.json')).default;
|
||||
break;
|
||||
case 'fr':
|
||||
messages = (await import('../locales/fr/common.json')).default;
|
||||
break;
|
||||
@@ -50,6 +53,9 @@ export default getRequestConfig(async ({ requestLocale }) => {
|
||||
case 'pt':
|
||||
messages = (await import('../locales/pt/common.json')).default;
|
||||
break;
|
||||
case 'ro':
|
||||
messages = (await import('../locales/ro/common.json')).default;
|
||||
break;
|
||||
case 'ru':
|
||||
messages = (await import('../locales/ru/common.json')).default;
|
||||
break;
|
||||
|
||||
+1
-1
@@ -12,7 +12,7 @@ const localePrefix = (process.env.NEXT_PUBLIC_LOCALE_PREFIX ?? 'never') as
|
||||
| 'always'
|
||||
| 'as-needed';
|
||||
|
||||
const SUPPORTED_LOCALES = ['cs', 'da', 'de', 'en', 'es', 'fr', 'hu', 'it', 'ja', 'ko', 'lv', 'nl', 'pl', 'pt', 'ru', 'tr', 'uk', 'zh'] as const;
|
||||
const SUPPORTED_LOCALES = ['cs', 'da', 'de', 'en', 'es', 'fa', 'fr', 'hu', 'it', 'ja', 'ko', 'lv', 'nl', 'pl', 'pt', 'ro', 'ru', 'tr', 'uk', 'zh'] as const;
|
||||
|
||||
// Fallback locale used when the visitor's Accept-Language header does not
|
||||
// match any supported locale (and no NEXT_LOCALE cookie is set yet). Admins
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import {
|
||||
snapshotAccount,
|
||||
restoreAccount,
|
||||
clearAllStores,
|
||||
evictAccount,
|
||||
evictAll,
|
||||
} from '@/lib/account-state-manager';
|
||||
import { useEmailStore } from '@/stores/email-store';
|
||||
import { useContactStore } from '@/stores/contact-store';
|
||||
import { useCalendarStore } from '@/stores/calendar-store';
|
||||
import { useFilterStore } from '@/stores/filter-store';
|
||||
import { useIdentityStore } from '@/stores/identity-store';
|
||||
import { useVacationStore } from '@/stores/vacation-store';
|
||||
import { DEFAULT_SEARCH_FILTERS } from '@/lib/jmap/search-utils';
|
||||
import { makeEmail, makeMailbox } from './helpers/factories';
|
||||
|
||||
// The store singletons and the module-level snapshot cache persist across
|
||||
// tests; reset both around every test.
|
||||
beforeEach(() => evictAll());
|
||||
afterEach(() => evictAll());
|
||||
|
||||
describe('snapshotAccount / restoreAccount', () => {
|
||||
it('round-trips the captured fields across all six stores', () => {
|
||||
useEmailStore.setState({
|
||||
emails: [makeEmail({ id: 'a1' })],
|
||||
mailboxes: [makeMailbox({ id: 'a-in' })],
|
||||
selectedMailbox: 'a-in',
|
||||
searchQuery: 'queryA',
|
||||
quota: { used: 1, total: 2 },
|
||||
});
|
||||
useContactStore.setState({ supportsSync: true });
|
||||
useCalendarStore.setState({ viewMode: 'week', supportsCalendar: true });
|
||||
useFilterStore.setState({ isSupported: true });
|
||||
useIdentityStore.setState({ preferredPrimaryId: 'idA' });
|
||||
useVacationStore.setState({ isEnabled: true });
|
||||
|
||||
snapshotAccount('A');
|
||||
|
||||
// Mutate everything to "account B" values.
|
||||
useEmailStore.setState({ emails: [], selectedMailbox: 'b-in', searchQuery: 'queryB', quota: null });
|
||||
useContactStore.setState({ supportsSync: false });
|
||||
useCalendarStore.setState({ viewMode: 'month', supportsCalendar: false });
|
||||
useFilterStore.setState({ isSupported: false });
|
||||
useIdentityStore.setState({ preferredPrimaryId: 'idB' });
|
||||
useVacationStore.setState({ isEnabled: false });
|
||||
|
||||
expect(restoreAccount('A')).toBe(true);
|
||||
|
||||
expect(useEmailStore.getState().emails.map((e) => e.id)).toEqual(['a1']);
|
||||
expect(useEmailStore.getState().selectedMailbox).toBe('a-in');
|
||||
expect(useEmailStore.getState().searchQuery).toBe('queryA');
|
||||
expect(useEmailStore.getState().quota).toEqual({ used: 1, total: 2 });
|
||||
expect(useContactStore.getState().supportsSync).toBe(true);
|
||||
expect(useCalendarStore.getState().viewMode).toBe('week');
|
||||
expect(useFilterStore.getState().isSupported).toBe(true);
|
||||
expect(useIdentityStore.getState().preferredPrimaryId).toBe('idA');
|
||||
expect(useVacationStore.getState().isEnabled).toBe(true);
|
||||
});
|
||||
|
||||
it('resets fields outside the snapshot subset to their defaults (no cross-account leak)', () => {
|
||||
// isLoading is NOT part of the email snapshot subset.
|
||||
useEmailStore.setState({ selectedMailbox: 'a-in', isLoading: false });
|
||||
snapshotAccount('A');
|
||||
useEmailStore.setState({ selectedMailbox: 'b-in', isLoading: true });
|
||||
|
||||
restoreAccount('A');
|
||||
|
||||
expect(useEmailStore.getState().selectedMailbox).toBe('a-in'); // captured → restored
|
||||
expect(useEmailStore.getState().isLoading).toBe(false); // uncaptured → reset, not leaked
|
||||
});
|
||||
|
||||
it('decouples the snapshot from later in-place mutation of the source array', () => {
|
||||
const arr = [makeEmail({ id: '1' })];
|
||||
useEmailStore.setState({ emails: arr });
|
||||
snapshotAccount('A');
|
||||
arr.push(makeEmail({ id: '2' })); // mutate the same array after snapshot
|
||||
useEmailStore.setState({ emails: [] });
|
||||
|
||||
restoreAccount('A');
|
||||
// The post-snapshot mutation did NOT leak into the snapshot.
|
||||
expect(useEmailStore.getState().emails.map((e) => e.id)).toEqual(['1']);
|
||||
});
|
||||
|
||||
it('returns false and leaves stores untouched for an unknown account', () => {
|
||||
useEmailStore.setState({ searchQuery: 'keep' });
|
||||
expect(restoreAccount('nope')).toBe(false);
|
||||
expect(useEmailStore.getState().searchQuery).toBe('keep');
|
||||
});
|
||||
});
|
||||
|
||||
describe('clearAllStores', () => {
|
||||
it('resets the email store to fresh empty collections', () => {
|
||||
useEmailStore.setState({
|
||||
emails: [makeEmail({ id: 'x' })],
|
||||
selectedEmailIds: new Set(['x']),
|
||||
searchQuery: 'q',
|
||||
tagCounts: { a: { total: 1, unread: 0 } },
|
||||
threadEmailsCache: new Map([['t', []]]),
|
||||
});
|
||||
|
||||
clearAllStores();
|
||||
|
||||
const s = useEmailStore.getState();
|
||||
expect(s.emails).toEqual([]);
|
||||
expect(s.searchQuery).toBe('');
|
||||
expect(s.selectedEmailIds.size).toBe(0);
|
||||
expect(s.threadEmailsCache.size).toBe(0);
|
||||
expect(s.tagCounts).toEqual({});
|
||||
expect(s.searchFilters).toEqual(DEFAULT_SEARCH_FILTERS);
|
||||
});
|
||||
});
|
||||
|
||||
describe('evictAccount / evictAll', () => {
|
||||
it('evictAccount drops a single snapshot', () => {
|
||||
snapshotAccount('A');
|
||||
evictAccount('A');
|
||||
expect(restoreAccount('A')).toBe(false);
|
||||
});
|
||||
|
||||
it('evictAll drops every snapshot', () => {
|
||||
snapshotAccount('B');
|
||||
snapshotAccount('C');
|
||||
evictAll();
|
||||
expect(restoreAccount('B')).toBe(false);
|
||||
expect(restoreAccount('C')).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,95 @@
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||
import {
|
||||
encryptSession,
|
||||
decryptSession,
|
||||
encryptPayload,
|
||||
decryptPayload,
|
||||
} from '@/lib/auth/crypto';
|
||||
|
||||
// crypto.ts derives its key solely from getSessionSecret(); mock that one seam
|
||||
// so we control the secret without touching configManager / env-file lookups.
|
||||
const { secretRef } = vi.hoisted(() => ({ secretRef: { value: 'x'.repeat(32) } }));
|
||||
vi.mock('@/lib/auth/session-secret', () => ({
|
||||
getSessionSecret: () => secretRef.value,
|
||||
hasSessionSecret: () => secretRef.value.length > 0,
|
||||
}));
|
||||
vi.mock('@/lib/logger', () => ({
|
||||
logger: { warn: () => {}, error: () => {}, info: () => {}, debug: () => {} },
|
||||
}));
|
||||
|
||||
const SECRET = 'x'.repeat(32);
|
||||
|
||||
beforeEach(() => {
|
||||
secretRef.value = SECRET;
|
||||
});
|
||||
|
||||
describe('encryptSession / decryptSession', () => {
|
||||
it('round-trips a session', () => {
|
||||
const token = encryptSession('https://mail.example.com', 'alice', 's3cret');
|
||||
expect(decryptSession(token)).toEqual({
|
||||
serverUrl: 'https://mail.example.com',
|
||||
username: 'alice',
|
||||
password: 's3cret',
|
||||
});
|
||||
});
|
||||
|
||||
it('produces a base64 token with a random IV (two encrypts differ, both decrypt equal)', () => {
|
||||
const a = encryptSession('https://x', 'u', 'p');
|
||||
const b = encryptSession('https://x', 'u', 'p');
|
||||
expect(a).not.toBe(b);
|
||||
expect(Buffer.from(a, 'base64').toString('base64')).toBe(a); // valid base64
|
||||
expect(decryptSession(a)).toEqual(decryptSession(b));
|
||||
});
|
||||
|
||||
it('returns null (not throw) on a tampered auth tag', () => {
|
||||
const token = encryptSession('https://x', 'u', 'p');
|
||||
const buf = Buffer.from(token, 'base64');
|
||||
buf[13] ^= 0xff; // flip a byte inside the GCM tag region (bytes 12..28)
|
||||
expect(decryptSession(buf.toString('base64'))).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null on a token shorter than IV+TAG', () => {
|
||||
expect(decryptSession(Buffer.alloc(10).toString('base64'))).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null when the version is not 1', () => {
|
||||
const token = encryptPayload({ v: 2, serverUrl: 'https://x', username: 'u', password: 'p' });
|
||||
expect(decryptSession(token)).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null when a required field is missing', () => {
|
||||
const token = encryptPayload({ v: 1, serverUrl: 'https://x', username: 'u' });
|
||||
expect(decryptSession(token)).toBeNull();
|
||||
});
|
||||
|
||||
it('throws when no secret is configured', () => {
|
||||
secretRef.value = '';
|
||||
expect(() => encryptSession('https://x', 'u', 'p')).toThrow('SESSION_SECRET not configured');
|
||||
});
|
||||
|
||||
it('throws when the secret is shorter than 32 characters', () => {
|
||||
secretRef.value = 'tooshort';
|
||||
expect(() => encryptSession('https://x', 'u', 'p')).toThrow(/at least 32 characters/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('encryptPayload / decryptPayload', () => {
|
||||
it('round-trips an arbitrary object', () => {
|
||||
const token = encryptPayload({ a: 1, b: 'two', c: { nested: true } });
|
||||
expect(decryptPayload(token)).toEqual({ a: 1, b: 'two', c: { nested: true } });
|
||||
});
|
||||
|
||||
it('does NOT enforce the version/field guard that decryptSession applies', () => {
|
||||
// CHARACTERISATION: decryptPayload returns whatever JSON parsed, with no
|
||||
// v===1 / required-field validation (unlike decryptSession).
|
||||
const token = encryptPayload({ v: 2, anything: 'goes' });
|
||||
expect(decryptPayload(token)).toEqual({ v: 2, anything: 'goes' });
|
||||
});
|
||||
|
||||
it('returns null on a tampered token', () => {
|
||||
const token = encryptPayload({ a: 1 });
|
||||
const buf = Buffer.from(token, 'base64');
|
||||
buf[20] ^= 0xff;
|
||||
expect(decryptPayload(buf.toString('base64'))).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,90 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import type { ContactCard } from '@/lib/jmap/types';
|
||||
import {
|
||||
createBirthdayCalendar,
|
||||
generateBirthdayEvents,
|
||||
BIRTHDAY_CALENDAR_ID,
|
||||
BIRTHDAY_CALENDAR_COLOR,
|
||||
} from '@/lib/birthday-calendar';
|
||||
|
||||
const contact = (over: Record<string, unknown> = {}): ContactCard =>
|
||||
({
|
||||
id: 'c1',
|
||||
'@type': 'Card',
|
||||
name: { full: 'Alice Smith' },
|
||||
anniversaries: { b1: { '@type': 'Anniversary', kind: 'birth', date: '1990-05-15' } },
|
||||
...over,
|
||||
} as unknown as ContactCard);
|
||||
|
||||
describe('createBirthdayCalendar', () => {
|
||||
it('returns the virtual calendar with defaults', () => {
|
||||
const cal = createBirthdayCalendar();
|
||||
expect(cal).toMatchObject({
|
||||
id: BIRTHDAY_CALENDAR_ID,
|
||||
name: 'Birthdays',
|
||||
color: BIRTHDAY_CALENDAR_COLOR,
|
||||
isSubscribed: true,
|
||||
myRights: { mayReadItems: true, mayWriteAll: false, mayDelete: false },
|
||||
});
|
||||
});
|
||||
|
||||
it('honours name/color overrides', () => {
|
||||
expect(createBirthdayCalendar('My BDays', '#fff')).toMatchObject({ name: 'My BDays', color: '#fff' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('generateBirthdayEvents', () => {
|
||||
it('emits one event per year in range, with age and stable ids', () => {
|
||||
const events = generateBirthdayEvents([contact()], '2020-01-01', '2022-12-31');
|
||||
expect(events.map((e) => e.id)).toEqual([
|
||||
'birthday-c1-b1-2020',
|
||||
'birthday-c1-b1-2021',
|
||||
'birthday-c1-b1-2022',
|
||||
]);
|
||||
expect(events[0]).toMatchObject({
|
||||
uid: 'birthday-c1-b1',
|
||||
title: '🎂 Alice Smith (30)',
|
||||
start: '2020-05-15T00:00:00',
|
||||
calendarIds: { [BIRTHDAY_CALENDAR_ID]: true },
|
||||
showWithoutTime: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('omits the age when the birthday has no year (partial date)', () => {
|
||||
const c = contact({ anniversaries: { b1: { '@type': 'Anniversary', kind: 'birth', date: '--05-15' } } });
|
||||
const events = generateBirthdayEvents([c], '2021-01-01', '2021-12-31');
|
||||
expect(events).toHaveLength(1);
|
||||
expect(events[0].title).toBe('🎂 Alice Smith');
|
||||
});
|
||||
|
||||
it('parses a Timestamp anniversary date', () => {
|
||||
const c = contact({ anniversaries: { b1: { '@type': 'Anniversary', kind: 'birth', date: { '@type': 'Timestamp', utc: '1985-03-10T00:00:00Z' } } } });
|
||||
const events = generateBirthdayEvents([c], '2021-01-01', '2021-12-31');
|
||||
expect(events[0].start).toBe('2021-03-10T00:00:00');
|
||||
});
|
||||
|
||||
it('parses a PartialDate anniversary date', () => {
|
||||
const c = contact({ anniversaries: { b1: { '@type': 'Anniversary', kind: 'birth', date: { month: 7, day: 4 } } } });
|
||||
const events = generateBirthdayEvents([c], '2021-01-01', '2021-12-31');
|
||||
expect(events[0].start).toBe('2021-07-04T00:00:00');
|
||||
});
|
||||
|
||||
it('clamps Feb 29 to Feb 28 in a non-leap year', () => {
|
||||
const c = contact({ anniversaries: { b1: { '@type': 'Anniversary', kind: 'birth', date: '2000-02-29' } } });
|
||||
const events = generateBirthdayEvents([c], '2021-01-01', '2021-12-31');
|
||||
expect(events[0].start).toBe('2021-02-28T00:00:00');
|
||||
});
|
||||
|
||||
it('excludes occurrences outside the range', () => {
|
||||
expect(generateBirthdayEvents([contact()], '2021-01-01', '2021-02-28')).toEqual([]); // May birthday
|
||||
});
|
||||
|
||||
it('returns [] for an invalid range', () => {
|
||||
expect(generateBirthdayEvents([contact()], 'not-a-date', '2021-12-31')).toEqual([]);
|
||||
});
|
||||
|
||||
it('skips non-birth anniversaries', () => {
|
||||
const c = contact({ anniversaries: { w1: { '@type': 'Anniversary', kind: 'wedding', date: '2010-06-01' } } });
|
||||
expect(generateBirthdayEvents([c], '2021-01-01', '2021-12-31')).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,85 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach, type Mock } from 'vitest';
|
||||
|
||||
vi.mock('next/server', () => ({
|
||||
NextResponse: {
|
||||
json: (data: unknown, init?: { status?: number }) => ({ status: init?.status ?? 200, json: async () => data }),
|
||||
},
|
||||
NextRequest: class {},
|
||||
}));
|
||||
vi.mock('@/lib/logger', () => ({ logger: { warn: () => {}, error: () => {} } }));
|
||||
vi.mock('@/lib/stalwart/credentials', () => ({ getStalwartCredentials: vi.fn() }));
|
||||
|
||||
import { POST } from '@/app/api/caldav/discover/route';
|
||||
import { getStalwartCredentials } from '@/lib/stalwart/credentials';
|
||||
|
||||
const mockCreds = getStalwartCredentials as unknown as Mock;
|
||||
const CREDS = { serverUrl: 'https://mail.example.com', username: 'u', authHeader: 'Basic abc' };
|
||||
|
||||
let fetchSpy: Mock;
|
||||
|
||||
function makeReq(body: unknown): Parameters<typeof POST>[0] {
|
||||
return { headers: { get: () => null }, json: async () => body } as unknown as Parameters<typeof POST>[0];
|
||||
}
|
||||
function read(res: unknown) {
|
||||
return res as { status: number; json: () => Promise<{ wellKnownUrl: string; accounts: Record<string, { url: string | null; resolvedAccount: string | null }> }> };
|
||||
}
|
||||
const target = (c: string) => `https://mail.example.com/dav/cal/${c}`;
|
||||
|
||||
beforeEach(() => {
|
||||
mockCreds.mockResolvedValue(CREDS);
|
||||
fetchSpy = vi.fn();
|
||||
vi.stubGlobal('fetch', fetchSpy);
|
||||
});
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
describe('POST /api/caldav/discover', () => {
|
||||
it('401 without credentials', async () => {
|
||||
mockCreds.mockResolvedValue(null);
|
||||
const res = read(await POST(makeReq({ accounts: [] })));
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
|
||||
it('returns the .well-known url and resolves the first 207 candidate, skipping the rest', async () => {
|
||||
fetchSpy.mockResolvedValue({ status: 207, headers: new Headers() });
|
||||
const res = read(await POST(makeReq({ accounts: [{ key: 'A', candidates: ['c1', 'c2'] }] })));
|
||||
const data = await res.json();
|
||||
|
||||
expect(data.wellKnownUrl).toBe('https://mail.example.com/.well-known/caldav');
|
||||
expect(data.accounts.A).toEqual({ url: target('c1'), resolvedAccount: 'c1' });
|
||||
expect(fetchSpy).toHaveBeenCalledTimes(1); // c2 never probed
|
||||
expect(fetchSpy).toHaveBeenCalledWith(target('c1'), expect.objectContaining({ method: 'PROPFIND' }));
|
||||
});
|
||||
|
||||
it('resolves a redirect Location relative to the probe URL', async () => {
|
||||
fetchSpy.mockResolvedValue({ status: 302, headers: new Headers({ Location: '/dav/cal/real-home' }) });
|
||||
const res = read(await POST(makeReq({ accounts: [{ key: 'A', candidates: ['c1'] }] })));
|
||||
const data = await res.json();
|
||||
expect(data.accounts.A).toEqual({ url: 'https://mail.example.com/dav/cal/real-home', resolvedAccount: 'c1' });
|
||||
});
|
||||
|
||||
it('returns null url when every candidate fails, but still 200', async () => {
|
||||
fetchSpy.mockResolvedValue({ status: 404, headers: new Headers() });
|
||||
const res = read(await POST(makeReq({ accounts: [{ key: 'A', candidates: ['c1', 'c2'] }] })));
|
||||
expect(res.status).toBe(200);
|
||||
expect((await res.json()).accounts.A).toEqual({ url: null, resolvedAccount: null });
|
||||
expect(fetchSpy).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('catches a probe error and continues to the next candidate', async () => {
|
||||
fetchSpy
|
||||
.mockRejectedValueOnce(new Error('boom'))
|
||||
.mockResolvedValueOnce({ status: 207, headers: new Headers() });
|
||||
const res = read(await POST(makeReq({ accounts: [{ key: 'A', candidates: ['c1', 'c2'] }] })));
|
||||
expect((await res.json()).accounts.A).toEqual({ url: target('c2'), resolvedAccount: 'c2' });
|
||||
});
|
||||
|
||||
it('de-duplicates and trims candidates before probing', async () => {
|
||||
fetchSpy.mockResolvedValue({ status: 404, headers: new Headers() });
|
||||
await POST(makeReq({ accounts: [{ key: 'A', candidates: [' c1 ', 'c1', '', 'c1'] }] }));
|
||||
expect(fetchSpy).toHaveBeenCalledTimes(1); // collapsed to a single "c1"
|
||||
expect(fetchSpy).toHaveBeenCalledWith(target('c1'), expect.anything());
|
||||
});
|
||||
});
|
||||
@@ -274,26 +274,31 @@ describe('buildParticipantMap', () => {
|
||||
|
||||
expect(Object.keys(map)).toHaveLength(3);
|
||||
|
||||
const org = map['organizer'];
|
||||
expect(org.name).toBe('Alice');
|
||||
expect(org.email).toBe('alice@example.com');
|
||||
expect(org.roles).toEqual({ owner: true, attendee: true });
|
||||
expect(org.participationStatus).toBe('accepted');
|
||||
expect(org.scheduleAgent).toBe('server');
|
||||
expect(org.sendTo).toEqual({ imip: 'mailto:alice@example.com' });
|
||||
expect(org.expectReply).toBe(false);
|
||||
// Entries are keyed by generated UUIDs (RFC 8984 participant ids), so
|
||||
// look them up by identity rather than by a fixed key.
|
||||
const entries = Object.values(map);
|
||||
|
||||
const att0 = map['attendee-0'];
|
||||
expect(att0.name).toBe('Bob');
|
||||
expect(att0.email).toBe('bob@example.com');
|
||||
expect(att0.roles).toEqual({ attendee: true });
|
||||
expect(att0.participationStatus).toBe('needs-action');
|
||||
expect(att0.scheduleAgent).toBe('server');
|
||||
expect(att0.expectReply).toBe(true);
|
||||
const org = entries.find(p => p.roles?.owner);
|
||||
expect(org).toBeDefined();
|
||||
expect(org!.name).toBe('Alice');
|
||||
expect(org!.email).toBe('alice@example.com');
|
||||
expect(org!.roles).toEqual({ owner: true, attendee: true });
|
||||
expect(org!.participationStatus).toBe('accepted');
|
||||
expect(org!.scheduleAgent).toBe('server');
|
||||
expect(org!.sendTo).toEqual({ imip: 'mailto:alice@example.com' });
|
||||
expect(org!.expectReply).toBe(false);
|
||||
|
||||
const att1 = map['attendee-1'];
|
||||
expect(att1.name).toBe('Carol');
|
||||
expect(att1.email).toBe('carol@example.com');
|
||||
const att0 = entries.find(p => p.email === 'bob@example.com');
|
||||
expect(att0).toBeDefined();
|
||||
expect(att0!.name).toBe('Bob');
|
||||
expect(att0!.roles).toEqual({ attendee: true });
|
||||
expect(att0!.participationStatus).toBe('needs-action');
|
||||
expect(att0!.scheduleAgent).toBe('server');
|
||||
expect(att0!.expectReply).toBe(true);
|
||||
|
||||
const att1 = entries.find(p => p.email === 'carol@example.com');
|
||||
expect(att1).toBeDefined();
|
||||
expect(att1!.name).toBe('Carol');
|
||||
});
|
||||
|
||||
it('creates only organizer when no attendees', () => {
|
||||
@@ -302,7 +307,9 @@ describe('buildParticipantMap', () => {
|
||||
[]
|
||||
);
|
||||
expect(Object.keys(map)).toHaveLength(1);
|
||||
expect(map['organizer']).toBeDefined();
|
||||
const org = Object.values(map)[0];
|
||||
expect(org).toBeDefined();
|
||||
expect(org.roles).toEqual({ owner: true, attendee: true });
|
||||
});
|
||||
|
||||
it('sets @type to Participant for all entries', () => {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { describe, expect, it, beforeAll, afterAll } from 'vitest';
|
||||
import type { CalendarEvent } from '@/lib/jmap/types';
|
||||
import {
|
||||
buildTimedFullDayWeekSegments,
|
||||
@@ -14,6 +14,18 @@ import {
|
||||
normalizeAllDayDuration,
|
||||
} from '../calendar-utils';
|
||||
|
||||
// Several suites assert wall-clock minutes/dates derived in local time. Pin the
|
||||
// timezone to UTC so results don't depend on the host's zone (CI here runs at
|
||||
// UTC+2); the expected values below are all UTC.
|
||||
let originalTZ: string | undefined;
|
||||
beforeAll(() => {
|
||||
originalTZ = process.env.TZ;
|
||||
process.env.TZ = 'UTC';
|
||||
});
|
||||
afterAll(() => {
|
||||
process.env.TZ = originalTZ;
|
||||
});
|
||||
|
||||
function expectLocalDateParts(date: Date, year: number, month: number, day: number, hour: number, minute = 0, second = 0, millisecond = 0) {
|
||||
expect(date.getFullYear()).toBe(year);
|
||||
expect(date.getMonth()).toBe(month - 1);
|
||||
@@ -124,7 +136,7 @@ describe('calendar-utils all-day handling', () => {
|
||||
});
|
||||
|
||||
expect(getTimedEventBoundsForDay(event, new Date('2026-03-14T00:00:00Z'))).toMatchObject({
|
||||
startMinutes: 1380,
|
||||
startMinutes: 1320,
|
||||
endMinutes: 1440,
|
||||
continuesBefore: false,
|
||||
continuesAfter: true,
|
||||
@@ -132,7 +144,7 @@ describe('calendar-utils all-day handling', () => {
|
||||
|
||||
expect(getTimedEventBoundsForDay(event, new Date('2026-03-15T00:00:00Z'))).toMatchObject({
|
||||
startMinutes: 0,
|
||||
endMinutes: 180,
|
||||
endMinutes: 120,
|
||||
continuesBefore: true,
|
||||
continuesAfter: false,
|
||||
});
|
||||
@@ -152,7 +164,7 @@ describe('calendar-utils all-day handling', () => {
|
||||
expect(layout).toHaveLength(1);
|
||||
expect(layout[0]).toMatchObject({
|
||||
startMinutes: 0,
|
||||
endMinutes: 180,
|
||||
endMinutes: 120,
|
||||
column: 0,
|
||||
totalColumns: 1,
|
||||
continuesBefore: true,
|
||||
|
||||
@@ -0,0 +1,146 @@
|
||||
// Pin TZ so the local-time date rendering in dateParts is deterministic.
|
||||
process.env.TZ = 'UTC';
|
||||
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import type { Email } from '@/lib/jmap/types';
|
||||
import {
|
||||
emailExportFilename,
|
||||
attachmentDownloadFilename,
|
||||
attachmentsBundleFilename,
|
||||
bundleExportFilename,
|
||||
emailVars,
|
||||
attachmentVars,
|
||||
buildSampleEmail,
|
||||
} from '@/lib/download-filename';
|
||||
|
||||
const makeEmail = (over: Partial<Email>): Email =>
|
||||
({ id: 'e', receivedAt: '2026-05-22T14:05:33Z', from: [], to: [], subject: '', ...over } as unknown as Email);
|
||||
|
||||
describe('emailExportFilename', () => {
|
||||
it('renders the default template from the sample email (UTC)', () => {
|
||||
expect(emailExportFilename(buildSampleEmail())).toBe(
|
||||
'2026-05-22 14.05.33 (Alice Sender-Bob Recipient) Benachrichtigung von Ihrem Gerät.eml',
|
||||
);
|
||||
});
|
||||
|
||||
it('applies lowercase + stripDiacritics + underscore-spaces transforms', () => {
|
||||
expect(
|
||||
emailExportFilename(buildSampleEmail(), {
|
||||
template: '{from_name}-{subject}',
|
||||
lowercase: true,
|
||||
stripDiacritics: true,
|
||||
spaceReplacement: 'underscore',
|
||||
}),
|
||||
).toBe('alice_sender-benachrichtigung_von_ihrem_gerat.eml');
|
||||
});
|
||||
|
||||
it('falls back to "no subject" for an empty subject', () => {
|
||||
expect(emailExportFilename(makeEmail({ subject: '' }), '{subject}')).toBe('no subject.eml');
|
||||
});
|
||||
|
||||
it('falls back to "email" when the template renders empty', () => {
|
||||
expect(emailExportFilename(makeEmail({}), '{unknown_token}')).toBe('email.eml');
|
||||
});
|
||||
|
||||
it('lets a single long token reach the 200-char filename cap', () => {
|
||||
// Each {token} is now capped at FILENAME_MAX_LEN (200), so the overall
|
||||
// filename limit governs instead of an earlier 80-char per-token cap.
|
||||
const out = emailExportFilename(makeEmail({ subject: 'a'.repeat(300) }), '{subject}');
|
||||
expect(out).toBe('a'.repeat(200) + '.eml');
|
||||
});
|
||||
});
|
||||
|
||||
describe('attachmentDownloadFilename', () => {
|
||||
it('email===null: sanitises the raw attachment name, applying transforms', () => {
|
||||
expect(attachmentDownloadFilename(null, { name: 'Report.PDF' })).toBe('Report.PDF');
|
||||
expect(attachmentDownloadFilename(null, { name: 'Report.PDF' }, { lowercase: true })).toBe('report.pdf');
|
||||
});
|
||||
|
||||
it('{filename} token preserves the original extension', () => {
|
||||
expect(
|
||||
attachmentDownloadFilename(buildSampleEmail(), { name: 'My Report.pdf' }, '{filename}'),
|
||||
).toBe('My Report.pdf');
|
||||
});
|
||||
|
||||
it('template without {ext}/{filename} appends the attachment extension', () => {
|
||||
expect(
|
||||
attachmentDownloadFilename(buildSampleEmail(), { name: 'My Report.PDF' }, '{name}'),
|
||||
).toBe('My Report.PDF');
|
||||
expect(
|
||||
attachmentDownloadFilename(buildSampleEmail(), { name: 'My Report.PDF' }, { template: '{name}', lowercase: true }),
|
||||
).toBe('my report.pdf');
|
||||
});
|
||||
|
||||
it('attachment with no extension yields no trailing dot', () => {
|
||||
expect(attachmentDownloadFilename(buildSampleEmail(), { name: 'noext' }, '{name}')).toBe('noext');
|
||||
});
|
||||
|
||||
it('sanitises path-traversal characters out of the name', () => {
|
||||
const out = attachmentDownloadFilename(null, { name: '../../etc/passwd' });
|
||||
expect(out).not.toContain('/');
|
||||
// CHARACTERISATION: slashes → "_", then the leading "._-" run is stripped,
|
||||
// so "../../etc/passwd" collapses to "etc_passwd".
|
||||
expect(out).toBe('etc_passwd');
|
||||
});
|
||||
});
|
||||
|
||||
describe('bundleExportFilename', () => {
|
||||
it('substitutes {count} and appends .zip', () => {
|
||||
expect(bundleExportFilename(3, '{count}-emails', '2026-05-22T14:05:33Z')).toBe('3-emails.zip');
|
||||
});
|
||||
|
||||
it('uses the default template', () => {
|
||||
expect(bundleExportFilename(5, {}, '2026-05-22T14:05:33Z')).toBe('emails-5.zip');
|
||||
});
|
||||
});
|
||||
|
||||
describe('attachmentsBundleFilename', () => {
|
||||
it('embeds the sanitised subject', () => {
|
||||
expect(attachmentsBundleFilename(makeEmail({ subject: 'Invoice March' }))).toBe('attachments_Invoice March.zip');
|
||||
});
|
||||
|
||||
it('sanitises filesystem-reserved characters', () => {
|
||||
expect(attachmentsBundleFilename(makeEmail({ subject: 'Q1/Q2: report' }))).toBe('attachments_Q1_Q2_ report.zip');
|
||||
});
|
||||
|
||||
it('falls back when the subject is empty', () => {
|
||||
expect(attachmentsBundleFilename(makeEmail({ subject: '' }))).toBe('attachments.zip');
|
||||
});
|
||||
|
||||
it('falls back when there is no email', () => {
|
||||
expect(attachmentsBundleFilename(null)).toBe('attachments.zip');
|
||||
});
|
||||
});
|
||||
|
||||
describe('emailVars (date + address labels)', () => {
|
||||
it('returns the invalid-date sentinel for an unparseable date', () => {
|
||||
const v = emailVars(makeEmail({ receivedAt: 'not-a-date', sentAt: undefined }));
|
||||
expect(v.date).toBe('0000-00-00 00.00.00');
|
||||
expect(v.date_short).toBe('0000-00-00');
|
||||
expect(v.time).toBe('00.00.00');
|
||||
expect(v.year).toBe('0000');
|
||||
});
|
||||
|
||||
it('addrLabel falls back name → email user-part → "unknown"', () => {
|
||||
expect(emailVars(makeEmail({ from: [{ email: 'alice@example.com' }] }) ).from).toBe('alice');
|
||||
expect(emailVars(makeEmail({ from: [] })).from).toBe('unknown');
|
||||
expect(emailVars(makeEmail({ from: [{ name: ' ', email: 'x@y.com' }] })).from).toBe('x');
|
||||
});
|
||||
});
|
||||
|
||||
describe('attachmentVars (extension split)', () => {
|
||||
it('splits name and ext on the last dot', () => {
|
||||
const v = attachmentVars(buildSampleEmail(), { name: 'doc.tar.gz' });
|
||||
expect(v).toMatchObject({ filename: 'doc.tar.gz', name: 'doc.tar', ext: 'gz' });
|
||||
});
|
||||
|
||||
it('treats a dotless name as having no extension', () => {
|
||||
const v = attachmentVars(buildSampleEmail(), { name: 'noext' });
|
||||
expect(v).toMatchObject({ name: 'noext', ext: '' });
|
||||
});
|
||||
|
||||
it('defaults a missing name to "attachment"', () => {
|
||||
const v = attachmentVars(buildSampleEmail(), {});
|
||||
expect(v).toMatchObject({ filename: 'attachment', ext: '' });
|
||||
});
|
||||
});
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
parseRecipient,
|
||||
parseRecipientList,
|
||||
formatRecipientList,
|
||||
splitPastedRecipients,
|
||||
} from "../email-composer-utils";
|
||||
|
||||
describe("plainTextToComposerBody", () => {
|
||||
@@ -150,6 +151,16 @@ describe("splitRecipients", () => {
|
||||
it("returns an empty array for an empty string", () => {
|
||||
expect(splitRecipients("")).toEqual([]);
|
||||
});
|
||||
|
||||
it("only splits on the given separators (default comma keeps semicolons/newlines literal)", () => {
|
||||
expect(splitRecipients("a@x.com; b@y.com")).toEqual(["a@x.com; b@y.com"]);
|
||||
});
|
||||
|
||||
it("splits on a wider separator set while keeping quotes/angles literal", () => {
|
||||
expect(
|
||||
splitRecipients('"Doo, John" <john@doo.org>; a@x.com\nb@y.com', ',;\n\r'),
|
||||
).toEqual(['"Doo, John" <john@doo.org>', "a@x.com", "b@y.com"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("formatRecipient / parseRecipient", () => {
|
||||
@@ -198,3 +209,68 @@ describe("parseRecipientList / formatRecipientList", () => {
|
||||
expect(parseRecipientList("")).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("splitPastedRecipients", () => {
|
||||
it("splits on commas, semicolons and whitespace (incl. newline/tab)", () => {
|
||||
const { valid, invalid } = splitPastedRecipients(
|
||||
"a@x.com, b@y.com; c@z.com\nd@w.com\te@v.com f@u.com",
|
||||
);
|
||||
expect(valid.map((r) => r.email)).toEqual([
|
||||
"a@x.com", "b@y.com", "c@z.com", "d@w.com", "e@v.com", "f@u.com",
|
||||
]);
|
||||
expect(invalid).toEqual([]);
|
||||
});
|
||||
|
||||
it("collapses runs of mixed separators and drops empties", () => {
|
||||
const { valid } = splitPastedRecipients(" a@x.com ,; , b@y.com ");
|
||||
expect(valid.map((r) => r.email)).toEqual(["a@x.com", "b@y.com"]);
|
||||
});
|
||||
|
||||
it("partitions invalid tokens into `invalid`, keeping valid as chips", () => {
|
||||
const { valid, invalid } = splitPastedRecipients("a@x.com not-an-email b@y.com");
|
||||
expect(valid.map((r) => r.email)).toEqual(["a@x.com", "b@y.com"]);
|
||||
expect(invalid).toEqual(["not-an-email"]);
|
||||
});
|
||||
|
||||
it("unwraps an angle-bracketed token before validating", () => {
|
||||
const { valid } = splitPastedRecipients("<a@x.com>");
|
||||
expect(valid).toEqual([{ email: "a@x.com" }]);
|
||||
});
|
||||
|
||||
it("keeps a `Name <email>` pair as a single chip with its display name", () => {
|
||||
const { valid, invalid } = splitPastedRecipients("John Doe <j@x.com>");
|
||||
expect(valid).toEqual([{ name: "John Doe", email: "j@x.com" }]);
|
||||
expect(invalid).toEqual([]);
|
||||
});
|
||||
|
||||
it("keeps a fully-quoted `\"Name <email>\"` entry with its display name", () => {
|
||||
const { valid, invalid } = splitPastedRecipients(
|
||||
'"Alice Smith <alice@x.com>", "Alex Smith <alex@x.com>"',
|
||||
);
|
||||
expect(valid).toEqual([
|
||||
{ name: "Alice Smith", email: "alice@x.com" },
|
||||
{ name: "Alex Smith", email: "alex@x.com" },
|
||||
]);
|
||||
expect(invalid).toEqual([]);
|
||||
});
|
||||
|
||||
it("keeps a comma inside a quoted display name intact", () => {
|
||||
const { valid } = splitPastedRecipients('"Doe, John" <j@x.com>; bob@z.com');
|
||||
expect(valid).toEqual([
|
||||
{ name: "Doe, John", email: "j@x.com" },
|
||||
{ email: "bob@z.com" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("dedupes case-insensitively within the paste and against existing emails", () => {
|
||||
const { valid } = splitPastedRecipients(
|
||||
"a@x.com A@X.com b@y.com c@z.com",
|
||||
["B@Y.com"],
|
||||
);
|
||||
expect(valid.map((r) => r.email)).toEqual(["a@x.com", "c@z.com"]);
|
||||
});
|
||||
|
||||
it("returns empty arrays for blank input", () => {
|
||||
expect(splitPastedRecipients(" ")).toEqual({ valid: [], invalid: [] });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -207,10 +207,10 @@ describe('getSecurityStatus', () => {
|
||||
expect(status.color).toContain('red');
|
||||
});
|
||||
|
||||
it('returns amber for softfail', () => {
|
||||
it('returns a warning color for softfail', () => {
|
||||
const status = getSecurityStatus('softfail');
|
||||
expect(status.icon).toBe('alert');
|
||||
expect(status.color).toContain('amber');
|
||||
expect(status.color).toContain('warning');
|
||||
});
|
||||
|
||||
it('returns amber for neutral and temperror', () => {
|
||||
|
||||
@@ -7,6 +7,13 @@ import {
|
||||
hasRichFormatting,
|
||||
plainTextToSafeHtml,
|
||||
EMAIL_SANITIZE_CONFIG,
|
||||
EMAIL_IFRAME_SANITIZE_CONFIG,
|
||||
isExternalResourceUrl,
|
||||
decodeCssEscapes,
|
||||
styleHasExternalUrl,
|
||||
stripExternalCssUrls,
|
||||
blockExternalResourcesOnNode,
|
||||
TRANSPARENT_BLOCKED_PIXEL,
|
||||
} from '../email-sanitization';
|
||||
|
||||
describe('email-sanitization', () => {
|
||||
@@ -324,6 +331,205 @@ describe('email-sanitization', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('isExternalResourceUrl', () => {
|
||||
it('detects http(s) and protocol-relative URLs', () => {
|
||||
expect(isExternalResourceUrl('https://tracker.example/p.png')).toBe(true);
|
||||
expect(isExternalResourceUrl('http://tracker.example/p.png')).toBe(true);
|
||||
expect(isExternalResourceUrl('//tracker.example/p.png')).toBe(true);
|
||||
});
|
||||
|
||||
it('sees through leading whitespace/newlines (imgNewlineSrc bypass)', () => {
|
||||
expect(isExternalResourceUrl('\n\nhttps://tracker.example/p.png')).toBe(true);
|
||||
expect(isExternalResourceUrl(' \t https://tracker.example/p.png')).toBe(true);
|
||||
// Tab/newline removed anywhere in the URL by the parser.
|
||||
expect(isExternalResourceUrl('h\nttps://tracker.example/p.png')).toBe(true);
|
||||
expect(isExternalResourceUrl('ht\ttps://tracker.example/p.png')).toBe(true);
|
||||
});
|
||||
|
||||
it('treats inline/local schemes as not external', () => {
|
||||
expect(isExternalResourceUrl('data:image/png;base64,AAAA')).toBe(false);
|
||||
expect(isExternalResourceUrl('blob:http://localhost/abc')).toBe(false);
|
||||
expect(isExternalResourceUrl('cid:image001@example.com')).toBe(false);
|
||||
expect(isExternalResourceUrl('/relative/path.png')).toBe(false);
|
||||
expect(isExternalResourceUrl('')).toBe(false);
|
||||
expect(isExternalResourceUrl(null)).toBe(false);
|
||||
expect(isExternalResourceUrl(undefined)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('decodeCssEscapes', () => {
|
||||
it('decodes hex escapes (cssEscape bypass)', () => {
|
||||
expect(decodeCssEscapes('\\68ttp://x')).toBe('http://x');
|
||||
expect(decodeCssEscapes('\\000068ttps://x')).toBe('https://x');
|
||||
// Hex escape consumes one trailing whitespace separator.
|
||||
expect(decodeCssEscapes('\\68 ttp')).toBe('http');
|
||||
});
|
||||
|
||||
it('decodes single-character escapes', () => {
|
||||
expect(decodeCssEscapes('\\h\\t\\t\\p')).toBe('http');
|
||||
});
|
||||
});
|
||||
|
||||
describe('styleHasExternalUrl / stripExternalCssUrls', () => {
|
||||
it('detects and strips plain external url()', () => {
|
||||
const style = 'background:url(https://tracker.example/p.png)';
|
||||
expect(styleHasExternalUrl(style)).toBe(true);
|
||||
expect(stripExternalCssUrls(style)).toBe('background:url()');
|
||||
});
|
||||
|
||||
it('detects and strips CSS-escaped external url()', () => {
|
||||
const style = 'background:url(\\68ttps://tracker.example/p.png)';
|
||||
expect(styleHasExternalUrl(style)).toBe(true);
|
||||
expect(stripExternalCssUrls(style)).toBe('background:url()');
|
||||
});
|
||||
|
||||
it('detects url() with whitespace/quotes', () => {
|
||||
expect(styleHasExternalUrl("background: url( '\n https://t/p.png' )")).toBe(true);
|
||||
});
|
||||
|
||||
it('leaves data: and relative url() untouched', () => {
|
||||
const style = "background:url('data:image/png;base64,AAAA')";
|
||||
expect(styleHasExternalUrl(style)).toBe(false);
|
||||
expect(stripExternalCssUrls(style)).toBe(style);
|
||||
});
|
||||
});
|
||||
|
||||
describe('blockExternalResourcesOnNode (anti-tracking vectors)', () => {
|
||||
function el(html: string): Element {
|
||||
return parseHtmlSafely(`<body>${html}</body>`).body.firstElementChild!;
|
||||
}
|
||||
|
||||
it('blocks an img whose src is hidden behind a leading newline', () => {
|
||||
const img = el('<img src="">');
|
||||
img.setAttribute('src', '\n\nhttps://tracker.example/pixel.png');
|
||||
expect(blockExternalResourcesOnNode(img)).toBe(true);
|
||||
expect(img.getAttribute('data-blocked-src')).toBe('https://tracker.example/pixel.png');
|
||||
expect(img.getAttribute('src')).toBe(TRANSPARENT_BLOCKED_PIXEL);
|
||||
});
|
||||
|
||||
it('blocks img srcset', () => {
|
||||
const img = el('<img srcset="https://tracker.example/1x.png 1x, https://tracker.example/2x.png 2x">');
|
||||
expect(blockExternalResourcesOnNode(img)).toBe(true);
|
||||
expect(img.hasAttribute('srcset')).toBe(false);
|
||||
expect(img.getAttribute('data-blocked-srcset')).toContain('tracker.example');
|
||||
});
|
||||
|
||||
it('blocks <picture><source srcset> (pictureSource)', () => {
|
||||
const source = el('<source srcset="https://tracker.example/pic.webp" type="image/webp">');
|
||||
expect(blockExternalResourcesOnNode(source)).toBe(true);
|
||||
expect(source.hasAttribute('srcset')).toBe(false);
|
||||
});
|
||||
|
||||
it('blocks <source src> for media', () => {
|
||||
const source = el('<source src="https://tracker.example/v.mp4">');
|
||||
expect(blockExternalResourcesOnNode(source)).toBe(true);
|
||||
expect(source.hasAttribute('src')).toBe(false);
|
||||
expect(source.getAttribute('data-blocked-src')).toContain('tracker.example');
|
||||
});
|
||||
|
||||
it('blocks <video poster> (videoPoster)', () => {
|
||||
const video = el('<video poster="https://tracker.example/poster.jpg"></video>');
|
||||
expect(blockExternalResourcesOnNode(video)).toBe(true);
|
||||
expect(video.hasAttribute('poster')).toBe(false);
|
||||
expect(video.getAttribute('data-blocked-poster')).toContain('tracker.example');
|
||||
});
|
||||
|
||||
it('blocks video src', () => {
|
||||
const video = el('<video src="https://tracker.example/v.mp4"></video>');
|
||||
expect(blockExternalResourcesOnNode(video)).toBe(true);
|
||||
expect(video.hasAttribute('src')).toBe(false);
|
||||
});
|
||||
|
||||
it('blocks the legacy background attribute', () => {
|
||||
// <td> is foster-parented out of <body>, so build it directly.
|
||||
const td = document.createElement('td');
|
||||
td.setAttribute('background', 'https://tracker.example/bg.png');
|
||||
expect(blockExternalResourcesOnNode(td)).toBe(true);
|
||||
expect(td.hasAttribute('background')).toBe(false);
|
||||
expect(td.getAttribute('data-blocked-background')).toContain('tracker.example');
|
||||
});
|
||||
|
||||
it('strips external inline style url() including CSS escapes (cssEscape)', () => {
|
||||
const div = el('<div style="background:url(\\68ttps://tracker.example/p.png)">x</div>');
|
||||
expect(blockExternalResourcesOnNode(div)).toBe(true);
|
||||
expect(div.getAttribute('style')).not.toContain('tracker.example');
|
||||
expect(div.getAttribute('data-blocked-style')).toContain('tracker.example');
|
||||
});
|
||||
|
||||
it('does not block inline/local resources', () => {
|
||||
const img = el('<img src="blob:http://localhost/inline">');
|
||||
expect(blockExternalResourcesOnNode(img)).toBe(false);
|
||||
expect(img.getAttribute('src')).toBe('blob:http://localhost/inline');
|
||||
|
||||
const dataImg = el('<img src="data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7">');
|
||||
expect(blockExternalResourcesOnNode(dataImg)).toBe(false);
|
||||
|
||||
const cidImg = el('<img src="cid:logo@example.com">');
|
||||
expect(blockExternalResourcesOnNode(cidImg)).toBe(false);
|
||||
});
|
||||
|
||||
it('works as a DOMPurify afterSanitizeAttributes hook across all vectors', () => {
|
||||
const html = `
|
||||
<img src=" https://tracker.example/a.png">
|
||||
<picture><source srcset="https://tracker.example/b.webp"><img src="https://tracker.example/c.png"></picture>
|
||||
<div style="background:url(\\68ttps://tracker.example/d.png)">bg</div>
|
||||
`;
|
||||
DOMPurify.addHook('afterSanitizeAttributes', (node) => {
|
||||
blockExternalResourcesOnNode(node as Element);
|
||||
});
|
||||
const clean = DOMPurify.sanitize(html, EMAIL_SANITIZE_CONFIG);
|
||||
DOMPurify.removeAllHooks();
|
||||
|
||||
const doc = parseHtmlSafely(clean);
|
||||
// No live src/srcset/style references the tracker anymore.
|
||||
doc.querySelectorAll('img, source').forEach((node) => {
|
||||
expect(node.getAttribute('src') ?? '').not.toContain('tracker.example');
|
||||
expect(node.getAttribute('srcset') ?? '').not.toContain('tracker.example');
|
||||
});
|
||||
expect(doc.querySelector('div')?.getAttribute('style') ?? '').not.toContain('tracker.example');
|
||||
// The originals are stashed for the banner/affordance.
|
||||
expect(clean).toContain('data-blocked-src');
|
||||
expect(clean).toContain('data-blocked-srcset');
|
||||
expect(clean).toContain('data-blocked-style');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Email Privacy Tester exact payloads (iframe render path)', () => {
|
||||
function render(html: string): string {
|
||||
DOMPurify.addHook('afterSanitizeAttributes', (node) =>
|
||||
blockExternalResourcesOnNode(node as Element)
|
||||
);
|
||||
const out = DOMPurify.sanitize(html, EMAIL_IFRAME_SANITIZE_CONFIG);
|
||||
DOMPurify.removeAllHooks();
|
||||
return out;
|
||||
}
|
||||
|
||||
it('pictureSource: <picture><source srcset> does not keep a live external ref', () => {
|
||||
const source = parseHtmlSafely(render('<picture><source srcset="http://TRACK/"><img src="#"></picture>')).querySelector('source')!;
|
||||
expect(source.hasAttribute('srcset')).toBe(false);
|
||||
expect(source.getAttribute('data-blocked-srcset')).toContain('TRACK');
|
||||
});
|
||||
|
||||
it('imgNewlineSrc: newline after the first slash (protocol-relative) is blocked', () => {
|
||||
const img = parseHtmlSafely(render('<img src="/\n/TRACK_HOST/PATH">')).querySelector('img')!;
|
||||
expect(img.getAttribute('src')).toBe(TRANSPARENT_BLOCKED_PIXEL);
|
||||
expect(img.getAttribute('data-blocked-src')).toContain('TRACK_HOST');
|
||||
});
|
||||
|
||||
it('videoPoster: poster and src are both stripped', () => {
|
||||
const video = parseHtmlSafely(render('<video poster="http://TRACK/" autoplay="true" src="http://OTHER/"></video>')).querySelector('video')!;
|
||||
expect(video.hasAttribute('poster')).toBe(false);
|
||||
expect(video.hasAttribute('src')).toBe(false);
|
||||
expect(video.getAttribute('data-blocked-poster')).toContain('TRACK');
|
||||
expect(video.getAttribute('data-blocked-src')).toContain('OTHER');
|
||||
});
|
||||
|
||||
it('anchor href is preserved (links stay clickable; DNS prefetch is disabled via iframe meta)', () => {
|
||||
const out = render('<a href="http://TRACK/">link</a>');
|
||||
expect(out).toContain('href="http://TRACK/"');
|
||||
});
|
||||
});
|
||||
|
||||
describe('plainTextToSafeHtml', () => {
|
||||
it('escapes HTML-special characters in surrounding text', () => {
|
||||
const result = plainTextToSafeHtml('<script>alert(1)</script> & "q" \'q\'');
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import JSZip from 'jszip';
|
||||
import { expandImportableEmails, EML_IMPORT_ACCEPT } from '@/lib/eml-import';
|
||||
|
||||
const emlFile = (name: string, content = 'raw email', type = 'message/rfc822') =>
|
||||
new File([content], name, { type });
|
||||
|
||||
describe('expandImportableEmails', () => {
|
||||
it('wraps a .eml file as a message/rfc822 blob, keeping its name', async () => {
|
||||
const out = await expandImportableEmails([emlFile('msg.eml')]);
|
||||
expect(out).toHaveLength(1);
|
||||
expect(out[0].name).toBe('msg.eml');
|
||||
expect(out[0].blob.type).toBe('message/rfc822');
|
||||
await expect(out[0].blob.text()).resolves.toBe('raw email');
|
||||
});
|
||||
|
||||
it('CHARACTERISATION: wraps a non-.eml, non-zip file as rfc822 too', async () => {
|
||||
const out = await expandImportableEmails([emlFile('note.txt', 'hi', 'text/plain')]);
|
||||
expect(out).toHaveLength(1);
|
||||
expect(out[0]).toMatchObject({ name: 'note.txt' });
|
||||
expect(out[0].blob.type).toBe('message/rfc822');
|
||||
});
|
||||
|
||||
it('extracts only .eml entries from a .zip, stripping path prefixes', async () => {
|
||||
const zip = new JSZip();
|
||||
zip.file('a.eml', 'A');
|
||||
zip.file('sub/b.eml', 'B');
|
||||
zip.file('c.txt', 'C'); // skipped (not .eml)
|
||||
zip.folder('emptydir'); // skipped (directory)
|
||||
const blob = await zip.generateAsync({ type: 'blob' });
|
||||
const file = new File([blob], 'archive.zip', { type: 'application/zip' });
|
||||
|
||||
const out = await expandImportableEmails([file]);
|
||||
expect(out.map((e) => e.name).sort()).toEqual(['a.eml', 'b.eml']);
|
||||
expect(out.every((e) => e.blob.type === 'message/rfc822')).toBe(true);
|
||||
});
|
||||
|
||||
it('uses the zip path when the MIME type is application/zip even without a .zip name', async () => {
|
||||
const zip = new JSZip();
|
||||
zip.file('only.eml', 'X');
|
||||
const blob = await zip.generateAsync({ type: 'blob' });
|
||||
const file = new File([blob], 'archive-no-ext', { type: 'application/zip' });
|
||||
|
||||
const out = await expandImportableEmails([file]);
|
||||
expect(out.map((e) => e.name)).toEqual(['only.eml']);
|
||||
});
|
||||
|
||||
it('exposes the accept string for the file picker', () => {
|
||||
expect(EML_IMPORT_ACCEPT).toBe('.eml,.zip,message/rfc822,application/zip');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,34 @@
|
||||
// Shared test factories. Additive only — existing tests keep their inline
|
||||
// factories; new tests can import these to avoid re-declaring the large
|
||||
// Email/Mailbox literals. Keep minimal and cast through `unknown` so callers
|
||||
// only specify the fields they assert on.
|
||||
import type { Email, Mailbox } from '@/lib/jmap/types';
|
||||
import type { IJMAPClient } from '@/lib/jmap/client-interface';
|
||||
|
||||
export const makeEmail = (over: Partial<Email> = {}): Email =>
|
||||
({
|
||||
id: 'e1',
|
||||
threadId: 't1',
|
||||
receivedAt: '2026-01-01T00:00:00Z',
|
||||
subject: '',
|
||||
from: [],
|
||||
to: [],
|
||||
cc: [],
|
||||
keywords: {},
|
||||
mailboxIds: {},
|
||||
...over,
|
||||
} as unknown as Email);
|
||||
|
||||
export const makeMailbox = (over: Partial<Mailbox> = {}): Mailbox =>
|
||||
({
|
||||
id: 'mb1',
|
||||
name: 'Inbox',
|
||||
role: 'inbox',
|
||||
unreadEmails: 0,
|
||||
totalEmails: 0,
|
||||
...over,
|
||||
} as unknown as Mailbox);
|
||||
|
||||
/** A bare fake JMAP client: only the methods you pass exist. */
|
||||
export const makeFakeJmapClient = (over: Partial<IJMAPClient> = {}): IJMAPClient =>
|
||||
over as unknown as IJMAPClient;
|
||||
@@ -0,0 +1,43 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { localizeMailboxName } from "@/lib/mailbox-label";
|
||||
|
||||
// Stand-in translator mirroring the `sidebar.mailboxes` namespace.
|
||||
const RU: Record<string, string> = {
|
||||
inbox: "Входящие",
|
||||
sent: "Отправленные",
|
||||
drafts: "Черновики",
|
||||
trash: "Корзина",
|
||||
archive: "Архив",
|
||||
spam: "Спам",
|
||||
important: "Важные",
|
||||
starred: "Помечённые",
|
||||
all_mail: "Вся почта",
|
||||
};
|
||||
const translate = (key: string) => RU[key] ?? `MISSING:${key}`;
|
||||
|
||||
describe("localizeMailboxName", () => {
|
||||
it("localizes special-use folders by JMAP role, ignoring the server name", () => {
|
||||
expect(localizeMailboxName("inbox", "Inbox", translate)).toBe("Входящие");
|
||||
expect(localizeMailboxName("sent", "Sent", translate)).toBe("Отправленные");
|
||||
expect(localizeMailboxName("drafts", "Drafts", translate)).toBe("Черновики");
|
||||
expect(localizeMailboxName("trash", "Deleted Items", translate)).toBe("Корзина");
|
||||
expect(localizeMailboxName("archive", "Archive", translate)).toBe("Архив");
|
||||
expect(localizeMailboxName("important", "Important", translate)).toBe("Важные");
|
||||
});
|
||||
|
||||
it("maps junk/flagged/all onto reused translation keys", () => {
|
||||
expect(localizeMailboxName("junk", "Junk", translate)).toBe("Спам");
|
||||
expect(localizeMailboxName("flagged", "Flagged", translate)).toBe("Помечённые");
|
||||
expect(localizeMailboxName("all", "All Mail", translate)).toBe("Вся почта");
|
||||
});
|
||||
|
||||
it("leaves user-created folders (no role) untouched", () => {
|
||||
expect(localizeMailboxName(undefined, "Projects", translate)).toBe("Projects");
|
||||
expect(localizeMailboxName(null, "Работа", translate)).toBe("Работа");
|
||||
expect(localizeMailboxName("", "Receipts", translate)).toBe("Receipts");
|
||||
});
|
||||
|
||||
it("falls back to the server name for unknown roles", () => {
|
||||
expect(localizeMailboxName("subscribed", "Subscribed", translate)).toBe("Subscribed");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,38 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { parseMailto } from "@/lib/protocol-handlers/mailto";
|
||||
import { formatRecipient, parseRecipientList } from "@/lib/email-composer-utils";
|
||||
|
||||
// Build a mailto: URL the way a recipient picker encodes one: each recipient
|
||||
// percent-encoded and comma-joined, To in the path, Cc/Bcc in a query param.
|
||||
// Used only to exercise parseMailto's quote-aware recipient splitting.
|
||||
function encodeMailto(recipients: string[], field: "to" | "cc" | "bcc"): string {
|
||||
const encoded = recipients.map((r) => encodeURIComponent(r)).join(",");
|
||||
return field === "to" ? `mailto:${encoded}` : `mailto:?${field}=${encoded}`;
|
||||
}
|
||||
|
||||
describe("parseMailto display-name handling", () => {
|
||||
it("preserves display names through the mailto round-trip", () => {
|
||||
const recipients = [
|
||||
formatRecipient("Alice Smith", "alice@x.com"),
|
||||
formatRecipient("Bob", "bob@y.com"),
|
||||
];
|
||||
const parsed = parseMailto(encodeMailto(recipients, "to"));
|
||||
expect(parseRecipientList(parsed!.to.join(", "))).toEqual([
|
||||
{ name: "Alice Smith", email: "alice@x.com" },
|
||||
{ name: "Bob", email: "bob@y.com" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("keeps a display name containing a comma intact (quote-aware split)", () => {
|
||||
const recipients = [
|
||||
formatRecipient("Doe, John", "john@doe.org"), // -> "Doe, John" <john@doe.org>
|
||||
"alice@x.com",
|
||||
];
|
||||
const parsed = parseMailto(encodeMailto(recipients, "cc"));
|
||||
expect(parsed!.cc).toEqual(['"Doe, John" <john@doe.org>', "alice@x.com"]);
|
||||
expect(parseRecipientList(parsed!.cc.join(", "))).toEqual([
|
||||
{ name: "Doe, John", email: "john@doe.org" },
|
||||
{ email: "alice@x.com" },
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,95 @@
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
||||
import { buildMdnMessage } from '@/lib/mdn';
|
||||
|
||||
// buildMdnMessage pulls in Date / Date.now / Math.random for the Date header,
|
||||
// Message-ID and MIME boundary. Pin all three so the output is reproducible.
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date('2026-05-28T14:23:00Z'));
|
||||
vi.spyOn(Math, 'random').mockReturnValue(0.5); // (0.5).toString(36).slice(2) === 'i'
|
||||
});
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
const base = {
|
||||
to: 'sender@other.com',
|
||||
fromEmail: 'me@example.com',
|
||||
originalSubject: 'Hello',
|
||||
originalMessageId: 'orig@other.com',
|
||||
};
|
||||
|
||||
describe('buildMdnMessage — structure', () => {
|
||||
it('emits the expected headers and a manual disposition by default', () => {
|
||||
const msg = buildMdnMessage(base);
|
||||
expect(msg).toContain('Date: Thu, 28 May 2026 14:23:00 +0000'); // rfc5322 UTC
|
||||
expect(msg).toMatch(/^From: me@example\.com$/m);
|
||||
expect(msg).toMatch(/^To: sender@other\.com$/m);
|
||||
expect(msg).toMatch(/^Subject: Read: Hello$/m); // default subject
|
||||
expect(msg).toMatch(/^Message-ID: <mdn\.[0-9a-z]+\.i@example\.com>$/m); // random token = 'i'
|
||||
expect(msg).toMatch(/^In-Reply-To: <orig@other\.com>$/m);
|
||||
expect(msg).toContain('Original-Message-ID: <orig@other.com>');
|
||||
expect(msg).toContain('Disposition: manual-action/MDN-sent-manually; displayed');
|
||||
expect(msg).toContain('Final-Recipient: rfc822;me@example.com');
|
||||
expect(msg).not.toContain('Original-Recipient:');
|
||||
expect(msg).toContain('Reporting-UA: example.com; Bulwark Webmail');
|
||||
});
|
||||
|
||||
it('uses CRLF line endings everywhere', () => {
|
||||
const msg = buildMdnMessage(base);
|
||||
expect(msg).toContain('\r\n');
|
||||
expect(msg).not.toMatch(/[^\r]\n/); // no bare LF
|
||||
});
|
||||
|
||||
it('marks an automatic action when automatic:true', () => {
|
||||
expect(buildMdnMessage({ ...base, automatic: true })).toContain(
|
||||
'Disposition: automatic-action/MDN-sent-automatically; displayed',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildMdnMessage — header encoding & normalisation', () => {
|
||||
it('RFC2047-encodes non-ASCII From name and Subject', () => {
|
||||
const msg = buildMdnMessage({ ...base, fromName: 'Müller', subject: 'Übersicht' });
|
||||
expect(msg).toMatch(/^From: =\?UTF-8\?B\?[A-Za-z0-9+/=]+\?= <me@example\.com>$/m);
|
||||
expect(msg).toMatch(/^Subject: =\?UTF-8\?B\?[A-Za-z0-9+/=]+\?=$/m);
|
||||
});
|
||||
|
||||
it('normalises Message-ID from a string[] and adds missing angle brackets', () => {
|
||||
expect(buildMdnMessage({ ...base, originalMessageId: ['arr@x.com'] })).toMatch(
|
||||
/^In-Reply-To: <arr@x\.com>$/m,
|
||||
);
|
||||
expect(buildMdnMessage({ ...base, originalMessageId: 'bare@x.com' })).toContain(
|
||||
'Original-Message-ID: <bare@x.com>',
|
||||
);
|
||||
});
|
||||
|
||||
it('omits In-Reply-To / Original-Message-ID when no original id is given', () => {
|
||||
const msg = buildMdnMessage({ to: base.to, fromEmail: base.fromEmail });
|
||||
expect(msg).not.toContain('In-Reply-To:');
|
||||
expect(msg).not.toContain('Original-Message-ID:');
|
||||
});
|
||||
|
||||
it('adds Original-Recipient and uses it as Final-Recipient when supplied', () => {
|
||||
const msg = buildMdnMessage({ ...base, originalRecipient: 'alias@example.com' });
|
||||
expect(msg).toContain('Original-Recipient: rfc822;alias@example.com');
|
||||
expect(msg).toContain('Final-Recipient: rfc822;alias@example.com');
|
||||
});
|
||||
|
||||
it('falls back to the localhost domain when fromEmail has no @', () => {
|
||||
const msg = buildMdnMessage({ to: base.to, fromEmail: 'invalid' });
|
||||
expect(msg).toMatch(/^Message-ID: <mdn\.[0-9a-z]+\.i@localhost>$/m);
|
||||
expect(msg).toContain('Reporting-UA: localhost; Bulwark Webmail');
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildMdnMessage — body', () => {
|
||||
it('base64-encodes the human-readable part wrapped at 76 columns', () => {
|
||||
const msg = buildMdnMessage({ ...base, humanText: 'A'.repeat(100) });
|
||||
const lines = msg.split('\r\n');
|
||||
// A 100-char ASCII body → 136 base64 chars → a 76-char line + a 60-char line.
|
||||
expect(lines.some((l) => l.length === 76 && /^[A-Za-z0-9+/]+$/.test(l))).toBe(true);
|
||||
expect(lines.every((l) => !/^[A-Za-z0-9+/]+={0,2}$/.test(l) || l.length <= 76)).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -15,14 +15,12 @@ beforeEach(() => {
|
||||
});
|
||||
|
||||
describe('exposePluginExternals', () => {
|
||||
it('sets window.__PLUGIN_EXTERNALS__ with React, ReactDOM, ReactJSX', () => {
|
||||
it('is a no-op that does not publish globals (sandbox injects React per-iframe)', () => {
|
||||
exposePluginExternals();
|
||||
// The blob-import loader that needed window.__PLUGIN_EXTERNALS__ is gone;
|
||||
// exposePluginExternals is kept only as a no-op for legacy callers.
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const externals = (globalThis as any).__PLUGIN_EXTERNALS__;
|
||||
expect(externals).toBeDefined();
|
||||
expect(externals.React).toBeDefined();
|
||||
expect(externals.ReactDOM).toBeDefined();
|
||||
expect(externals.ReactJSX).toBeDefined();
|
||||
expect((globalThis as any).__PLUGIN_EXTERNALS__).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -1,14 +1,22 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import React from 'react';
|
||||
import { render } from '@testing-library/react';
|
||||
import type { SlotRegistration } from '@/lib/plugin-types';
|
||||
// PluginSlot reads active plugins from the sandbox registry and renders each
|
||||
// inside a sandboxed iframe. Mock both so we can drive the offers and assert
|
||||
// what PluginSlot renders without a real iframe + postMessage bridge.
|
||||
const mockOffers: Record<string, Array<{ pluginId: string }>> = {};
|
||||
// useSyncExternalStore requires a referentially stable snapshot; hand back a
|
||||
// single shared empty array for unregistered slots instead of a fresh [].
|
||||
const EMPTY_OFFERS: Array<{ pluginId: string }> = [];
|
||||
|
||||
// Mock the plugin store
|
||||
const mockSlots: Record<string, SlotRegistration[]> = {};
|
||||
vi.mock('@/lib/plugin-sandbox/registry', () => ({
|
||||
offersForSlot: (slot: string) => mockOffers[slot] ?? EMPTY_OFFERS,
|
||||
subscribe: () => () => {},
|
||||
}));
|
||||
|
||||
vi.mock('@/stores/plugin-store', () => ({
|
||||
usePluginStore: (selector: (s: { slots: typeof mockSlots }) => unknown) =>
|
||||
selector({ slots: mockSlots }),
|
||||
vi.mock('@/components/plugins/plugin-iframe-slot', () => ({
|
||||
PluginIframeSlot: ({ pluginId, slot }: { pluginId: string; slot: string }) =>
|
||||
React.createElement('span', { 'data-iframe-plugin': pluginId }, `iframe:${slot}:${pluginId}`),
|
||||
}));
|
||||
|
||||
// Import after mocks
|
||||
@@ -16,42 +24,36 @@ import { PluginSlot } from '@/components/plugins/plugin-slot';
|
||||
import { PluginErrorBoundary } from '@/components/plugins/plugin-error-boundary';
|
||||
|
||||
beforeEach(() => {
|
||||
Object.keys(mockSlots).forEach(k => delete mockSlots[k]);
|
||||
Object.keys(mockOffers).forEach(k => delete mockOffers[k]);
|
||||
});
|
||||
|
||||
describe('PluginSlot', () => {
|
||||
it('renders null when no registrations', () => {
|
||||
mockSlots['toolbar-actions'] = [];
|
||||
it('renders null when the slot has an empty offer list', () => {
|
||||
mockOffers['toolbar-actions'] = [];
|
||||
const { container } = render(
|
||||
React.createElement(PluginSlot, { name: 'toolbar-actions' })
|
||||
);
|
||||
expect(container.innerHTML).toBe('');
|
||||
});
|
||||
|
||||
it('renders null when slot has undefined registrations', () => {
|
||||
// slot entry doesn't exist at all
|
||||
it('renders null when the slot has no offers at all', () => {
|
||||
// slot entry doesn't exist in the registry
|
||||
const { container } = render(
|
||||
React.createElement(PluginSlot, { name: 'toolbar-actions' })
|
||||
);
|
||||
expect(container.innerHTML).toBe('');
|
||||
});
|
||||
|
||||
it('renders registered components', () => {
|
||||
const TestComponent = () => React.createElement('span', null, 'Hello Plugin');
|
||||
mockSlots['email-footer'] = [
|
||||
{ pluginId: 'test', component: TestComponent, order: 100 },
|
||||
];
|
||||
it('renders an iframe slot per offer', () => {
|
||||
mockOffers['email-footer'] = [{ pluginId: 'test' }];
|
||||
const { getByText } = render(
|
||||
React.createElement(PluginSlot, { name: 'email-footer' })
|
||||
);
|
||||
expect(getByText('Hello Plugin')).toBeTruthy();
|
||||
expect(getByText('iframe:email-footer:test')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('sets data-plugin-slot attribute', () => {
|
||||
const TestComponent = () => React.createElement('span', null, 'x');
|
||||
mockSlots['sidebar-widget'] = [
|
||||
{ pluginId: 'sw', component: TestComponent, order: 100 },
|
||||
];
|
||||
mockOffers['sidebar-widget'] = [{ pluginId: 'sw' }];
|
||||
const { container } = render(
|
||||
React.createElement(PluginSlot, { name: 'sidebar-widget' })
|
||||
);
|
||||
|
||||
@@ -69,8 +69,8 @@ describe('plugin-types constants', () => {
|
||||
expect(MAX_PLUGIN_SIZE).toBe(5 * 1024 * 1024);
|
||||
});
|
||||
|
||||
it('MAX_THEME_SIZE is 1 MB', () => {
|
||||
expect(MAX_THEME_SIZE).toBe(1 * 1024 * 1024);
|
||||
it('MAX_THEME_SIZE is 2 MB', () => {
|
||||
expect(MAX_THEME_SIZE).toBe(2 * 1024 * 1024);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { buildQuoteHeader } from '@/lib/quote-header';
|
||||
|
||||
const base = {
|
||||
newTo: [] as string[],
|
||||
newCc: [] as string[],
|
||||
locale: 'en',
|
||||
timeFormat: '24h' as const,
|
||||
unknownLabel: 'Unknown',
|
||||
};
|
||||
|
||||
const sender = { name: 'Display Name', email: 'user@domain.tld' };
|
||||
|
||||
describe('buildQuoteHeader (#482 — sender address survives HTML rendering)', () => {
|
||||
it('forward TEXT keeps the full "Name <email>" sender', async () => {
|
||||
const h = await buildQuoteHeader({
|
||||
mode: 'forward',
|
||||
email: { from: [sender], subject: 'Hello', receivedAt: '2026-01-01T10:00:00Z' },
|
||||
...base,
|
||||
});
|
||||
expect(h.text).toContain('From: Display Name <user@domain.tld>');
|
||||
});
|
||||
|
||||
it('forward HTML escapes the angle brackets so the address is not eaten as a tag', async () => {
|
||||
const h = await buildQuoteHeader({
|
||||
mode: 'forward',
|
||||
email: { from: [sender], subject: 'Hello', receivedAt: '2026-01-01T10:00:00Z' },
|
||||
...base,
|
||||
});
|
||||
// The regression: a raw "<user@domain.tld>" is parsed as an HTML tag by the
|
||||
// rich-text composer and dropped, leaving only "From: Display Name".
|
||||
expect(h.html).toContain('Display Name <user@domain.tld>');
|
||||
expect(h.html).not.toContain('<user@domain.tld>');
|
||||
});
|
||||
|
||||
it('forward HTML escapes a subject containing markup (injection hardening)', async () => {
|
||||
const h = await buildQuoteHeader({
|
||||
mode: 'forward',
|
||||
email: { from: [sender], subject: 'Hi <b>x</b>', receivedAt: '2026-01-01T10:00:00Z' },
|
||||
...base,
|
||||
});
|
||||
expect(h.html).toContain('Hi <b>x</b>');
|
||||
expect(h.html).not.toContain('<b>x</b>');
|
||||
});
|
||||
|
||||
it('forward HTML escapes a malicious display name', async () => {
|
||||
const h = await buildQuoteHeader({
|
||||
mode: 'forward',
|
||||
email: {
|
||||
from: [{ name: '<img src=x onerror=alert(1)>', email: 'evil@x.tld' }],
|
||||
subject: 'Hello',
|
||||
receivedAt: '2026-01-01T10:00:00Z',
|
||||
},
|
||||
...base,
|
||||
});
|
||||
expect(h.html).not.toContain('<img src=x');
|
||||
expect(h.html).toContain('<img src=x');
|
||||
});
|
||||
|
||||
it('reply line includes the full "Name <email>" sender, escaped in HTML', async () => {
|
||||
const h = await buildQuoteHeader({
|
||||
mode: 'reply',
|
||||
email: { from: [sender], subject: 'Hello', receivedAt: '2026-01-01T10:00:00Z' },
|
||||
...base,
|
||||
});
|
||||
// TEXT keeps the real angle brackets ("On <date>, Display Name <user@domain.tld> wrote:").
|
||||
expect(h.text).toContain('Display Name <user@domain.tld> wrote:');
|
||||
// HTML escapes them so the address survives the rich-text editor (#482).
|
||||
expect(h.html).toContain('Display Name <user@domain.tld>');
|
||||
expect(h.html).not.toContain('<user@domain.tld>');
|
||||
});
|
||||
|
||||
it('reply line stays HTML-safe for a display name containing markup', async () => {
|
||||
const evil = await buildQuoteHeader({
|
||||
mode: 'reply',
|
||||
email: { from: [{ name: '<b>x</b>', email: 'e@x.tld' }], subject: 'Hello', receivedAt: '2026-01-01T10:00:00Z' },
|
||||
...base,
|
||||
});
|
||||
expect(evil.html).not.toContain('<b>x</b>');
|
||||
expect(evil.html).toContain('<b>x</b>');
|
||||
});
|
||||
|
||||
it('reply line falls back to bare email when there is no display name', async () => {
|
||||
const h = await buildQuoteHeader({
|
||||
mode: 'reply',
|
||||
email: { from: [{ email: 'noname@x.tld' }], subject: 'Hello', receivedAt: '2026-01-01T10:00:00Z' },
|
||||
...base,
|
||||
});
|
||||
expect(h.text).toContain('noname@x.tld wrote:');
|
||||
expect(h.text).not.toContain('<noname@x.tld>');
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,15 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { expandRecurringEvents } from '../recurrence-expansion';
|
||||
import type { CalendarEvent } from '@/lib/jmap/types';
|
||||
import type { CalendarEvent, CalendarRecurrenceRule } from '@/lib/jmap/types';
|
||||
|
||||
/**
|
||||
* Helper: builds a recurrence-rule fixture from only the fields a test cares
|
||||
* about. Cast-only (no defaults injected) so the expansion logic sees exactly
|
||||
* the same partial rule the tests previously passed via `as any`.
|
||||
*/
|
||||
function rule(partial: Partial<CalendarRecurrenceRule>): CalendarRecurrenceRule {
|
||||
return partial as CalendarRecurrenceRule;
|
||||
}
|
||||
|
||||
/** Helper: create a minimal CalendarEvent for testing recurrence */
|
||||
function makeEvent(overrides: Partial<CalendarEvent> = {}): CalendarEvent {
|
||||
@@ -41,7 +50,7 @@ describe('expandRecurringEvents', () => {
|
||||
describe('daily frequency', () => {
|
||||
it('expands daily events within range', () => {
|
||||
const event = makeEvent({
|
||||
recurrenceRules: [{ '@type': 'RecurrenceRule', frequency: 'daily' } as any],
|
||||
recurrenceRules: [rule({ '@type': 'RecurrenceRule', frequency: 'daily' })],
|
||||
});
|
||||
const result = expand(event, '2025-01-06T00:00:00', '2025-01-09T00:00:00');
|
||||
expect(starts(result)).toEqual([
|
||||
@@ -53,7 +62,7 @@ describe('expandRecurringEvents', () => {
|
||||
|
||||
it('respects interval', () => {
|
||||
const event = makeEvent({
|
||||
recurrenceRules: [{ '@type': 'RecurrenceRule', frequency: 'daily', interval: 2 } as any],
|
||||
recurrenceRules: [rule({ '@type': 'RecurrenceRule', frequency: 'daily', interval: 2 })],
|
||||
});
|
||||
const result = expand(event, '2025-01-06T00:00:00', '2025-01-12T00:00:00');
|
||||
expect(starts(result)).toEqual([
|
||||
@@ -65,7 +74,7 @@ describe('expandRecurringEvents', () => {
|
||||
|
||||
it('respects count', () => {
|
||||
const event = makeEvent({
|
||||
recurrenceRules: [{ '@type': 'RecurrenceRule', frequency: 'daily', count: 3 } as any],
|
||||
recurrenceRules: [rule({ '@type': 'RecurrenceRule', frequency: 'daily', count: 3 })],
|
||||
});
|
||||
const result = expand(event, '2025-01-06T00:00:00', '2025-12-31T00:00:00');
|
||||
expect(result).toHaveLength(3);
|
||||
@@ -73,7 +82,7 @@ describe('expandRecurringEvents', () => {
|
||||
|
||||
it('respects until', () => {
|
||||
const event = makeEvent({
|
||||
recurrenceRules: [{ '@type': 'RecurrenceRule', frequency: 'daily', until: '2025-01-08T09:00:00' } as any],
|
||||
recurrenceRules: [rule({ '@type': 'RecurrenceRule', frequency: 'daily', until: '2025-01-08T09:00:00' })],
|
||||
});
|
||||
const result = expand(event, '2025-01-06T00:00:00', '2025-12-31T00:00:00');
|
||||
expect(result).toHaveLength(3);
|
||||
@@ -86,7 +95,7 @@ describe('expandRecurringEvents', () => {
|
||||
describe('weekly frequency', () => {
|
||||
it('expands weekly with implicit byDay (same weekday as start)', () => {
|
||||
const event = makeEvent({
|
||||
recurrenceRules: [{ '@type': 'RecurrenceRule', frequency: 'weekly' } as any],
|
||||
recurrenceRules: [rule({ '@type': 'RecurrenceRule', frequency: 'weekly' })],
|
||||
});
|
||||
// Jan 6 is Monday, so every Monday
|
||||
const result = expand(event, '2025-01-06T00:00:00', '2025-01-28T00:00:00');
|
||||
@@ -100,11 +109,11 @@ describe('expandRecurringEvents', () => {
|
||||
|
||||
it('expands weekly with explicit byDay (MWF)', () => {
|
||||
const event = makeEvent({
|
||||
recurrenceRules: [{
|
||||
recurrenceRules: [rule({
|
||||
'@type': 'RecurrenceRule',
|
||||
frequency: 'weekly',
|
||||
byDay: [{ day: 'mo' }, { day: 'we' }, { day: 'fr' }],
|
||||
} as any],
|
||||
})],
|
||||
});
|
||||
const result = expand(event, '2025-01-06T00:00:00', '2025-01-13T00:00:00');
|
||||
expect(starts(result)).toEqual([
|
||||
@@ -116,12 +125,12 @@ describe('expandRecurringEvents', () => {
|
||||
|
||||
it('expands weekly with interval=2', () => {
|
||||
const event = makeEvent({
|
||||
recurrenceRules: [{
|
||||
recurrenceRules: [rule({
|
||||
'@type': 'RecurrenceRule',
|
||||
frequency: 'weekly',
|
||||
interval: 2,
|
||||
byDay: [{ day: 'mo' }],
|
||||
} as any],
|
||||
})],
|
||||
});
|
||||
const result = expand(event, '2025-01-06T00:00:00', '2025-02-03T00:00:00');
|
||||
expect(starts(result)).toEqual([
|
||||
@@ -138,7 +147,7 @@ describe('expandRecurringEvents', () => {
|
||||
it('expands monthly with implicit byMonthDay', () => {
|
||||
const event = makeEvent({
|
||||
start: '2025-01-15T10:00:00',
|
||||
recurrenceRules: [{ '@type': 'RecurrenceRule', frequency: 'monthly' } as any],
|
||||
recurrenceRules: [rule({ '@type': 'RecurrenceRule', frequency: 'monthly' })],
|
||||
});
|
||||
const result = expand(event, '2025-01-01T00:00:00', '2025-04-01T00:00:00');
|
||||
expect(starts(result)).toEqual([
|
||||
@@ -151,11 +160,11 @@ describe('expandRecurringEvents', () => {
|
||||
it('expands monthly with byMonthDay', () => {
|
||||
const event = makeEvent({
|
||||
start: '2025-01-01T08:00:00',
|
||||
recurrenceRules: [{
|
||||
recurrenceRules: [rule({
|
||||
'@type': 'RecurrenceRule',
|
||||
frequency: 'monthly',
|
||||
byMonthDay: [1, 15],
|
||||
} as any],
|
||||
})],
|
||||
});
|
||||
const result = expand(event, '2025-01-01T00:00:00', '2025-02-28T00:00:00');
|
||||
expect(starts(result)).toEqual([
|
||||
@@ -169,11 +178,11 @@ describe('expandRecurringEvents', () => {
|
||||
it('expands monthly with negative byMonthDay (-1 = last day)', () => {
|
||||
const event = makeEvent({
|
||||
start: '2025-01-31T08:00:00',
|
||||
recurrenceRules: [{
|
||||
recurrenceRules: [rule({
|
||||
'@type': 'RecurrenceRule',
|
||||
frequency: 'monthly',
|
||||
byMonthDay: [-1],
|
||||
} as any],
|
||||
})],
|
||||
});
|
||||
const result = expand(event, '2025-01-01T00:00:00', '2025-04-01T00:00:00');
|
||||
const days = result.map(e => e.start.substring(0, 10));
|
||||
@@ -183,11 +192,11 @@ describe('expandRecurringEvents', () => {
|
||||
it('expands monthly with byDay + nthOfPeriod (2nd Tuesday)', () => {
|
||||
const event = makeEvent({
|
||||
start: '2025-01-14T09:00:00', // 2nd Tuesday
|
||||
recurrenceRules: [{
|
||||
recurrenceRules: [rule({
|
||||
'@type': 'RecurrenceRule',
|
||||
frequency: 'monthly',
|
||||
byDay: [{ day: 'tu', nthOfPeriod: 2 }],
|
||||
} as any],
|
||||
})],
|
||||
});
|
||||
const result = expand(event, '2025-01-01T00:00:00', '2025-04-01T00:00:00');
|
||||
const days = result.map(e => e.start.substring(0, 10));
|
||||
@@ -197,11 +206,11 @@ describe('expandRecurringEvents', () => {
|
||||
it('expands monthly with byDay nthOfPeriod=-1 (last Friday)', () => {
|
||||
const event = makeEvent({
|
||||
start: '2025-01-31T09:00:00', // last Friday of Jan
|
||||
recurrenceRules: [{
|
||||
recurrenceRules: [rule({
|
||||
'@type': 'RecurrenceRule',
|
||||
frequency: 'monthly',
|
||||
byDay: [{ day: 'fr', nthOfPeriod: -1 }],
|
||||
} as any],
|
||||
})],
|
||||
});
|
||||
const result = expand(event, '2025-01-01T00:00:00', '2025-04-01T00:00:00');
|
||||
const days = result.map(e => e.start.substring(0, 10));
|
||||
@@ -216,7 +225,7 @@ describe('expandRecurringEvents', () => {
|
||||
it('expands yearly on the same date', () => {
|
||||
const event = makeEvent({
|
||||
start: '2023-03-15T12:00:00',
|
||||
recurrenceRules: [{ '@type': 'RecurrenceRule', frequency: 'yearly' } as any],
|
||||
recurrenceRules: [rule({ '@type': 'RecurrenceRule', frequency: 'yearly' })],
|
||||
});
|
||||
const result = expand(event, '2023-01-01T00:00:00', '2026-01-01T00:00:00');
|
||||
expect(starts(result)).toEqual([
|
||||
@@ -229,12 +238,12 @@ describe('expandRecurringEvents', () => {
|
||||
it('expands yearly with byMonth and byDay (last Friday of November = Thanksgiving-ish)', () => {
|
||||
const event = makeEvent({
|
||||
start: '2025-11-28T09:00:00', // last Friday of Nov 2025
|
||||
recurrenceRules: [{
|
||||
recurrenceRules: [rule({
|
||||
'@type': 'RecurrenceRule',
|
||||
frequency: 'yearly',
|
||||
byMonth: ['11'],
|
||||
byDay: [{ day: 'fr', nthOfPeriod: -1 }],
|
||||
} as any],
|
||||
})],
|
||||
});
|
||||
const result = expand(event, '2025-01-01T00:00:00', '2028-01-01T00:00:00');
|
||||
const days = result.map(e => e.start.substring(0, 10));
|
||||
@@ -246,12 +255,12 @@ describe('expandRecurringEvents', () => {
|
||||
const event = makeEvent({
|
||||
start: '2025-07-04T00:00:00',
|
||||
showWithoutTime: true,
|
||||
recurrenceRules: [{
|
||||
recurrenceRules: [rule({
|
||||
'@type': 'RecurrenceRule',
|
||||
frequency: 'yearly',
|
||||
byMonth: ['7'],
|
||||
byMonthDay: [4],
|
||||
} as any],
|
||||
})],
|
||||
});
|
||||
const result = expand(event, '2025-01-01T00:00:00', '2028-01-01T00:00:00');
|
||||
expect(result).toHaveLength(3);
|
||||
@@ -265,12 +274,12 @@ describe('expandRecurringEvents', () => {
|
||||
it('selects first and last from monthly byDay expansion', () => {
|
||||
const event = makeEvent({
|
||||
start: '2025-01-06T10:00:00',
|
||||
recurrenceRules: [{
|
||||
recurrenceRules: [rule({
|
||||
'@type': 'RecurrenceRule',
|
||||
frequency: 'monthly',
|
||||
byDay: [{ day: 'mo' }, { day: 'tu' }, { day: 'we' }, { day: 'th' }, { day: 'fr' }],
|
||||
bySetPosition: [1, -1], // first and last weekday of month
|
||||
} as any],
|
||||
})],
|
||||
});
|
||||
const result = expand(event, '2025-01-01T00:00:00', '2025-03-01T00:00:00');
|
||||
const days = result.map(e => e.start.substring(0, 10));
|
||||
@@ -289,7 +298,7 @@ describe('expandRecurringEvents', () => {
|
||||
describe('recurrenceOverrides', () => {
|
||||
it('applies overrides to matching occurrences', () => {
|
||||
const event = makeEvent({
|
||||
recurrenceRules: [{ '@type': 'RecurrenceRule', frequency: 'daily' } as any],
|
||||
recurrenceRules: [rule({ '@type': 'RecurrenceRule', frequency: 'daily' })],
|
||||
recurrenceOverrides: {
|
||||
'2025-01-07T09:00:00': { title: 'Modified' },
|
||||
},
|
||||
@@ -301,9 +310,9 @@ describe('expandRecurringEvents', () => {
|
||||
|
||||
it('excludes occurrences marked as excluded', () => {
|
||||
const event = makeEvent({
|
||||
recurrenceRules: [{ '@type': 'RecurrenceRule', frequency: 'daily' } as any],
|
||||
recurrenceRules: [rule({ '@type': 'RecurrenceRule', frequency: 'daily' })],
|
||||
recurrenceOverrides: {
|
||||
'2025-01-07T09:00:00': { excluded: true } as any,
|
||||
'2025-01-07T09:00:00': { excluded: true } as Partial<CalendarEvent> & { excluded?: boolean },
|
||||
},
|
||||
});
|
||||
const result = expand(event, '2025-01-06T00:00:00', '2025-01-09T00:00:00');
|
||||
@@ -313,7 +322,7 @@ describe('expandRecurringEvents', () => {
|
||||
|
||||
it('adds RDATE-style overrides not generated by rules', () => {
|
||||
const event = makeEvent({
|
||||
recurrenceRules: [{ '@type': 'RecurrenceRule', frequency: 'weekly' } as any],
|
||||
recurrenceRules: [rule({ '@type': 'RecurrenceRule', frequency: 'weekly' })],
|
||||
recurrenceOverrides: {
|
||||
'2025-01-08T09:00:00': { title: 'Extra Wednesday' }, // Not a Monday
|
||||
},
|
||||
@@ -329,12 +338,12 @@ describe('expandRecurringEvents', () => {
|
||||
describe('excludedRecurrenceRules', () => {
|
||||
it('removes occurrences generated by excluded rules', () => {
|
||||
const event = makeEvent({
|
||||
recurrenceRules: [{ '@type': 'RecurrenceRule', frequency: 'daily' } as any],
|
||||
excludedRecurrenceRules: [{
|
||||
recurrenceRules: [rule({ '@type': 'RecurrenceRule', frequency: 'daily' })],
|
||||
excludedRecurrenceRules: [rule({
|
||||
'@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');
|
||||
@@ -353,7 +362,7 @@ describe('expandRecurringEvents', () => {
|
||||
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],
|
||||
recurrenceRules: [rule({ '@type': 'RecurrenceRule', frequency: 'daily' })],
|
||||
});
|
||||
const result = expand(event, '2025-06-02T00:00:00', '2025-06-05T00:00:00');
|
||||
expect(starts(result)).toEqual([
|
||||
@@ -366,7 +375,7 @@ describe('expandRecurringEvents', () => {
|
||||
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],
|
||||
recurrenceRules: [rule({ '@type': 'RecurrenceRule', frequency: 'weekly' })],
|
||||
});
|
||||
const result = expand(event, '2025-06-01T00:00:00', '2025-06-30T00:00:00');
|
||||
const days = result.map(e => e.start.substring(0, 10));
|
||||
@@ -376,7 +385,7 @@ describe('expandRecurringEvents', () => {
|
||||
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],
|
||||
recurrenceRules: [rule({ '@type': 'RecurrenceRule', frequency: 'daily', count: 5 })],
|
||||
});
|
||||
// Range far after the 5 occurrences ran out
|
||||
const result = expand(event, '2025-06-01T00:00:00', '2025-06-30T00:00:00');
|
||||
@@ -395,13 +404,13 @@ describe('expandRecurringEvents', () => {
|
||||
timeZone: 'America/New_York',
|
||||
utcStart: '2025-03-03T15:00:00Z',
|
||||
utcEnd: '2025-03-03T16:00:00Z',
|
||||
recurrenceRules: [{ '@type': 'RecurrenceRule', frequency: 'weekly' } as any],
|
||||
recurrenceRules: [rule({ '@type': 'RecurrenceRule', frequency: 'weekly' })],
|
||||
} 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);
|
||||
const utcStarts = result.map(e => e.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);
|
||||
const utcEnds = result.map(e => e.utcEnd);
|
||||
expect(utcEnds[1]).toBe('2025-03-10T15:00:00.000Z');
|
||||
});
|
||||
});
|
||||
@@ -414,7 +423,7 @@ describe('expandRecurringEvents', () => {
|
||||
const event = makeEvent({
|
||||
start: '2025-01-06T00:00:00',
|
||||
showWithoutTime: true,
|
||||
recurrenceRules: [{ '@type': 'RecurrenceRule', frequency: 'weekly' } as any],
|
||||
recurrenceRules: [rule({ '@type': 'RecurrenceRule', frequency: 'weekly' })],
|
||||
});
|
||||
const result = expand(event, '2025-01-06T00:00:00', '2025-01-28T00:00:00');
|
||||
expect(result).toHaveLength(4); // 4 Mondays: 6, 13, 20, 27
|
||||
@@ -427,7 +436,7 @@ describe('expandRecurringEvents', () => {
|
||||
describe('edge cases', () => {
|
||||
it('does not exceed 500 occurrences', () => {
|
||||
const event = makeEvent({
|
||||
recurrenceRules: [{ '@type': 'RecurrenceRule', frequency: 'daily' } as any],
|
||||
recurrenceRules: [rule({ '@type': 'RecurrenceRule', frequency: 'daily' })],
|
||||
});
|
||||
const result = expand(event, '2025-01-01T00:00:00', '2030-01-01T00:00:00');
|
||||
expect(result.length).toBeLessThanOrEqual(500);
|
||||
@@ -436,7 +445,7 @@ describe('expandRecurringEvents', () => {
|
||||
it('handles invalid start date gracefully', () => {
|
||||
const event = makeEvent({
|
||||
start: 'invalid',
|
||||
recurrenceRules: [{ '@type': 'RecurrenceRule', frequency: 'daily' } as any],
|
||||
recurrenceRules: [rule({ '@type': 'RecurrenceRule', frequency: 'daily' })],
|
||||
});
|
||||
const result = expand(event, '2025-01-01T00:00:00', '2025-02-01T00:00:00');
|
||||
expect(result).toHaveLength(0);
|
||||
@@ -444,7 +453,7 @@ describe('expandRecurringEvents', () => {
|
||||
|
||||
it('generates synthetic IDs for occurrences', () => {
|
||||
const event = makeEvent({
|
||||
recurrenceRules: [{ '@type': 'RecurrenceRule', frequency: 'daily' } as any],
|
||||
recurrenceRules: [rule({ '@type': 'RecurrenceRule', frequency: 'daily' })],
|
||||
});
|
||||
const result = expand(event, '2025-01-06T00:00:00', '2025-01-08T00:00:00');
|
||||
expect(result[0].id).toBe('evt1:2025-01-06T09:00:00');
|
||||
@@ -453,7 +462,7 @@ describe('expandRecurringEvents', () => {
|
||||
|
||||
it('preserves originalId pointing to master', () => {
|
||||
const event = makeEvent({
|
||||
recurrenceRules: [{ '@type': 'RecurrenceRule', frequency: 'daily' } as any],
|
||||
recurrenceRules: [rule({ '@type': 'RecurrenceRule', frequency: 'daily' })],
|
||||
});
|
||||
const result = expand(event, '2025-01-06T00:00:00', '2025-01-08T00:00:00');
|
||||
expect(result[0].originalId).toBe('evt1');
|
||||
@@ -465,7 +474,7 @@ describe('expandRecurringEvents', () => {
|
||||
id: 'master1',
|
||||
uid: 'shared-uid',
|
||||
start: '2025-01-06T09:00:00',
|
||||
recurrenceRules: [{ '@type': 'RecurrenceRule', frequency: 'weekly' } as any],
|
||||
recurrenceRules: [rule({ '@type': 'RecurrenceRule', frequency: 'weekly' })],
|
||||
});
|
||||
const override1 = makeEvent({
|
||||
id: 'override1',
|
||||
@@ -527,10 +536,10 @@ describe('expandRecurringEvents', () => {
|
||||
const event = makeEvent({
|
||||
start: '2026-09-01T12:00:00',
|
||||
utcStart: '2026-09-01T10:00:00Z',
|
||||
recurrenceRules: [{ '@type': 'RecurrenceRule', frequency: 'weekly' } as any],
|
||||
recurrenceRules: [rule({ '@type': 'RecurrenceRule', frequency: 'weekly' })],
|
||||
});
|
||||
const result = expand(event, '2026-09-01T00:00:00', '2026-10-01T00:00:00');
|
||||
const utcStarts = result.map(e => (e as any).utcStart);
|
||||
const utcStarts = result.map(e => e.utcStart);
|
||||
// Each occurrence should have a unique utcStart
|
||||
expect(new Set(utcStarts).size).toBe(result.length);
|
||||
// First occurrence keeps master's UTC offset relationship
|
||||
@@ -545,10 +554,10 @@ describe('expandRecurringEvents', () => {
|
||||
duration: 'PT1H',
|
||||
utcStart: '2026-09-01T10:00:00Z',
|
||||
utcEnd: '2026-09-01T11:00:00Z',
|
||||
recurrenceRules: [{ '@type': 'RecurrenceRule', frequency: 'weekly' } as any],
|
||||
recurrenceRules: [rule({ '@type': 'RecurrenceRule', frequency: 'weekly' })],
|
||||
});
|
||||
const result = expand(event, '2026-09-01T00:00:00', '2026-10-01T00:00:00');
|
||||
const utcEnds = result.map(e => (e as any).utcEnd);
|
||||
const utcEnds = result.map(e => e.utcEnd);
|
||||
// Each occurrence should have a unique utcEnd
|
||||
expect(new Set(utcEnds).size).toBe(result.length);
|
||||
expect(utcEnds[0]).toContain('2026-09-01');
|
||||
@@ -559,7 +568,7 @@ describe('expandRecurringEvents', () => {
|
||||
it('does not set utcStart when master has none', () => {
|
||||
const event = makeEvent({
|
||||
start: '2025-01-06T09:00:00',
|
||||
recurrenceRules: [{ '@type': 'RecurrenceRule', frequency: 'daily' } as any],
|
||||
recurrenceRules: [rule({ '@type': 'RecurrenceRule', frequency: 'daily' })],
|
||||
});
|
||||
const result = expand(event, '2025-01-06T00:00:00', '2025-01-08T00:00:00');
|
||||
expect(result[0].utcStart).toBeUndefined();
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import {
|
||||
SESSION_COOKIE,
|
||||
SESSION_COOKIE_MAX_AGE,
|
||||
sessionCookieName,
|
||||
} from '@/lib/auth/session-cookie';
|
||||
|
||||
describe('session-cookie', () => {
|
||||
it('exposes the legacy cookie name and 30-day max-age', () => {
|
||||
expect(SESSION_COOKIE).toBe('jmap_session');
|
||||
expect(SESSION_COOKIE_MAX_AGE).toBe(2592000); // 30 * 24 * 60 * 60
|
||||
});
|
||||
|
||||
it('uses the bare legacy name for slot 0 (no suffix)', () => {
|
||||
expect(sessionCookieName(0)).toBe('jmap_session');
|
||||
});
|
||||
|
||||
it('suffixes the slot number for slots > 0', () => {
|
||||
expect(sessionCookieName(1)).toBe('jmap_session_1');
|
||||
expect(sessionCookieName(49)).toBe('jmap_session_49');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,56 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import {
|
||||
stripSubjectPrefixes,
|
||||
buildReplySubject,
|
||||
buildForwardSubject,
|
||||
} from '@/lib/subject-prefix';
|
||||
|
||||
describe('stripSubjectPrefixes', () => {
|
||||
it('strips a chain of mixed-language prefixes', () => {
|
||||
expect(stripSubjectPrefixes('Re: AW: WG: foo')).toBe('foo');
|
||||
});
|
||||
|
||||
it('strips the Outlook [N] and Eudora *N counters', () => {
|
||||
expect(stripSubjectPrefixes('Re[2]: foo')).toBe('foo');
|
||||
expect(stripSubjectPrefixes('Re*3: foo')).toBe('foo');
|
||||
});
|
||||
|
||||
it('is case-insensitive and idempotent', () => {
|
||||
expect(stripSubjectPrefixes('RE: Re: foo')).toBe('foo');
|
||||
expect(stripSubjectPrefixes(stripSubjectPrefixes('RE: Re: foo'))).toBe('foo');
|
||||
});
|
||||
|
||||
it('strips a Cyrillic token and an ASCII-colon Chinese token', () => {
|
||||
expect(stripSubjectPrefixes('Ответ: foo')).toBe('foo');
|
||||
expect(stripSubjectPrefixes('回复: foo')).toBe('foo');
|
||||
});
|
||||
|
||||
it('strips a token followed by a full-width colon (CJK clients)', () => {
|
||||
expect(stripSubjectPrefixes('回复:foo')).toBe('foo');
|
||||
expect(stripSubjectPrefixes('回覆:foo')).toBe('foo');
|
||||
expect(stripSubjectPrefixes('Re:foo')).toBe('foo');
|
||||
});
|
||||
|
||||
it('still does not strip a bare single-letter "R:"', () => {
|
||||
expect(stripSubjectPrefixes('R: budget 2024')).toBe('R: budget 2024');
|
||||
});
|
||||
|
||||
it('returns "" for empty / null / undefined and leaves clean subjects alone', () => {
|
||||
expect(stripSubjectPrefixes('')).toBe('');
|
||||
expect(stripSubjectPrefixes(null)).toBe('');
|
||||
expect(stripSubjectPrefixes(undefined)).toBe('');
|
||||
expect(stripSubjectPrefixes('foo')).toBe('foo');
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildReplySubject / buildForwardSubject', () => {
|
||||
it('replaces a prefix chain (incl. a full-width colon) with the given prefix', () => {
|
||||
expect(buildReplySubject('回复:foo', 'Re:')).toBe('Re: foo');
|
||||
expect(buildForwardSubject('Re: foo', 'Fwd:')).toBe('Fwd: foo');
|
||||
});
|
||||
|
||||
it('prepends to a clean subject and returns the bare prefix for empty input', () => {
|
||||
expect(buildReplySubject('foo', 'AW:')).toBe('AW: foo');
|
||||
expect(buildReplySubject('', 'AW:')).toBe('AW:');
|
||||
});
|
||||
});
|
||||
Binary file not shown.
@@ -0,0 +1,169 @@
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import type { Email, Mailbox } from '@/lib/jmap/types';
|
||||
import type { IJMAPClient } from '@/lib/jmap/client-interface';
|
||||
import {
|
||||
getCrossIncludedMailboxes,
|
||||
buildCrossFilter,
|
||||
getCrossUnreadTotal,
|
||||
fetchCrossViewEmails,
|
||||
resolveSourceFolderName,
|
||||
type UnifiedAccountClient,
|
||||
} from '@/lib/unified-mailbox';
|
||||
|
||||
const mb = (id: string, role: string | undefined, unread = 0, originalId?: string): Mailbox =>
|
||||
({ id, name: id, role, unreadEmails: unread, totalEmails: 0, originalId } as unknown as Mailbox);
|
||||
|
||||
const makeAccount = (
|
||||
over: Partial<UnifiedAccountClient> & { accountId: string },
|
||||
clientImpl: Partial<IJMAPClient> = {},
|
||||
): UnifiedAccountClient => ({
|
||||
accountLabel: over.accountId,
|
||||
mailboxes: [],
|
||||
client: clientImpl as unknown as IJMAPClient,
|
||||
clientAccountId: over.accountId,
|
||||
jmapAccountId: over.accountId,
|
||||
...over,
|
||||
});
|
||||
|
||||
describe('getCrossIncludedMailboxes', () => {
|
||||
it('excludes junk/sent/archive/trash/drafts, keeps inbox + custom folders', () => {
|
||||
const account = makeAccount({
|
||||
accountId: 'a',
|
||||
mailboxes: [
|
||||
mb('inbox', 'inbox'),
|
||||
mb('projects', undefined),
|
||||
mb('junk', 'junk'),
|
||||
mb('sent', 'sent'),
|
||||
mb('archive', 'archive'),
|
||||
mb('trash', 'trash'),
|
||||
mb('drafts', 'drafts'),
|
||||
],
|
||||
});
|
||||
const ids = getCrossIncludedMailboxes(account).map((m) => m.id);
|
||||
expect(ids).toEqual(['inbox', 'projects']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildCrossFilter', () => {
|
||||
it('all → single inMailbox for one folder', () => {
|
||||
expect(buildCrossFilter('all', ['m1'])).toEqual({ inMailbox: 'm1' });
|
||||
});
|
||||
|
||||
it('all → OR of inMailbox for multiple folders', () => {
|
||||
expect(buildCrossFilter('all', ['m1', 'm2'])).toEqual({
|
||||
operator: 'OR',
|
||||
conditions: [{ inMailbox: 'm1' }, { inMailbox: 'm2' }],
|
||||
});
|
||||
});
|
||||
|
||||
it('unread → AND(membership, notKeyword $seen)', () => {
|
||||
expect(buildCrossFilter('unread', ['m1', 'm2'])).toEqual({
|
||||
operator: 'AND',
|
||||
conditions: [
|
||||
{ operator: 'OR', conditions: [{ inMailbox: 'm1' }, { inMailbox: 'm2' }] },
|
||||
{ notKeyword: '$seen' },
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it('starred → AND(membership, hasKeyword $flagged)', () => {
|
||||
expect(buildCrossFilter('starred', ['m1'])).toEqual({
|
||||
operator: 'AND',
|
||||
conditions: [{ inMailbox: 'm1' }, { hasKeyword: '$flagged' }],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('getCrossUnreadTotal', () => {
|
||||
it('sums unread across included folders of every account, ignoring excluded roles', () => {
|
||||
const a = makeAccount({
|
||||
accountId: 'a',
|
||||
mailboxes: [mb('inbox', 'inbox', 3), mb('proj', undefined, 2), mb('junk', 'junk', 50)],
|
||||
});
|
||||
const b = makeAccount({
|
||||
accountId: 'b',
|
||||
mailboxes: [mb('inbox', 'inbox', 5), mb('sent', 'sent', 99)],
|
||||
});
|
||||
expect(getCrossUnreadTotal([a, b])).toBe(10);
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveSourceFolderName', () => {
|
||||
const emailIn = (ids: string[]): Email =>
|
||||
({ mailboxIds: Object.fromEntries(ids.map((id) => [id, true])) } as unknown as Email);
|
||||
|
||||
it('returns the name of the folder the email is in (personal account)', () => {
|
||||
const boxes = [mb('inbox', 'inbox'), mb('proj', undefined)];
|
||||
expect(resolveSourceFolderName(emailIn(['proj']), boxes)).toBe('proj');
|
||||
});
|
||||
|
||||
it('matches shared mailboxes by originalId (email keyed by owner-side id)', () => {
|
||||
// shared mailbox: namespaced store id, but email.mailboxIds uses originalId
|
||||
const shared = { id: 'owner:inbox', role: 'inbox', unreadEmails: 0, totalEmails: 0, originalId: 'orig-inbox', name: 'Team Inbox' } as unknown as Mailbox;
|
||||
expect(resolveSourceFolderName(emailIn(['orig-inbox']), [shared])).toBe('Team Inbox');
|
||||
});
|
||||
|
||||
it('returns undefined when no known folder contains the email', () => {
|
||||
expect(resolveSourceFolderName(emailIn(['unknown']), [mb('inbox', 'inbox')])).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('fetchCrossViewEmails', () => {
|
||||
it('merges + date-sorts across accounts and stamps account info', async () => {
|
||||
const clientA = {
|
||||
advancedSearchEmails: vi.fn().mockResolvedValue({
|
||||
emails: [{ id: 'a1', receivedAt: '2026-01-01T10:00:00Z' } as Email],
|
||||
total: 1,
|
||||
hasMore: false,
|
||||
}),
|
||||
};
|
||||
const clientB = {
|
||||
advancedSearchEmails: vi.fn().mockResolvedValue({
|
||||
emails: [{ id: 'b1', receivedAt: '2026-01-02T10:00:00Z' } as Email],
|
||||
total: 1,
|
||||
hasMore: true,
|
||||
}),
|
||||
};
|
||||
const a = makeAccount({ accountId: 'a', accountLabel: 'A', mailboxes: [mb('inbox', 'inbox')] }, clientA);
|
||||
const b = makeAccount({ accountId: 'b', accountLabel: 'B', mailboxes: [mb('inbox', 'inbox')] }, clientB);
|
||||
|
||||
const result = await fetchCrossViewEmails([a, b], 'all', 50, 0);
|
||||
|
||||
expect(result.emails.map((e) => e.id)).toEqual(['b1', 'a1']); // newest first
|
||||
expect(result.emails[0].accountId).toBe('b');
|
||||
expect(result.emails[1].accountLabel).toBe('A');
|
||||
expect(result.total).toBe(2);
|
||||
expect(result.hasMore).toBe(true);
|
||||
});
|
||||
|
||||
it('resolves shared folders via originalId + owner accountId', async () => {
|
||||
const advancedSearchEmails = vi.fn().mockResolvedValue({ emails: [], total: 0, hasMore: false });
|
||||
const shared = makeAccount(
|
||||
{ accountId: 'owner-1', accountLabel: 'Shared', isShared: true, mailboxes: [mb('ns:inbox', 'inbox', 0, 'orig-inbox')] },
|
||||
{ advancedSearchEmails },
|
||||
);
|
||||
|
||||
await fetchCrossViewEmails([shared], 'unread', 50, 0);
|
||||
|
||||
const [filter, accountId] = advancedSearchEmails.mock.calls[0];
|
||||
expect(accountId).toBe('owner-1');
|
||||
// filter membership uses the originalId, not the namespaced id
|
||||
expect(JSON.stringify(filter)).toContain('orig-inbox');
|
||||
expect(JSON.stringify(filter)).not.toContain('ns:inbox');
|
||||
});
|
||||
|
||||
it('collects per-account errors without failing the whole fan-out', async () => {
|
||||
const ok = makeAccount(
|
||||
{ accountId: 'ok', mailboxes: [mb('inbox', 'inbox')] },
|
||||
{ advancedSearchEmails: vi.fn().mockResolvedValue({ emails: [{ id: 'x', receivedAt: '2026-01-01T00:00:00Z' } as Email], total: 1, hasMore: false }) },
|
||||
);
|
||||
const bad = makeAccount(
|
||||
{ accountId: 'bad', mailboxes: [mb('inbox', 'inbox')] },
|
||||
{ advancedSearchEmails: vi.fn().mockRejectedValue(new Error('boom')) },
|
||||
);
|
||||
|
||||
const result = await fetchCrossViewEmails([ok, bad], 'all', 50, 0);
|
||||
expect(result.emails.map((e) => e.id)).toEqual(['x']);
|
||||
expect(result.errors.get('bad')).toBe('boom');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,210 @@
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import type { Email, Mailbox } from '@/lib/jmap/types';
|
||||
import type { IJMAPClient } from '@/lib/jmap/client-interface';
|
||||
import {
|
||||
findMailboxByRole,
|
||||
fetchUnifiedEmails,
|
||||
searchUnifiedEmails,
|
||||
advancedSearchUnifiedEmails,
|
||||
fetchUnifiedMailboxCounts,
|
||||
getUnifiedRoles,
|
||||
type UnifiedAccountClient,
|
||||
} from '@/lib/unified-mailbox';
|
||||
|
||||
// ── factories ────────────────────────────────────────────────────────────────
|
||||
const makeEmail = (id: string, receivedAt: string): Email =>
|
||||
({ id, receivedAt } as unknown as Email);
|
||||
|
||||
const makeMailbox = (over: Partial<Mailbox> & { role: string }): Mailbox =>
|
||||
({ id: `mb-${over.role}`, unreadEmails: 0, totalEmails: 0, ...over } as unknown as Mailbox);
|
||||
|
||||
type FetchResult = { emails: Email[]; total: number; hasMore: boolean };
|
||||
|
||||
function makeAccount(
|
||||
over: Partial<UnifiedAccountClient> & { accountId: string },
|
||||
clientImpl: Partial<IJMAPClient> = {},
|
||||
): UnifiedAccountClient {
|
||||
return {
|
||||
accountLabel: over.accountId,
|
||||
mailboxes: [],
|
||||
client: clientImpl as unknown as IJMAPClient,
|
||||
clientAccountId: over.accountId,
|
||||
jmapAccountId: over.accountId,
|
||||
...over,
|
||||
};
|
||||
}
|
||||
|
||||
describe('findMailboxByRole', () => {
|
||||
it('returns the first mailbox matching the role', () => {
|
||||
const a = makeMailbox({ role: 'inbox', id: 'a' });
|
||||
const b = makeMailbox({ role: 'inbox', id: 'b' });
|
||||
expect(findMailboxByRole([a, b], 'inbox')).toBe(a);
|
||||
});
|
||||
|
||||
it('returns undefined when no mailbox has the role', () => {
|
||||
expect(findMailboxByRole([makeMailbox({ role: 'sent' })], 'inbox')).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('fetchUnifiedEmails', () => {
|
||||
it('merges across accounts and sorts by receivedAt descending, decorating each email', async () => {
|
||||
const acc1 = makeAccount(
|
||||
{ accountId: 'A', accountLabel: 'Account A', mailboxes: [makeMailbox({ role: 'inbox', id: 'a-in' })] },
|
||||
{ getEmails: vi.fn(async (): Promise<FetchResult> => ({
|
||||
emails: [makeEmail('a1', '2026-01-01T10:00:00Z'), makeEmail('a2', '2026-01-03T10:00:00Z')],
|
||||
total: 5, hasMore: false,
|
||||
})) },
|
||||
);
|
||||
const acc2 = makeAccount(
|
||||
{ accountId: 'B', accountLabel: 'Account B', mailboxes: [makeMailbox({ role: 'inbox', id: 'b-in' })] },
|
||||
{ getEmails: vi.fn(async (): Promise<FetchResult> => ({
|
||||
emails: [makeEmail('b1', '2026-01-02T10:00:00Z')],
|
||||
total: 3, hasMore: true,
|
||||
})) },
|
||||
);
|
||||
|
||||
const result = await fetchUnifiedEmails([acc1, acc2], 'inbox', 20, 0);
|
||||
|
||||
expect(result.emails.map((e) => e.id)).toEqual(['a2', 'b1', 'a1']); // newest first
|
||||
expect(result.total).toBe(8); // sum of per-account totals, not merged length
|
||||
expect(result.hasMore).toBe(true); // OR across accounts
|
||||
expect(result.errors.size).toBe(0);
|
||||
// decoration
|
||||
const a2 = result.emails.find((e) => e.id === 'a2')!;
|
||||
expect(a2.accountId).toBe('A');
|
||||
expect(a2.accountLabel).toBe('Account A');
|
||||
expect(a2.sourceClientAccountId).toBe('A');
|
||||
expect(a2.sourceAccountId).toBe('A');
|
||||
// getEmails called with (mailboxId, accountId=undefined for personal, limit, position)
|
||||
expect(acc1.client.getEmails).toHaveBeenCalledWith('a-in', undefined, 20, 0);
|
||||
});
|
||||
|
||||
it('isolates per-account errors and still returns the rest', async () => {
|
||||
const ok = makeAccount(
|
||||
{ accountId: 'OK', mailboxes: [makeMailbox({ role: 'inbox', id: 'ok-in' })] },
|
||||
{ getEmails: vi.fn(async (): Promise<FetchResult> => ({ emails: [makeEmail('x', '2026-01-01T00:00:00Z')], total: 1, hasMore: false })) },
|
||||
);
|
||||
const boom = makeAccount(
|
||||
{ accountId: 'BOOM', mailboxes: [makeMailbox({ role: 'inbox', id: 'boom-in' })] },
|
||||
{ getEmails: vi.fn(async (): Promise<FetchResult> => { throw new Error('network down'); }) },
|
||||
);
|
||||
|
||||
const result = await fetchUnifiedEmails([ok, boom], 'inbox', 20, 0);
|
||||
expect(result.emails.map((e) => e.id)).toEqual(['x']);
|
||||
expect(result.total).toBe(1);
|
||||
expect(result.errors.get('BOOM')).toBe('network down');
|
||||
});
|
||||
|
||||
it('stringifies a non-Error rejection', async () => {
|
||||
const acc = makeAccount(
|
||||
{ accountId: 'S', mailboxes: [makeMailbox({ role: 'inbox' })] },
|
||||
{ getEmails: vi.fn(async (): Promise<FetchResult> => { throw 'boom-string'; }) },
|
||||
);
|
||||
const result = await fetchUnifiedEmails([acc], 'inbox', 20, 0);
|
||||
expect(result.errors.get('S')).toBe('boom-string');
|
||||
});
|
||||
|
||||
it('skips accounts that have no mailbox for the role (no error recorded)', async () => {
|
||||
const getEmails = vi.fn(async (): Promise<FetchResult> => ({ emails: [], total: 0, hasMore: false }));
|
||||
const acc = makeAccount(
|
||||
{ accountId: 'NOROLE', mailboxes: [makeMailbox({ role: 'sent' })] },
|
||||
{ getEmails },
|
||||
);
|
||||
const result = await fetchUnifiedEmails([acc], 'inbox', 20, 0);
|
||||
expect(result).toEqual({ emails: [], total: 0, hasMore: false, errors: new Map() });
|
||||
expect(getEmails).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('returns an empty result for no accounts', async () => {
|
||||
const result = await fetchUnifiedEmails([], 'inbox', 20, 0);
|
||||
expect(result).toEqual({ emails: [], total: 0, hasMore: false, errors: new Map() });
|
||||
});
|
||||
|
||||
it('does NOT mutate the source email objects (decorates copies)', async () => {
|
||||
const original = makeEmail('m1', '2026-01-01T00:00:00Z');
|
||||
const acc = makeAccount(
|
||||
{ accountId: 'A', accountLabel: 'Label A', mailboxes: [makeMailbox({ role: 'inbox' })] },
|
||||
{ getEmails: vi.fn(async (): Promise<FetchResult> => ({ emails: [original], total: 1, hasMore: false })) },
|
||||
);
|
||||
const res = await fetchUnifiedEmails([acc], 'inbox', 20, 0);
|
||||
// The returned email carries the account info, but the client's object is untouched.
|
||||
expect(res.emails[0]).toMatchObject({ id: 'm1', accountId: 'A', accountLabel: 'Label A' });
|
||||
expect('accountId' in original).toBe(false);
|
||||
expect('accountLabel' in original).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveJmapTarget (via searchUnifiedEmails / advancedSearchUnifiedEmails)', () => {
|
||||
const empty = async (): Promise<FetchResult> => ({ emails: [], total: 0, hasMore: false });
|
||||
|
||||
it('personal account: uses mailbox.id and undefined accountId', async () => {
|
||||
const searchEmails = vi.fn(empty);
|
||||
const acc = makeAccount(
|
||||
{ accountId: 'A', mailboxes: [makeMailbox({ role: 'inbox', id: 'real-id' })] },
|
||||
{ searchEmails },
|
||||
);
|
||||
await searchUnifiedEmails([acc], 'inbox', 'hello', 10, 0);
|
||||
expect(searchEmails).toHaveBeenCalledWith('hello', 'real-id', undefined, 10, 0);
|
||||
});
|
||||
|
||||
it('shared account: uses mailbox.originalId and the owner accountId', async () => {
|
||||
const searchEmails = vi.fn(empty);
|
||||
const acc = makeAccount(
|
||||
{ accountId: 'OWNER', isShared: true, mailboxes: [makeMailbox({ role: 'inbox', id: 'OWNER:orig', originalId: 'orig' })] },
|
||||
{ searchEmails },
|
||||
);
|
||||
await searchUnifiedEmails([acc], 'inbox', 'q', 10, 5);
|
||||
expect(searchEmails).toHaveBeenCalledWith('q', 'orig', 'OWNER', 10, 5);
|
||||
});
|
||||
|
||||
it('shared account without originalId: falls back to mailbox.id', async () => {
|
||||
const searchEmails = vi.fn(empty);
|
||||
const acc = makeAccount(
|
||||
{ accountId: 'OWNER', isShared: true, mailboxes: [makeMailbox({ role: 'inbox', id: 'just-id' })] },
|
||||
{ searchEmails },
|
||||
);
|
||||
await searchUnifiedEmails([acc], 'inbox', 'q', 10, 0);
|
||||
expect(searchEmails).toHaveBeenCalledWith('q', 'just-id', 'OWNER', 10, 0);
|
||||
});
|
||||
|
||||
it('advancedSearch: builds the filter from the resolved mailbox id and forwards accountId', async () => {
|
||||
const advancedSearchEmails = vi.fn(empty);
|
||||
const acc = makeAccount(
|
||||
{ accountId: 'A', mailboxes: [makeMailbox({ role: 'inbox', id: 'mbx' })] },
|
||||
{ advancedSearchEmails },
|
||||
);
|
||||
const filterFor = vi.fn((mailboxId: string) => ({ inMailbox: mailboxId, from: 'x' }));
|
||||
await advancedSearchUnifiedEmails([acc], 'inbox', filterFor, 10, 0);
|
||||
expect(filterFor).toHaveBeenCalledWith('mbx');
|
||||
expect(advancedSearchEmails).toHaveBeenCalledWith({ inMailbox: 'mbx', from: 'x' }, undefined, 10, 0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('fetchUnifiedMailboxCounts', () => {
|
||||
it('aggregates counts per role across accounts, in ALL_UNIFIED_ROLES order, omitting absent roles', () => {
|
||||
const acc1 = makeAccount({ accountId: 'A', mailboxes: [
|
||||
makeMailbox({ role: 'inbox', unreadEmails: 2, totalEmails: 10 }),
|
||||
makeMailbox({ role: 'sent', unreadEmails: 0, totalEmails: 4 }),
|
||||
] });
|
||||
const acc2 = makeAccount({ accountId: 'B', mailboxes: [
|
||||
makeMailbox({ role: 'inbox', unreadEmails: 3, totalEmails: 7 }),
|
||||
] });
|
||||
|
||||
expect(fetchUnifiedMailboxCounts([acc1, acc2])).toEqual([
|
||||
{ role: 'inbox', unreadEmails: 5, totalEmails: 17 },
|
||||
{ role: 'sent', unreadEmails: 0, totalEmails: 4 },
|
||||
]);
|
||||
});
|
||||
|
||||
it('returns an empty array when no accounts have mailboxes', () => {
|
||||
expect(fetchUnifiedMailboxCounts([makeAccount({ accountId: 'A' })])).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getUnifiedRoles', () => {
|
||||
it('lists roles present in at least one account once, in canonical order', () => {
|
||||
const acc1 = makeAccount({ accountId: 'A', mailboxes: [makeMailbox({ role: 'drafts' }), makeMailbox({ role: 'inbox' })] });
|
||||
const acc2 = makeAccount({ accountId: 'B', mailboxes: [makeMailbox({ role: 'inbox' }), makeMailbox({ role: 'trash' })] });
|
||||
expect(getUnifiedRoles([acc1, acc2])).toEqual(['inbox', 'drafts', 'trash']);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,133 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach, type Mock } from 'vitest';
|
||||
|
||||
// ── module mocks (hoisted) ───────────────────────────────────────────────────
|
||||
vi.mock('next/server', () => {
|
||||
class NextResponse {
|
||||
body: unknown;
|
||||
status: number;
|
||||
headers: Headers;
|
||||
constructor(body: unknown, init?: { status?: number; headers?: Headers }) {
|
||||
this.body = body;
|
||||
this.status = init?.status ?? 200;
|
||||
this.headers = init?.headers ?? new Headers();
|
||||
}
|
||||
static json(data: unknown, init?: { status?: number }) {
|
||||
return { status: init?.status ?? 200, headers: new Headers(), json: async () => data };
|
||||
}
|
||||
}
|
||||
return { NextResponse, NextRequest: class {} };
|
||||
});
|
||||
vi.mock('@/lib/logger', () => ({ logger: { error: () => {}, debug: () => {} } }));
|
||||
vi.mock('@/lib/stalwart/credentials', () => ({ getStalwartCredentials: vi.fn() }));
|
||||
|
||||
import { POST } from '@/app/api/webdav/route';
|
||||
import { getStalwartCredentials } from '@/lib/stalwart/credentials';
|
||||
|
||||
const mockCreds = getStalwartCredentials as unknown as Mock;
|
||||
const CREDS = { serverUrl: 'https://mail.example.com', username: 'user@example.com', authHeader: 'Basic abc' };
|
||||
|
||||
type Resp = { status: number; headers: Headers; body?: unknown; text?: () => Promise<string> };
|
||||
let fetchSpy: Mock;
|
||||
|
||||
function makeReq(headers: Record<string, string> = {}, body: unknown = null): Parameters<typeof POST>[0] {
|
||||
const lc: Record<string, string> = {};
|
||||
for (const [k, v] of Object.entries(headers)) lc[k.toLowerCase()] = v;
|
||||
return {
|
||||
headers: { get: (n: string) => lc[n.toLowerCase()] ?? null },
|
||||
arrayBuffer: async () => new ArrayBuffer(0),
|
||||
body,
|
||||
} as unknown as Parameters<typeof POST>[0];
|
||||
}
|
||||
|
||||
// The route returns either our mocked NextResponse instance or NextResponse.json's object.
|
||||
function read(res: unknown): { status: number; headers?: Headers; json?: () => Promise<unknown>; body?: unknown } {
|
||||
return res as { status: number; headers?: Headers; json?: () => Promise<unknown>; body?: unknown };
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
mockCreds.mockResolvedValue(CREDS);
|
||||
fetchSpy = vi.fn(async (): Promise<Resp> => ({
|
||||
status: 207,
|
||||
headers: new Headers({ 'Content-Type': 'text/plain' }),
|
||||
body: 'UPSTREAM-BODY',
|
||||
text: async () => '<xml/>',
|
||||
}));
|
||||
vi.stubGlobal('fetch', fetchSpy);
|
||||
});
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
describe('POST /api/webdav — guards', () => {
|
||||
it('401 when there are no credentials', async () => {
|
||||
mockCreds.mockResolvedValue(null);
|
||||
const res = read(await POST(makeReq({ 'X-WebDAV-Method': 'GET' })));
|
||||
expect(res.status).toBe(401);
|
||||
await expect(res.json!()).resolves.toEqual({ error: 'Not authenticated' });
|
||||
});
|
||||
|
||||
it('400 for a missing or disallowed method', async () => {
|
||||
expect(read(await POST(makeReq({}))).status).toBe(400);
|
||||
const res = read(await POST(makeReq({ 'X-WebDAV-Method': 'BOGUS' })));
|
||||
expect(res.status).toBe(400);
|
||||
await expect(res.json!()).resolves.toEqual({ error: 'Invalid WebDAV method' });
|
||||
});
|
||||
|
||||
it('400 on a path-traversal segment', async () => {
|
||||
const res = read(await POST(makeReq({ 'X-WebDAV-Method': 'PROPFIND', 'X-WebDAV-Path': '../etc' })));
|
||||
expect(res.status).toBe(400);
|
||||
await expect(res.json!()).resolves.toEqual({ error: 'Invalid WebDAV path segment' });
|
||||
});
|
||||
|
||||
it('400 on bad percent-encoding in the path', async () => {
|
||||
const res = read(await POST(makeReq({ 'X-WebDAV-Method': 'PUT', 'X-WebDAV-Path': '%zz' })));
|
||||
expect(res.status).toBe(400);
|
||||
await expect(res.json!()).resolves.toEqual({ error: 'Invalid WebDAV path encoding' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /api/webdav — proxying', () => {
|
||||
it('GET builds the upstream URL, forwards auth, and streams the body back', async () => {
|
||||
const res = read(await POST(makeReq({ 'X-WebDAV-Method': 'get', 'X-WebDAV-Path': 'file.txt' })));
|
||||
const target = 'https://mail.example.com/dav/file/user%40example.com/file.txt';
|
||||
expect(fetchSpy).toHaveBeenCalledWith(
|
||||
target,
|
||||
expect.objectContaining({ method: 'GET', headers: expect.objectContaining({ Authorization: 'Basic abc' }) }),
|
||||
);
|
||||
expect(res.status).toBe(207);
|
||||
expect(res.body).toBe('UPSTREAM-BODY');
|
||||
expect(res.headers!.get('Content-Type')).toBe('text/plain');
|
||||
expect(res.headers!.get('X-WebDAV-Request-URI')).toBe(target);
|
||||
});
|
||||
|
||||
it('PROPFIND forwards Depth and returns XML', async () => {
|
||||
const res = read(await POST(makeReq({ 'X-WebDAV-Method': 'PROPFIND', 'X-WebDAV-Path': 'dir', Depth: '1' })));
|
||||
expect(fetchSpy).toHaveBeenCalledWith(
|
||||
'https://mail.example.com/dav/file/user%40example.com/dir',
|
||||
expect.objectContaining({ method: 'PROPFIND', headers: expect.objectContaining({ Depth: '1' }) }),
|
||||
);
|
||||
expect(res.status).toBe(207);
|
||||
expect(res.body).toBe('<xml/>');
|
||||
expect(res.headers!.get('Content-Type')).toBe('application/xml; charset=utf-8');
|
||||
});
|
||||
|
||||
it('MOVE rebuilds the Destination URL and forwards Overwrite', async () => {
|
||||
await POST(makeReq({
|
||||
'X-WebDAV-Method': 'MOVE',
|
||||
'X-WebDAV-Path': 'old.txt',
|
||||
'X-WebDAV-Destination': 'sub/new.txt',
|
||||
Overwrite: 'F',
|
||||
}));
|
||||
expect(fetchSpy).toHaveBeenCalledWith(
|
||||
'https://mail.example.com/dav/file/user%40example.com/old.txt',
|
||||
expect.objectContaining({
|
||||
method: 'MOVE',
|
||||
headers: expect.objectContaining({
|
||||
Destination: 'https://mail.example.com/dav/file/user%40example.com/sub/new.txt',
|
||||
Overwrite: 'F',
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -11,7 +11,6 @@ import { useFilterStore } from '@/stores/filter-store';
|
||||
import { DEFAULT_SEARCH_FILTERS } from '@/lib/jmap/search-utils';
|
||||
import { useIdentityStore } from '@/stores/identity-store';
|
||||
import { useVacationStore } from '@/stores/vacation-store';
|
||||
import { useSmimeStore } from '@/stores/smime-store';
|
||||
|
||||
// Minimal snapshot shapes - we only capture what we need
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
@@ -37,33 +36,37 @@ export function snapshotAccount(accountId: string): void {
|
||||
const identityState = useIdentityStore.getState();
|
||||
const vacationState = useVacationStore.getState();
|
||||
|
||||
// Copy the captured collections so the snapshot is decoupled from the live
|
||||
// store: a later in-place mutation (e.g. an array push/splice, or stamping
|
||||
// fields onto a shared email object) must not retroactively corrupt a
|
||||
// snapshot taken earlier.
|
||||
cache.set(accountId, {
|
||||
email: {
|
||||
emails: emailState.emails,
|
||||
mailboxes: emailState.mailboxes,
|
||||
emails: [...emailState.emails],
|
||||
mailboxes: [...emailState.mailboxes],
|
||||
selectedEmail: emailState.selectedEmail,
|
||||
selectedMailbox: emailState.selectedMailbox,
|
||||
searchQuery: emailState.searchQuery,
|
||||
quota: emailState.quota,
|
||||
quota: emailState.quota ? { ...emailState.quota } : emailState.quota,
|
||||
},
|
||||
contact: {
|
||||
contacts: contactState.contacts,
|
||||
addressBooks: contactState.addressBooks,
|
||||
contacts: [...contactState.contacts],
|
||||
addressBooks: [...contactState.addressBooks],
|
||||
supportsSync: contactState.supportsSync,
|
||||
},
|
||||
calendar: {
|
||||
calendars: calendarState.calendars,
|
||||
events: calendarState.events,
|
||||
selectedCalendarIds: calendarState.selectedCalendarIds,
|
||||
calendars: [...calendarState.calendars],
|
||||
events: [...calendarState.events],
|
||||
selectedCalendarIds: [...calendarState.selectedCalendarIds],
|
||||
viewMode: calendarState.viewMode,
|
||||
supportsCalendar: calendarState.supportsCalendar,
|
||||
},
|
||||
filter: {
|
||||
rules: filterState.rules,
|
||||
rules: [...filterState.rules],
|
||||
isSupported: filterState.isSupported,
|
||||
},
|
||||
identity: {
|
||||
identities: identityState.identities,
|
||||
identities: [...identityState.identities],
|
||||
preferredPrimaryId: identityState.preferredPrimaryId,
|
||||
},
|
||||
vacation: {
|
||||
@@ -73,11 +76,22 @@ export function snapshotAccount(accountId: string): void {
|
||||
});
|
||||
}
|
||||
|
||||
/** Restore cached store states for the given account. Returns false if no cache exists. */
|
||||
/**
|
||||
* Restore cached store states for the given account. Returns false if no cache
|
||||
* exists.
|
||||
*
|
||||
* The snapshot only captures a subset of each store's fields (the loaded data),
|
||||
* so we reset every store to its baseline first. Without this, fields outside
|
||||
* the captured subset (e.g. email selection, loading flags, tag counts) would
|
||||
* carry over from whatever account was active, leaking state across accounts.
|
||||
* `setState` merges, so the captured fields are then layered back on top.
|
||||
*/
|
||||
export function restoreAccount(accountId: string): boolean {
|
||||
const snapshot = cache.get(accountId);
|
||||
if (!snapshot) return false;
|
||||
|
||||
clearAllStores();
|
||||
|
||||
useEmailStore.setState(snapshot.email);
|
||||
useContactStore.setState(snapshot.contact);
|
||||
useCalendarStore.setState(snapshot.calendar);
|
||||
@@ -118,7 +132,6 @@ export function clearAllStores(): void {
|
||||
useVacationStore.getState().clearState();
|
||||
useCalendarStore.getState().clearState();
|
||||
useFilterStore.getState().clearState();
|
||||
useSmimeStore.getState().clearState();
|
||||
}
|
||||
|
||||
/** Evict cached state for one account */
|
||||
|
||||
@@ -47,6 +47,9 @@ export interface ServerPlugin {
|
||||
author: string;
|
||||
description: string;
|
||||
type: string;
|
||||
/** Requested execution tier ('untrusted' | 'privileged'). Privileged plugins
|
||||
* run in a same-origin sandbox and require admin approval + consent. */
|
||||
tier?: string;
|
||||
permissions: string[];
|
||||
entrypoint: string;
|
||||
enabled: boolean;
|
||||
|
||||
@@ -51,6 +51,7 @@ export interface FeatureGates {
|
||||
settingsExportEnabled: boolean;
|
||||
customKeywordsEnabled: boolean;
|
||||
templatesEnabled: boolean;
|
||||
calendarEnabled: boolean;
|
||||
calendarTasksEnabled: boolean;
|
||||
smimeEnabled: boolean;
|
||||
externalContentEnabled: boolean;
|
||||
@@ -60,6 +61,9 @@ export interface FeatureGates {
|
||||
filesEnabled: boolean;
|
||||
contactsEnabled: boolean;
|
||||
allMailViewEnabled: boolean;
|
||||
crossUnreadViewEnabled: boolean;
|
||||
crossStarredViewEnabled: boolean;
|
||||
crossAllViewEnabled: boolean;
|
||||
}
|
||||
|
||||
export const DEFAULT_FEATURE_GATES: FeatureGates = {
|
||||
@@ -72,6 +76,7 @@ export const DEFAULT_FEATURE_GATES: FeatureGates = {
|
||||
settingsExportEnabled: true,
|
||||
customKeywordsEnabled: true,
|
||||
templatesEnabled: true,
|
||||
calendarEnabled: true,
|
||||
calendarTasksEnabled: true,
|
||||
smimeEnabled: true,
|
||||
externalContentEnabled: true,
|
||||
@@ -81,6 +86,9 @@ export const DEFAULT_FEATURE_GATES: FeatureGates = {
|
||||
filesEnabled: true,
|
||||
contactsEnabled: true,
|
||||
allMailViewEnabled: false,
|
||||
crossUnreadViewEnabled: false,
|
||||
crossStarredViewEnabled: false,
|
||||
crossAllViewEnabled: false,
|
||||
};
|
||||
|
||||
export interface ThemePolicy {
|
||||
@@ -163,6 +171,11 @@ export const CONFIG_ENV_MAP: Record<string, { envVar: string; fileEnvVar?: strin
|
||||
oauthClientId: { envVar: 'OAUTH_CLIENT_ID', type: 'string', defaultValue: '' },
|
||||
oauthClientSecret: { envVar: 'OAUTH_CLIENT_SECRET', fileEnvVar: 'OAUTH_CLIENT_SECRET_FILE', type: 'string', defaultValue: '' },
|
||||
oauthIssuerUrl: { envVar: 'OAUTH_ISSUER_URL', type: 'url', defaultValue: '' },
|
||||
// Overrides only the user-facing authorize endpoint. Discovery, token exchange
|
||||
// and refresh continue to use the canonical OAUTH_ISSUER_URL. Lets a per-brand
|
||||
// authorize host front a single canonical issuer. Empty = use the discovered
|
||||
// authorization_endpoint.
|
||||
oauthAuthorizeUrl: { envVar: 'OAUTH_AUTHORIZE_URL', type: 'url', defaultValue: '' },
|
||||
oauthScopes: { envVar: 'OAUTH_SCOPES', type: 'string', defaultValue: '' },
|
||||
oauthExtraScopes: { envVar: 'OAUTH_EXTRA_SCOPES', type: 'string', defaultValue: '' },
|
||||
oauthAllowPrivateEndpoints: { envVar: 'OAUTH_ALLOW_PRIVATE_ENDPOINTS', type: 'boolean', defaultValue: false },
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { ContactCard, CalendarEvent, Calendar, PartialDate, Timestamp } from '@/lib/jmap/types';
|
||||
import { getContactDisplayName } from '@/stores/contact-store';
|
||||
import { format, eachYearOfInterval, parseISO } from 'date-fns';
|
||||
import { eachYearOfInterval, parseISO } from 'date-fns';
|
||||
|
||||
export const BIRTHDAY_CALENDAR_ID = '__birthday-calendar__';
|
||||
export const BIRTHDAY_CALENDAR_COLOR = '#eab308'; // Yellow
|
||||
|
||||
@@ -67,7 +67,6 @@ export function getPathPrefix(locale?: string): string {
|
||||
* // Browser at /webmail/en/inbox → /webmail/api/jmap
|
||||
* // Browser at /en/inbox → /api/jmap
|
||||
*/
|
||||
// eslint-disable-next-line no-undef
|
||||
export function apiFetch(input: string, init?: RequestInit): Promise<Response> {
|
||||
if (input.startsWith('/') && !input.startsWith('//')) {
|
||||
return fetch(getPathPrefix() + input, init);
|
||||
|
||||
@@ -121,7 +121,7 @@ export class DemoJMAPClient implements IJMAPClient {
|
||||
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> {
|
||||
async createMailbox(name: string, parentId?: string, _accountId?: string): Promise<Mailbox> {
|
||||
const mb: Mailbox = {
|
||||
id: generateDemoId('mailbox'),
|
||||
name,
|
||||
@@ -222,7 +222,7 @@ export class DemoJMAPClient implements IJMAPClient {
|
||||
this.recalcMailboxCounts();
|
||||
}
|
||||
|
||||
async batchMarkAsRead(emailIds: string[], read: boolean = true): Promise<void> {
|
||||
async batchMarkAsRead(emailIds: string[], read: boolean = true, _accountId?: string): Promise<void> {
|
||||
for (const id of emailIds) {
|
||||
const email = this.data.emails.find(e => e.id === id);
|
||||
if (email) {
|
||||
@@ -233,7 +233,7 @@ export class DemoJMAPClient implements IJMAPClient {
|
||||
this.recalcMailboxCounts();
|
||||
}
|
||||
|
||||
async toggleStar(emailId: string, starred: boolean): Promise<void> {
|
||||
async toggleStar(emailId: string, starred: boolean, _accountId?: string): Promise<void> {
|
||||
const email = this.data.emails.find(e => e.id === emailId);
|
||||
if (!email) return;
|
||||
if (starred) email.keywords.$flagged = true;
|
||||
@@ -275,7 +275,7 @@ export class DemoJMAPClient implements IJMAPClient {
|
||||
this.recalcMailboxCounts();
|
||||
}
|
||||
|
||||
async batchDeleteEmails(emailIds: string[]): Promise<void> {
|
||||
async batchDeleteEmails(emailIds: string[], _accountId?: string): Promise<void> {
|
||||
const idSet = new Set(emailIds);
|
||||
this.data.emails = this.data.emails.filter(e => !idSet.has(e.id));
|
||||
this.recalcMailboxCounts();
|
||||
|
||||
@@ -64,6 +64,11 @@ export const BUNDLE_TOKENS: { token: string; description: string }[] = [
|
||||
{ token: "day", description: "2-digit day" },
|
||||
];
|
||||
|
||||
// Overall cap for a generated filename stem. Also used as the per-token cap so
|
||||
// a single long token (e.g. {subject}) isn't truncated earlier than the final
|
||||
// filename would be.
|
||||
const FILENAME_MAX_LEN = 200;
|
||||
|
||||
function sanitizePart(input: string, maxLen = 80): string {
|
||||
const cleaned = input
|
||||
.replace(SAFE_CHARS, "_")
|
||||
@@ -173,7 +178,7 @@ function renderRaw(template: string, vars: Record<string, string>): string {
|
||||
return template.replace(/\{(\w+)\}/g, (_, key: string) => {
|
||||
const value = vars[key];
|
||||
if (value === undefined) return "";
|
||||
return sanitizePart(value);
|
||||
return sanitizePart(value, FILENAME_MAX_LEN);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -184,9 +189,9 @@ export function emailExportFilename(
|
||||
const opts = typeof options === "string" ? { template: options } : options;
|
||||
const template = opts.template ?? DEFAULT_EMAIL_TEMPLATE;
|
||||
const rendered = renderRaw(template, emailVars(email));
|
||||
const cleaned = sanitizePart(rendered, 200);
|
||||
const cleaned = sanitizePart(rendered, FILENAME_MAX_LEN);
|
||||
const transformed = applyTransforms(cleaned, opts);
|
||||
const stem = transformed.slice(0, 200) || "email";
|
||||
const stem = transformed.slice(0, FILENAME_MAX_LEN) || "email";
|
||||
return `${stem}.eml`;
|
||||
}
|
||||
|
||||
@@ -199,7 +204,7 @@ export function attachmentDownloadFilename(
|
||||
const template = opts.template ?? DEFAULT_ATTACHMENT_TEMPLATE;
|
||||
if (!email) {
|
||||
const filename = (attachment.name || "attachment").trim();
|
||||
const cleaned = sanitizePart(filename, 200) || "attachment";
|
||||
const cleaned = sanitizePart(filename, FILENAME_MAX_LEN) || "attachment";
|
||||
return applyTransforms(cleaned, opts) || cleaned;
|
||||
}
|
||||
const vars = attachmentVars(email, attachment);
|
||||
@@ -208,10 +213,10 @@ export function attachmentDownloadFilename(
|
||||
if (value === undefined) return "";
|
||||
// Preserve dots in {filename} so the original extension survives the
|
||||
// sanitiser (it strips trailing dots otherwise).
|
||||
return key === "filename" ? value.replace(SAFE_CHARS, "_") : sanitizePart(value);
|
||||
return key === "filename" ? value.replace(SAFE_CHARS, "_") : sanitizePart(value, FILENAME_MAX_LEN);
|
||||
});
|
||||
const templateMentionsExt = /\{(ext|filename)\}/.test(template);
|
||||
const cleaned = sanitizePart(rendered, 200) || "attachment";
|
||||
const cleaned = sanitizePart(rendered, FILENAME_MAX_LEN) || "attachment";
|
||||
if (templateMentionsExt) {
|
||||
return applyTransforms(cleaned, opts) || cleaned;
|
||||
}
|
||||
@@ -222,6 +227,15 @@ export function attachmentDownloadFilename(
|
||||
return `${transformedStem}.${transformedExt}`;
|
||||
}
|
||||
|
||||
// Name for a .zip bundling every attachment of a single email, e.g.
|
||||
// `attachments_Invoice March.zip`. Falls back to `attachments.zip` when the
|
||||
// subject is empty or sanitises away to nothing.
|
||||
export function attachmentsBundleFilename(email: Email | null | undefined): string {
|
||||
const subject = email?.subject?.trim();
|
||||
const stem = subject ? sanitizePart(subject, FILENAME_MAX_LEN) : "";
|
||||
return stem ? `attachments_${stem}.zip` : "attachments.zip";
|
||||
}
|
||||
|
||||
export function bundleVars(count: number, iso?: string): Record<string, string> {
|
||||
const dp = dateParts(iso ?? new Date().toISOString());
|
||||
return { ...dp, count: String(count) };
|
||||
@@ -235,9 +249,9 @@ export function bundleExportFilename(
|
||||
const opts = typeof options === "string" ? { template: options } : options;
|
||||
const template = opts.template ?? DEFAULT_BUNDLE_TEMPLATE;
|
||||
const rendered = renderRaw(template, bundleVars(count, iso));
|
||||
const cleaned = sanitizePart(rendered, 200);
|
||||
const cleaned = sanitizePart(rendered, FILENAME_MAX_LEN);
|
||||
const transformed = applyTransforms(cleaned, opts);
|
||||
const stem = transformed.slice(0, 200) || "emails";
|
||||
const stem = transformed.slice(0, FILENAME_MAX_LEN) || "emails";
|
||||
return `${stem}.zip`;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { isValidEmail } from "@/lib/validation";
|
||||
|
||||
const HTML_ESCAPE_MAP = {
|
||||
"&": "&",
|
||||
"<": "<",
|
||||
@@ -56,13 +58,16 @@ export function rewriteCidImagesForEditor(html: string): string {
|
||||
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.
|
||||
* Splits a recipient string into individual entries on any character in
|
||||
* `separators`, treating those characters as literal when they sit inside a
|
||||
* quoted display name (`"Doo, John" <john@doo.org>`) or angle brackets
|
||||
* (`<a,b@x>`). Trims each part and drops empties.
|
||||
*
|
||||
* Defaults to comma-only, the (de)serialization boundary used by the composer
|
||||
* state and mailto handling. Pasted lists pass a wider set (see
|
||||
* {@link splitPasteEntries}) because they also use `;` and line breaks.
|
||||
*/
|
||||
export function splitRecipients(value: string): string[] {
|
||||
export function splitRecipients(value: string, separators = ','): string[] {
|
||||
const result: string[] = [];
|
||||
let current = '';
|
||||
let inQuotes = false;
|
||||
@@ -77,7 +82,7 @@ export function splitRecipients(value: string): string[] {
|
||||
} else if (ch === '>' && !inQuotes) {
|
||||
inAngle = false;
|
||||
current += ch;
|
||||
} else if (ch === ',' && !inQuotes && !inAngle) {
|
||||
} else if (separators.includes(ch) && !inQuotes && !inAngle) {
|
||||
const trimmed = current.trim();
|
||||
if (trimmed) result.push(trimmed);
|
||||
current = '';
|
||||
@@ -140,6 +145,71 @@ export function formatRecipientList(recipients: Recipient[]): string {
|
||||
return recipients.map((r) => formatRecipient(r.name, r.email)).join(', ');
|
||||
}
|
||||
|
||||
/**
|
||||
* Top-level split of a pasted block into recipient entries on commas,
|
||||
* semicolons and newlines (separators inside a quoted name or angle brackets
|
||||
* stay literal). Broader than the comma-only default of {@link splitRecipients}
|
||||
* because pasted lists also use `;` and line breaks as separators.
|
||||
*/
|
||||
function splitPasteEntries(value: string): string[] {
|
||||
return splitRecipients(value, ',;\n\r');
|
||||
}
|
||||
|
||||
/**
|
||||
* Splits pasted text into recipient candidates and partitions them: valid email
|
||||
* addresses become `Recipient`s (deduped case-insensitively against
|
||||
* `existingEmails` and within the paste), and everything else is returned as
|
||||
* `invalid` for the caller to drop back into the input field.
|
||||
*
|
||||
* Handles both structured and bare lists, preserving display names:
|
||||
* - `"Name <email>"` (the whole recipient quoted), `Name <email>`, and
|
||||
* `"Doe, John" <email>` entries are kept intact with their display name.
|
||||
* - Bare-address dumps (`a@x.com b@y.com`, spreadsheet columns, comma/space/
|
||||
* semicolon/newline separated) split into one chip per address.
|
||||
* - A token wrapped in angle brackets (`<a@x.com>`) is unwrapped before
|
||||
* validating, so an `a <a@x.com>` fragment still yields the address.
|
||||
*/
|
||||
export function splitPastedRecipients(
|
||||
text: string,
|
||||
existingEmails: string[] = [],
|
||||
): { valid: Recipient[]; invalid: string[] } {
|
||||
const seen = new Set(existingEmails.map((e) => e.toLowerCase()));
|
||||
const valid: Recipient[] = [];
|
||||
const invalid: string[] = [];
|
||||
|
||||
// Adds a recipient if its address is valid and unseen. Returns true when the
|
||||
// entry is fully handled (valid or a known duplicate) so the caller can stop.
|
||||
const tryAdd = (r: Recipient): boolean => {
|
||||
if (!isValidEmail(r.email)) return false;
|
||||
const key = r.email.toLowerCase();
|
||||
if (!seen.has(key)) {
|
||||
seen.add(key);
|
||||
valid.push(r.name ? { name: r.name, email: r.email } : { email: r.email });
|
||||
}
|
||||
return true;
|
||||
};
|
||||
|
||||
// Split on commas, semicolons and newlines in a quote/angle-aware way so a
|
||||
// `"Doe, John" <j@x.com>` or fully-quoted `"Name <email>"` entry stays a
|
||||
// single recipient (separators inside the name or the address are literal).
|
||||
for (const entry of splitPasteEntries(text)) {
|
||||
// 1. Structured: `Name <email>`, a bare address, or the whole
|
||||
// `Name <email>` wrapped in quotes (unwrap once and retry).
|
||||
if (tryAdd(parseRecipient(entry))) continue;
|
||||
const unwrapped = unquoteName(entry);
|
||||
if (unwrapped !== entry && tryAdd(parseRecipient(unwrapped))) continue;
|
||||
|
||||
// 2. Fallback: a bare-address run (`a@x.com b@y.com`) or a
|
||||
// `John Doe <j@x.com>` fragment where only the <addr> is valid.
|
||||
// Whitespace/semicolon-tokenize; leftover tokens stay behind.
|
||||
for (const token of entry.split(/[\s;]+/).map((t) => t.trim()).filter(Boolean)) {
|
||||
if (!tryAdd({ email: token.replace(/^<|>$/g, '') })) invalid.push(token);
|
||||
}
|
||||
}
|
||||
|
||||
return { valid, invalid };
|
||||
}
|
||||
|
||||
/**
|
||||
* Replaces the placeholder src on `<img data-cid="...">` elements with the
|
||||
* resolved data URL once the inline blob has been fetched. Leaves images
|
||||
|
||||
@@ -140,6 +140,161 @@ export function sanitizePlainTextRenderedHtml(html: string): string {
|
||||
return DOMPurify.sanitize(html, PLAIN_TEXT_RENDERED_CONFIG);
|
||||
}
|
||||
|
||||
/**
|
||||
* 1x1 transparent SVG used to replace a blocked external <img> so the layout
|
||||
* doesn't reflow to a broken-image icon. The real URL is stashed in
|
||||
* `data-blocked-src` for restore.
|
||||
*/
|
||||
export const TRANSPARENT_BLOCKED_PIXEL =
|
||||
'data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMSIgaGVpZ2h0PSIxIiB2aWV3Qm94PSIwIDAgMSAxIiBmaWxsPSJub25lIiB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciPgo8cmVjdCB3aWR0aD0iMSIgaGVpZ2h0PSIxIiBmaWxsPSJ0cmFuc3BhcmVudCIvPgo8L3N2Zz4=';
|
||||
|
||||
/**
|
||||
* True if a resource URL would trigger an external (network) fetch once the
|
||||
* browser normalizes it. The URL parser removes ASCII tab/newline characters
|
||||
* anywhere in the string and trims leading/trailing C0-control + space before
|
||||
* resolving, so `"\n\nhttps://t"` and `"h\ttps://t"` are both external even
|
||||
* though they don't literally start with "https://" (the `imgNewlineSrc`
|
||||
* tracking bypass). Protocol-relative `//host` is external too. data:, blob:,
|
||||
* and cid: are inline/local and never count as external.
|
||||
*/
|
||||
export function isExternalResourceUrl(value: string | null | undefined): boolean {
|
||||
if (!value) return false;
|
||||
// Mirror the URL parser: drop every ASCII C0-control and space char it
|
||||
// ignores (leading/trailing trim plus tab/newline/CR removed anywhere).
|
||||
// eslint-disable-next-line no-control-regex
|
||||
const normalized = value.replace(/[\u0000-\u0020]+/g, '');
|
||||
return /^(?:https?:\/\/|\/\/)/i.test(normalized);
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Decode CSS escape sequences so escaped tracking URLs can be recognised.
|
||||
* `\68ttp://x` and `\000068ttp://x` both decode to `http://x` (the `cssEscape`
|
||||
* bypass). Handles the two CSS escape forms: 1-6 hex digits (optionally
|
||||
* followed by one whitespace) and a backslash before any other character.
|
||||
*/
|
||||
export function decodeCssEscapes(value: string): string {
|
||||
return value.replace(/\\([0-9a-fA-F]{1,6})\s?|\\(.)/g, (_full, hex, char) => {
|
||||
if (hex) {
|
||||
const code = parseInt(hex, 16);
|
||||
return code ? String.fromCodePoint(code) : '';
|
||||
}
|
||||
return char ?? '';
|
||||
});
|
||||
}
|
||||
|
||||
const CSS_URL_PATTERN = /url\(\s*(['"]?)([^)]*?)\1\s*\)/gi;
|
||||
|
||||
/** True if any `url(...)` in a CSS string resolves to an external resource. */
|
||||
export function styleHasExternalUrl(style: string): boolean {
|
||||
let found = false;
|
||||
style.replace(CSS_URL_PATTERN, (full, _q, inner) => {
|
||||
if (isExternalResourceUrl(decodeCssEscapes(inner))) found = true;
|
||||
return full;
|
||||
});
|
||||
return found;
|
||||
}
|
||||
|
||||
/** Replace every external `url(...)` in a CSS string with an empty `url()`. */
|
||||
export function stripExternalCssUrls(style: string): string {
|
||||
return style.replace(CSS_URL_PATTERN, (full, _q, inner) =>
|
||||
isExternalResourceUrl(decodeCssEscapes(inner)) ? 'url()' : full
|
||||
);
|
||||
}
|
||||
|
||||
/** True if a srcset attribute lists at least one external candidate URL. */
|
||||
function srcsetHasExternalUrl(srcset: string): boolean {
|
||||
return srcset
|
||||
.split(',')
|
||||
.some((candidate) => isExternalResourceUrl(candidate.trim().split(/\s+/)[0]));
|
||||
}
|
||||
|
||||
/**
|
||||
* Neutralise every external-resource vector on a single sanitized element,
|
||||
* stashing the original value in a `data-blocked-*` attribute for later
|
||||
* restore. Covers the vectors Email Privacy Tester exercises beyond a bare
|
||||
* `<img src>`: whitespace/newline in src, `<picture><source srcset>`,
|
||||
* `<video poster>`/media src, the legacy `background` attribute, and inline
|
||||
* `style` url() (including CSS-escaped URLs).
|
||||
*
|
||||
* This is the first line of defence (it drives the "external content blocked"
|
||||
* banner and placeholder swap); the iframe's strict img-src/media-src/font-src
|
||||
* CSP is the guaranteed network-level backstop for anything expressed in ways
|
||||
* the DOM walk can't see (e.g. `<style>`-tag rules).
|
||||
*
|
||||
* @returns true if anything on the node was blocked.
|
||||
*/
|
||||
export function blockExternalResourcesOnNode(node: Element): boolean {
|
||||
let blocked = false;
|
||||
const tag = node.tagName;
|
||||
|
||||
if (tag === 'IMG') {
|
||||
const src = node.getAttribute('src');
|
||||
if (isExternalResourceUrl(src)) {
|
||||
node.setAttribute('data-blocked-src', src!.trim());
|
||||
node.setAttribute('src', TRANSPARENT_BLOCKED_PIXEL);
|
||||
node.setAttribute('alt', '');
|
||||
(node as HTMLElement).style.display = 'none';
|
||||
blocked = true;
|
||||
}
|
||||
}
|
||||
|
||||
// Responsive images: <img srcset> and <picture><source srcset>.
|
||||
if (tag === 'IMG' || tag === 'SOURCE') {
|
||||
const srcset = node.getAttribute('srcset');
|
||||
if (srcset && srcsetHasExternalUrl(srcset)) {
|
||||
node.setAttribute('data-blocked-srcset', srcset);
|
||||
node.removeAttribute('srcset');
|
||||
blocked = true;
|
||||
}
|
||||
}
|
||||
|
||||
// <source src> for <video>/<audio> (and rare <picture> src).
|
||||
if (tag === 'SOURCE') {
|
||||
const src = node.getAttribute('src');
|
||||
if (isExternalResourceUrl(src)) {
|
||||
node.setAttribute('data-blocked-src', src!.trim());
|
||||
node.removeAttribute('src');
|
||||
blocked = true;
|
||||
}
|
||||
}
|
||||
|
||||
// <video poster> and direct <video>/<audio> src.
|
||||
if (tag === 'VIDEO' || tag === 'AUDIO') {
|
||||
const poster = node.getAttribute('poster');
|
||||
if (isExternalResourceUrl(poster)) {
|
||||
node.setAttribute('data-blocked-poster', poster!.trim());
|
||||
node.removeAttribute('poster');
|
||||
blocked = true;
|
||||
}
|
||||
const src = node.getAttribute('src');
|
||||
if (isExternalResourceUrl(src)) {
|
||||
node.setAttribute('data-blocked-src', src!.trim());
|
||||
node.removeAttribute('src');
|
||||
blocked = true;
|
||||
}
|
||||
}
|
||||
|
||||
// Legacy table/cell background attribute.
|
||||
const bgAttr = node.getAttribute('background');
|
||||
if (isExternalResourceUrl(bgAttr)) {
|
||||
node.setAttribute('data-blocked-background', bgAttr!.trim());
|
||||
node.removeAttribute('background');
|
||||
blocked = true;
|
||||
}
|
||||
|
||||
// Inline style url() — read the raw attribute so CSS escapes survive for
|
||||
// decoding, then strip only the external urls.
|
||||
const styleAttr = node.getAttribute('style');
|
||||
if (styleAttr && styleHasExternalUrl(styleAttr)) {
|
||||
node.setAttribute('data-blocked-style', styleAttr);
|
||||
node.setAttribute('style', stripExternalCssUrls(styleAttr));
|
||||
blocked = true;
|
||||
}
|
||||
|
||||
return blocked;
|
||||
}
|
||||
|
||||
/**
|
||||
* Safe HTML parsing without execution
|
||||
* Use instead of innerHTML for detection/parsing
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
// Shared "View source" renderer. Builds a human-readable dump of a message's
|
||||
// headers, metadata and body from its JMAP Email object. Used both by the
|
||||
// email viewer's source modal and by the plugin projection so plugins see the
|
||||
// exact same text the UI shows. Pure: depends only on the passed `email`.
|
||||
|
||||
import type { Email } from '@/lib/jmap/types';
|
||||
import { formatFileSize } from '@/lib/utils';
|
||||
|
||||
export function generateEmailSource(email: Email): string {
|
||||
let source = '';
|
||||
|
||||
// Headers
|
||||
source += '=== EMAIL HEADERS ===\n\n';
|
||||
if (email.messageId) source += `Message-ID: ${email.messageId}\n`;
|
||||
if (email.from) source += `From: ${email.from.map(a => a.name ? `${a.name} <${a.email}>` : a.email).join(', ')}\n`;
|
||||
if (email.to) source += `To: ${email.to.map(a => a.name ? `${a.name} <${a.email}>` : a.email).join(', ')}\n`;
|
||||
if (email.cc) source += `Cc: ${email.cc.map(a => a.name ? `${a.name} <${a.email}>` : a.email).join(', ')}\n`;
|
||||
if (email.bcc) source += `Bcc: ${email.bcc.map(a => a.name ? `${a.name} <${a.email}>` : a.email).join(', ')}\n`;
|
||||
if (email.replyTo) source += `Reply-To: ${email.replyTo.map(a => a.name ? `${a.name} <${a.email}>` : a.email).join(', ')}\n`;
|
||||
if (email.subject) source += `Subject: ${email.subject}\n`;
|
||||
if (email.sentAt) source += `Date: ${new Date(email.sentAt).toUTCString()}\n`;
|
||||
if (email.receivedAt) source += `Received-At: ${new Date(email.receivedAt).toUTCString()}\n`;
|
||||
if (email.inReplyTo) source += `In-Reply-To: ${email.inReplyTo.join(', ')}\n`;
|
||||
if (email.references) source += `References: ${email.references.join(', ')}\n`;
|
||||
|
||||
// Additional headers
|
||||
if (email.headers) {
|
||||
source += '\n--- Additional Headers ---\n';
|
||||
// Headers should now always be a Record after client processing
|
||||
Object.entries(email.headers).forEach(([key, value]) => {
|
||||
const val = Array.isArray(value) ? value.join('\n ') : String(value);
|
||||
source += `${key}: ${val}\n`;
|
||||
});
|
||||
}
|
||||
|
||||
// Authentication results
|
||||
if (email.authenticationResults) {
|
||||
source += '\n--- Authentication Results ---\n';
|
||||
if (email.authenticationResults.spf) {
|
||||
source += `SPF: ${email.authenticationResults.spf.result}`;
|
||||
if (email.authenticationResults.spf.domain) source += ` (${email.authenticationResults.spf.domain})`;
|
||||
source += '\n';
|
||||
}
|
||||
if (email.authenticationResults.dkim) {
|
||||
source += `DKIM: ${email.authenticationResults.dkim.result}`;
|
||||
if (email.authenticationResults.dkim.domain) source += ` (${email.authenticationResults.dkim.domain})`;
|
||||
source += '\n';
|
||||
}
|
||||
if (email.authenticationResults.dmarc) {
|
||||
source += `DMARC: ${email.authenticationResults.dmarc.result}`;
|
||||
if (email.authenticationResults.dmarc.policy) source += ` policy=${email.authenticationResults.dmarc.policy}`;
|
||||
source += '\n';
|
||||
}
|
||||
}
|
||||
|
||||
if (email.spamScore !== undefined) {
|
||||
source += `Spam Score: ${email.spamScore}`;
|
||||
if (email.spamStatus) source += ` (${email.spamStatus})`;
|
||||
source += '\n';
|
||||
}
|
||||
|
||||
// Metadata
|
||||
source += '\n=== EMAIL METADATA ===\n\n';
|
||||
source += `Email ID: ${email.id}\n`;
|
||||
source += `Thread ID: ${email.threadId}\n`;
|
||||
source += `Size: ${formatFileSize(email.size)}\n`;
|
||||
source += `Has Attachment: ${email.hasAttachment ? 'Yes' : 'No'}\n`;
|
||||
if (email.keywords) {
|
||||
const keywords = Object.entries(email.keywords)
|
||||
.filter(([_, v]) => v)
|
||||
.map(([k]) => k)
|
||||
.join(', ');
|
||||
if (keywords) source += `Keywords: ${keywords}\n`;
|
||||
}
|
||||
|
||||
// Attachments
|
||||
if (email.attachments && email.attachments.length > 0) {
|
||||
source += '\n=== ATTACHMENTS ===\n\n';
|
||||
email.attachments.forEach((att, i) => {
|
||||
source += `[${i + 1}] ${att.name || 'Unnamed'}\n`;
|
||||
source += ` Type: ${att.type}\n`;
|
||||
source += ` Size: ${formatFileSize(att.size)}\n`;
|
||||
source += ` Blob ID: ${att.blobId}\n`;
|
||||
if (att.cid) source += ` Content-ID: ${att.cid}\n`;
|
||||
source += '\n';
|
||||
});
|
||||
}
|
||||
|
||||
// Body content
|
||||
source += '\n=== EMAIL BODY ===\n\n';
|
||||
|
||||
let hasBodyContent = false;
|
||||
|
||||
// Text version
|
||||
if (email.textBody?.[0]?.partId && email.bodyValues?.[email.textBody[0].partId]) {
|
||||
const textValue = email.bodyValues[email.textBody[0].partId].value;
|
||||
if (textValue && textValue.trim()) {
|
||||
source += '--- Plain Text Version ---\n\n';
|
||||
source += textValue;
|
||||
source += '\n\n';
|
||||
hasBodyContent = true;
|
||||
}
|
||||
}
|
||||
|
||||
// HTML version
|
||||
if (email.htmlBody?.[0]?.partId && email.bodyValues?.[email.htmlBody[0].partId]) {
|
||||
const htmlValue = email.bodyValues[email.htmlBody[0].partId].value;
|
||||
if (htmlValue && htmlValue.trim()) {
|
||||
source += '--- HTML Version ---\n\n';
|
||||
source += htmlValue;
|
||||
source += '\n\n';
|
||||
hasBodyContent = true;
|
||||
}
|
||||
}
|
||||
|
||||
// All body values if we haven't found content yet
|
||||
if (!hasBodyContent && email.bodyValues) {
|
||||
const bodyKeys = Object.keys(email.bodyValues);
|
||||
if (bodyKeys.length > 0) {
|
||||
source += '--- Body Parts ---\n\n';
|
||||
bodyKeys.forEach((key, index) => {
|
||||
const bodyValue = email.bodyValues![key].value;
|
||||
if (bodyValue && bodyValue.trim()) {
|
||||
source += `Part ${index + 1} (${key}):\n`;
|
||||
source += bodyValue;
|
||||
source += '\n\n';
|
||||
hasBodyContent = true;
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Preview if no body
|
||||
if (!hasBodyContent && email.preview) {
|
||||
source += '--- Preview Only ---\n\n';
|
||||
source += email.preview;
|
||||
source += '\n';
|
||||
}
|
||||
|
||||
if (!hasBodyContent && !email.preview) {
|
||||
source += '(No body content available)\n';
|
||||
}
|
||||
|
||||
return source;
|
||||
}
|
||||
@@ -73,7 +73,7 @@ export interface IJMAPClient {
|
||||
// ── Mailboxes ─────────────────────────────────────────────────
|
||||
getMailboxes(accountId?: string): Promise<Mailbox[]>;
|
||||
getAllMailboxes(): Promise<Mailbox[]>;
|
||||
createMailbox(name: string, parentId?: string): Promise<Mailbox>;
|
||||
createMailbox(name: string, parentId?: string, accountId?: string): Promise<Mailbox>;
|
||||
updateMailbox(mailboxId: string, changes: { name?: string; parentId?: string | null; role?: string | null; sortOrder?: number }): Promise<void>;
|
||||
deleteMailbox(mailboxId: string): Promise<void>;
|
||||
|
||||
@@ -92,14 +92,14 @@ export interface IJMAPClient {
|
||||
|
||||
// ── Email mutations ───────────────────────────────────────────
|
||||
markAsRead(emailId: string, read?: boolean, accountId?: string): Promise<void>;
|
||||
batchMarkAsRead(emailIds: string[], read?: boolean): Promise<void>;
|
||||
toggleStar(emailId: string, starred: boolean): Promise<void>;
|
||||
batchMarkAsRead(emailIds: string[], read?: boolean, accountId?: string): Promise<void>;
|
||||
toggleStar(emailId: string, starred: boolean, accountId?: string): Promise<void>;
|
||||
updateEmailKeywords(emailId: string, keywords: Record<string, boolean>): Promise<void>;
|
||||
setKeyword(emailId: string, keyword: string): Promise<void>;
|
||||
migrateKeyword(oldKeyword: string, newKeyword: string): Promise<number>;
|
||||
deleteEmail(emailId: string, accountId?: string): Promise<void>;
|
||||
moveToTrash(emailId: string, trashMailboxId: string, accountId?: string, markAsRead?: boolean): Promise<void>;
|
||||
batchDeleteEmails(emailIds: string[]): Promise<void>;
|
||||
batchDeleteEmails(emailIds: string[], accountId?: string): Promise<void>;
|
||||
batchMoveEmails(emailIds: string[], toMailboxId: string, accountId?: string, markAsRead?: boolean): Promise<void>;
|
||||
batchArchiveEmails(
|
||||
emails: Array<{ id: string; receivedAt: string }>,
|
||||
|
||||
+36
-18
@@ -459,8 +459,11 @@ function sanitizeIdentityDisplayName(name: string | undefined | null): string {
|
||||
}
|
||||
|
||||
function normalizeEnvelopeRecipients(recipients?: Array<string | EmailAddress>): Array<{ email: string }> {
|
||||
// The JMAP envelope rcptTo/mailFrom take a bare addr-spec, not an RFC 5322
|
||||
// mailbox. `to`/`cc`/`bcc` may arrive as "Name <addr>"; strip the display
|
||||
// name or the submission validator rejects the whole envelope (#…).
|
||||
return (recipients || [])
|
||||
.map((recipient) => typeof recipient === 'string' ? recipient : recipient.email)
|
||||
.map((recipient) => typeof recipient === 'string' ? parseRecipientString(recipient).email : recipient.email)
|
||||
.map((email) => email.trim())
|
||||
.filter(Boolean)
|
||||
.map((email) => ({ email }));
|
||||
@@ -699,7 +702,10 @@ export class JMAPClient implements IJMAPClient {
|
||||
if (sessionResponse.status === 402) {
|
||||
try {
|
||||
const body = await sessionResponse.json();
|
||||
if (body?.title?.toLowerCase().includes('totp')) {
|
||||
// Older Stalwart titled this "TOTP code required"; 0.16+ uses the
|
||||
// generic "MFA code required" - accept either to trigger the prompt.
|
||||
const title = body?.title?.toLowerCase() ?? '';
|
||||
if (title.includes('totp') || title.includes('mfa')) {
|
||||
throw new Error('TOTP_REQUIRED');
|
||||
}
|
||||
} catch (e) {
|
||||
@@ -1273,19 +1279,19 @@ export class JMAPClient implements IJMAPClient {
|
||||
]);
|
||||
}
|
||||
|
||||
async batchMarkAsRead(emailIds: string[], read: boolean = true): Promise<void> {
|
||||
async batchMarkAsRead(emailIds: string[], read: boolean = true, accountId?: string): Promise<void> {
|
||||
if (emailIds.length === 0) return;
|
||||
|
||||
const updates = Object.fromEntries(emailIds.map(id => [id, { "keywords/$seen": read }]));
|
||||
await this.request([
|
||||
["Email/set", { accountId: this.accountId, update: updates }, "0"],
|
||||
["Email/set", { accountId: accountId || this.accountId, update: updates }, "0"],
|
||||
]);
|
||||
}
|
||||
|
||||
async toggleStar(emailId: string, starred: boolean): Promise<void> {
|
||||
async toggleStar(emailId: string, starred: boolean, accountId?: string): Promise<void> {
|
||||
await this.request([
|
||||
["Email/set", {
|
||||
accountId: this.accountId,
|
||||
accountId: accountId || this.accountId,
|
||||
update: {
|
||||
[emailId]: {
|
||||
"keywords/$flagged": starred,
|
||||
@@ -1391,12 +1397,12 @@ export class JMAPClient implements IJMAPClient {
|
||||
]);
|
||||
}
|
||||
|
||||
async batchDeleteEmails(emailIds: string[]): Promise<void> {
|
||||
async batchDeleteEmails(emailIds: string[], accountId?: string): Promise<void> {
|
||||
if (emailIds.length === 0) return;
|
||||
|
||||
await this.request([
|
||||
["Email/set", {
|
||||
accountId: this.accountId,
|
||||
accountId: accountId || this.accountId,
|
||||
destroy: emailIds,
|
||||
}, "0"],
|
||||
]);
|
||||
@@ -1667,7 +1673,7 @@ export class JMAPClient implements IJMAPClient {
|
||||
async markAsSpam(emailId: string, accountId?: string, markAsRead?: boolean): Promise<void> {
|
||||
const targetAccountId = accountId || this.accountId;
|
||||
|
||||
const mailboxes = await this.getMailboxes();
|
||||
const mailboxes = await this.getMailboxes(accountId);
|
||||
const junkMailbox = mailboxes.find(m => {
|
||||
if (accountId) {
|
||||
return m.role === 'junk' && m.accountId === accountId;
|
||||
@@ -1709,7 +1715,7 @@ export class JMAPClient implements IJMAPClient {
|
||||
]);
|
||||
}
|
||||
|
||||
async createMailbox(name: string, parentId?: string): Promise<Mailbox> {
|
||||
async createMailbox(name: string, parentId?: string, accountId?: string): Promise<Mailbox> {
|
||||
const createId = `new-${Date.now()}`;
|
||||
const createData: Record<string, unknown> = { name };
|
||||
if (parentId) {
|
||||
@@ -1718,7 +1724,7 @@ export class JMAPClient implements IJMAPClient {
|
||||
|
||||
const response = await this.request([
|
||||
["Mailbox/set", {
|
||||
accountId: this.accountId,
|
||||
accountId: accountId || this.accountId,
|
||||
create: { [createId]: createData },
|
||||
}, "0"],
|
||||
]);
|
||||
@@ -2388,13 +2394,10 @@ export class JMAPClient implements IJMAPClient {
|
||||
const buildSubmissionCreate = (submissionId: string): Record<string, unknown> => {
|
||||
const create: Record<string, unknown> = { emailId: `#${emailId}`, identityId: finalIdentityId };
|
||||
if (holdForSeconds || envelopeMailFrom) {
|
||||
const envelopeRecipients = [...to, ...(cc || []), ...(bcc || [])]
|
||||
.map((email) => email.trim())
|
||||
.filter(Boolean)
|
||||
.map((email) => ({ email }));
|
||||
const envelopeRecipients = normalizeEnvelopeRecipients([...to, ...(cc || []), ...(bcc || [])]);
|
||||
create.envelope = {
|
||||
mailFrom: {
|
||||
email: envelopeMailFrom || fromEmail || this.username,
|
||||
email: parseRecipientString(envelopeMailFrom || fromEmail || this.username).email,
|
||||
...(holdForSeconds ? { parameters: { HOLDFOR: String(holdForSeconds) } } : {}),
|
||||
},
|
||||
rcptTo: envelopeRecipients,
|
||||
@@ -3367,7 +3370,16 @@ export class JMAPClient implements IJMAPClient {
|
||||
}
|
||||
|
||||
private getSubmissionAccountId(accountId?: string): string {
|
||||
return accountId || this.session?.primaryAccounts?.['urn:ietf:params:jmap:submission'] || this.accountId;
|
||||
// The requested (mail) account may not host EmailSubmission objects — JMAP
|
||||
// allows submission to live in a separate account (session
|
||||
// primaryAccounts['…:submission']). Only honour the requested account when
|
||||
// it actually advertises the submission capability; otherwise fall back to
|
||||
// the account JMAP designates for submission.
|
||||
const submissionPrimary = this.session?.primaryAccounts?.['urn:ietf:params:jmap:submission'];
|
||||
if (accountId && this.session?.accounts?.[accountId]?.accountCapabilities?.['urn:ietf:params:jmap:submission']) {
|
||||
return accountId;
|
||||
}
|
||||
return submissionPrimary || accountId || this.accountId;
|
||||
}
|
||||
|
||||
private getSubmissionCapability(accountId?: string): SubmissionCapability | undefined {
|
||||
@@ -4729,8 +4741,14 @@ export class JMAPClient implements IJMAPClient {
|
||||
|
||||
debug.log('calendar', 'CalendarEvent/batchCreate', { count: events.length, accountId });
|
||||
|
||||
// Never emit iMIP scheduling messages when importing. Imported events often
|
||||
// carry an organizer/participants where the current user is the organizer;
|
||||
// without this, Stalwart tries to send invitation emails to every attendee
|
||||
// synchronously during CalendarEvent/set, which is both wrong (importing a
|
||||
// calendar should not spam invites) and can block the request indefinitely,
|
||||
// leaving the import spinner spinning forever (#411).
|
||||
const response = await this.request([
|
||||
["CalendarEvent/set", { accountId, create: createMap }, "0"]
|
||||
["CalendarEvent/set", { accountId, sendSchedulingMessages: false, create: createMap }, "0"]
|
||||
], this.calendarUsing());
|
||||
|
||||
const createdIds: string[] = [];
|
||||
|
||||
+54
-1
@@ -58,9 +58,27 @@ export interface Email {
|
||||
// S/MIME support
|
||||
blobId?: string;
|
||||
bodyStructure?: EmailBodyPart;
|
||||
// Unified mailbox support - set when displaying emails from multiple accounts
|
||||
// Unified mailbox support - set when displaying emails from multiple accounts.
|
||||
// `accountId` is a DISPLAY-only reference (avatar color / label / badge) and may
|
||||
// hold either an AccountEntry.id (personal) or the JMAP owner id (shared). For
|
||||
// resolving the client + JMAP routing use the two dedicated fields below, which
|
||||
// are always set on aggregated emails and unambiguous.
|
||||
accountId?: string;
|
||||
accountLabel?: string;
|
||||
// AccountEntry.id of the logged-in client through which this email is reachable.
|
||||
// Always a real login key → `useAuthStore.getClientForAccount(...)` resolves it.
|
||||
// For personal sources this equals the account itself; for shared/group sources
|
||||
// it is the delegating login (the shared account has no own login).
|
||||
sourceClientAccountId?: string;
|
||||
// JMAP account id of the email's owning account (personal: the client's primary;
|
||||
// shared/group: the owner account). Always safe to pass as the JMAP `accountId`
|
||||
// argument — equal to the client's primary for personal sources, so it is a no-op
|
||||
// there, and triggers owner-scoped routing + mailbox-id namespacing for shared.
|
||||
sourceAccountId?: string;
|
||||
// Name of the email's originating folder, stamped for the aggregate "All …"
|
||||
// views (All Mail, unified, cross-account) so the list can show where each
|
||||
// message lives. Transient/client-only, not part of the JMAP object.
|
||||
sourceFolder?: string;
|
||||
// Client-only scheduled-send metadata, populated from EmailSubmission/query.
|
||||
scheduledSendAt?: string;
|
||||
emailSubmissionId?: string;
|
||||
@@ -879,3 +897,38 @@ export function isUnifiedMailboxId(id: string): boolean {
|
||||
* included is a per-user setting (see `allMailFolderIds`).
|
||||
*/
|
||||
export const ALL_MAIL_MAILBOX_ID = '__all_mail__';
|
||||
|
||||
/**
|
||||
* Cross-account "All …" views shown in the unified ("All accounts") section.
|
||||
* Each merges messages across EVERY account (including shared/group folders),
|
||||
* spanning all folders except junk/spam, sent, archive, trash and drafts, in
|
||||
* one date-sorted list. Distinct from the per-role unified ids (one role across
|
||||
* accounts) and from ALL_MAIL_MAILBOX_ID (all folders of a single account).
|
||||
*/
|
||||
export const CROSS_UNREAD = '__cross_unread__';
|
||||
export const CROSS_STARRED = '__cross_starred__';
|
||||
export const CROSS_ALL = '__cross_all__';
|
||||
|
||||
export type CrossView = 'unread' | 'starred' | 'all';
|
||||
|
||||
export const CROSS_VIEW_IDS: Record<CrossView, string> = {
|
||||
unread: CROSS_UNREAD,
|
||||
starred: CROSS_STARRED,
|
||||
all: CROSS_ALL,
|
||||
};
|
||||
|
||||
export const CROSS_VIEW_BY_ID: Record<string, CrossView> = Object.fromEntries(
|
||||
Object.entries(CROSS_VIEW_IDS).map(([view, id]) => [id, view as CrossView])
|
||||
) as Record<string, CrossView>;
|
||||
|
||||
export function isCrossViewId(id: string): boolean {
|
||||
return id in CROSS_VIEW_BY_ID;
|
||||
}
|
||||
|
||||
/**
|
||||
* Mailbox roles excluded from the cross-account views. Everything else (inbox
|
||||
* and custom/no-role folders) is included.
|
||||
*/
|
||||
export const CROSS_EXCLUDED_ROLES: ReadonlySet<string> = new Set([
|
||||
'junk', 'sent', 'archive', 'trash', 'drafts',
|
||||
]);
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user