Compare commits

...
60 Commits
Author SHA1 Message Date
Linus Rath 8c5aec9ca4 chore: update version to 1.6.7 2026-05-17 18:17:13 +02:00
Linus Rath 375220298d i18n: add missing translation keys across 16 locales 2026-05-17 18:12:17 +02:00
Linus Rath 452976ed95 fix: apply dark background to email content wrapper in dark mode 2026-05-17 17:40:35 +02:00
Linus Rath 243a2adfbf fix: improve dark mode background colors in email viewer 2026-05-17 17:39:49 +02:00
Linus Rath 1ba4a13353 fix: show "no body content" instead of infinite skeleton for bodyless emails 2026-05-17 17:33:06 +02:00
Linus Rath 5de12dfb79 perf: speed up calendar invitation banner load
Parallelize ICS parse with raw blob fetch, render the banner as soon
as parsing returns instead of awaiting the existing-event lookup, and
filter that lookup by UID server-side instead of fetching every event
on the calendar.
2026-05-17 17:28:10 +02:00
Linus Rath 689d646c57 fix: show contact popup when clicking sender name in email header 2026-05-17 17:19:43 +02:00
Linus Rath 49cd7f8130 feat: show details toggle and panel on mobile sender info 2026-05-17 17:15:19 +02:00
Linus Rath 4545e212f4 fix: align quick reply with mobile bottom toolbar 2026-05-17 17:06:30 +02:00
Linus Rath b1f4f6eae0 fix: pin quick reply to bottom for short emails 2026-05-17 16:56:54 +02:00
Linus Rath 9a431a873b fix: close attachment preview when clicking outside content 2026-05-17 16:49:13 +02:00
Linus Rath bb7e1c4538 fix: per-account push subscriptions so multi-account notifications work #298 2026-05-16 22:50:01 +02:00
Linus Rath 356abcfc2d fix: redact sensitive config secrets from admin API response 2026-05-16 22:48:06 +02:00
Linus Rath 3099b4801e fix: sandbox thread email HTML in srcDoc iframe with CSP meta 2026-05-16 20:49:59 +02:00
Linus Rath fc641e94ac fix: carry configSchema + settingsSchema through marketplace install 2026-05-16 19:51:59 +02:00
Linus Rath 0e758409ee fix: prevent long addresses from overflowing email details columns #297 2026-05-16 19:47:03 +02:00
Linus Rath 8c93941d8d feat: render app-top-banner slot on every authenticated page 2026-05-16 19:39:45 +02:00
Linus Rath 4221c9a50f fix: strip Stalwart master-user '%' suffix from displayed account 2026-05-16 19:07:10 +02:00
Linus Rath 3a559479bd fix: make impersonation cookies session-only 2026-05-16 18:59:46 +02:00
Linus Rath 482493a10d fix: register app-top-banner in plugin-store SLOT_NAMES 2026-05-16 18:53:06 +02:00
Linus Rath 0e1036eb49 fix: adopt orphan session cookie on first SPA load 2026-05-16 18:45:30 +02:00
Linus Rath 349406723c fix: use relative Location header in redirect 2026-05-16 18:33:44 +02:00
Linus Rath 997bedc91b feat: allow admin password overwrite during setup recovery 2026-05-16 18:21:48 +02:00
Linus Rath 307e6d5d34 fix: warn + block install when app version is below plugin's minAppVersion 2026-05-16 18:11:52 +02:00
Linus Rath ca1108f455 feat: master-user impersonation route + app-top-banner plugin slot 2026-05-16 17:59:07 +02:00
Linus Rath 0ff88f36ed Merge branch 'main' of https://github.com/bulwarkmail/webmail 2026-05-16 16:35:43 +02:00
Linus Rath 285b4e349c fix: add outputFileTracingExcludes to optimize Turbopack memory tracing 2026-05-16 16:35:06 +02:00
Linus Rath 2b4ebb1fbb feat: add HTTPS requirement warning in setup wizard 2026-05-16 16:31:12 +02:00
Timo StreuleandLinus Rath a829c2818f fix: pad safe-area-inset-top 2026-05-16 00:55:24 +02:00
Timo StreuleandLinus Rath c54cf73c3a fix: respect safe-area insets on mobile bottom bars 2026-05-15 23:50:29 +02:00
Timo StreuleandLinus Rath c45ef86924 fix: add viewport export with 'initialScale: 1' 2026-05-15 23:01:39 +02:00
Linus Rath f39366b470 fix: read OAUTH_SCOPES at runtime instead of build time 2026-05-15 20:37:00 +02:00
Linus Rath b725000f4d feat: implement vCard 4.0 parsing and generation support 2026-05-15 20:10:18 +02:00
Linus Rath 105194a8b9 chore: update version to 1.6.6 2026-05-15 15:20:07 +02:00
Linus Rath 8dbb538c98 feat: sync onboarding status across devices #285 2026-05-15 15:09:42 +02:00
Linus Rath e435356c53 Merge branch 'main' of https://github.com/bulwarkmail/webmail 2026-05-15 14:49:58 +02:00
Linus Rath 6f9982540c feat: add icons for shared, important, memos, scheduled, snoozed folders #288 2026-05-15 14:48:39 +02:00
Timo StreuleandLinus Rath d0d6632b24 chore: drop redundant '-- ' prefix from dev identity signatures
The signature separator is already controlled by the
signatureSeparatorEnabled setting (lib/email-composer), which prepends
'-- ' at compose time when enabled. Baking it into the fixture
double-prefixed it.
2026-05-15 14:46:43 +02:00
Timo StreuleandLinus Rath 4b7009dfc2 feat: raise HTML signature length cap to 50000 chars
5000 chars is too tight for signatures containing base64-embedded images (even a small PNG can run a few thousand chars).
2026-05-15 14:46:43 +02:00
Timo StreuleandLinus Rath 55a408e810 feat: allow img in HTML identity signatures
- Restricts src to https: URLs or base64-embedded raster data: URIs (png/jpeg/gif/webp).
- SVG is excluded for safety reasons.
- Images with a disallowed src are removed entirely so they don't render as broken-image icons.
2026-05-15 14:46:43 +02:00
Linus Rath d5dddba6df fix: hide Files settings/nav when filesEnabled policy is off #291 2026-05-15 14:41:57 +02:00
Linus Rath d1a0667c79 i18n: clean up Danish locale wiring and sort language lists 286 2026-05-15 14:31:24 +02:00
Jesper OrdrupandLinus Rath e700e4fd04 match any translation 2026-05-15 14:26:33 +02:00
Jesper OrdrupandLinus Rath cf993c1036 adjust flag 2026-05-15 14:26:33 +02:00
Jesper OrdrupandLinus Rath 5fdf226ebe feat(i18n): add danish localization 2026-05-15 14:26:33 +02:00
Linus Rath fae15f073e fix: honor cookieSameSite admin config override #284 2026-05-14 21:49:37 +02:00
Linus Rath c646c87030 fix: standardize punctuation in tooltips and comments across multiple locales and code files 2026-05-14 21:44:24 +02:00
Linus Rath b4a76bc4d1 chore: expand demo fixtures with more emails, contacts, and portrait photos 2026-05-14 15:19:24 +02:00
Linus Rath dfe886636b fix: broaden body font for non Latin script rendering #265 2026-05-13 14:43:07 +02:00
Linus Rath f499e87d2a chore: update version to 1.6.5 2026-05-13 14:38:00 +02:00
Linus Rath 32fe871b70 fix: support HTTP basic auth in iCal subscription URLs #275 2026-05-13 14:27:54 +02:00
Linus Rath aab19379e2 feat: route account avatars through shared Avatar component #278 2026-05-13 00:50:46 +02:00
Linus Rath b46a1a69e8 chore: unblock pre-commit lint hook 2026-05-13 00:34:35 +02:00
Linus Rath ea424cad7e fix: honor admin-uploaded favicon in root metadata #274 2026-05-13 00:33:23 +02:00
Lucas GaitzschandLinus Rath 3f444a8912 Feature/protocol handlers
* Added account selection for protocol links when multiple connected accounts are available, including mailto: links
* Added support for handling mailto: links in an already-open PWA/session instead of always opening a new tab
* Added webcal: protocol handling for calendar links
* Added account selection for webcal: links when multiple calendar-capable accounts are connected
* Added an import-or-subscribe choice for detected webcal calendars
* Added protocol handler settings for registering mail and calendar handlers and choosing the open mode
* Added service worker/session coordination for passing protocol requests between browser/PWA contexts
* Added tests and translations for the new protocol handler flows
2026-05-12 20:49:05 +02:00
Linus Rath 8b0e2052cf fix: honor NEXT_PUBLIC_BASE_PATH in admin sidebar nav links #271 2026-05-12 16:10:29 +02:00
Linus Rath c99934a92c fix: update version to 1.6.4 2026-05-12 16:06:14 +02:00
Linus Rath ce2731cd9d fix: update types for cursor and toRemove 2026-05-12 16:04:46 +02:00
Linus Rath f9f8af2f11 fix: preserve signature styling and reactivity in above-quote mode #272 2026-05-12 16:03:10 +02:00
Linus Rath d8e2a10806 docs: update CONTRIBUTING.md 2026-05-11 20:41:04 +02:00
129 changed files with 9231 additions and 935 deletions
+84
View File
@@ -1,5 +1,89 @@
# Changelog
## 1.6.7 (2026-05-17)
### Features
- **Contacts**: vCard 4.0 parsing and generation support
- **Admin**: Master-user impersonation route with `app-top-banner` plugin slot rendered on every authenticated page
- **Admin**: Allow admin password overwrite during setup recovery
- **Setup**: HTTPS requirement warning in the setup wizard
- **Mobile**: Show details toggle and expandable panel for sender info
### Performance
- **Calendar**: Speed up calendar invitation banner load
### Security
- **Mail**: Sandbox thread email HTML in `srcDoc` iframe with a CSP `<meta>` tag
- **Admin**: Redact sensitive config secrets from the admin API response
- **Admin**: Make impersonation cookies session-only
### Fixes
- **Auth**: Read `OAUTH_SCOPES` at runtime instead of build time
- **Auth**: Use a relative `Location` header in redirects
- **Auth**: Adopt orphan session cookie on first SPA load
- **Mail**: Per-account push subscriptions so multi-account notifications work (#298)
- **Mail**: Close attachment preview when clicking outside the content area
- **Mail**: Pin quick reply to the bottom for short emails
- **Mail**: Show "no body content" instead of an infinite skeleton for bodyless emails
- **Mail**: Show contact popup when clicking the sender name in the email header
- **Mail**: Prevent long addresses from overflowing email details columns (#297)
- **Mobile**: Align quick reply with the mobile bottom toolbar
- **Mobile**: Respect safe-area insets on mobile bottom bars
- **Mobile**: Pad `safe-area-inset-top`
- **UI**: Apply dark background to the email content wrapper in dark mode
- **UI**: Improve dark mode background colors in the email viewer
- **UI**: Add viewport export with `initialScale: 1`
- **UI**: Strip the Stalwart master-user `%` suffix from the displayed account
- **Plugins**: Warn and block install when the app version is below the plugin's `minAppVersion`
- **Plugins**: Register `app-top-banner` in plugin-store `SLOT_NAMES`
- **Plugins**: Carry `configSchema` + `settingsSchema` through marketplace install
- **Build**: Add `outputFileTracingExcludes` to reduce Turbopack memory tracing
### i18n
- Add missing translation keys across 16 locales
## 1.6.6 (2026-05-15)
### Features
- **Mail**: Sync onboarding completion state across devices so the welcome flow only runs once per account (#285)
- **Mail**: Distinct icons for Shared, Important, Memos, Scheduled, and Snoozed folders (#288)
- **Compose**: Raise HTML identity signature length cap to 50,000 characters
- **Compose**: Allow `<img>` tags in HTML identity signatures for inline logos and banners
### Fixes
- **Files**: Hide Files settings entry and sidebar nav when the `filesEnabled` policy is off (#291)
- **Admin**: Honor the `cookieSameSite` admin config override instead of always defaulting (#284)
- **UI**: Standardize punctuation in tooltips and inline comments across locales
### i18n
- Add Danish localization
- Clean up Danish locale wiring and sort the language picker alphabetically (#286)
## 1.6.5 (2026-05-13)
### Features
- **Protocol**: Register as the system handler for `mailto:` and `webcal:` links from a new protocol handler settings page
- **Protocol**: Account picker for protocol links when multiple accounts are connected
- **Protocol**: Import-or-subscribe choice for detected webcal calendars
- **Protocol**: Reuse the open PWA/session for `mailto:` links instead of always opening a new tab
- **UI**: Route account avatars through the shared `Avatar` component for consistent fallbacks (#278)
### Fixes
- **Calendar**: Support HTTP basic auth in iCal subscription URLs (#275)
- **Admin**: Honor admin-uploaded favicon in root metadata (#274)
- **Admin**: Honor `NEXT_PUBLIC_BASE_PATH` in admin sidebar nav links (#271)
- **UI**: Broaden body font stack so Thai (and other non-Latin scripts) render correctly in subjects, sender names, and other chrome (#265)
## 1.6.4 (2026-05-11)
### Web Setup Wizard
+26 -34
View File
@@ -10,14 +10,17 @@
# Contributing to Bulwark Webmail
Thank you for your interest in contributing to Bulwark Webmail! This document provides guidelines and information for contributors.
We're writing the webmail we wanted in 2026 and didn't find. Modern protocol, modern tooling, modern UI. Not a SaaS. Not a startup. Not for sale.
## Join our Community
**New to the project or looking for a place to start?** You don't need to be an expert to contribute! Whether you need help setting up your environment, want to report a bug, or are interested in helping with translations, our Discord is the best place to connect.
If that resonates with you, we'd love your help. This guide covers how to get the project running, the conventions we follow, and how to land your first change.
* **Get Support:** Get real-time help with development hurdles.
* **Contribute:** Share ideas, suggest features, or help us improve documentation.
* **Collaborate:** Meet the team and other contributors working to make Bulwark better.
## Join the Community
You don't need to be an expert to contribute. Whether you're setting up your dev environment for the first time, filing a bug, or translating a string, the Discord is the fastest way to get unstuck and meet the people working on this.
- **Get support** - real-time help with development hurdles
- **Share ideas** - feature suggestions, design feedback, doc improvements
- **Collaborate** - meet the team and other contributors
[**Join the Bulwark Discord Server**](https://discord.gg/tYCujymGrT)
@@ -94,37 +97,31 @@ These checks run automatically on commit via Husky pre-commit hooks.
## Internationalization (i18n)
This project uses **next-intl** for internationalization. Please follow these guidelines:
This project uses **next-intl**. English (`/locales/en/common.json`) is the source of truth; we ship 15 additional locales (cs, de, es, fr, it, ja, ko, lv, nl, pl, pt, ru, tr, uk, zh).
### Key Rules
### Rules
1. **Never hardcode user-facing text** - Always use translations:
1. **Never hardcode user-facing text** - always use translations:
```tsx
const t = useTranslations("namespace");
return <div>{t("key")}</div>;
```
2. **Translation file locations**:
- English: `/locales/en/common.json`
- French: `/locales/fr/common.json`
2. **Add new keys to `en/common.json` first.** Other locales can follow in the same PR or a follow-up - missing keys fall back to English.
3. **Namespace organization**:
- `login.*` - Login page strings
- `sidebar.*` - Sidebar navigation
- `email_list.*` - Email list component
- `email_viewer.*` - Email viewer component
- `email_composer.*` - Email composer
- `common.*` - Shared strings
- `notifications.*` - Toast/alert messages
- `settings.*` - Settings page
- `login.*` - login page
- `sidebar.*` - sidebar navigation
- `email_list.*` - email list
- `email_viewer.*` - email viewer
- `email_composer.*` - composer
- `settings.*` - settings page
- `notifications.*` - toasts and alerts
- `common.*` - shared strings
4. **Adding new strings**:
- Add to **both** English and French translation files
- Use descriptive, hierarchical keys
- Keep translations consistent in tone
4. **Locale-aware navigation**:
5. **Locale-aware navigation**:
```tsx
router.push(`/${params.locale}/settings`);
```
@@ -203,16 +200,11 @@ webmail/
## Security
- **Never commit sensitive data** (API keys, passwords, etc.)
- **Never commit secrets** - API keys, passwords, tokens, `.env*` files
- **Sanitize user input** and email content
- **Block external content** by default for privacy
- Report security vulnerabilities privately (e.g. bulwark@rbm.systems)
- **Block external content** by default - privacy is the point
- **Report vulnerabilities privately** to bulwark@rbm.systems, not via public issues
## Questions?
If you have questions about contributing, feel free to:
- Open an issue for discussion
- Check existing issues and pull requests
Thank you for helping improve Bulwark Webmail!
Open an issue, search existing ones, or ask in Discord. Thanks for helping build the webmail we all wished existed.
+1 -1
View File
@@ -98,7 +98,7 @@
## Internationalization
15 languages: English · Français · 日本語 · Español · Italiano · Deutsch · Nederlands · Português · Русский · Türkçe · 한국어 · Polski · Latviešu · 简体中文 · Українська
17 languages: Česky · Dansk · Deutsch · English · Español · Français · Italiano · Latviešu · Nederlands · Polski · Português · Türkçe · Русский · Українська · 한국어 · 日本語 · 简体中文
Automatic browser detection with persistent preference. Configurable locale URL prefix via `NEXT_PUBLIC_LOCALE_PREFIX`.
+1 -1
View File
@@ -12,7 +12,7 @@ A modern, self-hosted webmail client for [Stalwart Mail Server](https://stalw.ar
[![License: AGPL v3](https://img.shields.io/badge/license-AGPL%20v3-blue.svg?logo=gnu&logoColor=white)](LICENSE)
[![Discord](https://img.shields.io/discord/1482128142939455674?color=7289da&label=discord&logo=discord&logoColor=white)](https://discord.gg/tYCujymGrT)
[![Version](https://img.shields.io/badge/version-1.6.4-green.svg?logo=git&logoColor=white)](CHANGELOG.md)
[![Version](https://img.shields.io/badge/version-1.6.7-green.svg?logo=git&logoColor=white)](CHANGELOG.md)
[![Docker](https://img.shields.io/badge/docker-ghcr.io%2Fbulwarkmail%2Fwebmail-blue?logo=docker&logoColor=white)](https://ghcr.io/bulwarkmail/webmail)
[![Grafana](https://img.shields.io/badge/grafana-dashboard-orange?logo=grafana&logoColor=white)](https://grafana.external.bulwarkmail.org/)
+1 -1
View File
@@ -1 +1 @@
1.6.3
1.6.6
+156 -7
View File
@@ -15,6 +15,7 @@ import { useAuthStore, redirectToLogin } from "@/stores/auth-store";
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 { toast } from "@/stores/toast-store";
import { useIsMobile } from "@/hooks/use-media-query";
import { Button } from "@/components/ui/button";
@@ -31,12 +32,14 @@ import { CalendarSidebarPanel } from "@/components/calendar/calendar-sidebar-pan
import { EventModal, type PendingEventPreview } from "@/components/calendar/event-modal";
import { EventDetailPopover } from "@/components/calendar/event-detail-popover";
import { EventContextMenu } from "@/components/calendar/event-context-menu";
import { AppTopBannerSlot } from "@/components/plugins/app-top-banner-slot";
import { EmptySpaceContextMenu } from "@/components/calendar/empty-space-context-menu";
import { useContextMenu } from "@/hooks/use-context-menu";
import { useRefreshGesture } from "@/hooks/use-refresh-gesture";
import { downloadEventICS } from "@/lib/calendar-ics-export";
import { ICalImportModal } from "@/components/calendar/ical-import-modal";
import { ICalSubscriptionModal } from "@/components/calendar/ical-subscription-modal";
import { ProtocolAccountPicker } from "@/components/protocol/protocol-account-picker";
import { RecurrenceScopeDialog, type RecurrenceEditScope } from "@/components/calendar/recurrence-scope-dialog";
import { NavigationRail } from "@/components/layout/navigation-rail";
import { SidebarAppsModal } from "@/components/layout/sidebar-apps-modal";
@@ -56,6 +59,8 @@ import { CreateCalendarModal } from "@/components/calendar/create-calendar-modal
import { getUserParticipantId } from "@/lib/calendar-participants";
import { generateBirthdayEvents, createBirthdayCalendar, BIRTHDAY_CALENDAR_ID } from "@/lib/birthday-calendar";
import { debug } from "@/lib/debug";
import { consumePendingWebcal, hasPendingWebcal, subscribeToPendingWebcal } from "@/lib/protocol-handlers/session";
import type { ParsedWebcal } from "@/lib/protocol-handlers/webcal";
type PendingScopeAction =
| { type: "edit"; event: CalendarEvent; updates: Partial<CalendarEvent>; sendScheduling?: boolean }
@@ -68,9 +73,10 @@ function isRecurringEvent(event: CalendarEvent): boolean {
export default function CalendarPage() {
const router = useRouter();
const t = useTranslations("calendar");
const tWebcalAction = useTranslations("calendar.webcal_action");
const isMobile = useIsMobile();
const { showAppsModal, inlineApp, loadedApps, handleManageApps, handleInlineApp, closeInlineApp, closeAppsModal } = useSidebarApps();
const { client, isAuthenticated, logout, checkAuth, isLoading: authLoading } = useAuthStore();
const { client, isAuthenticated, logout, checkAuth, switchAccount, activeAccountId, isLoading: authLoading } = useAuthStore();
const [initialCheckDone, setInitialCheckDone] = useState(() => useAuthStore.getState().isAuthenticated && !!useAuthStore.getState().client);
const { quota, isPushConnected } = useEmailStore();
const {
@@ -96,6 +102,10 @@ export default function CalendarPage() {
const [showEventModal, setShowEventModal] = useState(false);
const [showImportModal, setShowImportModal] = useState(false);
const [showSubscriptionModal, setShowSubscriptionModal] = useState(false);
const [pendingSubscription, setPendingSubscription] = useState<{ url: string; name: string } | null>(null);
const [showWebcalActionChoice, setShowWebcalActionChoice] = useState(false);
const [pendingWebcalAccountChoice, setPendingWebcalAccountChoice] = useState<ParsedWebcal | null>(null);
const [isProtocolAccountSwitching, setIsProtocolAccountSwitching] = useState(false);
const [editingSubscription, setEditingSubscription] = useState<string | null>(null);
const [sharingCalendarId, setSharingCalendarId] = useState<string | null>(null);
const [defaultCalendarIdForCreate, setDefaultCalendarIdForCreate] = useState<string | undefined>(undefined);
@@ -156,10 +166,10 @@ export default function CalendarPage() {
if (initialCheckDone && !isAuthenticated && !authLoading) {
try { sessionStorage.setItem('redirect_after_login', window.location.pathname); } catch { /* ignore */ }
redirectToLogin();
} else if (client && !supportsCalendar) {
} else if (client && !supportsCalendar && !pendingWebcalAccountChoice && !isProtocolAccountSwitching && !pendingSubscription && !showWebcalActionChoice && !hasPendingWebcal()) {
router.push("/");
}
}, [initialCheckDone, isAuthenticated, authLoading, client, supportsCalendar, router]);
}, [initialCheckDone, isAuthenticated, authLoading, client, supportsCalendar, pendingWebcalAccountChoice, isProtocolAccountSwitching, pendingSubscription, showWebcalActionChoice, router]);
useEffect(() => {
if (error) {
@@ -167,6 +177,84 @@ export default function CalendarPage() {
}
}, [error]);
const getWebcalProtocolAccounts = useCallback(() => {
const connectedClients = useAuthStore.getState().getAllConnectedClients();
return useAccountStore.getState().accounts.filter((account) => {
if (!account.isConnected) return false;
return connectedClients.get(account.id)?.supportsCalendars() === true;
});
}, []);
const openWebcalForAccount = useCallback(async (pending: ParsedWebcal, accountId: string) => {
setIsProtocolAccountSwitching(true);
try {
if (useAuthStore.getState().activeAccountId !== accountId) {
await switchAccount(accountId);
}
setPendingWebcalAccountChoice(null);
setPendingSubscription({
url: pending.subscriptionUrl,
name: pending.suggestedName,
});
setShowWebcalActionChoice(true);
} finally {
setIsProtocolAccountSwitching(false);
}
}, [switchAccount]);
const handleWebcalProtocolRequest = useCallback((pending: ParsedWebcal) => {
const protocolAccounts = getWebcalProtocolAccounts();
if (protocolAccounts.length > 1) {
setPendingWebcalAccountChoice(pending);
return;
}
if (protocolAccounts.length === 0 && !supportsCalendar) {
return;
}
const accountId = protocolAccounts[0]?.id ?? activeAccountId;
if (accountId) {
void openWebcalForAccount(pending, accountId);
return;
}
setPendingSubscription({
url: pending.subscriptionUrl,
name: pending.suggestedName,
});
setShowWebcalActionChoice(true);
}, [activeAccountId, getWebcalProtocolAccounts, openWebcalForAccount, supportsCalendar]);
const closeWebcalActionChoice = useCallback(() => {
setShowWebcalActionChoice(false);
setPendingSubscription(null);
}, []);
const handleImportWebcal = useCallback(() => {
setShowWebcalActionChoice(false);
setShowImportModal(true);
}, []);
const handleSubscribeWebcal = useCallback(() => {
setShowWebcalActionChoice(false);
setShowSubscriptionModal(true);
}, []);
useEffect(() => {
if (!isAuthenticated || !client) return;
const openPendingWebcal = () => {
const pending = consumePendingWebcal();
if (!pending) return;
handleWebcalProtocolRequest(pending);
};
openPendingWebcal();
return subscribeToPendingWebcal(openPendingWebcal);
}, [isAuthenticated, client, handleWebcalProtocolRequest]);
useEffect(() => {
if (client && !hasFetched.current) {
hasFetched.current = true;
@@ -955,7 +1043,54 @@ export default function CalendarPage() {
});
}, [events, selectedCalendarIds, visibleEvents]);
if (!isAuthenticated || !supportsCalendar) return null;
const renderWebcalAccountPicker = () => pendingWebcalAccountChoice ? (
<ProtocolAccountPicker
kind="webcal"
operation={pendingWebcalAccountChoice}
accounts={getWebcalProtocolAccounts()}
activeAccountId={activeAccountId}
isSwitching={isProtocolAccountSwitching}
onSelect={(accountId) => void openWebcalForAccount(pendingWebcalAccountChoice, accountId)}
onCancel={() => setPendingWebcalAccountChoice(null)}
/>
) : null;
const renderWebcalActionChoice = () => showWebcalActionChoice && pendingSubscription ? (
<div className="fixed inset-0 z-50 flex items-center justify-center">
<div className="absolute inset-0 bg-black/50 backdrop-blur-[1px]" onClick={closeWebcalActionChoice} aria-hidden="true" />
<div
role="dialog"
aria-modal="true"
aria-label={tWebcalAction("title")}
className="relative bg-background border border-border rounded-lg shadow-xl w-full max-w-md mx-4 animate-in zoom-in-95 duration-200"
>
<div className="px-6 py-4 border-b border-border">
<h2 className="text-lg font-semibold">{tWebcalAction("title")}</h2>
<p className="text-sm text-muted-foreground mt-1">{tWebcalAction("description", { name: pendingSubscription.name })}</p>
</div>
<div className="px-6 py-4 space-y-3">
<Button variant="outline" className="w-full justify-start h-auto py-3" onClick={handleImportWebcal}>
<span className="text-left">
<span className="block font-medium">{tWebcalAction("import_title")}</span>
<span className="block text-xs text-muted-foreground mt-0.5">{tWebcalAction("import_description")}</span>
</span>
</Button>
<Button variant="outline" className="w-full justify-start h-auto py-3" onClick={handleSubscribeWebcal}>
<span className="text-left">
<span className="block font-medium">{tWebcalAction("subscribe_title")}</span>
<span className="block text-xs text-muted-foreground mt-0.5">{tWebcalAction("subscribe_description")}</span>
</span>
</Button>
</div>
<div className="flex items-center justify-end gap-2 px-6 py-4 border-t border-border">
<Button variant="ghost" onClick={closeWebcalActionChoice}>{tWebcalAction("cancel")}</Button>
</div>
</div>
</div>
) : null;
if (!isAuthenticated) return null;
if (!supportsCalendar) return renderWebcalAccountPicker();
const renderView = () => {
if (isLoading && calendars.length === 0) {
@@ -1082,7 +1217,9 @@ export default function CalendarPage() {
};
return (
<div className={cn("flex h-dvh bg-background overflow-hidden", isMobile && "flex-col")}>
<div className="flex flex-col h-dvh bg-background overflow-hidden pt-[env(safe-area-inset-top)]">
<AppTopBannerSlot />
<div className={cn("flex flex-1 min-h-0 overflow-hidden", isMobile && "flex-col")}>
{/* Left Navigation Rail */}
{!isMobile && (
<div className="w-14 bg-secondary flex flex-col flex-shrink-0" style={{ borderRight: '1px solid rgba(128, 128, 128, 0.3)' }}>
@@ -1378,14 +1515,23 @@ export default function CalendarPage() {
<ICalImportModal
calendars={calendars}
client={client}
onClose={() => setShowImportModal(false)}
initialUrl={pendingSubscription?.url}
onClose={() => {
setShowImportModal(false);
setPendingSubscription(null);
}}
/>
)}
{showSubscriptionModal && client && (
<ICalSubscriptionModal
client={client}
onClose={() => setShowSubscriptionModal(false)}
initialUrl={pendingSubscription?.url}
initialName={pendingSubscription?.name}
onClose={() => {
setShowSubscriptionModal(false);
setPendingSubscription(null);
}}
/>
)}
@@ -1402,6 +1548,8 @@ export default function CalendarPage() {
})()}
<SidebarAppsModal isOpen={showAppsModal} onClose={closeAppsModal} />
{renderWebcalAccountPicker()}
{renderWebcalActionChoice()}
<RecurrenceScopeDialog
isOpen={!!pendingScopeAction}
actionType={pendingScopeAction?.type || "edit"}
@@ -1435,6 +1583,7 @@ export default function CalendarPage() {
/>
);
})()}
</div>
</div>
);
}
+5 -1
View File
@@ -15,6 +15,7 @@ import { ContactsSidebar, type ContactCategory } from "@/components/contacts/con
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 { useAuthStore, redirectToLogin } from "@/stores/auth-store";
import { useEmailStore } from "@/stores/email-store";
@@ -649,7 +650,9 @@ export default function ContactsPage() {
};
return (
<div className={cn("flex h-dvh bg-background overflow-hidden", isMobile && "flex-col")}>
<div className="flex flex-col h-dvh bg-background overflow-hidden pt-[env(safe-area-inset-top)]">
<AppTopBannerSlot />
<div className={cn("flex flex-1 min-h-0 overflow-hidden", isMobile && "flex-col")}>
{/* Navigation Rail - desktop only */}
{!isMobile && (
<div className="w-14 bg-secondary flex flex-col flex-shrink-0" style={{ borderRight: '1px solid rgba(128, 128, 128, 0.3)' }}>
@@ -882,6 +885,7 @@ export default function ContactsPage() {
/>
);
})()}
</div>
</div>
);
}
+5 -1
View File
@@ -24,6 +24,7 @@ import { ImagePreviewModal } from "@/components/files/image-preview-modal";
import { FilePreviewModal } from "@/components/files/file-preview-modal";
import { loadFilesSettings } from "@/components/files/files-settings-dialog";
import type { FolderLayout } from "@/components/files/files-settings-dialog";
import { AppTopBannerSlot } from "@/components/plugins/app-top-banner-slot";
import { AlertTriangle } from "lucide-react";
export default function FilesPage() {
@@ -374,7 +375,9 @@ export default function FilesPage() {
if (!isAuthenticated) return null;
return (
<div className="flex h-dvh bg-background overflow-hidden">
<div className="flex flex-col h-dvh bg-background overflow-hidden pt-[env(safe-area-inset-top)]">
<AppTopBannerSlot />
<div className="flex flex-1 min-h-0 overflow-hidden">
{!isMobile && (
<div className="w-14 bg-secondary flex flex-col flex-shrink-0" style={{ borderRight: '1px solid rgba(128, 128, 128, 0.3)' }}>
<NavigationRail
@@ -514,6 +517,7 @@ export default function FilesPage() {
<SidebarAppsModal isOpen={showAppsModal} onClose={closeAppsModal} />
<ConfirmDialog {...confirmDialogProps} />
</div>
</div>
);
}
+4 -1
View File
@@ -5,6 +5,7 @@ import { CalendarAlertProvider } from "@/components/providers/calendar-alert-pro
import { EmbeddedBridgeProvider } from "@/components/providers/embedded-bridge-provider";
import { RateLimitToastProvider } from "@/components/providers/rate-limit-toast-provider";
import { TourProvider } from "@/components/tour/tour-provider";
import { ProtocolLaunchHandlerProvider } from "@/components/protocol/protocol-launch-handler-provider";
import { locales } from "@/i18n/routing";
export default async function LocaleLayout({
@@ -32,7 +33,9 @@ export default async function LocaleLayout({
<RateLimitToastProvider>
<EmbeddedBridgeProvider>
<TourProvider>
{children}
<ProtocolLaunchHandlerProvider>
{children}
</ProtocolLaunchHandlerProvider>
</TourProvider>
</EmbeddedBridgeProvider>
</RateLimitToastProvider>
+2 -3
View File
@@ -16,7 +16,6 @@ import { cn } from "@/lib/utils";
import { AlertCircle, Loader2, X, Info, Eye, EyeOff, LogIn, Sun, Moon, Monitor, Check, Shield, Play, Copy } from "lucide-react";
import { discoverOAuth, type OAuthMetadata } from "@/lib/oauth/discovery";
import { generateCodeVerifier, generateCodeChallenge, generateState } from "@/lib/oauth/pkce";
import { OAUTH_SCOPES } from "@/lib/oauth/tokens";
import { useUpdateStore, selectBanner } from "@/stores/update-store";
import type { PublicJmapServerEntry } from "@/lib/admin/jmap-servers";
@@ -117,7 +116,7 @@ export default function LoginPage() {
const isAddAccountMode = searchParams.get("mode") === "add-account";
const { login, loginDemo, isLoading, error, clearError, isAuthenticated } = useAuthStore();
const { theme, setTheme, initializeTheme } = useThemeStore(useShallow((s) => ({ theme: s.theme, setTheme: s.setTheme, initializeTheme: s.initializeTheme })));
const { appName, jmapServerUrl: configuredServerUrl, oauthEnabled, oauthOnly, oauthClientId: globalOauthClientId, oauthIssuerUrl: globalOauthIssuerUrl, rememberMeEnabled, devMode, demoMode, loginLogoLightUrl, loginLogoDarkUrl, loginCompanyName, loginImprintUrl, loginPrivacyPolicyUrl, loginWebsiteUrl, isLoading: configLoading, error: configError, autoSsoEnabled, embeddedMode: _embeddedMode, allowCustomJmapEndpoint, jmapServers, jmapServerAutoPickByDomain } = useConfig();
const { appName, jmapServerUrl: configuredServerUrl, oauthEnabled, oauthOnly, oauthClientId: globalOauthClientId, oauthIssuerUrl: globalOauthIssuerUrl, oauthScopes, rememberMeEnabled, devMode, demoMode, loginLogoLightUrl, loginLogoDarkUrl, loginCompanyName, loginImprintUrl, loginPrivacyPolicyUrl, loginWebsiteUrl, isLoading: configLoading, error: configError, autoSsoEnabled, embeddedMode: _embeddedMode, allowCustomJmapEndpoint, jmapServers, jmapServerAutoPickByDomain } = useConfig();
const resolvedTheme = useThemeStore((s) => s.resolvedTheme);
const [formData, setFormData] = useState({
@@ -532,7 +531,7 @@ export default function LoginPage() {
authUrl.searchParams.set("response_type", "code");
authUrl.searchParams.set("client_id", effectiveOauthClientId);
authUrl.searchParams.set("redirect_uri", redirectUri);
authUrl.searchParams.set("scope", OAUTH_SCOPES);
authUrl.searchParams.set("scope", oauthScopes || "openid email profile");
authUrl.searchParams.set("state", state);
authUrl.searchParams.set("code_challenge", challenge);
authUrl.searchParams.set("code_challenge_method", "S256");
+109 -6
View File
@@ -8,6 +8,7 @@ import { EmailList } from "@/components/email/email-list";
import { EmailViewer } from "@/components/email/email-viewer";
import { EmailComposer } from "@/components/email/email-composer";
import type { ComposerDraftData } from "@/components/email/email-composer";
import { ProtocolAccountPicker } from "@/components/protocol/protocol-account-picker";
import { ThreadConversationView } from "@/components/email/thread-conversation-view";
import { MobileHeader } from "@/components/layout/mobile-header";
import { ThreadGroup, Email, isUnifiedMailboxId, UNIFIED_ROLE_BY_ID } from "@/lib/jmap/types";
@@ -59,7 +60,11 @@ import { ResizeHandle } from "@/components/layout/resize-handle";
import { Button } from "@/components/ui/button";
import { useConfig } from "@/hooks/use-config";
import { usePluginStore } from "@/stores/plugin-store";
import { AppTopBannerSlot } from "@/components/plugins/app-top-banner-slot";
import { useThemeStore } from "@/stores/theme-store";
import { consumePendingMailto, subscribeToPendingMailto } from "@/lib/protocol-handlers/session";
import type { ParsedMailto } from "@/lib/protocol-handlers/mailto";
import { plainTextToComposerBody } from "@/lib/email-composer-utils";
import { appLifecycleHooks, uiHooks, routerHooks, toastHooks, emailHooks } from "@/lib/plugin-hooks";
import { emailToReadView } from "@/lib/plugin-projection";
@@ -74,6 +79,7 @@ export default function Home() {
const [composerDraftText, setComposerDraftText] = useState("");
const [pendingDraft, setPendingDraft] = useState<ComposerDraftData | null>(null);
const [composerSessionId, setComposerSessionId] = useState(0);
const suppressComposerStateSaveSessionRef = useRef<number | null>(null);
const { dialogProps: confirmDialogProps, confirm: confirmDialog } = useConfirmDialog();
const { dialogProps: promptDialogProps, prompt: promptDialog } = usePromptDialog();
const { showAppsModal, inlineApp, loadedApps, handleManageApps, handleInlineApp, closeInlineApp, closeAppsModal } = useSidebarApps();
@@ -89,8 +95,10 @@ export default function Home() {
const [isLoadingConversation, setIsLoadingConversation] = useState(false);
const [rateLimitSecondsLeft, setRateLimitSecondsLeft] = useState<number | null>(null);
const [previewAttachment, setPreviewAttachment] = useState<{ blobId: string; name: string; type?: string } | null>(null);
const [pendingMailtoAccountChoice, setPendingMailtoAccountChoice] = useState<ParsedMailto | null>(null);
const [isProtocolAccountSwitching, setIsProtocolAccountSwitching] = useState(false);
const markAsReadTimeoutRef = useRef<NodeJS.Timeout | null>(null);
const { isAuthenticated, client, logout, checkAuth, isLoading: authLoading, connectionLost, isRateLimited, rateLimitUntil } = useAuthStore();
const { isAuthenticated, client, logout, checkAuth, switchAccount, activeAccountId, isLoading: authLoading, connectionLost, isRateLimited, rateLimitUntil } = useAuthStore();
const { identities } = useIdentityStore();
useIdentitySync();
const trustedSendersAddressBook = useSettingsStore((state) => state.trustedSendersAddressBook);
@@ -308,6 +316,13 @@ export default function Home() {
[],
);
const getMailtoProtocolAccounts = useCallback(() => {
const connectedClients = useAuthStore.getState().getAllConnectedClients();
return useAccountStore.getState().accounts.filter((account) =>
account.isConnected && connectedClients.has(account.id)
);
}, []);
// Browser back / forward integration. The restore handler reads the
// latest values from a ref so we don't have to recreate the callback on
// every render (and so the popstate listener is never stale).
@@ -651,6 +666,74 @@ export default function Home() {
}
}, [initialCheckDone, isAuthenticated, authLoading]);
const openMailtoDraft = useCallback((pending: ParsedMailto) => {
const body = useSettingsStore.getState().plainTextMode
? pending.body
: plainTextToComposerBody(pending.body);
if (showComposer) {
suppressComposerStateSaveSessionRef.current = composerSessionId;
}
setComposerSessionId((id) => id + 1);
setPendingDraft({
to: pending.to.join(", "),
cc: pending.cc.join(", "),
bcc: pending.bcc.join(", "),
subject: pending.subject,
body,
showCc: pending.cc.length > 0,
showBcc: pending.bcc.length > 0,
selectedIdentityId: null,
subAddressTag: "",
mode: "compose",
draftId: null,
});
setComposerMode("compose");
setShowComposer(true);
if (isMobile) setActiveView("viewer");
}, [composerSessionId, isMobile, setActiveView, showComposer]);
const openMailtoForAccount = useCallback(async (pending: ParsedMailto, accountId: string) => {
setIsProtocolAccountSwitching(true);
try {
if (useAuthStore.getState().activeAccountId !== accountId) {
await switchAccount(accountId);
}
setPendingMailtoAccountChoice(null);
openMailtoDraft(pending);
} finally {
setIsProtocolAccountSwitching(false);
}
}, [openMailtoDraft, switchAccount]);
const handleMailtoProtocolRequest = useCallback((pending: ParsedMailto) => {
const protocolAccounts = getMailtoProtocolAccounts();
if (protocolAccounts.length > 1) {
setPendingMailtoAccountChoice(pending);
return;
}
const accountId = protocolAccounts[0]?.id ?? activeAccountId;
if (accountId) {
void openMailtoForAccount(pending, accountId);
return;
}
openMailtoDraft(pending);
}, [activeAccountId, getMailtoProtocolAccounts, openMailtoDraft, openMailtoForAccount]);
useEffect(() => {
if (!isAuthenticated || !client) return;
const openPendingMailto = () => {
const pending = consumePendingMailto();
if (pending) handleMailtoProtocolRequest(pending);
};
openPendingMailto();
return subscribeToPendingMailto(openPendingMailto);
}, [isAuthenticated, client, handleMailtoProtocolRequest]);
// Fallback fetch for paths that didn't go through login()'s prefetch
// (notably checkAuth on page refresh). The prefetch in auth-store/login()
// populates mailboxes before this effect first runs, so on the post-login
@@ -1659,7 +1742,9 @@ export default function Home() {
// Append signature from the sending identity (fall back to primary
// when the reply-from lives on the same identity but a different alias).
const finalBody = appendPlainTextSignature(body, sendingIdentity);
const finalBody = appendPlainTextSignature(body, sendingIdentity, {
separator: useSettingsStore.getState().signatureSeparatorEnabled,
});
const originalEmailId = selectedEmail.id;
@@ -1875,7 +1960,8 @@ export default function Home() {
return (
<DragDropProvider>
<div className="flex flex-col h-dvh bg-background overflow-hidden">
<div className="flex flex-col h-dvh bg-background overflow-hidden pt-[env(safe-area-inset-top)]">
<AppTopBannerSlot />
{isRateLimited && rateLimitSecondsLeft !== null && (
<div className="flex items-center justify-center gap-2 bg-amber-500/10 border-b border-amber-500/30 text-amber-700 dark:text-amber-300 text-sm py-1.5 px-4 flex-shrink-0">
<AlertTriangle className="h-3.5 w-3.5" />
@@ -1925,7 +2011,7 @@ export default function Home() {
"flex-shrink-0 h-full z-50",
!isResizing && "transition-[width] duration-300",
// Mobile/Tablet: fixed overlay
"max-lg:fixed max-lg:inset-y-0 max-lg:left-0 max-lg:w-72",
"max-lg:fixed max-lg:inset-y-0 max-lg:left-0 max-lg:w-72 max-lg:pt-[env(safe-area-inset-top)]",
"max-lg:transform max-lg:transition-transform max-lg:duration-300 max-lg:ease-in-out",
!sidebarOpen && "max-lg:-translate-x-full",
// Desktop: normal flow
@@ -2334,7 +2420,7 @@ export default function Home() {
isHorizontalMailLayout ? "min-h-0" : "h-full",
// Mobile: full screen overlay when active
"max-md:fixed max-md:inset-0 max-md:z-30",
"max-md:h-full",
"max-md:h-full max-md:pt-[env(safe-area-inset-top)]",
isMobile && activeView !== "viewer" && "max-md:hidden",
// Tablet/Desktop: relative
"md:relative",
@@ -2371,7 +2457,13 @@ export default function Home() {
} : undefined)}
initialDraftText={composerDraftText}
initialData={pendingDraft}
onSaveState={(data) => setPendingDraft(data)}
onSaveState={(data) => {
if (suppressComposerStateSaveSessionRef.current === composerSessionId) {
suppressComposerStateSaveSessionRef.current = null;
return;
}
setPendingDraft(data);
}}
onSend={async (data) => {
await handleEmailSend(data);
setPendingDraft(null);
@@ -2526,6 +2618,17 @@ export default function Home() {
<div className="sr-only" aria-live="polite" aria-atomic="true" id="sr-status" />
<SidebarAppsModal isOpen={showAppsModal} onClose={closeAppsModal} />
{pendingMailtoAccountChoice && (
<ProtocolAccountPicker
kind="mailto"
operation={pendingMailtoAccountChoice}
accounts={getMailtoProtocolAccounts()}
activeAccountId={activeAccountId}
isSwitching={isProtocolAccountSwitching}
onSelect={(accountId) => void openMailtoForAccount(pendingMailtoAccountChoice, accountId)}
onCancel={() => setPendingMailtoAccountChoice(null)}
/>
)}
<ConfirmDialog {...confirmDialogProps} />
<PromptDialog {...promptDialogProps} />
<TotpReauthDialog />
+18 -4
View File
@@ -26,6 +26,7 @@ import {
Bell,
Puzzle,
LayoutGrid,
Link as LinkIcon,
BookOpen,
PenLine,
EyeOff,
@@ -38,6 +39,7 @@ import {
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { AppearanceSettings } from '@/components/settings/appearance-settings';
import { AppTopBannerSlot } from '@/components/plugins/app-top-banner-slot';
import { LayoutSettings } from '@/components/settings/layout-settings';
import { LanguageSettings } from '@/components/settings/language-settings';
import { ReadingSettings } from '@/components/settings/reading-settings';
@@ -63,6 +65,7 @@ import { SidebarAppsSettings } from '@/components/settings/sidebar-apps-settings
import { NotificationSettings } from '@/components/settings/notification-settings';
import { ThemesSettings } from '@/components/settings/themes-settings';
import { PluginsSettings } from '@/components/settings/plugins-settings';
import { ProtocolHandlerSettings } from '@/components/settings/protocol-handler-settings';
import { useAuthStore, redirectToLogin } from '@/stores/auth-store';
import { useEmailStore } from '@/stores/email-store';
import { usePluginStore } from '@/stores/plugin-store';
@@ -98,6 +101,7 @@ type Tab =
| 'calendar'
| 'contacts'
| 'files'
| 'protocol_handlers'
| 'sidebar_apps'
| 'about_data'
| 'themes'
@@ -133,6 +137,7 @@ const tabIcons: Record<Tab, LucideIcon> = {
calendar: Calendar,
contacts: BookUser,
files: HardDrive,
protocol_handlers: LinkIcon,
sidebar_apps: PanelLeftClose,
about_data: Info,
themes: Palette,
@@ -211,6 +216,7 @@ const tabSearchPaths: Record<Tab, string[]> = {
calendar: ['calendar.settings', 'calendar.management'],
contacts: ['settings.contacts', 'contacts'],
files: ['settings.files'],
protocol_handlers: ['protocol_handlers'],
sidebar_apps: ['settings.sidebar_apps', 'sidebar_apps'],
about_data: ['settings.advanced'],
themes: [],
@@ -240,6 +246,7 @@ const tabKeywords: Record<Tab, string> = {
calendar: 'event schedule appointment meeting timezone',
contacts: 'address book contact',
files: 'attachments cloud drive storage upload',
protocol_handlers: 'mailto webcal links default app protocol handler',
sidebar_apps: 'apps webview iframe',
about_data: 'export import storage quota privacy backup',
themes: 'custom theme css skin appearance',
@@ -560,6 +567,7 @@ export default function SettingsPage() {
{ id: 'account', label: t('tabs.account'), icon: tabIcons.account, group: 'general' },
{ id: 'language', label: t('tabs.language'), icon: tabIcons.language, group: 'general' },
{ id: 'notifications', label: t('tabs.notifications'), icon: tabIcons.notifications, group: 'general' },
{ id: 'protocol_handlers', label: t('tabs.protocol_handlers'), icon: tabIcons.protocol_handlers, group: 'general' },
// Appearance
{ id: 'appearance', label: t('tabs.appearance'), icon: tabIcons.appearance, group: 'appearance' },
@@ -583,7 +591,7 @@ export default function SettingsPage() {
// Apps
...(supportsCalendar ? [{ id: 'calendar' as Tab, label: t('tabs.calendar'), icon: tabIcons.calendar, group: 'apps' as TabGroup }] : []),
{ id: 'contacts', label: t('tabs.contacts'), icon: tabIcons.contacts, group: 'apps' },
...(supportsFiles ? [{ id: 'files' as Tab, label: t('tabs.files'), icon: tabIcons.files, 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 }] : []),
// Advanced
@@ -666,6 +674,7 @@ export default function SettingsPage() {
{effectiveActiveTab === 'calendar' && <><CalendarSettings /><div className="mt-8"><CalendarManagementSettings /></div></>}
{effectiveActiveTab === 'contacts' && <><ContactsSettings /><div className="mt-8"><AddressBookManagementSettings /></div></>}
{effectiveActiveTab === 'files' && <FilesSettingsComponent />}
{effectiveActiveTab === 'protocol_handlers' && <ProtocolHandlerSettings supportsCalendar={supportsCalendar} />}
{effectiveActiveTab === 'sidebar_apps' && <SidebarAppsSettings />}
{effectiveActiveTab === 'about_data' && <AboutDataSettings />}
{effectiveActiveTab === 'themes' && <ThemesSettings />}
@@ -678,7 +687,8 @@ export default function SettingsPage() {
if (!isDesktop) {
if (mobileShowContent) {
return (
<div className="flex flex-col h-dvh bg-background">
<div className="flex flex-col h-dvh bg-background pt-[env(safe-area-inset-top)]">
<AppTopBannerSlot />
<div className="flex items-center gap-2 px-4 h-14 border-b border-border bg-background shrink-0">
<Button
variant="ghost"
@@ -708,7 +718,8 @@ export default function SettingsPage() {
}
return (
<div className="flex flex-col h-dvh bg-background">
<div className="flex flex-col h-dvh bg-background pt-[env(safe-area-inset-top)]">
<AppTopBannerSlot />
<div className="flex items-center gap-2 px-4 h-14 border-b border-border bg-background shrink-0">
<Button
variant="ghost"
@@ -818,7 +829,9 @@ export default function SettingsPage() {
// Desktop layout
return (
<div className="flex h-dvh bg-background">
<div className="flex flex-col h-dvh bg-background pt-[env(safe-area-inset-top)]">
<AppTopBannerSlot />
<div className="flex flex-1 min-h-0">
<div className="w-14 bg-secondary flex flex-col flex-shrink-0" style={{ borderRight: '1px solid rgba(128, 128, 128, 0.3)' }}>
<NavigationRail
collapsed
@@ -950,6 +963,7 @@ export default function SettingsPage() {
</>
)}
<SidebarAppsModal isOpen={showAppsModal} onClose={closeAppsModal} />
</div>
</div>
);
}
+6 -2
View File
@@ -5,8 +5,12 @@ import { Save, Loader2, RotateCcw, Sparkles } from 'lucide-react';
import { apiFetch } from '@/lib/browser-navigation';
interface ConfigEntry {
value: unknown;
// Sensitive keys (sessionSecret, oauthClientSecret) come back with
// `value` omitted and `hasValue` set instead — the server never echoes
// the raw secret to the client.
value?: unknown;
source: 'admin' | 'env' | 'default';
hasValue?: boolean;
}
export function AuthTab() {
@@ -267,7 +271,7 @@ export function AuthTab() {
<Toggle label="OAuth Enabled" configKey="oauthEnabled" value={currentValue('oauthEnabled') as boolean} source={config.oauthEnabled?.source} onChange={handleChange} onRevert={handleRevert} />
<Toggle label="OAuth Only" description="Hide password login form when enabled" configKey="oauthOnly" value={currentValue('oauthOnly') as boolean} source={config.oauthOnly?.source} onChange={handleChange} onRevert={handleRevert} />
<Text label="OAuth Client ID" configKey="oauthClientId" value={currentValue('oauthClientId') as string} source={config.oauthClientId?.source} onChange={handleChange} onRevert={handleRevert} />
<Text label="OAuth Client Secret" configKey="oauthClientSecret" value={currentValue('oauthClientSecret') as string} source={config.oauthClientSecret?.source} onChange={handleChange} onRevert={handleRevert} type="password" />
<Text label="OAuth Client Secret" configKey="oauthClientSecret" value={currentValue('oauthClientSecret') as string} source={config.oauthClientSecret?.source} onChange={handleChange} onRevert={handleRevert} type="password" placeholder={config.oauthClientSecret?.hasValue ? '•••••••• (saved — type to replace)' : undefined} />
<Text label="OAuth Issuer URL" configKey="oauthIssuerUrl" value={currentValue('oauthIssuerUrl') as string} source={config.oauthIssuerUrl?.source} onChange={handleChange} onRevert={handleRevert} placeholder="https://auth.example.com" />
</Section>
+2 -1
View File
@@ -5,8 +5,9 @@ import { Save, Loader2, RotateCcw, ImageIcon, Upload, Trash2 } from 'lucide-reac
import { apiFetch } from '@/lib/browser-navigation';
interface ConfigEntry {
value: unknown;
value?: unknown;
source: 'admin' | 'env' | 'default';
hasValue?: boolean;
}
const IMAGE_FIELDS = [
+4 -2
View File
@@ -26,7 +26,7 @@ export function DashboardTab() {
const [status, setStatus] = useState<AdminStatus | null>(null);
const [recentActivity, setRecentActivity] = useState<AuditEntry[]>([]);
const [config, setConfig] = useState<ConfigData | null>(null);
const [, setConfigSources] = useState<Record<string, { value: unknown; source: string }> | null>(null);
const [, setConfigSources] = useState<Record<string, { value?: unknown; source: string; hasValue?: boolean }> | null>(null);
const [warnings, setWarnings] = useState<string[]>([]);
const [pluginCount, setPluginCount] = useState(0);
const [themeCount, setThemeCount] = useState(0);
@@ -96,7 +96,9 @@ export function DashboardTab() {
const sources = await adminConfigRes.json();
setConfigSources(sources);
const sessionSecret = sources?.sessionSecret;
if (!sessionSecret?.value || sessionSecret.value === 'your-secret-key-here') {
// Server redacts the raw value for sensitive keys; rely on hasValue,
// which is false when unset or matching a known placeholder default.
if (!sessionSecret?.hasValue) {
w.push('SESSION_SECRET is not set or using a default value. Sessions are insecure.');
}
const adminPassword = sources?.adminPassword;
+22 -2
View File
@@ -2,8 +2,11 @@
import { useEffect, useState, useCallback } from 'react';
import Link from 'next/link';
import { Search, Download, Check, Loader2, Store, Puzzle, SwatchBook, Star, Eye } from 'lucide-react';
import { Search, Download, Check, Loader2, Store, Puzzle, SwatchBook, Star, Eye, AlertTriangle } from 'lucide-react';
import { apiFetch } from '@/lib/browser-navigation';
import { isVersionSatisfied } from '@/lib/version-compare';
const CURRENT_APP_VERSION = process.env.NEXT_PUBLIC_APP_VERSION || '0.0.0';
interface Extension {
slug: string;
@@ -94,6 +97,13 @@ export function MarketplaceTab() {
}, [searchInput]);
async function handleInstall(ext: Extension) {
if (ext.minAppVersion && !isVersionSatisfied(CURRENT_APP_VERSION, ext.minAppVersion)) {
setMessage({
type: 'error',
text: `"${ext.name}" requires app v${ext.minAppVersion}+. You are running v${CURRENT_APP_VERSION}.`,
});
return;
}
setInstalling(ext.slug);
setMessage(null);
@@ -258,6 +268,8 @@ function ExtensionCard({
}) {
const isPlugin = extension.type === 'plugin';
const previewHref = `/admin/marketplace/${encodeURIComponent(extension.slug)}`;
const versionMismatch = !!extension.minAppVersion
&& !isVersionSatisfied(CURRENT_APP_VERSION, extension.minAppVersion);
return (
<div className="group relative border border-border rounded-lg overflow-hidden hover:border-ring/30 transition-colors">
@@ -346,12 +358,20 @@ function ExtensionCard({
</div>
</Link>
<div className="px-4 pb-4 -mt-1">
<div className="px-4 pb-4 -mt-1 flex items-center gap-2 flex-wrap">
{extension.installed ? (
<span className="inline-flex items-center gap-1 h-7 px-2.5 rounded-md bg-emerald-100 text-emerald-700 dark:bg-emerald-950/30 dark:text-emerald-400 text-xs font-medium">
<Check className="w-3 h-3" />
Installed
</span>
) : versionMismatch ? (
<span
className="inline-flex items-center gap-1 h-7 px-2.5 rounded-md bg-amber-100 text-amber-800 dark:bg-amber-950/30 dark:text-amber-300 text-xs font-medium"
title={`Requires app v${extension.minAppVersion}+. You are running v${CURRENT_APP_VERSION}.`}
>
<AlertTriangle className="w-3 h-3" />
Requires v{extension.minAppVersion}+
</span>
) : (
<button
onClick={(e) => { e.preventDefault(); e.stopPropagation(); onInstall(); }}
+2 -1
View File
@@ -7,8 +7,9 @@ import { JmapServersSection } from './_jmap-servers-section';
import type { JmapServerEntry } from '@/lib/admin/jmap-servers';
interface ConfigEntry {
value: unknown;
value?: unknown;
source: 'admin' | 'env' | 'default';
hasValue?: boolean;
}
export function SettingsTab() {
+36 -24
View File
@@ -27,11 +27,12 @@ import {
} from 'lucide-react';
import { cn } from '@/lib/utils';
import { useConfig } from '@/hooks/use-config';
import { usePolicyStore } from '@/stores/policy-store';
import { useThemeStore } from '@/stores/theme-store';
import { getActiveAccountSlotHeaders } from '@/lib/auth/active-account-slot';
import { useUpdateStore, selectHasUpdate } from '@/stores/update-store';
import { apiFetch } from '@/lib/browser-navigation';
import { apiFetch, getPathPrefix } from '@/lib/browser-navigation';
// Single-page tab navigation: clicks update a Zustand store. The URL stays
// at /admin so React doesn't fire a route transition on every tab switch -
@@ -87,6 +88,7 @@ export default function AdminLayout({ children }: { children: React.ReactNode })
const [isStalwartAdmin, setIsStalwartAdmin] = useState(false);
const [mobileNavOpen, setMobileNavOpen] = useState(false);
const { appLogoLightUrl, appLogoDarkUrl, loginLogoLightUrl, loginLogoDarkUrl } = useConfig();
const filesEnabled = usePolicyStore((s) => s.isFeatureEnabled('filesEnabled'));
const resolvedTheme = useThemeStore((s) => s.resolvedTheme);
const logoUrl = resolvedTheme === 'dark'
? (appLogoDarkUrl || appLogoLightUrl || loginLogoDarkUrl)
@@ -177,6 +179,12 @@ export default function AdminLayout({ children }: { children: React.ReactNode })
return <>{children}</>;
}
// /admin lives outside the [locale] tree, so links back to the webmail
// apps are bare <a> tags (hard navigation). Next.js only auto-applies
// basePath to <Link>/router APIs - for these we prepend it manually so
// NEXT_PUBLIC_BASE_PATH=/webmail deployments don't redirect to "/".
const prefix = getPathPrefix();
const navContent = (
<>
<div className="flex-1 overflow-y-auto py-2">
@@ -274,39 +282,41 @@ export default function AdminLayout({ children }: { children: React.ReactNode })
<div className="w-7 h-7 mb-2" />
)}
<a
href="/"
href={`${prefix}/`}
className="flex items-center justify-center w-10 h-10 rounded-md transition-colors text-muted-foreground hover:text-foreground hover:bg-muted"
title="Mail"
>
<Mail className="w-[18px] h-[18px]" />
</a>
<a
href="/calendar"
href={`${prefix}/calendar`}
className="flex items-center justify-center w-10 h-10 rounded-md transition-colors text-muted-foreground hover:text-foreground hover:bg-muted"
title="Calendar"
>
<Calendar className="w-[18px] h-[18px]" />
</a>
<a
href="/contacts"
href={`${prefix}/contacts`}
className="flex items-center justify-center w-10 h-10 rounded-md transition-colors text-muted-foreground hover:text-foreground hover:bg-muted"
title="Contacts"
>
<BookUser className="w-[18px] h-[18px]" />
</a>
<a
href="/files"
className="flex items-center justify-center w-10 h-10 rounded-md transition-colors text-muted-foreground hover:text-foreground hover:bg-muted"
title="Files"
>
<HardDrive className="w-[18px] h-[18px]" />
</a>
{filesEnabled && (
<a
href={`${prefix}/files`}
className="flex items-center justify-center w-10 h-10 rounded-md transition-colors text-muted-foreground hover:text-foreground hover:bg-muted"
title="Files"
>
<HardDrive className="w-[18px] h-[18px]" />
</a>
)}
<div className="mt-auto flex flex-col items-center gap-2">
<div className="flex items-center justify-center w-10 h-10 rounded-md bg-primary/10 text-primary" title="Admin">
<Shield className="w-[18px] h-[18px]" />
</div>
<a
href="/settings"
href={`${prefix}/settings`}
className="flex items-center justify-center w-10 h-10 rounded-md transition-colors text-muted-foreground hover:text-foreground hover:bg-muted"
title="Settings"
>
@@ -411,7 +421,7 @@ export default function AdminLayout({ children }: { children: React.ReactNode })
aria-label="Main navigation"
>
<a
href="/"
href={`${prefix}/`}
className="flex flex-col items-center justify-center gap-1 py-2 px-1 min-h-[44px] grow shrink-0 basis-[64px] transition-colors duration-150 text-muted-foreground hover:text-foreground"
title="Mail"
>
@@ -419,7 +429,7 @@ export default function AdminLayout({ children }: { children: React.ReactNode })
<span className="text-[10px] font-medium leading-tight truncate max-w-full">Mail</span>
</a>
<a
href="/calendar"
href={`${prefix}/calendar`}
className="flex flex-col items-center justify-center gap-1 py-2 px-1 min-h-[44px] grow shrink-0 basis-[64px] transition-colors duration-150 text-muted-foreground hover:text-foreground"
title="Calendar"
>
@@ -427,21 +437,23 @@ export default function AdminLayout({ children }: { children: React.ReactNode })
<span className="text-[10px] font-medium leading-tight truncate max-w-full">Calendar</span>
</a>
<a
href="/contacts"
href={`${prefix}/contacts`}
className="flex flex-col items-center justify-center gap-1 py-2 px-1 min-h-[44px] grow shrink-0 basis-[64px] transition-colors duration-150 text-muted-foreground hover:text-foreground"
title="Contacts"
>
<BookUser className="w-5 h-5" />
<span className="text-[10px] font-medium leading-tight truncate max-w-full">Contacts</span>
</a>
<a
href="/files"
className="flex flex-col items-center justify-center gap-1 py-2 px-1 min-h-[44px] grow shrink-0 basis-[64px] transition-colors duration-150 text-muted-foreground hover:text-foreground"
title="Files"
>
<HardDrive className="w-5 h-5" />
<span className="text-[10px] font-medium leading-tight truncate max-w-full">Files</span>
</a>
{filesEnabled && (
<a
href={`${prefix}/files`}
className="flex flex-col items-center justify-center gap-1 py-2 px-1 min-h-[44px] grow shrink-0 basis-[64px] transition-colors duration-150 text-muted-foreground hover:text-foreground"
title="Files"
>
<HardDrive className="w-5 h-5" />
<span className="text-[10px] font-medium leading-tight truncate max-w-full">Files</span>
</a>
)}
<div
className="flex flex-col items-center justify-center gap-1 py-2 px-1 min-h-[44px] grow shrink-0 basis-[64px] text-primary"
title="Admin"
@@ -454,7 +466,7 @@ export default function AdminLayout({ children }: { children: React.ReactNode })
<span className="text-[10px] font-medium leading-tight truncate max-w-full">Admin</span>
</div>
<a
href="/settings"
href={`${prefix}/settings`}
className="flex flex-col items-center justify-center gap-1 py-2 px-1 min-h-[44px] grow shrink-0 basis-[64px] transition-colors duration-150 text-muted-foreground hover:text-foreground"
title="Settings"
>
+21 -2
View File
@@ -21,6 +21,9 @@ import {
ChevronUp,
} from 'lucide-react';
import { apiFetch } from '@/lib/browser-navigation';
import { isVersionSatisfied } from '@/lib/version-compare';
const CURRENT_APP_VERSION = process.env.NEXT_PUBLIC_APP_VERSION || '0.0.0';
interface PreviewData {
extension: {
@@ -200,6 +203,7 @@ export default function MarketplacePreviewPage() {
const manifestPerms = (bundle.manifest?.permissions as string[] | undefined) || ext.permissions || [];
const frameOrigins = (bundle.manifest?.frameOrigins as string[] | undefined) || [];
const settingsSchema = bundle.manifest?.settingsSchema as Record<string, { type: string; label: string; description?: string; default?: unknown }> | undefined;
const versionMismatch = !!ext.minAppVersion && !isVersionSatisfied(CURRENT_APP_VERSION, ext.minAppVersion);
return (
<div className="space-y-6 max-w-4xl">
@@ -294,8 +298,11 @@ export default function MarketplacePreviewPage() {
) : (
<button
onClick={handleInstall}
disabled={installing || !!bundle.error}
className="inline-flex items-center gap-1.5 h-9 px-4 rounded-md bg-primary text-primary-foreground text-sm font-medium hover:bg-primary/90 disabled:opacity-50 transition-colors"
disabled={installing || !!bundle.error || versionMismatch}
title={versionMismatch
? `Requires app v${ext.minAppVersion}+. You are running v${CURRENT_APP_VERSION}. Update Bulwark to install.`
: undefined}
className="inline-flex items-center gap-1.5 h-9 px-4 rounded-md bg-primary text-primary-foreground text-sm font-medium hover:bg-primary/90 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
{installing ? <Loader2 className="w-4 h-4 animate-spin" /> : <Download className="w-4 h-4" />}
Install
@@ -310,6 +317,18 @@ export default function MarketplacePreviewPage() {
</div>
)}
{versionMismatch && (
<div className="flex items-start gap-2 text-sm rounded-md px-3 py-2 bg-amber-50 text-amber-800 dark:bg-amber-950/30 dark:text-amber-300">
<AlertTriangle className="w-4 h-4 shrink-0 mt-0.5" />
<div>
<p className="font-medium">Update Bulwark to install this extension</p>
<p className="text-xs mt-0.5 opacity-90">
Requires app v{ext.minAppVersion}+. You are running v{CURRENT_APP_VERSION}.
</p>
</div>
</div>
)}
{bundle.error && (
<div className="flex items-start gap-2 text-sm rounded-md px-3 py-2 bg-amber-50 text-amber-800 dark:bg-amber-950/30 dark:text-amber-300">
<AlertTriangle className="w-4 h-4 shrink-0 mt-0.5" />
+25 -2
View File
@@ -2,12 +2,23 @@ import { NextRequest, NextResponse } from 'next/server';
import { configManager } from '@/lib/admin/config-manager';
import { requireAdminAuth, getClientIP } from '@/lib/admin/session';
import { auditLog } from '@/lib/admin/audit';
import { CONFIG_ENV_MAP } from '@/lib/admin/types';
import { CONFIG_ENV_MAP, SENSITIVE_CONFIG_KEYS } from '@/lib/admin/types';
import { parseJmapServers } from '@/lib/admin/jmap-servers';
import { logger } from '@/lib/logger';
// Strings that count as "no real secret configured" — used so the dashboard
// can warn about a placeholder session secret without us ever returning the
// raw value to the client.
const SENSITIVE_PLACEHOLDERS = new Set(['your-secret-key-here']);
/**
* GET /api/admin/config - Get full config with sources (admin-protected)
*
* Sensitive keys (sessionSecret, oauthClientSecret) are returned with
* `value` omitted and a `hasValue` boolean instead. An admin session is
* enough to read every other config knob; the secrets themselves stay on
* the server so that an XSS or session-theft can't lift them in one
* request and forge admin/user session cookies offline.
*/
export async function GET() {
try {
@@ -17,7 +28,19 @@ export async function GET() {
await configManager.ensureLoaded();
const config = configManager.getAllWithSources();
return NextResponse.json(config, {
const safe: Record<string, { value?: unknown; source: 'admin' | 'env' | 'default'; hasValue?: boolean }> = {};
for (const [key, entry] of Object.entries(config)) {
if (SENSITIVE_CONFIG_KEYS.has(key)) {
const v = entry.value;
const hasValue =
typeof v === 'string' && v.length > 0 && !SENSITIVE_PLACEHOLDERS.has(v);
safe[key] = { source: entry.source, hasValue };
} else {
safe[key] = entry;
}
}
return NextResponse.json(safe, {
headers: { 'Cache-Control': 'no-store' },
});
} catch (error) {
+6
View File
@@ -278,6 +278,12 @@ export async function POST(request: NextRequest) {
enabled: true,
installedAt: now,
updatedAt: now,
...(manifest.configSchema && typeof manifest.configSchema === 'object'
? { configSchema: manifest.configSchema as ServerPlugin['configSchema'] }
: {}),
...(manifest.settingsSchema && typeof manifest.settingsSchema === 'object'
? { settingsSchema: manifest.settingsSchema as ServerPlugin['settingsSchema'] }
: {}),
...(declaredFrameOrigins.length > 0
? { frameOrigins: declaredFrameOrigins }
: {}),
+141
View File
@@ -0,0 +1,141 @@
import { NextRequest, NextResponse } from 'next/server';
import { cookies } from 'next/headers';
import { logger } from '@/lib/logger';
import { encryptSession } from '@/lib/auth/crypto';
import { sessionCookieName } from '@/lib/auth/session-cookie';
import { getCookieOptions } from '@/lib/oauth/cookie-config';
import { normalizeJmapServerUrl } from '@/lib/auth/verify-jmap-auth';
import { setStalwartAuthContextInStore } from '@/lib/stalwart/auth-context';
import { recordLogin } from '@/lib/telemetry/login-tracker';
import {
ImpersonationJwtError,
impersonationReplayCache,
verifyImpersonationJwt,
} from '@/lib/impersonation/jwt';
import {
readImpersonationConfig,
resolveImpersonationServerUrl,
} from '@/lib/impersonation/master-config';
export const runtime = 'nodejs';
const IMPERSONATION_SLOT = 0;
/**
* Impersonation cookies deliberately omit Max-Age so the browser treats
* them as session cookies — the impersonated session ends when the user
* closes the browser, not 30 days later. Impersonation is a temporary
* support handoff; a normal password login is the only thing that should
* survive a browser restart.
*/
function impersonationCookieOptions() {
const { maxAge: _maxAge, ...rest } = getCookieOptions();
return rest;
}
/**
* GET /api/auth/impersonate?token=<jwt>
*
* Master-user impersonation via signed JWT. The token carries the target
* mailbox; Bulwark verifies the signature, resolves the configured Stalwart
* master credentials from env, then mints the same session cookies the
* password-login path produces. The browser is redirected to "/" and the
* SPA hydrates as if the user had just logged in with master@target%master.
*
* Returns 404 when the feature is not configured so an unconfigured
* deployment does not advertise the endpoint.
*/
export async function GET(request: NextRequest) {
const config = readImpersonationConfig();
if (!config) {
// Not configured — behave exactly like an unknown route.
return new NextResponse('Not found', { status: 404 });
}
const token = request.nextUrl.searchParams.get('token');
if (!token) {
return NextResponse.json({ error: 'Missing token' }, { status: 400 });
}
let claims;
try {
claims = verifyImpersonationJwt(token, config.jwtSecret, {
expectedIssuer: config.expectedIssuer,
});
} catch (err) {
if (err instanceof ImpersonationJwtError) {
logger.warn('Impersonation JWT rejected', { code: err.code });
return NextResponse.json({ error: err.message }, { status: err.status });
}
logger.error('Impersonation JWT error', {
error: err instanceof Error ? err.message : 'Unknown',
});
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
}
if (!impersonationReplayCache.consume(claims.jti, claims.exp)) {
logger.warn('Impersonation JWT replay rejected', { jti: claims.jti });
return NextResponse.json({ error: 'Token already used' }, { status: 401 });
}
const serverUrl = await resolveImpersonationServerUrl();
if (!serverUrl) {
logger.error('Impersonation requested but jmapServerUrl is not configured');
return NextResponse.json({ error: 'JMAP server not configured' }, { status: 500 });
}
let normalizedServerUrl: string;
try {
normalizedServerUrl = normalizeJmapServerUrl(serverUrl);
} catch {
return NextResponse.json({ error: 'Invalid JMAP server URL' }, { status: 500 });
}
// Stalwart master-user impersonation: username = "<target>%<master>",
// password = <master_password>. Per Stalwart docs:
// https://stalw.art/docs/auth/authorization/administrator/
const impersonatedUsername = `${claims.mailbox}%${config.masterUser}`;
const authHeader = `Basic ${Buffer.from(
`${impersonatedUsername}:${config.masterPassword}`,
).toString('base64')}`;
const cookieStore = await cookies();
const sessionToken = encryptSession(
normalizedServerUrl,
impersonatedUsername,
config.masterPassword,
);
cookieStore.set(sessionCookieName(IMPERSONATION_SLOT), sessionToken, impersonationCookieOptions());
setStalwartAuthContextInStore(cookieStore, IMPERSONATION_SLOT, {
serverUrl: normalizedServerUrl,
username: impersonatedUsername,
authHeader,
});
// Structured audit log — operators rely on this for security review.
logger.info('Impersonation session granted', {
event: 'impersonation_granted',
jti: claims.jti,
mailbox: claims.mailbox,
tenant_id: claims.tenant_id,
actor_user_id: claims.actor_user_id,
iss: claims.iss,
ip:
request.headers.get('x-forwarded-for')?.split(',')[0]?.trim() ||
request.headers.get('x-real-ip') ||
null,
referer: request.headers.get('referer'),
user_agent: request.headers.get('user-agent'),
});
void recordLogin(impersonatedUsername, normalizedServerUrl);
// Use a relative Location header so the browser resolves it against the
// public request URL. NextResponse.redirect(new URL('/', request.url))
// would absolutise to the container's internal bind (http://0.0.0.0:3000)
// when running behind a reverse proxy that doesn't set X-Forwarded-Host.
return new NextResponse(null, {
status: 303,
headers: { Location: '/' },
});
}
+7 -5
View File
@@ -20,10 +20,12 @@ import { recordLogin } from '@/lib/telemetry/login-tracker';
import { parseJmapServers, resolveTrustedJmapUrl } from '@/lib/admin/jmap-servers';
import { MAX_ACCOUNT_SLOTS } from '@/lib/account-utils';
const COOKIE_OPTIONS = {
...getCookieOptions(),
maxAge: SESSION_COOKIE_MAX_AGE,
};
function sessionCookieOptions() {
return {
...getCookieOptions(),
maxAge: SESSION_COOKIE_MAX_AGE,
};
}
function getSlot(request: NextRequest): number {
const raw = request.nextUrl.searchParams.get('slot');
@@ -88,7 +90,7 @@ export async function POST(request: NextRequest) {
: await verifyJmapAuth(upstreamUrl, authHeader, { trusted: false });
const token = encryptSession(normalizedServerUrl, username, password);
const cookieStore = await cookies();
cookieStore.set(cookieName, token, COOKIE_OPTIONS);
cookieStore.set(cookieName, token, sessionCookieOptions());
setStalwartAuthContextInStore(cookieStore, slot, {
serverUrl: normalizedServerUrl,
username,
+2 -2
View File
@@ -5,7 +5,7 @@ import { encryptPayload } from '@/lib/auth/crypto';
import { generateCodeVerifierServer, generateCodeChallengeServer, generateStateServer } from '@/lib/oauth/pkce-server';
import { getRequiredConfig } from '@/lib/oauth/token-exchange';
import { discoverOAuth } from '@/lib/oauth/discovery';
import { OAUTH_SCOPES } from '@/lib/oauth/tokens';
import { getOauthScopes } from '@/lib/oauth/tokens';
import { getCookieOptions } from '@/lib/oauth/cookie-config';
import { hasSessionSecret } from '@/lib/auth/session-secret';
@@ -73,7 +73,7 @@ export async function POST(request: NextRequest) {
authUrl.searchParams.set('response_type', 'code');
authUrl.searchParams.set('client_id', clientId);
authUrl.searchParams.set('redirect_uri', redirect_uri);
authUrl.searchParams.set('scope', OAUTH_SCOPES);
authUrl.searchParams.set('scope', getOauthScopes());
authUrl.searchParams.set('state', state);
authUrl.searchParams.set('code_challenge', codeChallenge);
authUrl.searchParams.set('code_challenge_method', 'S256');
+1 -1
View File
@@ -60,7 +60,7 @@ export async function POST(request: NextRequest) {
// Trusted (admin-configured) URLs skip the upstream re-fetch: the caller
// just authenticated to JMAP with these credentials, and the cookie we
// write here is only ever consumed for requests on behalf of this same
// user a bogus auth header would just yield 401s downstream, not
// user - a bogus auth header would just yield 401s downstream, not
// privilege escalation. For untrusted custom endpoints we still verify
// upstream as before.
const normalizedServerUrl = upstreamTrusted
+2
View File
@@ -3,6 +3,7 @@ import { logger } from '@/lib/logger';
import { configManager } from '@/lib/admin/config-manager';
import { parseJmapServers, redactJmapServers } from '@/lib/admin/jmap-servers';
import { hasSessionSecret } from '@/lib/auth/session-secret';
import { getOauthScopes } from '@/lib/oauth/tokens';
/**
* Runtime configuration endpoint
@@ -35,6 +36,7 @@ export async function GET() {
oauthOnly,
oauthClientId: configManager.get<string>('oauthClientId', ''),
oauthIssuerUrl: configManager.get<string>('oauthIssuerUrl', ''),
oauthScopes: getOauthScopes(),
rememberMeEnabled: hasSessionSecret(),
settingsSyncEnabled: configManager.get<boolean>('settingsSyncEnabled', false) && hasSessionSecret(),
stalwartFeaturesEnabled,
+47 -21
View File
@@ -106,15 +106,15 @@ const emails: MockEmail[] = [
// =====================================================================
{
id: 'email-001', threadId: 'thread-001', mailboxIds: { 'mb-inbox': true }, keywords: {}, size: 4200, receivedAt: daysAgo(0),
from: [{ name: 'Sophie Müller', email: 'sophie@eurotech.example' }],
from: [{ name: 'Sophie Example', email: 'sophie@eurotech.example' }],
to: [{ name: 'Dev User', email: 'dev@localhost' }], cc: [],
subject: 'Willkommen bei Bulwark Webmail!',
preview: 'Hallo! This is a sample email to help you get started with the Bulwark Webmail development environment.',
preview: 'Hallo! Welcome to Bulwark - a modern, open-source webmail client for Stalwart Mail Server, built fresh on JMAP.',
hasAttachment: false,
textBody: [{ partId: 'p1', blobId: 'blob-001', size: 280, type: 'text/plain' }],
textBody: [{ partId: 'p1', blobId: 'blob-001', size: 2200, type: 'text/plain' }],
htmlBody: [],
bodyValues: {
p1: { value: 'Hallo!\n\nThis is a sample email to help you get started with the Bulwark Webmail development environment.\n\nFeel free to explore the UI - all data here is mock data.\n\nBeste Grüße,\nSophie' },
p1: { value: 'Hallo!\n\nWelcome to Bulwark - a modern, open-source webmail client for Stalwart Mail Server, built fresh on the JMAP protocol. No PHP, no 2008 architecture, no plugin-of-plugins archaeology; just clean TypeScript and Next.js, instant push, and a UI that feels like a native app instead of a Gmail polyfill.\n\nWhy JMAP matters: one TLS connection instead of long-polling, push notifications the moment new mail arrives, batched mutations so a click never waits on three round-trips, and threading stitched on the server rather than reassembled in the browser. The result is a webmail that feels quick on a flaky train Wi-Fi and quicker on fibre.\n\nMail, calendar, contacts, and files - everything Stalwart already serves, surfaced through a single window. Threaded inbox with full-text search and Sieve filters. Month, week, day and agenda views with recurring events and iMIP invitations. Multiple address books with vCard import and export. File previews backed by Stalwart\'s JMAP FileNode storage. S/MIME, templates, keyboard shortcuts, dark mode, dozens of languages - the boring stuff that should just work, working.\n\nTwo containers behind your reverse proxy of choice is all it takes to host it yourself: Stalwart for the server side, Bulwark for the client. Caddy, Traefik, nginx - pick one, there are working examples for each. Stalwart stays the source of truth, Bulwark is what you point your browser at, and the setup wizard handles the parts that would otherwise live in a config file.\n\nIt is AGPL, the codebase is small enough to read in an afternoon, and the extension directory already hosts a growing collection of plugins and themes. If something is missing, you can fork it, file an issue, or send a patch - a person will read it.\n\nBeste Grüße,\nSophie' },
},
},
{
@@ -197,7 +197,7 @@ const emails: MockEmail[] = [
id: 'email-014', threadId: 'thread-013', mailboxIds: { 'mb-inbox': true }, keywords: {}, size: 3400, receivedAt: hoursAgo(2),
from: [{ name: 'Lars Johansson', email: 'lars.johansson@fjord-systems.example' }],
to: [{ name: 'Dev User', email: 'dev@localhost' }],
cc: [{ name: 'Sophie Müller', email: 'sophie@eurotech.example' }, { name: 'Élise Moreau', email: 'elise.moreau@fjord-systems.example' }],
cc: [{ name: 'Sophie Example', email: 'sophie@eurotech.example' }, { name: 'Élise Moreau', email: 'elise.moreau@fjord-systems.example' }],
subject: 'Sprint planning - next week priorities',
preview: 'Hej team, here are the priorities for next sprint. Please review before our planning meeting tomorrow.',
hasAttachment: false,
@@ -367,7 +367,7 @@ const emails: MockEmail[] = [
},
{
id: 'email-026', threadId: 'thread-013', mailboxIds: { 'mb-inbox': true }, keywords: {}, size: 2400, receivedAt: hoursAgo(1),
from: [{ name: 'Sophie Müller', email: 'sophie@eurotech.example' }],
from: [{ name: 'Sophie Example', email: 'sophie@eurotech.example' }],
to: [{ name: 'Lars Johansson', email: 'lars.johansson@fjord-systems.example' }],
cc: [{ name: 'Dev User', email: 'dev@localhost' }, { name: 'Élise Moreau', email: 'elise.moreau@fjord-systems.example' }],
subject: 'Re: Sprint planning - next week priorities',
@@ -471,7 +471,7 @@ const emails: MockEmail[] = [
{
id: 'email-008', threadId: 'thread-007', mailboxIds: { 'mb-sent': true }, keywords: { $seen: true }, size: 3100, receivedAt: daysAgo(5),
from: [{ name: 'Dev User', email: 'dev@localhost' }],
to: [{ name: 'Sophie Müller', email: 'sophie@eurotech.example' }], cc: [],
to: [{ name: 'Sophie Example', email: 'sophie@eurotech.example' }], cc: [],
subject: 'Design review feedback',
preview: 'Hallo Sophie, I reviewed the new mockups and have a few suggestions.',
hasAttachment: false,
@@ -485,7 +485,7 @@ const emails: MockEmail[] = [
id: 'email-027', threadId: 'thread-013', mailboxIds: { 'mb-sent': true }, keywords: { $seen: true }, size: 1900, receivedAt: hoursAgo(0.5),
from: [{ name: 'Dev User', email: 'dev@localhost' }],
to: [{ name: 'Lars Johansson', email: 'lars.johansson@fjord-systems.example' }],
cc: [{ name: 'Sophie Müller', email: 'sophie@eurotech.example' }, { name: 'Élise Moreau', email: 'elise.moreau@fjord-systems.example' }],
cc: [{ name: 'Sophie Example', email: 'sophie@eurotech.example' }, { name: 'Élise Moreau', email: 'elise.moreau@fjord-systems.example' }],
subject: 'Re: Sprint planning - next week priorities',
preview: 'Great suggestions Sophie. 10:30 works for me. I\'ll update the calendar invite.',
hasAttachment: false,
@@ -639,7 +639,7 @@ const emails: MockEmail[] = [
},
{
id: 'email-012', threadId: 'thread-011', mailboxIds: { 'mb-archive': true }, keywords: { $seen: true, $flagged: true }, size: 2600, receivedAt: daysAgo(30),
from: [{ name: 'Sophie Müller', email: 'sophie@eurotech.example' }],
from: [{ name: 'Sophie Example', email: 'sophie@eurotech.example' }],
to: [{ name: 'Dev User', email: 'dev@localhost' }], cc: [],
subject: 'Conference talk accepted!',
preview: 'Toll! Your talk proposal for the JMAP Conf has been accepted!',
@@ -728,8 +728,8 @@ const IDENTITIES = [
email: 'dev@localhost',
replyTo: null,
bcc: null,
textSignature: '-- \nDev User\nBulwark Webmail Developer',
htmlSignature: '<p>--<br>Dev User<br><em>Bulwark Webmail Developer</em></p>',
textSignature: 'Dev User\nBulwark Webmail Developer',
htmlSignature: '<p>Dev User<br><em>Bulwark Webmail Developer</em></p>',
mayDelete: false,
},
];
@@ -743,6 +743,12 @@ const addressBooks = [
{ id: 'ab-2', name: 'Arbeit / Work', isDefault: false },
];
// Profile photos served straight from randomuser.me's CDN; the API at
// https://randomuser.me/api/ also returns these portrait URLs, but for a
// fixed mock dataset we link them directly to keep things offline-friendly.
// See https://randomuser.me/documentation#howto
const PORTRAIT = (gender: 'men' | 'women', n: number) => `https://randomuser.me/api/portraits/${gender}/${n}.jpg`;
const contacts = [
// --- Personal address book ---
{ id: 'contact-001', uid: 'urn:uuid:c0000001-0000-0000-0000-000000000001', addressBookIds: { 'ab-1': true }, kind: 'individual',
@@ -752,6 +758,7 @@ const contacts = [
organizations: { o1: { name: 'EuroTech GmbH' } },
addresses: { a1: { street: [{ value: 'Kurfürstendamm 42' }], locality: 'Berlin', region: '', country: 'Germany', postcode: '10719' } },
notes: { n1: { note: 'Frontend lead. Always brings Kuchen to the office.' } },
media: { photo1: { kind: 'photo' as const, uri: PORTRAIT('women', 14), mediaType: 'image/jpeg' } },
},
{ id: 'contact-002', uid: 'urn:uuid:c0000002-0000-0000-0000-000000000002', addressBookIds: { 'ab-1': true }, kind: 'individual',
name: { components: [{ kind: 'given', value: 'Pierre' }, { kind: 'surname', value: 'Dubois' }] },
@@ -760,6 +767,7 @@ const contacts = [
organizations: { o1: { name: 'Dubois Consulting' } },
addresses: { a1: { street: [{ value: '42 Rue de Rivoli' }], locality: 'Paris', country: 'France', postcode: '75001' } },
notes: { n1: { note: 'Product manager. Knows every boulangerie in Paris.' } },
media: { photo1: { kind: 'photo' as const, uri: PORTRAIT('men', 23), mediaType: 'image/jpeg' } },
},
{ id: 'contact-003', uid: 'urn:uuid:c0000003-0000-0000-0000-000000000003', addressBookIds: { 'ab-1': true }, kind: 'individual',
name: { components: [{ kind: 'given', value: 'Chiara' }, { kind: 'surname', value: 'Rossi' }] },
@@ -768,6 +776,7 @@ const contacts = [
organizations: { o1: { name: 'Rossi Design Studio' } },
addresses: { a1: { street: [{ value: 'Via Montenapoleone 8' }], locality: 'Milano', country: 'Italy', postcode: '20121' } },
notes: { n1: { note: 'UX designer. Her risotto recipes are legendary.' } },
media: { photo1: { kind: 'photo' as const, uri: PORTRAIT('women', 40), mediaType: 'image/jpeg' } },
},
{ id: 'contact-004', uid: 'urn:uuid:c0000004-0000-0000-0000-000000000004', addressBookIds: { 'ab-1': true }, kind: 'individual',
name: { components: [{ kind: 'given', value: 'Karel' }, { kind: 'surname', value: 'de Vries' }] },
@@ -775,6 +784,7 @@ const contacts = [
phones: { p1: { number: '+31 20 555 0142' } },
addresses: { a1: { street: [{ value: 'Herengracht 142' }], locality: 'Amsterdam', country: 'Netherlands', postcode: '1015 BN' } },
notes: { n1: { note: 'Backend developer. Cycles to work rain or shine - true Dutchman.' } },
media: { photo1: { kind: 'photo' as const, uri: PORTRAIT('men', 45), mediaType: 'image/jpeg' } },
},
{ id: 'contact-005', uid: 'urn:uuid:c0000005-0000-0000-0000-000000000005', addressBookIds: { 'ab-1': true }, kind: 'individual',
name: { components: [{ kind: 'given', value: 'Lars' }, { kind: 'surname', value: 'Johansson' }] },
@@ -783,6 +793,7 @@ const contacts = [
organizations: { o1: { name: 'Fjord Systems AB' } },
addresses: { a1: { street: [{ value: 'Drottninggatan 42' }], locality: 'Stockholm', country: 'Sweden', postcode: '111 51' } },
notes: { n1: { note: 'Tech lead. FIKA is sacred. Do not schedule meetings during fika.' } },
media: { photo1: { kind: 'photo' as const, uri: PORTRAIT('men', 61), mediaType: 'image/jpeg' } },
},
{ id: 'contact-006', uid: 'urn:uuid:c0000006-0000-0000-0000-000000000006', addressBookIds: { 'ab-1': true }, kind: 'individual',
name: { components: [{ kind: 'given', value: 'Élise' }, { kind: 'surname', value: 'Moreau' }] },
@@ -791,6 +802,7 @@ const contacts = [
organizations: { o1: { name: 'Fjord Systems AB' } },
addresses: { a1: { street: [{ value: '15 Boulevard Saint-Germain' }], locality: 'Paris', country: 'France', postcode: '75005' } },
notes: { n1: { note: 'Backend dev. Remote from Paris. Once fixed a production bug from a café terrace.' } },
media: { photo1: { kind: 'photo' as const, uri: PORTRAIT('women', 29), mediaType: 'image/jpeg' } },
},
{ id: 'contact-007', uid: 'urn:uuid:c0000007-0000-0000-0000-000000000007', addressBookIds: { 'ab-1': true }, kind: 'individual',
name: { components: [{ kind: 'given', value: 'Francesco' }, { kind: 'surname', value: 'Bianchi' }] },
@@ -798,6 +810,7 @@ const contacts = [
phones: { p1: { number: '+39 06 9876 5432' } },
addresses: { a1: { street: [{ value: 'Via dei Condotti 22' }], locality: 'Roma', country: 'Italy', postcode: '00187' } },
notes: { n1: { note: 'Old university friend. Once tried to implement RFC 2549 (IP over Avian Carriers) with actual pigeons. It did not scale.' } },
media: { photo1: { kind: 'photo' as const, uri: PORTRAIT('men', 72), mediaType: 'image/jpeg' } },
},
{ id: 'contact-008', uid: 'urn:uuid:c0000008-0000-0000-0000-000000000008', addressBookIds: { 'ab-1': true }, kind: 'individual',
name: { components: [{ kind: 'given', value: 'Astrid' }, { kind: 'surname', value: 'van der Berg' }] },
@@ -806,6 +819,7 @@ const contacts = [
organizations: { o1: { name: 'BergLabs' } },
addresses: { a1: { street: [{ value: 'Prinsengracht 263' }], locality: 'Amsterdam', country: 'Netherlands', postcode: '1016 GV' } },
notes: { n1: { note: 'Solutions architect. Her whiteboard diagrams belong in a museum.' } },
media: { photo1: { kind: 'photo' as const, uri: PORTRAIT('women', 58), mediaType: 'image/jpeg' } },
},
{ id: 'contact-009', uid: 'urn:uuid:c0000009-0000-0000-0000-000000000009', addressBookIds: { 'ab-1': true }, kind: 'individual',
name: { components: [{ kind: 'given', value: 'Henrik' }, { kind: 'surname', value: 'Nielsen' }] },
@@ -814,6 +828,7 @@ const contacts = [
organizations: { o1: { name: 'Nielsen Konsult' } },
addresses: { a1: { street: [{ value: 'Nyhavn 42' }], locality: 'København', country: 'Denmark', postcode: '1051' } },
notes: { n1: { note: 'Freelance DevOps. Speaks 5 languages. Kubernetes kubectl alias: k → kansen.' } },
media: { photo1: { kind: 'photo' as const, uri: PORTRAIT('men', 35), mediaType: 'image/jpeg' } },
},
{ id: 'contact-010', uid: 'urn:uuid:c0000010-0000-0000-0000-000000000010', addressBookIds: { 'ab-1': true }, kind: 'individual',
name: { components: [{ kind: 'given', value: 'Isabelle' }, { kind: 'surname', value: 'Martin' }] },
@@ -822,6 +837,7 @@ const contacts = [
organizations: { o1: { name: 'Sorbonne Université' } },
addresses: { a1: { street: [{ value: '21 Rue de l\'École de Médecine' }], locality: 'Paris', country: 'France', postcode: '75006' } },
notes: { n1: { note: 'Professor of computer science. Thesis on formal verification of email protocols.' } },
media: { photo1: { kind: 'photo' as const, uri: PORTRAIT('women', 63), mediaType: 'image/jpeg' } },
},
// --- Work address book ---
{ id: 'contact-011', uid: 'urn:uuid:c0000011-0000-0000-0000-000000000011', addressBookIds: { 'ab-2': true }, kind: 'individual',
@@ -831,6 +847,7 @@ const contacts = [
organizations: { o1: { name: 'Lefèvre & Associés' } },
addresses: { a1: { street: [{ value: '8 Avenue de l\'Opéra' }], locality: 'Paris', country: 'France', postcode: '75001' } },
notes: { n1: { note: 'Lawyer. Specializes in IP and tech law. Always replies within 42 minutes.' } },
media: { photo1: { kind: 'photo' as const, uri: PORTRAIT('men', 81), mediaType: 'image/jpeg' } },
},
{ id: 'contact-012', uid: 'urn:uuid:c0000012-0000-0000-0000-000000000012', addressBookIds: { 'ab-2': true }, kind: 'individual',
name: { components: [{ kind: 'given', value: 'Katrin' }, { kind: 'surname', value: 'Bauer' }] },
@@ -839,6 +856,7 @@ const contacts = [
organizations: { o1: { name: 'Charité Klinik Berlin' } },
addresses: { a1: { street: [{ value: 'Charitéplatz 1' }], locality: 'Berlin', country: 'Germany', postcode: '10117' } },
notes: { n1: { note: 'Medical center admin. Organizes the best team events in Berlin.' } },
media: { photo1: { kind: 'photo' as const, uri: PORTRAIT('women', 26), mediaType: 'image/jpeg' } },
},
{ id: 'contact-013', uid: 'urn:uuid:c0000013-0000-0000-0000-000000000013', addressBookIds: { 'ab-2': true }, kind: 'individual',
name: { components: [{ kind: 'given', value: 'Liam' }, { kind: 'surname', value: 'Ó Donaill' }] },
@@ -847,6 +865,7 @@ const contacts = [
organizations: { o1: { name: 'Finanz Dublin' } },
addresses: { a1: { street: [{ value: '42 St. Stephen\'s Green' }], locality: 'Dublin', country: 'Ireland', postcode: 'D02 HX65' } },
notes: { n1: { note: 'Finance lead. Can explain SEPA regulations over a pint of Guinness.' } },
media: { photo1: { kind: 'photo' as const, uri: PORTRAIT('men', 19), mediaType: 'image/jpeg' } },
},
{ id: 'contact-014', uid: 'urn:uuid:c0000014-0000-0000-0000-000000000014', addressBookIds: { 'ab-2': true }, kind: 'individual',
name: { components: [{ kind: 'given', value: 'María' }, { kind: 'surname', value: 'García' }] },
@@ -855,6 +874,7 @@ const contacts = [
organizations: { o1: { name: 'García Design Studio' } },
addresses: { a1: { street: [{ value: 'Calle Gran Vía 42' }], locality: 'Madrid', country: 'Spain', postcode: '28013' } },
notes: { n1: { note: 'Brand designer. Her color palettes are pure art. Siesta enthusiast.' } },
media: { photo1: { kind: 'photo' as const, uri: PORTRAIT('women', 50), mediaType: 'image/jpeg' } },
},
{ id: 'contact-015', uid: 'urn:uuid:c0000015-0000-0000-0000-000000000015', addressBookIds: { 'ab-2': true }, kind: 'individual',
name: { components: [{ kind: 'given', value: 'Nils' }, { kind: 'surname', value: 'Andersson' }] },
@@ -863,6 +883,7 @@ const contacts = [
organizations: { o1: { name: 'Digitaal BV' } },
addresses: { a1: { street: [{ value: 'Vijzelstraat 42' }], locality: 'Amsterdam', country: 'Netherlands', postcode: '1017 HK' } },
notes: { n1: { note: 'Platform engineer. fika buddy. Appreciates a good kanelbulle.' } },
media: { photo1: { kind: 'photo' as const, uri: PORTRAIT('men', 57), mediaType: 'image/jpeg' } },
},
{ id: 'contact-016', uid: 'urn:uuid:c0000016-0000-0000-0000-000000000016', addressBookIds: { 'ab-2': true }, kind: 'individual',
name: { components: [{ kind: 'given', value: 'Olivia' }, { kind: 'surname', value: 'Kowalska' }] },
@@ -871,6 +892,7 @@ const contacts = [
organizations: { o1: { name: 'Kowalska Marketing' } },
addresses: { a1: { street: [{ value: 'ul. Nowy Świat 42' }], locality: 'Warszawa', country: 'Poland', postcode: '00-363' } },
notes: { n1: { note: 'Marketing strategist. Her campaign analytics dashboards are works of art.' } },
media: { photo1: { kind: 'photo' as const, uri: PORTRAIT('women', 71), mediaType: 'image/jpeg' } },
},
{ id: 'contact-017', uid: 'urn:uuid:c0000017-0000-0000-0000-000000000017', addressBookIds: { 'ab-2': true }, kind: 'individual',
name: { components: [{ kind: 'given', value: 'Pádraig' }, { kind: 'surname', value: 'Murphy' }] },
@@ -879,6 +901,7 @@ const contacts = [
organizations: { o1: { name: 'Murphy Bau GmbH' } },
addresses: { a1: { street: [{ value: 'Grafton Street 42' }], locality: 'Dublin', country: 'Ireland', postcode: 'D02 R296' } },
notes: { n1: { note: 'Construction project manager. Irish-German bilingual. Builds things that last.' } },
media: { photo1: { kind: 'photo' as const, uri: PORTRAIT('men', 93), mediaType: 'image/jpeg' } },
},
{ id: 'contact-018', uid: 'urn:uuid:c0000018-0000-0000-0000-000000000018', addressBookIds: { 'ab-2': true }, kind: 'individual',
name: { components: [{ kind: 'given', value: 'Raquel' }, { kind: 'surname', value: 'Ferreira' }] },
@@ -887,6 +910,7 @@ const contacts = [
organizations: { o1: { name: 'Ferreira Media' } },
addresses: { a1: { street: [{ value: 'Rua Augusta 42' }], locality: 'Lisboa', country: 'Portugal', postcode: '1100-053' } },
notes: { n1: { note: 'Media consultant. Can turn any press release into poetry. Loves pastéis de nata.' } },
media: { photo1: { kind: 'photo' as const, uri: PORTRAIT('women', 82), mediaType: 'image/jpeg' } },
},
{ id: 'contact-019', uid: 'urn:uuid:c0000019-0000-0000-0000-000000000019', addressBookIds: { 'ab-2': true }, kind: 'individual',
name: { components: [{ kind: 'given', value: 'Sébastien' }, { kind: 'surname', value: 'Dumont' }] },
@@ -895,6 +919,7 @@ const contacts = [
organizations: { o1: { name: 'Dumont Conseil' } },
addresses: { a1: { street: [{ value: 'Avenue Louise 42' }], locality: 'Bruxelles', country: 'Belgium', postcode: '1050' } },
notes: { n1: { note: 'Strategy consultant. Knows the difference between Belgian and French chocolate. Will argue passionately about it.' } },
media: { photo1: { kind: 'photo' as const, uri: PORTRAIT('men', 4), mediaType: 'image/jpeg' } },
},
{ id: 'contact-020', uid: 'urn:uuid:c0000020-0000-0000-0000-000000000020', addressBookIds: { 'ab-2': true }, kind: 'individual',
name: { components: [{ kind: 'given', value: 'Annika' }, { kind: 'surname', value: 'Lindgren' }] },
@@ -904,6 +929,7 @@ const contacts = [
addresses: { a1: { street: [{ value: 'Strandvägen 42' }], locality: 'Stockholm', country: 'Sweden', postcode: '114 56' } },
nicknames: { n1: { name: 'Anni' } },
notes: { n1: { note: 'Independent consultant specializing in GDPR compliance. Yes, she has opinions about cookie banners.' } },
media: { photo1: { kind: 'photo' as const, uri: PORTRAIT('women', 36), mediaType: 'image/jpeg' } },
},
// --- Groups ---
{ id: 'contact-group-001', addressBookIds: { 'ab-1': true }, kind: 'group' as const,
@@ -976,7 +1002,7 @@ const calendarEvents = [
participants: {
p1: participant('Dev User', 'dev@localhost', 'owner'),
p2: participant('Lars Johansson', 'lars.johansson@fjord-systems.example'),
p3: participant('Sophie Müller', 'sophie@eurotech.example'),
p3: participant('Sophie Example', 'sophie@eurotech.example'),
p4: participant('Élise Moreau', 'elise.moreau@fjord-systems.example'),
},
alerts: { a1: { trigger: { '@type': 'OffsetTrigger', offset: '-PT5M', relativeTo: 'start' }, action: 'display' } },
@@ -986,7 +1012,7 @@ const calendarEvents = [
participants: {
p1: participant('Dev User', 'dev@localhost', 'owner'),
p2: participant('Lars Johansson', 'lars.johansson@fjord-systems.example'),
p3: participant('Sophie Müller', 'sophie@eurotech.example'),
p3: participant('Sophie Example', 'sophie@eurotech.example'),
p4: participant('Élise Moreau', 'elise.moreau@fjord-systems.example'),
p5: participant('Astrid van der Berg', 'astrid@berglabs.example'),
},
@@ -1024,7 +1050,7 @@ const calendarEvents = [
virtualLocations: { vl1: { uri: 'https://meet.example/eurotech', name: 'Teams' } },
participants: {
p1: participant('Dev User', 'dev@localhost', 'owner'),
p2: participant('Sophie Müller', 'sophie@eurotech.example'),
p2: participant('Sophie Example', 'sophie@eurotech.example'),
p3: participant('Pierre Dubois', 'pierre@dubois.example'),
},
description: 'Discuss API rate limit escalation for EuroTech enterprise account.',
@@ -1054,7 +1080,7 @@ const calendarEvents = [
participants: {
p1: participant('Dev User', 'dev@localhost', 'owner'),
p2: participant('Lars Johansson', 'lars.johansson@fjord-systems.example'),
p3: participant('Sophie Müller', 'sophie@eurotech.example'),
p3: participant('Sophie Example', 'sophie@eurotech.example'),
p4: participant('Élise Moreau', 'elise.moreau@fjord-systems.example'),
p5: participant('Astrid van der Berg', 'astrid@berglabs.example'),
p6: participant('Pierre Dubois', 'pierre@dubois.example'),
@@ -1066,7 +1092,7 @@ const calendarEvents = [
participants: {
p1: participant('Dev User', 'dev@localhost'),
p2: participant('María García', 'maria@garcia-design.example', 'owner'),
p3: participant('Sophie Müller', 'sophie@eurotech.example'),
p3: participant('Sophie Example', 'sophie@eurotech.example'),
},
}),
makeEvent('evt-011', 'cal-2', 'API Deprecation Deadline', localDateTime(30, 0, 0), 'P1D', {
@@ -1084,7 +1110,7 @@ const calendarEvents = [
p2: participant('Dev User', 'dev@localhost'),
p3: participant('Pierre Dubois', 'pierre@dubois.example'),
p4: participant('Chiara Rossi', 'chiara@rossi.example'),
p5: participant('Sophie Müller', 'sophie@eurotech.example'),
p5: participant('Sophie Example', 'sophie@eurotech.example'),
},
}),
makeEvent('evt-013', 'cal-3', 'Team Retro: What went well?', localDateTime(-2, 16, 0), 'PT1H', {
@@ -1093,7 +1119,7 @@ const calendarEvents = [
p1: participant('Dev User', 'dev@localhost', 'owner'),
p2: participant('Lars Johansson', 'lars.johansson@fjord-systems.example'),
p3: participant('Élise Moreau', 'elise.moreau@fjord-systems.example'),
p4: participant('Sophie Müller', 'sophie@eurotech.example'),
p4: participant('Sophie Example', 'sophie@eurotech.example'),
},
}),
makeEvent('evt-014', 'cal-3', 'Lunch & Learn: JMAP Protocol Deep Dive', localDateTime(4, 12, 0), 'PT1H', {
@@ -1109,7 +1135,7 @@ const calendarEvents = [
location: 'Sophie\'s apartment, Kreuzberg, Berlin',
description: 'Annual Eurovision Song Contest watch party!\n\nRules:\n1. Scorecards mandatory (printed copies provided)\n2. Drink when someone says "douze points"\n3. Best costume contest (prize: a waffle iron)\n4. No spoilers from the semis!\n\nBring: snacks from your home country.',
participants: {
p1: participant('Sophie Müller', 'sophie@eurotech.example', 'owner'),
p1: participant('Sophie Example', 'sophie@eurotech.example', 'owner'),
p2: participant('Dev User', 'dev@localhost'),
p3: participant('Pierre Dubois', 'pierre@dubois.example'),
p4: participant('Chiara Rossi', 'chiara@rossi.example'),
@@ -1192,7 +1218,7 @@ const calendarEvents = [
}),
// ===== Birthday calendar (cal-5) =====
makeEvent('evt-030', 'cal-5', '🎂 Sophie Müller', localDateTime(8, 0, 0), 'P1D', {
makeEvent('evt-030', 'cal-5', '🎂 Sophie Example', localDateTime(8, 0, 0), 'P1D', {
showWithoutTime: true,
recurrence: [{ frequency: 'yearly' }],
description: 'Don\'t forget to bring Kuchen!',
@@ -1220,7 +1246,7 @@ const calendarEvents = [
description: 'Your talk: "Building Modern Webmail with JMAP" - Day 1, 14:00, Main Hall.\nDon\'t forget slide deck!',
participants: {
p1: participant('Dev User', 'dev@localhost'),
p2: participant('Sophie Müller', 'sophie@eurotech.example'),
p2: participant('Sophie Example', 'sophie@eurotech.example'),
p3: participant('Isabelle Martin', 'isabelle.martin@sorbonne.example'),
},
}),
+39 -6
View File
@@ -4,6 +4,26 @@ import { isPublicHttpUrl } from '@/lib/security/url-guard';
const MAX_RESPONSE_SIZE = 10 * 1024 * 1024; // 10MB
const FETCH_TIMEOUT_MS = 15000;
function extractBasicAuth(rawUrl: string): { cleanUrl: string; authHeader: string | null } | null {
let parsed: URL;
try {
parsed = new URL(rawUrl);
} catch {
return null;
}
let authHeader: string | null = null;
if (parsed.username || parsed.password) {
const username = decodeURIComponent(parsed.username);
const password = decodeURIComponent(parsed.password);
authHeader = `Basic ${Buffer.from(`${username}:${password}`).toString('base64')}`;
parsed.username = '';
parsed.password = '';
}
return { cleanUrl: parsed.toString(), authHeader };
}
export async function POST(request: NextRequest) {
let body: { url?: string };
try {
@@ -18,7 +38,14 @@ export async function POST(request: NextRequest) {
return NextResponse.json({ error: 'URL is required' }, { status: 400 });
}
if (!(await isPublicHttpUrl(url))) {
const extracted = extractBasicAuth(url);
if (!extracted) {
return NextResponse.json({ error: 'Invalid or disallowed URL' }, { status: 400 });
}
const { cleanUrl, authHeader } = extracted;
if (!(await isPublicHttpUrl(cleanUrl))) {
return NextResponse.json({ error: 'Invalid or disallowed URL' }, { status: 400 });
}
@@ -27,7 +54,8 @@ export async function POST(request: NextRequest) {
const timeout = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
const MAX_REDIRECTS = 5;
let currentUrl = url;
let currentUrl = cleanUrl;
const originalOrigin = new URL(cleanUrl).origin;
let response: Response | undefined;
for (let i = 0; i <= MAX_REDIRECTS; i++) {
@@ -36,12 +64,17 @@ export async function POST(request: NextRequest) {
return NextResponse.json({ error: 'Redirect to disallowed URL' }, { status: 400 });
}
const headers: Record<string, string> = {
'Accept': 'text/calendar, application/ics, text/plain, */*',
'User-Agent': 'JMAP-Webmail/1.0 Calendar-Fetcher',
};
if (authHeader && new URL(currentUrl).origin === originalOrigin) {
headers['Authorization'] = authHeader;
}
response = await fetch(currentUrl, {
signal: controller.signal,
headers: {
'Accept': 'text/calendar, application/ics, text/plain, */*',
'User-Agent': 'JMAP-Webmail/1.0 Calendar-Fetcher',
},
headers,
redirect: 'manual',
});
+91 -21
View File
@@ -1,10 +1,73 @@
import { cookies } from 'next/headers';
import { NextRequest, NextResponse } from 'next/server';
import { logger } from '@/lib/logger';
import { getStalwartCredentials } from '@/lib/stalwart/credentials';
import { MAX_ACCOUNT_SLOTS } from '@/lib/account-utils';
import { readStalwartAuthContextFromStore } from '@/lib/stalwart/auth-context';
import {
getStalwartCredentials,
type StalwartCredentials,
} from '@/lib/stalwart/credentials';
export const runtime = 'nodejs';
export const dynamic = 'force-dynamic';
interface ResolvedTarget {
authHeader: string;
apiUrl: string;
accountId: string;
}
// When the SW passes ?accountId=, we need the slot whose JMAP session owns
// that account - not just "the first signed-in slot", which is what
// getStalwartCredentials() defaults to. Probe each candidate's session in
// parallel and return the first match.
async function resolveTargetForAccount(accountId: string): Promise<ResolvedTarget | null> {
const cookieStore = await cookies();
const probes: Promise<ResolvedTarget | null>[] = [];
for (let slot = 0; slot < MAX_ACCOUNT_SLOTS; slot++) {
const ctx = readStalwartAuthContextFromStore(cookieStore, slot);
if (!ctx) continue;
const serverUrl = ctx.serverUrl.replace(/\/+$/, '');
probes.push(
(async () => {
try {
const res = await fetch(`${serverUrl}/.well-known/jmap`, {
headers: { Authorization: ctx.authHeader },
});
if (!res.ok) return null;
const session = (await res.json()) as {
apiUrl?: string;
primaryAccounts?: Record<string, string>;
};
const mailAccountId = session.primaryAccounts?.['urn:ietf:params:jmap:mail'];
if (!session.apiUrl || !mailAccountId) return null;
if (mailAccountId !== accountId) return null;
return { authHeader: ctx.authHeader, apiUrl: session.apiUrl, accountId: mailAccountId };
} catch {
return null;
}
})(),
);
}
const results = await Promise.all(probes);
return results.find((r): r is ResolvedTarget => r !== null) ?? null;
}
async function resolveDefaultTarget(creds: StalwartCredentials): Promise<ResolvedTarget | null> {
const sessionRes = await fetch(`${creds.serverUrl}/.well-known/jmap`, {
headers: { Authorization: creds.authHeader },
});
if (!sessionRes.ok) return null;
const session = (await sessionRes.json()) as {
apiUrl?: string;
primaryAccounts?: Record<string, string>;
};
const apiUrl = session.apiUrl;
const accountId = session.primaryAccounts?.['urn:ietf:params:jmap:mail'];
if (!apiUrl || !accountId) return null;
return { authHeader: creds.authHeader, apiUrl, accountId };
}
/**
* GET /api/push/preview
*
@@ -19,31 +82,38 @@ export const dynamic = 'force-dynamic';
*/
export async function GET(request: NextRequest) {
try {
const creds = await getStalwartCredentials(request);
if (!creds) {
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
// SW passes ?accountId=<jmap-account-id> derived from the push payload's
// StateChange so multi-account browsers fetch from the right slot. Older
// clients (and the manual /api/push/preview probe) omit it and fall back
// to the first signed-in slot.
const requestedAccountId = request.nextUrl.searchParams.get('accountId');
let target: ResolvedTarget | null = null;
let authHeader: string;
if (requestedAccountId) {
target = await resolveTargetForAccount(requestedAccountId);
if (!target) {
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
}
authHeader = target.authHeader;
} else {
const creds = await getStalwartCredentials(request);
if (!creds) {
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
}
target = await resolveDefaultTarget(creds);
if (!target) {
return NextResponse.json({ error: 'JMAP session failed' }, { status: 502 });
}
authHeader = creds.authHeader;
}
const sessionRes = await fetch(`${creds.serverUrl}/.well-known/jmap`, {
headers: { Authorization: creds.authHeader },
});
if (!sessionRes.ok) {
return NextResponse.json({ error: 'JMAP session failed' }, { status: 502 });
}
const session = (await sessionRes.json()) as {
apiUrl?: string;
primaryAccounts?: Record<string, string>;
};
const apiUrl = session.apiUrl;
const accountId = session.primaryAccounts?.['urn:ietf:params:jmap:mail'];
if (!apiUrl || !accountId) {
return NextResponse.json({ error: 'Incomplete JMAP session' }, { status: 502 });
}
const { apiUrl, accountId } = target;
const inboxRes = await fetch(apiUrl, {
method: 'POST',
headers: {
Authorization: creds.authHeader,
Authorization: authHeader,
'Content-Type': 'application/json',
},
body: JSON.stringify({
@@ -119,7 +189,7 @@ export async function GET(request: NextRequest) {
const jmapRes = await fetch(apiUrl, {
method: 'POST',
headers: {
Authorization: creds.authHeader,
Authorization: authHeader,
'Content-Type': 'application/json',
},
body: JSON.stringify(requestBody),
+5 -5
View File
@@ -47,14 +47,14 @@ function sanitizeFilename(name: string): string {
}
/**
* POST /api/setup/branding wizard branding upload.
* POST /api/setup/branding - wizard branding upload.
*
* Multipart form fields:
* file the image (SVG/PNG/JPEG/WebP/ICO, max 2 MB)
* slot which branding key (faviconUrl, loginLogoLightUrl, etc.)
* file - the image (SVG/PNG/JPEG/WebP/ICO, max 2 MB)
* slot - which branding key (faviconUrl, loginLogoLightUrl, etc.)
*
* Mirrors /api/admin/branding but authenticates via the wizard cookie
* instead of admin session admin auth doesn't exist yet during bootstrap.
* instead of admin session - admin auth doesn't exist yet during bootstrap.
* Files land in the same directory; the public read endpoint at
* /api/admin/branding/<filename> serves both wizard- and admin-uploaded
* assets after setup.
@@ -126,7 +126,7 @@ export async function POST(request: NextRequest) {
}
/**
* DELETE /api/setup/branding remove an uploaded asset and clear the
* DELETE /api/setup/branding - remove an uploaded asset and clear the
* config override so the slot falls back to the system default.
*
* Body: { slot: string }
+8 -5
View File
@@ -58,13 +58,16 @@ export async function POST(request: NextRequest) {
}
try {
// 1. Provision the admin account. Aborts cleanly if one already exists
// (defence in depth - should be impossible in bootstrap state).
const created = await setInitialAdminPassword(adminPassword);
// 1. Provision the admin account. An admin.json file may already exist
// from a previous ADMIN_PASSWORD env var or an aborted earlier wizard
// run while setupComplete is still false — accept the wizard's
// password as authoritative in that case. The finish route is gated
// by the bootstrap state + one-time setup token, so this is safe.
const created = await setInitialAdminPassword(adminPassword, { allowOverwrite: true });
if (!created) {
return NextResponse.json(
{ error: 'Admin account already exists; cannot finish setup again' },
{ status: 409 },
{ error: 'Failed to write admin credentials' },
{ status: 500 },
);
}
+8 -1
View File
@@ -171,8 +171,15 @@ body {
background-color: var(--color-background);
color: var(--color-foreground);
font-family:
system-ui,
-apple-system,
BlinkMacSystemFont,
"Segoe UI",
Roboto,
"Helvetica Neue",
Arial,
"Noto Sans Thai",
"Leelawadee UI",
Tahoma,
sans-serif;
font-feature-settings:
"rlig" 1,
-20
View File
@@ -1,20 +0,0 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" style="isolation:isolate" viewBox="0 0 1000 1000">
<defs>
<clipPath id="_clipPath_ONeeZd4dujNSzmUupv5CE8R64LUE9BqV"><rect width="1000" height="1000"/></clipPath>
<style>
.icon-bg { fill: #ffffff; }
.icon-mark { fill: rgb(219,45,84); }
@media (prefers-color-scheme: dark) {
.icon-bg { fill: #18181b; }
}
</style>
</defs>
<g clip-path="url(#_clipPath_ONeeZd4dujNSzmUupv5CE8R64LUE9BqV)">
<rect width="1000" height="1000" class="icon-bg"/>
<path d=" M 489.315 575.068 L 225.342 338.071 C 222.394 335.424 220 330.058 220 326.095 L 220 297.377 C 220 293.415 223.135 289.474 226.996 288.583 L 320.697 266.96 C 324.558 266.069 327.692 268.563 327.692 272.525 L 327.692 331.61 L 406.851 313.338 C 410.712 312.446 413.846 308.506 413.846 304.543 L 413.846 252.643 C 413.846 248.681 416.981 244.741 420.842 243.85 L 493.004 227.197 C 496.865 226.306 503.135 226.306 506.996 227.197 L 579.158 243.85 C 583.019 244.741 586.154 248.681 586.154 252.643 L 586.154 304.543 C 586.154 308.506 589.288 312.446 593.149 313.338 L 672.308 331.61 L 672.308 272.525 C 672.308 268.563 675.442 266.069 679.303 266.96 L 773.004 288.583 C 776.865 289.474 780 293.415 780 297.377 L 780 326.095 C 780 330.058 777.606 335.424 774.658 338.071 L 510.685 575.068 C 504.788 580.362 495.212 580.362 489.315 575.068 Z " class="icon-mark"/>
<path d=" M 780 429.762 L 780 470.138 C 780 474.101 777.725 479.593 774.923 482.394 L 742 515.318 C 739.198 518.12 736.923 523.612 736.923 527.574 L 736.923 649.625 C 736.922 672.529 730.827 692.394 719.048 710.431 L 599.991 591.373 L 780 429.762 Z " class="icon-mark"/>
<path d=" M 220 429.762 L 220 462.959 C 220 470.884 224.55 481.867 230.153 487.471 L 252.924 510.241 C 258.527 515.845 263.077 526.829 263.077 534.754 L 263.077 649.625 C 263.078 672.529 269.173 692.394 280.952 710.431 L 400.009 591.373 L 220 429.762 Z " class="icon-mark"/>
<path d=" M 667.232 760.147 C 627.163 787.649 570.672 813.211 500 843.472 Q 500 843.472 500 843.472 C 429.328 813.211 372.837 787.649 332.768 760.147 L 454.622 638.293 C 459.461 641.204 464.582 643.644 469.918 645.569 C 479.567 649.058 489.741 650.839 500 650.832 C 510.259 650.839 520.433 649.058 530.082 645.569 C 535.418 643.644 540.539 641.204 545.378 638.293 L 667.232 760.147 Z " class="icon-mark"/>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 2.4 KiB

+11 -3
View File
@@ -1,9 +1,10 @@
import type { Metadata } from "next";
import type { Metadata, Viewport } from "next";
import { Geist, Geist_Mono } from "next/font/google";
import { headers } from "next/headers";
import { getLocale } from "next-intl/server";
import { PWAInstallPrompt } from "@/components/pwa-install-prompt";
import { ServiceWorkerRegistration } from "@/components/service-worker-registration";
import { configManager } from "@/lib/admin/config-manager";
import "./globals.css";
const geistSans = Geist({
@@ -16,8 +17,15 @@ const geistMono = Geist_Mono({
subsets: ["latin"],
});
export const viewport: Viewport = {
width: "device-width",
initialScale: 1,
viewportFit: "cover",
};
export async function generateMetadata(): Promise<Metadata> {
const faviconUrl = process.env.FAVICON_URL;
await configManager.ensureLoaded();
const faviconUrl = configManager.get<string>("faviconUrl", "/branding/Bulwark_Favicon.svg");
return {
title: process.env.APP_NAME || process.env.NEXT_PUBLIC_APP_NAME || "Webmail",
@@ -30,7 +38,7 @@ export async function generateMetadata(): Promise<Metadata> {
formatDetection: {
telephone: false,
},
...(faviconUrl ? { icons: { icon: faviconUrl } } : {}),
icons: { icon: faviconUrl },
};
}
+21 -1
View File
@@ -2,13 +2,26 @@ import type { MetadataRoute } from "next";
export const dynamic = "force-dynamic";
type WebAppProtocolHandler = {
protocol: string;
url: string;
};
type ExtendedManifest = MetadataRoute.Manifest & {
protocol_handlers?: WebAppProtocolHandler[];
launch_handler?: {
client_mode?: "navigate-existing" | "auto" | "focus-existing" | "navigate-new"
| Array<"navigate-existing" | "auto" | "focus-existing" | "navigate-new">;
};
};
// Manifest paths must include the deployment subpath - browsers resolve them
// against the document origin, not the manifest's location, and Next.js does
// not auto-prefix string literals inside MetadataRoute payloads.
const BASE_PATH = (process.env.NEXT_PUBLIC_BASE_PATH ?? "").replace(/\/+$/, "");
const withBase = (p: string) => `${BASE_PATH}${p}`;
export default function manifest(): MetadataRoute.Manifest {
export default function manifest(): ExtendedManifest {
const appName =
process.env.APP_NAME ||
process.env.NEXT_PUBLIC_APP_NAME ||
@@ -57,5 +70,12 @@ export default function manifest(): MetadataRoute.Manifest {
{ src: withBase("/screenshot-540x720.png"), sizes: "540x720", type: "image/png" },
{ src: withBase("/screenshot-1280x720.png"), sizes: "1280x720", type: "image/png" },
],
protocol_handlers: [
{ protocol: "mailto", url: withBase("/protocol/mailto?url=%s") },
{ protocol: "webcal", url: withBase("/protocol/webcal?url=%s") },
],
launch_handler: {
client_mode: ["focus-existing", "navigate-new"],
},
};
}
+8
View File
@@ -0,0 +1,8 @@
import { getTranslations } from "next-intl/server";
import { MailtoProtocolClient } from "@/components/protocol/mailto-protocol-client";
export default async function MailtoProtocolPage() {
const t = await getTranslations("protocol_handlers");
return <MailtoProtocolClient openingText={t("opening_mailto")} />;
}
+8
View File
@@ -0,0 +1,8 @@
import { getTranslations } from "next-intl/server";
import { WebcalProtocolClient } from "@/components/protocol/webcal-protocol-client";
export default async function WebcalProtocolPage() {
const t = await getTranslations("protocol_handlers");
return <WebcalProtocolClient openingText={t("opening_webcal")} />;
}
+76 -11
View File
@@ -2,7 +2,7 @@
import { useEffect, useState, type FormEvent, type ReactNode } from 'react';
import { useRouter, useSearchParams } from 'next/navigation';
import { CheckCircle2, AlertTriangle, AlertCircle, Server, ShieldCheck, KeyRound, FileText, Palette, Lock } from 'lucide-react';
import { CheckCircle2, AlertTriangle, AlertCircle, Server, ShieldCheck, KeyRound, FileText, Palette, Lock, ShieldAlert } from 'lucide-react';
import { apiFetch } from '@/lib/browser-navigation';
type State = 'bootstrap' | 'configured' | 'env-managed';
@@ -101,9 +101,20 @@ export default function SetupWizardPage() {
const [config, setConfig] = useState<WizardConfig>(EMPTY_CONFIG);
const [stepIndex, setStepIndex] = useState(0);
const [completed, setCompleted] = useState(false);
// Detect synchronously on first client render so we don't flash the loading
// screen before the warning appears. The session cookie is set with the
// Secure flag in production, which browsers silently drop over plain HTTP -
// every subsequent step call then 401s with "Wizard session required".
const [insecureContext] = useState<boolean>(detectInsecureContext);
// ─── Initial status load ────────────────────────────────────────────────
useEffect(() => {
// Skip the status fetch entirely when we're going to render the HTTPS
// notice - the wizard cookie can't survive an HTTP origin anyway.
if (insecureContext) {
setBootstrapping(false);
return;
}
let cancelled = false;
(async () => {
try {
@@ -141,7 +152,7 @@ export default function SetupWizardPage() {
return () => {
cancelled = true;
};
}, [router]);
}, [router, insecureContext]);
// ─── Token submit (welcome step) ────────────────────────────────────────
async function submitToken(token: string) {
@@ -173,6 +184,10 @@ export default function SetupWizardPage() {
}
// ─── Render shell ───────────────────────────────────────────────────────
if (insecureContext) {
return <InsecureContextScreen />;
}
if (bootstrapping) {
return <CenteredCard><p className="text-muted-foreground">Loading</p></CenteredCard>;
}
@@ -229,7 +244,7 @@ export default function SetupWizardPage() {
} catch (e) {
const msg = humanError(e);
setError(msg);
// Session expired mid-flow kick the user back to the
// Session expired mid-flow - kick the user back to the
// welcome step so they can re-enter the token without
// having to refresh.
if (/wizard session required/i.test(msg)) {
@@ -241,7 +256,7 @@ export default function SetupWizardPage() {
onBack={() => setStepIndex((i) => Math.max(i - 1, 1))}
onFinish={() => {
setCompleted(true);
// Hard navigation after a beat gives the user a moment
// Hard navigation after a beat - gives the user a moment
// to see the success screen and works around any router
// edge cases that swallow client-side replaces after the
// setupComplete flag flips.
@@ -347,6 +362,44 @@ function CompletedScreen() {
);
}
function InsecureContextScreen() {
const httpsUrl =
typeof window !== 'undefined'
? `https://${window.location.host}${window.location.pathname}${window.location.search}`
: '';
return (
<CenteredCard>
<div className="text-center">
<div className="mx-auto h-12 w-12 rounded-full bg-warning/15 text-warning flex items-center justify-center mb-4">
<ShieldAlert className="h-6 w-6" />
</div>
<h1 className="text-xl font-semibold">HTTPS required for setup</h1>
<p className="text-sm text-muted-foreground mt-2">
The setup wizard signs you in with a <code className="font-mono text-xs">Secure</code> cookie,
which your browser will only accept over HTTPS. Loading this page over plain HTTP causes every
step to fail with <em>Wizard session required</em>.
</p>
</div>
<div className="mt-5 text-left text-sm text-muted-foreground space-y-2">
<p className="font-medium text-foreground">To continue, do one of the following:</p>
<ul className="list-disc pl-5 space-y-1">
<li>Reach this page over HTTPS (terminate TLS on the container or a reverse proxy in front of it).</li>
<li>If you already have a reverse proxy, make sure it forwards to the webmail and forwards the
<code className="font-mono text-xs"> X-Forwarded-Proto</code> header.</li>
</ul>
</div>
{httpsUrl && (
<a
href={httpsUrl}
className="mt-6 block w-full rounded-md bg-primary text-primary-foreground text-center px-4 py-2.5 text-sm font-medium hover:bg-primary/90"
>
Open over HTTPS
</a>
)}
</CenteredCard>
);
}
function AlreadyConfiguredScreen() {
return (
<CenteredCard>
@@ -536,7 +589,7 @@ function ServerStep({ config, setConfig, onNext }: Pick<StepProps, 'config' | 's
const data = await res.json();
let entry: { status: ProbeStatus; message: string; url: string };
if (data.status === 'jmap_detected') {
entry = { status: 'jmap_detected', message: 'Connected this looks like a JMAP server.', url: config.jmapServerUrl };
entry = { status: 'jmap_detected', message: 'Connected - this looks like a JMAP server.', url: config.jmapServerUrl };
} else if (data.status === 'reachable_no_jmap') {
entry = { status: 'reachable_no_jmap', message: "We reached the server, but it doesn't look like a JMAP endpoint.", url: config.jmapServerUrl };
} else if (data.status === 'invalid_url') {
@@ -618,7 +671,7 @@ function ServerStep({ config, setConfig, onNext }: Pick<StepProps, 'config' | 's
}
if (!result) return;
// Hard-fail on these no "are you sure" since they can't be right.
// Hard-fail on these - no "are you sure" since they can't be right.
if (result.status === 'invalid_url' || result.status === 'unreachable') {
return;
}
@@ -685,7 +738,7 @@ function ServerStep({ config, setConfig, onNext }: Pick<StepProps, 'config' | 's
This URL uses plain HTTP.
</p>
<p className="text-sm text-muted-foreground mt-0.5 leading-relaxed">
Passwords and email contents will travel unencrypted between users and your server. Use <code className="font-mono text-xs">https://</code> in production terminate TLS on the mail server or a reverse proxy in front of it.
Passwords and email contents will travel unencrypted between users and your server. Use <code className="font-mono text-xs">https://</code> in production - terminate TLS on the mail server or a reverse proxy in front of it.
</p>
</div>
</div>
@@ -720,7 +773,7 @@ function ServerStep({ config, setConfig, onNext }: Pick<StepProps, 'config' | 's
onChange={(e) => setConfirmedNonJmap(e.target.checked)}
className="h-4 w-4"
/>
<span className="text-sm text-foreground">I&apos;m sure this is the right URL continue anyway.</span>
<span className="text-sm text-foreground">I&apos;m sure this is the right URL - continue anyway.</span>
</label>
</div>
) : (
@@ -1105,7 +1158,7 @@ function BrandingStep({ config, setConfig, onNext, onBack }: Pick<StepProps, 'co
<form onSubmit={handle} className="space-y-4">
<StepHeader
title="Branding"
subtitle="All fields optional. Upload a file or paste a URL defaults are used for anything you skip."
subtitle="All fields optional. Upload a file or paste a URL - defaults are used for anything you skip."
/>
<Field label="Company / organization name">
<Input value={config.loginCompanyName} onChange={(v) => setConfig({ ...config, loginCompanyName: v })} />
@@ -1174,7 +1227,7 @@ function BrandingStep({ config, setConfig, onNext, onBack }: Pick<StepProps, 'co
* One branding asset slot: shows a thumbnail preview if a value is set,
* a file picker (uploads to /api/setup/branding), and a URL field for
* operators who'd rather paste a link. Upload and URL are mutually
* compatible the URL field always reflects the persisted value.
* compatible - the URL field always reflects the persisted value.
*/
function BrandingAsset({
label,
@@ -1522,7 +1575,7 @@ function SummaryRow({ label, value, mono }: { label: string; value: string; mono
<div className="flex justify-between items-baseline gap-3 text-sm">
<span className="text-muted-foreground shrink-0">{label}</span>
<span className={'text-foreground text-right truncate min-w-0 ' + (mono ? 'font-mono text-xs' : '')}>
{value || <span className="text-muted-foreground italic"></span>}
{value || <span className="text-muted-foreground italic">-</span>}
</span>
</div>
);
@@ -1729,6 +1782,18 @@ function isInsecureHttpUrl(url: string): boolean {
return /^http:\/\//i.test(url.trim());
}
function detectInsecureContext(): boolean {
if (typeof window === 'undefined') return false;
if (window.location.protocol !== 'http:') return false;
// Browsers treat localhost/loopback as "potentially trustworthy" and accept
// Secure cookies even without TLS, so the wizard still works there.
const host = window.location.hostname;
if (host === 'localhost' || host === '127.0.0.1' || host === '::1' || host === '[::1]') {
return false;
}
return true;
}
function humanError(e: unknown): string {
if (e instanceof Error) return e.message;
if (typeof e === 'string') return e;
+4 -3
View File
@@ -17,6 +17,7 @@ interface ICalImportModalProps {
calendars: Calendar[];
client: IJMAPClient;
onClose: () => void;
initialUrl?: string;
}
const MAX_FILE_SIZE = 10 * 1024 * 1024; // 10MB
@@ -25,7 +26,7 @@ const ACCEPTED_EXTENSIONS = [".ics", ".ical"];
type ImportStep = "select" | "preview" | "importing";
type ImportMode = "file" | "url";
export function ICalImportModal({ calendars, client, onClose }: ICalImportModalProps) {
export function ICalImportModal({ calendars, client, onClose, initialUrl }: ICalImportModalProps) {
const t = useTranslations("calendar.import");
const tCal = useTranslations("calendar");
const tCommon = useTranslations("common");
@@ -43,8 +44,8 @@ export function ICalImportModal({ calendars, client, onClose }: ICalImportModalP
const [isParsing, setIsParsing] = useState(false);
const [isDragging, setIsDragging] = useState(false);
const [error, setError] = useState<string | null>(null);
const [importMode, setImportMode] = useState<ImportMode>("file");
const [urlInput, setUrlInput] = useState("");
const [importMode, setImportMode] = useState<ImportMode>(initialUrl ? "url" : "file");
const [urlInput, setUrlInput] = useState(initialUrl || "");
const [isFetchingUrl, setIsFetchingUrl] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null);
const modalRef = useRef<HTMLDivElement>(null);
@@ -13,9 +13,11 @@ interface ICalSubscriptionModalProps {
client: IJMAPClient;
onClose: () => void;
editSubscription?: ICalSubscription;
initialUrl?: string;
initialName?: string;
}
export function ICalSubscriptionModal({ client, onClose, editSubscription }: ICalSubscriptionModalProps) {
export function ICalSubscriptionModal({ client, onClose, editSubscription, initialUrl, initialName }: ICalSubscriptionModalProps) {
const t = useTranslations("calendar.subscription");
const tCommon = useTranslations("common");
const addICalSubscription = useCalendarStore((s) => s.addICalSubscription);
@@ -23,8 +25,8 @@ export function ICalSubscriptionModal({ client, onClose, editSubscription }: ICa
const isEdit = !!editSubscription;
const [url, setUrl] = useState(editSubscription?.url || "");
const [name, setName] = useState(editSubscription?.name || "");
const [url, setUrl] = useState(editSubscription?.url || initialUrl || "");
const [name, setName] = useState(editSubscription?.name || initialName || "");
const [color, setColor] = useState(editSubscription?.color || "#3b82f6");
const [refreshInterval, setRefreshInterval] = useState(editSubscription?.refreshInterval || 60);
const [isSubmitting, setIsSubmitting] = useState(false);
+47 -32
View File
@@ -388,41 +388,56 @@ export function CalendarInvitationBanner({ email }: CalendarInvitationBannerProp
setActionNotice(null);
setActionError(null);
try {
const events = await client.parseCalendarEvents(client.getCalendarsAccountId(), attachment.blobId);
if (events.length > 0) {
const parsed = events[0];
setParsedEvent(parsed);
// JMAP strips parameters from Content-Type (RFC 8621), so method=REQUEST
// is lost. Fetch raw ICS to extract METHOD as a reliable fallback.
try {
const blob = await client.fetchBlob(attachment.blobId, 'invite.ics', 'text/calendar');
const rawText = await blob.text();
const icsMethod = extractMethodFromRawIcs(rawText);
if (icsMethod !== 'unknown') {
setRawIcsMethod(icsMethod);
// JMAP strips parameters from Content-Type (RFC 8621), so method=REQUEST
// is lost. Fetch raw ICS to extract METHOD as a reliable fallback — in
// parallel with parsing to save a roundtrip.
const [events, rawText] = await Promise.all([
client.parseCalendarEvents(client.getCalendarsAccountId(), attachment.blobId),
(async () => {
try {
const blob = await client.fetchBlob(attachment.blobId, 'invite.ics', 'text/calendar');
return await blob.text();
} catch {
return null;
}
} catch { /* ignore - fall back to heuristic detection */ }
})(),
]);
if (parsed.uid && supportsCalendar) {
const storeHasIt = useCalendarStore.getState().events.some((e) => e.uid === parsed.uid);
if (!storeHasIt) {
try {
const serverEvents = await client.queryCalendarEvents({});
const matching = serverEvents.filter((e) => e.uid === parsed.uid);
if (matching.length > 0) {
useCalendarStore.setState((s) => {
const existingIds = new Set(s.events.map((e) => e.id));
const newEvents = matching.filter((e) => !existingIds.has(e.id));
return newEvents.length > 0 ? { events: [...s.events, ...newEvents] } : s;
});
}
} catch { /* ignore lookup failure */ }
}
}
setState('parsed');
} else {
if (events.length === 0) {
setState('error');
return;
}
const parsed = events[0];
setParsedEvent(parsed);
if (rawText) {
const icsMethod = extractMethodFromRawIcs(rawText);
if (icsMethod !== 'unknown') {
setRawIcsMethod(icsMethod);
}
}
setState('parsed');
// Hydrate the calendar store with the matching event in the background —
// only needed for the "already in calendar" pill, must not block the banner.
// Filter by UID server-side; the previous unfiltered query fetched up to
// 1000 events plus multiple /get batches just to find one match.
if (parsed.uid && supportsCalendar) {
const storeHasIt = useCalendarStore.getState().events.some((e) => e.uid === parsed.uid);
if (!storeHasIt) {
client.queryCalendarEvents({ uid: parsed.uid })
.then((matching) => {
if (matching.length === 0) return;
useCalendarStore.setState((s) => {
const existingIds = new Set(s.events.map((e) => e.id));
const newEvents = matching.filter((e) => !existingIds.has(e.id));
return newEvents.length > 0 ? { events: [...s.events, ...newEvents] } : s;
});
})
.catch(() => { /* ignore lookup failure */ });
}
}
} catch {
setState('error');
+118 -27
View File
@@ -35,6 +35,7 @@ import { appendPlainTextSignature, getPlainTextSignature } from "@/lib/signature
import { resolveReplyFrom } from "@/lib/reply-identity";
import { computeReplyThreadingHeaders } from "@/lib/email-threading";
import { RichTextEditor } from "@/components/email/rich-text-editor";
import type { Editor } from "@tiptap/react";
/** Strip HTML tags and decode entities to get a plain-text version */
function htmlToPlainText(html: string): string {
@@ -55,7 +56,7 @@ export interface ComposerDraftData {
mode: 'compose' | 'reply' | 'replyAll' | 'forward';
replyTo?: EmailComposerProps['replyTo'];
draftId: string | null;
/** When set, overrides the header From: sent through the selected identity's envelope. */
/** When set, overrides the header From: - sent through the selected identity's envelope. */
fromOverrideEmail?: string;
fromOverrideName?: string;
fromOverrideEnabled?: boolean;
@@ -116,6 +117,39 @@ type ComposerAttachment = {
abortController?: AbortController;
};
type SignatureIdentityLike = {
htmlSignature?: string;
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.
function buildEmbeddedSignatureHtml(
identity: SignatureIdentityLike,
options: { embed: boolean; separator: boolean }
): string {
if (!options.embed) return '';
const startMarker = options.separator
? `<p data-signature-block="separator">-- </p>`
: `<p data-signature-block="start"></p>`;
const endMarker = `<p data-signature-block="end"></p>`;
if (identity?.htmlSignature) {
return `${startMarker}${sanitizeEmailHtml(identity.htmlSignature)}${endMarker}`;
}
if (identity?.textSignature) {
const escaped = identity.textSignature
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/\n/g, '<br>');
return `${startMarker}<p>${escaped}</p>${endMarker}`;
}
return '';
}
export function EmailComposer({
onSend,
onClose,
@@ -136,6 +170,7 @@ export function EmailComposer({
const attachmentReminderEnabled = useSettingsStore((state) => state.attachmentReminderEnabled);
const attachmentReminderKeywords = useSettingsStore((state) => state.attachmentReminderKeywords);
const signaturePosition = useSettingsStore((state) => state.signaturePosition);
const signatureSeparatorEnabled = useSettingsStore((state) => state.signatureSeparatorEnabled);
const identities = useIdentityStore((s) => s.identities);
const primaryIdentity = identities[0] ?? null;
@@ -204,10 +239,11 @@ export function EmailComposer({
// When "above quote" is configured, splice signature between the user's
// drafting area and the quoted content so it reads naturally as a
// closing for the reply body. Send-time append is skipped see
// closing for the reply body. Send-time append is skipped - see
// shouldEmbedSignatureAboveQuote.
const plainSep = signatureSeparatorEnabled ? '\n\n-- \n' : '\n\n';
const signatureBlock = shouldEmbedSignatureAboveQuote
? `\n\n-- \n${getPlainTextSignature(initialSignatureIdentity)}`
? `${plainSep}${getPlainTextSignature(initialSignatureIdentity)}`
: '';
if (mode === 'forward') {
@@ -225,21 +261,10 @@ export function EmailComposer({
const from = replyTo.from?.[0];
const fromStr = from ? `${from.name || from.email}` : tCommon('unknown');
// When "above quote" is configured, splice signature between the user's
// drafting area and the quoted content so it reads naturally as a closing
// for the reply body. Send-time append is skipped — see
// shouldEmbedSignatureAboveQuote.
const buildEmbeddedSignatureHtml = (): string => {
if (!shouldEmbedSignatureAboveQuote) return '';
if (initialSignatureIdentity?.htmlSignature) {
return `<br><br>-- <br>${sanitizeEmailHtml(initialSignatureIdentity.htmlSignature)}`;
}
if (initialSignatureIdentity?.textSignature) {
return `<br><br>-- <br>${initialSignatureIdentity.textSignature.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/\n/g, '<br>')}`;
}
return '';
};
const signatureBlock = buildEmbeddedSignatureHtml();
const signatureBlock = buildEmbeddedSignatureHtml(initialSignatureIdentity, {
embed: shouldEmbedSignatureAboveQuote,
separator: signatureSeparatorEnabled,
});
// Build quoted content as HTML
if (replyTo.htmlBody && (mode === 'reply' || mode === 'replyAll' || mode === 'forward')) {
@@ -335,6 +360,69 @@ export function EmailComposer({
const signatureIdentity = (currentIdentity?.htmlSignature || currentIdentity?.textSignature)
? currentIdentity
: primaryIdentity;
// Hold the TipTap editor instance so we can swap the embedded signature
// when the user switches identity in "above quote" mode without rebuilding
// the whole body (which would lose user edits to the surrounding draft).
const editorRef = useRef<Editor | null>(null);
const prevSignatureIdentityIdRef = useRef<string | null | undefined>(signatureIdentity?.id);
const prevSignatureSeparatorRef = useRef<boolean>(signatureSeparatorEnabled);
useEffect(() => {
const editor = editorRef.current;
const identityChanged = prevSignatureIdentityIdRef.current !== signatureIdentity?.id;
const separatorChanged = prevSignatureSeparatorRef.current !== signatureSeparatorEnabled;
prevSignatureIdentityIdRef.current = signatureIdentity?.id;
prevSignatureSeparatorRef.current = signatureSeparatorEnabled;
if (!editor) return;
if (!identityChanged && !separatorChanged) return;
if (plainTextMode) return;
if (mode !== 'reply' && mode !== 'replyAll' && mode !== 'forward') return;
if (signaturePosition !== 'above_quote') return;
const currentHtml = editor.getHTML();
const doc = new DOMParser().parseFromString(currentHtml, 'text/html');
const startEl = doc.querySelector('[data-signature-block="separator"], [data-signature-block="start"]');
if (!startEl) return;
const endEl = doc.querySelector('[data-signature-block="end"]');
const newSignature = buildEmbeddedSignatureHtml(signatureIdentity, {
embed: true,
separator: signatureSeparatorEnabled,
});
if (!newSignature) return;
// Build a temporary container holding the replacement nodes so we can
// splice them in without re-serializing/parsing twice.
const replacementHost = doc.createElement('div');
replacementHost.innerHTML = newSignature;
const replacementNodes = Array.from(replacementHost.childNodes);
const parent = startEl.parentNode;
if (!parent) return;
// Remove the existing signature range [startEl … endEl] inclusive, or
// from startEl to the next blockquote if no end marker is present.
const removeUntil = endEl && endEl.parentNode === parent ? endEl : null;
const toRemove: Node[] = [];
let cursor: Node | null = startEl;
while (cursor) {
toRemove.push(cursor);
if (cursor === removeUntil) break;
const next: Node | null = cursor.nextSibling;
if (!removeUntil && next && (next as Element).tagName === 'BLOCKQUOTE') break;
cursor = next;
}
const insertBefore = toRemove[toRemove.length - 1]?.nextSibling ?? null;
toRemove.forEach((node) => parent.removeChild(node));
replacementNodes.forEach((node) => parent.insertBefore(node, insertBefore));
const nextHtml = doc.body.innerHTML;
if (nextHtml !== currentHtml) {
editor.commands.setContent(nextHtml, { emitUpdate: true });
}
}, [signatureIdentity?.id, signatureIdentity?.htmlSignature, signatureIdentity?.textSignature, signatureSeparatorEnabled, signaturePosition, mode, plainTextMode]);
useEffect(() => {
if (!autoSelectReplyIdentity) return;
if (selectedIdentityId || initialData?.selectedIdentityId) return;
@@ -1018,7 +1106,7 @@ export function EmailComposer({
: undefined;
// When the user has typed a From override, that becomes the header From
// (and MIME-builder From in the S/MIME path). The identity still drives
// the SMTP envelope MAIL FROM set explicitly so it doesn't mistakenly
// the SMTP envelope MAIL FROM - set explicitly so it doesn't mistakenly
// default to the override address.
const overrideActive = fromOverrideEnabled && fromOverrideEmail.trim().length > 0;
const fromEmail = overrideActive ? fromOverrideEmail.trim() : identityFromEmail;
@@ -1038,11 +1126,12 @@ export function EmailComposer({
// Build HTML signature block (used only in rich text mode)
const buildSignatureHtml = (): string => {
if (signatureAlreadyInBody) return '';
const sep = signatureSeparatorEnabled ? `<br><br>-- <br>` : `<br><br>`;
if (signatureIdentity?.htmlSignature) {
return `<br><br>-- <br>${sanitizeEmailHtml(signatureIdentity.htmlSignature)}`;
return `${sep}${sanitizeEmailHtml(signatureIdentity.htmlSignature)}`;
}
if (signatureIdentity?.textSignature) {
return `<br><br>-- <br>${signatureIdentity.textSignature.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/\n/g, '<br>')}`;
return `${sep}${signatureIdentity.textSignature.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/\n/g, '<br>')}`;
}
return '';
};
@@ -1053,9 +1142,10 @@ export function EmailComposer({
: null;
// In plain text mode, send text/plain only (no HTML body)
const signatureOpts = { separator: signatureSeparatorEnabled };
const finalBody = plainTextMode
? (signatureAlreadyInBody ? body : appendPlainTextSignature(body, signatureIdentity))
: (signatureAlreadyInBody ? htmlToPlainText(body) : appendPlainTextSignature(htmlToPlainText(body), signatureIdentity));
? (signatureAlreadyInBody ? body : appendPlainTextSignature(body, signatureIdentity, signatureOpts))
: (signatureAlreadyInBody ? htmlToPlainText(body) : appendPlainTextSignature(htmlToPlainText(body), signatureIdentity, signatureOpts));
const rewritten = plainTextMode ? null : rewriteInlineImages(body);
const finalHtmlBody = plainTextMode
@@ -1094,7 +1184,7 @@ export function EmailComposer({
// would produce a signature whose Subject differs from the visible
// From, which most clients reject or flag. Refuse up front.
if (overrideActive) {
throw new Error('Cannot use From override with S/MIME disable one to send.');
throw new Error('Cannot use From override with S/MIME - disable one to send.');
}
// 2. Ensure key is unlocked for signing
@@ -1628,6 +1718,7 @@ export function EmailComposer({
onImageUpload={handleImageUpload}
placeholder={t('body_placeholder')}
hasError={validationErrors.body}
onEditorReady={(ed) => { editorRef.current = ed; }}
/>
</div>
)}
@@ -1638,13 +1729,13 @@ export function EmailComposer({
: plainTextMode ? (
getPlainTextSignature(signatureIdentity) ? (
<div className="px-4 pb-3 text-sm leading-6 text-muted-foreground break-words whitespace-pre-wrap font-mono">
{'-- \n'}{getPlainTextSignature(signatureIdentity)}
{signatureSeparatorEnabled ? '-- \n' : ''}{getPlainTextSignature(signatureIdentity)}
</div>
) : null
) : composerSignatureHtml ? (
<div
className="px-4 pb-3 text-sm leading-6 text-foreground break-words [&_a]:text-primary [&_a]:underline-offset-2 [&_a:hover]:underline"
dangerouslySetInnerHTML={{ __html: `<div>-- </div>${composerSignatureHtml}` }}
dangerouslySetInnerHTML={{ __html: `${signatureSeparatorEnabled ? '<div>-- </div>' : ''}${composerSignatureHtml}` }}
/>
) : null}
</div>
@@ -1702,7 +1793,7 @@ export function EmailComposer({
)}
{/* Bottom toolbar */}
<div className="flex items-center justify-between px-4 py-2.5 border-t bg-background shrink-0">
<div className="flex items-center justify-between px-4 py-2.5 border-t bg-background shrink-0 pb-[calc(env(safe-area-inset-bottom)/2)]">
{/* Left side actions */}
<div className="flex items-center gap-1">
<input
+1 -1
View File
@@ -296,7 +296,7 @@ export function EmailListItem({ email, selected, onClick, onContextMenu, onToggl
? "text-muted-foreground"
: "text-muted-foreground/80"
)}>
{trimmedPreview || "No preview available"}
{trimmedPreview || t('no_preview_available')}
</p>
)}
</>
+368 -328
View File
@@ -2489,7 +2489,7 @@ export function EmailViewer({
}
return {
html: '<p style="color: var(--color-muted-foreground);">No content available</p>',
html: `<p style="color: var(--color-muted-foreground); font-style: italic;">${t('no_body_content')}</p>`,
isHtml: false,
hasStyleTag: false,
};
@@ -2497,7 +2497,7 @@ export function EmailViewer({
// toggling permission imperatively unblocks content via restoreBlockedContent
// in an effect below, so the iframe srcDoc stays stable and doesn't reload/flash.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [email, externalContentPolicy, cidBlobUrls]);
}, [email, externalContentPolicy, cidBlobUrls, t]);
// Override email content with S/MIME decrypted content when available
const effectiveEmailContent = useMemo(() => {
@@ -2740,8 +2740,8 @@ export function EmailViewer({
// i.e. light-on-light. The second rule disables filter on bgcolor-like
// elements that are descendants of another bgcolor-like element.
const darkModeCSS = isDark && !emailHasNativeDarkMode ? `
html { background: #1a1a1a; }
body { filter: invert(1) hue-rotate(180deg); }
html { background: #121212; }
body { filter: invert(1) hue-rotate(180deg); background: #ededed; }
img, video, svg, canvas, object, embed, input[type="image"] {
filter: invert(1) hue-rotate(180deg);
}
@@ -2776,8 +2776,16 @@ 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'";
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}">
<style>
body { margin: 0; padding: ${bodyPadding}; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; font-size: 14px; line-height: 1.6; color: #1a1a1a; background: #ffffff; word-wrap: break-word; overflow-wrap: break-word; }
img { max-width: 100% !important; height: auto !important; }
@@ -2854,7 +2862,10 @@ export function EmailViewer({
// True while the new email's body is still being fetched. Catches the
// window between selectedEmail changing and isLoading flipping true, so the
// quick reply / body don't flicker through a partial render.
const isBodyLoading = isLoading || !email?.bodyValues || Object.keys(email.bodyValues).length === 0;
// An empty bodyValues with no referenced parts means the email has no body
// (e.g. calendar-only invites) — not "still loading".
const hasBodyParts = (email?.textBody?.length ?? 0) > 0 || (email?.htmlBody?.length ?? 0) > 0;
const isBodyLoading = isLoading || (hasBodyParts && (!email?.bodyValues || Object.keys(email.bodyValues).length === 0));
// Gates the quick reply on the iframe having loaded the current srcDoc, so
// it doesn't flash in below a still-resizing iframe.
@@ -4028,7 +4039,8 @@ export function EmailViewer({
)}
{/* Email Content Area */}
<div className={cn("flex-1 overflow-auto overscroll-contain bg-muted/30", isMobile && "pb-16")}>
<div className={cn("flex-1 overflow-auto overscroll-contain bg-muted/30", isMobile && "pb-[calc(3.25rem+env(safe-area-inset-bottom)/2)] sm:pb-0")}>
<div className="min-h-full flex flex-col">
{/* === SENDER INFO (Desktop) === */}
<div className="hidden lg:block bg-background border-b border-border px-6" style={{ paddingBlock: 'var(--density-header-py)' }}>
@@ -4052,13 +4064,16 @@ export function EmailViewer({
<div>
<div className="min-w-0">
<div className="flex items-center gap-2 flex-wrap">
<button
onClick={() => sender?.email && handleViewContactSidebar(null, sender.email)}
className="font-semibold text-foreground hover:text-primary hover:underline transition-colors cursor-pointer text-left"
title={t('view_contact')}
>
{sender?.name || sender?.email || t('unknown_sender')}
</button>
{sender?.email ? (
<RecipientPopover
name={sender?.name}
email={sender.email}
onViewContact={handleViewContactSidebar}
className="font-semibold text-left"
/>
) : (
<span className="font-semibold text-foreground">{t('unknown_sender')}</span>
)}
<EmailIdentityBadge email={email} identities={identities} />
{shouldShowUnsubBanner && listHeaders?.listUnsubscribe && (
<UnsubscribeBanner
@@ -4134,310 +4149,6 @@ export function EmailViewer({
</button>
</div>
{/* Expandable Details */}
{showFullHeaders && (() => {
const translateAuthResult = (result?: string) => {
const r = (result || '').toLowerCase();
switch (r) {
case 'pass': return t('authentication.result.pass');
case 'fail': return t('authentication.result.fail');
case 'softfail': return t('authentication.result.softfail');
case 'neutral': return t('authentication.result.neutral');
case 'permerror': return t('authentication.result.permerror');
case 'temperror': return t('authentication.result.temperror');
case 'none': return t('authentication.result.none');
default: return result || '';
}
};
const replyToDifferent = !!email.replyTo?.length &&
(!email.from || email.replyTo[0].email !== email.from[0]?.email);
const deliveryDeltaMs = email.sentAt && email.receivedAt
? Math.abs(new Date(email.receivedAt).getTime() - new Date(email.sentAt).getTime())
: 0;
const formatDelta = (diff: number) => {
const minutes = Math.floor(diff / 60000);
const hours = Math.floor(minutes / 60);
const days = Math.floor(hours / 24);
const dayUnit = days > 1 ? t('time.days') : t('time.day');
const hourUnit = (hours % 24) > 1 ? t('time.hours') : t('time.hour');
const minuteUnit = (minutes % 60) > 1 ? t('time.minutes') : t('time.minute');
const minuteUnitSingle = minutes > 1 ? t('time.minutes') : t('time.minute');
if (days > 0) return `${days} ${dayUnit} ${hours % 24} ${hourUnit}`;
if (hours > 0) return `${hours} ${hourUnit} ${minutes % 60} ${minuteUnit}`;
return `${minutes} ${minuteUnitSingle}`;
};
const fullDate = (iso?: string) => iso
? formatDateTime(iso, timeFormat, { weekday: 'long', year: 'numeric', month: 'long', day: 'numeric', second: '2-digit', timeZoneName: 'short' })
: '-';
const auth = email.authenticationResults;
const totalAttachmentSize = effectiveAttachments.reduce((s, a) => s + (a.size || 0), 0);
const topMimeType = email.bodyStructure?.type;
const SectionHeader = ({ children }: { children: React.ReactNode }) => (
<div className="text-[10px] font-semibold tracking-wider text-muted-foreground uppercase mb-1.5">
{children}
</div>
);
const Row = ({ label, children, mono }: { label: string; children: React.ReactNode; mono?: boolean }) => (
<>
<dt className="text-muted-foreground text-xs pt-1">{label}</dt>
<dd className={cn(
"text-sm text-foreground min-w-0 break-words",
mono && "font-mono text-xs",
)}>{children}</dd>
</>
);
const AuthChip = ({ name, result, extra, tooltip }: { name: string; result?: string; extra?: React.ReactNode; tooltip?: string }) => {
if (!result) return null;
const status = getSecurityStatus(result);
const Icon = status.icon === 'check' ? Check
: status.icon === 'x' ? X
: status.icon === 'alert' ? AlertTriangle
: Minus;
return (
<span
className={cn(
"inline-flex items-center gap-1.5 px-2 py-1 rounded-md border text-xs",
tooltip && "cursor-help",
status.icon === 'check' && "bg-green-500/[0.07] border-green-500/30",
status.icon === 'x' && "bg-red-500/[0.07] border-red-500/30",
status.icon === 'alert' && "bg-amber-500/[0.07] border-amber-500/30",
status.icon === 'minus' && "bg-muted/40 border-border",
)}
title={tooltip}
>
<Icon className={cn("w-3.5 h-3.5 flex-shrink-0", status.color)} />
<span className="font-medium text-foreground">{name}</span>
<span className={cn("text-[10px] uppercase tracking-wider", status.color)}>
{translateAuthResult(result)}
</span>
{extra && (
<>
<span className="text-muted-foreground/50">·</span>
<span className="text-muted-foreground">{extra}</span>
</>
)}
</span>
);
};
const hasIdentifiers = !!(email.messageId || email.inReplyTo?.length || email.references?.length || email.threadId);
const hasListInfo = !!(listHeaders?.listId || listHeaders?.listUnsubscribe || listHeaders?.listHelp || listHeaders?.listPost);
const hasAuthSection = !!(auth?.spf || auth?.dkim || auth?.dmarc || auth?.iprev || email.spamScore !== undefined || email.spamLLM);
return (
<div className="mt-3 pt-3 border-t border-border grid grid-cols-1 lg:grid-cols-2 gap-x-10 gap-y-5">
<section>
<SectionHeader>{t('details.recipients_routing')}</SectionHeader>
<dl className="grid grid-cols-[7rem_1fr] gap-x-4 gap-y-1.5">
<Row label={t('from')}>
<div className="flex flex-wrap items-center gap-1">
<RecipientPopover
name={sender?.name}
email={sender?.email || ''}
displayLabel={sender?.name && sender?.email ? `${sender.name} <${sender.email}>` : undefined}
onViewContact={handleViewContactSidebar}
className="text-sm text-left"
/>
</div>
</Row>
{replyToDifferent && (
<Row label={t('reply_to_label').replace(':', '')}>
<div className="flex flex-wrap items-center gap-1">
{email.replyTo!.map((r, i) => (
<RecipientPopover key={r.email + i} name={r.name} email={r.email} onViewContact={handleViewContactSidebar} className="text-sm" />
))}
</div>
</Row>
)}
{email.to && email.to.length > 0 && (
<Row label={t('to')}>
<div className="flex flex-wrap items-center gap-1">
{renderClickableRecipients(email.to, currentUserEmail, t, handleViewContactSidebar, 100)}
</div>
</Row>
)}
{email.cc && email.cc.length > 0 && (
<Row label={t('cc')}>
<div className="flex flex-wrap items-center gap-1">
{renderClickableRecipients(email.cc, currentUserEmail, t, handleViewContactSidebar, 100)}
</div>
</Row>
)}
{email.bcc && email.bcc.length > 0 && (
<Row label={t('bcc')}>
<div className="flex flex-wrap items-center gap-1">
{renderClickableRecipients(email.bcc, currentUserEmail, t, handleViewContactSidebar, 100)}
</div>
</Row>
)}
{email.sentAt && (
<Row label={t('details.sent')}>{fullDate(email.sentAt)}</Row>
)}
<Row label={t('details.received')}>
{fullDate(email.receivedAt)}
{deliveryDeltaMs > 60000 && (
<span className="text-muted-foreground"> · {formatDelta(deliveryDeltaMs)} {t('details.delivery_time').toLowerCase()}</span>
)}
</Row>
</dl>
</section>
{hasAuthSection && (
<section>
<SectionHeader>{t('details.authentication_security')}</SectionHeader>
<div className="flex flex-wrap gap-1.5">
{auth?.spf && (
<AuthChip name="SPF" result={auth.spf.result} extra={auth.spf.domain} tooltip={t('authentication.tooltip_spf')} />
)}
{auth?.dkim && (
<AuthChip name="DKIM" result={auth.dkim.result} extra={auth.dkim.domain} tooltip={t('authentication.tooltip_dkim')} />
)}
{auth?.dmarc && (
<AuthChip name="DMARC" result={auth.dmarc.result} extra={auth.dmarc.policy ? `${t('authentication.policy').toLowerCase()}: ${auth.dmarc.policy}` : undefined} tooltip={t('authentication.tooltip_dmarc')} />
)}
{auth?.iprev && (
<AuthChip name={t('details.iprev')} result={auth.iprev.result} extra={auth.iprev.ip} />
)}
{email.spamScore !== undefined && (
<span className={cn(
"inline-flex items-center gap-1.5 px-2 py-1 rounded-md border text-xs",
email.spamScore > 5 ? "bg-red-500/[0.07] border-red-500/30" :
email.spamScore > 2 ? "bg-amber-500/[0.07] border-amber-500/30" :
"bg-green-500/[0.07] border-green-500/30",
)}>
<Shield className={cn(
"w-3.5 h-3.5",
email.spamScore > 5 ? "text-red-700 dark:text-red-400" :
email.spamScore > 2 ? "text-amber-700 dark:text-amber-400" :
"text-green-700 dark:text-green-400",
)} />
<span className="font-medium text-foreground">{t('authentication.spam_score')}</span>
<span className={cn(
"text-[10px] uppercase tracking-wider",
email.spamScore > 5 ? "text-red-700 dark:text-red-400" :
email.spamScore > 2 ? "text-amber-700 dark:text-amber-400" :
"text-green-700 dark:text-green-400",
)}>
{email.spamScore.toFixed(1)}
</span>
{email.spamStatus && (
<>
<span className="text-muted-foreground/50">·</span>
<span className="text-muted-foreground">{email.spamStatus}</span>
</>
)}
</span>
)}
</div>
{email.spamLLM && (
<div className="mt-2 flex items-start gap-2 text-sm">
{email.spamLLM.verdict === 'LEGITIMATE' ? <Brain className="w-4 h-4 mt-0.5 flex-shrink-0 text-green-700 dark:text-green-400" /> :
email.spamLLM.verdict === 'SPAM' ? <ShieldAlert className="w-4 h-4 mt-0.5 flex-shrink-0 text-red-700 dark:text-red-400" /> :
<AlertTriangle className="w-4 h-4 mt-0.5 flex-shrink-0 text-amber-700 dark:text-amber-400" />}
<div className="min-w-0">
<span className={cn(
"font-medium",
email.spamLLM.verdict === 'LEGITIMATE' ? "text-green-700 dark:text-green-400" :
email.spamLLM.verdict === 'SPAM' ? "text-red-700 dark:text-red-400" :
"text-amber-700 dark:text-amber-400",
)}>
{email.spamLLM.verdict}
</span>
<span className="text-muted-foreground"> · {email.spamLLM.explanation}</span>
</div>
</div>
)}
</section>
)}
{hasIdentifiers && (
<section>
<SectionHeader>{t('details.identifiers_threading')}</SectionHeader>
<dl className="grid grid-cols-[7rem_1fr] gap-x-4 gap-y-1.5">
{email.messageId && (
<Row label={t('headers.message_id')} mono>{email.messageId}</Row>
)}
{email.inReplyTo && email.inReplyTo.length > 0 && (
<Row label={t('details.in_reply_to')} mono>
<div className="space-y-0.5">
{email.inReplyTo.map((id, i) => <div key={i} className="break-all">{id}</div>)}
</div>
</Row>
)}
{email.references && email.references.length > 0 && (
<Row label={t('details.references')}>
<details className="group">
<summary className="cursor-pointer text-sm text-muted-foreground hover:text-foreground transition-colors list-none flex items-center gap-1">
<ChevronDown className="w-3 h-3 group-open:rotate-180 transition-transform" />
{t(email.references.length === 1 ? 'previous_messages' : 'previous_messages_plural', { count: email.references.length })}
</summary>
<div className="mt-1 space-y-0.5 font-mono text-xs">
{email.references.map((id, i) => <div key={i} className="break-all">{id}</div>)}
</div>
</details>
</Row>
)}
{email.threadId && (
<Row label={t('details.thread_id')} mono>{email.threadId}</Row>
)}
</dl>
</section>
)}
<section>
<SectionHeader>{t('details.message_properties')}</SectionHeader>
<dl className="grid grid-cols-[7rem_1fr] gap-x-4 gap-y-1.5">
{email.subject !== undefined && (
<Row label={t('subject')}>{email.subject || <span className="italic text-muted-foreground">{t('details.no_subject')}</span>}</Row>
)}
<Row label={t('details.size')}>
{formatFileSize(email.size)}
{topMimeType && (
<span className="text-muted-foreground"> · <span className="font-mono text-xs">{topMimeType}</span></span>
)}
</Row>
{effectiveAttachments.length > 0 && (
<Row label={t('attachments')}>
{t('details.attachments_summary', {
count: effectiveAttachments.length,
size: formatFileSize(totalAttachmentSize),
})}
</Row>
)}
{email.accountLabel && (
<Row label={t('details.account')}>{email.accountLabel}</Row>
)}
</dl>
</section>
{hasListInfo && (
<section className="lg:col-span-2">
<SectionHeader>{t('details.mailing_list')}</SectionHeader>
<dl className="grid grid-cols-[7rem_1fr] gap-x-4 gap-y-1.5">
{listHeaders?.listId && (
<Row label={t('details.list_id')} mono>{listHeaders.listId}</Row>
)}
{listHeaders?.listUnsubscribe?.preferred && (
<Row label={t('details.list_unsubscribe')}>
<span className="break-all">
{listHeaders.listUnsubscribe.preferred === 'http'
? listHeaders.listUnsubscribe.http
: listHeaders.listUnsubscribe.mailto}
</span>
</Row>
)}
{listHeaders?.listHelp && (
<Row label={t('details.list_help')}><span className="break-all">{listHeaders.listHelp}</span></Row>
)}
{listHeaders?.listPost && (
<Row label={t('details.list_post')}><span className="break-all">{listHeaders.listPost}</span></Row>
)}
</dl>
</section>
)}
</div>
);
})()}
</div>
{/* Attachments on the right (beside-sender mode) */}
@@ -4597,12 +4308,16 @@ export function EmailViewer({
<div className="flex-1 min-w-0">
{/* Row 1: Sender name + badges */}
<div className="flex items-center gap-1.5 flex-wrap">
<button
onClick={() => sender?.email && handleViewContactSidebar(null, sender.email)}
className="text-sm font-semibold text-foreground hover:text-primary hover:underline transition-colors cursor-pointer text-left"
>
{sender?.name || sender?.email || t('unknown_sender')}
</button>
{sender?.email ? (
<RecipientPopover
name={sender?.name}
email={sender.email}
onViewContact={handleViewContactSidebar}
className="text-sm font-semibold text-left"
/>
) : (
<span className="text-sm font-semibold text-foreground">{t('unknown_sender')}</span>
)}
<EmailIdentityBadge email={email} identities={identities} />
{shouldShowUnsubBanner && listHeaders?.listUnsubscribe && (
<UnsubscribeBanner
@@ -4639,6 +4354,22 @@ export function EmailViewer({
)}
</>
)}
<button
onClick={() => setShowFullHeaders(!showFullHeaders)}
className="text-xs text-muted-foreground hover:text-foreground flex items-center gap-0.5 transition-colors ml-1"
>
{showFullHeaders ? (
<>
<ChevronUp className="w-3 h-3" />
{t('hide_details')}
</>
) : (
<>
<ChevronDown className="w-3 h-3" />
{t('show_details')}
</>
)}
</button>
</div>
</div>
{/* Date/time + size on the right (mobile) */}
@@ -4655,6 +4386,313 @@ export function EmailViewer({
</div>
</div>
{/* Expandable Details (shared across mobile/tablet/desktop) */}
{showFullHeaders && (() => {
const translateAuthResult = (result?: string) => {
const r = (result || '').toLowerCase();
switch (r) {
case 'pass': return t('authentication.result.pass');
case 'fail': return t('authentication.result.fail');
case 'softfail': return t('authentication.result.softfail');
case 'neutral': return t('authentication.result.neutral');
case 'permerror': return t('authentication.result.permerror');
case 'temperror': return t('authentication.result.temperror');
case 'none': return t('authentication.result.none');
default: return result || '';
}
};
const replyToDifferent = !!email.replyTo?.length &&
(!email.from || email.replyTo[0].email !== email.from[0]?.email);
const deliveryDeltaMs = email.sentAt && email.receivedAt
? Math.abs(new Date(email.receivedAt).getTime() - new Date(email.sentAt).getTime())
: 0;
const formatDelta = (diff: number) => {
const minutes = Math.floor(diff / 60000);
const hours = Math.floor(minutes / 60);
const days = Math.floor(hours / 24);
const dayUnit = days > 1 ? t('time.days') : t('time.day');
const hourUnit = (hours % 24) > 1 ? t('time.hours') : t('time.hour');
const minuteUnit = (minutes % 60) > 1 ? t('time.minutes') : t('time.minute');
const minuteUnitSingle = minutes > 1 ? t('time.minutes') : t('time.minute');
if (days > 0) return `${days} ${dayUnit} ${hours % 24} ${hourUnit}`;
if (hours > 0) return `${hours} ${hourUnit} ${minutes % 60} ${minuteUnit}`;
return `${minutes} ${minuteUnitSingle}`;
};
const fullDate = (iso?: string) => iso
? formatDateTime(iso, timeFormat, { weekday: 'long', year: 'numeric', month: 'long', day: 'numeric', second: '2-digit', timeZoneName: 'short' })
: '-';
const auth = email.authenticationResults;
const totalAttachmentSize = effectiveAttachments.reduce((s, a) => s + (a.size || 0), 0);
const topMimeType = email.bodyStructure?.type;
const SectionHeader = ({ children }: { children: React.ReactNode }) => (
<div className="text-[10px] font-semibold tracking-wider text-muted-foreground uppercase mb-1.5">
{children}
</div>
);
const Row = ({ label, children, mono }: { label: string; children: React.ReactNode; mono?: boolean }) => (
<>
<dt className="text-muted-foreground text-xs pt-1">{label}</dt>
<dd className={cn(
"text-sm text-foreground min-w-0 break-words",
mono && "font-mono text-xs",
)}>{children}</dd>
</>
);
const AuthChip = ({ name, result, extra, tooltip }: { name: string; result?: string; extra?: React.ReactNode; tooltip?: string }) => {
if (!result) return null;
const status = getSecurityStatus(result);
const Icon = status.icon === 'check' ? Check
: status.icon === 'x' ? X
: status.icon === 'alert' ? AlertTriangle
: Minus;
return (
<span
className={cn(
"inline-flex items-center gap-1.5 px-2 py-1 rounded-md border text-xs",
tooltip && "cursor-help",
status.icon === 'check' && "bg-green-500/[0.07] border-green-500/30",
status.icon === 'x' && "bg-red-500/[0.07] border-red-500/30",
status.icon === 'alert' && "bg-amber-500/[0.07] border-amber-500/30",
status.icon === 'minus' && "bg-muted/40 border-border",
)}
title={tooltip}
>
<Icon className={cn("w-3.5 h-3.5 flex-shrink-0", status.color)} />
<span className="font-medium text-foreground">{name}</span>
<span className={cn("text-[10px] uppercase tracking-wider", status.color)}>
{translateAuthResult(result)}
</span>
{extra && (
<>
<span className="text-muted-foreground/50">·</span>
<span className="text-muted-foreground">{extra}</span>
</>
)}
</span>
);
};
const hasIdentifiers = !!(email.messageId || email.inReplyTo?.length || email.references?.length || email.threadId);
const hasListInfo = !!(listHeaders?.listId || listHeaders?.listUnsubscribe || listHeaders?.listHelp || listHeaders?.listPost);
const hasAuthSection = !!(auth?.spf || auth?.dkim || auth?.dmarc || auth?.iprev || email.spamScore !== undefined || email.spamLLM);
return (
<div className="bg-background border-b border-border px-4 lg:px-6" style={{ paddingBlock: 'var(--density-header-py)' }}>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-x-10 gap-y-5">
<section className="min-w-0">
<SectionHeader>{t('details.recipients_routing')}</SectionHeader>
<dl className="grid grid-cols-[7rem_1fr] gap-x-4 gap-y-1.5">
<Row label={t('from')}>
<div className="flex flex-wrap items-center gap-1">
<RecipientPopover
name={sender?.name}
email={sender?.email || ''}
displayLabel={sender?.name && sender?.email ? `${sender.name} <${sender.email}>` : undefined}
onViewContact={handleViewContactSidebar}
className="text-sm text-left"
/>
</div>
</Row>
{replyToDifferent && (
<Row label={t('reply_to_label').replace(':', '')}>
<div className="flex flex-wrap items-center gap-1">
{email.replyTo!.map((r, i) => (
<RecipientPopover key={r.email + i} name={r.name} email={r.email} onViewContact={handleViewContactSidebar} className="text-sm" />
))}
</div>
</Row>
)}
{email.to && email.to.length > 0 && (
<Row label={t('to')}>
<div className="flex flex-wrap items-center gap-1">
{renderClickableRecipients(email.to, currentUserEmail, t, handleViewContactSidebar, 100)}
</div>
</Row>
)}
{email.cc && email.cc.length > 0 && (
<Row label={t('cc')}>
<div className="flex flex-wrap items-center gap-1">
{renderClickableRecipients(email.cc, currentUserEmail, t, handleViewContactSidebar, 100)}
</div>
</Row>
)}
{email.bcc && email.bcc.length > 0 && (
<Row label={t('bcc')}>
<div className="flex flex-wrap items-center gap-1">
{renderClickableRecipients(email.bcc, currentUserEmail, t, handleViewContactSidebar, 100)}
</div>
</Row>
)}
{email.sentAt && (
<Row label={t('details.sent')}>{fullDate(email.sentAt)}</Row>
)}
<Row label={t('details.received')}>
{fullDate(email.receivedAt)}
{deliveryDeltaMs > 60000 && (
<span className="text-muted-foreground"> · {formatDelta(deliveryDeltaMs)} {t('details.delivery_time').toLowerCase()}</span>
)}
</Row>
</dl>
</section>
{hasAuthSection && (
<section className="min-w-0">
<SectionHeader>{t('details.authentication_security')}</SectionHeader>
<div className="flex flex-wrap gap-1.5">
{auth?.spf && (
<AuthChip name="SPF" result={auth.spf.result} extra={auth.spf.domain} tooltip={t('authentication.tooltip_spf')} />
)}
{auth?.dkim && (
<AuthChip name="DKIM" result={auth.dkim.result} extra={auth.dkim.domain} tooltip={t('authentication.tooltip_dkim')} />
)}
{auth?.dmarc && (
<AuthChip name="DMARC" result={auth.dmarc.result} extra={auth.dmarc.policy ? `${t('authentication.policy').toLowerCase()}: ${auth.dmarc.policy}` : undefined} tooltip={t('authentication.tooltip_dmarc')} />
)}
{auth?.iprev && (
<AuthChip name={t('details.iprev')} result={auth.iprev.result} extra={auth.iprev.ip} />
)}
{email.spamScore !== undefined && (
<span className={cn(
"inline-flex items-center gap-1.5 px-2 py-1 rounded-md border text-xs",
email.spamScore > 5 ? "bg-red-500/[0.07] border-red-500/30" :
email.spamScore > 2 ? "bg-amber-500/[0.07] border-amber-500/30" :
"bg-green-500/[0.07] border-green-500/30",
)}>
<Shield className={cn(
"w-3.5 h-3.5",
email.spamScore > 5 ? "text-red-700 dark:text-red-400" :
email.spamScore > 2 ? "text-amber-700 dark:text-amber-400" :
"text-green-700 dark:text-green-400",
)} />
<span className="font-medium text-foreground">{t('authentication.spam_score')}</span>
<span className={cn(
"text-[10px] uppercase tracking-wider",
email.spamScore > 5 ? "text-red-700 dark:text-red-400" :
email.spamScore > 2 ? "text-amber-700 dark:text-amber-400" :
"text-green-700 dark:text-green-400",
)}>
{email.spamScore.toFixed(1)}
</span>
{email.spamStatus && (
<>
<span className="text-muted-foreground/50">·</span>
<span className="text-muted-foreground">{email.spamStatus}</span>
</>
)}
</span>
)}
</div>
{email.spamLLM && (
<div className="mt-2 flex items-start gap-2 text-sm">
{email.spamLLM.verdict === 'LEGITIMATE' ? <Brain className="w-4 h-4 mt-0.5 flex-shrink-0 text-green-700 dark:text-green-400" /> :
email.spamLLM.verdict === 'SPAM' ? <ShieldAlert className="w-4 h-4 mt-0.5 flex-shrink-0 text-red-700 dark:text-red-400" /> :
<AlertTriangle className="w-4 h-4 mt-0.5 flex-shrink-0 text-amber-700 dark:text-amber-400" />}
<div className="min-w-0">
<span className={cn(
"font-medium",
email.spamLLM.verdict === 'LEGITIMATE' ? "text-green-700 dark:text-green-400" :
email.spamLLM.verdict === 'SPAM' ? "text-red-700 dark:text-red-400" :
"text-amber-700 dark:text-amber-400",
)}>
{email.spamLLM.verdict}
</span>
<span className="text-muted-foreground"> · {email.spamLLM.explanation}</span>
</div>
</div>
)}
</section>
)}
{hasIdentifiers && (
<section className="min-w-0">
<SectionHeader>{t('details.identifiers_threading')}</SectionHeader>
<dl className="grid grid-cols-[7rem_1fr] gap-x-4 gap-y-1.5">
{email.messageId && (
<Row label={t('headers.message_id')} mono>{email.messageId}</Row>
)}
{email.inReplyTo && email.inReplyTo.length > 0 && (
<Row label={t('details.in_reply_to')} mono>
<div className="space-y-0.5">
{email.inReplyTo.map((id, i) => <div key={i} className="break-all">{id}</div>)}
</div>
</Row>
)}
{email.references && email.references.length > 0 && (
<Row label={t('details.references')}>
<details className="group">
<summary className="cursor-pointer text-sm text-muted-foreground hover:text-foreground transition-colors list-none flex items-center gap-1">
<ChevronDown className="w-3 h-3 group-open:rotate-180 transition-transform" />
{t(email.references.length === 1 ? 'previous_messages' : 'previous_messages_plural', { count: email.references.length })}
</summary>
<div className="mt-1 space-y-0.5 font-mono text-xs">
{email.references.map((id, i) => <div key={i} className="break-all">{id}</div>)}
</div>
</details>
</Row>
)}
{email.threadId && (
<Row label={t('details.thread_id')} mono>{email.threadId}</Row>
)}
</dl>
</section>
)}
<section className="min-w-0">
<SectionHeader>{t('details.message_properties')}</SectionHeader>
<dl className="grid grid-cols-[7rem_1fr] gap-x-4 gap-y-1.5">
{email.subject !== undefined && (
<Row label={t('subject')}>{email.subject || <span className="italic text-muted-foreground">{t('details.no_subject')}</span>}</Row>
)}
<Row label={t('details.size')}>
{formatFileSize(email.size)}
{topMimeType && (
<span className="text-muted-foreground"> · <span className="font-mono text-xs">{topMimeType}</span></span>
)}
</Row>
{effectiveAttachments.length > 0 && (
<Row label={t('attachments')}>
{t('details.attachments_summary', {
count: effectiveAttachments.length,
size: formatFileSize(totalAttachmentSize),
})}
</Row>
)}
{email.accountLabel && (
<Row label={t('details.account')}>{email.accountLabel}</Row>
)}
</dl>
</section>
{hasListInfo && (
<section className="lg:col-span-2 min-w-0">
<SectionHeader>{t('details.mailing_list')}</SectionHeader>
<dl className="grid grid-cols-[7rem_1fr] gap-x-4 gap-y-1.5">
{listHeaders?.listId && (
<Row label={t('details.list_id')} mono>{listHeaders.listId}</Row>
)}
{listHeaders?.listUnsubscribe?.preferred && (
<Row label={t('details.list_unsubscribe')}>
<span className="break-all">
{listHeaders.listUnsubscribe.preferred === 'http'
? listHeaders.listUnsubscribe.http
: listHeaders.listUnsubscribe.mailto}
</span>
</Row>
)}
{listHeaders?.listHelp && (
<Row label={t('details.list_help')}><span className="break-all">{listHeaders.listHelp}</span></Row>
)}
{listHeaders?.listPost && (
<Row label={t('details.list_post')}><span className="break-all">{listHeaders.listPost}</span></Row>
)}
</dl>
</section>
)}
</div>
</div>
);
})()}
{/* S/MIME Status Banner */}
{smimeStatus && (
<div className="border-b border-border bg-muted/30">
@@ -5091,13 +5129,14 @@ export function EmailViewer({
</div>
)}
<div>
<div className="grow shrink-0 flex flex-col">
{/* Email Body */}
<div className={cn(
"email-content-wrapper overflow-x-auto",
!isDark && resolvedTheme === 'dark' ? "bg-white email-content-light" : "bg-background"
)}>
)}
style={isDark ? { backgroundColor: '#121212' } : undefined}>
{isBodyLoading ? (
<div
className="space-y-3 px-6 py-4 animate-pulse"
@@ -5137,7 +5176,7 @@ export function EmailViewer({
<PluginSlot name="email-footer" />
{/* Quick Reply Section - hidden for drafts and while loading a new email */}
{!isDraft && !isBodyLoading && (effectiveEmailContent.isHtml ? iframeReady : true) && (<div className="bg-background border-t border-border px-6" style={{ paddingBlock: 'var(--density-header-py)' }}>
{!isDraft && !isBodyLoading && (effectiveEmailContent.isHtml ? iframeReady : true) && (<div className="bg-background border-t border-border px-6 mt-auto" style={{ paddingBlock: 'var(--density-header-py)' }}>
<div className="flex items-start" style={{ gap: 'var(--density-item-gap)' }}>
<div className="flex-shrink-0">
<Avatar
@@ -5232,6 +5271,7 @@ export function EmailViewer({
</div>)}
</div>
</div>
</div>
{/* Email Source Modal */}
{showSourceModal && email && (
@@ -5283,7 +5323,7 @@ export function EmailViewer({
{/* Mobile bottom action bar */}
{isMobile && (
<nav className="fixed bottom-0 left-0 right-0 z-[50] bg-background border-t border-border sm:hidden overflow-hidden">
<nav className="fixed bottom-0 left-0 right-0 z-50 bg-background border-t border-border sm:hidden overflow-hidden pb-[calc(env(safe-area-inset-bottom)/2)]">
<div className="flex items-center overflow-x-auto mobile-scroll-hidden">
<button
onClick={onNavigatePrev}
@@ -5433,4 +5473,4 @@ export function EmailViewer({
</div>
);
}
}
+1 -1
View File
@@ -127,7 +127,7 @@ export function RecipientPopover({ name, email, displayLabel, onViewContact, cla
ref={triggerRef}
onClick={handleOpen}
className={cn(
"text-foreground hover:text-primary hover:underline cursor-pointer transition-colors",
"text-foreground hover:text-primary hover:underline cursor-pointer transition-colors min-w-0 break-words",
className
)}
>
+62 -2
View File
@@ -1,8 +1,10 @@
"use client";
import React, { useEffect, useCallback, useState, useRef } from "react";
import { useEditor, EditorContent } from "@tiptap/react";
import { useEditor, EditorContent, type Editor } from "@tiptap/react";
import StarterKit from "@tiptap/starter-kit";
import Paragraph from "@tiptap/extension-paragraph";
import Heading from "@tiptap/extension-heading";
import Underline from "@tiptap/extension-underline";
import Link from "@tiptap/extension-link";
import TextAlign from "@tiptap/extension-text-align";
@@ -44,6 +46,51 @@ export interface InlineImageUpload {
cid?: string;
}
// Pasted email content (signatures, replies, quoted text) commonly carries
// inline styles on block elements. StarterKit's default Paragraph/Heading
// drop unknown attributes; extend them to round-trip `style` and `class` so
// signature formatting survives the editor.
const styledBlockAttributes = {
style: {
default: null as string | null,
parseHTML: (el: HTMLElement) => el.getAttribute("style"),
renderHTML: (attrs: Record<string, string | null>) =>
attrs.style ? { style: attrs.style } : {},
},
class: {
default: null as string | null,
parseHTML: (el: HTMLElement) => el.getAttribute("class"),
renderHTML: (attrs: Record<string, string | null>) =>
attrs.class ? { class: attrs.class } : {},
},
"data-signature-block": {
default: null as string | null,
parseHTML: (el: HTMLElement) => el.getAttribute("data-signature-block"),
renderHTML: (attrs: Record<string, string | null>) =>
attrs["data-signature-block"]
? { "data-signature-block": attrs["data-signature-block"] }
: {},
},
};
const StyledParagraph = Paragraph.extend({
addAttributes() {
return {
...this.parent?.(),
...styledBlockAttributes,
};
},
});
const StyledHeading = Heading.extend({
addAttributes() {
return {
...this.parent?.(),
...styledBlockAttributes,
};
},
});
interface RichTextEditorProps {
content: string;
onChange: (html: string) => void;
@@ -51,6 +98,7 @@ interface RichTextEditorProps {
placeholder?: string;
className?: string;
hasError?: boolean;
onEditorReady?: (editor: Editor) => void;
}
function ToolbarButton({
@@ -131,17 +179,23 @@ export function RichTextEditor({
placeholder,
className,
hasError,
onEditorReady,
}: RichTextEditorProps) {
const onImageUploadRef = React.useRef(onImageUpload);
onImageUploadRef.current = onImageUpload;
const onEditorReadyRef = React.useRef(onEditorReady);
onEditorReadyRef.current = onEditorReady;
const editor = useEditor({
extensions: [
StarterKit.configure({
heading: { levels: [1, 2] },
heading: false,
paragraph: false,
link: false,
underline: false,
}),
StyledParagraph,
StyledHeading.configure({ levels: [1, 2] }),
Underline,
Link.configure({
openOnClick: false,
@@ -239,6 +293,12 @@ export function RichTextEditor({
}
}, [content, editor]);
// Expose the editor instance once it's ready so parents can target
// specific nodes (e.g. swap the embedded signature on identity change).
useEffect(() => {
if (editor) onEditorReadyRef.current?.(editor);
}, [editor]);
const addLink = useCallback(() => {
if (!editor) return;
const previousUrl = editor.getAttributes("link").href;
+69 -14
View File
@@ -1,6 +1,6 @@
"use client";
import { useState, useEffect, useMemo } from "react";
import { useState, useEffect, useMemo, useRef, useCallback } from "react";
import DOMPurify from "dompurify";
import { Email, ThreadGroup } from "@/lib/jmap/types";
import { EMAIL_SANITIZE_CONFIG, collapseBlockedImageContainers, plainTextToSafeHtml } from "@/lib/email-sanitization";
@@ -440,6 +440,49 @@ function EmailCard({
return { html: "", isHtml: false };
}, [email, allowExternal, resolvedTheme, emailAlwaysLightMode, cidBlobUrls]);
// Render the sanitized HTML body inside a sandboxed iframe so a malicious
// (or accidentally-bypassed) email cannot inject styles/scripts/forms into
// the host page. CSP <meta> is defense-in-depth in case the sanitizer ever
// emits a <script> tag through a parser quirk.
const iframeRef = useRef<HTMLIFrameElement>(null);
const emailIframeSrcDoc = useMemo(() => {
if (!emailContent.isHtml || !emailContent.html) return '';
const csp = "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><head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta http-equiv="Content-Security-Policy" content="${csp}">
<style>
body { margin: 0; padding: 0; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; font-size: 14px; line-height: 1.6; color: #1a1a1a; background: #ffffff; word-wrap: break-word; overflow-wrap: break-word; }
img { max-width: 100% !important; height: auto !important; }
a { color: #1a73e8; }
table { max-width: 100% !important; table-layout: auto; overflow-wrap: break-word; }
td, th { word-break: break-word; padding: 0.5rem; }
pre { white-space: pre-wrap; word-wrap: break-word; }
</style></head><body>${emailContent.html}</body></html>`;
}, [emailContent.isHtml, emailContent.html]);
const handleIframeLoad = useCallback(() => {
const iframe = iframeRef.current;
if (!iframe) return;
try {
const doc = iframe.contentDocument;
if (!doc?.body) return;
const resize = () => {
iframe.style.height = doc.documentElement.scrollHeight + 'px';
};
resize();
const ro = new ResizeObserver(resize);
ro.observe(doc.body);
doc.querySelectorAll('a').forEach((a) => {
a.setAttribute('target', '_blank');
a.setAttribute('rel', 'noopener noreferrer');
});
} catch {
// contentDocument may be inaccessible under stricter sandboxes; ignore.
}
}, []);
return (
<div className={cn(
"rounded-lg border border-border overflow-hidden transition-all duration-200",
@@ -483,7 +526,7 @@ function EmailCard({
</div>
{!isExpanded && density !== 'extra-compact' && (
<p className="text-sm text-muted-foreground mt-1 line-clamp-2">
{email.preview || "No preview available"}
{email.preview || t('email_viewer.no_preview_available')}
</p>
)}
</div>
@@ -534,18 +577,30 @@ function EmailCard({
{/* Email Body */}
<div style={{ padding: 'var(--density-card-p)' }}>
<div
className={cn(
"prose prose-sm max-w-none",
!emailAlwaysLightMode && "dark:prose-invert",
"prose-p:my-2 prose-headings:my-3",
"prose-a:text-primary prose-a:no-underline hover:prose-a:underline",
"[&_table]:border-collapse [&_td]:p-2 [&_th]:p-2",
"[&_img]:max-w-full [&_img]:h-auto"
)}
style={!emailContent.isHtml ? { whiteSpace: 'pre-wrap', fontFamily: 'ui-monospace, "SF Mono", Consolas, monospace', fontSize: '13px' } : undefined}
dangerouslySetInnerHTML={{ __html: emailContent.html }}
/>
{emailContent.isHtml ? (
<iframe
ref={iframeRef}
srcDoc={emailIframeSrcDoc}
sandbox="allow-same-origin allow-popups allow-popups-to-escape-sandbox"
title="Email content"
className="w-full border-0 block"
style={{ minHeight: '60px' }}
onLoad={handleIframeLoad}
/>
) : (
<div
className={cn(
"prose prose-sm max-w-none",
!emailAlwaysLightMode && "dark:prose-invert",
"prose-p:my-2 prose-headings:my-3",
"prose-a:text-primary prose-a:no-underline hover:prose-a:underline",
"[&_table]:border-collapse [&_td]:p-2 [&_th]:p-2",
"[&_img]:max-w-full [&_img]:h-auto"
)}
style={{ whiteSpace: 'pre-wrap', fontFamily: 'ui-monospace, "SF Mono", Consolas, monospace', fontSize: '13px' }}
dangerouslySetInnerHTML={{ __html: emailContent.html }}
/>
)}
</div>
{/* Attachments */}
+3 -1
View File
@@ -1,6 +1,7 @@
"use client";
import { useCallback } from "react";
import { useTranslations } from "next-intl";
import { formatDate } from "@/lib/utils";
import { Email } from "@/lib/jmap/types";
import { cn } from "@/lib/utils";
@@ -27,6 +28,7 @@ export function ThreadEmailItem({
onClick,
onContextMenu,
}: ThreadEmailItemProps) {
const t = useTranslations('email_viewer');
const isUnread = !email.keywords?.$seen;
const isStarred = email.keywords?.$flagged;
const isAnswered = email.keywords?.$answered;
@@ -177,7 +179,7 @@ export function ThreadEmailItem({
? "text-muted-foreground"
: "text-muted-foreground/70"
)}>
{email.preview || "No preview"}
{email.preview || t('no_preview_available')}
</span>
{/* Date */}
+4 -2
View File
@@ -52,6 +52,7 @@ interface SingleEmailItemProps {
const SingleEmailItem = React.forwardRef<HTMLDivElement, SingleEmailItemProps>(
function SingleEmailItem({ email, selected, onClick, onContextMenu, showPreview, colorTag, onToggleStar, onMarkAsRead, onDelete, onArchive, onSetColorTag, onMarkAsSpam }, ref) {
const t = useTranslations('email_viewer');
const isUnread = !email.keywords?.$seen;
const isStarred = email.keywords?.$flagged;
const isAnswered = email.keywords?.$answered;
@@ -317,7 +318,7 @@ const SingleEmailItem = React.forwardRef<HTMLDivElement, SingleEmailItemProps>(
? "text-muted-foreground"
: "text-muted-foreground/80"
)}>
{trimmedPreview || "No preview available"}
{trimmedPreview || t('no_preview_available')}
</p>
)}
</>
@@ -360,6 +361,7 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
onMarkAsSpam,
}, ref) {
const t = useTranslations('threads');
const tEmailViewer = useTranslations('email_viewer');
const showPreview = useSettingsStore((state) => state.showPreview);
const density = useSettingsStore((state) => state.density);
const mailLayout = useSettingsStore((state) => state.mailLayout);
@@ -724,7 +726,7 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
? "text-muted-foreground"
: "text-muted-foreground/80"
)}>
{trimmedPreview || "No preview available"}
{trimmedPreview || tEmailViewer('no_preview_available')}
</p>
)}
</>
+19 -5
View File
@@ -182,7 +182,7 @@ export function FilePreviewModal({ name, onClose, onDownload, getFileContent }:
</div>
</div>
<div className="flex-1 flex items-center justify-center overflow-auto p-4" onClick={(e) => e.stopPropagation()}>
<div className="flex-1 flex items-center justify-center overflow-auto p-4">
{loading && (
<div className="flex flex-col items-center gap-2 text-muted-foreground">
<Loader2 className="w-8 h-8 animate-spin" />
@@ -194,13 +194,19 @@ export function FilePreviewModal({ name, onClose, onDownload, getFileContent }:
)}
{!loading && !error && (fileType === "text") && content !== null && (
<pre className="bg-background rounded-lg p-6 max-w-4xl w-full max-h-full overflow-auto text-sm font-mono whitespace-pre-wrap break-words">
<pre
className="bg-background rounded-lg p-6 max-w-4xl w-full max-h-full overflow-auto text-sm font-mono whitespace-pre-wrap break-words"
onClick={(e) => e.stopPropagation()}
>
{content}
</pre>
)}
{!loading && !error && fileType === "markdown" && content !== null && (
<div className="bg-background rounded-lg p-6 max-w-4xl w-full max-h-full overflow-auto text-sm">
<div
className="bg-background rounded-lg p-6 max-w-4xl w-full max-h-full overflow-auto text-sm"
onClick={(e) => e.stopPropagation()}
>
<SimpleMarkdown content={content} />
</div>
)}
@@ -211,6 +217,7 @@ export function FilePreviewModal({ name, onClose, onDownload, getFileContent }:
alt={name}
className="max-w-full max-h-full object-contain rounded-lg bg-background"
draggable={false}
onClick={(e) => e.stopPropagation()}
/>
)}
@@ -220,6 +227,7 @@ export function FilePreviewModal({ name, onClose, onDownload, getFileContent }:
sandbox=""
className="w-full max-w-5xl h-full rounded-lg bg-white"
title={name}
onClick={(e) => e.stopPropagation()}
/>
)}
@@ -229,6 +237,7 @@ export function FilePreviewModal({ name, onClose, onDownload, getFileContent }:
type="application/pdf"
className="w-full max-w-5xl h-full rounded-lg bg-white"
aria-label={name}
onClick={(e) => e.stopPropagation()}
>
<Button onClick={() => void onDownload()}>
<Download className="w-4 h-4 mr-2" />
@@ -238,14 +247,19 @@ export function FilePreviewModal({ name, onClose, onDownload, getFileContent }:
)}
{!loading && !error && fileType === "audio" && objectUrl && (
<div className="bg-background rounded-lg p-8 max-w-lg w-full">
<div className="bg-background rounded-lg p-8 max-w-lg w-full" onClick={(e) => e.stopPropagation()}>
<p className="text-sm font-medium mb-4 text-center">{name}</p>
<audio controls className="w-full" src={objectUrl} />
</div>
)}
{!loading && !error && fileType === "video" && objectUrl && (
<video controls className="max-w-4xl max-h-full rounded-lg" src={objectUrl} />
<video
controls
className="max-w-4xl max-h-full rounded-lg"
src={objectUrl}
onClick={(e) => e.stopPropagation()}
/>
)}
</div>
</div>
+1 -1
View File
@@ -263,7 +263,7 @@ export function IdentityForm({ identity, onSave, onCancel }: IdentityFormProps)
</label>
<textarea
id="identity-html-sig"
maxLength={5000}
maxLength={50000}
value={formData.htmlSignature}
onChange={(e) => setFormData({ ...formData, htmlSignature: e.target.value })}
rows={5}
+10 -11
View File
@@ -6,9 +6,10 @@ import { Check, Plus, LogOut, Star, ChevronDown, AlertCircle } from "lucide-reac
import { useTranslations } from "next-intl";
import { useAccountStore, type AccountEntry } from "@/stores/account-store";
import { useAuthStore } from "@/stores/auth-store";
import { getInitials, getMaxAccounts } from "@/lib/account-utils";
import { getMaxAccounts } from "@/lib/account-utils";
import { cn } from "@/lib/utils";
import { useRouter } from "@/i18n/navigation";
import { Avatar } from "@/components/ui/avatar";
interface AccountSwitcherProps {
/** "rail" = small avatar only (NavigationRail), "expanded" = avatar + name + email (Sidebar) */
@@ -17,17 +18,15 @@ interface AccountSwitcherProps {
}
function AccountAvatar({ account, size = "sm" }: { account: AccountEntry; size?: "sm" | "md" }) {
const initials = getInitials(account.displayName || account.label, account.email || account.username);
const sizeClasses = size === "sm" ? "w-8 h-8 text-xs" : "w-9 h-9 text-sm";
return (
<div
className={cn("rounded-full flex items-center justify-center text-white font-medium flex-shrink-0", sizeClasses)}
style={{ backgroundColor: account.avatarColor }}
title={account.label}
>
{initials}
</div>
<Avatar
name={account.displayName || account.label}
email={account.email || account.username}
size="sm"
className={cn("flex-shrink-0", size === "md" && "w-9 h-9 text-sm")}
disableFavicon
fallbackColor={account.avatarColor}
/>
);
}
+11 -6
View File
@@ -17,11 +17,12 @@ import { useAuthStore } from "@/stores/auth-store";
import { useAccountStore } from "@/stores/account-store";
import { useUpdateStore, selectHasUpdate } from "@/stores/update-store";
import { getActiveAccountSlotHeaders } from "@/lib/auth/active-account-slot";
import { getInitials, getMaxAccounts } from "@/lib/account-utils";
import { getMaxAccounts } from "@/lib/account-utils";
import { cn, formatFileSize } from "@/lib/utils";
import { PluginSlot } from "@/components/plugins/plugin-slot";
import { KeyboardShortcutsModal } from "@/components/keyboard-shortcuts-modal";
import { apiFetch } from "@/lib/browser-navigation";
import { Avatar } from "@/components/ui/avatar";
interface NavItem {
id: string;
@@ -272,7 +273,7 @@ export function NavigationRail({
if (orientation === "horizontal") {
return (
<nav
className={cn("flex items-center bg-background border-t border-border shrink-0 overflow-x-auto mobile-scroll-hidden", className)}
className={cn("flex items-center bg-background border-t border-border shrink-0 overflow-x-auto mobile-scroll-hidden pb-[calc(env(safe-area-inset-bottom)/2)]", className)}
role="navigation"
aria-label={t("nav_label")}
>
@@ -610,7 +611,6 @@ export function NavigationRail({
<div className="flex flex-col items-center gap-3">
{accounts.map((account) => {
const isActive = account.id === activeAccountId;
const initials = getInitials(account.displayName || account.label, account.email || account.username);
return (
<button
key={account.id}
@@ -618,15 +618,20 @@ export function NavigationRail({
if (!isActive) switchAccount(account.id);
}}
className={cn(
"relative flex items-center justify-center w-8 h-8 rounded-full text-white text-[11px] font-medium transition-all flex-shrink-0",
"relative w-8 h-8 rounded-full transition-all flex-shrink-0",
isActive
? "ring-2 ring-primary ring-offset-2 ring-offset-background"
: "opacity-70 hover:opacity-100"
)}
style={{ backgroundColor: account.avatarColor }}
title={`${account.displayName || account.label} (${account.email || account.username})`}
>
{initials}
<Avatar
name={account.displayName || account.label}
email={account.email || account.username}
size="sm"
disableFavicon
fallbackColor={account.avatarColor}
/>
{isActive && (
<span className="absolute -bottom-0.5 -right-0.5 w-3 h-3 rounded-full bg-primary flex items-center justify-center">
<Check className="w-2 h-2 text-primary-foreground" />
+20
View File
@@ -20,6 +20,7 @@ import {
Folder,
FolderOpen,
User,
Users,
Palmtree,
Settings,
X,
@@ -28,6 +29,10 @@ import {
FlaskConical,
PlayCircle,
Loader2,
AlertTriangle,
NotebookPen,
CalendarClock,
BellOff,
} from "lucide-react";
import { cn, buildMailboxTree, MailboxNode } from "@/lib/utils";
import { Mailbox } from "@/lib/jmap/types";
@@ -88,6 +93,11 @@ const getIconForMailbox = (role?: string, name?: string, hasChildren?: boolean,
if (role === "trash" || lowerName.includes("trash") || lowerName.includes("deleted")) return Trash2;
if (role === "junk" || role === "spam" || lowerName.includes("junk") || lowerName.includes("spam")) return Ban;
if (role === "archive" || lowerName.includes("archive")) return Archive;
if (role === "shared" || lowerName.includes("shared")) return Users;
if (role === "important" || lowerName.includes("important")) return AlertTriangle;
if (role === "memos" || lowerName.includes("memo")) return NotebookPen;
if (role === "scheduled" || lowerName.includes("scheduled")) return CalendarClock;
if (role === "snoozed" || lowerName.includes("snoozed")) return BellOff;
if (lowerName.includes("star") || lowerName.includes("flag")) return Star;
if (hasChildren) {
@@ -104,6 +114,11 @@ const ROLE_ICON_COLOR: Record<string, string> = {
trash: "text-muted-foreground",
junk: "text-red-600/80 dark:text-red-400/80",
archive: "text-amber-600/80 dark:text-amber-400/80",
shared: "text-cyan-600/80 dark:text-cyan-400/80",
important: "text-orange-600/80 dark:text-orange-400/80",
memos: "text-yellow-600/80 dark:text-yellow-400/80",
scheduled: "text-sky-600/80 dark:text-sky-400/80",
snoozed: "text-slate-500/80 dark:text-slate-400/80",
};
function resolveRoleKey(role?: string, name?: string): string | undefined {
@@ -114,6 +129,11 @@ function resolveRoleKey(role?: string, name?: string): string | undefined {
if (role === "trash" || lowerName.includes("trash") || lowerName.includes("deleted")) return "trash";
if (role === "junk" || role === "spam" || lowerName.includes("junk") || lowerName.includes("spam")) return "junk";
if (role === "archive" || lowerName.includes("archive")) return "archive";
if (role === "shared" || lowerName.includes("shared")) return "shared";
if (role === "important" || lowerName.includes("important")) return "important";
if (role === "memos" || lowerName.includes("memo")) return "memos";
if (role === "scheduled" || lowerName.includes("scheduled")) return "scheduled";
if (role === "snoozed" || lowerName.includes("snoozed")) return "snoozed";
return undefined;
}
@@ -0,0 +1,16 @@
'use client';
import { PluginSlot } from '@/components/plugins/plugin-slot';
import { useAuthStore } from '@/stores/auth-store';
/**
* Mounts the `app-top-banner` plugin slot with the current session
* username + serverUrl as extraProps. Drop this at the top of every
* authenticated page so plugins like impersonation-notice render
* everywhere, not just on the mail page.
*/
export function AppTopBannerSlot() {
const username = useAuthStore((s) => s.username);
const serverUrl = useAuthStore((s) => s.serverUrl);
return <PluginSlot name="app-top-banner" extraProps={{ username, serverUrl }} />;
}
@@ -0,0 +1,107 @@
"use client";
import { useEffect } from "react";
import { parseMailto } from "@/lib/protocol-handlers/mailto";
import { requestOpenMailtoInExistingClient, savePendingMailto } from "@/lib/protocol-handlers/session";
import { useSettingsStore } from "@/stores/settings-store";
type StandaloneNavigator = Navigator & { standalone?: boolean };
function getProtocolPathPrefix(): string {
const marker = "/protocol/mailto";
const index = window.location.pathname.indexOf(marker);
return index > 0 ? window.location.pathname.slice(0, index) : "";
}
function returnToSourcePage() {
window.close();
window.setTimeout(() => {
if (window.history.length > 1) {
window.history.back();
}
}, 150);
}
function openFallbackAppTab(raw: string): boolean {
const url = `${getProtocolPathPrefix()}/protocol/mailto?url=${encodeURIComponent(raw)}&fallback=1`;
const opened = window.open(url, "_blank");
if (!opened) return false;
opened.opener = null;
return true;
}
function shouldOpenFallbackAppTab(): boolean {
const standalone = window.matchMedia?.("(display-mode: standalone)").matches
|| (navigator as StandaloneNavigator).standalone === true;
return !standalone && window.history.length > 1;
}
async function focusExistingClient() {
if (!("serviceWorker" in navigator)) return;
try {
const registration = await navigator.serviceWorker.ready;
const worker = navigator.serviceWorker.controller ?? registration.active;
worker?.postMessage({ type: "focus-existing-mailto-client" });
} catch {
// Focusing is a progressive enhancement; the composer handoff still works.
}
}
interface MailtoProtocolClientProps {
openingText: string;
}
export function MailtoProtocolClient({ openingText }: MailtoProtocolClientProps) {
useEffect(() => {
let cancelled = false;
async function handleMailto() {
const params = new URLSearchParams(window.location.search);
const raw = params.get("url");
const isFallbackAppTab = params.get("fallback") === "1";
const openMode = useSettingsStore.getState().protocolOpenMode;
const parsed = raw ? parseMailto(raw) : null;
if (parsed) {
if (!isFallbackAppTab && openMode === "new-tab") {
if (raw && shouldOpenFallbackAppTab() && openFallbackAppTab(raw)) {
returnToSourcePage();
return;
}
} else if (!isFallbackAppTab) {
const delivered = await requestOpenMailtoInExistingClient(parsed);
if (cancelled) return;
if (delivered) {
void focusExistingClient();
returnToSourcePage();
return;
}
if (raw && shouldOpenFallbackAppTab() && openFallbackAppTab(raw)) {
returnToSourcePage();
return;
}
}
savePendingMailto(parsed);
}
window.location.replace(`${getProtocolPathPrefix()}/`);
}
void handleMailto();
return () => {
cancelled = true;
};
}, []);
return (
<main className="flex min-h-screen items-center justify-center">
<p>{openingText}</p>
</main>
);
}
@@ -0,0 +1,163 @@
"use client";
import { Loader2, X } from "lucide-react";
import { useTranslations } from "next-intl";
import type { ParsedMailto } from "@/lib/protocol-handlers/mailto";
import type { ParsedWebcal } from "@/lib/protocol-handlers/webcal";
import type { AccountEntry } from "@/stores/account-store";
import { cn } from "@/lib/utils";
import { Avatar } from "@/components/ui/avatar";
type ProtocolAccountPickerProps = {
accounts: AccountEntry[];
activeAccountId: string | null;
isSwitching?: boolean;
onSelect: (accountId: string) => void;
onCancel: () => void;
} & (
| { kind: "mailto"; operation?: ParsedMailto }
| { kind: "webcal"; operation?: ParsedWebcal }
);
function getHost(value: string): string {
try {
return new URL(value).hostname;
} catch {
return value;
}
}
export function ProtocolAccountPicker({
kind,
accounts,
activeAccountId,
isSwitching = false,
onSelect,
onCancel,
operation,
}: ProtocolAccountPickerProps) {
const t = useTranslations("protocol_handlers");
const tCommon = useTranslations("common");
const details = operation
? kind === "mailto"
? [
{ label: t("detail_to"), value: operation.to.join(", ") || "-" },
{ label: t("detail_subject"), value: operation.subject || t("detail_no_subject") },
]
: [
{ label: t("detail_calendar"), value: operation.suggestedName },
{ label: t("detail_source"), value: getHost(operation.subscriptionUrl) },
]
: [];
return (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
<div className="absolute inset-0 bg-black/50 backdrop-blur-[1px]" onClick={onCancel} aria-hidden="true" />
<div
role="dialog"
aria-modal="true"
aria-label={t("select_account_title")}
className="relative w-full max-w-md rounded-lg border border-border bg-background shadow-xl animate-in zoom-in-95 duration-200"
>
<div className="flex items-start justify-between gap-4 border-b border-border px-5 py-4">
<div>
<h2 className="text-lg font-semibold text-foreground">{t("select_account_title")}</h2>
<p className="mt-1 text-sm text-muted-foreground">
{kind === "mailto" ? t("select_mailto_account") : t("select_webcal_account")}
</p>
</div>
<button
type="button"
onClick={onCancel}
className="rounded-md p-1.5 text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
aria-label={tCommon("close")}
>
<X className="h-5 w-5" />
</button>
</div>
{details.length > 0 && (
<div className="border-b border-border bg-muted/40 px-5 py-3">
<dl className="space-y-1.5 text-sm">
{details.map((detail) => (
<div key={detail.label} className="grid grid-cols-[5.5rem_minmax(0,1fr)] gap-3">
<dt className="text-xs font-medium uppercase tracking-wide text-muted-foreground">{detail.label}</dt>
<dd className="truncate text-foreground" title={detail.value}>{detail.value}</dd>
</div>
))}
</dl>
</div>
)}
<div className="max-h-80 overflow-y-auto p-2">
{accounts.map((account) => {
const isActive = account.id === activeAccountId;
let host = account.serverUrl;
try {
host = new URL(account.serverUrl).hostname;
} catch {
// Keep the configured value when it is not an absolute URL.
}
return (
<button
key={account.id}
type="button"
disabled={isSwitching}
onClick={() => onSelect(account.id)}
className={cn(
"flex w-full items-center gap-3 rounded-md px-3 py-2.5 text-left transition-colors",
isActive ? "bg-accent/50" : "hover:bg-muted",
isSwitching && "cursor-wait opacity-70"
)}
>
<Avatar
name={account.displayName || account.label}
email={account.email || account.username}
size="md"
className="shrink-0"
disableFavicon
fallbackColor={account.avatarColor}
/>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<span className="truncate text-sm font-medium text-foreground">
{account.displayName || account.label}
</span>
{isActive && (
<span className="rounded-full bg-primary/10 px-2 py-0.5 text-[10px] font-medium text-primary">
{t("active_account")}
</span>
)}
</div>
<p className="truncate text-xs text-muted-foreground">{account.email || account.username}</p>
<p className="truncate text-[10px] text-muted-foreground">{host}</p>
</div>
</button>
);
})}
</div>
<div className="flex items-center justify-between border-t border-border px-5 py-3">
{isSwitching ? (
<span className="inline-flex items-center gap-2 text-sm text-muted-foreground">
<Loader2 className="h-4 w-4 animate-spin" />
{t("switching_account")}
</span>
) : (
<span className="text-xs text-muted-foreground">{t("select_account_note")}</span>
)}
<button
type="button"
onClick={onCancel}
disabled={isSwitching}
className="rounded-md px-3 py-1.5 text-sm text-muted-foreground transition-colors hover:bg-muted hover:text-foreground disabled:opacity-50"
>
{tCommon("cancel")}
</button>
</div>
</div>
</div>
);
}
@@ -0,0 +1,133 @@
"use client";
import { useEffect } from "react";
import type { ReactNode } from "react";
import { useTranslations } from "next-intl";
import { usePathname, useRouter } from "@/i18n/navigation";
import { getPathPrefix } from "@/lib/browser-navigation";
import { parseMailto } from "@/lib/protocol-handlers/mailto";
import { parseWebcal } from "@/lib/protocol-handlers/webcal";
import {
listenForMailtoRequests,
notifyPendingMailto,
notifyPendingWebcal,
requestOpenMailtoInExistingClient,
savePendingMailto,
savePendingWebcal,
} from "@/lib/protocol-handlers/session";
import { useSettingsStore } from "@/stores/settings-store";
type LaunchParams = { targetURL?: string };
type StandaloneNavigator = Navigator & { standalone?: boolean };
declare global {
interface Window {
launchQueue?: {
setConsumer: (consumer: (launchParams: LaunchParams) => void) => void;
};
}
}
function getProtocolLaunch(targetURL: string):
| { kind: "mailto"; raw: string }
| { kind: "webcal"; raw: string }
| null {
let url: URL;
try {
url = new URL(targetURL, window.location.origin);
} catch {
return null;
}
if (url.origin !== window.location.origin) return null;
const raw = url.searchParams.get("url");
if (!raw) return null;
if (url.pathname.includes("/protocol/mailto")) return { kind: "mailto", raw };
if (url.pathname.includes("/protocol/webcal")) return { kind: "webcal", raw };
return null;
}
function isStandaloneDisplayMode() {
return window.matchMedia?.("(display-mode: standalone)").matches
|| (navigator as StandaloneNavigator).standalone === true;
}
function openProtocolInNewTab(protocol: "mailto" | "webcal", raw: string): boolean {
const url = `${getPathPrefix()}/protocol/${protocol}?url=${encodeURIComponent(raw)}&fallback=1`;
const opened = window.open(url, "_blank");
if (!opened) return false;
opened.opener = null;
return true;
}
interface ProtocolLaunchHandlerProviderProps {
children: ReactNode;
}
export function ProtocolLaunchHandlerProvider({ children }: ProtocolLaunchHandlerProviderProps) {
const t = useTranslations("protocol_handlers");
const router = useRouter();
const pathname = usePathname();
useEffect(() => {
if (pathname.startsWith("/protocol/")) return;
return listenForMailtoRequests((pending) => {
savePendingMailto(pending);
notifyPendingMailto();
if (pathname !== "/") router.push("/");
}, () => ({
path: pathname,
standalone: isStandaloneDisplayMode(),
focusNotificationTitle: t("focus_notification_title"),
focusNotificationBody: t("focus_notification_body"),
}));
}, [pathname, router, t]);
useEffect(() => {
if (typeof window === "undefined" || !window.launchQueue) return;
window.launchQueue.setConsumer((launchParams) => {
if (!launchParams.targetURL) return;
const launch = getProtocolLaunch(launchParams.targetURL);
if (!launch) return;
if (launch.kind === "mailto") {
const parsed = parseMailto(launch.raw);
if (!parsed) return;
if (useSettingsStore.getState().protocolOpenMode === "new-tab") {
if (openProtocolInNewTab("mailto", launch.raw)) return;
savePendingMailto(parsed);
notifyPendingMailto();
if (pathname !== "/") router.push("/");
return;
}
void requestOpenMailtoInExistingClient(parsed).then((delivered) => {
if (delivered) return;
savePendingMailto(parsed);
notifyPendingMailto();
if (pathname !== "/") router.push("/");
});
return;
}
const parsed = parseWebcal(launch.raw);
if (!parsed) return;
if (useSettingsStore.getState().protocolOpenMode === "new-tab") {
if (openProtocolInNewTab("webcal", launch.raw)) return;
}
savePendingWebcal(parsed);
notifyPendingWebcal();
if (pathname !== "/calendar") router.push("/calendar");
});
}, [pathname, router]);
return children;
}
@@ -0,0 +1,73 @@
"use client";
import { useEffect } from "react";
import { parseWebcal } from "@/lib/protocol-handlers/webcal";
import { savePendingWebcal } from "@/lib/protocol-handlers/session";
import { useSettingsStore } from "@/stores/settings-store";
type StandaloneNavigator = Navigator & { standalone?: boolean };
function getProtocolPathPrefix(): string {
const marker = "/protocol/webcal";
const index = window.location.pathname.indexOf(marker);
return index > 0 ? window.location.pathname.slice(0, index) : "";
}
function returnToSourcePage() {
window.close();
window.setTimeout(() => {
if (window.history.length > 1) {
window.history.back();
}
}, 150);
}
function openFallbackAppTab(raw: string): boolean {
const url = `${getProtocolPathPrefix()}/protocol/webcal?url=${encodeURIComponent(raw)}&fallback=1`;
const opened = window.open(url, "_blank");
if (!opened) return false;
opened.opener = null;
return true;
}
function shouldOpenFallbackAppTab(): boolean {
const standalone = window.matchMedia?.("(display-mode: standalone)").matches
|| (navigator as StandaloneNavigator).standalone === true;
return !standalone && window.history.length > 1;
}
interface WebcalProtocolClientProps {
openingText: string;
}
export function WebcalProtocolClient({ openingText }: WebcalProtocolClientProps) {
useEffect(() => {
const params = new URLSearchParams(window.location.search);
const raw = params.get("url");
const isFallbackAppTab = params.get("fallback") === "1";
if (raw) {
const parsed = parseWebcal(raw);
if (parsed) {
if (!isFallbackAppTab
&& useSettingsStore.getState().protocolOpenMode === "new-tab"
&& shouldOpenFallbackAppTab()
&& openFallbackAppTab(raw)) {
returnToSourcePage();
return;
}
savePendingWebcal(parsed);
}
}
window.location.replace(`${getProtocolPathPrefix()}/calendar`);
}, []);
return (
<main className="flex min-h-screen items-center justify-center">
<p>{openingText}</p>
</main>
);
}
+8 -6
View File
@@ -4,13 +4,14 @@ import { useEffect, useState } from 'react';
import { NextIntlClientProvider } from 'next-intl';
import { useLocaleStore } from '@/stores/locale-store';
import csMessages from '@/locales/cs/common.json';
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 frMessages from '@/locales/fr/common.json';
import itMessages from '@/locales/it/common.json';
import jaMessages from '@/locales/ja/common.json';
import koMessages from '@/locales/ko/common.json';
import esMessages from '@/locales/es/common.json';
import itMessages from '@/locales/it/common.json';
import deMessages from '@/locales/de/common.json';
import lvMessages from '@/locales/lv/common.json';
import nlMessages from '@/locales/nl/common.json';
import plMessages from '@/locales/pl/common.json';
@@ -23,13 +24,14 @@ import zhMessages from '@/locales/zh/common.json';
// Pre-loaded translations (loaded at build time, not runtime)
const ALL_MESSAGES = {
cs: csMessages,
da: daMessages,
de: deMessages,
en: enMessages,
es: esMessages,
fr: frMessages,
it: itMessages,
ja: jaMessages,
ko: koMessages,
es: esMessages,
it: itMessages,
de: deMessages,
lv: lvMessages,
nl: nlMessages,
pl: plMessages,
+15
View File
@@ -10,5 +10,20 @@ export function ThemeProvider({ children }: { children: React.ReactNode }) {
initializeTheme();
}, [initializeTheme]);
useEffect(() => {
if (process.env.NODE_ENV === 'production') return;
const handleKeyDown = (e: KeyboardEvent) => {
if ((e.ctrlKey || e.metaKey) && e.shiftKey && e.key.toLowerCase() === 'l') {
e.preventDefault();
const { resolvedTheme, setTheme } = useThemeStore.getState();
setTheme(resolvedTheme === 'dark' ? 'light' : 'dark');
}
};
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, []);
return <>{children}</>;
}
+8 -1
View File
@@ -67,7 +67,7 @@ export function AppearanceSettings() {
const tAdvanced = useTranslations('settings.advanced');
const tTour = useTranslations('tour');
const { theme, setTheme } = useThemeStore();
const { fontSize, density, animationsEnabled, senderFavicons, showAvatarsInJunk, updateSetting } = useSettingsStore();
const { fontSize, density, animationsEnabled, senderFavicons, showAvatarsInJunk, showOnboardingOnNewDevices, updateSetting } = useSettingsStore();
const { startTour, resetTourCompletion } = useTour();
const { isSettingLocked, isSettingHidden } = usePolicyStore();
@@ -145,6 +145,13 @@ export function AppearanceSettings() {
{tTour('restart_button')}
</Button>
</SettingItem>
<SettingItem label={tTour('show_on_new_devices_title')} description={tTour('show_on_new_devices_desc')}>
<ToggleSwitch
checked={showOnboardingOnNewDevices}
onChange={(checked) => updateSetting('showOnboardingOnNewDevices', checked)}
/>
</SettingItem>
</SettingsSection>
);
}
+10 -35
View File
@@ -1,12 +1,10 @@
"use client";
import { useState, useCallback } from 'react';
import { useState } from 'react';
import { useTranslations } from 'next-intl';
import { useConfig } from '@/hooks/use-config';
import { useSettingsStore } from '@/stores/settings-store';
import { SettingsSection, SettingItem, Select, ToggleSwitch } from './settings-section';
import { Mail, X } from 'lucide-react';
import { getPathPrefix } from '@/lib/browser-navigation';
import { X } from 'lucide-react';
import {
SUPPORTED_SUB_ADDRESS_DELIMITERS,
isSupportedSubAddressDelimiter,
@@ -18,8 +16,6 @@ const DEFAULT_CUSTOM_DELIMITER = '~';
export function ComposingSettings() {
const t = useTranslations('settings.email_behavior');
const { appName } = useConfig();
const [defaultMailStatus, setDefaultMailStatus] = useState<'idle' | 'success' | 'error'>('idle');
const [newKeyword, setNewKeyword] = useState('');
const {
@@ -28,20 +24,10 @@ export function ComposingSettings() {
attachmentReminderKeywords,
subAddressDelimiter,
signaturePosition,
signatureSeparatorEnabled,
updateSetting,
} = useSettingsStore();
const handleSetDefaultMailProgram = useCallback(() => {
try {
if (typeof navigator !== 'undefined' && navigator.registerProtocolHandler) {
navigator.registerProtocolHandler('mailto', `${window.location.origin}${getPathPrefix()}/compose?mailto=%s`);
setDefaultMailStatus('success');
}
} catch {
setDefaultMailStatus('error');
}
}, []);
return (
<SettingsSection title={t('title')} description={t('description')}>
<SettingItem label={t('auto_select_reply_identity.label')} description={t('auto_select_reply_identity.description')}>
@@ -62,6 +48,13 @@ export function ComposingSettings() {
/>
</SettingItem>
<SettingItem label={t('signature_separator.label')} description={t('signature_separator.description')}>
<ToggleSwitch
checked={signatureSeparatorEnabled}
onChange={(checked) => updateSetting('signatureSeparatorEnabled', checked)}
/>
</SettingItem>
<SettingItem
label={t('sub_address_delimiter.label')}
description={t('sub_address_delimiter.description', { delimiter: subAddressDelimiter })}
@@ -160,24 +153,6 @@ export function ComposingSettings() {
</form>
</div>
)}
<SettingItem label={t('default_mail_program.label')} description={t('default_mail_program.description', { appName: appName || 'Bulwark' })}>
<div className="flex flex-col items-end gap-1">
<button
onClick={handleSetDefaultMailProgram}
className="flex items-center gap-2 px-3 py-1.5 bg-muted hover:bg-accent rounded-md transition-colors"
>
<Mail className="w-4 h-4" />
<span className="text-sm text-foreground">{t('default_mail_program.button')}</span>
</button>
{defaultMailStatus === 'success' && (
<p className="text-xs text-green-600 dark:text-green-400">{t('default_mail_program.success')}</p>
)}
{defaultMailStatus === 'error' && (
<p className="text-xs text-destructive">{t('default_mail_program.error')}</p>
)}
</div>
</SettingItem>
</SettingsSection>
);
}
+6
View File
@@ -13,6 +13,7 @@ import {
Inbox, Send, FileText, Trash, ShieldAlert, Archive,
Star, Heart, Bookmark, Tag, Flag, Briefcase, Users,
Bell, Zap, Globe, Lock, Eye, MessageSquare, Mail,
AlertTriangle, NotebookPen, CalendarClock, BellOff,
type LucideIcon,
} from 'lucide-react';
import { cn, buildMailboxTree, type MailboxNode } from '@/lib/utils';
@@ -27,6 +28,11 @@ const ROLE_ICONS: Record<string, LucideIcon> = {
trash: Trash,
junk: ShieldAlert,
archive: Archive,
shared: Users,
important: AlertTriangle,
memos: NotebookPen,
scheduled: CalendarClock,
snoozed: BellOff,
};
const ICON_CHOICES: { name: string; icon: LucideIcon }[] = [
@@ -52,11 +52,14 @@ export function NotificationSettings() {
useEffect(() => {
if (!supported) return;
if (!client) return;
const accountId = client.getAccountId();
if (!accountId) return;
void (async () => {
const enabled = await isWebPushEnabled();
if (enabled) setPushStatus({ kind: 'enabled' });
const enabled = await isWebPushEnabled(accountId);
setPushStatus(enabled ? { kind: 'enabled' } : { kind: 'idle' });
})();
}, [supported]);
}, [supported, client]);
const trimmedRelay = relayUrl.trim().replace(/\/+$/, '');
const isValidRelay = /^https?:\/\/.+/i.test(trimmedRelay);
@@ -0,0 +1,108 @@
"use client";
import { useEffect, useState } from "react";
import { useTranslations } from "next-intl";
import { Button } from "@/components/ui/button";
import { getPathPrefix } from "@/lib/browser-navigation";
import { useSettingsStore } from "@/stores/settings-store";
import type { ProtocolOpenMode } from "@/stores/settings-store";
import { toast } from "@/stores/toast-store";
import { SettingsSection, SettingItem, Select } from "./settings-section";
type Protocol = "mailto" | "webcal";
function canRegisterProtocolHandler(): boolean {
return typeof navigator !== "undefined"
&& "registerProtocolHandler" in navigator
&& typeof window !== "undefined"
&& window.isSecureContext;
}
function getProtocolHandlerUrl(protocol: Protocol) {
return `${window.location.origin}${getPathPrefix()}/protocol/${protocol}?url=%s`;
}
function registerProtocolHandler(protocol: Protocol) {
navigator.registerProtocolHandler(
protocol,
getProtocolHandlerUrl(protocol),
);
}
interface ProtocolHandlerSettingsProps {
supportsCalendar: boolean;
}
export function ProtocolHandlerSettings({ supportsCalendar }: ProtocolHandlerSettingsProps) {
const t = useTranslations("protocol_handlers");
const protocolOpenMode = useSettingsStore((state) => state.protocolOpenMode);
const updateSetting = useSettingsStore((state) => state.updateSetting);
const [supported, setSupported] = useState(false);
useEffect(() => {
setSupported(canRegisterProtocolHandler());
}, []);
const handleOpenModeChange = async (value: string) => {
const openMode = value as ProtocolOpenMode;
if (openMode === "active-session"
&& typeof window !== "undefined"
&& "Notification" in window
&& Notification.permission === "default") {
await Notification.requestPermission();
}
updateSetting("protocolOpenMode", openMode);
};
const handleRegister = (protocol: Protocol) => {
try {
registerProtocolHandler(protocol);
toast.success(protocol === "mailto" ? t("mailto_registered") : t("webcal_registered"));
} catch {
toast.error(t("registration_failed"));
}
};
const renderRegistrationControl = (protocol: Protocol) => {
return (
<Button size="sm" onClick={() => handleRegister(protocol)} disabled={!supported}>
{protocol === "mailto" ? t("register_mailto") : t("register_webcal")}
</Button>
);
};
return (
<SettingsSection title={t("title")} description={t("description")}>
{!supported && (
<div className="rounded-md border border-border bg-muted/40 px-3 py-2 text-sm text-muted-foreground">
{t("unsupported")}
</div>
)}
<SettingItem label={t("mailto_label")} description={t("mailto_description")}>
{renderRegistrationControl("mailto")}
</SettingItem>
{supportsCalendar && (
<SettingItem label={t("webcal_label")} description={t("webcal_description")}>
{renderRegistrationControl("webcal")}
</SettingItem>
)}
<SettingItem label={t("protocol_open_mode_label")} description={t("protocol_open_mode_description")}>
<Select
value={protocolOpenMode}
onChange={handleOpenModeChange}
options={[
{ value: "new-tab", label: t("protocol_open_mode_new_tab") },
{ value: "active-session", label: t("protocol_open_mode_active_session") },
]}
/>
</SettingItem>
<p className="text-xs text-muted-foreground">{t("browser_note")}</p>
</SettingsSection>
);
}
+29 -4
View File
@@ -5,6 +5,7 @@ import { useRouter, usePathname } from "@/i18n/navigation";
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 { getTourSteps, type TourStep } from "./tour-steps";
import { TourOverlay } from "./tour-overlay";
@@ -38,6 +39,9 @@ export function TourProvider({ children }: { children: ReactNode }) {
const { isDemoMode } = useAuthStore();
const { supportsCalendar } = useCalendarStore();
const { supportsWebDAV } = useWebDAVStore();
const tourCompleted = useSettingsStore((s) => s.tourCompleted);
const showOnboardingOnNewDevices = useSettingsStore((s) => s.showOnboardingOnNewDevices);
const updateSetting = useSettingsStore((s) => s.updateSetting);
const [isActive, setIsActive] = useState(false);
const [currentStep, setCurrentStep] = useState(0);
@@ -46,10 +50,29 @@ export function TourProvider({ children }: { children: ReactNode }) {
const steps = getTourSteps({ isDemoMode, supportsCalendar, supportsWebDAV: supportsWebDAV !== false });
useEffect(() => {
// One-time migration: if the legacy per-device flag is set but the synced
// setting isn't yet, mirror it into synced state.
try {
setHasCompletedTour(localStorage.getItem(TOUR_COMPLETED_KEY) === "true");
const legacy = localStorage.getItem(TOUR_COMPLETED_KEY) === "true";
if (legacy && !tourCompleted) {
updateSetting("tourCompleted", true);
}
} catch { /* */ }
}, []);
}, [tourCompleted, updateSetting]);
useEffect(() => {
if (!tourCompleted) {
setHasCompletedTour(false);
return;
}
if (showOnboardingOnNewDevices) {
try {
setHasCompletedTour(localStorage.getItem(TOUR_COMPLETED_KEY) === "true");
return;
} catch { /* */ }
}
setHasCompletedTour(true);
}, [tourCompleted, showOnboardingOnNewDevices]);
const startTour = useCallback(() => {
let resumeStep = 0;
@@ -85,11 +108,12 @@ export function TourProvider({ children }: { children: ReactNode }) {
const completeTour = useCallback(() => {
setIsActive(false);
setHasCompletedTour(true);
updateSetting("tourCompleted", true);
try {
localStorage.setItem(TOUR_COMPLETED_KEY, "true");
localStorage.removeItem(TOUR_CURRENT_STEP_KEY);
} catch { /* */ }
}, []);
}, [updateSetting]);
const nextStep = useCallback(() => {
if (currentStep >= steps.length - 1) {
@@ -131,11 +155,12 @@ export function TourProvider({ children }: { children: ReactNode }) {
const resetTourCompletion = useCallback(() => {
setHasCompletedTour(false);
updateSetting("tourCompleted", false);
try {
localStorage.removeItem(TOUR_COMPLETED_KEY);
localStorage.removeItem(TOUR_CURRENT_STEP_KEY);
} catch { /* */ }
}, []);
}, [updateSetting]);
const value: TourContextValue = {
isActive,
+7 -3
View File
@@ -142,9 +142,13 @@ interface AvatarProps {
className?: string;
/** When true, suppress all image sources (favicons, plugin avatars, profile pics, contact photos) and render initials only. */
disableImages?: boolean;
/** When true, do not fall through to the sender's domain favicon. Use for the user's own account avatar where the mail-provider logo is not meaningful. */
disableFavicon?: boolean;
/** Background color used when no image source resolves. Overrides the hash-based default. */
fallbackColor?: string;
}
export function Avatar({ name, email, contactPhotoUri, size = "md", className, disableImages = false }: AvatarProps) {
export function Avatar({ name, email, contactPhotoUri, size = "md", className, disableImages = false, disableFavicon = false, fallbackColor }: AvatarProps) {
const [imgError, setImgError] = useState(false);
const [pluginAvatarUrl, setPluginAvatarUrl] = useState<string | null>(null);
const [pluginAvatarFailed, setPluginAvatarFailed] = useState(false);
@@ -226,7 +230,7 @@ export function Avatar({ name, email, contactPhotoUri, size = "md", className, d
const profilePic = email && domain ? getProfilePictureUrl(email, domain, devMode, name) : null;
const showFavicon =
senderFavicons && faviconDomain && !PERSONAL_DOMAINS.has(faviconDomain) && !imgError && !domainFailed;
!disableFavicon && senderFavicons && faviconDomain && !PERSONAL_DOMAINS.has(faviconDomain) && !imgError && !domainFailed;
// Priority: contact photo > plugin avatar (e.g. Gravatar) > custom avatar > profile picture > company favicon > initials
const customAvatar = devMode && email ? CUSTOM_AVATARS[email.toLowerCase()] : null;
@@ -257,7 +261,7 @@ export function Avatar({ name, email, contactPhotoUri, size = "md", className, d
sizeClasses[size],
className
)}
style={{ backgroundColor: imgSrc ? "#ffffff" : getBackgroundColor() }}
style={{ backgroundColor: imgSrc ? "#ffffff" : (fallbackColor ?? getBackgroundColor()) }}
title={name || email}
>
{imgSrc ? (
+15 -4
View File
@@ -201,15 +201,27 @@ export function FlagCS(props: FlagProps) {
);
}
/** Denmark Red with a white Nordic cross */
export function FlagDK(props: FlagProps) {
return (
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 37 28" width={W} height={H} className={flagClass} {...props}>
<path fill="#C8102E" d="M0,0H37V28H0Z" />
<path stroke="#fff" strokeWidth="4" d="M0,14h37M14,0v28" />
</svg>
);
}
/** Map locale codes to flag components */
export const flagComponents: Record<string, (props: FlagProps) => ReactElement> = {
cs: FlagCS,
da: FlagDK,
de: FlagDE,
en: FlagGB,
es: FlagES,
fr: FlagFR,
it: FlagIT,
ja: FlagJP,
ko: FlagKR,
es: FlagES,
it: FlagIT,
de: FlagDE,
lv: FlagLV,
nl: FlagNL,
pl: FlagPL,
@@ -218,5 +230,4 @@ export const flagComponents: Record<string, (props: FlagProps) => ReactElement>
tr: FlagTR,
uk: FlagUA,
zh: FlagCN,
cs: FlagCS,
};
+8 -7
View File
@@ -9,20 +9,21 @@ import { flagComponents } from './flag-icons';
const languages = [
{ value: 'cs', label: 'Česky' },
{ value: 'en', label: 'English' },
{ value: 'fr', label: 'Français' },
{ value: 'ja', label: '日本語' },
{ value: 'ko', label: '한국어' },
{ value: 'es', label: 'Español' },
{ value: 'it', label: 'Italiano' },
{ value: 'da', label: 'Dansk' },
{ value: 'de', label: 'Deutsch' },
{ value: 'en', label: 'English' },
{ value: 'es', label: 'Español' },
{ value: 'fr', label: 'Français' },
{ value: 'it', label: 'Italiano' },
{ value: 'lv', label: 'Latviešu' },
{ value: 'nl', label: 'Nederlands' },
{ value: 'pl', label: 'Polski' },
{ value: 'pt', label: 'Português' },
{ value: 'ru', label: 'Русский' },
{ value: 'tr', label: 'Türkçe' },
{ value: 'ru', label: 'Русский' },
{ value: 'uk', label: 'Українська' },
{ value: 'ko', label: '한국어' },
{ value: 'ja', label: '日本語' },
{ value: 'zh', label: '简体中文' },
];
+29 -4
View File
@@ -6,6 +6,7 @@ import { X, Lightbulb, Settings, PlayCircle } from "lucide-react";
import { Button } from "@/components/ui/button";
import { useRouter } from "@/i18n/navigation";
import { useTour } from "@/components/tour/tour-provider";
import { useSettingsStore } from "@/stores/settings-store";
const ONBOARDING_KEY = "onboarding_completed";
@@ -13,23 +14,47 @@ export function WelcomeBanner() {
const t = useTranslations("welcome");
const router = useRouter();
const { startTour } = useTour();
const onboardingCompleted = useSettingsStore((s) => s.onboardingCompleted);
const showOnboardingOnNewDevices = useSettingsStore((s) => s.showOnboardingOnNewDevices);
const updateSetting = useSettingsStore((s) => s.updateSetting);
const [visible, setVisible] = useState(false);
const [dismissed, setDismissed] = useState(false);
useEffect(() => {
// One-time migration: if the legacy per-device flag is set but the synced
// setting isn't yet, mirror it into synced state so the user isn't shown
// the banner again on this device after the upgrade.
try {
if (!localStorage.getItem(ONBOARDING_KEY)) {
setVisible(true);
const legacy = localStorage.getItem(ONBOARDING_KEY) === "true";
if (legacy && !onboardingCompleted) {
updateSetting("onboardingCompleted", true);
}
} catch { /* localStorage unavailable */ }
}, []);
}, [onboardingCompleted, updateSetting]);
useEffect(() => {
if (!onboardingCompleted) {
setVisible(true);
return;
}
if (showOnboardingOnNewDevices) {
try {
if (localStorage.getItem(ONBOARDING_KEY) !== "true") {
setVisible(true);
return;
}
} catch { /* localStorage unavailable */ }
}
setVisible(false);
}, [onboardingCompleted, showOnboardingOnNewDevices]);
const dismiss = useCallback(() => {
setDismissed(true);
updateSetting("onboardingCompleted", true);
try {
localStorage.setItem(ONBOARDING_KEY, "true");
} catch { /* localStorage unavailable */ }
}, []);
}, [updateSetting]);
useEffect(() => {
if (!visible) return;
+1
View File
@@ -45,6 +45,7 @@ export default [
"react-hooks/rules-of-hooks": "error",
"react-hooks/exhaustive-deps": "warn",
"no-unused-vars": "off",
"no-undef": "off",
},
settings: {
react: {
+2 -2
View File
@@ -66,7 +66,7 @@ export function useAttachmentDrag(
urlRef.current = url;
// Mark as owned so we revoke on unmount. Callers that hand back a
// shared URL (e.g. a cached thumbnail blob URL) can return the same
// string each time we still revoke once on unmount.
// string each time - we still revoke once on unmount.
ownedRef.current = true;
}
return url;
@@ -101,7 +101,7 @@ export function useAttachmentDrag(
);
const handleDragEnd = useCallback(() => {
// Keep the blob URL around briefly Chromium asynchronously fetches the
// Keep the blob URL around briefly - Chromium asynchronously fetches the
// blob: URL after dragend fires, so revoking immediately races the OS.
if (urlRef.current && ownedRef.current) {
const url = urlRef.current;
+4
View File
@@ -12,6 +12,7 @@ interface ConfigData {
oauthOnly: boolean;
oauthClientId: string;
oauthIssuerUrl: string;
oauthScopes: string;
rememberMeEnabled: boolean;
settingsSyncEnabled: boolean;
stalwartFeaturesEnabled: boolean;
@@ -90,6 +91,7 @@ export function useConfig(): AppConfig {
oauthOnly: configCache?.oauthOnly || false,
oauthClientId: configCache?.oauthClientId || '',
oauthIssuerUrl: configCache?.oauthIssuerUrl || '',
oauthScopes: configCache?.oauthScopes || '',
rememberMeEnabled: configCache?.rememberMeEnabled || false,
settingsSyncEnabled: configCache?.settingsSyncEnabled || false,
stalwartFeaturesEnabled: configCache?.stalwartFeaturesEnabled ?? true,
@@ -124,6 +126,7 @@ export function useConfig(): AppConfig {
oauthOnly: configCache.oauthOnly,
oauthClientId: configCache.oauthClientId,
oauthIssuerUrl: configCache.oauthIssuerUrl,
oauthScopes: configCache.oauthScopes,
rememberMeEnabled: configCache.rememberMeEnabled,
settingsSyncEnabled: configCache.settingsSyncEnabled,
stalwartFeaturesEnabled: configCache.stalwartFeaturesEnabled,
@@ -159,6 +162,7 @@ export function useConfig(): AppConfig {
oauthOnly: data.oauthOnly,
oauthClientId: data.oauthClientId,
oauthIssuerUrl: data.oauthIssuerUrl,
oauthScopes: data.oauthScopes,
rememberMeEnabled: data.rememberMeEnabled,
settingsSyncEnabled: data.settingsSyncEnabled,
stalwartFeaturesEnabled: data.stalwartFeaturesEnabled,
+5 -2
View File
@@ -14,8 +14,8 @@ export default getRequestConfig(async ({ requestLocale }) => {
case 'cs':
messages = (await import('../locales/cs/common.json')).default;
break;
case 'fr':
messages = (await import('../locales/fr/common.json')).default;
case 'da':
messages = (await import('../locales/da/common.json')).default;
break;
case 'de':
messages = (await import('../locales/de/common.json')).default;
@@ -23,6 +23,9 @@ export default getRequestConfig(async ({ requestLocale }) => {
case 'es':
messages = (await import('../locales/es/common.json')).default;
break;
case 'fr':
messages = (await import('../locales/fr/common.json')).default;
break;
case 'it':
messages = (await import('../locales/it/common.json')).default;
break;
+1 -1
View File
@@ -13,7 +13,7 @@ const localePrefix = (process.env.NEXT_PUBLIC_LOCALE_PREFIX ?? 'never') as
| 'as-needed';
export const routing = defineRouting({
locales: ['cs', 'en', 'fr', 'de', 'es', 'it', 'ja', 'ko', 'lv', 'nl', 'pl', 'pt', 'ru', 'tr', 'uk', 'zh'],
locales: ['cs', 'da', 'de', 'en', 'es', 'fr', 'it', 'ja', 'ko', 'lv', 'nl', 'pl', 'pt', 'ru', 'tr', 'uk', 'zh'],
defaultLocale: 'en',
localePrefix
});
@@ -0,0 +1,26 @@
import { describe, expect, it } from "vitest";
import { plainTextToComposerBody } from "../email-composer-utils";
describe("plainTextToComposerBody", () => {
it("returns an empty string for empty input", () => {
expect(plainTextToComposerBody("")).toBe("");
});
it("escapes HTML before building composer paragraphs", () => {
expect(plainTextToComposerBody("<script>alert('x') & \"q\"</script>")).toBe(
"<p>&lt;script&gt;alert(&#39;x&#39;) &amp; &quot;q&quot;&lt;/script&gt;</p>"
);
});
it("normalizes line endings and preserves single line breaks", () => {
expect(plainTextToComposerBody("line1\r\nline2\rline3")).toBe(
"<p>line1<br>line2<br>line3</p>"
);
});
it("splits paragraphs on blank lines", () => {
expect(plainTextToComposerBody("first\n\nsecond\nthird")).toBe(
"<p>first</p><p>second<br>third</p>"
);
});
});
+65 -8
View File
@@ -78,11 +78,67 @@ describe('email-sanitization', () => {
expect(clean).toContain('John Doe');
});
it('should remove images from signatures', () => {
const signature = '<p>John</p><img src="logo.png" alt="Logo">';
it('should allow img with https src', () => {
const signature = '<p>John</p><img src="https://cdn.example.com/logo.png" alt="Logo" width="120" height="40">';
const clean = sanitizeSignatureHtml(signature);
expect(clean).toContain('<img');
expect(clean).toContain('src="https://cdn.example.com/logo.png"');
expect(clean).toContain('alt="Logo"');
expect(clean).toContain('width="120"');
expect(clean).toContain('height="40"');
});
it('should allow img with data:image/png;base64 src', () => {
const dataUri = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABAQMAAAAl21bKAAAAA1BMVEX/AAAZ4gk3AAAAAXRSTlPM0jRW/QAAAAlwSFlzAAALEwAACxMBAJqcGAAAAA1JREFUCNdjYGBgAAAABAABc7Rs9wAAAABJRU5ErkJggg==';
const signature = `<img src="${dataUri}" alt="Logo">`;
const clean = sanitizeSignatureHtml(signature);
expect(clean).toContain('<img');
expect(clean).toContain('data:image/png;base64,');
});
it('should allow img with data:image/jpeg, gif, webp', () => {
const cases = ['data:image/jpeg;base64,AAA', 'data:image/jpg;base64,AAA', 'data:image/gif;base64,AAA', 'data:image/webp;base64,AAA'];
for (const src of cases) {
const clean = sanitizeSignatureHtml(`<img src="${src}" alt="x">`);
expect(clean).toContain('<img');
expect(clean).toContain(src);
}
});
it('should strip img with http: src (https only)', () => {
const signature = '<img src="http://insecure.example.com/logo.png" alt="Logo">';
const clean = sanitizeSignatureHtml(signature);
expect(clean).not.toContain('http://insecure.example.com');
expect(clean).not.toContain('<img');
expect(clean).toContain('John');
});
it('should strip img with javascript: src', () => {
const signature = '<img src="javascript:alert(1)" alt="x">';
const clean = sanitizeSignatureHtml(signature);
expect(clean).not.toContain('javascript:');
expect(clean).not.toContain('<img');
});
it('should strip img with data:image/svg+xml src (SVG forbidden)', () => {
const signature = '<img src="data:image/svg+xml;base64,PHN2Zy8+" alt="x">';
const clean = sanitizeSignatureHtml(signature);
expect(clean).not.toContain('data:image/svg');
expect(clean).not.toContain('<img');
});
it('should strip img with non-image data: URI', () => {
const signature = '<img src="data:text/html;base64,PHA+aGk8L3A+" alt="x">';
const clean = sanitizeSignatureHtml(signature);
expect(clean).not.toContain('data:text/html');
expect(clean).not.toContain('<img');
});
it('should strip event handlers on img', () => {
const signature = '<img src="https://cdn.example.com/logo.png" alt="x" onerror="alert(1)" onload="alert(2)">';
const clean = sanitizeSignatureHtml(signature);
expect(clean).not.toContain('onerror');
expect(clean).not.toContain('onload');
expect(clean).toContain('https://cdn.example.com/logo.png');
});
it('should remove video and audio tags', () => {
@@ -113,16 +169,17 @@ describe('email-sanitization', () => {
});
it('should be stricter than email sanitization', () => {
const html = '<p>Text</p><img src="pic.jpg"><table><tr><td>Data</td></tr></table>';
const html = '<p>Text</p><table><tr><td>Data</td></tr></table><video src="v.mp4"></video>';
const emailClean = sanitizeEmailHtml(html);
const signatureClean = sanitizeSignatureHtml(html);
// Email allows img and table
expect(emailClean).toContain('<img');
// Email allows table
expect(emailClean).toContain('<table>');
// Signature blocks img but may allow some tables (verify in implementation)
expect(signatureClean).not.toContain('<img');
// Signature blocks table and video
expect(signatureClean).not.toContain('<table');
expect(signatureClean).not.toContain('<video');
expect(signatureClean).toContain('Text');
});
});
+129
View File
@@ -0,0 +1,129 @@
import { describe, expect, it, beforeEach } from 'vitest';
import { createHmac } from 'node:crypto';
import {
ImpersonationJwtError,
verifyImpersonationJwt,
impersonationReplayCache,
} from '@/lib/impersonation/jwt';
const SECRET = 'a'.repeat(64);
const ISSUER = 'platform-api/webmail';
function base64Url(input: Buffer | string): string {
return Buffer.from(input)
.toString('base64')
.replace(/\+/g, '-')
.replace(/\//g, '_')
.replace(/=+$/, '');
}
function sign(payload: Record<string, unknown>, secret: string = SECRET, header: Record<string, unknown> = { alg: 'HS256', typ: 'JWT' }): string {
const h = base64Url(JSON.stringify(header));
const p = base64Url(JSON.stringify(payload));
const sig = createHmac('sha256', secret).update(`${h}.${p}`).digest();
return `${h}.${p}.${base64Url(sig)}`;
}
function basePayload(overrides: Partial<Record<string, unknown>> = {}): Record<string, unknown> {
const now = Math.floor(Date.now() / 1000);
return {
iss: ISSUER,
iat: now,
exp: now + 120,
jti: 'jti-' + Math.random().toString(36).slice(2),
mailbox: 'alice@example.test',
...overrides,
};
}
describe('verifyImpersonationJwt', () => {
beforeEach(() => {
impersonationReplayCache.clear();
});
it('accepts a valid HS256 token', () => {
const token = sign(basePayload());
const claims = verifyImpersonationJwt(token, SECRET, { expectedIssuer: ISSUER });
expect(claims.mailbox).toBe('alice@example.test');
});
it('rejects non-HS256 algorithms', () => {
const header = { alg: 'none', typ: 'JWT' };
const h = base64Url(JSON.stringify(header));
const p = base64Url(JSON.stringify(basePayload()));
const token = `${h}.${p}.`;
expect(() => verifyImpersonationJwt(token, SECRET)).toThrow(ImpersonationJwtError);
});
it('rejects tokens with a forged signature', () => {
const token = sign(basePayload(), 'a-different-secret-that-is-also-long-enough-32');
expect(() => verifyImpersonationJwt(token, SECRET)).toThrowError(/signature/i);
});
it('rejects when secret is too short', () => {
const token = sign(basePayload());
expect(() => verifyImpersonationJwt(token, 'short')).toThrowError(/32 characters/);
});
it('rejects expired tokens', () => {
const now = Math.floor(Date.now() / 1000);
const token = sign(basePayload({ iat: now - 600, exp: now - 300 }));
expect(() => verifyImpersonationJwt(token, SECRET)).toThrowError(/expired/i);
});
it('rejects tokens with lifetime over the 300s ceiling', () => {
const now = Math.floor(Date.now() / 1000);
const token = sign(basePayload({ iat: now, exp: now + 3600 }));
expect(() => verifyImpersonationJwt(token, SECRET)).toThrowError(/lifetime/i);
});
it('rejects tokens with iss mismatch when expectedIssuer is set', () => {
const token = sign(basePayload({ iss: 'someone-else' }));
expect(() =>
verifyImpersonationJwt(token, SECRET, { expectedIssuer: ISSUER }),
).toThrowError(/issuer/i);
});
it("rejects mailbox containing '%'", () => {
const token = sign(basePayload({ mailbox: 'a%b@example.test' }));
expect(() => verifyImpersonationJwt(token, SECRET)).toThrowError(/'%'/);
});
it("rejects mailbox containing ':'", () => {
const token = sign(basePayload({ mailbox: 'a:b@example.test' }));
expect(() => verifyImpersonationJwt(token, SECRET)).toThrowError(/':'/);
});
it('rejects malformed tokens', () => {
expect(() => verifyImpersonationJwt('not.a.jwt.extra', SECRET)).toThrow();
expect(() => verifyImpersonationJwt('', SECRET)).toThrow();
});
it('honours nbf with skew', () => {
const now = Math.floor(Date.now() / 1000);
const token = sign(basePayload({ nbf: now + 600 }));
expect(() => verifyImpersonationJwt(token, SECRET)).toThrowError(/not yet valid/i);
});
});
describe('impersonationReplayCache', () => {
beforeEach(() => {
impersonationReplayCache.clear();
});
it('accepts a jti once and rejects it on second use', () => {
const now = Math.floor(Date.now() / 1000);
expect(impersonationReplayCache.consume('jti-1', now + 60, now)).toBe(true);
expect(impersonationReplayCache.consume('jti-1', now + 60, now)).toBe(false);
});
it('prunes expired jtis on next consume', () => {
const now = Math.floor(Date.now() / 1000);
impersonationReplayCache.consume('jti-old', now - 600, now - 600);
// Far in the future — pruning should clear the old entry.
expect(impersonationReplayCache.consume('jti-new', now + 60, now + 1000)).toBe(true);
// Re-using the old jti is allowed after pruning (security irrelevant since
// the token would fail signature/exp validation upstream).
expect(impersonationReplayCache.consume('jti-old', now + 60, now + 1000)).toBe(true);
});
});
+1
View File
@@ -44,6 +44,7 @@ function resetStore() {
plugins: [],
slots: {
'toolbar-actions': [],
'app-top-banner': [],
'email-banner': [],
'email-footer': [],
'composer-toolbar': [],
+170
View File
@@ -0,0 +1,170 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { parseMailto } from "../protocol-handlers/mailto";
import { listenForMailtoRequests } from "../protocol-handlers/session";
import { parseWebcal } from "../protocol-handlers/webcal";
const originalServiceWorkerDescriptor = Object.getOwnPropertyDescriptor(navigator, "serviceWorker");
function installServiceWorkerMock() {
const listeners = new Set<(event: MessageEvent) => void>();
const worker = { postMessage: vi.fn() };
const serviceWorker = {
ready: Promise.resolve({ active: worker }),
controller: worker,
addEventListener: vi.fn((type: string, listener: EventListener) => {
if (type === "message") listeners.add(listener as (event: MessageEvent) => void);
}),
removeEventListener: vi.fn((type: string, listener: EventListener) => {
if (type === "message") listeners.delete(listener as (event: MessageEvent) => void);
}),
};
Object.defineProperty(navigator, "serviceWorker", {
configurable: true,
value: serviceWorker,
});
return {
dispatch(data: unknown) {
listeners.forEach((listener) => listener(new MessageEvent("message", { data })));
},
};
}
afterEach(() => {
vi.restoreAllMocks();
if (originalServiceWorkerDescriptor) {
Object.defineProperty(navigator, "serviceWorker", originalServiceWorkerDescriptor);
return;
}
Reflect.deleteProperty(navigator, "serviceWorker");
});
describe("protocol handlers", () => {
describe("parseMailto", () => {
it("parses a single path recipient", () => {
expect(parseMailto("mailto:alice@example.com")).toEqual({
to: ["alice@example.com"],
cc: [],
bcc: [],
subject: "",
body: "",
});
});
it("parses multiple recipients with subject and body", () => {
expect(parseMailto("mailto:alice@example.com,bob@example.com?subject=Hello&body=Hi")).toMatchObject({
to: ["alice@example.com", "bob@example.com"],
subject: "Hello",
body: "Hi",
});
});
it("parses to, cc, and bcc query recipients", () => {
expect(parseMailto("mailto:?to=alice@example.com&cc=bob@example.com&bcc=eve@example.com")).toMatchObject({
to: ["alice@example.com"],
cc: ["bob@example.com"],
bcc: ["eve@example.com"],
});
});
it("decodes subject and body values", () => {
expect(parseMailto("mailto:alice@example.com?subject=Hello%20World&body=line1%0Aline2")).toMatchObject({
subject: "Hello World",
body: "line1\nline2",
});
});
it("preserves literal plus signs in query values", () => {
expect(parseMailto("mailto:?to=user+tag@example.com&subject=C++&body=a+b")).toMatchObject({
to: ["user+tag@example.com"],
subject: "C++",
body: "a+b",
});
});
it("rejects non-mailto URLs", () => {
expect(parseMailto("https://example.com")).toBeNull();
});
it("allows an empty mailto URL", () => {
expect(parseMailto("mailto:")).toEqual({
to: [],
cc: [],
bcc: [],
subject: "",
body: "",
});
});
it("removes control characters and caps recipients", () => {
const recipients = Array.from({ length: 250 }, (_, index) => `user${index}@example.com`).join(",");
const parsed = parseMailto(`mailto:${recipients}?subject=Hi%0ABcc:evil@example.com`);
expect(parsed?.to).toHaveLength(200);
expect(parsed?.subject).toBe("HiBcc:evil@example.com");
});
});
describe("parseWebcal", () => {
it("normalizes webcal to https", () => {
expect(parseWebcal("webcal://example.com/calendar.ics")?.subscriptionUrl).toBe("https://example.com/calendar.ics");
});
it("normalizes webcals to https", () => {
expect(parseWebcal("webcals://example.com/calendar.ics")?.subscriptionUrl).toBe("https://example.com/calendar.ics");
});
it("accepts https URLs", () => {
expect(parseWebcal("https://example.com/calendar.ics")?.subscriptionUrl).toBe("https://example.com/calendar.ics");
});
it("rejects unsupported protocols", () => {
expect(parseWebcal("ftp://example.com/calendar.ics")).toBeNull();
});
it("suggests a name from the path", () => {
expect(parseWebcal("webcal://example.com/team.ics")?.suggestedName).toBe("team");
});
it("falls back to hostname for suggested name", () => {
expect(parseWebcal("webcal://example.com/")?.suggestedName).toBe("example.com");
});
it("prefers a name query parameter", () => {
expect(parseWebcal("webcal://example.com/team.ics?name=Team%20Calendar")?.suggestedName).toBe("Team Calendar");
});
});
describe("listenForMailtoRequests", () => {
const mailtoValue = {
to: ["alice@example.com"],
cc: [],
bcc: [],
subject: "Hello",
body: "Hi",
};
it("accepts legacy service-worker mailto messages without a client id", () => {
const serviceWorker = installServiceWorkerMock();
const onMailto = vi.fn();
vi.spyOn(window, "focus").mockImplementation(() => undefined);
const cleanup = listenForMailtoRequests(onMailto, () => ({ path: "/", standalone: false }));
serviceWorker.dispatch({ type: "mailto-request", id: "legacy", value: mailtoValue });
expect(onMailto).toHaveBeenCalledWith(mailtoValue);
cleanup();
});
it("ignores service-worker mailto messages for another client", () => {
const serviceWorker = installServiceWorkerMock();
const onMailto = vi.fn();
const cleanup = listenForMailtoRequests(onMailto, () => ({ path: "/", standalone: false }));
serviceWorker.dispatch({ type: "mailto-request", id: "targeted", clientId: "other-client", value: mailtoValue });
expect(onMailto).not.toHaveBeenCalled();
cleanup();
});
});
});
+252
View File
@@ -481,6 +481,258 @@ describe("round-trip: parse → generate → parse", () => {
});
});
describe("vCard 4.0 parsing (issue #289)", () => {
it("strips group prefix from property names (item1.EMAIL)", () => {
// Evolution / Apple Contacts emit grouped properties so an X-ABLABEL line
// can attach a label. We must still parse the EMAIL itself.
const vcf = [
"BEGIN:VCARD",
"VERSION:4.0",
"FN:Ada Lovelace",
"item1.EMAIL:ada@example.com",
"item1.X-ABLABEL:Personal",
"item2.TEL:tel:+1-555-0100",
"item2.X-ABLABEL:Mobile",
"END:VCARD",
].join("\r\n");
const result = parseVCard(vcf);
expect(result).toHaveLength(1);
const card = result[0];
expect(card.emails?.e0?.address).toBe("ada@example.com");
expect(card.phones?.p0?.number).toBe("+1-555-0100");
});
it("strips tel:/mailto: URI scheme from TEL/EMAIL values", () => {
const vcf = [
"BEGIN:VCARD",
"VERSION:4.0",
"FN:Alan Turing",
"EMAIL:mailto:alan@example.com",
"TEL;VALUE=uri:tel:+44-20-1234-5678",
"END:VCARD",
].join("\r\n");
const result = parseVCard(vcf);
expect(result[0].emails?.e0?.address).toBe("alan@example.com");
expect(result[0].phones?.p0?.number).toBe("+44-20-1234-5678");
});
it("maps PREF=n parameter to pref field", () => {
const vcf = [
"BEGIN:VCARD",
"VERSION:4.0",
"FN:Grace Hopper",
"EMAIL;PREF=1:grace@home.example",
"EMAIL;PREF=2:grace@work.example",
"TEL;PREF=1:+1-555-9999",
"END:VCARD",
].join("\r\n");
const result = parseVCard(vcf);
expect(result[0].emails?.e0?.pref).toBe(1);
expect(result[0].emails?.e1?.pref).toBe(2);
expect(result[0].phones?.p0?.pref).toBe(1);
});
it("decodes RFC 6868 caret-encoded parameter values", () => {
// ^n → LF, ^^ → ^, ^' → DQUOTE
const vcf = [
"BEGIN:VCARD",
"VERSION:4.0",
"FN:Test",
'ADR;LABEL="Line 1^nLine 2";TYPE=HOME:;;Sub St;Town;;;US',
"EMAIL:t@example.com",
"END:VCARD",
].join("\r\n");
const result = parseVCard(vcf);
expect(result[0].addresses?.a0?.fullAddress).toBe("Line 1\nLine 2");
expect(result[0].addresses?.a0?.contexts).toEqual({ private: true });
});
it("survives quoted parameter values containing semicolons", () => {
// Without quote-aware param splitting, the ; inside LABEL would shred
// the param list and the ADR would lose its TYPE.
const vcf = [
"BEGIN:VCARD",
"VERSION:4.0",
"FN:Lev",
'ADR;LABEL="Building A; Suite 12";TYPE=WORK:;;1 Plaza;NYC;NY;10001;US',
"EMAIL:lev@example.com",
"END:VCARD",
].join("\r\n");
const result = parseVCard(vcf);
expect(result[0].addresses?.a0?.fullAddress).toBe("Building A; Suite 12");
expect(result[0].addresses?.a0?.contexts).toEqual({ work: true });
expect(result[0].addresses?.a0?.locality).toBe("NYC");
});
it("parses BIRTHPLACE and DEATHPLACE (RFC 6474)", () => {
const vcf = [
"BEGIN:VCARD",
"VERSION:4.0",
"FN:Marie Curie",
"BDAY:18671107",
"BIRTHPLACE:Warsaw\\, Poland",
"DEATHDATE:19340704",
"DEATHPLACE:Passy\\, France",
"END:VCARD",
].join("\r\n");
const result = parseVCard(vcf);
const annivs = Object.values(result[0].anniversaries || {});
const birth = annivs.find((a) => a.kind === "birth");
const death = annivs.find((a) => a.kind === "death");
expect(birth?.place?.fullAddress).toBe("Warsaw, Poland");
expect(death?.place?.fullAddress).toBe("Passy, France");
});
it("parses EXPERTISE / HOBBY / INTEREST with LEVEL (RFC 6715)", () => {
const vcf = [
"BEGIN:VCARD",
"VERSION:4.0",
"FN:Polymath",
"EXPERTISE;LEVEL=expert:cryptography",
"EXPERTISE;LEVEL=beginner:welding",
"HOBBY;LEVEL=high:gardening",
"INTEREST;LEVEL=medium:opera",
"END:VCARD",
].join("\r\n");
const result = parseVCard(vcf);
const info = Object.values(result[0].personalInfo || {});
expect(info).toEqual(expect.arrayContaining([
{ kind: "expertise", value: "cryptography", level: "high" },
{ kind: "expertise", value: "welding", level: "low" },
{ kind: "hobby", value: "gardening", level: "high" },
{ kind: "interest", value: "opera", level: "medium" },
]));
});
it("parses ORG-DIRECTORY (RFC 6715) and CONTACT-URI (RFC 8605)", () => {
const vcf = [
"BEGIN:VCARD",
"VERSION:4.0",
"FN:Corp Person",
"ORG-DIRECTORY:https://example.com/staff/",
"CONTACT-URI;PREF=1:https://example.com/contact",
"EMAIL:c@example.com",
"END:VCARD",
].join("\r\n");
const result = parseVCard(vcf);
expect(Object.values(result[0].directories || {})[0]).toMatchObject({
uri: "https://example.com/staff/",
kind: "directory",
});
const links = Object.values(result[0].links || {});
expect(links[0]).toMatchObject({
uri: "https://example.com/contact",
kind: "contact",
pref: 1,
});
});
it("parses RFC 9554 CREATED, GRAMGENDER, PRONOUNS", () => {
const vcf = [
"BEGIN:VCARD",
"VERSION:4.0",
"FN:Modern Person",
"CREATED:20250101T120000Z",
"GRAMGENDER:neuter",
"PRONOUNS:they/them",
"PRONOUNS;PREF=2:ze/zir",
"EMAIL:m@example.com",
"END:VCARD",
].join("\r\n");
const result = parseVCard(vcf);
expect(result[0].created).toBe("20250101T120000Z");
expect(result[0].speakToAs?.grammaticalGender).toBe("neuter");
const pronouns = Object.values(result[0].speakToAs?.pronouns || {});
expect(pronouns).toEqual(expect.arrayContaining([
expect.objectContaining({ pronouns: "they/them" }),
expect.objectContaining({ pronouns: "ze/zir", pref: 2 }),
]));
});
it("accepts vCard 4.0 KIND values (location, device, application)", () => {
for (const k of ["location", "device", "application"] as const) {
const vcf = [
"BEGIN:VCARD",
"VERSION:4.0",
`KIND:${k}`,
"FN:Thing",
"END:VCARD",
].join("\r\n");
expect(parseVCard(vcf)[0].kind).toBe(k);
}
});
it("handles ADR with LABEL/GEO/TZ/CC parameters (RFC 9554)", () => {
const vcf = [
"BEGIN:VCARD",
"VERSION:4.0",
"FN:GeoPerson",
'ADR;CC=DE;GEO="geo:52.5,13.4";TZ=Europe/Berlin;LABEL="Unter den Linden 1\\nBerlin":;;Unter den Linden 1;Berlin;;10117;Germany',
"END:VCARD",
].join("\r\n");
const result = parseVCard(vcf);
const addr = result[0].addresses?.a0;
expect(addr?.countryCode).toBe("DE");
expect(addr?.coordinates).toBe("52.5,13.4");
expect(addr?.timeZone).toBe("Europe/Berlin");
expect(addr?.fullAddress).toContain("Unter den Linden 1");
expect(addr?.locality).toBe("Berlin");
});
it("unfolds LF-only continuation lines (no CR)", () => {
// Unix exporters often use LF only; we must still unfold.
const vcf = "BEGIN:VCARD\nVERSION:4.0\nFN:John\n Doe\nEMAIL:j@d.com\nEND:VCARD";
const result = parseVCard(vcf);
expect(result[0].name?.components).toEqual(
expect.arrayContaining([{ kind: "given", value: "JohnDoe" }])
);
});
it("round-trips vCard 4.0-only properties through generateVCard", () => {
const original = [
"BEGIN:VCARD",
"VERSION:4.0",
"FN:Round Trip",
"EMAIL;PREF=1:rt@example.com",
"BDAY:19700101",
"BIRTHPLACE:Somewhere",
"EXPERTISE;LEVEL=expert:vCard",
"HOBBY;LEVEL=medium:reading",
"ORG-DIRECTORY:https://example.com/dir",
"CONTACT-URI:https://example.com/contact",
"CREATED:20240101T000000Z",
"END:VCARD",
].join("\r\n");
const exported = generateVCard(parseVCard(original));
const reparsed = parseVCard(exported)[0];
expect(reparsed.emails?.e0?.pref).toBe(1);
expect(Object.values(reparsed.anniversaries || {}).find(a => a.kind === "birth")?.place?.fullAddress).toBe("Somewhere");
const info = Object.values(reparsed.personalInfo || {});
expect(info).toEqual(expect.arrayContaining([
{ kind: "expertise", value: "vCard", level: "high" },
{ kind: "hobby", value: "reading", level: "medium" },
]));
expect(Object.values(reparsed.directories || {})[0]?.uri).toBe("https://example.com/dir");
expect(Object.values(reparsed.links || {})[0]).toMatchObject({
uri: "https://example.com/contact",
kind: "contact",
});
expect(reparsed.created).toBe("20240101T000000Z");
});
});
describe("detectDuplicates", () => {
it("detects duplicates by matching email (case-insensitive)", () => {
const existing: ContactCard[] = [
+46
View File
@@ -0,0 +1,46 @@
import { describe, it, expect } from 'vitest';
import { compareVersions, isVersionSatisfied } from '@/lib/version-compare';
describe('compareVersions', () => {
it('orders by major, minor, patch', () => {
expect(compareVersions('1.0.0', '1.0.0')).toBe(0);
expect(compareVersions('1.0.1', '1.0.0')).toBeGreaterThan(0);
expect(compareVersions('1.0.0', '1.0.1')).toBeLessThan(0);
expect(compareVersions('2.0.0', '1.9.9')).toBeGreaterThan(0);
expect(compareVersions('1.10.0', '1.9.0')).toBeGreaterThan(0);
});
it('treats missing segments as 0', () => {
expect(compareVersions('1', '1.0.0')).toBe(0);
expect(compareVersions('1.2', '1.2.0')).toBe(0);
});
it('tolerates a leading v', () => {
expect(compareVersions('v1.6.7', '1.6.7')).toBe(0);
});
it('ignores pre-release / build metadata', () => {
expect(compareVersions('1.6.7-rc.1', '1.6.7')).toBe(0);
expect(compareVersions('1.6.7+build.5', '1.6.7')).toBe(0);
});
});
describe('isVersionSatisfied', () => {
it('returns true when current >= required', () => {
expect(isVersionSatisfied('1.6.7', '1.6.7')).toBe(true);
expect(isVersionSatisfied('1.6.8', '1.6.7')).toBe(true);
expect(isVersionSatisfied('2.0.0', '1.9.9')).toBe(true);
});
it('returns false when current < required', () => {
expect(isVersionSatisfied('1.6.6', '1.6.7')).toBe(false);
expect(isVersionSatisfied('1.5.0', '1.6.0')).toBe(false);
expect(isVersionSatisfied('0.0.0', '1.0.0')).toBe(false);
});
it('treats empty / null / undefined required as no requirement', () => {
expect(isVersionSatisfied('1.0.0', '')).toBe(true);
expect(isVersionSatisfied('1.0.0', null)).toBe(true);
expect(isVersionSatisfied('1.0.0', undefined)).toBe(true);
});
});
+13 -3
View File
@@ -189,11 +189,21 @@ export async function changeAdminPassword(currentPassword: string, newPassword:
/**
* Set the admin password without verifying a current one. Used by the setup
* wizard during initial bootstrap. Refuses to overwrite an existing password.
* wizard during initial bootstrap.
*
* Refuses to overwrite an existing password unless `allowOverwrite` is true.
* The wizard's finish route passes `allowOverwrite: true` so a half-completed
* setup (admin.json left behind by an ADMIN_PASSWORD env var or an aborted
* earlier wizard run, while setupComplete is still false) can be recovered
* by simply running the wizard again. Safe because the finish route is
* already gated by the one-time setup token.
*/
export async function setInitialAdminPassword(newPassword: string): Promise<boolean> {
export async function setInitialAdminPassword(
newPassword: string,
options: { allowOverwrite?: boolean } = {},
): Promise<boolean> {
const existing = await readConfigData();
if (existing) return false;
if (existing && !options.allowOverwrite) return false;
const hash = await hashPassword(newPassword);
cachedConfig = { passwordHash: hash };
cachedState = freshState();
+2
View File
@@ -146,6 +146,8 @@ 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: '' },
oauthScopes: { envVar: 'OAUTH_SCOPES', type: 'string', defaultValue: '' },
oauthExtraScopes: { envVar: 'OAUTH_EXTRA_SCOPES', type: 'string', defaultValue: '' },
allowCustomJmapEndpoint: { envVar: 'ALLOW_CUSTOM_JMAP_ENDPOINT', type: 'boolean', defaultValue: false },
jmapServers: { envVar: 'JMAP_SERVERS', type: 'json', defaultValue: [] },
jmapServerAutoPickByDomain: { envVar: 'JMAP_SERVER_AUTO_PICK_BY_DOMAIN', type: 'boolean', defaultValue: false },
+76
View File
@@ -1,5 +1,16 @@
import type { ContactCard, AddressBook } from '@/lib/jmap/types';
// randomuser.me serves stable portrait URLs at
// https://randomuser.me/api/portraits/{men|women}/{0..99}.jpg
// See https://randomuser.me/documentation#howto - we use these directly
// rather than hitting the JSON API so the demo works offline.
const portrait = (gender: 'men' | 'women', n: number): string =>
`https://randomuser.me/api/portraits/${gender}/${n}.jpg`;
const photo = (gender: 'men' | 'women', n: number) => ({
photo1: { kind: 'photo' as const, uri: portrait(gender, n), mediaType: 'image/jpeg' },
});
export function createDemoAddressBooks(): AddressBook[] {
return [
{
@@ -34,6 +45,7 @@ export function createDemoContacts(): ContactCard[] {
organizations: { o1: { name: 'Acme Corp', units: [{ name: 'Engineering' }] } },
titles: { t1: { name: 'Senior Engineer', kind: 'title' } },
anniversaries: { a1: { kind: 'birth', date: { year: 1990, month: 3, day: 15 } } },
media: photo('women', 44),
},
{
id: 'demo-contact-2',
@@ -50,6 +62,7 @@ export function createDemoContacts(): ContactCard[] {
},
organizations: { o1: { name: 'Acme Corp', units: [{ name: 'Backend Team' }] } },
titles: { t1: { name: 'Staff Engineer', kind: 'title' } },
media: photo('men', 32),
},
{
id: 'demo-contact-3',
@@ -60,6 +73,7 @@ export function createDemoContacts(): ContactCard[] {
phones: { p1: { number: '+1-555-0104', features: { voice: true } } },
organizations: { o1: { name: 'DesignCo' } },
titles: { t1: { name: 'UX Designer', kind: 'title' } },
media: photo('women', 68),
},
{
id: 'demo-contact-4',
@@ -69,6 +83,7 @@ export function createDemoContacts(): ContactCard[] {
emails: { e1: { address: 'carlos.rivera@example.com', pref: 1 } },
phones: { p1: { number: '+1-555-0105', features: { cell: true } } },
notes: { n1: { note: 'Met at the DevConf 2024 conference' } },
media: photo('men', 15),
},
{
id: 'demo-contact-5',
@@ -89,6 +104,7 @@ export function createDemoContacts(): ContactCard[] {
},
},
anniversaries: { a1: { kind: 'birth', date: { month: 7, day: 22 } } },
media: photo('women', 22),
},
{
id: 'demo-contact-6',
@@ -97,6 +113,7 @@ export function createDemoContacts(): ContactCard[] {
name: { components: [{ kind: 'given', value: 'David' }, { kind: 'surname', value: 'Park' }] },
emails: { e1: { address: 'david.park@example.com', pref: 1 } },
phones: { p1: { number: '+82-10-1234-5678', features: { cell: true } } },
media: photo('men', 67),
},
{
id: 'demo-contact-7',
@@ -123,6 +140,58 @@ export function createDemoContacts(): ContactCard[] {
kind: 'individual',
name: { components: [{ kind: 'given', value: 'Lisa' }, { kind: 'surname', value: 'Tanaka' }] },
emails: { e1: { address: 'lisa.tanaka@example.com', pref: 1 } },
media: photo('women', 85),
},
{
id: 'demo-contact-16',
addressBookIds: { 'demo-addressbook-personal': true },
kind: 'individual',
name: { components: [{ kind: 'given', value: 'Sofia' }, { kind: 'surname', value: 'Russo' }] },
emails: { e1: { address: 'sofia.russo@example.com', contexts: { private: true }, pref: 1 } },
phones: { p1: { number: '+39-340-555-0111', features: { cell: true }, contexts: { private: true } } },
notes: { n1: { note: 'Mom' } },
anniversaries: { a1: { kind: 'birth', date: { year: 1962, month: 5, day: 9 } } },
media: photo('women', 3),
},
{
id: 'demo-contact-17',
addressBookIds: { 'demo-addressbook-personal': true },
kind: 'individual',
name: { components: [{ kind: 'given', value: 'Anna' }, { kind: 'surname', value: 'Kowalski' }] },
emails: { e1: { address: 'anna.kowalski@example.com', contexts: { private: true }, pref: 1 } },
phones: { p1: { number: '+48-602-555-0144', features: { cell: true } } },
notes: { n1: { note: 'Sister - lives in Kraków' } },
anniversaries: { a1: { kind: 'birth', date: { month: 11, day: 4 } } },
media: photo('women', 47),
},
{
id: 'demo-contact-18',
addressBookIds: { 'demo-addressbook-personal': true },
kind: 'individual',
name: { components: [{ kind: 'given', value: 'Marcus' }, { kind: 'surname', value: 'Hughes' }] },
emails: { e1: { address: 'marcus.hughes@example.com', pref: 1 } },
notes: { n1: { note: 'College friend - book club organiser' } },
media: photo('men', 96),
},
{
id: 'demo-contact-19',
addressBookIds: { 'demo-addressbook-personal': true },
kind: 'individual',
name: { components: [{ kind: 'given', value: 'Olivia' }, { kind: 'surname', value: 'Bennett' }] },
emails: { e1: { address: 'olivia.bennett@example.com', contexts: { work: true }, pref: 1 } },
organizations: { o1: { name: 'Northwind Studio' } },
titles: { t1: { name: 'Product Designer', kind: 'title' } },
media: photo('women', 91),
},
{
id: 'demo-contact-20',
addressBookIds: { 'demo-addressbook-personal': true },
kind: 'individual',
name: { components: [{ kind: 'given', value: 'Daniel' }, { kind: 'surname', value: 'Cooper' }] },
emails: { e1: { address: 'daniel.cooper@example.com', pref: 1 } },
organizations: { o1: { name: 'Freelance' } },
titles: { t1: { name: 'Illustrator', kind: 'title' } },
media: photo('men', 76),
},
// ── Work address book ──────────────────────────────────────
@@ -135,6 +204,7 @@ export function createDemoContacts(): ContactCard[] {
phones: { p1: { number: '+1-555-0301', features: { voice: true }, contexts: { work: true } } },
organizations: { o1: { name: 'Company Inc', units: [{ name: 'Product' }] } },
titles: { t1: { name: 'Product Manager', kind: 'title' } },
media: photo('men', 41),
},
{
id: 'demo-contact-10',
@@ -144,6 +214,7 @@ export function createDemoContacts(): ContactCard[] {
emails: { e1: { address: 'rachel.green@company.example', contexts: { work: true }, pref: 1 } },
organizations: { o1: { name: 'Company Inc', units: [{ name: 'Marketing' }] } },
titles: { t1: { name: 'Marketing Lead', kind: 'title' } },
media: photo('women', 12),
},
{
id: 'demo-contact-11',
@@ -153,6 +224,7 @@ export function createDemoContacts(): ContactCard[] {
emails: { e1: { address: 'james.miller@company.example', contexts: { work: true }, pref: 1 } },
organizations: { o1: { name: 'Company Inc', units: [{ name: 'Engineering' }] } },
titles: { t1: { name: 'CTO', kind: 'title' } },
media: photo('men', 52),
},
{
id: 'demo-contact-12',
@@ -162,6 +234,7 @@ export function createDemoContacts(): ContactCard[] {
emails: { e1: { address: 'priya.sharma@company.example', contexts: { work: true }, pref: 1 } },
organizations: { o1: { name: 'Company Inc', units: [{ name: 'QA' }] } },
titles: { t1: { name: 'QA Engineer', kind: 'title' } },
media: photo('women', 77),
},
{
id: 'demo-contact-13',
@@ -171,6 +244,7 @@ export function createDemoContacts(): ContactCard[] {
emails: { e1: { address: 'ahmed.hassan@company.example', contexts: { work: true }, pref: 1 } },
organizations: { o1: { name: 'Company Inc', units: [{ name: 'DevOps' }] } },
titles: { t1: { name: 'DevOps Engineer', kind: 'title' } },
media: photo('men', 89),
},
{
id: 'demo-contact-14',
@@ -180,6 +254,7 @@ export function createDemoContacts(): ContactCard[] {
emails: { e1: { address: 'maria.lopez@company.example', contexts: { work: true }, pref: 1 } },
organizations: { o1: { name: 'Company Inc', units: [{ name: 'HR' }] } },
titles: { t1: { name: 'HR Business Partner', kind: 'title' } },
media: photo('women', 55),
},
{
id: 'demo-contact-15',
@@ -189,6 +264,7 @@ export function createDemoContacts(): ContactCard[] {
emails: { e1: { address: 'wei.zhang@company.example', contexts: { work: true }, pref: 1 } },
organizations: { o1: { name: 'Company Inc', units: [{ name: 'Data Science' }] } },
titles: { t1: { name: 'Data Scientist', kind: 'title' } },
media: photo('men', 8),
},
];
}
+590 -110
View File
@@ -1,6 +1,35 @@
import type { Email } from '@/lib/jmap/types';
import { demoDate } from '../demo-utils';
const USER = { name: 'Demo User', email: 'demo@example.com' } as const;
// Helper to keep the fixtures short - auto-assigns a partId/blobId per body.
let bodyCounter = 0;
function body(value: string, type: 'text/plain' | 'text/html' = 'text/plain') {
const partId = String(++bodyCounter);
const blobId = `blob-${partId}`;
return {
part: { partId, blobId, size: value.length, type },
values: { [partId]: { value } },
};
}
/** Build text+html parts in one shot. */
function bodies(text: string, html: string) {
const t = body(text, 'text/plain');
const h = body(html, 'text/html');
return {
textBody: [t.part],
htmlBody: [h.part],
bodyValues: { ...t.values, ...h.values },
};
}
function textOnly(text: string) {
const t = body(text, 'text/plain');
return { textBody: [t.part], bodyValues: t.values };
}
export function createDemoEmails(): Email[] {
return [
// ── Inbox ───────────────────────────────────────────────────
@@ -12,19 +41,61 @@ export function createDemoEmails(): Email[] {
size: 4200,
receivedAt: demoDate(0, -2),
from: [{ name: 'Bulwark Team', email: 'welcome@bulwark.email' }],
to: [{ name: 'Demo User', email: 'demo@example.com' }],
to: [USER],
subject: 'Welcome to Bulwark Mail!',
sentAt: demoDate(0, -2),
preview: 'Thanks for trying out Bulwark Mail. This is a demo environment where you can explore all features...',
hasAttachment: false,
textBody: [{ partId: '1', blobId: 'blob-1', size: 350, type: 'text/plain' }],
htmlBody: [{ partId: '2', blobId: 'blob-2', size: 800, type: 'text/html' }],
bodyValues: {
'1': { value: 'Thanks for trying out Bulwark Mail!\n\nThis is a demo environment where you can explore all features without connecting to a real server. All data stays on your device.\n\nFeel free to:\n- Read, compose, and organize emails\n- Manage contacts and calendars\n- Configure filters and settings\n- Try keyboard shortcuts (press ? to see them)\n\nEnjoy exploring!' },
'2': { value: '<div><h2>Welcome to Bulwark Mail!</h2><p>Thanks for trying out Bulwark Mail!</p><p>This is a demo environment where you can explore all features without connecting to a real server. <strong>All data stays on your device.</strong></p><p>Feel free to:</p><ul><li>Read, compose, and organize emails</li><li>Manage contacts and calendars</li><li>Configure filters and settings</li><li>Try keyboard shortcuts (press <kbd>?</kbd> to see them)</li></ul><p>Enjoy exploring!</p></div>' },
},
...bodies(
'Thanks for trying out Bulwark Mail!\n\nThis is a demo environment where you can explore all features without connecting to a real server. All data stays on your device.\n\nFeel free to:\n- Read, compose, and organize emails\n- Manage contacts and calendars\n- Configure filters and settings\n- Try keyboard shortcuts (press ? to see them)\n\nEnjoy exploring!',
'<div><h2>Welcome to Bulwark Mail!</h2><p>Thanks for trying out Bulwark Mail!</p><p>This is a demo environment where you can explore all features without connecting to a real server. <strong>All data stays on your device.</strong></p><p>Feel free to:</p><ul><li>Read, compose, and organize emails</li><li>Manage contacts and calendars</li><li>Configure filters and settings</li><li>Try keyboard shortcuts (press <kbd>?</kbd> to see them)</li></ul><p>Enjoy exploring!</p></div>',
),
messageId: '<welcome@demo.bulwark.email>',
},
// Mom - personal message, unread
{
id: 'demo-email-mom',
threadId: 'demo-thread-mom',
mailboxIds: { 'demo-mailbox-inbox': true },
keywords: {},
size: 1900,
receivedAt: demoDate(0, -4, -12),
from: [{ name: 'Sofia Russo', email: 'sofia.russo@example.com' }],
to: [USER],
subject: 'when are you coming home?',
sentAt: demoDate(0, -4, -12),
preview: 'Hi sweetie, your father and I were just talking - we miss you. Any chance you can come down for a weekend...',
hasAttachment: false,
...textOnly(
"Hi sweetie,\n\nYour father and I were just talking - we miss you. Any chance you can come down for a weekend before Christmas?\n\nNo pressure if you're swamped with work. Anna said she might be in town the 22nd, would be nice to all be in one place again.\n\nThe lemon tree finally fruited! Twelve lemons. I'll save you some.\n\nLove,\nMom",
),
messageId: '<5a8c-mom@example.com>',
},
// GitHub - PR review request
{
id: 'demo-email-gh-pr',
threadId: 'demo-thread-gh-pr',
mailboxIds: { 'demo-mailbox-inbox': true },
keywords: {},
size: 6400,
receivedAt: demoDate(0, -3, -5),
from: [{ name: 'Alice Johnson (via GitHub)', email: 'notifications@github.com' }],
replyTo: [{ name: 'reply', email: 'reply+abc123@reply.github.com' }],
to: [USER],
subject: '[acme/api-gateway] Add token-bucket rate limiter (#1284)',
sentAt: demoDate(0, -3, -5),
preview: '@demo-user requested your review on this pull request. Replaces the fixed-window limiter with a leaky token-bucket...',
hasAttachment: false,
...bodies(
'@demo-user requested your review on this pull request.\n\nReplaces the fixed-window limiter with a leaky token-bucket so we stop punishing clients at the second-boundary edge. Per-endpoint config lives in rate-limit.toml.\n\nThree files changed, +312 47.\n\nView it on GitHub:\nhttps://github.com/acme/api-gateway/pull/1284\n\n-\nReply to this email directly, or view it on GitHub.',
'<table style="font-family:-apple-system,sans-serif"><tr><td><strong>@demo-user</strong> requested your review on this pull request.</td></tr><tr><td style="padding-top:12px">Replaces the fixed-window limiter with a leaky token-bucket so we stop punishing clients at the second-boundary edge. Per-endpoint config lives in <code>rate-limit.toml</code>.</td></tr><tr><td style="padding-top:12px;color:#666">Three files changed, <span style="color:#16a34a">+312</span> <span style="color:#dc2626">47</span></td></tr><tr><td style="padding-top:16px"><a href="https://github.com/acme/api-gateway/pull/1284" style="background:#1f2328;color:#fff;padding:8px 16px;text-decoration:none;border-radius:6px">View on GitHub</a></td></tr></table>',
),
messageId: '<acme/api-gateway/pull/1284@github.com>',
},
// Hacker Newsletter - newsletter, read
{
id: 'demo-email-2',
threadId: 'demo-thread-2',
@@ -33,20 +104,19 @@ export function createDemoEmails(): Email[] {
size: 18500,
receivedAt: demoDate(-1, -5),
from: [{ name: 'TechDigest Weekly', email: 'newsletter@techdigest.example' }],
to: [{ name: 'Demo User', email: 'demo@example.com' }],
subject: 'This Week in Tech: AI Developments & Open Source Updates',
to: [USER],
subject: 'Issue #218 - RFC 9844, the second WebAssembly draft, and a quiet announcement from Mozilla',
sentAt: demoDate(-1, -5),
preview: 'Your weekly roundup of the most important technology news and open source developments...',
hasAttachment: false,
textBody: [{ partId: '1', blobId: 'blob-3', size: 2400, type: 'text/plain' }],
htmlBody: [{ partId: '2', blobId: 'blob-4', size: 5200, type: 'text/html' }],
bodyValues: {
'1': { value: 'This Week in Tech\n\n1. AI-Powered Code Review Tools\nNew tools are making code reviews faster and more thorough...\n\n2. Open Source Licensing Update\nThe OSI has published new guidelines for AI-generated code...\n\n3. WebAssembly 2.0 Draft\nThe W3C has released the first draft of WebAssembly 2.0...\n\nRead more at techdigest.example' },
'2': { value: '<div style="max-width:600px;margin:0 auto;"><h1>This Week in Tech</h1><h3>1. AI-Powered Code Review Tools</h3><p>New tools are making code reviews faster and more thorough, with several open-source options gaining traction.</p><h3>2. Open Source Licensing Update</h3><p>The OSI has published new guidelines for AI-generated code contributions to open source projects.</p><h3>3. WebAssembly 2.0 Draft</h3><p>The W3C has released the first draft of WebAssembly 2.0, promising improved memory management.</p></div>' },
},
messageId: '<weekly-42@techdigest.example>',
...bodies(
'TechDigest #218\n\n- THE WEEK IN STANDARDS -\n\n1. RFC 9844: Per-message TLS extensions are now official. The implications for SMTP delivery reports are surprisingly large - Mike Crispin has a write-up that runs through what changes for transactional senders.\n\n2. WebAssembly 2.0 (second public draft). Tail calls are in. SIMD is in. Component model is *almost* in but punted to a separate spec, which feels like the right call.\n\n3. Mozilla quietly shipped a privacy-preserving telemetry channel to Firefox 132. No, it doesn\'t replace ad tracking. Yes, it\'s a real cryptographic system. Worth reading the post.\n\n- TOOLS -\n\n- Datasette 1.0 is out. Ten years from the first commit.\n- Fly.io published their object store, Tigris-style, written in Go.\n- Linear added an SSO migration tool that actually handles the IdP-initiated case.\n\n- ESSAYS -\n\n* "Postgres is enough" by E. Tan - a long-form rebuttal to the microservices-by-default pattern.\n* "I rewrote my home network in TypeScript so you don\'t have to" - exactly what it sounds like.\n\n- UNSUBSCRIBE -\n\nManage your subscription at techdigest.example/manage.',
'<div style="max-width:560px;margin:0 auto;font-family:-apple-system,sans-serif;line-height:1.5"><div style="border-bottom:2px solid #111;padding-bottom:16px"><div style="font-size:11px;letter-spacing:0.12em;text-transform:uppercase;color:#888">TechDigest · Issue #218</div><h1 style="font-size:22px;margin:4px 0 0">RFC 9844, the second WebAssembly draft, and a quiet announcement from Mozilla</h1></div><h2 style="font-size:14px;text-transform:uppercase;letter-spacing:0.08em;color:#666;margin-top:24px">The week in standards</h2><p><strong>1.</strong> RFC 9844: Per-message TLS extensions are now official. The implications for SMTP delivery reports are surprisingly large - Mike Crispin has a <a href="#" style="color:#db2d54">write-up</a> that runs through what changes for transactional senders.</p><p><strong>2.</strong> WebAssembly 2.0 (second public draft). Tail calls are in. SIMD is in. Component model is <em>almost</em> in but punted to a separate spec, which feels like the right call.</p><p><strong>3.</strong> Mozilla quietly shipped a privacy-preserving telemetry channel to Firefox 132. No, it doesn\'t replace ad tracking. Yes, it\'s a real cryptographic system.</p><h2 style="font-size:14px;text-transform:uppercase;letter-spacing:0.08em;color:#666;margin-top:24px">Tools</h2><ul><li>Datasette 1.0 is out. Ten years from the first commit.</li><li>Fly.io published their object store, Tigris-style, written in Go.</li><li>Linear added an SSO migration tool that actually handles the IdP-initiated case.</li></ul><h2 style="font-size:14px;text-transform:uppercase;letter-spacing:0.08em;color:#666;margin-top:24px">Essays</h2><p style="margin:0 0 6px">"Postgres is enough" by E. Tan - a long-form rebuttal to the microservices-by-default pattern.</p><p style="margin:0">"I rewrote my home network in TypeScript so you don\'t have to" - exactly what it sounds like.</p><div style="margin-top:28px;padding-top:16px;border-top:1px solid #eee;font-size:12px;color:#888">Manage your subscription at <a href="#" style="color:#888">techdigest.example/manage</a></div></div>',
),
messageId: '<weekly-218@techdigest.example>',
},
// Thread: Project discussion (3 emails in same thread)
// Thread: Q4 Project Timeline - Alice → Bob → Alice (4 messages)
{
id: 'demo-email-3a',
threadId: 'demo-thread-3',
@@ -55,17 +125,15 @@ export function createDemoEmails(): Email[] {
size: 3100,
receivedAt: demoDate(-3, -10),
from: [{ name: 'Alice Johnson', email: 'alice.johnson@example.com' }],
to: [{ name: 'Demo User', email: 'demo@example.com' }, { name: 'Bob Chen', email: 'bob.chen@example.com' }],
to: [USER, { name: 'Bob Chen', email: 'bob.chen@example.com' }],
subject: 'Q4 Project Timeline',
sentAt: demoDate(-3, -10),
preview: 'Hi team, I wanted to share the updated timeline for our Q4 deliverables...',
hasAttachment: false,
textBody: [{ partId: '1', blobId: 'blob-5', size: 450, type: 'text/plain' }],
htmlBody: [{ partId: '2', blobId: 'blob-6', size: 650, type: 'text/html' }],
bodyValues: {
'1': { value: 'Hi team,\n\nI wanted to share the updated timeline for our Q4 deliverables:\n\n- Phase 1: Design review - Oct 15\n- Phase 2: Development - Nov 1-30\n- Phase 3: Testing - Dec 1-15\n- Phase 4: Launch - Dec 20\n\nPlease review and let me know if you see any conflicts.\n\nBest,\nAlice' },
'2': { value: '<p>Hi team,</p><p>I wanted to share the updated timeline for our Q4 deliverables:</p><ul><li>Phase 1: Design review - Oct 15</li><li>Phase 2: Development - Nov 1-30</li><li>Phase 3: Testing - Dec 1-15</li><li>Phase 4: Launch - Dec 20</li></ul><p>Please review and let me know if you see any conflicts.</p><p>Best,<br>Alice</p>' },
},
...bodies(
'Hi team,\n\nI wanted to share the updated timeline for our Q4 deliverables:\n\n- Phase 1: Design review - Oct 15\n- Phase 2: Development - Nov 1-30\n- Phase 3: Testing - Dec 1-15\n- Phase 4: Launch - Dec 20\n\nPlease review and let me know if you see any conflicts.\n\nBest,\nAlice',
'<p>Hi team,</p><p>I wanted to share the updated timeline for our Q4 deliverables:</p><ul><li>Phase 1: Design review - Oct 15</li><li>Phase 2: Development - Nov 1-30</li><li>Phase 3: Testing - Dec 1-15</li><li>Phase 4: Launch - Dec 20</li></ul><p>Please review and let me know if you see any conflicts.</p><p>Best,<br>Alice</p>',
),
messageId: '<q4-timeline-1@example.com>',
},
{
@@ -76,15 +144,14 @@ export function createDemoEmails(): Email[] {
size: 3500,
receivedAt: demoDate(-2, -8),
from: [{ name: 'Bob Chen', email: 'bob.chen@example.com' }],
to: [{ name: 'Alice Johnson', email: 'alice.johnson@example.com' }, { name: 'Demo User', email: 'demo@example.com' }],
to: [{ name: 'Alice Johnson', email: 'alice.johnson@example.com' }, USER],
subject: 'Re: Q4 Project Timeline',
sentAt: demoDate(-2, -8),
preview: 'Looks good to me! One concern: the testing window might be tight given the holidays...',
hasAttachment: false,
textBody: [{ partId: '1', blobId: 'blob-7', size: 520, type: 'text/plain' }],
bodyValues: {
'1': { value: 'Looks good to me! One concern: the testing window might be tight given the holidays. Could we start testing a few days earlier?\n\nAlso, should we set up a shared doc for tracking blockers?\n\n- Bob' },
},
...textOnly(
"Looks good to me! One concern: the testing window might be tight given the holidays. Could we start testing a few days earlier?\n\nAlso, should we set up a shared doc for tracking blockers?\n\n- Bob",
),
messageId: '<q4-timeline-2@example.com>',
inReplyTo: ['<q4-timeline-1@example.com>'],
references: ['<q4-timeline-1@example.com>'],
@@ -97,20 +164,41 @@ export function createDemoEmails(): Email[] {
size: 3800,
receivedAt: demoDate(-1, -3),
from: [{ name: 'Alice Johnson', email: 'alice.johnson@example.com' }],
to: [{ name: 'Bob Chen', email: 'bob.chen@example.com' }, { name: 'Demo User', email: 'demo@example.com' }],
to: [{ name: 'Bob Chen', email: 'bob.chen@example.com' }, USER],
subject: 'Re: Q4 Project Timeline',
sentAt: demoDate(-1, -3),
preview: 'Great point Bob. Let\'s move testing to Nov 28. I\'ll create the shared doc today...',
preview: "Great point Bob. Let's move testing to Nov 28. I'll create the shared doc today...",
hasAttachment: false,
textBody: [{ partId: '1', blobId: 'blob-8', size: 400, type: 'text/plain' }],
bodyValues: {
'1': { value: 'Great point Bob. Let\'s move testing to Nov 28. I\'ll create the shared doc today and share the link.\n\nUpdated timeline:\n- Design review: Oct 15\n- Development: Nov 1-27\n- Testing: Nov 28 - Dec 15\n- Launch: Dec 20\n\n- Alice' },
},
...textOnly(
"Great point Bob. Let's move testing to Nov 28. I'll create the shared doc today and share the link.\n\nUpdated timeline:\n- Design review: Oct 15\n- Development: Nov 1-27\n- Testing: Nov 28 - Dec 15\n- Launch: Dec 20\n\n- Alice",
),
messageId: '<q4-timeline-3@example.com>',
inReplyTo: ['<q4-timeline-2@example.com>'],
references: ['<q4-timeline-1@example.com>', '<q4-timeline-2@example.com>'],
},
// Email with attachments
// Stripe receipt
{
id: 'demo-email-stripe',
threadId: 'demo-thread-stripe',
mailboxIds: { 'demo-mailbox-inbox': true },
keywords: { $seen: true },
size: 11200,
receivedAt: demoDate(-1, -1, -22),
from: [{ name: 'Stripe', email: 'receipts@stripe.com' }],
to: [USER],
subject: 'Your receipt from Linear Inc. [#2451-9928]',
sentAt: demoDate(-1, -1, -22),
preview: 'Receipt from Linear Inc. for $16.00. Thanks for your business.',
hasAttachment: false,
...bodies(
'Receipt from Linear Inc.\nAmount paid: $16.00\nDate paid: yesterday\nPayment method: Visa •••• 4242\n\nDescription: Linear Standard (monthly)\n\nReceipt #2451-9928\n\nThis charge will appear on your statement as LINEAR INC.\n\nQuestions? Contact support@linear.app.',
'<div style="max-width:560px;margin:0 auto;font-family:-apple-system,sans-serif"><div style="text-align:center;padding:24px 0"><div style="font-size:11px;letter-spacing:0.12em;color:#888;text-transform:uppercase">Receipt</div><div style="font-size:32px;font-weight:700;margin-top:4px">$16.00</div><div style="color:#666;margin-top:4px">Linear Inc.</div></div><table style="width:100%;border-top:1px solid #eee;border-bottom:1px solid #eee"><tr><td style="padding:10px 0;color:#666">Amount</td><td style="padding:10px 0;text-align:right">$16.00</td></tr><tr><td style="padding:10px 0;color:#666;border-top:1px solid #f4f4f4">Payment method</td><td style="padding:10px 0;text-align:right;border-top:1px solid #f4f4f4">Visa •••• 4242</td></tr><tr><td style="padding:10px 0;color:#666;border-top:1px solid #f4f4f4">Receipt number</td><td style="padding:10px 0;text-align:right;border-top:1px solid #f4f4f4;font-family:monospace">2451-9928</td></tr></table><p style="color:#666;font-size:13px;margin-top:24px">Description: Linear Standard (monthly). This charge will appear on your statement as LINEAR INC.</p></div>',
),
messageId: '<receipt-2451-9928@stripe.com>',
},
// Email with attachments - invoice
{
id: 'demo-email-4',
threadId: 'demo-thread-4',
@@ -119,22 +207,22 @@ export function createDemoEmails(): Email[] {
size: 245000,
receivedAt: demoDate(0, -6),
from: [{ name: 'Sarah Kim', email: 'sarah.kim@example.com' }],
to: [{ name: 'Demo User', email: 'demo@example.com' }],
subject: 'Invoice #2024-089 & Project Screenshot',
to: [USER],
subject: 'Invoice #2024-089 & landing-page prototype v3',
sentAt: demoDate(0, -6),
preview: 'Hi, please find attached the invoice for October and a screenshot of the latest prototype...',
preview: "Hi, please find attached the invoice for October and a screenshot of the latest prototype...",
hasAttachment: true,
textBody: [{ partId: '1', blobId: 'blob-9', size: 280, type: 'text/plain' }],
bodyValues: {
'1': { value: 'Hi,\n\nPlease find attached the invoice for October and a screenshot of the latest prototype.\n\nLet me know if you have any questions.\n\nBest regards,\nSarah' },
},
...textOnly(
"Hi,\n\nPlease find attached the invoice for October and a screenshot of the latest prototype. I went with Option B for the hero (the one with the asymmetric grid) since you mentioned the symmetrical version felt too flat in our last call.\n\nIf the invoice line items look off, ping me - I had to back out the November pre-payment.\n\nBest regards,\nSarah",
),
attachments: [
{ partId: 'att-1', blobId: 'demo-blob-att-1', size: 145000, name: 'Invoice-2024-089.pdf', type: 'application/pdf' },
{ partId: 'att-2', blobId: 'demo-blob-att-2', size: 89000, name: 'prototype-v3.png', type: 'image/png' },
],
messageId: '<invoice-089@example.com>',
},
// Starred email
// Carlos - starred, social
{
id: 'demo-email-5',
threadId: 'demo-thread-5',
@@ -143,18 +231,286 @@ export function createDemoEmails(): Email[] {
size: 2800,
receivedAt: demoDate(-2, -1),
from: [{ name: 'Carlos Rivera', email: 'carlos.rivera@example.com' }],
to: [{ name: 'Demo User', email: 'demo@example.com' }],
subject: 'Reminder: Team Dinner Friday',
to: [USER],
subject: 'Friday dinner - moved to 7:30 (sorry!)',
sentAt: demoDate(-2, -1),
preview: 'Hey! Just a reminder about our team dinner this Friday at 7 PM at The Garden Bistro...',
preview: 'Quick heads up - had to push the dinner back half an hour. Bistro could only do the late seating...',
hasAttachment: false,
textBody: [{ partId: '1', blobId: 'blob-10', size: 320, type: 'text/plain' }],
bodyValues: {
'1': { value: 'Hey!\n\nJust a reminder about our team dinner this Friday at 7 PM at The Garden Bistro. I\'ve made a reservation for 8 people.\n\nAddress: 123 Oak Street\n\nLet me know if you can make it!\n\nCheers,\nCarlos' },
},
...textOnly(
"Quick heads up - had to push the dinner back half an hour. Bistro could only do the late seating.\n\nNew time: Friday, 7:30 PM\nThe Garden Bistro, 123 Oak Street\n\nReservation under my name, 8 people. Let me know if that doesn't work for you and I can try to wrangle something.\n\nCheers,\nCarlos",
),
messageId: '<dinner-reminder@example.com>',
},
// Linear - issue assigned
{
id: 'demo-email-linear',
threadId: 'demo-thread-linear',
mailboxIds: { 'demo-mailbox-inbox': true },
keywords: {},
size: 5400,
receivedAt: demoDate(0, -7, -15),
from: [{ name: 'Linear', email: 'notifications@linear.app' }],
to: [USER],
subject: 'BUL-2031 was assigned to you - "Compose: drag-and-drop attachments duplicated on slow networks"',
sentAt: demoDate(0, -7, -15),
preview: 'Priya Sharma assigned this issue to you. Repro on a throttled connection (Slow 3G): drop a file twice and...',
hasAttachment: false,
...bodies(
"Priya Sharma assigned BUL-2031 to you.\n\nTitle: Compose: drag-and-drop attachments duplicated on slow networks\nPriority: Medium\n\nRepro on a throttled connection (Slow 3G): drop a file twice in quick succession into the compose drop zone. The first upload doesn't get debounced and both attempts complete, so the attachment shows up twice in the draft.\n\nOpen in Linear: https://linear.app/bulwark/issue/BUL-2031",
'<table style="font-family:-apple-system,sans-serif;max-width:520px"><tr><td><div style="font-size:11px;color:#888;letter-spacing:0.08em;text-transform:uppercase">Linear · BUL-2031</div><div style="font-size:18px;font-weight:600;margin-top:6px">Compose: drag-and-drop attachments duplicated on slow networks</div><div style="margin-top:8px;color:#666"><strong>Priya Sharma</strong> assigned this issue to you · Priority Medium</div></td></tr><tr><td style="padding-top:16px;color:#444">Repro on a throttled connection (Slow 3G): drop a file twice in quick succession into the compose drop zone. The first upload doesn\'t get debounced and both attempts complete, so the attachment shows up twice in the draft.</td></tr><tr><td style="padding-top:16px"><a href="https://linear.app/bulwark/issue/BUL-2031" style="background:#5e6ad2;color:#fff;padding:8px 16px;text-decoration:none;border-radius:6px;font-size:13px">Open in Linear</a></td></tr></table>',
),
messageId: '<BUL-2031-assign@linear.app>',
},
// Anna - sister, photos
{
id: 'demo-email-anna',
threadId: 'demo-thread-anna',
mailboxIds: { 'demo-mailbox-inbox': true },
keywords: {},
size: 4800000,
receivedAt: demoDate(-1, -19),
from: [{ name: 'Anna Kowalski', email: 'anna.kowalski@example.com' }],
to: [USER],
subject: 'photos from the wedding',
sentAt: demoDate(-1, -19),
preview: "finally got around to going through these. there are like 600 more on the drive but here's the highlights...",
hasAttachment: true,
...textOnly(
"ok finally got around to going through these. there are like 600 more on the drive but here's the highlights - the ones I'd actually want to print.\n\nmom looked SO happy. dad cried during the speech btw, did you see?\n\nlet me know which ones you want full-res of\n\na",
),
attachments: [
{ partId: 'att-3', blobId: 'demo-blob-att-3', size: 1800000, name: 'wedding-001.jpg', type: 'image/jpeg' },
{ partId: 'att-4', blobId: 'demo-blob-att-4', size: 1600000, name: 'wedding-014-mom-dad.jpg', type: 'image/jpeg' },
{ partId: 'att-5', blobId: 'demo-blob-att-5', size: 1400000, name: 'wedding-038-the-toast.jpg', type: 'image/jpeg' },
],
messageId: '<wedding-photos@example.com>',
},
// AWS billing
{
id: 'demo-email-aws',
threadId: 'demo-thread-aws',
mailboxIds: { 'demo-mailbox-inbox': true },
keywords: { $seen: true },
size: 9100,
receivedAt: demoDate(-2, -3, -45),
from: [{ name: 'AWS Billing', email: 'no-reply-aws@amazon.com' }],
to: [USER],
subject: 'Your AWS bill is available - $127.43',
sentAt: demoDate(-2, -3, -45),
preview: 'Your bill for the previous billing period is now available. Total this period: $127.43 (down $4.12)...',
hasAttachment: false,
...textOnly(
"Your bill for the previous billing period is now available.\n\nTotal this period: $127.43 (down $4.12 from last period)\n\nTop services:\n EC2 - $61.20\n S3 - $28.94\n Route 53 - $14.50\n CloudFront - $11.02\n Other - $11.77\n\nView the full invoice in the Billing Console.",
),
messageId: '<aws-bill-2024-11@amazon.com>',
},
// 2FA code - system, unread
{
id: 'demo-email-2fa',
threadId: 'demo-thread-2fa',
mailboxIds: { 'demo-mailbox-inbox': true },
keywords: {},
size: 1700,
receivedAt: demoDate(0, -1, -8),
from: [{ name: '1Password', email: 'noreply@1password.com' }],
to: [USER],
subject: 'Your one-time verification code is 814-302',
sentAt: demoDate(0, -1, -8),
preview: "Use this code within 10 minutes to sign in. If you didn't request it, ignore this email.",
hasAttachment: false,
...textOnly(
"Your verification code: 814-302\n\nUse this code within 10 minutes to sign in. If you didn't request it, you can safely ignore this email - your account remains secure.",
),
messageId: '<otp-814302@1password.com>',
},
// LinkedIn - cold-ish
{
id: 'demo-email-linkedin',
threadId: 'demo-thread-linkedin',
mailboxIds: { 'demo-mailbox-inbox': true },
keywords: { $seen: true },
size: 8200,
receivedAt: demoDate(-3, -11),
from: [{ name: 'LinkedIn', email: 'jobs-noreply@linkedin.com' }],
to: [USER],
subject: '5 jobs matching "staff engineer · remote · eu" - including one at Datadog',
sentAt: demoDate(-3, -11),
preview: "We thought you'd be interested in these jobs based on your profile and search history.",
hasAttachment: false,
...textOnly(
'Based on your saved search "staff engineer · remote · eu":\n\n1. Staff Software Engineer - Datadog (Remote, EU)\n2. Principal Engineer, Platform - Sentry (Remote, EU)\n3. Staff Backend Engineer - Linear (Remote)\n4. Tech Lead, Infrastructure - Tailscale (Remote, EU)\n5. Staff Engineer, Mobile - Notion (Remote, EU)\n\nManage job alerts at linkedin.com/jobs/preferences.',
),
messageId: '<jobs-1107@linkedin.com>',
},
// Book club - Marcus
{
id: 'demo-email-bookclub',
threadId: 'demo-thread-bookclub',
mailboxIds: { 'demo-mailbox-inbox': true },
keywords: {},
size: 2400,
receivedAt: demoDate(-1, -14),
from: [{ name: 'Marcus Hughes', email: 'marcus.hughes@example.com' }],
to: [USER, { name: 'Emma Wilson', email: 'emma.wilson@example.com' }, { name: 'David Park', email: 'david.park@example.com' }],
subject: 'book club thursday - picking the next one',
sentAt: demoDate(-1, -14),
preview: 'Reminder: 7pm at mine. We finish off Le Guin and pick the next read. My vote is the Calvino but I know Emma...',
hasAttachment: false,
...textOnly(
"Reminder: 7pm at mine. We finish off Le Guin and pick the next read.\n\nMy vote is the Calvino but I know Emma's been pushing for the Knausgaard. I'll bring wine, can someone else handle snacks?\n\nm",
),
messageId: '<bookclub-nov@example.com>',
},
// DHL package
{
id: 'demo-email-dhl',
threadId: 'demo-thread-dhl',
mailboxIds: { 'demo-mailbox-inbox': true },
keywords: {},
size: 5600,
receivedAt: demoDate(0, -9, -30),
from: [{ name: 'DHL Express', email: 'noreply@dhl.com' }],
to: [USER],
subject: 'Your package is out for delivery - arriving today',
sentAt: demoDate(0, -9, -30),
preview: 'Tracking 1Z 999 AA1 0123 4567 84 · Estimated delivery: today between 14:00 and 18:00.',
hasAttachment: false,
...textOnly(
'Your package is on the truck.\n\nTracking: 1Z 999 AA1 0123 4567 84\nEstimated delivery window: today, 14:0018:00\n\nIf no one is home, the driver will attempt redelivery tomorrow or leave it at the nearest pickup point.\n\nTrack live at dhl.com/track.',
),
messageId: '<delivery-1Z999AA1@dhl.com>',
},
// Notion
{
id: 'demo-email-notion',
threadId: 'demo-thread-notion',
mailboxIds: { 'demo-mailbox-inbox': true },
keywords: { $seen: true },
size: 4100,
receivedAt: demoDate(-2, -16),
from: [{ name: 'Olivia Bennett (via Notion)', email: 'team@mail.notion.so' }],
to: [USER],
subject: 'Olivia shared "Q1 2026 - design north star" with you',
sentAt: demoDate(-2, -16),
preview: 'Olivia Bennett shared a page with you in the Northwind workspace. Open in Notion to view.',
hasAttachment: false,
...textOnly(
'Olivia Bennett shared a page with you in the Northwind workspace.\n\n"Q1 2026 - design north star"\n\nOpen in Notion: https://notion.so/northwind/q1-design-north-star',
),
messageId: '<share-northwind-q1@mail.notion.so>',
},
// Spotify wrap
{
id: 'demo-email-spotify',
threadId: 'demo-thread-spotify',
mailboxIds: { 'demo-mailbox-inbox': true },
keywords: { $seen: true },
size: 7400,
receivedAt: demoDate(-4, -8),
from: [{ name: 'Spotify', email: 'no-reply@spotify.com' }],
to: [USER],
subject: 'Your year in music is ready',
sentAt: demoDate(-4, -8),
preview: 'You spent 38,420 minutes listening this year. Your top artist was Big Thief, and your top genre was indie folk.',
hasAttachment: false,
...textOnly(
'Your year, in music.\n\n38,420 minutes listened\nTop artist: Big Thief\nTop song: "Vampire Empire"\nTop genre: indie folk\nDiscover Weekly hit rate: 41%\n\nOpen Spotify to see your full Wrapped.',
),
messageId: '<wrapped-2025@spotify.com>',
},
// Booking.com confirmation
{
id: 'demo-email-booking',
threadId: 'demo-thread-booking',
mailboxIds: { 'demo-mailbox-inbox': true },
keywords: { $seen: true },
size: 32100,
receivedAt: demoDate(-5, -10),
from: [{ name: 'Booking.com', email: 'no-reply@booking.com' }],
to: [USER],
subject: 'Confirmation 4892-7714-3320 - Hotel Lago, Lake Como (Dec 2225)',
sentAt: demoDate(-5, -10),
preview: 'Your booking is confirmed. Check-in: Dec 22, after 15:00. Check-out: Dec 25, before 11:00.',
hasAttachment: true,
...textOnly(
'Your booking is confirmed.\n\nHotel Lago, Lake Como (Italy)\nCheck-in: Dec 22, after 15:00\nCheck-out: Dec 25, before 11:00\n\nRoom: Lake-view double, breakfast included\nTotal: €612 (paid)\n\nConfirmation number: 4892-7714-3320\n\nYour voucher is attached. Show it at reception.',
),
attachments: [
{ partId: 'att-6', blobId: 'demo-blob-att-6', size: 31000, name: 'booking-voucher-4892-7714-3320.pdf', type: 'application/pdf' },
],
messageId: '<conf-4892-7714-3320@booking.com>',
},
// Substack post
{
id: 'demo-email-substack',
threadId: 'demo-thread-substack',
mailboxIds: { 'demo-mailbox-inbox': true },
keywords: { $seen: true },
size: 22400,
receivedAt: demoDate(-1, -12),
from: [{ name: 'Robin Sloan', email: 'robin@substack.com' }],
to: [USER],
subject: 'a small newsletter about a small forge',
sentAt: demoDate(-1, -12),
preview: 'I have been spending the slow weeks of November in the workshop, slowly forging a knife from a piece of...',
hasAttachment: false,
...textOnly(
"Hello, friends.\n\nI have been spending the slow weeks of November in the workshop, slowly forging a knife from a piece of railway track. It is going badly, in the way that is good for one's soul.\n\nWhat I'm reading: Annie Dillard, again. \"The Writing Life\". Specifically the chapter about her cabin, which I read every year around this time and which always makes me want to throw my laptop into the sea.\n\nWhat I'm watching: very little. There is something about December that makes television feel like an admission of defeat.\n\nUntil next month -\nR.",
),
messageId: '<nov-2025@robin.substack.com>',
},
// Recruiter cold outreach
{
id: 'demo-email-recruiter',
threadId: 'demo-thread-recruiter',
mailboxIds: { 'demo-mailbox-inbox': true },
keywords: {},
size: 3200,
receivedAt: demoDate(0, -10),
from: [{ name: 'Jennifer Hayes', email: 'jennifer@talent-partners.example' }],
to: [USER],
subject: 'Senior role - Distributed Systems - €180-220k + equity',
sentAt: demoDate(0, -10),
preview: "Hi, I came across your profile and thought you'd be a great fit for a senior position with one of our clients...",
hasAttachment: false,
...textOnly(
"Hi,\n\nI came across your profile and thought you'd be a great fit for a senior position with one of our clients - a well-funded Series B (real-time data infrastructure, 60-person eng team, fully remote within EU).\n\nThe core stack: Rust + Postgres + a non-trivial amount of Go. Hiring level is roughly equivalent to Staff at FAANG.\n\nWould you be open to a 15-minute call this week or next?\n\nBest,\nJennifer Hayes\nTalent Partners",
),
messageId: '<outreach-jh-2025-11@talent-partners.example>',
},
// Dentist reminder
{
id: 'demo-email-dentist',
threadId: 'demo-thread-dentist',
mailboxIds: { 'demo-mailbox-inbox': true },
keywords: {},
size: 2200,
receivedAt: demoDate(-1, -2),
from: [{ name: "Dr. Smith's Office", email: 'appointments@drsmith.example' }],
to: [USER],
subject: 'Appointment reminder - Tuesday at 10:00',
sentAt: demoDate(-1, -2),
preview: 'This is a friendly reminder of your upcoming cleaning appointment on Tuesday at 10:00 AM.',
hasAttachment: false,
...textOnly(
"Hello,\n\nThis is a friendly reminder of your upcoming cleaning appointment on Tuesday at 10:00 AM with Dr. Smith.\n\nLocation: 123 Medical Plaza, Suite 4\n\nNeed to reschedule? Reply to this email or call (555) 010-7878.\n\nSee you Tuesday!\nDr. Smith's office",
),
messageId: '<appt-reminder-dr-smith@drsmith.example>',
},
// ── Sent ────────────────────────────────────────────────────
{
id: 'demo-email-6',
@@ -163,16 +519,15 @@ export function createDemoEmails(): Email[] {
keywords: { $seen: true },
size: 2100,
receivedAt: demoDate(-1, -4),
from: [{ name: 'Demo User', email: 'demo@example.com' }],
from: [USER],
to: [{ name: 'Alice Johnson', email: 'alice.johnson@example.com' }],
subject: 'Updated Requirements Document',
sentAt: demoDate(-1, -4),
preview: 'Hi Alice, I\'ve updated the requirements document with the changes we discussed...',
preview: "Hi Alice, I've updated the requirements document with the changes we discussed...",
hasAttachment: false,
textBody: [{ partId: '1', blobId: 'blob-11', size: 290, type: 'text/plain' }],
bodyValues: {
'1': { value: 'Hi Alice,\n\nI\'ve updated the requirements document with the changes we discussed in yesterday\'s meeting. The main updates are in sections 3 and 5.\n\nLet me know if you have any questions.\n\nBest,\nDemo User' },
},
...textOnly(
"Hi Alice,\n\nI've updated the requirements document with the changes we discussed in yesterday's meeting. The main updates are in sections 3 and 5.\n\nLet me know if you have any questions.\n\nBest,\nDemo User",
),
messageId: '<sent-1@example.com>',
},
{
@@ -182,18 +537,37 @@ export function createDemoEmails(): Email[] {
keywords: { $seen: true },
size: 1800,
receivedAt: demoDate(-4, -2),
from: [{ name: 'Demo User', email: 'demo@example.com' }],
from: [USER],
to: [{ name: 'Sarah Kim', email: 'sarah.kim@example.com' }],
subject: 'Re: Design Feedback',
sentAt: demoDate(-4, -2),
preview: 'Thanks Sarah! The new color scheme looks great. I especially like the contrast improvements...',
hasAttachment: false,
textBody: [{ partId: '1', blobId: 'blob-12', size: 250, type: 'text/plain' }],
bodyValues: {
'1': { value: 'Thanks Sarah! The new color scheme looks great. I especially like the contrast improvements for accessibility.\n\nLet\'s go with Option B for the navigation.\n\nBest,\nDemo User' },
},
...textOnly(
"Thanks Sarah! The new color scheme looks great. I especially like the contrast improvements for accessibility.\n\nLet's go with Option B for the navigation.\n\nBest,\nDemo User",
),
messageId: '<sent-2@example.com>',
},
{
id: 'demo-email-sent-mom',
threadId: 'demo-thread-mom',
mailboxIds: { 'demo-mailbox-sent': true },
keywords: { $seen: true },
size: 1400,
receivedAt: demoDate(0, -2, -10),
from: [USER],
to: [{ name: 'Sofia Russo', email: 'sofia.russo@example.com' }],
subject: 'Re: when are you coming home?',
sentAt: demoDate(0, -2, -10),
preview: "Mom - I miss you too. Let me check the calendar tonight and I'll get back to you tomorrow about the weekend...",
hasAttachment: false,
...textOnly(
"Mom - I miss you too. Let me check the calendar tonight and I'll get back to you tomorrow about the weekend. Lemons sound like a bribe and I will not pretend otherwise.\n\nLove you both.",
),
messageId: '<re-mom-1@example.com>',
inReplyTo: ['<5a8c-mom@example.com>'],
references: ['<5a8c-mom@example.com>'],
},
// ── Drafts ──────────────────────────────────────────────────
{
@@ -203,18 +577,35 @@ export function createDemoEmails(): Email[] {
keywords: { $seen: true, $draft: true },
size: 900,
receivedAt: demoDate(0, -1),
from: [{ name: 'Demo User', email: 'demo@example.com' }],
from: [USER],
to: [{ name: 'Bob Chen', email: 'bob.chen@example.com' }],
subject: 'Meeting Notes - Draft',
sentAt: demoDate(0, -1),
preview: 'Here are the notes from today\'s standup...',
preview: "Here are the notes from today's standup...",
hasAttachment: false,
textBody: [{ partId: '1', blobId: 'blob-13', size: 180, type: 'text/plain' }],
bodyValues: {
'1': { value: 'Here are the notes from today\'s standup:\n\n- API integration on track\n- Need to resolve the caching issue\n- ' },
},
...textOnly(
"Here are the notes from today's standup:\n\n- API integration on track\n- Need to resolve the caching issue\n- ",
),
messageId: '<draft-1@example.com>',
},
{
id: 'demo-email-draft-recruiter',
threadId: 'demo-thread-draft-recruiter',
mailboxIds: { 'demo-mailbox-drafts': true },
keywords: { $seen: true, $draft: true },
size: 720,
receivedAt: demoDate(0, -8),
from: [USER],
to: [{ name: 'Jennifer Hayes', email: 'jennifer@talent-partners.example' }],
subject: 'Re: Senior role - Distributed Systems',
sentAt: demoDate(0, -8),
preview: "Hi Jennifer, thanks for reaching out. I'm not actively looking, but the role sounds interesting enough that...",
hasAttachment: false,
...textOnly(
"Hi Jennifer,\n\nThanks for reaching out. I'm not actively looking, but the role sounds interesting enough that I'd be open to a quick call. A few questions before we set something up:\n\n- ",
),
messageId: '<draft-recruiter@example.com>',
},
// ── Trash ───────────────────────────────────────────────────
{
@@ -225,15 +616,14 @@ export function createDemoEmails(): Email[] {
size: 15200,
receivedAt: demoDate(-5, -3),
from: [{ name: 'Promo Store', email: 'deals@promostore.example' }],
to: [{ name: 'Demo User', email: 'demo@example.com' }],
to: [USER],
subject: '🎉 Flash Sale: 50% Off Everything!',
sentAt: demoDate(-5, -3),
preview: 'Limited time offer! Get 50% off all items in our store...',
hasAttachment: false,
textBody: [{ partId: '1', blobId: 'blob-14', size: 400, type: 'text/plain' }],
bodyValues: {
'1': { value: 'Limited time offer! Get 50% off all items in our store. Use code FLASH50 at checkout.' },
},
...textOnly(
'Limited time offer! Get 50% off all items in our store. Use code FLASH50 at checkout.',
),
messageId: '<promo-1@promostore.example>',
},
{
@@ -244,15 +634,14 @@ export function createDemoEmails(): Email[] {
size: 2300,
receivedAt: demoDate(-7, 0),
from: [{ name: 'System Notification', email: 'noreply@service.example' }],
to: [{ name: 'Demo User', email: 'demo@example.com' }],
to: [USER],
subject: 'Your password was changed',
sentAt: demoDate(-7, 0),
preview: 'Your account password was successfully changed on...',
hasAttachment: false,
textBody: [{ partId: '1', blobId: 'blob-15', size: 200, type: 'text/plain' }],
bodyValues: {
'1': { value: 'Your account password was successfully changed. If you did not make this change, please contact support immediately.' },
},
...textOnly(
'Your account password was successfully changed. If you did not make this change, please contact support immediately.',
),
messageId: '<notification-1@service.example>',
},
@@ -265,15 +654,14 @@ export function createDemoEmails(): Email[] {
size: 4500,
receivedAt: demoDate(-2, -7),
from: [{ name: 'Alice Johnson', email: 'alice.johnson@example.com' }],
to: [{ name: 'Demo User', email: 'demo@example.com' }],
to: [USER],
subject: '[Project] Sprint Planning Agenda',
sentAt: demoDate(-2, -7),
preview: 'Here\'s the agenda for next week\'s sprint planning session...',
preview: "Here's the agenda for next week's sprint planning session...",
hasAttachment: false,
textBody: [{ partId: '1', blobId: 'blob-16', size: 600, type: 'text/plain' }],
bodyValues: {
'1': { value: 'Hi team,\n\nHere\'s the agenda for next week\'s sprint planning:\n\n1. Review previous sprint velocity\n2. Discuss tech debt items\n3. Prioritize backlog\n4. Assign story points\n5. Capacity planning\n\nPlease come prepared with your updates.\n\nThanks,\nAlice' },
},
...textOnly(
"Hi team,\n\nHere's the agenda for next week's sprint planning:\n\n1. Review previous sprint velocity\n2. Discuss tech debt items\n3. Prioritize backlog\n4. Assign story points\n5. Capacity planning\n\nPlease come prepared with your updates.\n\nThanks,\nAlice",
),
messageId: '<project-1@example.com>',
},
{
@@ -284,17 +672,37 @@ export function createDemoEmails(): Email[] {
size: 3200,
receivedAt: demoDate(0, -8),
from: [{ name: 'Bob Chen', email: 'bob.chen@example.com' }],
to: [{ name: 'Demo User', email: 'demo@example.com' }],
to: [USER],
subject: '[Project] API Rate Limiting Discussion',
sentAt: demoDate(0, -8),
preview: 'I\'ve been thinking about our rate limiting approach and wanted to propose a few changes...',
preview: "I've been thinking about our rate limiting approach and wanted to propose a few changes...",
hasAttachment: false,
textBody: [{ partId: '1', blobId: 'blob-17', size: 480, type: 'text/plain' }],
bodyValues: {
'1': { value: 'Hey,\n\nI\'ve been thinking about our rate limiting approach and wanted to propose:\n\n1. Token bucket algorithm instead of fixed window\n2. Per-endpoint limits rather than global\n3. Graduated response (warn → throttle → block)\n\nThoughts? I can put together a more detailed RFC if we agree on the direction.\n\n- Bob' },
},
...textOnly(
"Hey,\n\nI've been thinking about our rate limiting approach and wanted to propose:\n\n1. Token bucket algorithm instead of fixed window\n2. Per-endpoint limits rather than global\n3. Graduated response (warn → throttle → block)\n\nThoughts? I can put together a more detailed RFC if we agree on the direction.\n\n- Bob",
),
messageId: '<project-2@example.com>',
},
{
id: 'demo-email-roadmap',
threadId: 'demo-thread-roadmap',
mailboxIds: { 'demo-mailbox-projects': true },
keywords: {},
size: 4900,
receivedAt: demoDate(-1, -15),
from: [{ name: 'Michael Torres', email: 'michael.torres@company.example' }],
to: [USER, { name: 'Alice Johnson', email: 'alice.johnson@example.com' }, { name: 'James Miller', email: 'james.miller@company.example' }],
subject: '[Project] Q1 2026 roadmap - first cut',
sentAt: demoDate(-1, -15),
preview: 'Attached is the first cut of the Q1 roadmap. Three themes: reliability, mobile, and the long-promised...',
hasAttachment: true,
...textOnly(
"Team,\n\nAttached is the first cut of the Q1 roadmap. Three themes:\n\n1. Reliability (Alice's team)\n2. Mobile parity (cross-functional)\n3. The long-promised search rework (James, this is mostly on you)\n\nLet's leave comments in the doc rather than do a meeting - I'd rather have the meeting be the *decisions*, not the discussion. Closing comments end-of-week.\n\nM",
),
attachments: [
{ partId: 'att-7', blobId: 'demo-blob-att-7', size: 84000, name: 'Q1-2026-roadmap-v0.pdf', type: 'application/pdf' },
],
messageId: '<roadmap-q1-2026@company.example>',
},
// ── Archive ─────────────────────────────────────────────────
{
@@ -304,18 +712,35 @@ export function createDemoEmails(): Email[] {
keywords: { $seen: true },
size: 2600,
receivedAt: demoDate(-14, -6),
from: [{ name: 'HR Department', email: 'hr@company.example' }],
to: [{ name: 'Demo User', email: 'demo@example.com' }],
from: [{ name: 'Maria Lopez', email: 'maria.lopez@company.example' }],
to: [USER],
subject: 'Updated PTO Policy - Effective January 1',
sentAt: demoDate(-14, -6),
preview: 'Please review the updated PTO policy that takes effect January 1st...',
hasAttachment: false,
textBody: [{ partId: '1', blobId: 'blob-18', size: 380, type: 'text/plain' }],
bodyValues: {
'1': { value: 'Dear team,\n\nPlease review the updated PTO policy effective January 1st. Key changes include:\n\n- Increased annual allowance from 20 to 25 days\n- Flexible half-day options\n- Rollover limit increased to 10 days\n\nPlease acknowledge receipt.\n\nBest,\nHR Department' },
},
...textOnly(
'Dear team,\n\nPlease review the updated PTO policy effective January 1st. Key changes include:\n\n- Increased annual allowance from 20 to 25 days\n- Flexible half-day options\n- Rollover limit increased to 10 days\n\nPlease acknowledge receipt.\n\nBest,\nMaria - People Ops',
),
messageId: '<hr-policy-1@company.example>',
},
{
id: 'demo-email-archive-support',
threadId: 'demo-thread-archive-support',
mailboxIds: { 'demo-mailbox-archive': true },
keywords: { $seen: true },
size: 3400,
receivedAt: demoDate(-21, -4),
from: [{ name: 'Fastmail Support', email: 'support@fastmail.com' }],
to: [USER],
subject: 'Re: Ticket #438201 - DKIM signing fails on cross-account aliases',
sentAt: demoDate(-21, -4),
preview: "Thanks for the additional logs. We were able to reproduce on our side - the issue was indeed the alias resolution...",
hasAttachment: false,
...textOnly(
"Hi,\n\nThanks for the additional logs. We were able to reproduce on our side - the issue was indeed the alias resolution path skipping the DKIM signer step. Fix has been deployed to the AU and SY clusters; EU rolls out tomorrow.\n\nResolved on our end. Please reopen if you see anything related.\n\nBest,\nClaire - Fastmail Support",
),
messageId: '<ticket-438201-resolved@fastmail.com>',
},
// ── Receipts ────────────────────────────────────────────────
{
@@ -325,17 +750,37 @@ export function createDemoEmails(): Email[] {
keywords: { $seen: true },
size: 5200,
receivedAt: demoDate(-3, -12),
from: [{ name: 'Cloud Services', email: 'billing@cloudprovider.example' }],
to: [{ name: 'Demo User', email: 'demo@example.com' }],
subject: 'Payment Receipt - Invoice #INV-2024-1042',
from: [{ name: 'Hetzner', email: 'billing@hetzner.com' }],
to: [USER],
subject: 'Invoice #INV-2024-1042 - €49.99 (paid)',
sentAt: demoDate(-3, -12),
preview: 'Your payment of $49.99 has been processed successfully...',
preview: 'Your payment of 49.99 has been processed successfully...',
hasAttachment: true,
...textOnly(
'Payment Confirmation\n\nAmount: €49.99\nDate: 3 days ago\nInvoice: INV-2024-1042\nService: CX22 dedicated (Helsinki, monthly)\n\nThank you for your payment.',
),
attachments: [
{ partId: 'att-8', blobId: 'demo-blob-att-8', size: 28000, name: 'INV-2024-1042.pdf', type: 'application/pdf' },
],
messageId: '<receipt-1@hetzner.com>',
},
{
id: 'demo-email-receipts-domain',
threadId: 'demo-thread-receipts-domain',
mailboxIds: { 'demo-mailbox-receipts': true },
keywords: { $seen: true },
size: 3100,
receivedAt: demoDate(-9, -8),
from: [{ name: 'Porkbun', email: 'support@porkbun.com' }],
to: [USER],
subject: 'Renewal confirmation - example.com (1 year)',
sentAt: demoDate(-9, -8),
preview: 'Your domain example.com has been renewed for 1 year. Next renewal: 11 months from today.',
hasAttachment: false,
textBody: [{ partId: '1', blobId: 'blob-19', size: 350, type: 'text/plain' }],
bodyValues: {
'1': { value: 'Payment Confirmation\n\nAmount: $49.99\nDate: Processing date\nInvoice: INV-2024-1042\nService: Cloud Hosting (Standard Plan)\n\nThank you for your payment.' },
},
messageId: '<receipt-1@cloudprovider.example>',
...textOnly(
"Hi,\n\nYour domain example.com has been renewed for 1 year.\n\nAmount: $11.06\nNext renewal: 11 months from today\nAutorenew: on\n\nReply to this email if you need a tax-receipt-style invoice.\n\n- Porkbun",
),
messageId: '<renewal-example.com@porkbun.com>',
},
// ── Spam ────────────────────────────────────────────────────
@@ -347,16 +792,51 @@ export function createDemoEmails(): Email[] {
size: 8900,
receivedAt: demoDate(-1, -9),
from: [{ name: 'Prize Center', email: 'winner@totallylegit.example' }],
to: [{ name: 'Demo User', email: 'demo@example.com' }],
to: [USER],
subject: 'Congratulations! You Won $1,000,000!!!',
sentAt: demoDate(-1, -9),
preview: 'Dear lucky winner, you have been selected to receive one million dollars...',
hasAttachment: false,
textBody: [{ partId: '1', blobId: 'blob-20', size: 500, type: 'text/plain' }],
bodyValues: {
'1': { value: 'Dear lucky winner,\n\nYou have been selected to receive ONE MILLION DOLLARS! Click below to claim your prize immediately.\n\n[This is a demo spam email]' },
},
...textOnly(
'Dear lucky winner,\n\nYou have been selected to receive ONE MILLION DOLLARS! Click below to claim your prize immediately.\n\n[This is a demo spam email]',
),
messageId: '<spam-1@totallylegit.example>',
},
{
id: 'demo-email-spam-phish',
threadId: 'demo-thread-spam-phish',
mailboxIds: { 'demo-mailbox-junk': true },
keywords: {},
size: 4600,
receivedAt: demoDate(-2, -3),
from: [{ name: 'Secure Banking', email: 'security-alert@secur1ty-bank.example' }],
to: [USER],
subject: 'URGENT: Unusual activity on your account - verify within 24 hours',
sentAt: demoDate(-2, -3),
preview: "We've detected suspicious activity. Click below to verify your identity or your account will be suspended...",
hasAttachment: false,
...textOnly(
"We've detected suspicious activity on your account. To prevent suspension, please verify your details within 24 hours by clicking the link below.\n\n[Phishing demo - never click links like this in real life.]",
),
messageId: '<phish-1@secur1ty-bank.example>',
},
{
id: 'demo-email-spam-crypto',
threadId: 'demo-thread-spam-crypto',
mailboxIds: { 'demo-mailbox-junk': true },
keywords: {},
size: 6800,
receivedAt: demoDate(-3, -19),
from: [{ name: 'CryptoGrowth Daily', email: 'invest@cryptogrowth.example' }],
to: [USER],
subject: '🚀 The coin Elon won\'t tell you about - 1000x potential',
sentAt: demoDate(-3, -19),
preview: 'Three early backers turned $500 into $5M in 90 days. Today, you have a chance to get in even earlier...',
hasAttachment: false,
...textOnly(
'Three early backers turned $500 into $5M in 90 days. Today, you have a chance to get in even earlier. Limited spots. No experience needed.\n\n[Demo spam.]',
),
messageId: '<spam-crypto@cryptogrowth.example>',
},
];
}
+8 -7
View File
@@ -3,15 +3,16 @@ import type { Mailbox } from '@/lib/jmap/types';
const RIGHTS_SYSTEM = { mayReadItems: true, mayAddItems: true, mayRemoveItems: true, maySetSeen: true, maySetKeywords: true, mayCreateChild: true, mayRename: false, mayDelete: false, maySubmit: true };
const RIGHTS_CUSTOM = { ...RIGHTS_SYSTEM, mayRename: true, mayDelete: true };
// Counts must stay in sync with createDemoEmails() in fixtures/emails.ts.
export function createDemoMailboxes(): Mailbox[] {
return [
{ id: 'demo-mailbox-inbox', name: 'Inbox', role: 'inbox', sortOrder: 1, totalEmails: 12, unreadEmails: 5, totalThreads: 10, unreadThreads: 4, myRights: RIGHTS_SYSTEM, isSubscribed: true },
{ id: 'demo-mailbox-sent', name: 'Sent', role: 'sent', sortOrder: 2, totalEmails: 8, unreadEmails: 0, totalThreads: 8, unreadThreads: 0, myRights: RIGHTS_SYSTEM, isSubscribed: true },
{ id: 'demo-mailbox-drafts', name: 'Drafts', role: 'drafts', sortOrder: 3, totalEmails: 1, unreadEmails: 0, totalThreads: 1, unreadThreads: 0, myRights: RIGHTS_SYSTEM, isSubscribed: true },
{ id: 'demo-mailbox-inbox', name: 'Inbox', role: 'inbox', sortOrder: 1, totalEmails: 22, unreadEmails: 13, totalThreads: 20, unreadThreads: 12, myRights: RIGHTS_SYSTEM, isSubscribed: true },
{ id: 'demo-mailbox-sent', name: 'Sent', role: 'sent', sortOrder: 2, totalEmails: 3, unreadEmails: 0, totalThreads: 3, unreadThreads: 0, myRights: RIGHTS_SYSTEM, isSubscribed: true },
{ id: 'demo-mailbox-drafts', name: 'Drafts', role: 'drafts', sortOrder: 3, totalEmails: 2, unreadEmails: 0, totalThreads: 2, unreadThreads: 0, myRights: RIGHTS_SYSTEM, isSubscribed: true },
{ id: 'demo-mailbox-trash', name: 'Trash', role: 'trash', sortOrder: 5, totalEmails: 2, unreadEmails: 0, totalThreads: 2, unreadThreads: 0, myRights: RIGHTS_SYSTEM, isSubscribed: true },
{ id: 'demo-mailbox-archive', name: 'Archive', role: 'archive', sortOrder: 4, totalEmails: 4, unreadEmails: 0, totalThreads: 4, unreadThreads: 0, myRights: RIGHTS_SYSTEM, isSubscribed: true },
{ id: 'demo-mailbox-junk', name: 'Spam', role: 'junk', sortOrder: 6, totalEmails: 3, unreadEmails: 1, totalThreads: 3, unreadThreads: 1, myRights: RIGHTS_SYSTEM, isSubscribed: true },
{ id: 'demo-mailbox-projects', name: 'Projects', sortOrder: 10, totalEmails: 5, unreadEmails: 2, totalThreads: 5, unreadThreads: 2, myRights: RIGHTS_CUSTOM, isSubscribed: true },
{ id: 'demo-mailbox-receipts', name: 'Receipts', sortOrder: 11, totalEmails: 3, unreadEmails: 0, totalThreads: 3, unreadThreads: 0, myRights: RIGHTS_CUSTOM, isSubscribed: true },
{ id: 'demo-mailbox-archive', name: 'Archive', role: 'archive', sortOrder: 4, totalEmails: 2, unreadEmails: 0, totalThreads: 2, unreadThreads: 0, myRights: RIGHTS_SYSTEM, isSubscribed: true },
{ id: 'demo-mailbox-junk', name: 'Spam', role: 'junk', sortOrder: 6, totalEmails: 3, unreadEmails: 3, totalThreads: 3, unreadThreads: 3, myRights: RIGHTS_SYSTEM, isSubscribed: true },
{ id: 'demo-mailbox-projects', name: 'Projects', sortOrder: 10, totalEmails: 3, unreadEmails: 2, totalThreads: 3, unreadThreads: 2, myRights: RIGHTS_CUSTOM, isSubscribed: true },
{ id: 'demo-mailbox-receipts', name: 'Receipts', sortOrder: 11, totalEmails: 2, unreadEmails: 0, totalThreads: 2, unreadThreads: 0, myRights: RIGHTS_CUSTOM, isSubscribed: true },
];
}
+23
View File
@@ -0,0 +1,23 @@
const HTML_ESCAPE_MAP = {
"&": "&amp;",
"<": "&lt;",
">": "&gt;",
'"': "&quot;",
"'": "&#39;",
} as const;
function escapeHtml(value: string): string {
return value.replace(/[&<>"']/g, (char) =>
HTML_ESCAPE_MAP[char as keyof typeof HTML_ESCAPE_MAP]
);
}
export function plainTextToComposerBody(text: string): string {
if (!text) return "";
return text
.replace(/\r\n?/g, "\n")
.split(/\n{2,}/)
.map((paragraph) => `<p>${escapeHtml(paragraph).replace(/\n/g, "<br>")}</p>`)
.join("");
}
+21 -6
View File
@@ -58,24 +58,39 @@ export function sanitizeEmailHtmlForIframe(html: string): string {
/**
* Sanitize HTML signature with stricter rules
* Only allows basic formatting, no external resources
* Allows basic formatting plus <img> for company logos
*/
export const SIGNATURE_SANITIZE_CONFIG = {
ALLOWED_TAGS: ['p', 'br', 'b', 'strong', 'i', 'em', 'u', 'a', 'span', 'div'],
ALLOWED_ATTR: ['href', 'style', 'class'],
ALLOWED_TAGS: ['p', 'br', 'b', 'strong', 'i', 'em', 'u', 'a', 'span', 'div', 'img'],
ALLOWED_ATTR: ['href', 'style', 'class', 'src', 'alt', 'width', 'height', 'title'],
ALLOW_DATA_ATTR: false,
FORBID_TAGS: ['script', 'iframe', 'object', 'embed', 'img', 'video', 'audio'],
FORBID_TAGS: ['script', 'iframe', 'object', 'embed', 'video', 'audio'],
FORBID_ATTR: ['onerror', 'onload', 'onclick', 'onmouseover'],
};
/**
* Sanitize HTML signature for storage and display
* Sanitize HTML signature for storage and display.
* img src is restricted to https: or base64-embedded raster data: URIs
* (png/jpeg/gif/webp). SVG is excluded because DOMPurify cannot inspect
* bytes inside a data: URI. Images with a disallowed src are removed
* entirely so they don't render as broken-image icons.
* @param html - User-provided HTML signature
* @returns Sanitized signature (no scripts, no external resources)
*/
export function sanitizeSignatureHtml(html: string): string {
if (!html?.trim()) return '';
return DOMPurify.sanitize(html, SIGNATURE_SANITIZE_CONFIG);
DOMPurify.addHook('afterSanitizeAttributes', (node) => {
if (node.tagName !== 'IMG') return;
const src = node.getAttribute('src');
if (!src || !/^(?:https:\/\/|data:image\/(?:png|jpe?g|gif|webp);base64,)/i.test(src)) {
node.remove();
}
});
try {
return DOMPurify.sanitize(html, SIGNATURE_SANITIZE_CONFIG);
} finally {
DOMPurify.removeAllHooks();
}
}
/**
+189
View File
@@ -0,0 +1,189 @@
import { createHmac, timingSafeEqual } from 'node:crypto';
export class ImpersonationJwtError extends Error {
status: number;
code: string;
constructor(code: string, message: string, status: number = 401) {
super(message);
this.name = 'ImpersonationJwtError';
this.code = code;
this.status = status;
}
}
export interface ImpersonationClaims {
iss: string;
iat: number;
exp: number;
nbf?: number;
jti: string;
mailbox: string;
tenant_id?: string;
actor_user_id?: string;
}
const MAX_TOKEN_LIFETIME_SEC = 300;
const CLOCK_SKEW_SEC = 60;
const MIN_SECRET_LENGTH = 32;
function base64UrlDecode(input: string): Buffer {
const pad = input.length % 4 === 0 ? 0 : 4 - (input.length % 4);
const b64 = input.replace(/-/g, '+').replace(/_/g, '/') + '='.repeat(pad);
return Buffer.from(b64, 'base64');
}
function parseSegment(segment: string): unknown {
try {
return JSON.parse(base64UrlDecode(segment).toString('utf8'));
} catch {
throw new ImpersonationJwtError('malformed', 'Malformed JWT segment', 400);
}
}
function assertString(value: unknown, field: string): string {
if (typeof value !== 'string' || value.length === 0) {
throw new ImpersonationJwtError('claims', `Missing or invalid '${field}' claim`);
}
return value;
}
function assertNumber(value: unknown, field: string): number {
if (typeof value !== 'number' || !Number.isFinite(value)) {
throw new ImpersonationJwtError('claims', `Missing or invalid '${field}' claim`);
}
return value;
}
/**
* Verify an HS256 JWT for master-user impersonation. Returns the validated
* claims on success; throws ImpersonationJwtError otherwise.
*
* Caller must perform replay-protection (jti tracking) on the returned claims.
*/
export function verifyImpersonationJwt(
token: string,
secret: string,
options: { expectedIssuer?: string; now?: number } = {},
): ImpersonationClaims {
if (typeof token !== 'string' || token.length === 0) {
throw new ImpersonationJwtError('malformed', 'Missing token', 400);
}
if (typeof secret !== 'string' || secret.length < MIN_SECRET_LENGTH) {
throw new ImpersonationJwtError(
'config',
`BULWARK_JWT_AUTH_SECRET must be at least ${MIN_SECRET_LENGTH} characters`,
500,
);
}
const parts = token.split('.');
if (parts.length !== 3) {
throw new ImpersonationJwtError('malformed', 'Token must have 3 segments', 400);
}
const [headerB64, payloadB64, sigB64] = parts;
// Header — reject anything but HS256 BEFORE attempting signature verification.
const header = parseSegment(headerB64) as Record<string, unknown>;
if (header.alg !== 'HS256') {
throw new ImpersonationJwtError('alg', `Unsupported alg '${String(header.alg)}'`);
}
if (header.typ !== undefined && header.typ !== 'JWT') {
throw new ImpersonationJwtError('alg', `Unsupported typ '${String(header.typ)}'`);
}
// Signature — constant-time compare.
const expected = createHmac('sha256', secret).update(`${headerB64}.${payloadB64}`).digest();
const provided = base64UrlDecode(sigB64);
if (provided.length !== expected.length || !timingSafeEqual(provided, expected)) {
throw new ImpersonationJwtError('signature', 'Invalid signature');
}
// Claims.
const payload = parseSegment(payloadB64) as Record<string, unknown>;
const iss = assertString(payload.iss, 'iss');
if (options.expectedIssuer && iss !== options.expectedIssuer) {
throw new ImpersonationJwtError('iss', `Unexpected issuer '${iss}'`);
}
const iat = assertNumber(payload.iat, 'iat');
const exp = assertNumber(payload.exp, 'exp');
const jti = assertString(payload.jti, 'jti');
const mailbox = assertString(payload.mailbox, 'mailbox');
// Mailbox MUST NOT contain '%' or ':' — those would inject into the
// master-user auth header.
if (mailbox.includes('%') || mailbox.includes(':')) {
throw new ImpersonationJwtError('mailbox', "mailbox must not contain '%' or ':'");
}
const nowSec = options.now ?? Math.floor(Date.now() / 1000);
if (typeof payload.nbf === 'number' && nowSec + CLOCK_SKEW_SEC < payload.nbf) {
throw new ImpersonationJwtError('nbf', 'Token not yet valid');
}
if (nowSec - CLOCK_SKEW_SEC > exp) {
throw new ImpersonationJwtError('exp', 'Token expired');
}
if (iat - CLOCK_SKEW_SEC > nowSec) {
throw new ImpersonationJwtError('iat', 'Token issued in the future');
}
// Hard ceiling on lifetime — refuse long-lived handoff tokens even if the
// signer asked for one.
if (exp - iat > MAX_TOKEN_LIFETIME_SEC) {
throw new ImpersonationJwtError('lifetime', `Token lifetime exceeds ${MAX_TOKEN_LIFETIME_SEC}s ceiling`);
}
const claims: ImpersonationClaims = { iss, iat, exp, jti, mailbox };
if (typeof payload.nbf === 'number') claims.nbf = payload.nbf;
if (typeof payload.tenant_id === 'string') claims.tenant_id = payload.tenant_id;
if (typeof payload.actor_user_id === 'string') claims.actor_user_id = payload.actor_user_id;
return claims;
}
// ─── Replay protection ──────────────────────────────────────────
// In-memory LRU keyed by jti. Entries expire automatically once their
// underlying JWT could no longer be replayed (exp + skew). On a multi-pod
// deployment each pod has its own cache; that's acceptable because a token
// stolen mid-flight could only be replayed against the pod that already
// consumed it (and that pod will reject it). For stronger guarantees,
// platforms can issue per-pod-routed tokens or front Bulwark with a
// single-leader load balancer for the impersonate route.
const REPLAY_CACHE_MAX = 4096;
class ReplayCache {
private entries = new Map<string, number>(); // jti -> exp epoch seconds
/** Returns true if jti was not previously seen and has been recorded. */
consume(jti: string, exp: number, now: number = Math.floor(Date.now() / 1000)): boolean {
this.prune(now);
if (this.entries.has(jti)) return false;
if (this.entries.size >= REPLAY_CACHE_MAX) {
// Evict the oldest entry — Map preserves insertion order.
const first = this.entries.keys().next().value;
if (first !== undefined) this.entries.delete(first);
}
this.entries.set(jti, exp);
return true;
}
private prune(now: number): void {
for (const [jti, exp] of this.entries) {
if (exp + CLOCK_SKEW_SEC < now) {
this.entries.delete(jti);
} else {
// Insertion order means later entries are no older than this one — but
// exp isn't strictly monotonic with insertion, so we can't break here.
}
}
}
get size(): number {
return this.entries.size;
}
clear(): void {
this.entries.clear();
}
}
export const impersonationReplayCache = new ReplayCache();
+51
View File
@@ -0,0 +1,51 @@
import { configManager } from '@/lib/admin/config-manager';
export interface ImpersonationConfig {
jwtSecret: string;
masterUser: string;
masterPassword: string;
expectedIssuer: string;
}
/**
* Returns null when impersonation is not configured the route MUST surface
* that as a 404 so an unconfigured deployment doesn't expose the endpoint.
*
* Required env:
* BULWARK_JWT_AUTH_SECRET (>= 32 chars)
* BULWARK_STALWART_MASTER_USER master account address (e.g. master@example.com)
* BULWARK_STALWART_MASTER_PASSWORD
*
* Optional env:
* BULWARK_JWT_AUTH_ISSUER (default: "platform-api/webmail")
*/
export function readImpersonationConfig(): ImpersonationConfig | null {
const jwtSecret = process.env.BULWARK_JWT_AUTH_SECRET ?? '';
const masterUser = process.env.BULWARK_STALWART_MASTER_USER ?? '';
const masterPassword = process.env.BULWARK_STALWART_MASTER_PASSWORD ?? '';
if (!jwtSecret || !masterUser || !masterPassword) return null;
return {
jwtSecret,
masterUser,
masterPassword,
expectedIssuer: process.env.BULWARK_JWT_AUTH_ISSUER ?? 'platform-api/webmail',
};
}
/**
* Resolves the upstream JMAP server URL the same way /api/auth/session does
* for trusted entries: the global `jmapServerUrl` admin setting, then the
* legacy env fallbacks. Returns null if none is configured.
*
* The impersonation flow is server-to-server (no user input), so we never
* accept a custom endpoint only admin-configured URLs.
*/
export async function resolveImpersonationServerUrl(): Promise<string | null> {
await configManager.ensureLoaded();
const url =
configManager.get<string>('jmapServerUrl', '') ||
process.env.JMAP_SERVER_URL ||
process.env.NEXT_PUBLIC_JMAP_SERVER_URL ||
'';
return url || null;
}
+9 -6
View File
@@ -1,13 +1,16 @@
const COOKIE_SAME_SITE = (process.env.COOKIE_SAME_SITE || 'lax') as 'lax' | 'none' | 'strict';
const COOKIE_SECURE = process.env.COOKIE_SECURE !== undefined
? process.env.COOKIE_SECURE === 'true'
: (COOKIE_SAME_SITE === 'none' || process.env.NODE_ENV === 'production');
import { configManager } from '@/lib/admin/config-manager';
type SameSite = 'lax' | 'none' | 'strict';
export function getCookieOptions() {
const sameSite = configManager.get<SameSite>('cookieSameSite', 'lax');
const secure = process.env.COOKIE_SECURE !== undefined
? process.env.COOKIE_SECURE === 'true'
: (sameSite === 'none' || process.env.NODE_ENV === 'production');
return {
httpOnly: true,
secure: COOKIE_SECURE,
sameSite: COOKIE_SAME_SITE,
secure,
sameSite,
path: '/',
maxAge: 30 * 24 * 60 * 60,
};
+16 -2
View File
@@ -1,6 +1,20 @@
import { configManager } from '@/lib/admin/config-manager';
const DEFAULT_SCOPES = 'openid email profile';
const EXTRA_SCOPES = process.env.OAUTH_EXTRA_SCOPES || '';
export const OAUTH_SCOPES = process.env.OAUTH_SCOPES || (EXTRA_SCOPES ? `${DEFAULT_SCOPES} ${EXTRA_SCOPES}`.trim() : DEFAULT_SCOPES);
/**
* Resolve the OAuth scopes to request at authorize time.
*
* Reads admin override / OAUTH_SCOPES / OAUTH_EXTRA_SCOPES at call time so
* runtime env vars (and admin dashboard changes) take effect without a rebuild.
* Server-only: callers in the browser must read `oauthScopes` from /api/config.
*/
export function getOauthScopes(): string {
const explicit = configManager.get<string>('oauthScopes', '');
if (explicit) return explicit;
const extra = configManager.get<string>('oauthExtraScopes', '');
return extra ? `${DEFAULT_SCOPES} ${extra}`.trim() : DEFAULT_SCOPES;
}
export const REFRESH_TOKEN_COOKIE = 'jmap_rt';
export const REFRESH_TOKEN_SERVER_COOKIE = 'jmap_rts';
+14
View File
@@ -185,6 +185,15 @@ export interface PluginAPI {
i18n: PluginI18n;
ui: {
registerToolbarAction: (action: ToolbarAction) => Disposable;
/**
* Register a banner that renders at the very top of the authenticated app
* shell above the navigation rail, sidebar and content panes. Used for
* persistent global notices (impersonation, maintenance, etc.). The
* component receives `{ username, serverUrl }` as props.
*
* Requires the `ui:app-top-banner` permission.
*/
registerAppTopBanner: (component: React.ComponentType<Record<string, unknown>>) => Disposable;
registerEmailBanner: (factory: BannerFactory) => Disposable;
registerEmailFooter: (component: React.ComponentType) => Disposable;
registerSettingsSection: (section: SettingsSection) => Disposable;
@@ -710,6 +719,11 @@ export function createPluginAPI(plugin: InstalledPlugin): PluginAPI {
return registerSlot(plugin.id, 'email-banner', factory.render as unknown as React.ComponentType<Record<string, unknown>>, 100);
},
registerAppTopBanner: (component: React.ComponentType<Record<string, unknown>>) => {
requirePermission(plugin, 'ui:app-top-banner');
return registerSlot(plugin.id, 'app-top-banner', component, 100);
},
registerEmailFooter: (component: React.ComponentType) => {
requirePermission(plugin, 'ui:email-footer');
return registerSlot(plugin.id, 'email-footer', component as React.ComponentType<Record<string, unknown>>, 100);
+2 -1
View File
@@ -230,6 +230,7 @@ export interface InstalledPlugin {
export type SlotName =
| 'toolbar-actions'
| 'app-top-banner'
| 'email-banner'
| 'email-footer'
| 'composer-toolbar'
@@ -778,7 +779,7 @@ export const ALL_PERMISSIONS = [
'security:read',
'auth:observe',
'http:post', 'http:fetch',
'ui:observe', 'ui:toolbar', 'ui:email-banner', 'ui:email-footer',
'ui:observe', 'ui:toolbar', 'ui:app-top-banner', 'ui:email-banner', 'ui:email-footer',
'ui:composer-toolbar', 'ui:composer-sidebar',
'ui:sidebar-widget', 'ui:settings-section',
'ui:context-menu', 'ui:navigation-rail', 'ui:keyboard',
+117
View File
@@ -0,0 +1,117 @@
export interface ParsedMailto {
to: string[];
cc: string[];
bcc: string[];
subject: string;
body: string;
}
const MAX_RECIPIENTS = 200;
const MAX_SUBJECT_LENGTH = 998;
const MAX_BODY_LENGTH = 64 * 1024;
// eslint-disable-next-line no-control-regex
const CONTROL_CHARS = /[\u0000-\u001F\u007F]/g;
// eslint-disable-next-line no-control-regex
const CONTROL_CHARS_EXCEPT_LINE_BREAKS = /[\u0000-\u0008\u000B\u000C\u000E-\u001F\u007F]/g;
function stripControlChars(value: string): string {
return value.replace(CONTROL_CHARS, "");
}
function stripBodyControlChars(value: string): string {
return value
.replace(/\r\n?/g, "\n")
.replace(CONTROL_CHARS_EXCEPT_LINE_BREAKS, "");
}
function splitRecipients(value: string): string[] {
return stripControlChars(value)
.split(",")
.map((recipient) => recipient.trim())
.filter(Boolean);
}
type QueryParam = {
key: string;
value: string;
};
function getQueryValue(searchParams: QueryParam[], key: string): string {
const values: string[] = [];
const lowerKey = key.toLowerCase();
for (const { key: paramKey, value } of searchParams) {
if (paramKey.toLowerCase() === lowerKey) {
values.push(value);
}
}
return values.join(",");
}
function decodePathname(pathname: string): string | null {
try {
return decodeURIComponent(pathname || "");
} catch {
return null;
}
}
function decodeQueryPart(value: string): string | null {
try {
// RFC 6068 uses percent-encoding for mailto query fields; unlike form
// encoding, a literal '+' is part of the value and must not become space.
return decodeURIComponent(value);
} catch {
return null;
}
}
function parseQuery(query: string): QueryParam[] | null {
if (!query) return [];
const params: QueryParam[] = [];
for (const part of query.split("&")) {
if (!part) continue;
const separatorIndex = part.indexOf("=");
const rawKey = separatorIndex >= 0 ? part.slice(0, separatorIndex) : part;
const rawValue = separatorIndex >= 0 ? part.slice(separatorIndex + 1) : "";
const key = decodeQueryPart(rawKey);
const value = decodeQueryPart(rawValue);
if (key === null || value === null) return null;
params.push({ key, value });
}
return params;
}
export function parseMailto(raw: string): ParsedMailto | null {
if (!raw.toLowerCase().startsWith("mailto:")) return null;
const addressAndQuery = raw.slice("mailto:".length);
const queryIndex = addressAndQuery.indexOf("?");
const rawPathname = queryIndex >= 0 ? addressAndQuery.slice(0, queryIndex) : addressAndQuery;
const rawQuery = queryIndex >= 0 ? addressAndQuery.slice(queryIndex + 1) : "";
const decodedPathname = decodePathname(rawPathname);
if (decodedPathname === null) return null;
const searchParams = parseQuery(rawQuery);
if (searchParams === null) return null;
const to = [
...splitRecipients(decodedPathname),
...splitRecipients(getQueryValue(searchParams, "to")),
].slice(0, MAX_RECIPIENTS);
const remainingAfterTo = Math.max(0, MAX_RECIPIENTS - to.length);
const cc = splitRecipients(getQueryValue(searchParams, "cc")).slice(0, remainingAfterTo);
const remainingAfterCc = Math.max(0, MAX_RECIPIENTS - to.length - cc.length);
const bcc = splitRecipients(getQueryValue(searchParams, "bcc")).slice(0, remainingAfterCc);
return {
to,
cc,
bcc,
subject: stripControlChars(getQueryValue(searchParams, "subject")).slice(0, MAX_SUBJECT_LENGTH),
body: stripBodyControlChars(getQueryValue(searchParams, "body")).slice(0, MAX_BODY_LENGTH),
};
}
+357
View File
@@ -0,0 +1,357 @@
import type { ParsedMailto } from "./mailto";
import type { ParsedWebcal } from "./webcal";
const MAILTO_KEY = "bulwark:pending-mailto";
const WEBCAL_KEY = "bulwark:pending-webcal";
const PROTOCOL_CHANNEL = "bulwark:protocol-handlers";
const PENDING_TTL_MS = 5 * 60 * 1000;
const MAILTO_REQUEST = "mailto-request";
const MAILTO_CANDIDATE = "mailto-candidate";
const MAILTO_ACK = "mailto-ack";
const OPEN_MAILTO_IN_CLIENT = "open-mailto-in-client";
const MAILTO_CLIENT_READY = "mailto-client-ready";
const MAILTO_CLIENT_GONE = "mailto-client-gone";
const PENDING_MAILTO_EVENT = "bulwark:pending-mailto";
const PENDING_WEBCAL_EVENT = "bulwark:pending-webcal";
type PendingValue<T> = T & { createdAt: number };
type PendingMailtoRequest = { type: typeof MAILTO_REQUEST; id: string; value: ParsedMailto; clientId?: string };
type PendingMailtoCandidate = { type: typeof MAILTO_CANDIDATE; id: string; clientId: string; priority: number };
type PendingMailtoAck = { type: typeof MAILTO_ACK; id: string };
type OpenMailtoInClientRequest = {
type: typeof OPEN_MAILTO_IN_CLIENT;
id: string;
value: ParsedMailto;
clientId?: string;
};
type ProtocolClientInfo = {
path: string;
standalone: boolean;
clientId?: string;
focusNotificationTitle?: string;
focusNotificationBody?: string;
};
function savePending<T>(key: string, value: T) {
try {
sessionStorage.setItem(key, JSON.stringify({ ...value, createdAt: Date.now() }));
} catch {
// Storage can be unavailable in hardened/private browser modes.
}
}
function consumePending<T>(key: string, validate: (value: unknown) => value is T): T | null {
try {
const raw = sessionStorage.getItem(key);
sessionStorage.removeItem(key);
if (!raw) return null;
const parsed = JSON.parse(raw) as PendingValue<unknown>;
if (typeof parsed.createdAt !== "number" || Date.now() - parsed.createdAt > PENDING_TTL_MS) {
return null;
}
return validate(parsed) ? parsed : null;
} catch {
return null;
}
}
function hasPending<T>(key: string, validate: (value: unknown) => value is T): boolean {
try {
const raw = sessionStorage.getItem(key);
if (!raw) return false;
const parsed = JSON.parse(raw) as PendingValue<unknown>;
if (typeof parsed.createdAt !== "number" || Date.now() - parsed.createdAt > PENDING_TTL_MS) {
sessionStorage.removeItem(key);
return false;
}
return validate(parsed);
} catch {
return false;
}
}
function isParsedMailto(value: unknown): value is ParsedMailto {
if (!value || typeof value !== "object") return false;
const candidate = value as Partial<ParsedMailto>;
return Array.isArray(candidate.to)
&& Array.isArray(candidate.cc)
&& Array.isArray(candidate.bcc)
&& typeof candidate.subject === "string"
&& typeof candidate.body === "string";
}
function isPendingMailtoRequest(value: unknown): value is PendingMailtoRequest {
if (!value || typeof value !== "object") return false;
const candidate = value as Partial<PendingMailtoRequest>;
return candidate.type === MAILTO_REQUEST
&& typeof candidate.id === "string"
&& isParsedMailto(candidate.value)
&& (candidate.clientId === undefined || typeof candidate.clientId === "string");
}
function isPendingMailtoAck(value: unknown, id: string): value is PendingMailtoAck {
if (!value || typeof value !== "object") return false;
const candidate = value as Partial<PendingMailtoAck>;
return candidate.type === MAILTO_ACK && candidate.id === id;
}
function isPendingMailtoCandidate(value: unknown, id: string): value is PendingMailtoCandidate {
if (!value || typeof value !== "object") return false;
const candidate = value as Partial<PendingMailtoCandidate>;
return candidate.type === MAILTO_CANDIDATE
&& candidate.id === id
&& typeof candidate.clientId === "string"
&& typeof candidate.priority === "number";
}
function isOpenMailtoInClientRequest(value: unknown): value is OpenMailtoInClientRequest {
if (!value || typeof value !== "object") return false;
const candidate = value as Partial<OpenMailtoInClientRequest>;
return candidate.type === OPEN_MAILTO_IN_CLIENT
&& typeof candidate.id === "string"
&& isParsedMailto(candidate.value)
&& (candidate.clientId === undefined || typeof candidate.clientId === "string");
}
function createRequestId(): string {
if (typeof crypto !== "undefined" && "randomUUID" in crypto) {
return crypto.randomUUID();
}
return `${Date.now()}-${Math.random().toString(36).slice(2)}`;
}
const BROWSER_CLIENT_ID = createRequestId();
function getMailtoClientPriority(info: ProtocolClientInfo): number {
const isMailSection = info.path === "/" || info.path === "";
if (info.standalone && isMailSection) return 0;
if (isMailSection) return 1;
if (info.standalone) return 2;
return 3;
}
function getDefaultProtocolClientInfo(): ProtocolClientInfo {
const nav = navigator as Navigator & { standalone?: boolean };
const standalone = window.matchMedia?.("(display-mode: standalone)").matches || nav.standalone === true;
return { path: window.location.pathname, standalone, clientId: BROWSER_CLIENT_ID };
}
async function requestMailtoViaServiceWorker(value: ParsedMailto, timeoutMs: number): Promise<boolean> {
if (typeof navigator === "undefined"
|| !("serviceWorker" in navigator)
|| typeof MessageChannel === "undefined") {
return false;
}
try {
const registration = await Promise.race([
navigator.serviceWorker.ready,
new Promise<null>((resolve) => globalThis.setTimeout(() => resolve(null), timeoutMs)),
]);
if (!registration) return false;
const worker = navigator.serviceWorker.controller ?? registration.active;
if (!worker) return false;
return await new Promise((resolve) => {
const channel = new MessageChannel();
const timeout = globalThis.setTimeout(() => {
channel.port1.close();
resolve(false);
}, timeoutMs);
channel.port1.onmessage = (event) => {
globalThis.clearTimeout(timeout);
channel.port1.close();
resolve(event.data?.delivered === true);
};
worker.postMessage({
type: OPEN_MAILTO_IN_CLIENT,
id: createRequestId(),
value,
} satisfies OpenMailtoInClientRequest, [channel.port2]);
});
} catch {
return false;
}
}
function notifyServiceWorker(
type: typeof MAILTO_CLIENT_READY | typeof MAILTO_CLIENT_GONE,
info?: ProtocolClientInfo,
) {
if (typeof navigator === "undefined" || !("serviceWorker" in navigator)) return;
navigator.serviceWorker.ready
.then((registration) => {
const worker = navigator.serviceWorker.controller ?? registration.active;
worker?.postMessage({ type, ...info });
})
.catch(() => {
// Service worker registration is optional for local/dev environments.
});
}
function isParsedWebcal(value: unknown): value is ParsedWebcal {
if (!value || typeof value !== "object") return false;
const candidate = value as Partial<ParsedWebcal>;
return typeof candidate.originalUrl === "string"
&& typeof candidate.subscriptionUrl === "string"
&& typeof candidate.suggestedName === "string";
}
export function savePendingMailto(value: ParsedMailto) {
savePending(MAILTO_KEY, value);
}
export function consumePendingMailto(): ParsedMailto | null {
return consumePending(MAILTO_KEY, isParsedMailto);
}
export function notifyPendingMailto() {
if (typeof window !== "undefined") {
window.dispatchEvent(new Event(PENDING_MAILTO_EVENT));
}
}
export function subscribeToPendingMailto(callback: () => void): () => void {
if (typeof window === "undefined") return () => {};
window.addEventListener(PENDING_MAILTO_EVENT, callback);
return () => window.removeEventListener(PENDING_MAILTO_EVENT, callback);
}
async function requestMailtoViaBroadcastChannel(value: ParsedMailto, timeoutMs: number): Promise<boolean> {
if (typeof BroadcastChannel === "undefined") {
return false;
}
return new Promise((resolve) => {
const id = createRequestId();
const channel = new BroadcastChannel(PROTOCOL_CHANNEL);
const candidates: PendingMailtoCandidate[] = [];
let selected = false;
let selectionTimer: ReturnType<typeof globalThis.setTimeout> | null = null;
const candidateWindowMs = Math.min(75, Math.max(25, Math.floor(timeoutMs / 3)));
const timeout = globalThis.setTimeout(() => {
if (selectionTimer) globalThis.clearTimeout(selectionTimer);
channel.close();
resolve(false);
}, timeoutMs);
const selectCandidate = () => {
if (selected) return;
selected = true;
const best = candidates.sort((a, b) => a.priority - b.priority)[0];
if (!best) {
globalThis.clearTimeout(timeout);
channel.close();
resolve(false);
return;
}
channel.postMessage({
type: OPEN_MAILTO_IN_CLIENT,
id,
clientId: best.clientId,
value,
} satisfies OpenMailtoInClientRequest);
};
channel.onmessage = (event) => {
if (isPendingMailtoCandidate(event.data, id)) {
candidates.push(event.data);
selectionTimer ??= globalThis.setTimeout(selectCandidate, candidateWindowMs);
return;
}
if (isPendingMailtoAck(event.data, id)) {
if (selectionTimer) globalThis.clearTimeout(selectionTimer);
globalThis.clearTimeout(timeout);
channel.close();
resolve(true);
}
};
channel.postMessage({ type: MAILTO_REQUEST, id, value } satisfies PendingMailtoRequest);
});
}
export async function requestOpenMailtoInExistingClient(value: ParsedMailto, timeoutMs = 300): Promise<boolean> {
if (await requestMailtoViaServiceWorker(value, timeoutMs)) return true;
return requestMailtoViaBroadcastChannel(value, timeoutMs);
}
export function listenForMailtoRequests(
onMailto: (value: ParsedMailto) => void,
getClientInfo: () => ProtocolClientInfo = getDefaultProtocolClientInfo,
): () => void {
const cleanup: Array<() => void> = [];
const clientInfo = getClientInfo();
if (typeof navigator !== "undefined" && "serviceWorker" in navigator) {
const handleServiceWorkerMessage = (event: MessageEvent) => {
if (isPendingMailtoRequest(event.data)) {
if (event.data.clientId !== undefined && event.data.clientId !== BROWSER_CLIENT_ID) return;
if (typeof window !== "undefined") window.focus();
onMailto(event.data.value);
}
};
navigator.serviceWorker.addEventListener("message", handleServiceWorkerMessage);
notifyServiceWorker(MAILTO_CLIENT_READY, { ...clientInfo, clientId: BROWSER_CLIENT_ID });
cleanup.push(() => {
notifyServiceWorker(MAILTO_CLIENT_GONE, { ...clientInfo, clientId: BROWSER_CLIENT_ID });
navigator.serviceWorker.removeEventListener("message", handleServiceWorkerMessage);
});
}
if (typeof BroadcastChannel !== "undefined") {
const channel = new BroadcastChannel(PROTOCOL_CHANNEL);
channel.onmessage = (event) => {
if (isPendingMailtoRequest(event.data)) {
channel.postMessage({
type: MAILTO_CANDIDATE,
id: event.data.id,
clientId: BROWSER_CLIENT_ID,
priority: getMailtoClientPriority(getClientInfo()),
} satisfies PendingMailtoCandidate);
return;
}
if (!isOpenMailtoInClientRequest(event.data) || event.data.clientId !== BROWSER_CLIENT_ID) return;
if (typeof window !== "undefined") window.focus();
onMailto(event.data.value);
channel.postMessage({ type: MAILTO_ACK, id: event.data.id } satisfies PendingMailtoAck);
};
cleanup.push(() => channel.close());
}
return () => cleanup.forEach((dispose) => dispose());
}
export function savePendingWebcal(value: ParsedWebcal) {
savePending(WEBCAL_KEY, value);
}
export function consumePendingWebcal(): ParsedWebcal | null {
return consumePending(WEBCAL_KEY, isParsedWebcal);
}
export function notifyPendingWebcal() {
if (typeof window !== "undefined") {
window.dispatchEvent(new Event(PENDING_WEBCAL_EVENT));
}
}
export function subscribeToPendingWebcal(callback: () => void): () => void {
if (typeof window === "undefined") return () => {};
window.addEventListener(PENDING_WEBCAL_EVENT, callback);
return () => window.removeEventListener(PENDING_WEBCAL_EVENT, callback);
}
export function hasPendingWebcal(): boolean {
return hasPending(WEBCAL_KEY, isParsedWebcal);
}
+49
View File
@@ -0,0 +1,49 @@
export interface ParsedWebcal {
originalUrl: string;
subscriptionUrl: string;
suggestedName: string;
}
function stripControlChars(value: string): string {
// eslint-disable-next-line no-control-regex
return value.replace(/[\u0000-\u001F\u007F]/g, "").trim();
}
function extensionlessName(value: string): string {
return value.replace(/\.(ics|ical)$/i, "");
}
function decodePathSegment(value: string): string {
try {
return decodeURIComponent(value);
} catch {
return value;
}
}
export function parseWebcal(raw: string): ParsedWebcal | null {
let url: URL;
try {
url = new URL(raw);
} catch {
return null;
}
if (url.protocol === "webcal:" || url.protocol === "webcals:") {
url = new URL(raw.replace(/^webcals?:/i, "https:"));
} else if (url.protocol !== "http:" && url.protocol !== "https:") {
return null;
}
const subscriptionUrl = url.toString();
const queryName = stripControlChars(url.searchParams.get("name") || "");
const pathSegment = stripControlChars(decodePathSegment(url.pathname.split("/").filter(Boolean).pop() || ""));
const suggestedName = queryName || extensionlessName(pathSegment) || url.hostname;
return {
originalUrl: raw,
subscriptionUrl,
suggestedName,
};
}
+1 -1
View File
@@ -73,7 +73,7 @@ export interface ReplyFromResolution {
/**
* Override for the outgoing `From:` header. Populated when the incoming
* message was delivered to an address on a domain the user owns (by
* identity) but that isn't itself a configured identity typical
* identity) but that isn't itself a configured identity - typical
* domain-catch-all deployments. When set, the composer should put this
* address (and `overrideName`) in the message's From header while sending
* through the chosen identity.

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