Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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,48 @@
|
||||
# Changelog
|
||||
|
||||
## 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}
|
||||
|
||||
+167
-51
@@ -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 mailbox = mailboxes.find(mb => mb.id === selectedMailbox);
|
||||
const accountId = perAccountClient
|
||||
? undefined
|
||||
: mailbox?.isShared ? mailbox.accountId : undefined;
|
||||
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);
|
||||
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) => {
|
||||
|
||||
@@ -627,7 +627,7 @@ export default function SettingsPage() {
|
||||
{ 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 +646,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[])
|
||||
: [];
|
||||
|
||||
@@ -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 = [
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -617,7 +617,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');
|
||||
});
|
||||
});
|
||||
@@ -32,6 +32,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,7 +117,7 @@ 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());
|
||||
@@ -201,7 +203,6 @@ 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]) : [];
|
||||
@@ -260,14 +261,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
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -160,6 +160,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 }));
|
||||
|
||||
@@ -0,0 +1,317 @@
|
||||
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/smime-store', () => {
|
||||
const state = {
|
||||
certs: [],
|
||||
signingEnabled: false,
|
||||
encryptionEnabled: false,
|
||||
defaultSigningCertId: null,
|
||||
defaultEncryptionCertId: null,
|
||||
};
|
||||
const hook = (sel?: (s: typeof state) => unknown) =>
|
||||
typeof sel === 'function' ? sel(state) : state;
|
||||
hook.getState = () => state;
|
||||
hook.setState = (p: Partial<typeof state>) => Object.assign(state, p);
|
||||
return { useSmimeStore: hook };
|
||||
});
|
||||
|
||||
vi.mock('@/stores/email-store', () => {
|
||||
const state = {
|
||||
draftSaveEnabled: false,
|
||||
sendRawEmail: async () => ({ sent: true }),
|
||||
};
|
||||
const hook = (sel?: (s: typeof state) => unknown) =>
|
||||
typeof sel === 'function' ? sel(state) : state;
|
||||
hook.getState = () => state;
|
||||
hook.setState = (p: Partial<typeof state>) => Object.assign(state, p);
|
||||
return { useEmailStore: hook };
|
||||
});
|
||||
|
||||
vi.mock('@/stores/settings-store', () => {
|
||||
const state = {
|
||||
timeFormat: '24h',
|
||||
plainTextMode: false,
|
||||
subAddressDelimiter: '+',
|
||||
autoSelectReplyIdentity: true,
|
||||
attachmentReminderEnabled: false,
|
||||
attachmentReminderKeywords: [],
|
||||
sendDelaySeconds: 0,
|
||||
signaturePosition: 'above_quote',
|
||||
signatureSeparatorEnabled: false,
|
||||
requestReadReceiptDefault: false,
|
||||
addTrustedSender: () => {},
|
||||
trustedSendersAddressBook: null,
|
||||
};
|
||||
const hook = (sel?: (s: typeof state) => unknown) =>
|
||||
typeof sel === 'function' ? sel(state) : state;
|
||||
hook.getState = () => state;
|
||||
hook.setState = (p: Partial<typeof state>) => Object.assign(state, p);
|
||||
return { useSettingsStore: hook };
|
||||
});
|
||||
|
||||
vi.mock('@/stores/contact-store', () => {
|
||||
const state = {
|
||||
contacts: [],
|
||||
getAutocomplete: async () => [],
|
||||
addToTrustedSendersBook: async () => {},
|
||||
};
|
||||
const hook = (sel?: (s: typeof state) => unknown) =>
|
||||
typeof sel === 'function' ? sel(state) : state;
|
||||
hook.getState = () => state;
|
||||
hook.setState = (p: Partial<typeof state>) => Object.assign(state, p);
|
||||
return { useContactStore: hook };
|
||||
});
|
||||
|
||||
vi.mock('@/stores/template-store', () => {
|
||||
const state = { templates: [], addTemplate: async () => {} };
|
||||
const hook = (sel?: (s: typeof state) => unknown) =>
|
||||
typeof sel === 'function' ? sel(state) : state;
|
||||
hook.getState = () => state;
|
||||
hook.setState = (p: Partial<typeof state>) => Object.assign(state, p);
|
||||
return { useTemplateStore: hook };
|
||||
});
|
||||
|
||||
// ─── Misc dependency mocks ────────────────────────────────────────────────────
|
||||
|
||||
vi.mock('@/stores/toast-store', () => ({
|
||||
toast: { info: () => {}, error: () => {}, success: () => {} },
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/plugin-hooks', () => ({
|
||||
emailHooks: {
|
||||
onComposerOpen: { call: async () => [] },
|
||||
onRecipientChange: { call: async () => [] },
|
||||
getRecipientSuggestions: { call: async () => [] },
|
||||
onSend: { call: async () => [] },
|
||||
beforeSend: { call: async () => [] },
|
||||
},
|
||||
contactHooks: {
|
||||
search: { call: async () => [] },
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/email-sanitization', () => ({
|
||||
sanitizeSignatureHtml: (v: string) => v,
|
||||
sanitizeEmailHtml: (v: string) => v,
|
||||
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/smime/smime-sign', () => ({ smimeSign: async () => null }));
|
||||
vi.mock('@/lib/smime/smime-encrypt', () => ({ smimeEncrypt: async () => null }));
|
||||
vi.mock('@/lib/smime/mime-builder', () => ({
|
||||
buildMimeMessage: () => null,
|
||||
wrapCmsAsSmimeMessage: () => null,
|
||||
}));
|
||||
vi.mock('@/lib/debug', () => ({ debug: () => {} }));
|
||||
vi.mock('@/components/email/quoted-html', () => ({
|
||||
buildQuotedHtmlBlock: () => '',
|
||||
serializeEditorContent: () => '',
|
||||
}));
|
||||
vi.mock('@/lib/template-utils', () => ({ substitutePlaceholders: (s: string) => s }));
|
||||
|
||||
// ─── 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();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -15,6 +15,7 @@ import { sanitizeSignatureHtml, sanitizeEmailHtml } from "@/lib/email-sanitizati
|
||||
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";
|
||||
@@ -49,6 +50,7 @@ import {
|
||||
parseRecipient,
|
||||
parseRecipientList,
|
||||
formatRecipientList,
|
||||
splitPastedRecipients,
|
||||
type Recipient,
|
||||
} from "@/lib/email-composer-utils";
|
||||
import { RichTextEditor } from "@/components/email/rich-text-editor";
|
||||
@@ -187,11 +189,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 +206,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
|
||||
@@ -646,6 +650,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(() => {
|
||||
@@ -1484,7 +1493,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 +1537,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.
|
||||
@@ -1849,6 +1872,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);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -2028,7 +2054,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"
|
||||
@@ -2535,7 +2561,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 +2571,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 +2599,7 @@ export function EmailComposer({
|
||||
) : (
|
||||
<Button
|
||||
onClick={() => handleSend()}
|
||||
disabled={!canSend}
|
||||
disabled={!canSend || isSending}
|
||||
title={getSendTooltip()}
|
||||
className="hidden md:inline-flex"
|
||||
>
|
||||
@@ -2636,7 +2662,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>
|
||||
<Button onClick={handleScheduleSend} disabled={!canSend || isSending}>{t('schedule_send')}</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -2919,6 +2945,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 +3153,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
|
||||
: Icon;
|
||||
: 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!)}
|
||||
|
||||
+174
-122
@@ -3,9 +3,9 @@
|
||||
import { useState, useEffect, useLayoutEffect, useMemo, useRef, useCallback } from "react";
|
||||
import DOMPurify from "dompurify";
|
||||
import { Email, ContactCard, Mailbox } from "@/lib/jmap/types";
|
||||
import { emailExportFilename, attachmentDownloadFilename, DEFAULT_EMAIL_TEMPLATE, DEFAULT_ATTACHMENT_TEMPLATE } from "@/lib/download-filename";
|
||||
import { emailExportFilename, attachmentDownloadFilename, attachmentsBundleFilename, DEFAULT_EMAIL_TEMPLATE, DEFAULT_ATTACHMENT_TEMPLATE } from "@/lib/download-filename";
|
||||
import { EML_IMPORT_ACCEPT, expandImportableEmails } from "@/lib/eml-import";
|
||||
import { EMAIL_IFRAME_SANITIZE_CONFIG, collapseBlockedImageContainers, escapeHtml, plainTextToSafeHtml, sanitizeEmailHtml, sanitizePlainTextRenderedHtml } from "@/lib/email-sanitization";
|
||||
import { EMAIL_IFRAME_SANITIZE_CONFIG, blockExternalResourcesOnNode, collapseBlockedImageContainers, escapeHtml, plainTextToSafeHtml, sanitizeEmailHtml, sanitizePlainTextRenderedHtml } from "@/lib/email-sanitization";
|
||||
import { hasMeaningfulHtmlBody } from "@/lib/signature-utils";
|
||||
import { withBasePath } from "@/lib/browser-navigation";
|
||||
import { Button } from "@/components/ui/button";
|
||||
@@ -1004,6 +1004,7 @@ export function EmailViewer({
|
||||
const [showAllBesideAttachments, setShowAllBesideAttachments] = useState(false);
|
||||
const [showAllMobileAttachments, setShowAllMobileAttachments] = useState(false);
|
||||
const [showAllBelowHeaderAttachments, setShowAllBelowHeaderAttachments] = useState(false);
|
||||
const [isDownloadingAll, setIsDownloadingAll] = useState(false);
|
||||
const [visibleBelowHeaderCount, setVisibleBelowHeaderCount] = useState<number | null>(null);
|
||||
const belowHeaderRowRef = useRef<HTMLDivElement>(null);
|
||||
const belowHeaderGhostRef = useRef<HTMLDivElement>(null);
|
||||
@@ -1331,6 +1332,10 @@ export function EmailViewer({
|
||||
}, markAsReadDelay);
|
||||
|
||||
return () => clearTimeout(timeout);
|
||||
// Keyed to email id + $seen only: depending on the whole `email` object
|
||||
// would reset the mark-as-read delay timer whenever any unrelated email
|
||||
// field updates (e.g. a background re-fetch).
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [email?.id, email?.keywords?.$seen, onMarkAsRead]);
|
||||
|
||||
// Reset external content permission and quick reply when email changes
|
||||
@@ -2260,6 +2265,11 @@ export function EmailViewer({
|
||||
}));
|
||||
|
||||
return [...jmapAttachments, ...tnefExtracted, ...embeddedExtracted];
|
||||
// The memo derives only from `email.attachments` (findCalendarAttachment
|
||||
// scans that array); depending on the whole `email` object would rebuild the
|
||||
// attachment list — and its downstream layout measurement — on every email
|
||||
// field change.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [email?.attachments, smimeDecryptedAttachments, tnefHtml, tnefText, tnefAttachments, embeddedEmailUnwrapped, embeddedEmailAttachments, calendarInvitationParsingEnabled, hideInlineImageAttachments]);
|
||||
|
||||
// Measure attachment chips in the below-header row to determine how many fit
|
||||
@@ -2453,7 +2463,7 @@ export function EmailViewer({
|
||||
|
||||
// Sanitize and prepare email HTML content
|
||||
const emailContent = useMemo(() => {
|
||||
if (!email) return { html: "", isHtml: false, hasStyleTag: false };
|
||||
if (!email) return { html: "", isHtml: false, hasStyleTag: false, externalBlocked: false };
|
||||
|
||||
// Check if we have body values
|
||||
if (email.bodyValues) {
|
||||
@@ -2521,38 +2531,14 @@ export function EmailViewer({
|
||||
}
|
||||
|
||||
DOMPurify.addHook('afterSanitizeAttributes', (node) => {
|
||||
const htmlNode = node as HTMLElement;
|
||||
|
||||
if (shouldBlockExternal) {
|
||||
if (node.tagName === 'IMG') {
|
||||
const src = node.getAttribute('src');
|
||||
if (src && (src.startsWith('http://') || src.startsWith('https://') || src.startsWith('//'))) {
|
||||
node.setAttribute('data-blocked-src', src);
|
||||
node.setAttribute('src', 'data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMSIgaGVpZ2h0PSIxIiB2aWV3Qm94PSIwIDAgMSAxIiBmaWxsPSJub25lIiB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciPgo8cmVjdCB3aWR0aD0iMSIgaGVpZ2h0PSIxIiBmaWxsPSJ0cmFuc3BhcmVudCIvPgo8L3N2Zz4=');
|
||||
node.setAttribute('alt', '');
|
||||
htmlNode.style.display = 'none';
|
||||
blockedExternalContent = true;
|
||||
}
|
||||
}
|
||||
|
||||
const bgAttr = node.getAttribute?.('background');
|
||||
if (bgAttr && (bgAttr.startsWith('http://') || bgAttr.startsWith('https://') || bgAttr.startsWith('//'))) {
|
||||
node.setAttribute('data-blocked-background', bgAttr);
|
||||
node.removeAttribute('background');
|
||||
// Blocks every external-resource vector (img src incl.
|
||||
// whitespace/newline tricks, srcset, <source>, <video poster>,
|
||||
// media src, background attr, inline style url() incl. CSS
|
||||
// escapes). The strict iframe CSP below is the network backstop.
|
||||
if (blockExternalResourcesOnNode(node)) {
|
||||
blockedExternalContent = true;
|
||||
}
|
||||
|
||||
if (htmlNode.style) {
|
||||
const style = htmlNode.style.cssText;
|
||||
if (style && style.includes('url(')) {
|
||||
const urlMatch = style.match(/url\(['"]?(https?:\/\/[^'")\s]+)['"]?\)/gi);
|
||||
if (urlMatch) {
|
||||
node.setAttribute('data-blocked-style', style);
|
||||
htmlNode.style.cssText = style.replace(/url\(['"]?https?:\/\/[^'")\s]+['"]?\)/gi, 'url()');
|
||||
blockedExternalContent = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (node.tagName === 'A') {
|
||||
@@ -2583,6 +2569,11 @@ export function EmailViewer({
|
||||
html: cleanHtml,
|
||||
isHtml: true,
|
||||
hasStyleTag: /<style[\s>]/i.test(htmlContent),
|
||||
// Drives the strict iframe CSP: when we're in blocking mode the
|
||||
// iframe forbids external img/media/font fetches entirely, so any
|
||||
// vector the DOM walk above missed (e.g. <style>-tag url()) still
|
||||
// can't phone home. Cleared once the user allows / trusts.
|
||||
externalBlocked: shouldBlockExternal,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -2594,6 +2585,7 @@ export function EmailViewer({
|
||||
html: plainTextToSafeHtml(textContent),
|
||||
isHtml: false,
|
||||
hasStyleTag: false,
|
||||
externalBlocked: false,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -2609,6 +2601,7 @@ export function EmailViewer({
|
||||
html: `<div style="color: var(--color-muted-foreground); font-style: italic;">${previewHtml}</div>`,
|
||||
isHtml: false,
|
||||
hasStyleTag: false,
|
||||
externalBlocked: false,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -2616,12 +2609,16 @@ export function EmailViewer({
|
||||
html: `<p style="color: var(--color-muted-foreground); font-style: italic;">${t('no_body_content')}</p>`,
|
||||
isHtml: false,
|
||||
hasStyleTag: false,
|
||||
externalBlocked: false,
|
||||
};
|
||||
// Intentionally omit allowExternalContent and trust state from deps:
|
||||
// toggling permission imperatively unblocks content via restoreBlockedContent
|
||||
// in an effect below, so the iframe srcDoc stays stable and doesn't reload/flash.
|
||||
// Recompute when permission changes so the srcDoc rebuilds with the
|
||||
// unblocked content AND the permissive CSP. The strict blocking-mode CSP
|
||||
// can't be relaxed in place (a document's CSP is fixed at load), so the
|
||||
// "Load images" / "Trust sender" buttons (both flip allowExternalContent)
|
||||
// intentionally trigger a fresh srcDoc. Trust selectors are read inside and
|
||||
// re-read on that rebuild, so they're deliberately omitted from deps.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [email, externalContentPolicy, cidBlobUrls, t]);
|
||||
}, [email, externalContentPolicy, allowExternalContent, cidBlobUrls, t]);
|
||||
|
||||
// Override email content with S/MIME decrypted content when available
|
||||
const effectiveEmailContent = useMemo(() => {
|
||||
@@ -2633,26 +2630,26 @@ export function EmailViewer({
|
||||
}
|
||||
);
|
||||
const cleanHtml = DOMPurify.sanitize(htmlWithCidUrls, EMAIL_IFRAME_SANITIZE_CONFIG);
|
||||
return { html: cleanHtml, isHtml: true, hasStyleTag: /<style[\s>]/i.test(smimeDecryptedHtml) };
|
||||
return { html: cleanHtml, isHtml: true, hasStyleTag: /<style[\s>]/i.test(smimeDecryptedHtml), externalBlocked: false };
|
||||
}
|
||||
if (smimeDecryptedText) {
|
||||
return { html: plainTextToSafeHtml(smimeDecryptedText), isHtml: false, hasStyleTag: false };
|
||||
return { html: plainTextToSafeHtml(smimeDecryptedText), isHtml: false, hasStyleTag: false, externalBlocked: false };
|
||||
}
|
||||
// TNEF (winmail.dat) extracted content
|
||||
if (tnefHtml) {
|
||||
const cleanHtml = DOMPurify.sanitize(tnefHtml, EMAIL_IFRAME_SANITIZE_CONFIG);
|
||||
return { html: cleanHtml, isHtml: true, hasStyleTag: /<style[\s>]/i.test(tnefHtml) };
|
||||
return { html: cleanHtml, isHtml: true, hasStyleTag: /<style[\s>]/i.test(tnefHtml), externalBlocked: false };
|
||||
}
|
||||
if (tnefText) {
|
||||
return { html: plainTextToSafeHtml(tnefText), isHtml: false, hasStyleTag: false };
|
||||
return { html: plainTextToSafeHtml(tnefText), isHtml: false, hasStyleTag: false, externalBlocked: false };
|
||||
}
|
||||
// Embedded message/rfc822 unwrapped content
|
||||
if (embeddedEmailHtml) {
|
||||
const cleanHtml = DOMPurify.sanitize(embeddedEmailHtml, EMAIL_IFRAME_SANITIZE_CONFIG);
|
||||
return { html: cleanHtml, isHtml: true, hasStyleTag: /<style[\s>]/i.test(embeddedEmailHtml) };
|
||||
return { html: cleanHtml, isHtml: true, hasStyleTag: /<style[\s>]/i.test(embeddedEmailHtml), externalBlocked: false };
|
||||
}
|
||||
if (embeddedEmailText) {
|
||||
return { html: plainTextToSafeHtml(embeddedEmailText), isHtml: false, hasStyleTag: false };
|
||||
return { html: plainTextToSafeHtml(embeddedEmailText), isHtml: false, hasStyleTag: false, externalBlocked: false };
|
||||
}
|
||||
return emailContent;
|
||||
}, [cidBlobUrls, emailContent, smimeDecryptedHtml, smimeDecryptedText, tnefHtml, tnefText, embeddedEmailHtml, embeddedEmailText]);
|
||||
@@ -2793,6 +2790,86 @@ export function EmailViewer({
|
||||
setTimeout(() => URL.revokeObjectURL(objectUrl), 60_000);
|
||||
}, [onDownloadAttachment, email?.id, resolveAttachmentName]);
|
||||
|
||||
// Bundle every attachment of this email into a single .zip and download it.
|
||||
// Fetches blob-backed attachments through the JMAP client and reuses already
|
||||
// decoded bytes for S/MIME-decrypted and TNEF-extracted ones. Individual
|
||||
// failures are skipped so a single bad blob doesn't sink the whole archive.
|
||||
const handleDownloadAllAttachments = useCallback(async () => {
|
||||
if (isDownloadingAll || effectiveAttachments.length === 0) return;
|
||||
setIsDownloadingAll(true);
|
||||
try {
|
||||
const { default: JSZip } = await import('jszip');
|
||||
const zip = new JSZip();
|
||||
const used = new Set<string>();
|
||||
// Zip entries must be unique; suffix collisions with " (n)" before the
|
||||
// extension so duplicates stay recognisable.
|
||||
const uniqueName = (raw: string): string => {
|
||||
const base = raw || 'attachment';
|
||||
if (!used.has(base)) { used.add(base); return base; }
|
||||
const dot = base.lastIndexOf('.');
|
||||
const stem = dot > 0 ? base.slice(0, dot) : base;
|
||||
const ext = dot > 0 ? base.slice(dot) : '';
|
||||
let i = 1;
|
||||
let candidate = `${stem} (${i})${ext}`;
|
||||
while (used.has(candidate)) { i++; candidate = `${stem} (${i})${ext}`; }
|
||||
used.add(candidate);
|
||||
return candidate;
|
||||
};
|
||||
|
||||
let added = 0;
|
||||
for (const attachment of effectiveAttachments) {
|
||||
const entryName = uniqueName(getAttachmentDisplayName(attachment.name, attachment.type));
|
||||
try {
|
||||
if (attachment.blobId && client) {
|
||||
const blob = await client.fetchBlob(attachment.blobId, attachment.name || entryName, attachment.type);
|
||||
zip.file(entryName, blob);
|
||||
added++;
|
||||
} else if (attachment.tnefData) {
|
||||
zip.file(entryName, attachment.tnefData);
|
||||
added++;
|
||||
} else if (attachment.decryptedAttachment) {
|
||||
const bytes = getAttachmentContentBytes(attachment.decryptedAttachment);
|
||||
if (bytes && bytes.byteLength > 0) {
|
||||
zip.file(entryName, bytes);
|
||||
added++;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Skip individual failures; remaining attachments still bundle.
|
||||
}
|
||||
}
|
||||
|
||||
if (added === 0) return;
|
||||
|
||||
const zipBlob = await zip.generateAsync({ type: 'blob', mimeType: 'application/zip' });
|
||||
const objectUrl = URL.createObjectURL(zipBlob);
|
||||
const anchor = document.createElement('a');
|
||||
anchor.href = objectUrl;
|
||||
anchor.download = attachmentsBundleFilename(email);
|
||||
document.body.appendChild(anchor);
|
||||
anchor.click();
|
||||
anchor.remove();
|
||||
setTimeout(() => URL.revokeObjectURL(objectUrl), 60_000);
|
||||
} finally {
|
||||
setIsDownloadingAll(false);
|
||||
}
|
||||
}, [isDownloadingAll, effectiveAttachments, client, email]);
|
||||
|
||||
// Shared "Download all" chip, shown only when bundling is worthwhile (2+).
|
||||
const downloadAllButton = effectiveAttachments.length > 1 ? (
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); handleDownloadAllAttachments(); }}
|
||||
disabled={isDownloadingAll}
|
||||
title={t('download_all')}
|
||||
className="inline-flex items-center gap-1 px-2.5 py-1.5 text-xs text-muted-foreground hover:text-foreground hover:bg-muted/60 rounded-md border border-border/50 transition-colors flex-shrink-0 disabled:opacity-60 disabled:cursor-wait"
|
||||
>
|
||||
{isDownloadingAll
|
||||
? <Loader2 className="w-3.5 h-3.5 animate-spin" />
|
||||
: <FileArchive className="w-3.5 h-3.5" />}
|
||||
{t('download_all')}
|
||||
</button>
|
||||
) : null;
|
||||
|
||||
// Pre-fetch object URLs for image attachments so their actual contents can be
|
||||
// rendered as thumbnails inside the chip. Skips images larger than 10 MB.
|
||||
useEffect(() => {
|
||||
@@ -2932,16 +3009,25 @@ export function EmailViewer({
|
||||
p.MsoNormal, li.MsoNormal, div.MsoNormal { margin: 0 0 6px; }
|
||||
` : '';
|
||||
|
||||
// Defense-in-depth CSP inside srcDoc: even if the sanitizer ever lets a
|
||||
// <script> tag through, the iframe document forbids script execution
|
||||
// (default-src 'none'). img/style/font remain permissive to match what the
|
||||
// sanitizer is allowed to emit and what the host already permits when
|
||||
// external content is loaded.
|
||||
const iframeCsp = "default-src 'none'; img-src data: blob: http: https:; style-src 'unsafe-inline'; font-src data: http: https:; media-src data: blob: http: https:; base-uri 'none'; form-action 'none'; frame-src 'none'";
|
||||
// Defense-in-depth CSP inside srcDoc. default-src 'none' forbids script
|
||||
// execution even if the sanitizer ever lets a <script> through.
|
||||
//
|
||||
// When external content is blocked, img/media/font are restricted to
|
||||
// data:/blob: only — this is the network-level backstop for every tracking
|
||||
// vector, including ones the DOM-walk blocker can't see (CSS escapes,
|
||||
// <style>-tag url(), @font-face). When the user loads/trusts the sender the
|
||||
// srcDoc is rebuilt (see emailContent) with the permissive variant so real
|
||||
// images, web fonts and media load. cid:/inline images are pre-rewritten to
|
||||
// blob: URLs, so they survive the strict variant.
|
||||
const iframeCsp = effectiveEmailContent.externalBlocked
|
||||
? "default-src 'none'; img-src data: blob:; style-src 'unsafe-inline'; font-src data:; media-src data: blob:; base-uri 'none'; form-action 'none'; frame-src 'none'"
|
||||
: "default-src 'none'; img-src data: blob: http: https:; style-src 'unsafe-inline'; font-src data: http: https:; media-src data: blob: http: https:; base-uri 'none'; form-action 'none'; frame-src 'none'";
|
||||
|
||||
return `<!DOCTYPE html>
|
||||
<html style="color-scheme: ${colorScheme};"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<meta http-equiv="Content-Security-Policy" content="${iframeCsp}">
|
||||
<meta name="referrer" content="no-referrer">
|
||||
<meta http-equiv="x-dns-prefetch-control" content="off">
|
||||
<style>
|
||||
/* Force content height: some emails set html/body { height: 100% }, which -
|
||||
combined with overflow:hidden and our scrollHeight-based auto-resize -
|
||||
@@ -2968,64 +3054,14 @@ export function EmailViewer({
|
||||
${wordHtmlCSS}
|
||||
${darkModeCSS}
|
||||
</style></head><body>${effectiveEmailContent.html}<style>html,body{height:auto!important;min-height:0!important;max-height:none!important}</style></body></html>`;
|
||||
}, [effectiveEmailContent.html, effectiveEmailContent.isHtml, isDark, emailHasNativeDarkMode]);
|
||||
}, [effectiveEmailContent.html, effectiveEmailContent.isHtml, effectiveEmailContent.hasStyleTag, effectiveEmailContent.externalBlocked, isDark, emailHasNativeDarkMode]);
|
||||
|
||||
// Imperatively restore blocked external content inside the iframe document.
|
||||
// Avoids re-rendering the iframe srcDoc (which would reload and flash) when
|
||||
// the user clicks "Load images" or "Trust sender".
|
||||
const restoreBlockedContent = useCallback(() => {
|
||||
const doc = iframeRef.current?.contentDocument;
|
||||
if (!doc) return;
|
||||
|
||||
doc.querySelectorAll('img[data-blocked-src]').forEach((node) => {
|
||||
const el = node as HTMLImageElement;
|
||||
const src = el.getAttribute('data-blocked-src');
|
||||
if (src) {
|
||||
el.setAttribute('src', src);
|
||||
el.style.display = '';
|
||||
el.removeAttribute('data-blocked-src');
|
||||
}
|
||||
});
|
||||
|
||||
doc.querySelectorAll('[data-blocked-style]').forEach((node) => {
|
||||
const el = node as HTMLElement;
|
||||
const style = el.getAttribute('data-blocked-style');
|
||||
if (style !== null) {
|
||||
el.style.cssText = style;
|
||||
el.removeAttribute('data-blocked-style');
|
||||
}
|
||||
});
|
||||
|
||||
doc.querySelectorAll('[data-blocked-background]').forEach((node) => {
|
||||
const el = node as HTMLElement;
|
||||
const bg = el.getAttribute('data-blocked-background');
|
||||
if (bg) {
|
||||
el.setAttribute('background', bg);
|
||||
el.removeAttribute('data-blocked-background');
|
||||
}
|
||||
});
|
||||
|
||||
doc.querySelectorAll('[data-blocked-collapsed-style]').forEach((node) => {
|
||||
const el = node as HTMLElement;
|
||||
const style = el.getAttribute('data-blocked-collapsed-style');
|
||||
if (style !== null) {
|
||||
el.style.cssText = style;
|
||||
el.removeAttribute('data-blocked-collapsed-style');
|
||||
}
|
||||
});
|
||||
}, []);
|
||||
|
||||
// Whenever permission is granted (allow toggled, or sender becomes trusted),
|
||||
// restore blocked content in the existing iframe - no srcDoc rebuild.
|
||||
const senderEmailLower = email?.from?.[0]?.email?.toLowerCase();
|
||||
const senderIsTrustedNow = senderEmailLower
|
||||
? isSenderTrusted(senderEmailLower) || (trustedSendersAddressBook && isTrustedAddressBookSender(senderEmailLower))
|
||||
: false;
|
||||
useEffect(() => {
|
||||
if (!hasBlockedContent) return;
|
||||
if (!allowExternalContent && !senderIsTrustedNow) return;
|
||||
restoreBlockedContent();
|
||||
}, [allowExternalContent, senderIsTrustedNow, hasBlockedContent, restoreBlockedContent]);
|
||||
// Unblocking external content is handled by rebuilding the iframe srcDoc:
|
||||
// toggling allowExternalContent (both "Load images" and "Trust sender" set
|
||||
// it) recomputes emailContent without blocking and swaps the strict CSP for
|
||||
// the permissive one. In-place restore isn't possible because a document's
|
||||
// CSP is fixed at load — the strict blocking-mode CSP would keep refusing the
|
||||
// restored URLs.
|
||||
|
||||
// Tracks the last rendered body height so the loading skeleton can hold
|
||||
// the same size - avoids the body shrink/expand flash when switching emails.
|
||||
@@ -3414,6 +3450,9 @@ export function EmailViewer({
|
||||
if (autoMdnRef.current === email.id) return;
|
||||
autoMdnRef.current = email.id;
|
||||
sendReadReceiptNow(true).catch(() => { autoMdnRef.current = null; });
|
||||
// email is already captured via email?.id and sendReadReceiptNow (which
|
||||
// depends on `email`); the autoMdnRef guard prevents a double send.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [readReceiptResponse, shouldOfferReadReceipt, email?.id, sendReadReceiptNow]);
|
||||
|
||||
// Show loading skeleton while email is being fetched
|
||||
@@ -3557,9 +3596,19 @@ export function EmailViewer({
|
||||
)}
|
||||
{isScheduled && canCancelScheduled && (
|
||||
<>
|
||||
<Button variant="default" size="sm" onClick={onCancelScheduled} className="sm:flex sm:h-8" title={t('cancel_scheduled_send')}>
|
||||
<Button
|
||||
variant="default"
|
||||
size="sm"
|
||||
onClick={() => onRescheduleScheduled?.(new Date(Date.now() + 1000).toISOString())}
|
||||
className="sm:flex sm:h-8"
|
||||
title={t('send_now')}
|
||||
>
|
||||
<Send className="w-4 h-4" />
|
||||
{showToolbarLabels && <span className="hidden sm:inline text-sm">{t('send_now')}</span>}
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" onClick={onCancelScheduled} className="sm:flex sm:h-8" title={t('cancel_scheduled_send')}>
|
||||
<X className="w-4 h-4" />
|
||||
<span className="hidden sm:inline text-sm">{t('cancel_scheduled_send')}</span>
|
||||
{showToolbarLabels && <span className="hidden sm:inline text-sm">{t('cancel_scheduled_send')}</span>}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
@@ -3846,17 +3895,17 @@ export function EmailViewer({
|
||||
|
||||
{/* Dark/light mode toggle for HTML emails */}
|
||||
{effectiveEmailContent.isHtml && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setEmailViewDarkOverride(prev => prev === null ? !(resolvedTheme === 'dark') : !prev)}
|
||||
data-overflow-item
|
||||
data-overflow-priority="11"
|
||||
className="hidden sm:inline-flex h-8 gap-1.5"
|
||||
title={isDark ? 'View in light mode' : 'View in dark mode'}
|
||||
>
|
||||
{isDark ? <Sun className="w-4 h-4" /> : <Moon className="w-4 h-4" />}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setEmailViewDarkOverride(prev => prev === null ? !(resolvedTheme === 'dark') : !prev)}
|
||||
data-overflow-item
|
||||
data-overflow-priority="11"
|
||||
className="hidden sm:inline-flex h-8 gap-1.5"
|
||||
title={isDark ? 'View in light mode' : 'View in dark mode'}
|
||||
>
|
||||
{isDark ? <Sun className="w-4 h-4" /> : <Moon className="w-4 h-4" />}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{/* More menu - click-based */}
|
||||
@@ -4098,9 +4147,8 @@ export function EmailViewer({
|
||||
|
||||
return (
|
||||
<div
|
||||
key={email.id}
|
||||
data-tour="email-viewer"
|
||||
className={cn("flex-1 flex flex-row h-full bg-background overflow-hidden animate-in fade-in duration-300 relative", className)}
|
||||
className={cn("flex-1 flex flex-row h-full bg-background overflow-hidden relative", className)}
|
||||
>
|
||||
{/* Mobile More menu sidebar overlay */}
|
||||
{!isScheduled && isMobile && moreMenuOpen && (
|
||||
@@ -4566,6 +4614,7 @@ export function EmailViewer({
|
||||
+{effectiveAttachments.length - 2} {t('more')}
|
||||
</button>
|
||||
)}
|
||||
{downloadAllButton}
|
||||
{/* Floating popup for remaining attachments */}
|
||||
{showAllBesideAttachments && effectiveAttachments.length > 2 && (
|
||||
<>
|
||||
@@ -5072,6 +5121,7 @@ export function EmailViewer({
|
||||
<div className="flex items-center gap-2">
|
||||
{canCancelScheduled && (
|
||||
<>
|
||||
<Button size="sm" variant="default" onClick={() => onRescheduleScheduled?.(new Date(Date.now() + 1000).toISOString())}>{t('send_now')}</Button>
|
||||
<Button size="sm" variant="outline" onClick={onCancelScheduled}>{t('cancel_scheduled_send')}</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
@@ -5213,8 +5263,8 @@ export function EmailViewer({
|
||||
{/* === ATTACHMENTS below header (below-header mode, desktop only) === */}
|
||||
{attachmentPosition === 'below-header' && effectiveAttachments.length > 0 && (
|
||||
<div className="hidden lg:block bg-background border-b border-border px-4 lg:px-6 py-2">
|
||||
<div className="relative">
|
||||
<div ref={belowHeaderRowRef} className="relative flex items-center gap-2 overflow-hidden">
|
||||
<div className="relative flex items-center gap-2">
|
||||
<div ref={belowHeaderRowRef} className="relative flex items-center gap-2 overflow-hidden flex-1 min-w-0">
|
||||
{/* Hidden ghost row used purely for measuring chip widths */}
|
||||
<div
|
||||
ref={belowHeaderGhostRef}
|
||||
@@ -5335,6 +5385,7 @@ export function EmailViewer({
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{downloadAllButton}
|
||||
{showAllBelowHeaderAttachments && visibleBelowHeaderCount !== null && effectiveAttachments.length > visibleBelowHeaderCount && (
|
||||
<>
|
||||
<div className="fixed inset-0 z-40" onClick={() => setShowAllBelowHeaderAttachments(false)} />
|
||||
@@ -5472,6 +5523,7 @@ export function EmailViewer({
|
||||
+{effectiveAttachments.length - 2} {t('more')}
|
||||
</button>
|
||||
)}
|
||||
{downloadAllButton}
|
||||
{showAllMobileAttachments && effectiveAttachments.length > 2 && (
|
||||
<>
|
||||
<div className="fixed inset-0 z-40" onClick={() => setShowAllMobileAttachments(false)} />
|
||||
|
||||
@@ -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>`;
|
||||
}
|
||||
@@ -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,46 +617,49 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
|
||||
</button>
|
||||
)}
|
||||
|
||||
{!isMobile && !isFocusedMailLayout && (
|
||||
<button
|
||||
data-expand-toggle
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onToggleExpand();
|
||||
}}
|
||||
className={cn(
|
||||
"p-1 rounded mt-2 flex-shrink-0 transition-all duration-200",
|
||||
"hover:bg-muted/50 hover:scale-110",
|
||||
"active:scale-95",
|
||||
"text-muted-foreground hover:text-foreground"
|
||||
)}
|
||||
aria-expanded={isExpanded}
|
||||
aria-label={t('toggle_thread')}
|
||||
>
|
||||
{isLoading ? (
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
) : isExpanded ? (
|
||||
<ChevronDown className="w-4 h-4" />
|
||||
) : (
|
||||
<ChevronRight className="w-4 h-4" />
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
|
||||
{hasUnread && (
|
||||
<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>
|
||||
)}
|
||||
|
||||
{density !== 'extra-compact' && (
|
||||
<Avatar
|
||||
name={avatarPerson?.name}
|
||||
email={avatarPerson?.email}
|
||||
size={isFocusedMailLayout ? "sm" : "md"}
|
||||
className="flex-shrink-0 shadow-sm"
|
||||
disableImages={hideJunkAvatarImages}
|
||||
/>
|
||||
<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
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onToggleExpand();
|
||||
}}
|
||||
className={cn(
|
||||
"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",
|
||||
"bg-background border border-border"
|
||||
)}
|
||||
aria-expanded={isExpanded}
|
||||
aria-label={t('toggle_thread')}
|
||||
>
|
||||
{isLoading ? (
|
||||
<Loader2 className="w-3 h-3 animate-spin" />
|
||||
) : isExpanded ? (
|
||||
<ChevronDown className="w-3 h-3" />
|
||||
) : (
|
||||
<ChevronRight className="w-3 h-3" />
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex-1 min-w-0">
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
@@ -433,12 +440,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 +474,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 +691,10 @@ export function Sidebar({
|
||||
scheduledTotal = 0,
|
||||
showScheduledMailbox = false,
|
||||
showAllMailMailbox = false,
|
||||
showCrossUnread = false,
|
||||
showCrossStarred = false,
|
||||
showCrossAll = false,
|
||||
crossUnreadCount = 0,
|
||||
className,
|
||||
multiAccountMode = false,
|
||||
accountMailboxes,
|
||||
@@ -992,7 +1005,7 @@ export function Sidebar({
|
||||
isCollapsed={isCollapsed}
|
||||
/>
|
||||
)}
|
||||
{showUnified && (
|
||||
{(showUnified || showCrossUnread || showCrossStarred || showCrossAll) && (
|
||||
<div>
|
||||
<SidebarSectionHeader
|
||||
label={t("all_accounts")}
|
||||
@@ -1003,7 +1016,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 +1034,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>
|
||||
|
||||
@@ -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, 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')}>
|
||||
@@ -226,6 +245,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 +283,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')}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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,209 @@
|
||||
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('CHARACTERISATION: mutates the source email objects in place (shared reference)', 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 })) },
|
||||
);
|
||||
await fetchUnifiedEmails([acc], 'inbox', 20, 0);
|
||||
// The very object passed back by the client was mutated, not a copy.
|
||||
expect(original.accountId).toBe('A');
|
||||
expect(original.accountLabel).toBe('Label A');
|
||||
});
|
||||
});
|
||||
|
||||
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',
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -37,33 +37,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 +77,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);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 }>,
|
||||
|
||||
+32
-17
@@ -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 }));
|
||||
@@ -1273,19 +1276,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 +1394,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 +1670,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 +1712,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 +1721,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 +2391,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 +3367,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 +4738,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',
|
||||
]);
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
/**
|
||||
* Localizes the display name of special-use mailboxes.
|
||||
*
|
||||
* Mail servers (e.g. Stalwart) always create system folders with English
|
||||
* names — "Inbox", "Sent", "Junk" — regardless of the account locale. JMAP
|
||||
* exposes the special-use semantics via the mailbox `role`, so we map that
|
||||
* role onto a translated label (mirroring Roundcube's behaviour). Folders
|
||||
* without a recognized role — i.e. user-created folders — keep their server
|
||||
* name untouched.
|
||||
*
|
||||
* The keys live under the `sidebar.mailboxes` namespace; `junk`, `flagged`
|
||||
* and `all` reuse the existing `spam`/`starred`/`all_mail` keys so every
|
||||
* locale already has a translation.
|
||||
*/
|
||||
const ROLE_TRANSLATION_KEY: Record<string, string> = {
|
||||
inbox: "inbox",
|
||||
sent: "sent",
|
||||
drafts: "drafts",
|
||||
trash: "trash",
|
||||
archive: "archive",
|
||||
junk: "spam",
|
||||
important: "important",
|
||||
flagged: "starred",
|
||||
all: "all_mail",
|
||||
};
|
||||
|
||||
/**
|
||||
* Returns the localized name for a mailbox, or its raw server name when the
|
||||
* role is unknown/absent.
|
||||
*
|
||||
* @param translate Resolves a `sidebar.mailboxes` leaf key (e.g. `"inbox"`) to
|
||||
* its localized string. Callers scoped to the `sidebar` namespace pass
|
||||
* `(k) => t(`mailboxes.${k}`)`; root-scoped callers pass
|
||||
* `(k) => t(`sidebar.mailboxes.${k}`)`.
|
||||
*/
|
||||
export function localizeMailboxName(
|
||||
role: string | undefined | null,
|
||||
name: string,
|
||||
translate: (key: string) => string,
|
||||
): string {
|
||||
if (!role) return name;
|
||||
const key = ROLE_TRANSLATION_KEY[role];
|
||||
return key ? translate(key) : name;
|
||||
}
|
||||
@@ -14,7 +14,7 @@ import {
|
||||
} from '../plugin-hooks';
|
||||
import { verifyBundle } from './bundle-integrity';
|
||||
import { createBackgroundInstance } from './host-bridge';
|
||||
import { register as registerActive, deregister as deregisterActive } from './registry';
|
||||
import { register as registerActive, deregister as deregisterActive, all as allActiveEntries } from './registry';
|
||||
import { cancelPluginDialogs } from './host-api';
|
||||
import { registerShortcuts } from './shortcuts';
|
||||
|
||||
@@ -181,10 +181,10 @@ export async function activateAllSandboxed(plugins: InstalledPlugin[]): Promise<
|
||||
}
|
||||
|
||||
export function deactivateAllSandboxed(): void {
|
||||
// import lazily to avoid a circular dep when registry mutates while we iterate.
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
const { all } = require('./registry') as typeof import('./registry');
|
||||
for (const e of all()) unloadSandboxedPlugin(e.plugin.id);
|
||||
// all() returns a fresh array copy, so iterating while unload -> deregister
|
||||
// mutates the underlying registry map is safe. (No circular import: registry
|
||||
// only pulls in types, so a static import is fine and works under ESM.)
|
||||
for (const e of allActiveEntries()) unloadSandboxedPlugin(e.plugin.id);
|
||||
}
|
||||
|
||||
// ─── Auto-disable ─────────────────────────────────────────────
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { splitRecipients as splitRecipientString } from "@/lib/email-composer-utils";
|
||||
|
||||
export interface ParsedMailto {
|
||||
to: string[];
|
||||
cc: string[];
|
||||
@@ -25,10 +27,9 @@ function stripBodyControlChars(value: string): string {
|
||||
}
|
||||
|
||||
function splitRecipients(value: string): string[] {
|
||||
return stripControlChars(value)
|
||||
.split(",")
|
||||
.map((recipient) => recipient.trim())
|
||||
.filter(Boolean);
|
||||
// Quote/angle-aware split so a `"Doe, John" <john@doo.org>` display name with
|
||||
// an embedded comma stays a single recipient instead of being torn in two.
|
||||
return splitRecipientString(stripControlChars(value));
|
||||
}
|
||||
|
||||
type QueryParam = {
|
||||
|
||||
@@ -784,7 +784,6 @@ function nthWeekdayInYear(date: Date, nth: number): number {
|
||||
const firstYd = getDayOfYear(first);
|
||||
return Math.floor((yd - firstYd) / 7) + 1;
|
||||
} else {
|
||||
const daysInYear = isLeapYear(date.getFullYear()) ? 366 : 365;
|
||||
const dec31 = new Date(date.getFullYear(), 11, 31);
|
||||
let last = dec31;
|
||||
while (last.getDay() !== weekday) last = addDays(last, -1);
|
||||
|
||||
@@ -9,6 +9,22 @@ import { smimeVerify } from '../smime-verify';
|
||||
import { extractCertificateInfo } from '../certificate-utils';
|
||||
import type { SmimeKeyRecord } from '../types';
|
||||
|
||||
// ─── KNOWN ISSUE: skipped (pre-existing, not a logical test failure) ──────────
|
||||
// This suite OOMs its Vitest worker: during the encrypt/decrypt roundtrip the
|
||||
// heap climbs past ~4 GB and the worker dies with
|
||||
// "FATAL ERROR: Reached heap limit Allocation failed - JavaScript heap out of
|
||||
// memory". The cause is excessive allocation in the S/MIME crypto path
|
||||
// (real 2048-bit RSA via pkijs/asn1js under the Node webcrypto engine), not the
|
||||
// assertions themselves. It reproduces on main, independent of any branch.
|
||||
//
|
||||
// Skipped so the rest of the suite stays green and CI workers don't crash.
|
||||
// To work on it: flip the flag below to false and run only this file, e.g.
|
||||
// npx vitest run lib/smime/__tests__/smime-crypto.test.ts
|
||||
// Likely directions: investigate the pkijs CMS allocation growth / retained
|
||||
// buffers, reuse a single generated key set, or split into smaller cases.
|
||||
const SKIP_SMIME_CRYPTO_OOM = true;
|
||||
const describeSmime = SKIP_SMIME_CRYPTO_OOM ? describe.skip : describe;
|
||||
|
||||
/**
|
||||
* Integration tests for S/MIME sign→verify and encrypt→decrypt roundtrips.
|
||||
* Uses Node.js crypto (not jsdom) for accurate Web Crypto behavior.
|
||||
@@ -107,6 +123,9 @@ let bobEncCertDer: ArrayBuffer;
|
||||
let bobKeyRecord: SmimeKeyRecord;
|
||||
|
||||
beforeAll(async () => {
|
||||
// Suite is skipped (see SKIP_SMIME_CRYPTO_OOM); bail before the expensive RSA
|
||||
// key generation so the skipped file stays fast.
|
||||
if (SKIP_SMIME_CRYPTO_OOM) return;
|
||||
pkijs.setEngine('test', crypto, cryptoEngine);
|
||||
|
||||
// --- Signing identity ---
|
||||
@@ -150,7 +169,7 @@ beforeAll(async () => {
|
||||
bobKeyRecord = await makeKeyRecord('key-bob-enc', 'bob@example.com', bobEncCertDer);
|
||||
});
|
||||
|
||||
describe('smimeSign + smimeVerify roundtrip', () => {
|
||||
describeSmime('smimeSign + smimeVerify roundtrip', () => {
|
||||
it('signs and verifies a message successfully', async () => {
|
||||
const signedBlob = await smimeSign(testMimeBytes, signKeyPair.privateKey, signCertDer);
|
||||
expect(signedBlob).toBeInstanceOf(Blob);
|
||||
@@ -179,7 +198,7 @@ describe('smimeSign + smimeVerify roundtrip', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('smimeEncrypt + smimeDecrypt roundtrip', () => {
|
||||
describeSmime('smimeEncrypt + smimeDecrypt roundtrip', () => {
|
||||
it('encrypts and decrypts a message', async () => {
|
||||
const encryptedBlob = await smimeEncrypt(
|
||||
testMimeBytes,
|
||||
@@ -224,7 +243,7 @@ describe('smimeEncrypt + smimeDecrypt roundtrip', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('SmimeKeyLockedError', () => {
|
||||
describeSmime('SmimeKeyLockedError', () => {
|
||||
it('has correct name and keyRecordId', () => {
|
||||
const err = new SmimeKeyLockedError('test', 'key-1');
|
||||
expect(err.name).toBe('SmimeKeyLockedError');
|
||||
@@ -234,7 +253,7 @@ describe('SmimeKeyLockedError', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('findDecryptionCandidates', () => {
|
||||
describeSmime('findDecryptionCandidates', () => {
|
||||
it('returns empty array for invalid CMS data', () => {
|
||||
const garbage = new Uint8Array([0, 1, 2, 3]).buffer;
|
||||
const result = findDecryptionCandidates(garbage, [encKeyRecord]);
|
||||
@@ -242,14 +261,14 @@ describe('findDecryptionCandidates', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('smimeVerify edge cases', () => {
|
||||
describeSmime('smimeVerify edge cases', () => {
|
||||
it('throws on invalid ASN.1 data', async () => {
|
||||
const garbage = new Uint8Array([0, 1, 2, 3]).buffer;
|
||||
await expect(smimeVerify(garbage)).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('normalizeCmsBytes', () => {
|
||||
describeSmime('normalizeCmsBytes', () => {
|
||||
// Helper: a minimal DER-encoded ASN.1 SEQUENCE (0x30 tag)
|
||||
const derBytes = new Uint8Array([0x30, 0x03, 0x02, 0x01, 0x05]);
|
||||
|
||||
|
||||
@@ -225,7 +225,6 @@ class Pkcs12CryptoEngine extends pkijs.CryptoEngine {
|
||||
const cryptoKey = await this.importKey(
|
||||
'raw',
|
||||
keyData,
|
||||
// eslint-disable-next-line no-undef
|
||||
{ name: algName, length: keyLen * 8 } as Algorithm,
|
||||
false,
|
||||
['decrypt'],
|
||||
@@ -234,7 +233,6 @@ class Pkcs12CryptoEngine extends pkijs.CryptoEngine {
|
||||
// Decrypt
|
||||
const ciphertext = parameters.encryptedContentInfo.getEncryptedContent();
|
||||
return this.decrypt(
|
||||
// eslint-disable-next-line no-undef
|
||||
{ name: algName, iv: ivBytes } as Algorithm,
|
||||
cryptoKey,
|
||||
ciphertext,
|
||||
|
||||
@@ -64,8 +64,10 @@ function buildPrefixRegex(tokens: string[]): RegExp {
|
||||
// Sort by length DESC so longer tokens (e.g. "Пересл") win over their
|
||||
// shorter prefixes (e.g. "Пер") during alternation matching.
|
||||
escaped.sort((a, b) => b.length - a.length);
|
||||
// Accept both the ASCII colon and the full-width colon ":" (U+FF1A) that CJK
|
||||
// mail clients emit after a localized prefix (e.g. "回复:foo").
|
||||
return new RegExp(
|
||||
`^\\s*(?:${escaped.join("|")})(?:\\[\\d+\\]|\\*\\d*)?\\s*:\\s*`,
|
||||
`^\\s*(?:${escaped.join("|")})(?:\\[\\d+\\]|\\*\\d*)?\\s*[:\\uFF1A]\\s*`,
|
||||
"i",
|
||||
);
|
||||
}
|
||||
|
||||
@@ -65,7 +65,7 @@ async function readFeatures(): Promise<TelemetryFeatures> {
|
||||
return {
|
||||
// Booleans only. We read whether a feature is enabled - never any
|
||||
// config value beyond a presence check.
|
||||
calendar: gates.calendarTasksEnabled === true,
|
||||
calendar: gates.calendarEnabled === true,
|
||||
contacts: gates.contactsEnabled === true,
|
||||
files: gates.filesEnabled === true,
|
||||
extensions: gates.pluginsEnabled === true,
|
||||
|
||||
+170
-1
@@ -1,11 +1,22 @@
|
||||
import type { Email, Mailbox, UnifiedMailboxRole } from '@/lib/jmap/types';
|
||||
import type { Email, Mailbox, UnifiedMailboxRole, CrossView } from '@/lib/jmap/types';
|
||||
import { CROSS_EXCLUDED_ROLES } from '@/lib/jmap/types';
|
||||
import type { IJMAPClient } from '@/lib/jmap/client-interface';
|
||||
|
||||
export interface UnifiedAccountClient {
|
||||
// Display reference (avatar color / label). For personal entries this is the
|
||||
// AccountEntry.id; for shared entries it is the JMAP owner id (see Email.accountId).
|
||||
accountId: string;
|
||||
accountLabel: string;
|
||||
client: IJMAPClient;
|
||||
mailboxes: Mailbox[];
|
||||
// AccountEntry.id of the logged-in client this entry uses (`getClientForAccount`
|
||||
// key). Stamped onto each email as `sourceClientAccountId` so single-email and
|
||||
// batch actions can resolve the reaching client without scanning capabilities.
|
||||
clientAccountId: string;
|
||||
// JMAP account id of the data this entry reads (personal: the client's primary;
|
||||
// shared: the owner id). Stamped onto each email as `sourceAccountId` and passed
|
||||
// as the JMAP `accountId` for owner-scoped routing + mailbox-id namespacing.
|
||||
jmapAccountId: string;
|
||||
// When true, this entry represents a group/shared account owned by
|
||||
// `accountId` but accessed through someone else's `client`. JMAP requests
|
||||
// must use the mailbox's `originalId` and explicitly target this accountId
|
||||
@@ -30,6 +41,19 @@ const ALL_UNIFIED_ROLES: UnifiedMailboxRole[] = [
|
||||
'inbox', 'sent', 'drafts', 'trash', 'archive', 'junk',
|
||||
];
|
||||
|
||||
/**
|
||||
* Resolves the display name of the folder an email lives in, for the aggregate
|
||||
* "All …" views. Matches the email's mailbox membership against the account's
|
||||
* mailbox list (originalId for shared/namespaced mailboxes). Returns the first
|
||||
* match, or undefined if none of the account's known folders contain it.
|
||||
*/
|
||||
export function resolveSourceFolderName(email: Email, mailboxes: Mailbox[]): string | undefined {
|
||||
for (const m of mailboxes) {
|
||||
if (email.mailboxIds?.[m.originalId ?? m.id]) return m.name;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds the first mailbox matching the given role.
|
||||
*/
|
||||
@@ -99,6 +123,9 @@ export async function fetchUnifiedEmails(
|
||||
for (const email of result.emails) {
|
||||
email.accountId = account.accountId;
|
||||
email.accountLabel = account.accountLabel;
|
||||
email.sourceClientAccountId = account.clientAccountId;
|
||||
email.sourceAccountId = account.jmapAccountId;
|
||||
email.sourceFolder = resolveSourceFolderName(email, account.mailboxes);
|
||||
}
|
||||
|
||||
mergedEmails = mergedEmails.concat(result.emails);
|
||||
@@ -223,6 +250,9 @@ async function fanOutUnifiedQuery(
|
||||
for (const email of result.emails) {
|
||||
email.accountId = account.accountId;
|
||||
email.accountLabel = account.accountLabel;
|
||||
email.sourceClientAccountId = account.clientAccountId;
|
||||
email.sourceAccountId = account.jmapAccountId;
|
||||
email.sourceFolder = resolveSourceFolderName(email, account.mailboxes);
|
||||
}
|
||||
mergedEmails = mergedEmails.concat(result.emails);
|
||||
totalSum += result.total;
|
||||
@@ -269,6 +299,145 @@ export function fetchUnifiedMailboxCounts(
|
||||
return counts;
|
||||
}
|
||||
|
||||
// ─── Cross-account views (unread / starred / all) ─────────────────────────────
|
||||
//
|
||||
// These merge messages across EVERY account (including shared) and across all
|
||||
// folders except the CROSS_EXCLUDED_ROLES (junk, sent, archive, trash, drafts),
|
||||
// i.e. inbox + custom folders, into one date-sorted list. Unlike the per-role
|
||||
// unified fan-out above, the query spans many mailboxes per account, so the
|
||||
// filter is built from each account's included-mailbox ids.
|
||||
|
||||
/**
|
||||
* Mailboxes of an account included in the cross-account views: everything whose
|
||||
* role is not excluded (inbox + custom/no-role folders).
|
||||
*/
|
||||
export function getCrossIncludedMailboxes(account: UnifiedAccountClient): Mailbox[] {
|
||||
return account.mailboxes.filter((m) => !CROSS_EXCLUDED_ROLES.has(m.role ?? ''));
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds the JMAP Email/query filter for a cross-account view over the given
|
||||
* JMAP-side mailbox ids. `all` is just the mailbox membership; `unread` and
|
||||
* `starred` AND a keyword condition onto it.
|
||||
*/
|
||||
export function buildCrossFilter(
|
||||
view: CrossView,
|
||||
jmapMailboxIds: string[],
|
||||
): Record<string, unknown> {
|
||||
const inAny: Record<string, unknown> = jmapMailboxIds.length === 1
|
||||
? { inMailbox: jmapMailboxIds[0] }
|
||||
: { operator: 'OR', conditions: jmapMailboxIds.map((id) => ({ inMailbox: id })) };
|
||||
if (view === 'all') return inAny;
|
||||
const keyword = view === 'unread' ? { notKeyword: '$seen' } : { hasKeyword: '$flagged' };
|
||||
return { operator: 'AND', conditions: [inAny, keyword] };
|
||||
}
|
||||
|
||||
/**
|
||||
* Total unread count across every account's included cross-view mailboxes. Used
|
||||
* for the unread badge on the "All unread" and "All mail" entries. Mirrors the
|
||||
* unified count behaviour (sum of per-mailbox unread metadata, no extra query).
|
||||
*/
|
||||
export function getCrossUnreadTotal(accounts: UnifiedAccountClient[]): number {
|
||||
let unread = 0;
|
||||
for (const account of accounts) {
|
||||
for (const m of getCrossIncludedMailboxes(account)) unread += m.unreadEmails;
|
||||
}
|
||||
return unread;
|
||||
}
|
||||
|
||||
async function fanOutCrossQuery(
|
||||
accounts: UnifiedAccountClient[],
|
||||
run: (
|
||||
account: UnifiedAccountClient,
|
||||
jmapAccountId: string | undefined,
|
||||
includedJmapIds: string[],
|
||||
) => Promise<{ emails: Email[]; total: number; hasMore: boolean }>,
|
||||
): Promise<UnifiedFetchResult> {
|
||||
const errors = new Map<string, string>();
|
||||
|
||||
type AccountResult = {
|
||||
account: UnifiedAccountClient;
|
||||
result: { emails: Email[]; total: number; hasMore: boolean };
|
||||
} | null;
|
||||
|
||||
const promises = accounts.map(async (account): Promise<AccountResult> => {
|
||||
const included = getCrossIncludedMailboxes(account);
|
||||
if (included.length === 0) return null;
|
||||
const jmapAccountId = account.isShared ? account.accountId : undefined;
|
||||
const includedJmapIds = included.map((m) => account.isShared ? (m.originalId ?? m.id) : m.id);
|
||||
try {
|
||||
const result = await run(account, jmapAccountId, includedJmapIds);
|
||||
return { account, result };
|
||||
} catch (err) {
|
||||
errors.set(account.accountId, err instanceof Error ? err.message : String(err));
|
||||
return null;
|
||||
}
|
||||
});
|
||||
|
||||
const results = await Promise.allSettled(promises);
|
||||
|
||||
let mergedEmails: Email[] = [];
|
||||
let totalSum = 0;
|
||||
let anyHasMore = false;
|
||||
|
||||
for (const outcome of results) {
|
||||
if (outcome.status !== 'fulfilled' || outcome.value === null) continue;
|
||||
const { account, result } = outcome.value;
|
||||
for (const email of result.emails) {
|
||||
email.accountId = account.accountId;
|
||||
email.accountLabel = account.accountLabel;
|
||||
email.sourceClientAccountId = account.clientAccountId;
|
||||
email.sourceAccountId = account.jmapAccountId;
|
||||
email.sourceFolder = resolveSourceFolderName(email, account.mailboxes);
|
||||
}
|
||||
mergedEmails = mergedEmails.concat(result.emails);
|
||||
totalSum += result.total;
|
||||
if (result.hasMore) anyHasMore = true;
|
||||
}
|
||||
|
||||
mergedEmails.sort((a, b) => {
|
||||
const dateA = new Date(a.receivedAt).getTime();
|
||||
const dateB = new Date(b.receivedAt).getTime();
|
||||
return dateB - dateA;
|
||||
});
|
||||
|
||||
return { emails: mergedEmails, total: totalSum, hasMore: anyHasMore, errors };
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches a cross-account view (browse), merging and date-sorting across all
|
||||
* accounts. Per-account failures are collected in the errors map.
|
||||
*/
|
||||
export async function fetchCrossViewEmails(
|
||||
accounts: UnifiedAccountClient[],
|
||||
view: CrossView,
|
||||
limit: number,
|
||||
position: number,
|
||||
): Promise<UnifiedFetchResult> {
|
||||
return fanOutCrossQuery(accounts, (account, jmapAccountId, ids) =>
|
||||
account.client.advancedSearchEmails(buildCrossFilter(view, ids), jmapAccountId, limit, position));
|
||||
}
|
||||
|
||||
/**
|
||||
* Text search within a cross-account view: the view filter AND a free-text
|
||||
* condition, fanned out across accounts.
|
||||
*/
|
||||
export async function searchCrossViewEmails(
|
||||
accounts: UnifiedAccountClient[],
|
||||
view: CrossView,
|
||||
query: string,
|
||||
limit: number,
|
||||
position: number,
|
||||
): Promise<UnifiedFetchResult> {
|
||||
return fanOutCrossQuery(accounts, (account, jmapAccountId, ids) =>
|
||||
account.client.advancedSearchEmails(
|
||||
{ operator: 'AND', conditions: [buildCrossFilter(view, ids), { text: query }] },
|
||||
jmapAccountId,
|
||||
limit,
|
||||
position,
|
||||
));
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the list of unified roles that exist in at least one account's
|
||||
* mailboxes.
|
||||
|
||||
+50
-9
@@ -203,6 +203,33 @@ async function registerWithRelay(params: {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Ask the relay whether a leftover subscription is dead. Returns true ONLY when
|
||||
* the relay positively reports it inactive - a record it knows about that has
|
||||
* never forwarded a push and isn't freshly registered. Every other outcome (the
|
||||
* relay doesn't recognise the id, an older relay without this endpoint, a
|
||||
* network blip, or a live subscription) returns false, so we never reap
|
||||
* anything we can't confirm is dead. This lets enableWebPush clear its own
|
||||
* abandoned attempts - and dead siblings left by cleared site data that
|
||||
* regenerated the deviceClientId - without disturbing another live device or
|
||||
* the mobile app that shares the account.
|
||||
*/
|
||||
async function relayReportsDead(
|
||||
relayBaseUrl: string,
|
||||
subscriptionId: string,
|
||||
): Promise<boolean> {
|
||||
try {
|
||||
const res = await fetch(
|
||||
buildRelayUrl(relayBaseUrl, `/api/push/active/${encodeURIComponent(subscriptionId)}`),
|
||||
);
|
||||
if (!res.ok) return false;
|
||||
const body = (await res.json()) as { active?: unknown };
|
||||
return body.active === false;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function pollVerificationCode(
|
||||
relayBaseUrl: string,
|
||||
subscriptionId: string,
|
||||
@@ -314,15 +341,29 @@ export async function enableWebPush(
|
||||
localStorage.removeItem(subIdKey);
|
||||
}
|
||||
|
||||
// Reap any leftover subscriptions still bound to this device. These pile
|
||||
// up when a previous enable attempt failed mid-flow (verification timed
|
||||
// out, browser tab closed, etc). Stalwart per-account rate-limits
|
||||
// verification posts, so leaving stragglers around blocks the new one.
|
||||
const stragglers = existingSubs.filter(
|
||||
(s) => s.deviceClientId === deviceClientId && s.id !== storedServerId,
|
||||
);
|
||||
for (const s of stragglers) {
|
||||
await params.client.destroyPushSubscription(s.id).catch(() => undefined);
|
||||
// Reap leftover subscriptions that would otherwise starve the new one's
|
||||
// verification. Stalwart emits only one PushVerification per account per ~60s
|
||||
// and picks the oldest unverified subscription, so a single stale straggler
|
||||
// blocks every fresh attempt - the symptom is the confusing "Timed out
|
||||
// waiting for PushVerification" error. We can't read a subscription's
|
||||
// verified state or URL over JMAP (Stalwart hides both), only its
|
||||
// deviceClientId, so we decide what's safe to remove like this:
|
||||
// - same deviceClientId as ours: a previous attempt from THIS browser,
|
||||
// always safe to reap.
|
||||
// - a different deviceClientId: could be another live device or the mobile
|
||||
// app on this account. Ask the relay whether it's still alive and only
|
||||
// reap the ones it confirms are dead. Anything live - or anything the
|
||||
// relay can't vouch for (a different relay, a non-Bulwark client, a
|
||||
// network blip) - is left untouched.
|
||||
for (const s of existingSubs) {
|
||||
if (s.id === storedServerId) continue;
|
||||
if (s.deviceClientId === deviceClientId) {
|
||||
await params.client.destroyPushSubscription(s.id).catch(() => undefined);
|
||||
continue;
|
||||
}
|
||||
if (await relayReportsDead(relayBaseUrl, s.deviceClientId)) {
|
||||
await params.client.destroyPushSubscription(s.id).catch(() => undefined);
|
||||
}
|
||||
}
|
||||
|
||||
const serverAssignedId = await params.client.createPushSubscription({
|
||||
|
||||
+37
-4
@@ -134,7 +134,10 @@
|
||||
"mail": "Pošta",
|
||||
"nav_label": "Navigace",
|
||||
"add_app": "Aplikace",
|
||||
"scheduled": "Naplánováno"
|
||||
"scheduled": "Naplánováno",
|
||||
"unified_all_unread": "Vše nepřečtené",
|
||||
"unified_all_starred": "Vše s hvězdičkou",
|
||||
"unified_all_mail": "Veškerá pošta"
|
||||
},
|
||||
"protocol_handlers": {
|
||||
"title": "Výchozí aplikace",
|
||||
@@ -562,7 +565,8 @@
|
||||
"add_to_contacts": "Přidat do kontaktů",
|
||||
"copied": "Zkopírováno!",
|
||||
"copy_failed": "Kopírování se nezdařilo"
|
||||
}
|
||||
},
|
||||
"send_now": "Odeslat nyní"
|
||||
},
|
||||
"email_composer": {
|
||||
"read_receipt_on": "Vyžádáno potvrzení o přečtení (kliknutím vypnete)",
|
||||
@@ -801,7 +805,10 @@
|
||||
"pt": "Português",
|
||||
"ru": "Русский",
|
||||
"uk": "Українська",
|
||||
"zh": "简体中文"
|
||||
"zh": "简体中文",
|
||||
"fa": "فارسی",
|
||||
"farsi": "فارسی",
|
||||
"switch_to_farsi": "Přepnout na perštinu"
|
||||
},
|
||||
"settings": {
|
||||
"title": "Nastavení",
|
||||
@@ -919,6 +926,7 @@
|
||||
"description": "Show an \"All Mail\" entry above your folders that merges messages from across this account's folders into one list.",
|
||||
"folders_label": "Folders in All Mail",
|
||||
"folders_description": "Choose which folders are merged into the All Mail view.",
|
||||
"account_hint": "Applies to {account}.",
|
||||
"no_folders": "No folders available."
|
||||
},
|
||||
"colorful_sidebar_icons": {
|
||||
@@ -930,6 +938,18 @@
|
||||
"description": "Rozložení pro pokročilé uživatele pouze pro stolní počítače s prohlížením zpráv na více kartách a pracovními postupy napříč účty. Standardní rozhraní zůstává nedotčeno; kdykoli se můžete vrátit.",
|
||||
"open_label": "Otevřít Pro rozhraní",
|
||||
"back_to_standard": "Zpět na standard"
|
||||
},
|
||||
"cross_unread": {
|
||||
"label": "Všechny účty: Nepřečtené",
|
||||
"description": "Zobrazí v sekci Všechny účty položku s nepřečtenou poštou ze všech účtů, napříč všemi složkami kromě spamu, odeslané, archivu, koše a konceptů."
|
||||
},
|
||||
"cross_starred": {
|
||||
"label": "Všechny účty: S hvězdičkou",
|
||||
"description": "Zobrazí v sekci Všechny účty položku s poštou s hvězdičkou ze všech účtů, napříč všemi složkami kromě spamu, odeslané, archivu, koše a konceptů."
|
||||
},
|
||||
"cross_all": {
|
||||
"label": "Všechny účty: Veškerá pošta",
|
||||
"description": "Zobrazí v sekci Všechny účty položku s veškerou poštou ze všech účtů, napříč všemi složkami kromě spamu, odeslané, archivu, koše a konceptů."
|
||||
}
|
||||
},
|
||||
"keywords": {
|
||||
@@ -1186,6 +1206,7 @@
|
||||
"archive": "Archivovat",
|
||||
"tag": "Štítek",
|
||||
"spam": "Označit jako spam",
|
||||
"not_spam": "Není spam",
|
||||
"none_selected": "Nebyly vybrány žádné akce",
|
||||
"mode_label": "Režim zobrazení",
|
||||
"mode_inline": "Vložené",
|
||||
@@ -1226,6 +1247,10 @@
|
||||
"off": "Vypnuto",
|
||||
"seconds": "{seconds} sekund",
|
||||
"unsupported": "Aktuální účet neoznamuje podporu odloženého odeslání. Nastavení zůstane uloženo pro jiné účty."
|
||||
},
|
||||
"return_to_list_after_action": {
|
||||
"label": "Return to list after delete or mark unread",
|
||||
"description": "After deleting or marking the open message unread, go back to the message list instead of opening the next message."
|
||||
}
|
||||
},
|
||||
"composer": {
|
||||
@@ -2147,6 +2172,9 @@
|
||||
"title": "Sdílené"
|
||||
},
|
||||
"address_books": {
|
||||
"create": "Nový adresář",
|
||||
"created": "Adresář vytvořen",
|
||||
"create_failed": "Nepodařilo se vytvořit adresář",
|
||||
"title": "Moje adresáře",
|
||||
"shared_prefix": "Sdílené: {name}",
|
||||
"moved": "Kontakt přesunut do {name}",
|
||||
@@ -2359,7 +2387,12 @@
|
||||
"members_label": "Členové",
|
||||
"search_members": "Hledat kontakty k přidání...",
|
||||
"no_members": "Tato skupina nemá žádné členy",
|
||||
"member_count": "{count, plural, =0 {Žádní členové} one {1 člen} few {# členové} other {# členů}}"
|
||||
"member_count": "{count, plural, =0 {Žádní členové} one {1 člen} few {# členové} other {# členů}}",
|
||||
"send_email": "Send email to group",
|
||||
"send_email_to": "To",
|
||||
"send_email_cc": "Cc",
|
||||
"send_email_bcc": "Bcc",
|
||||
"no_member_emails": "This group has no members with an email address."
|
||||
},
|
||||
"import": {
|
||||
"title": "Importovat kontakty",
|
||||
|
||||
+37
-4
@@ -134,7 +134,10 @@
|
||||
"mail": "Mail",
|
||||
"nav_label": "Navigation",
|
||||
"add_app": "Apps",
|
||||
"scheduled": "Planlagt"
|
||||
"scheduled": "Planlagt",
|
||||
"unified_all_unread": "Alle ulæste",
|
||||
"unified_all_starred": "Alle med stjerne",
|
||||
"unified_all_mail": "Al post"
|
||||
},
|
||||
"protocol_handlers": {
|
||||
"title": "Standardapps",
|
||||
@@ -562,7 +565,8 @@
|
||||
"add_to_contacts": "Tilføj til kontakter",
|
||||
"copied": "Kopieret!",
|
||||
"copy_failed": "Kunne ikke kopiere"
|
||||
}
|
||||
},
|
||||
"send_now": "Send nu"
|
||||
},
|
||||
"email_composer": {
|
||||
"read_receipt_on": "Læsekvittering anmodet (klik for at deaktivere)",
|
||||
@@ -801,7 +805,10 @@
|
||||
"pt": "Português",
|
||||
"ru": "Русский",
|
||||
"uk": "Українська",
|
||||
"zh": "简体中文"
|
||||
"zh": "简体中文",
|
||||
"fa": "فارسی",
|
||||
"farsi": "فارسی",
|
||||
"switch_to_farsi": "Skift til farsi"
|
||||
},
|
||||
"settings": {
|
||||
"title": "Indstillinger",
|
||||
@@ -922,6 +929,7 @@
|
||||
"description": "Show an \"All Mail\" entry above your folders that merges messages from across this account's folders into one list.",
|
||||
"folders_label": "Folders in All Mail",
|
||||
"folders_description": "Choose which folders are merged into the All Mail view.",
|
||||
"account_hint": "Applies to {account}.",
|
||||
"no_folders": "No folders available."
|
||||
},
|
||||
"colorful_sidebar_icons": {
|
||||
@@ -933,6 +941,18 @@
|
||||
"description": "Power user-layout kun til skrivebordet med beskedvisning på flere faner og arbejdsforløb på tværs af konti. Standardgrænsefladen påvirkes ikke; du kan skifte tilbage når som helst.",
|
||||
"open_label": "Åbn Pro-grænsefladen",
|
||||
"back_to_standard": "Tilbage til standard"
|
||||
},
|
||||
"cross_unread": {
|
||||
"label": "Alle konti: Ulæste",
|
||||
"description": "Viser en post i sektionen Alle konti med ulæst post fra alle konti, på tværs af alle mapper undtagen spam, sendt, arkiv, papirkurv og kladder."
|
||||
},
|
||||
"cross_starred": {
|
||||
"label": "Alle konti: Med stjerne",
|
||||
"description": "Viser en post i sektionen Alle konti med post med stjerne fra alle konti, på tværs af alle mapper undtagen spam, sendt, arkiv, papirkurv og kladder."
|
||||
},
|
||||
"cross_all": {
|
||||
"label": "Alle konti: Al post",
|
||||
"description": "Viser en post i sektionen Alle konti med al post fra alle konti, på tværs af alle mapper undtagen spam, sendt, arkiv, papirkurv og kladder."
|
||||
}
|
||||
},
|
||||
"keywords": {
|
||||
@@ -1189,6 +1209,7 @@
|
||||
"archive": "Arkivér",
|
||||
"tag": "Tag",
|
||||
"spam": "Markér som spam",
|
||||
"not_spam": "Ikke spam",
|
||||
"none_selected": "Ingen handlinger valgt",
|
||||
"mode_label": "Visningstilstand",
|
||||
"mode_inline": "Indlejret",
|
||||
@@ -1229,6 +1250,10 @@
|
||||
"off": "Fra",
|
||||
"seconds": "{seconds} sekunder",
|
||||
"unsupported": "Den aktuelle konto annoncerer ikke understøttelse af forsinket afsendelse. Indstillingen gemmes stadig for andre konti."
|
||||
},
|
||||
"return_to_list_after_action": {
|
||||
"label": "Return to list after delete or mark unread",
|
||||
"description": "After deleting or marking the open message unread, go back to the message list instead of opening the next message."
|
||||
}
|
||||
},
|
||||
"composer": {
|
||||
@@ -2147,6 +2172,9 @@
|
||||
"title": "Delt"
|
||||
},
|
||||
"address_books": {
|
||||
"create": "Ny adressebog",
|
||||
"created": "Adressebog oprettet",
|
||||
"create_failed": "Kunne ikke oprette adressebog",
|
||||
"title": "Mine adressebøger",
|
||||
"shared_prefix": "Delt: {name}",
|
||||
"moved": "Kontakt flyttet til {name}",
|
||||
@@ -2359,7 +2387,12 @@
|
||||
"members_label": "Medlemmer",
|
||||
"search_members": "Søg efter kontakter at tilføje...",
|
||||
"no_members": "Ingen medlemmer i denne gruppe",
|
||||
"member_count": "{count, plural, =0 {Ingen medlemmer} one {1 medlem} other {# medlemmer}}"
|
||||
"member_count": "{count, plural, =0 {Ingen medlemmer} one {1 medlem} other {# medlemmer}}",
|
||||
"send_email": "Send email to group",
|
||||
"send_email_to": "To",
|
||||
"send_email_cc": "Cc",
|
||||
"send_email_bcc": "Bcc",
|
||||
"no_member_emails": "This group has no members with an email address."
|
||||
},
|
||||
"import": {
|
||||
"title": "Importér kontakter",
|
||||
|
||||
+37
-4
@@ -134,7 +134,10 @@
|
||||
"nav_label": "Navigation",
|
||||
"add_app": "Apps",
|
||||
"shared": "Geteilt",
|
||||
"scheduled": "Geplant"
|
||||
"scheduled": "Geplant",
|
||||
"unified_all_unread": "Alle ungelesenen",
|
||||
"unified_all_starred": "Alle markierten",
|
||||
"unified_all_mail": "Alle Mails"
|
||||
},
|
||||
"protocol_handlers": {
|
||||
"title": "Standard-Apps",
|
||||
@@ -562,7 +565,8 @@
|
||||
"add_to_contacts": "Zu Kontakten hinzufügen",
|
||||
"copied": "Kopiert!",
|
||||
"copy_failed": "Kopieren fehlgeschlagen"
|
||||
}
|
||||
},
|
||||
"send_now": "Jetzt senden"
|
||||
},
|
||||
"email_composer": {
|
||||
"read_receipt_on": "Lesebestätigung angefordert (klicken zum Deaktivieren)",
|
||||
@@ -801,7 +805,10 @@
|
||||
"pt": "Português",
|
||||
"ru": "Русский",
|
||||
"uk": "Українська",
|
||||
"zh": "简体中文"
|
||||
"zh": "简体中文",
|
||||
"fa": "فارسی",
|
||||
"farsi": "فارسی",
|
||||
"switch_to_farsi": "Zu Farsi wechseln"
|
||||
},
|
||||
"settings": {
|
||||
"title": "Einstellungen",
|
||||
@@ -919,6 +926,7 @@
|
||||
"description": "Show an \"All Mail\" entry above your folders that merges messages from across this account's folders into one list.",
|
||||
"folders_label": "Folders in All Mail",
|
||||
"folders_description": "Choose which folders are merged into the All Mail view.",
|
||||
"account_hint": "Gilt für {account}.",
|
||||
"no_folders": "No folders available."
|
||||
},
|
||||
"colorful_sidebar_icons": {
|
||||
@@ -930,6 +938,18 @@
|
||||
"description": "Desktop-Power-User-Layout mit Multi-Tab-Nachrichtenansicht und kontoübergreifenden Workflows. Die Standardoberfläche bleibt unverändert; Sie können jederzeit zurückwechseln.",
|
||||
"open_label": "Pro-Oberfläche öffnen",
|
||||
"back_to_standard": "Zurück zum Standard"
|
||||
},
|
||||
"cross_unread": {
|
||||
"label": "Alle Konten: Ungelesen",
|
||||
"description": "Zeigt in der Sektion „All accounts“ einen Eintrag mit ungelesenen Mails über alle Konten, über alle Ordner außer Junk, Gesendet, Archiv, Papierkorb und Entwürfe."
|
||||
},
|
||||
"cross_starred": {
|
||||
"label": "Alle Konten: Markiert",
|
||||
"description": "Zeigt in der Sektion „All accounts“ einen Eintrag mit markierten Mails über alle Konten, über alle Ordner außer Junk, Gesendet, Archiv, Papierkorb und Entwürfe."
|
||||
},
|
||||
"cross_all": {
|
||||
"label": "Alle Konten: Alle Mails",
|
||||
"description": "Zeigt in der Sektion „All accounts“ einen Eintrag mit allen Mails über alle Konten, über alle Ordner außer Junk, Gesendet, Archiv, Papierkorb und Entwürfe."
|
||||
}
|
||||
},
|
||||
"keywords": {
|
||||
@@ -1186,6 +1206,7 @@
|
||||
"archive": "Archivieren",
|
||||
"tag": "Label",
|
||||
"spam": "Als Spam markieren",
|
||||
"not_spam": "Kein Spam",
|
||||
"none_selected": "Keine Aktionen ausgewählt",
|
||||
"mode_label": "Anzeigemodus",
|
||||
"mode_inline": "Eingebettet",
|
||||
@@ -1226,6 +1247,10 @@
|
||||
"off": "Aus",
|
||||
"seconds": "{seconds} Sekunden",
|
||||
"unsupported": "Das aktuelle Konto meldet keine Unterstützung für verzögertes Senden. Die Einstellung bleibt für andere Konten gespeichert."
|
||||
},
|
||||
"return_to_list_after_action": {
|
||||
"label": "Return to list after delete or mark unread",
|
||||
"description": "After deleting or marking the open message unread, go back to the message list instead of opening the next message."
|
||||
}
|
||||
},
|
||||
"composer": {
|
||||
@@ -2147,6 +2172,9 @@
|
||||
"title": "Geteilt"
|
||||
},
|
||||
"address_books": {
|
||||
"create": "Neues Adressbuch",
|
||||
"created": "Adressbuch erstellt",
|
||||
"create_failed": "Adressbuch konnte nicht erstellt werden",
|
||||
"title": "Meine Adressbücher",
|
||||
"shared_prefix": "Geteilt: {name}",
|
||||
"moved": "Kontakt verschoben nach {name}",
|
||||
@@ -2359,7 +2387,12 @@
|
||||
"members_label": "Mitglieder",
|
||||
"search_members": "Kontakte zum Hinzufügen suchen...",
|
||||
"no_members": "Keine Mitglieder in dieser Gruppe",
|
||||
"member_count": "{count, plural, =0 {Keine Mitglieder} one {1 Mitglied} other {# Mitglieder}}"
|
||||
"member_count": "{count, plural, =0 {Keine Mitglieder} one {1 Mitglied} other {# Mitglieder}}",
|
||||
"send_email": "Send email to group",
|
||||
"send_email_to": "To",
|
||||
"send_email_cc": "Cc",
|
||||
"send_email_bcc": "Bcc",
|
||||
"no_member_emails": "This group has no members with an email address."
|
||||
},
|
||||
"import": {
|
||||
"title": "Kontakte importieren",
|
||||
|
||||
+37
-4
@@ -134,7 +134,10 @@
|
||||
"mail": "Mail",
|
||||
"nav_label": "Navigation",
|
||||
"add_app": "Apps",
|
||||
"scheduled": "Scheduled"
|
||||
"scheduled": "Scheduled",
|
||||
"unified_all_unread": "All unread",
|
||||
"unified_all_starred": "All starred",
|
||||
"unified_all_mail": "All mail"
|
||||
},
|
||||
"protocol_handlers": {
|
||||
"title": "Default apps",
|
||||
@@ -562,7 +565,8 @@
|
||||
"add_to_contacts": "Add to contacts",
|
||||
"copied": "Copied!",
|
||||
"copy_failed": "Failed to copy"
|
||||
}
|
||||
},
|
||||
"send_now": "Send now"
|
||||
},
|
||||
"email_composer": {
|
||||
"read_receipt_on": "Read receipt requested (click to disable)",
|
||||
@@ -801,7 +805,10 @@
|
||||
"pt": "Português",
|
||||
"ru": "Русский",
|
||||
"uk": "Українська",
|
||||
"zh": "简体中文"
|
||||
"zh": "简体中文",
|
||||
"fa": "فارسی",
|
||||
"farsi": "فارسی",
|
||||
"switch_to_farsi": "Switch to Farsi"
|
||||
},
|
||||
"settings": {
|
||||
"title": "Settings",
|
||||
@@ -922,6 +929,7 @@
|
||||
"description": "Show an \"All Mail\" entry above your folders that merges messages from across this account's folders into one list.",
|
||||
"folders_label": "Folders in All Mail",
|
||||
"folders_description": "Choose which folders are merged into the All Mail view.",
|
||||
"account_hint": "Applies to {account}.",
|
||||
"no_folders": "No folders available."
|
||||
},
|
||||
"colorful_sidebar_icons": {
|
||||
@@ -933,6 +941,18 @@
|
||||
"description": "Desktop-only power-user layout with multi-tab message browsing and cross-account workflows. The standard interface is unaffected; you can switch back at any time.",
|
||||
"open_label": "Open Pro Interface",
|
||||
"back_to_standard": "Back to standard"
|
||||
},
|
||||
"cross_unread": {
|
||||
"label": "All accounts: Unread",
|
||||
"description": "Show an entry in the All accounts section listing unread mail across every account, spanning all folders except junk, sent, archive, trash and drafts."
|
||||
},
|
||||
"cross_starred": {
|
||||
"label": "All accounts: Starred",
|
||||
"description": "Show an entry in the All accounts section listing starred mail across every account, spanning all folders except junk, sent, archive, trash and drafts."
|
||||
},
|
||||
"cross_all": {
|
||||
"label": "All accounts: All mail",
|
||||
"description": "Show an entry in the All accounts section listing all mail across every account, spanning all folders except junk, sent, archive, trash and drafts."
|
||||
}
|
||||
},
|
||||
"keywords": {
|
||||
@@ -1189,6 +1209,7 @@
|
||||
"archive": "Archive",
|
||||
"tag": "Tag",
|
||||
"spam": "Mark as Spam",
|
||||
"not_spam": "Not Spam",
|
||||
"none_selected": "No actions selected",
|
||||
"mode_label": "Display Mode",
|
||||
"mode_inline": "Inline",
|
||||
@@ -1229,6 +1250,10 @@
|
||||
"off": "Off",
|
||||
"seconds": "{seconds} seconds",
|
||||
"unsupported": "The current account does not advertise delayed-send support. The setting is still saved for other accounts."
|
||||
},
|
||||
"return_to_list_after_action": {
|
||||
"label": "Return to list after delete or mark unread",
|
||||
"description": "After deleting or marking the open message unread, go back to the message list instead of opening the next message."
|
||||
}
|
||||
},
|
||||
"composer": {
|
||||
@@ -2148,6 +2173,9 @@
|
||||
"title": "Shared"
|
||||
},
|
||||
"address_books": {
|
||||
"create": "New address book",
|
||||
"created": "Address book created",
|
||||
"create_failed": "Failed to create address book",
|
||||
"title": "My Address Books",
|
||||
"shared_prefix": "Shared: {name}",
|
||||
"moved": "Contact moved to {name}",
|
||||
@@ -2360,7 +2388,12 @@
|
||||
"members_label": "Members",
|
||||
"search_members": "Search contacts to add...",
|
||||
"no_members": "No members in this group",
|
||||
"member_count": "{count, plural, =0 {No members} one {1 member} other {# members}}"
|
||||
"member_count": "{count, plural, =0 {No members} one {1 member} other {# members}}",
|
||||
"send_email": "Send email to group",
|
||||
"send_email_to": "To",
|
||||
"send_email_cc": "Cc",
|
||||
"send_email_bcc": "Bcc",
|
||||
"no_member_emails": "This group has no members with an email address."
|
||||
},
|
||||
"import": {
|
||||
"title": "Import Contacts",
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user