Compare commits

...
15 Commits
Author SHA1 Message Date
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
55 changed files with 4035 additions and 268 deletions
+20
View File
@@ -1,5 +1,25 @@
# Changelog # Changelog
## 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) ## 1.6.5 (2026-05-13)
### Features ### Features
+12 -12
View File
@@ -101,24 +101,24 @@ This project uses **next-intl**. English (`/locales/en/common.json`) is the sour
### Rules ### Rules
1. **Never hardcode user-facing text** always use translations: 1. **Never hardcode user-facing text** - always use translations:
```tsx ```tsx
const t = useTranslations("namespace"); const t = useTranslations("namespace");
return <div>{t("key")}</div>; return <div>{t("key")}</div>;
``` ```
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. 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**: 3. **Namespace organization**:
- `login.*` login page - `login.*` - login page
- `sidebar.*` sidebar navigation - `sidebar.*` - sidebar navigation
- `email_list.*` email list - `email_list.*` - email list
- `email_viewer.*` email viewer - `email_viewer.*` - email viewer
- `email_composer.*` composer - `email_composer.*` - composer
- `settings.*` settings page - `settings.*` - settings page
- `notifications.*` toasts and alerts - `notifications.*` - toasts and alerts
- `common.*` shared strings - `common.*` - shared strings
4. **Locale-aware navigation**: 4. **Locale-aware navigation**:
@@ -200,9 +200,9 @@ webmail/
## Security ## Security
- **Never commit secrets** API keys, passwords, tokens, `.env*` files - **Never commit secrets** - API keys, passwords, tokens, `.env*` files
- **Sanitize user input** and email content - **Sanitize user input** and email content
- **Block external content** by default privacy is the point - **Block external content** by default - privacy is the point
- **Report vulnerabilities privately** to bulwark@rbm.systems, not via public issues - **Report vulnerabilities privately** to bulwark@rbm.systems, not via public issues
## Questions? ## Questions?
+1 -1
View File
@@ -98,7 +98,7 @@
## Internationalization ## 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`. 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) [![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) [![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.5-green.svg?logo=git&logoColor=white)](CHANGELOG.md) [![Version](https://img.shields.io/badge/version-1.6.6-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) [![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/) [![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.5 1.6.6
+1 -1
View File
@@ -590,7 +590,7 @@ export default function SettingsPage() {
// Apps // Apps
...(supportsCalendar ? [{ id: 'calendar' as Tab, label: t('tabs.calendar'), icon: tabIcons.calendar, group: 'apps' as TabGroup }] : []), ...(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' }, { 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 }] : []), ...(isFeatureEnabled('sidebarAppsEnabled') ? [{ id: 'sidebar_apps' as Tab, label: t('tabs.sidebar_apps'), icon: tabIcons.sidebar_apps, group: 'apps' as TabGroup }] : []),
// Advanced // Advanced
+22 -16
View File
@@ -27,6 +27,7 @@ import {
} from 'lucide-react'; } from 'lucide-react';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { useConfig } from '@/hooks/use-config'; import { useConfig } from '@/hooks/use-config';
import { usePolicyStore } from '@/stores/policy-store';
import { useThemeStore } from '@/stores/theme-store'; import { useThemeStore } from '@/stores/theme-store';
import { getActiveAccountSlotHeaders } from '@/lib/auth/active-account-slot'; import { getActiveAccountSlotHeaders } from '@/lib/auth/active-account-slot';
@@ -87,6 +88,7 @@ export default function AdminLayout({ children }: { children: React.ReactNode })
const [isStalwartAdmin, setIsStalwartAdmin] = useState(false); const [isStalwartAdmin, setIsStalwartAdmin] = useState(false);
const [mobileNavOpen, setMobileNavOpen] = useState(false); const [mobileNavOpen, setMobileNavOpen] = useState(false);
const { appLogoLightUrl, appLogoDarkUrl, loginLogoLightUrl, loginLogoDarkUrl } = useConfig(); const { appLogoLightUrl, appLogoDarkUrl, loginLogoLightUrl, loginLogoDarkUrl } = useConfig();
const filesEnabled = usePolicyStore((s) => s.isFeatureEnabled('filesEnabled'));
const resolvedTheme = useThemeStore((s) => s.resolvedTheme); const resolvedTheme = useThemeStore((s) => s.resolvedTheme);
const logoUrl = resolvedTheme === 'dark' const logoUrl = resolvedTheme === 'dark'
? (appLogoDarkUrl || appLogoLightUrl || loginLogoDarkUrl) ? (appLogoDarkUrl || appLogoLightUrl || loginLogoDarkUrl)
@@ -179,7 +181,7 @@ export default function AdminLayout({ children }: { children: React.ReactNode })
// /admin lives outside the [locale] tree, so links back to the webmail // /admin lives outside the [locale] tree, so links back to the webmail
// apps are bare <a> tags (hard navigation). Next.js only auto-applies // apps are bare <a> tags (hard navigation). Next.js only auto-applies
// basePath to <Link>/router APIs for these we prepend it manually so // basePath to <Link>/router APIs - for these we prepend it manually so
// NEXT_PUBLIC_BASE_PATH=/webmail deployments don't redirect to "/". // NEXT_PUBLIC_BASE_PATH=/webmail deployments don't redirect to "/".
const prefix = getPathPrefix(); const prefix = getPathPrefix();
@@ -300,13 +302,15 @@ export default function AdminLayout({ children }: { children: React.ReactNode })
> >
<BookUser className="w-[18px] h-[18px]" /> <BookUser className="w-[18px] h-[18px]" />
</a> </a>
<a {filesEnabled && (
href={`${prefix}/files`} <a
className="flex items-center justify-center w-10 h-10 rounded-md transition-colors text-muted-foreground hover:text-foreground hover:bg-muted" href={`${prefix}/files`}
title="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> <HardDrive className="w-[18px] h-[18px]" />
</a>
)}
<div className="mt-auto flex flex-col items-center gap-2"> <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"> <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]" /> <Shield className="w-[18px] h-[18px]" />
@@ -440,14 +444,16 @@ export default function AdminLayout({ children }: { children: React.ReactNode })
<BookUser className="w-5 h-5" /> <BookUser className="w-5 h-5" />
<span className="text-[10px] font-medium leading-tight truncate max-w-full">Contacts</span> <span className="text-[10px] font-medium leading-tight truncate max-w-full">Contacts</span>
</a> </a>
<a {filesEnabled && (
href={`${prefix}/files`} <a
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" href={`${prefix}/files`}
title="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> <HardDrive className="w-5 h-5" />
</a> <span className="text-[10px] font-medium leading-tight truncate max-w-full">Files</span>
</a>
)}
<div <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" 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" title="Admin"
+7 -5
View File
@@ -20,10 +20,12 @@ import { recordLogin } from '@/lib/telemetry/login-tracker';
import { parseJmapServers, resolveTrustedJmapUrl } from '@/lib/admin/jmap-servers'; import { parseJmapServers, resolveTrustedJmapUrl } from '@/lib/admin/jmap-servers';
import { MAX_ACCOUNT_SLOTS } from '@/lib/account-utils'; import { MAX_ACCOUNT_SLOTS } from '@/lib/account-utils';
const COOKIE_OPTIONS = { function sessionCookieOptions() {
...getCookieOptions(), return {
maxAge: SESSION_COOKIE_MAX_AGE, ...getCookieOptions(),
}; maxAge: SESSION_COOKIE_MAX_AGE,
};
}
function getSlot(request: NextRequest): number { function getSlot(request: NextRequest): number {
const raw = request.nextUrl.searchParams.get('slot'); const raw = request.nextUrl.searchParams.get('slot');
@@ -88,7 +90,7 @@ export async function POST(request: NextRequest) {
: await verifyJmapAuth(upstreamUrl, authHeader, { trusted: false }); : await verifyJmapAuth(upstreamUrl, authHeader, { trusted: false });
const token = encryptSession(normalizedServerUrl, username, password); const token = encryptSession(normalizedServerUrl, username, password);
const cookieStore = await cookies(); const cookieStore = await cookies();
cookieStore.set(cookieName, token, COOKIE_OPTIONS); cookieStore.set(cookieName, token, sessionCookieOptions());
setStalwartAuthContextInStore(cookieStore, slot, { setStalwartAuthContextInStore(cookieStore, slot, {
serverUrl: normalizedServerUrl, serverUrl: normalizedServerUrl,
username, username,
+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 // Trusted (admin-configured) URLs skip the upstream re-fetch: the caller
// just authenticated to JMAP with these credentials, and the cookie we // just authenticated to JMAP with these credentials, and the cookie we
// write here is only ever consumed for requests on behalf of this same // 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 // privilege escalation. For untrusted custom endpoints we still verify
// upstream as before. // upstream as before.
const normalizedServerUrl = upstreamTrusted const normalizedServerUrl = upstreamTrusted
+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), 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: [], to: [{ name: 'Dev User', email: 'dev@localhost' }], cc: [],
subject: 'Willkommen bei Bulwark Webmail!', 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, 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: [], htmlBody: [],
bodyValues: { 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), 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' }], from: [{ name: 'Lars Johansson', email: 'lars.johansson@fjord-systems.example' }],
to: [{ name: 'Dev User', email: 'dev@localhost' }], 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', subject: 'Sprint planning - next week priorities',
preview: 'Hej team, here are the priorities for next sprint. Please review before our planning meeting tomorrow.', preview: 'Hej team, here are the priorities for next sprint. Please review before our planning meeting tomorrow.',
hasAttachment: false, 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), 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' }], 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' }], cc: [{ name: 'Dev User', email: 'dev@localhost' }, { name: 'Élise Moreau', email: 'elise.moreau@fjord-systems.example' }],
subject: 'Re: Sprint planning - next week priorities', 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), 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' }], 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', subject: 'Design review feedback',
preview: 'Hallo Sophie, I reviewed the new mockups and have a few suggestions.', preview: 'Hallo Sophie, I reviewed the new mockups and have a few suggestions.',
hasAttachment: false, 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), 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' }], from: [{ name: 'Dev User', email: 'dev@localhost' }],
to: [{ name: 'Lars Johansson', email: 'lars.johansson@fjord-systems.example' }], 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', subject: 'Re: Sprint planning - next week priorities',
preview: 'Great suggestions Sophie. 10:30 works for me. I\'ll update the calendar invite.', preview: 'Great suggestions Sophie. 10:30 works for me. I\'ll update the calendar invite.',
hasAttachment: false, 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), 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: [], to: [{ name: 'Dev User', email: 'dev@localhost' }], cc: [],
subject: 'Conference talk accepted!', subject: 'Conference talk accepted!',
preview: 'Toll! Your talk proposal for the JMAP Conf has been accepted!', preview: 'Toll! Your talk proposal for the JMAP Conf has been accepted!',
@@ -728,8 +728,8 @@ const IDENTITIES = [
email: 'dev@localhost', email: 'dev@localhost',
replyTo: null, replyTo: null,
bcc: null, bcc: null,
textSignature: '-- \nDev User\nBulwark Webmail Developer', textSignature: 'Dev User\nBulwark Webmail Developer',
htmlSignature: '<p>--<br>Dev User<br><em>Bulwark Webmail Developer</em></p>', htmlSignature: '<p>Dev User<br><em>Bulwark Webmail Developer</em></p>',
mayDelete: false, mayDelete: false,
}, },
]; ];
@@ -743,6 +743,12 @@ const addressBooks = [
{ id: 'ab-2', name: 'Arbeit / Work', isDefault: false }, { 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 = [ const contacts = [
// --- Personal address book --- // --- Personal address book ---
{ id: 'contact-001', uid: 'urn:uuid:c0000001-0000-0000-0000-000000000001', addressBookIds: { 'ab-1': true }, kind: 'individual', { 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' } }, organizations: { o1: { name: 'EuroTech GmbH' } },
addresses: { a1: { street: [{ value: 'Kurfürstendamm 42' }], locality: 'Berlin', region: '', country: 'Germany', postcode: '10719' } }, 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.' } }, 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', { 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' }] }, name: { components: [{ kind: 'given', value: 'Pierre' }, { kind: 'surname', value: 'Dubois' }] },
@@ -760,6 +767,7 @@ const contacts = [
organizations: { o1: { name: 'Dubois Consulting' } }, organizations: { o1: { name: 'Dubois Consulting' } },
addresses: { a1: { street: [{ value: '42 Rue de Rivoli' }], locality: 'Paris', country: 'France', postcode: '75001' } }, addresses: { a1: { street: [{ value: '42 Rue de Rivoli' }], locality: 'Paris', country: 'France', postcode: '75001' } },
notes: { n1: { note: 'Product manager. Knows every boulangerie in Paris.' } }, 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', { 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' }] }, name: { components: [{ kind: 'given', value: 'Chiara' }, { kind: 'surname', value: 'Rossi' }] },
@@ -768,6 +776,7 @@ const contacts = [
organizations: { o1: { name: 'Rossi Design Studio' } }, organizations: { o1: { name: 'Rossi Design Studio' } },
addresses: { a1: { street: [{ value: 'Via Montenapoleone 8' }], locality: 'Milano', country: 'Italy', postcode: '20121' } }, addresses: { a1: { street: [{ value: 'Via Montenapoleone 8' }], locality: 'Milano', country: 'Italy', postcode: '20121' } },
notes: { n1: { note: 'UX designer. Her risotto recipes are legendary.' } }, 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', { 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' }] }, name: { components: [{ kind: 'given', value: 'Karel' }, { kind: 'surname', value: 'de Vries' }] },
@@ -775,6 +784,7 @@ const contacts = [
phones: { p1: { number: '+31 20 555 0142' } }, phones: { p1: { number: '+31 20 555 0142' } },
addresses: { a1: { street: [{ value: 'Herengracht 142' }], locality: 'Amsterdam', country: 'Netherlands', postcode: '1015 BN' } }, 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.' } }, 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', { 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' }] }, name: { components: [{ kind: 'given', value: 'Lars' }, { kind: 'surname', value: 'Johansson' }] },
@@ -783,6 +793,7 @@ const contacts = [
organizations: { o1: { name: 'Fjord Systems AB' } }, organizations: { o1: { name: 'Fjord Systems AB' } },
addresses: { a1: { street: [{ value: 'Drottninggatan 42' }], locality: 'Stockholm', country: 'Sweden', postcode: '111 51' } }, 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.' } }, 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', { 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' }] }, name: { components: [{ kind: 'given', value: 'Élise' }, { kind: 'surname', value: 'Moreau' }] },
@@ -791,6 +802,7 @@ const contacts = [
organizations: { o1: { name: 'Fjord Systems AB' } }, organizations: { o1: { name: 'Fjord Systems AB' } },
addresses: { a1: { street: [{ value: '15 Boulevard Saint-Germain' }], locality: 'Paris', country: 'France', postcode: '75005' } }, 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.' } }, 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', { 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' }] }, name: { components: [{ kind: 'given', value: 'Francesco' }, { kind: 'surname', value: 'Bianchi' }] },
@@ -798,6 +810,7 @@ const contacts = [
phones: { p1: { number: '+39 06 9876 5432' } }, phones: { p1: { number: '+39 06 9876 5432' } },
addresses: { a1: { street: [{ value: 'Via dei Condotti 22' }], locality: 'Roma', country: 'Italy', postcode: '00187' } }, 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.' } }, 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', { 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' }] }, name: { components: [{ kind: 'given', value: 'Astrid' }, { kind: 'surname', value: 'van der Berg' }] },
@@ -806,6 +819,7 @@ const contacts = [
organizations: { o1: { name: 'BergLabs' } }, organizations: { o1: { name: 'BergLabs' } },
addresses: { a1: { street: [{ value: 'Prinsengracht 263' }], locality: 'Amsterdam', country: 'Netherlands', postcode: '1016 GV' } }, 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.' } }, 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', { 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' }] }, name: { components: [{ kind: 'given', value: 'Henrik' }, { kind: 'surname', value: 'Nielsen' }] },
@@ -814,6 +828,7 @@ const contacts = [
organizations: { o1: { name: 'Nielsen Konsult' } }, organizations: { o1: { name: 'Nielsen Konsult' } },
addresses: { a1: { street: [{ value: 'Nyhavn 42' }], locality: 'København', country: 'Denmark', postcode: '1051' } }, 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.' } }, 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', { 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' }] }, name: { components: [{ kind: 'given', value: 'Isabelle' }, { kind: 'surname', value: 'Martin' }] },
@@ -822,6 +837,7 @@ const contacts = [
organizations: { o1: { name: 'Sorbonne Université' } }, organizations: { o1: { name: 'Sorbonne Université' } },
addresses: { a1: { street: [{ value: '21 Rue de l\'École de Médecine' }], locality: 'Paris', country: 'France', postcode: '75006' } }, 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.' } }, 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 --- // --- Work address book ---
{ id: 'contact-011', uid: 'urn:uuid:c0000011-0000-0000-0000-000000000011', addressBookIds: { 'ab-2': true }, kind: 'individual', { 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' } }, organizations: { o1: { name: 'Lefèvre & Associés' } },
addresses: { a1: { street: [{ value: '8 Avenue de l\'Opéra' }], locality: 'Paris', country: 'France', postcode: '75001' } }, 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.' } }, 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', { 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' }] }, name: { components: [{ kind: 'given', value: 'Katrin' }, { kind: 'surname', value: 'Bauer' }] },
@@ -839,6 +856,7 @@ const contacts = [
organizations: { o1: { name: 'Charité Klinik Berlin' } }, organizations: { o1: { name: 'Charité Klinik Berlin' } },
addresses: { a1: { street: [{ value: 'Charitéplatz 1' }], locality: 'Berlin', country: 'Germany', postcode: '10117' } }, 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.' } }, 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', { 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' }] }, name: { components: [{ kind: 'given', value: 'Liam' }, { kind: 'surname', value: 'Ó Donaill' }] },
@@ -847,6 +865,7 @@ const contacts = [
organizations: { o1: { name: 'Finanz Dublin' } }, organizations: { o1: { name: 'Finanz Dublin' } },
addresses: { a1: { street: [{ value: '42 St. Stephen\'s Green' }], locality: 'Dublin', country: 'Ireland', postcode: 'D02 HX65' } }, 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.' } }, 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', { 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' }] }, 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' } }, organizations: { o1: { name: 'García Design Studio' } },
addresses: { a1: { street: [{ value: 'Calle Gran Vía 42' }], locality: 'Madrid', country: 'Spain', postcode: '28013' } }, 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.' } }, 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', { 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' }] }, name: { components: [{ kind: 'given', value: 'Nils' }, { kind: 'surname', value: 'Andersson' }] },
@@ -863,6 +883,7 @@ const contacts = [
organizations: { o1: { name: 'Digitaal BV' } }, organizations: { o1: { name: 'Digitaal BV' } },
addresses: { a1: { street: [{ value: 'Vijzelstraat 42' }], locality: 'Amsterdam', country: 'Netherlands', postcode: '1017 HK' } }, addresses: { a1: { street: [{ value: 'Vijzelstraat 42' }], locality: 'Amsterdam', country: 'Netherlands', postcode: '1017 HK' } },
notes: { n1: { note: 'Platform engineer. fika buddy. Appreciates a good kanelbulle.' } }, 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', { 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' }] }, name: { components: [{ kind: 'given', value: 'Olivia' }, { kind: 'surname', value: 'Kowalska' }] },
@@ -871,6 +892,7 @@ const contacts = [
organizations: { o1: { name: 'Kowalska Marketing' } }, organizations: { o1: { name: 'Kowalska Marketing' } },
addresses: { a1: { street: [{ value: 'ul. Nowy Świat 42' }], locality: 'Warszawa', country: 'Poland', postcode: '00-363' } }, 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.' } }, 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', { 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' }] }, name: { components: [{ kind: 'given', value: 'Pádraig' }, { kind: 'surname', value: 'Murphy' }] },
@@ -879,6 +901,7 @@ const contacts = [
organizations: { o1: { name: 'Murphy Bau GmbH' } }, organizations: { o1: { name: 'Murphy Bau GmbH' } },
addresses: { a1: { street: [{ value: 'Grafton Street 42' }], locality: 'Dublin', country: 'Ireland', postcode: 'D02 R296' } }, 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.' } }, 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', { 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' }] }, name: { components: [{ kind: 'given', value: 'Raquel' }, { kind: 'surname', value: 'Ferreira' }] },
@@ -887,6 +910,7 @@ const contacts = [
organizations: { o1: { name: 'Ferreira Media' } }, organizations: { o1: { name: 'Ferreira Media' } },
addresses: { a1: { street: [{ value: 'Rua Augusta 42' }], locality: 'Lisboa', country: 'Portugal', postcode: '1100-053' } }, 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.' } }, 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', { 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' }] }, name: { components: [{ kind: 'given', value: 'Sébastien' }, { kind: 'surname', value: 'Dumont' }] },
@@ -895,6 +919,7 @@ const contacts = [
organizations: { o1: { name: 'Dumont Conseil' } }, organizations: { o1: { name: 'Dumont Conseil' } },
addresses: { a1: { street: [{ value: 'Avenue Louise 42' }], locality: 'Bruxelles', country: 'Belgium', postcode: '1050' } }, 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.' } }, 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', { 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' }] }, 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' } }, addresses: { a1: { street: [{ value: 'Strandvägen 42' }], locality: 'Stockholm', country: 'Sweden', postcode: '114 56' } },
nicknames: { n1: { name: 'Anni' } }, nicknames: { n1: { name: 'Anni' } },
notes: { n1: { note: 'Independent consultant specializing in GDPR compliance. Yes, she has opinions about cookie banners.' } }, 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 --- // --- Groups ---
{ id: 'contact-group-001', addressBookIds: { 'ab-1': true }, kind: 'group' as const, { id: 'contact-group-001', addressBookIds: { 'ab-1': true }, kind: 'group' as const,
@@ -976,7 +1002,7 @@ const calendarEvents = [
participants: { participants: {
p1: participant('Dev User', 'dev@localhost', 'owner'), p1: participant('Dev User', 'dev@localhost', 'owner'),
p2: participant('Lars Johansson', 'lars.johansson@fjord-systems.example'), 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'), p4: participant('Élise Moreau', 'elise.moreau@fjord-systems.example'),
}, },
alerts: { a1: { trigger: { '@type': 'OffsetTrigger', offset: '-PT5M', relativeTo: 'start' }, action: 'display' } }, alerts: { a1: { trigger: { '@type': 'OffsetTrigger', offset: '-PT5M', relativeTo: 'start' }, action: 'display' } },
@@ -986,7 +1012,7 @@ const calendarEvents = [
participants: { participants: {
p1: participant('Dev User', 'dev@localhost', 'owner'), p1: participant('Dev User', 'dev@localhost', 'owner'),
p2: participant('Lars Johansson', 'lars.johansson@fjord-systems.example'), 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'), p4: participant('Élise Moreau', 'elise.moreau@fjord-systems.example'),
p5: participant('Astrid van der Berg', 'astrid@berglabs.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' } }, virtualLocations: { vl1: { uri: 'https://meet.example/eurotech', name: 'Teams' } },
participants: { participants: {
p1: participant('Dev User', 'dev@localhost', 'owner'), 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'), p3: participant('Pierre Dubois', 'pierre@dubois.example'),
}, },
description: 'Discuss API rate limit escalation for EuroTech enterprise account.', description: 'Discuss API rate limit escalation for EuroTech enterprise account.',
@@ -1054,7 +1080,7 @@ const calendarEvents = [
participants: { participants: {
p1: participant('Dev User', 'dev@localhost', 'owner'), p1: participant('Dev User', 'dev@localhost', 'owner'),
p2: participant('Lars Johansson', 'lars.johansson@fjord-systems.example'), 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'), p4: participant('Élise Moreau', 'elise.moreau@fjord-systems.example'),
p5: participant('Astrid van der Berg', 'astrid@berglabs.example'), p5: participant('Astrid van der Berg', 'astrid@berglabs.example'),
p6: participant('Pierre Dubois', 'pierre@dubois.example'), p6: participant('Pierre Dubois', 'pierre@dubois.example'),
@@ -1066,7 +1092,7 @@ const calendarEvents = [
participants: { participants: {
p1: participant('Dev User', 'dev@localhost'), p1: participant('Dev User', 'dev@localhost'),
p2: participant('María García', 'maria@garcia-design.example', 'owner'), 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', { 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'), p2: participant('Dev User', 'dev@localhost'),
p3: participant('Pierre Dubois', 'pierre@dubois.example'), p3: participant('Pierre Dubois', 'pierre@dubois.example'),
p4: participant('Chiara Rossi', 'chiara@rossi.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', { 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'), p1: participant('Dev User', 'dev@localhost', 'owner'),
p2: participant('Lars Johansson', 'lars.johansson@fjord-systems.example'), p2: participant('Lars Johansson', 'lars.johansson@fjord-systems.example'),
p3: participant('Élise Moreau', 'elise.moreau@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', { 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', 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.', 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: { participants: {
p1: participant('Sophie Müller', 'sophie@eurotech.example', 'owner'), p1: participant('Sophie Example', 'sophie@eurotech.example', 'owner'),
p2: participant('Dev User', 'dev@localhost'), p2: participant('Dev User', 'dev@localhost'),
p3: participant('Pierre Dubois', 'pierre@dubois.example'), p3: participant('Pierre Dubois', 'pierre@dubois.example'),
p4: participant('Chiara Rossi', 'chiara@rossi.example'), p4: participant('Chiara Rossi', 'chiara@rossi.example'),
@@ -1192,7 +1218,7 @@ const calendarEvents = [
}), }),
// ===== Birthday calendar (cal-5) ===== // ===== 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, showWithoutTime: true,
recurrence: [{ frequency: 'yearly' }], recurrence: [{ frequency: 'yearly' }],
description: 'Don\'t forget to bring Kuchen!', 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!', description: 'Your talk: "Building Modern Webmail with JMAP" - Day 1, 14:00, Main Hall.\nDon\'t forget slide deck!',
participants: { participants: {
p1: participant('Dev User', 'dev@localhost'), 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'), p3: participant('Isabelle Martin', 'isabelle.martin@sorbonne.example'),
}, },
}), }),
+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: * Multipart form fields:
* file the image (SVG/PNG/JPEG/WebP/ICO, max 2 MB) * file - the image (SVG/PNG/JPEG/WebP/ICO, max 2 MB)
* slot which branding key (faviconUrl, loginLogoLightUrl, etc.) * slot - which branding key (faviconUrl, loginLogoLightUrl, etc.)
* *
* Mirrors /api/admin/branding but authenticates via the wizard cookie * 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 * Files land in the same directory; the public read endpoint at
* /api/admin/branding/<filename> serves both wizard- and admin-uploaded * /api/admin/branding/<filename> serves both wizard- and admin-uploaded
* assets after setup. * 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. * config override so the slot falls back to the system default.
* *
* Body: { slot: string } * Body: { slot: string }
+9 -9
View File
@@ -229,7 +229,7 @@ export default function SetupWizardPage() {
} catch (e) { } catch (e) {
const msg = humanError(e); const msg = humanError(e);
setError(msg); 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 // welcome step so they can re-enter the token without
// having to refresh. // having to refresh.
if (/wizard session required/i.test(msg)) { if (/wizard session required/i.test(msg)) {
@@ -241,7 +241,7 @@ export default function SetupWizardPage() {
onBack={() => setStepIndex((i) => Math.max(i - 1, 1))} onBack={() => setStepIndex((i) => Math.max(i - 1, 1))}
onFinish={() => { onFinish={() => {
setCompleted(true); 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 // to see the success screen and works around any router
// edge cases that swallow client-side replaces after the // edge cases that swallow client-side replaces after the
// setupComplete flag flips. // setupComplete flag flips.
@@ -536,7 +536,7 @@ function ServerStep({ config, setConfig, onNext }: Pick<StepProps, 'config' | 's
const data = await res.json(); const data = await res.json();
let entry: { status: ProbeStatus; message: string; url: string }; let entry: { status: ProbeStatus; message: string; url: string };
if (data.status === 'jmap_detected') { 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') { } 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 }; 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') { } else if (data.status === 'invalid_url') {
@@ -618,7 +618,7 @@ function ServerStep({ config, setConfig, onNext }: Pick<StepProps, 'config' | 's
} }
if (!result) return; 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') { if (result.status === 'invalid_url' || result.status === 'unreachable') {
return; return;
} }
@@ -685,7 +685,7 @@ function ServerStep({ config, setConfig, onNext }: Pick<StepProps, 'config' | 's
This URL uses plain HTTP. This URL uses plain HTTP.
</p> </p>
<p className="text-sm text-muted-foreground mt-0.5 leading-relaxed"> <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> </p>
</div> </div>
</div> </div>
@@ -720,7 +720,7 @@ function ServerStep({ config, setConfig, onNext }: Pick<StepProps, 'config' | 's
onChange={(e) => setConfirmedNonJmap(e.target.checked)} onChange={(e) => setConfirmedNonJmap(e.target.checked)}
className="h-4 w-4" 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> </label>
</div> </div>
) : ( ) : (
@@ -1105,7 +1105,7 @@ function BrandingStep({ config, setConfig, onNext, onBack }: Pick<StepProps, 'co
<form onSubmit={handle} className="space-y-4"> <form onSubmit={handle} className="space-y-4">
<StepHeader <StepHeader
title="Branding" 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"> <Field label="Company / organization name">
<Input value={config.loginCompanyName} onChange={(v) => setConfig({ ...config, loginCompanyName: v })} /> <Input value={config.loginCompanyName} onChange={(v) => setConfig({ ...config, loginCompanyName: v })} />
@@ -1174,7 +1174,7 @@ function BrandingStep({ config, setConfig, onNext, onBack }: Pick<StepProps, 'co
* One branding asset slot: shows a thumbnail preview if a value is set, * 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 * 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 * 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({ function BrandingAsset({
label, label,
@@ -1522,7 +1522,7 @@ function SummaryRow({ label, value, mono }: { label: string; value: string; mono
<div className="flex justify-between items-baseline gap-3 text-sm"> <div className="flex justify-between items-baseline gap-3 text-sm">
<span className="text-muted-foreground shrink-0">{label}</span> <span className="text-muted-foreground shrink-0">{label}</span>
<span className={'text-foreground text-right truncate min-w-0 ' + (mono ? 'font-mono text-xs' : '')}> <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> </span>
</div> </div>
); );
+4 -4
View File
@@ -56,7 +56,7 @@ export interface ComposerDraftData {
mode: 'compose' | 'reply' | 'replyAll' | 'forward'; mode: 'compose' | 'reply' | 'replyAll' | 'forward';
replyTo?: EmailComposerProps['replyTo']; replyTo?: EmailComposerProps['replyTo'];
draftId: string | null; 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; fromOverrideEmail?: string;
fromOverrideName?: string; fromOverrideName?: string;
fromOverrideEnabled?: boolean; fromOverrideEnabled?: boolean;
@@ -239,7 +239,7 @@ export function EmailComposer({
// When "above quote" is configured, splice signature between the user's // When "above quote" is configured, splice signature between the user's
// drafting area and the quoted content so it reads naturally as a // 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. // shouldEmbedSignatureAboveQuote.
const plainSep = signatureSeparatorEnabled ? '\n\n-- \n' : '\n\n'; const plainSep = signatureSeparatorEnabled ? '\n\n-- \n' : '\n\n';
const signatureBlock = shouldEmbedSignatureAboveQuote const signatureBlock = shouldEmbedSignatureAboveQuote
@@ -1106,7 +1106,7 @@ export function EmailComposer({
: undefined; : undefined;
// When the user has typed a From override, that becomes the header From // 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 // (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. // default to the override address.
const overrideActive = fromOverrideEnabled && fromOverrideEmail.trim().length > 0; const overrideActive = fromOverrideEnabled && fromOverrideEmail.trim().length > 0;
const fromEmail = overrideActive ? fromOverrideEmail.trim() : identityFromEmail; const fromEmail = overrideActive ? fromOverrideEmail.trim() : identityFromEmail;
@@ -1184,7 +1184,7 @@ export function EmailComposer({
// would produce a signature whose Subject differs from the visible // would produce a signature whose Subject differs from the visible
// From, which most clients reject or flag. Refuse up front. // From, which most clients reject or flag. Refuse up front.
if (overrideActive) { 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 // 2. Ensure key is unlocked for signing
+1 -1
View File
@@ -263,7 +263,7 @@ export function IdentityForm({ identity, onSave, onCancel }: IdentityFormProps)
</label> </label>
<textarea <textarea
id="identity-html-sig" id="identity-html-sig"
maxLength={5000} maxLength={50000}
value={formData.htmlSignature} value={formData.htmlSignature}
onChange={(e) => setFormData({ ...formData, htmlSignature: e.target.value })} onChange={(e) => setFormData({ ...formData, htmlSignature: e.target.value })}
rows={5} rows={5}
+20
View File
@@ -20,6 +20,7 @@ import {
Folder, Folder,
FolderOpen, FolderOpen,
User, User,
Users,
Palmtree, Palmtree,
Settings, Settings,
X, X,
@@ -28,6 +29,10 @@ import {
FlaskConical, FlaskConical,
PlayCircle, PlayCircle,
Loader2, Loader2,
AlertTriangle,
NotebookPen,
CalendarClock,
BellOff,
} from "lucide-react"; } from "lucide-react";
import { cn, buildMailboxTree, MailboxNode } from "@/lib/utils"; import { cn, buildMailboxTree, MailboxNode } from "@/lib/utils";
import { Mailbox } from "@/lib/jmap/types"; 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 === "trash" || lowerName.includes("trash") || lowerName.includes("deleted")) return Trash2;
if (role === "junk" || role === "spam" || lowerName.includes("junk") || lowerName.includes("spam")) return Ban; if (role === "junk" || role === "spam" || lowerName.includes("junk") || lowerName.includes("spam")) return Ban;
if (role === "archive" || lowerName.includes("archive")) return Archive; 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 (lowerName.includes("star") || lowerName.includes("flag")) return Star;
if (hasChildren) { if (hasChildren) {
@@ -104,6 +114,11 @@ const ROLE_ICON_COLOR: Record<string, string> = {
trash: "text-muted-foreground", trash: "text-muted-foreground",
junk: "text-red-600/80 dark:text-red-400/80", junk: "text-red-600/80 dark:text-red-400/80",
archive: "text-amber-600/80 dark:text-amber-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 { 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 === "trash" || lowerName.includes("trash") || lowerName.includes("deleted")) return "trash";
if (role === "junk" || role === "spam" || lowerName.includes("junk") || lowerName.includes("spam")) return "junk"; if (role === "junk" || role === "spam" || lowerName.includes("junk") || lowerName.includes("spam")) return "junk";
if (role === "archive" || lowerName.includes("archive")) return "archive"; 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; return undefined;
} }
+8 -6
View File
@@ -4,13 +4,14 @@ import { useEffect, useState } from 'react';
import { NextIntlClientProvider } from 'next-intl'; import { NextIntlClientProvider } from 'next-intl';
import { useLocaleStore } from '@/stores/locale-store'; import { useLocaleStore } from '@/stores/locale-store';
import csMessages from '@/locales/cs/common.json'; 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 enMessages from '@/locales/en/common.json';
import esMessages from '@/locales/es/common.json';
import frMessages from '@/locales/fr/common.json'; import frMessages from '@/locales/fr/common.json';
import itMessages from '@/locales/it/common.json';
import jaMessages from '@/locales/ja/common.json'; import jaMessages from '@/locales/ja/common.json';
import koMessages from '@/locales/ko/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 lvMessages from '@/locales/lv/common.json';
import nlMessages from '@/locales/nl/common.json'; import nlMessages from '@/locales/nl/common.json';
import plMessages from '@/locales/pl/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) // Pre-loaded translations (loaded at build time, not runtime)
const ALL_MESSAGES = { const ALL_MESSAGES = {
cs: csMessages, cs: csMessages,
da: daMessages,
de: deMessages,
en: enMessages, en: enMessages,
es: esMessages,
fr: frMessages, fr: frMessages,
it: itMessages,
ja: jaMessages, ja: jaMessages,
ko: koMessages, ko: koMessages,
es: esMessages,
it: itMessages,
de: deMessages,
lv: lvMessages, lv: lvMessages,
nl: nlMessages, nl: nlMessages,
pl: plMessages, pl: plMessages,
+15
View File
@@ -10,5 +10,20 @@ export function ThemeProvider({ children }: { children: React.ReactNode }) {
initializeTheme(); initializeTheme();
}, [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}</>; return <>{children}</>;
} }
+8 -1
View File
@@ -67,7 +67,7 @@ export function AppearanceSettings() {
const tAdvanced = useTranslations('settings.advanced'); const tAdvanced = useTranslations('settings.advanced');
const tTour = useTranslations('tour'); const tTour = useTranslations('tour');
const { theme, setTheme } = useThemeStore(); 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 { startTour, resetTourCompletion } = useTour();
const { isSettingLocked, isSettingHidden } = usePolicyStore(); const { isSettingLocked, isSettingHidden } = usePolicyStore();
@@ -145,6 +145,13 @@ export function AppearanceSettings() {
{tTour('restart_button')} {tTour('restart_button')}
</Button> </Button>
</SettingItem> </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> </SettingsSection>
); );
} }
+6
View File
@@ -13,6 +13,7 @@ import {
Inbox, Send, FileText, Trash, ShieldAlert, Archive, Inbox, Send, FileText, Trash, ShieldAlert, Archive,
Star, Heart, Bookmark, Tag, Flag, Briefcase, Users, Star, Heart, Bookmark, Tag, Flag, Briefcase, Users,
Bell, Zap, Globe, Lock, Eye, MessageSquare, Mail, Bell, Zap, Globe, Lock, Eye, MessageSquare, Mail,
AlertTriangle, NotebookPen, CalendarClock, BellOff,
type LucideIcon, type LucideIcon,
} from 'lucide-react'; } from 'lucide-react';
import { cn, buildMailboxTree, type MailboxNode } from '@/lib/utils'; import { cn, buildMailboxTree, type MailboxNode } from '@/lib/utils';
@@ -27,6 +28,11 @@ const ROLE_ICONS: Record<string, LucideIcon> = {
trash: Trash, trash: Trash,
junk: ShieldAlert, junk: ShieldAlert,
archive: Archive, archive: Archive,
shared: Users,
important: AlertTriangle,
memos: NotebookPen,
scheduled: CalendarClock,
snoozed: BellOff,
}; };
const ICON_CHOICES: { name: string; icon: LucideIcon }[] = [ const ICON_CHOICES: { name: string; icon: LucideIcon }[] = [
+29 -4
View File
@@ -5,6 +5,7 @@ import { useRouter, usePathname } from "@/i18n/navigation";
import { useAuthStore } from "@/stores/auth-store"; import { useAuthStore } from "@/stores/auth-store";
import { useCalendarStore } from "@/stores/calendar-store"; import { useCalendarStore } from "@/stores/calendar-store";
import { useWebDAVStore } from "@/stores/webdav-store"; import { useWebDAVStore } from "@/stores/webdav-store";
import { useSettingsStore } from "@/stores/settings-store";
import { getTourSteps, type TourStep } from "./tour-steps"; import { getTourSteps, type TourStep } from "./tour-steps";
import { TourOverlay } from "./tour-overlay"; import { TourOverlay } from "./tour-overlay";
@@ -38,6 +39,9 @@ export function TourProvider({ children }: { children: ReactNode }) {
const { isDemoMode } = useAuthStore(); const { isDemoMode } = useAuthStore();
const { supportsCalendar } = useCalendarStore(); const { supportsCalendar } = useCalendarStore();
const { supportsWebDAV } = useWebDAVStore(); 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 [isActive, setIsActive] = useState(false);
const [currentStep, setCurrentStep] = useState(0); const [currentStep, setCurrentStep] = useState(0);
@@ -46,10 +50,29 @@ export function TourProvider({ children }: { children: ReactNode }) {
const steps = getTourSteps({ isDemoMode, supportsCalendar, supportsWebDAV: supportsWebDAV !== false }); const steps = getTourSteps({ isDemoMode, supportsCalendar, supportsWebDAV: supportsWebDAV !== false });
useEffect(() => { 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 { try {
setHasCompletedTour(localStorage.getItem(TOUR_COMPLETED_KEY) === "true"); const legacy = localStorage.getItem(TOUR_COMPLETED_KEY) === "true";
if (legacy && !tourCompleted) {
updateSetting("tourCompleted", true);
}
} catch { /* */ } } 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(() => { const startTour = useCallback(() => {
let resumeStep = 0; let resumeStep = 0;
@@ -85,11 +108,12 @@ export function TourProvider({ children }: { children: ReactNode }) {
const completeTour = useCallback(() => { const completeTour = useCallback(() => {
setIsActive(false); setIsActive(false);
setHasCompletedTour(true); setHasCompletedTour(true);
updateSetting("tourCompleted", true);
try { try {
localStorage.setItem(TOUR_COMPLETED_KEY, "true"); localStorage.setItem(TOUR_COMPLETED_KEY, "true");
localStorage.removeItem(TOUR_CURRENT_STEP_KEY); localStorage.removeItem(TOUR_CURRENT_STEP_KEY);
} catch { /* */ } } catch { /* */ }
}, []); }, [updateSetting]);
const nextStep = useCallback(() => { const nextStep = useCallback(() => {
if (currentStep >= steps.length - 1) { if (currentStep >= steps.length - 1) {
@@ -131,11 +155,12 @@ export function TourProvider({ children }: { children: ReactNode }) {
const resetTourCompletion = useCallback(() => { const resetTourCompletion = useCallback(() => {
setHasCompletedTour(false); setHasCompletedTour(false);
updateSetting("tourCompleted", false);
try { try {
localStorage.removeItem(TOUR_COMPLETED_KEY); localStorage.removeItem(TOUR_COMPLETED_KEY);
localStorage.removeItem(TOUR_CURRENT_STEP_KEY); localStorage.removeItem(TOUR_CURRENT_STEP_KEY);
} catch { /* */ } } catch { /* */ }
}, []); }, [updateSetting]);
const value: TourContextValue = { const value: TourContextValue = {
isActive, isActive,
+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 */ /** Map locale codes to flag components */
export const flagComponents: Record<string, (props: FlagProps) => ReactElement> = { export const flagComponents: Record<string, (props: FlagProps) => ReactElement> = {
cs: FlagCS,
da: FlagDK,
de: FlagDE,
en: FlagGB, en: FlagGB,
es: FlagES,
fr: FlagFR, fr: FlagFR,
it: FlagIT,
ja: FlagJP, ja: FlagJP,
ko: FlagKR, ko: FlagKR,
es: FlagES,
it: FlagIT,
de: FlagDE,
lv: FlagLV, lv: FlagLV,
nl: FlagNL, nl: FlagNL,
pl: FlagPL, pl: FlagPL,
@@ -218,5 +230,4 @@ export const flagComponents: Record<string, (props: FlagProps) => ReactElement>
tr: FlagTR, tr: FlagTR,
uk: FlagUA, uk: FlagUA,
zh: FlagCN, zh: FlagCN,
cs: FlagCS,
}; };
+8 -7
View File
@@ -9,20 +9,21 @@ import { flagComponents } from './flag-icons';
const languages = [ const languages = [
{ value: 'cs', label: 'Česky' }, { value: 'cs', label: 'Česky' },
{ value: 'en', label: 'English' }, { value: 'da', label: 'Dansk' },
{ value: 'fr', label: 'Français' },
{ value: 'ja', label: '日本語' },
{ value: 'ko', label: '한국어' },
{ value: 'es', label: 'Español' },
{ value: 'it', label: 'Italiano' },
{ value: 'de', label: 'Deutsch' }, { 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: 'lv', label: 'Latviešu' },
{ value: 'nl', label: 'Nederlands' }, { value: 'nl', label: 'Nederlands' },
{ value: 'pl', label: 'Polski' }, { value: 'pl', label: 'Polski' },
{ value: 'pt', label: 'Português' }, { value: 'pt', label: 'Português' },
{ value: 'ru', label: 'Русский' },
{ value: 'tr', label: 'Türkçe' }, { value: 'tr', label: 'Türkçe' },
{ value: 'ru', label: 'Русский' },
{ value: 'uk', label: 'Українська' }, { value: 'uk', label: 'Українська' },
{ value: 'ko', label: '한국어' },
{ value: 'ja', label: '日本語' },
{ value: 'zh', 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 { Button } from "@/components/ui/button";
import { useRouter } from "@/i18n/navigation"; import { useRouter } from "@/i18n/navigation";
import { useTour } from "@/components/tour/tour-provider"; import { useTour } from "@/components/tour/tour-provider";
import { useSettingsStore } from "@/stores/settings-store";
const ONBOARDING_KEY = "onboarding_completed"; const ONBOARDING_KEY = "onboarding_completed";
@@ -13,23 +14,47 @@ export function WelcomeBanner() {
const t = useTranslations("welcome"); const t = useTranslations("welcome");
const router = useRouter(); const router = useRouter();
const { startTour } = useTour(); 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 [visible, setVisible] = useState(false);
const [dismissed, setDismissed] = useState(false); const [dismissed, setDismissed] = useState(false);
useEffect(() => { 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 { try {
if (!localStorage.getItem(ONBOARDING_KEY)) { const legacy = localStorage.getItem(ONBOARDING_KEY) === "true";
setVisible(true); if (legacy && !onboardingCompleted) {
updateSetting("onboardingCompleted", true);
} }
} catch { /* localStorage unavailable */ } } 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(() => { const dismiss = useCallback(() => {
setDismissed(true); setDismissed(true);
updateSetting("onboardingCompleted", true);
try { try {
localStorage.setItem(ONBOARDING_KEY, "true"); localStorage.setItem(ONBOARDING_KEY, "true");
} catch { /* localStorage unavailable */ } } catch { /* localStorage unavailable */ }
}, []); }, [updateSetting]);
useEffect(() => { useEffect(() => {
if (!visible) return; if (!visible) return;
+2 -2
View File
@@ -66,7 +66,7 @@ export function useAttachmentDrag(
urlRef.current = url; urlRef.current = url;
// Mark as owned so we revoke on unmount. Callers that hand back a // 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 // 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; ownedRef.current = true;
} }
return url; return url;
@@ -101,7 +101,7 @@ export function useAttachmentDrag(
); );
const handleDragEnd = useCallback(() => { 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. // blob: URL after dragend fires, so revoking immediately races the OS.
if (urlRef.current && ownedRef.current) { if (urlRef.current && ownedRef.current) {
const url = urlRef.current; const url = urlRef.current;
+5 -2
View File
@@ -14,8 +14,8 @@ export default getRequestConfig(async ({ requestLocale }) => {
case 'cs': case 'cs':
messages = (await import('../locales/cs/common.json')).default; messages = (await import('../locales/cs/common.json')).default;
break; break;
case 'fr': case 'da':
messages = (await import('../locales/fr/common.json')).default; messages = (await import('../locales/da/common.json')).default;
break; break;
case 'de': case 'de':
messages = (await import('../locales/de/common.json')).default; messages = (await import('../locales/de/common.json')).default;
@@ -23,6 +23,9 @@ export default getRequestConfig(async ({ requestLocale }) => {
case 'es': case 'es':
messages = (await import('../locales/es/common.json')).default; messages = (await import('../locales/es/common.json')).default;
break; break;
case 'fr':
messages = (await import('../locales/fr/common.json')).default;
break;
case 'it': case 'it':
messages = (await import('../locales/it/common.json')).default; messages = (await import('../locales/it/common.json')).default;
break; break;
+1 -1
View File
@@ -13,7 +13,7 @@ const localePrefix = (process.env.NEXT_PUBLIC_LOCALE_PREFIX ?? 'never') as
| 'as-needed'; | 'as-needed';
export const routing = defineRouting({ 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', defaultLocale: 'en',
localePrefix localePrefix
}); });
+65 -8
View File
@@ -78,11 +78,67 @@ describe('email-sanitization', () => {
expect(clean).toContain('John Doe'); expect(clean).toContain('John Doe');
}); });
it('should remove images from signatures', () => { it('should allow img with https src', () => {
const signature = '<p>John</p><img src="logo.png" alt="Logo">'; const signature = '<p>John</p><img src="https://cdn.example.com/logo.png" alt="Logo" width="120" height="40">';
const clean = sanitizeSignatureHtml(signature); 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).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', () => { it('should remove video and audio tags', () => {
@@ -113,16 +169,17 @@ describe('email-sanitization', () => {
}); });
it('should be stricter than 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 emailClean = sanitizeEmailHtml(html);
const signatureClean = sanitizeSignatureHtml(html); const signatureClean = sanitizeSignatureHtml(html);
// Email allows img and table // Email allows table
expect(emailClean).toContain('<img');
expect(emailClean).toContain('<table>'); expect(emailClean).toContain('<table>');
// Signature blocks img but may allow some tables (verify in implementation) // Signature blocks table and video
expect(signatureClean).not.toContain('<img'); expect(signatureClean).not.toContain('<table');
expect(signatureClean).not.toContain('<video');
expect(signatureClean).toContain('Text');
}); });
}); });
+76
View File
@@ -1,5 +1,16 @@
import type { ContactCard, AddressBook } from '@/lib/jmap/types'; 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[] { export function createDemoAddressBooks(): AddressBook[] {
return [ return [
{ {
@@ -34,6 +45,7 @@ export function createDemoContacts(): ContactCard[] {
organizations: { o1: { name: 'Acme Corp', units: [{ name: 'Engineering' }] } }, organizations: { o1: { name: 'Acme Corp', units: [{ name: 'Engineering' }] } },
titles: { t1: { name: 'Senior Engineer', kind: 'title' } }, titles: { t1: { name: 'Senior Engineer', kind: 'title' } },
anniversaries: { a1: { kind: 'birth', date: { year: 1990, month: 3, day: 15 } } }, anniversaries: { a1: { kind: 'birth', date: { year: 1990, month: 3, day: 15 } } },
media: photo('women', 44),
}, },
{ {
id: 'demo-contact-2', id: 'demo-contact-2',
@@ -50,6 +62,7 @@ export function createDemoContacts(): ContactCard[] {
}, },
organizations: { o1: { name: 'Acme Corp', units: [{ name: 'Backend Team' }] } }, organizations: { o1: { name: 'Acme Corp', units: [{ name: 'Backend Team' }] } },
titles: { t1: { name: 'Staff Engineer', kind: 'title' } }, titles: { t1: { name: 'Staff Engineer', kind: 'title' } },
media: photo('men', 32),
}, },
{ {
id: 'demo-contact-3', id: 'demo-contact-3',
@@ -60,6 +73,7 @@ export function createDemoContacts(): ContactCard[] {
phones: { p1: { number: '+1-555-0104', features: { voice: true } } }, phones: { p1: { number: '+1-555-0104', features: { voice: true } } },
organizations: { o1: { name: 'DesignCo' } }, organizations: { o1: { name: 'DesignCo' } },
titles: { t1: { name: 'UX Designer', kind: 'title' } }, titles: { t1: { name: 'UX Designer', kind: 'title' } },
media: photo('women', 68),
}, },
{ {
id: 'demo-contact-4', id: 'demo-contact-4',
@@ -69,6 +83,7 @@ export function createDemoContacts(): ContactCard[] {
emails: { e1: { address: 'carlos.rivera@example.com', pref: 1 } }, emails: { e1: { address: 'carlos.rivera@example.com', pref: 1 } },
phones: { p1: { number: '+1-555-0105', features: { cell: true } } }, phones: { p1: { number: '+1-555-0105', features: { cell: true } } },
notes: { n1: { note: 'Met at the DevConf 2024 conference' } }, notes: { n1: { note: 'Met at the DevConf 2024 conference' } },
media: photo('men', 15),
}, },
{ {
id: 'demo-contact-5', id: 'demo-contact-5',
@@ -89,6 +104,7 @@ export function createDemoContacts(): ContactCard[] {
}, },
}, },
anniversaries: { a1: { kind: 'birth', date: { month: 7, day: 22 } } }, anniversaries: { a1: { kind: 'birth', date: { month: 7, day: 22 } } },
media: photo('women', 22),
}, },
{ {
id: 'demo-contact-6', id: 'demo-contact-6',
@@ -97,6 +113,7 @@ export function createDemoContacts(): ContactCard[] {
name: { components: [{ kind: 'given', value: 'David' }, { kind: 'surname', value: 'Park' }] }, name: { components: [{ kind: 'given', value: 'David' }, { kind: 'surname', value: 'Park' }] },
emails: { e1: { address: 'david.park@example.com', pref: 1 } }, emails: { e1: { address: 'david.park@example.com', pref: 1 } },
phones: { p1: { number: '+82-10-1234-5678', features: { cell: true } } }, phones: { p1: { number: '+82-10-1234-5678', features: { cell: true } } },
media: photo('men', 67),
}, },
{ {
id: 'demo-contact-7', id: 'demo-contact-7',
@@ -123,6 +140,58 @@ export function createDemoContacts(): ContactCard[] {
kind: 'individual', kind: 'individual',
name: { components: [{ kind: 'given', value: 'Lisa' }, { kind: 'surname', value: 'Tanaka' }] }, name: { components: [{ kind: 'given', value: 'Lisa' }, { kind: 'surname', value: 'Tanaka' }] },
emails: { e1: { address: 'lisa.tanaka@example.com', pref: 1 } }, 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 ────────────────────────────────────── // ── Work address book ──────────────────────────────────────
@@ -135,6 +204,7 @@ export function createDemoContacts(): ContactCard[] {
phones: { p1: { number: '+1-555-0301', features: { voice: true }, contexts: { work: true } } }, phones: { p1: { number: '+1-555-0301', features: { voice: true }, contexts: { work: true } } },
organizations: { o1: { name: 'Company Inc', units: [{ name: 'Product' }] } }, organizations: { o1: { name: 'Company Inc', units: [{ name: 'Product' }] } },
titles: { t1: { name: 'Product Manager', kind: 'title' } }, titles: { t1: { name: 'Product Manager', kind: 'title' } },
media: photo('men', 41),
}, },
{ {
id: 'demo-contact-10', id: 'demo-contact-10',
@@ -144,6 +214,7 @@ export function createDemoContacts(): ContactCard[] {
emails: { e1: { address: 'rachel.green@company.example', contexts: { work: true }, pref: 1 } }, emails: { e1: { address: 'rachel.green@company.example', contexts: { work: true }, pref: 1 } },
organizations: { o1: { name: 'Company Inc', units: [{ name: 'Marketing' }] } }, organizations: { o1: { name: 'Company Inc', units: [{ name: 'Marketing' }] } },
titles: { t1: { name: 'Marketing Lead', kind: 'title' } }, titles: { t1: { name: 'Marketing Lead', kind: 'title' } },
media: photo('women', 12),
}, },
{ {
id: 'demo-contact-11', id: 'demo-contact-11',
@@ -153,6 +224,7 @@ export function createDemoContacts(): ContactCard[] {
emails: { e1: { address: 'james.miller@company.example', contexts: { work: true }, pref: 1 } }, emails: { e1: { address: 'james.miller@company.example', contexts: { work: true }, pref: 1 } },
organizations: { o1: { name: 'Company Inc', units: [{ name: 'Engineering' }] } }, organizations: { o1: { name: 'Company Inc', units: [{ name: 'Engineering' }] } },
titles: { t1: { name: 'CTO', kind: 'title' } }, titles: { t1: { name: 'CTO', kind: 'title' } },
media: photo('men', 52),
}, },
{ {
id: 'demo-contact-12', id: 'demo-contact-12',
@@ -162,6 +234,7 @@ export function createDemoContacts(): ContactCard[] {
emails: { e1: { address: 'priya.sharma@company.example', contexts: { work: true }, pref: 1 } }, emails: { e1: { address: 'priya.sharma@company.example', contexts: { work: true }, pref: 1 } },
organizations: { o1: { name: 'Company Inc', units: [{ name: 'QA' }] } }, organizations: { o1: { name: 'Company Inc', units: [{ name: 'QA' }] } },
titles: { t1: { name: 'QA Engineer', kind: 'title' } }, titles: { t1: { name: 'QA Engineer', kind: 'title' } },
media: photo('women', 77),
}, },
{ {
id: 'demo-contact-13', id: 'demo-contact-13',
@@ -171,6 +244,7 @@ export function createDemoContacts(): ContactCard[] {
emails: { e1: { address: 'ahmed.hassan@company.example', contexts: { work: true }, pref: 1 } }, emails: { e1: { address: 'ahmed.hassan@company.example', contexts: { work: true }, pref: 1 } },
organizations: { o1: { name: 'Company Inc', units: [{ name: 'DevOps' }] } }, organizations: { o1: { name: 'Company Inc', units: [{ name: 'DevOps' }] } },
titles: { t1: { name: 'DevOps Engineer', kind: 'title' } }, titles: { t1: { name: 'DevOps Engineer', kind: 'title' } },
media: photo('men', 89),
}, },
{ {
id: 'demo-contact-14', id: 'demo-contact-14',
@@ -180,6 +254,7 @@ export function createDemoContacts(): ContactCard[] {
emails: { e1: { address: 'maria.lopez@company.example', contexts: { work: true }, pref: 1 } }, emails: { e1: { address: 'maria.lopez@company.example', contexts: { work: true }, pref: 1 } },
organizations: { o1: { name: 'Company Inc', units: [{ name: 'HR' }] } }, organizations: { o1: { name: 'Company Inc', units: [{ name: 'HR' }] } },
titles: { t1: { name: 'HR Business Partner', kind: 'title' } }, titles: { t1: { name: 'HR Business Partner', kind: 'title' } },
media: photo('women', 55),
}, },
{ {
id: 'demo-contact-15', id: 'demo-contact-15',
@@ -189,6 +264,7 @@ export function createDemoContacts(): ContactCard[] {
emails: { e1: { address: 'wei.zhang@company.example', contexts: { work: true }, pref: 1 } }, emails: { e1: { address: 'wei.zhang@company.example', contexts: { work: true }, pref: 1 } },
organizations: { o1: { name: 'Company Inc', units: [{ name: 'Data Science' }] } }, organizations: { o1: { name: 'Company Inc', units: [{ name: 'Data Science' }] } },
titles: { t1: { name: 'Data Scientist', kind: 'title' } }, 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 type { Email } from '@/lib/jmap/types';
import { demoDate } from '../demo-utils'; 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[] { export function createDemoEmails(): Email[] {
return [ return [
// ── Inbox ─────────────────────────────────────────────────── // ── Inbox ───────────────────────────────────────────────────
@@ -12,19 +41,61 @@ export function createDemoEmails(): Email[] {
size: 4200, size: 4200,
receivedAt: demoDate(0, -2), receivedAt: demoDate(0, -2),
from: [{ name: 'Bulwark Team', email: 'welcome@bulwark.email' }], from: [{ name: 'Bulwark Team', email: 'welcome@bulwark.email' }],
to: [{ name: 'Demo User', email: 'demo@example.com' }], to: [USER],
subject: 'Welcome to Bulwark Mail!', subject: 'Welcome to Bulwark Mail!',
sentAt: demoDate(0, -2), sentAt: demoDate(0, -2),
preview: 'Thanks for trying out Bulwark Mail. This is a demo environment where you can explore all features...', preview: 'Thanks for trying out Bulwark Mail. This is a demo environment where you can explore all features...',
hasAttachment: false, hasAttachment: false,
textBody: [{ partId: '1', blobId: 'blob-1', size: 350, type: 'text/plain' }], ...bodies(
htmlBody: [{ partId: '2', blobId: 'blob-2', size: 800, type: 'text/html' }], '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!',
bodyValues: { '<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>',
'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>' },
},
messageId: '<welcome@demo.bulwark.email>', 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', id: 'demo-email-2',
threadId: 'demo-thread-2', threadId: 'demo-thread-2',
@@ -33,20 +104,19 @@ export function createDemoEmails(): Email[] {
size: 18500, size: 18500,
receivedAt: demoDate(-1, -5), receivedAt: demoDate(-1, -5),
from: [{ name: 'TechDigest Weekly', email: 'newsletter@techdigest.example' }], from: [{ name: 'TechDigest Weekly', email: 'newsletter@techdigest.example' }],
to: [{ name: 'Demo User', email: 'demo@example.com' }], to: [USER],
subject: 'This Week in Tech: AI Developments & Open Source Updates', subject: 'Issue #218 - RFC 9844, the second WebAssembly draft, and a quiet announcement from Mozilla',
sentAt: demoDate(-1, -5), sentAt: demoDate(-1, -5),
preview: 'Your weekly roundup of the most important technology news and open source developments...', preview: 'Your weekly roundup of the most important technology news and open source developments...',
hasAttachment: false, hasAttachment: false,
textBody: [{ partId: '1', blobId: 'blob-3', size: 2400, type: 'text/plain' }], ...bodies(
htmlBody: [{ partId: '2', blobId: 'blob-4', size: 5200, type: 'text/html' }], '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.',
bodyValues: { '<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>',
'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-218@techdigest.example>',
},
messageId: '<weekly-42@techdigest.example>',
}, },
// Thread: Project discussion (3 emails in same thread)
// Thread: Q4 Project Timeline - Alice → Bob → Alice (4 messages)
{ {
id: 'demo-email-3a', id: 'demo-email-3a',
threadId: 'demo-thread-3', threadId: 'demo-thread-3',
@@ -55,17 +125,15 @@ export function createDemoEmails(): Email[] {
size: 3100, size: 3100,
receivedAt: demoDate(-3, -10), receivedAt: demoDate(-3, -10),
from: [{ name: 'Alice Johnson', email: 'alice.johnson@example.com' }], 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', subject: 'Q4 Project Timeline',
sentAt: demoDate(-3, -10), sentAt: demoDate(-3, -10),
preview: 'Hi team, I wanted to share the updated timeline for our Q4 deliverables...', preview: 'Hi team, I wanted to share the updated timeline for our Q4 deliverables...',
hasAttachment: false, hasAttachment: false,
textBody: [{ partId: '1', blobId: 'blob-5', size: 450, type: 'text/plain' }], ...bodies(
htmlBody: [{ partId: '2', blobId: 'blob-6', size: 650, type: 'text/html' }], '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',
bodyValues: { '<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>',
'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>' },
},
messageId: '<q4-timeline-1@example.com>', messageId: '<q4-timeline-1@example.com>',
}, },
{ {
@@ -76,15 +144,14 @@ export function createDemoEmails(): Email[] {
size: 3500, size: 3500,
receivedAt: demoDate(-2, -8), receivedAt: demoDate(-2, -8),
from: [{ name: 'Bob Chen', email: 'bob.chen@example.com' }], 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', subject: 'Re: Q4 Project Timeline',
sentAt: demoDate(-2, -8), sentAt: demoDate(-2, -8),
preview: 'Looks good to me! One concern: the testing window might be tight given the holidays...', preview: 'Looks good to me! One concern: the testing window might be tight given the holidays...',
hasAttachment: false, hasAttachment: false,
textBody: [{ partId: '1', blobId: 'blob-7', size: 520, type: 'text/plain' }], ...textOnly(
bodyValues: { "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",
'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' }, ),
},
messageId: '<q4-timeline-2@example.com>', messageId: '<q4-timeline-2@example.com>',
inReplyTo: ['<q4-timeline-1@example.com>'], inReplyTo: ['<q4-timeline-1@example.com>'],
references: ['<q4-timeline-1@example.com>'], references: ['<q4-timeline-1@example.com>'],
@@ -97,20 +164,41 @@ export function createDemoEmails(): Email[] {
size: 3800, size: 3800,
receivedAt: demoDate(-1, -3), receivedAt: demoDate(-1, -3),
from: [{ name: 'Alice Johnson', email: 'alice.johnson@example.com' }], 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', subject: 'Re: Q4 Project Timeline',
sentAt: demoDate(-1, -3), 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, hasAttachment: false,
textBody: [{ partId: '1', blobId: 'blob-8', size: 400, type: 'text/plain' }], ...textOnly(
bodyValues: { "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",
'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' }, ),
},
messageId: '<q4-timeline-3@example.com>', messageId: '<q4-timeline-3@example.com>',
inReplyTo: ['<q4-timeline-2@example.com>'], inReplyTo: ['<q4-timeline-2@example.com>'],
references: ['<q4-timeline-1@example.com>', '<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', id: 'demo-email-4',
threadId: 'demo-thread-4', threadId: 'demo-thread-4',
@@ -119,22 +207,22 @@ export function createDemoEmails(): Email[] {
size: 245000, size: 245000,
receivedAt: demoDate(0, -6), receivedAt: demoDate(0, -6),
from: [{ name: 'Sarah Kim', email: 'sarah.kim@example.com' }], from: [{ name: 'Sarah Kim', email: 'sarah.kim@example.com' }],
to: [{ name: 'Demo User', email: 'demo@example.com' }], to: [USER],
subject: 'Invoice #2024-089 & Project Screenshot', subject: 'Invoice #2024-089 & landing-page prototype v3',
sentAt: demoDate(0, -6), 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, hasAttachment: true,
textBody: [{ partId: '1', blobId: 'blob-9', size: 280, type: 'text/plain' }], ...textOnly(
bodyValues: { "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",
'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' }, ),
},
attachments: [ attachments: [
{ partId: 'att-1', blobId: 'demo-blob-att-1', size: 145000, name: 'Invoice-2024-089.pdf', type: 'application/pdf' }, { 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' }, { partId: 'att-2', blobId: 'demo-blob-att-2', size: 89000, name: 'prototype-v3.png', type: 'image/png' },
], ],
messageId: '<invoice-089@example.com>', messageId: '<invoice-089@example.com>',
}, },
// Starred email
// Carlos - starred, social
{ {
id: 'demo-email-5', id: 'demo-email-5',
threadId: 'demo-thread-5', threadId: 'demo-thread-5',
@@ -143,18 +231,286 @@ export function createDemoEmails(): Email[] {
size: 2800, size: 2800,
receivedAt: demoDate(-2, -1), receivedAt: demoDate(-2, -1),
from: [{ name: 'Carlos Rivera', email: 'carlos.rivera@example.com' }], from: [{ name: 'Carlos Rivera', email: 'carlos.rivera@example.com' }],
to: [{ name: 'Demo User', email: 'demo@example.com' }], to: [USER],
subject: 'Reminder: Team Dinner Friday', subject: 'Friday dinner - moved to 7:30 (sorry!)',
sentAt: demoDate(-2, -1), 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, hasAttachment: false,
textBody: [{ partId: '1', blobId: 'blob-10', size: 320, type: 'text/plain' }], ...textOnly(
bodyValues: { "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",
'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' }, ),
},
messageId: '<dinner-reminder@example.com>', 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 ──────────────────────────────────────────────────── // ── Sent ────────────────────────────────────────────────────
{ {
id: 'demo-email-6', id: 'demo-email-6',
@@ -163,16 +519,15 @@ export function createDemoEmails(): Email[] {
keywords: { $seen: true }, keywords: { $seen: true },
size: 2100, size: 2100,
receivedAt: demoDate(-1, -4), receivedAt: demoDate(-1, -4),
from: [{ name: 'Demo User', email: 'demo@example.com' }], from: [USER],
to: [{ name: 'Alice Johnson', email: 'alice.johnson@example.com' }], to: [{ name: 'Alice Johnson', email: 'alice.johnson@example.com' }],
subject: 'Updated Requirements Document', subject: 'Updated Requirements Document',
sentAt: demoDate(-1, -4), 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, hasAttachment: false,
textBody: [{ partId: '1', blobId: 'blob-11', size: 290, type: 'text/plain' }], ...textOnly(
bodyValues: { "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",
'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' }, ),
},
messageId: '<sent-1@example.com>', messageId: '<sent-1@example.com>',
}, },
{ {
@@ -182,18 +537,37 @@ export function createDemoEmails(): Email[] {
keywords: { $seen: true }, keywords: { $seen: true },
size: 1800, size: 1800,
receivedAt: demoDate(-4, -2), receivedAt: demoDate(-4, -2),
from: [{ name: 'Demo User', email: 'demo@example.com' }], from: [USER],
to: [{ name: 'Sarah Kim', email: 'sarah.kim@example.com' }], to: [{ name: 'Sarah Kim', email: 'sarah.kim@example.com' }],
subject: 'Re: Design Feedback', subject: 'Re: Design Feedback',
sentAt: demoDate(-4, -2), sentAt: demoDate(-4, -2),
preview: 'Thanks Sarah! The new color scheme looks great. I especially like the contrast improvements...', preview: 'Thanks Sarah! The new color scheme looks great. I especially like the contrast improvements...',
hasAttachment: false, hasAttachment: false,
textBody: [{ partId: '1', blobId: 'blob-12', size: 250, type: 'text/plain' }], ...textOnly(
bodyValues: { "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",
'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' }, ),
},
messageId: '<sent-2@example.com>', 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 ────────────────────────────────────────────────── // ── Drafts ──────────────────────────────────────────────────
{ {
@@ -203,18 +577,35 @@ export function createDemoEmails(): Email[] {
keywords: { $seen: true, $draft: true }, keywords: { $seen: true, $draft: true },
size: 900, size: 900,
receivedAt: demoDate(0, -1), receivedAt: demoDate(0, -1),
from: [{ name: 'Demo User', email: 'demo@example.com' }], from: [USER],
to: [{ name: 'Bob Chen', email: 'bob.chen@example.com' }], to: [{ name: 'Bob Chen', email: 'bob.chen@example.com' }],
subject: 'Meeting Notes - Draft', subject: 'Meeting Notes - Draft',
sentAt: demoDate(0, -1), sentAt: demoDate(0, -1),
preview: 'Here are the notes from today\'s standup...', preview: "Here are the notes from today's standup...",
hasAttachment: false, hasAttachment: false,
textBody: [{ partId: '1', blobId: 'blob-13', size: 180, type: 'text/plain' }], ...textOnly(
bodyValues: { "Here are the notes from today's standup:\n\n- API integration on track\n- Need to resolve the caching issue\n- ",
'1': { value: '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>', 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 ─────────────────────────────────────────────────── // ── Trash ───────────────────────────────────────────────────
{ {
@@ -225,15 +616,14 @@ export function createDemoEmails(): Email[] {
size: 15200, size: 15200,
receivedAt: demoDate(-5, -3), receivedAt: demoDate(-5, -3),
from: [{ name: 'Promo Store', email: 'deals@promostore.example' }], from: [{ name: 'Promo Store', email: 'deals@promostore.example' }],
to: [{ name: 'Demo User', email: 'demo@example.com' }], to: [USER],
subject: '🎉 Flash Sale: 50% Off Everything!', subject: '🎉 Flash Sale: 50% Off Everything!',
sentAt: demoDate(-5, -3), sentAt: demoDate(-5, -3),
preview: 'Limited time offer! Get 50% off all items in our store...', preview: 'Limited time offer! Get 50% off all items in our store...',
hasAttachment: false, hasAttachment: false,
textBody: [{ partId: '1', blobId: 'blob-14', size: 400, type: 'text/plain' }], ...textOnly(
bodyValues: { 'Limited time offer! Get 50% off all items in our store. Use code FLASH50 at checkout.',
'1': { value: 'Limited time offer! Get 50% off all items in our store. Use code FLASH50 at checkout.' }, ),
},
messageId: '<promo-1@promostore.example>', messageId: '<promo-1@promostore.example>',
}, },
{ {
@@ -244,15 +634,14 @@ export function createDemoEmails(): Email[] {
size: 2300, size: 2300,
receivedAt: demoDate(-7, 0), receivedAt: demoDate(-7, 0),
from: [{ name: 'System Notification', email: 'noreply@service.example' }], from: [{ name: 'System Notification', email: 'noreply@service.example' }],
to: [{ name: 'Demo User', email: 'demo@example.com' }], to: [USER],
subject: 'Your password was changed', subject: 'Your password was changed',
sentAt: demoDate(-7, 0), sentAt: demoDate(-7, 0),
preview: 'Your account password was successfully changed on...', preview: 'Your account password was successfully changed on...',
hasAttachment: false, hasAttachment: false,
textBody: [{ partId: '1', blobId: 'blob-15', size: 200, type: 'text/plain' }], ...textOnly(
bodyValues: { 'Your account password was successfully changed. If you did not make this change, please contact support immediately.',
'1': { value: 'Your account password was successfully changed. If you did not make this change, please contact support immediately.' }, ),
},
messageId: '<notification-1@service.example>', messageId: '<notification-1@service.example>',
}, },
@@ -265,15 +654,14 @@ export function createDemoEmails(): Email[] {
size: 4500, size: 4500,
receivedAt: demoDate(-2, -7), receivedAt: demoDate(-2, -7),
from: [{ name: 'Alice Johnson', email: 'alice.johnson@example.com' }], from: [{ name: 'Alice Johnson', email: 'alice.johnson@example.com' }],
to: [{ name: 'Demo User', email: 'demo@example.com' }], to: [USER],
subject: '[Project] Sprint Planning Agenda', subject: '[Project] Sprint Planning Agenda',
sentAt: demoDate(-2, -7), 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, hasAttachment: false,
textBody: [{ partId: '1', blobId: 'blob-16', size: 600, type: 'text/plain' }], ...textOnly(
bodyValues: { "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",
'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' }, ),
},
messageId: '<project-1@example.com>', messageId: '<project-1@example.com>',
}, },
{ {
@@ -284,17 +672,37 @@ export function createDemoEmails(): Email[] {
size: 3200, size: 3200,
receivedAt: demoDate(0, -8), receivedAt: demoDate(0, -8),
from: [{ name: 'Bob Chen', email: 'bob.chen@example.com' }], 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', subject: '[Project] API Rate Limiting Discussion',
sentAt: demoDate(0, -8), 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, hasAttachment: false,
textBody: [{ partId: '1', blobId: 'blob-17', size: 480, type: 'text/plain' }], ...textOnly(
bodyValues: { "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",
'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' }, ),
},
messageId: '<project-2@example.com>', 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 ───────────────────────────────────────────────── // ── Archive ─────────────────────────────────────────────────
{ {
@@ -304,18 +712,35 @@ export function createDemoEmails(): Email[] {
keywords: { $seen: true }, keywords: { $seen: true },
size: 2600, size: 2600,
receivedAt: demoDate(-14, -6), receivedAt: demoDate(-14, -6),
from: [{ name: 'HR Department', email: 'hr@company.example' }], from: [{ name: 'Maria Lopez', email: 'maria.lopez@company.example' }],
to: [{ name: 'Demo User', email: 'demo@example.com' }], to: [USER],
subject: 'Updated PTO Policy - Effective January 1', subject: 'Updated PTO Policy - Effective January 1',
sentAt: demoDate(-14, -6), sentAt: demoDate(-14, -6),
preview: 'Please review the updated PTO policy that takes effect January 1st...', preview: 'Please review the updated PTO policy that takes effect January 1st...',
hasAttachment: false, hasAttachment: false,
textBody: [{ partId: '1', blobId: 'blob-18', size: 380, type: 'text/plain' }], ...textOnly(
bodyValues: { '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',
'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' }, ),
},
messageId: '<hr-policy-1@company.example>', 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 ──────────────────────────────────────────────── // ── Receipts ────────────────────────────────────────────────
{ {
@@ -325,17 +750,37 @@ export function createDemoEmails(): Email[] {
keywords: { $seen: true }, keywords: { $seen: true },
size: 5200, size: 5200,
receivedAt: demoDate(-3, -12), receivedAt: demoDate(-3, -12),
from: [{ name: 'Cloud Services', email: 'billing@cloudprovider.example' }], from: [{ name: 'Hetzner', email: 'billing@hetzner.com' }],
to: [{ name: 'Demo User', email: 'demo@example.com' }], to: [USER],
subject: 'Payment Receipt - Invoice #INV-2024-1042', subject: 'Invoice #INV-2024-1042 - €49.99 (paid)',
sentAt: demoDate(-3, -12), 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, hasAttachment: false,
textBody: [{ partId: '1', blobId: 'blob-19', size: 350, type: 'text/plain' }], ...textOnly(
bodyValues: { "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",
'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: '<renewal-example.com@porkbun.com>',
messageId: '<receipt-1@cloudprovider.example>',
}, },
// ── Spam ──────────────────────────────────────────────────── // ── Spam ────────────────────────────────────────────────────
@@ -347,16 +792,51 @@ export function createDemoEmails(): Email[] {
size: 8900, size: 8900,
receivedAt: demoDate(-1, -9), receivedAt: demoDate(-1, -9),
from: [{ name: 'Prize Center', email: 'winner@totallylegit.example' }], 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!!!', subject: 'Congratulations! You Won $1,000,000!!!',
sentAt: demoDate(-1, -9), sentAt: demoDate(-1, -9),
preview: 'Dear lucky winner, you have been selected to receive one million dollars...', preview: 'Dear lucky winner, you have been selected to receive one million dollars...',
hasAttachment: false, hasAttachment: false,
textBody: [{ partId: '1', blobId: 'blob-20', size: 500, type: 'text/plain' }], ...textOnly(
bodyValues: { '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]',
'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]' }, ),
},
messageId: '<spam-1@totallylegit.example>', 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_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 }; const RIGHTS_CUSTOM = { ...RIGHTS_SYSTEM, mayRename: true, mayDelete: true };
// Counts must stay in sync with createDemoEmails() in fixtures/emails.ts.
export function createDemoMailboxes(): Mailbox[] { export function createDemoMailboxes(): Mailbox[] {
return [ 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-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: 8, unreadEmails: 0, totalThreads: 8, unreadThreads: 0, 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: 1, unreadEmails: 0, totalThreads: 1, 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-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-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: 1, totalThreads: 3, unreadThreads: 1, 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: 5, unreadEmails: 2, totalThreads: 5, unreadThreads: 2, myRights: RIGHTS_CUSTOM, 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: 3, unreadEmails: 0, totalThreads: 3, unreadThreads: 0, 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 },
]; ];
} }
+21 -6
View File
@@ -58,24 +58,39 @@ export function sanitizeEmailHtmlForIframe(html: string): string {
/** /**
* Sanitize HTML signature with stricter rules * 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 = { export const SIGNATURE_SANITIZE_CONFIG = {
ALLOWED_TAGS: ['p', 'br', 'b', 'strong', 'i', 'em', 'u', 'a', 'span', 'div'], ALLOWED_TAGS: ['p', 'br', 'b', 'strong', 'i', 'em', 'u', 'a', 'span', 'div', 'img'],
ALLOWED_ATTR: ['href', 'style', 'class'], ALLOWED_ATTR: ['href', 'style', 'class', 'src', 'alt', 'width', 'height', 'title'],
ALLOW_DATA_ATTR: false, 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'], 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 * @param html - User-provided HTML signature
* @returns Sanitized signature (no scripts, no external resources) * @returns Sanitized signature (no scripts, no external resources)
*/ */
export function sanitizeSignatureHtml(html: string): string { export function sanitizeSignatureHtml(html: string): string {
if (!html?.trim()) return ''; 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();
}
} }
/** /**
+9 -6
View File
@@ -1,13 +1,16 @@
const COOKIE_SAME_SITE = (process.env.COOKIE_SAME_SITE || 'lax') as 'lax' | 'none' | 'strict'; import { configManager } from '@/lib/admin/config-manager';
const COOKIE_SECURE = process.env.COOKIE_SECURE !== undefined
? process.env.COOKIE_SECURE === 'true' type SameSite = 'lax' | 'none' | 'strict';
: (COOKIE_SAME_SITE === 'none' || process.env.NODE_ENV === 'production');
export function getCookieOptions() { 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 { return {
httpOnly: true, httpOnly: true,
secure: COOKIE_SECURE, secure,
sameSite: COOKIE_SAME_SITE, sameSite,
path: '/', path: '/',
maxAge: 30 * 24 * 60 * 60, maxAge: 30 * 24 * 60 * 60,
}; };
+1 -1
View File
@@ -73,7 +73,7 @@ export interface ReplyFromResolution {
/** /**
* Override for the outgoing `From:` header. Populated when the incoming * Override for the outgoing `From:` header. Populated when the incoming
* message was delivered to an address on a domain the user owns (by * 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 * domain-catch-all deployments. When set, the composer should put this
* address (and `overrideName`) in the message's From header while sending * address (and `overrideName`) in the message's From header while sending
* through the chosen identity. * through the chosen identity.
+3 -1
View File
@@ -565,7 +565,7 @@
"from_override": { "from_override": {
"toggle_off": "Přepsat", "toggle_off": "Přepsat",
"toggle_on": "Zrušit přepsání", "toggle_on": "Zrušit přepsání",
"toggle_tooltip": "Volně upravujte jméno a adresu odesílatele. Pošta se stále odesílá přes vaši identitu mění se pouze viditelné záhlaví Od.", "toggle_tooltip": "Volně upravujte jméno a adresu odesílatele. Pošta se stále odesílá přes vaši identitu - mění se pouze viditelné záhlaví Od.",
"name_label": "Jméno odesílatele", "name_label": "Jméno odesílatele",
"name_placeholder": "Jméno", "name_placeholder": "Jméno",
"email_label": "E-mailová adresa odesílatele", "email_label": "E-mailová adresa odesílatele",
@@ -2844,6 +2844,8 @@
"restart_title": "Úvodní průvodce", "restart_title": "Úvodní průvodce",
"restart_desc": "Přehrát průvodce rozhraním krok za krokem", "restart_desc": "Přehrát průvodce rozhraním krok za krokem",
"restart_button": "Spustit průvodce znovu", "restart_button": "Spustit průvodce znovu",
"show_on_new_devices_title": "Zobrazit na nových zařízeních",
"show_on_new_devices_desc": "Přehrát uvítací banner a průvodce při prvním přihlášení na novém zařízení, i když jste je již dokončili jinde",
"sidebar_title": "Vaše poštovní schránky", "sidebar_title": "Vaše poštovní schránky",
"sidebar_desc": "Toto je postranní panel se složkami. Kliknutím na libovolnou schránku zobrazíte její zprávy. Můžete vytvářet složky, přetahovat zprávy mezi nimi a okamžitě vidět počet nepřečtených e-mailů.", "sidebar_desc": "Toto je postranní panel se složkami. Kliknutím na libovolnou schránku zobrazíte její zprávy. Můžete vytvářet složky, přetahovat zprávy mezi nimi a okamžitě vidět počet nepřečtených e-mailů.",
"compose_title": "Napsat zprávu", "compose_title": "Napsat zprávu",
File diff suppressed because it is too large Load Diff
+3 -1
View File
@@ -565,7 +565,7 @@
"from_override": { "from_override": {
"toggle_off": "Überschreiben", "toggle_off": "Überschreiben",
"toggle_on": "Überschreibung aufheben", "toggle_on": "Überschreibung aufheben",
"toggle_tooltip": "Bearbeiten Sie Absendername und -adresse frei. Die E-Mail wird weiterhin über Ihre Identität gesendet nur die sichtbare Absenderkopfzeile ändert sich.", "toggle_tooltip": "Bearbeiten Sie Absendername und -adresse frei. Die E-Mail wird weiterhin über Ihre Identität gesendet - nur die sichtbare Absenderkopfzeile ändert sich.",
"name_label": "Absendername", "name_label": "Absendername",
"name_placeholder": "Name", "name_placeholder": "Name",
"email_label": "Absender-E-Mail-Adresse", "email_label": "Absender-E-Mail-Adresse",
@@ -2844,6 +2844,8 @@
"restart_title": "Einführungstour", "restart_title": "Einführungstour",
"restart_desc": "Geführte Tour durch die Oberfläche erneut abspielen", "restart_desc": "Geführte Tour durch die Oberfläche erneut abspielen",
"restart_button": "Tour neu starten", "restart_button": "Tour neu starten",
"show_on_new_devices_title": "Auf neuen Geräten anzeigen",
"show_on_new_devices_desc": "Zeige das Willkommensbanner und die Tour beim ersten Anmelden auf einem neuen Gerät erneut, auch wenn du sie bereits anderswo abgeschlossen hast",
"sidebar_title": "Ihre Postfächer", "sidebar_title": "Ihre Postfächer",
"sidebar_desc": "Dies ist Ihre Ordner-Seitenleiste. Klicken Sie auf ein Postfach, um seine E-Mails anzuzeigen. Sie können Ordner erstellen, E-Mails zwischen ihnen verschieben und ungelesene Zähler auf einen Blick sehen.", "sidebar_desc": "Dies ist Ihre Ordner-Seitenleiste. Klicken Sie auf ein Postfach, um seine E-Mails anzuzeigen. Sie können Ordner erstellen, E-Mails zwischen ihnen verschieben und ungelesene Zähler auf einen Blick sehen.",
"compose_title": "E-Mail verfassen", "compose_title": "E-Mail verfassen",
+3 -1
View File
@@ -568,7 +568,7 @@
"from_override": { "from_override": {
"toggle_off": "Override", "toggle_off": "Override",
"toggle_on": "Cancel override", "toggle_on": "Cancel override",
"toggle_tooltip": "Edit the From name and address freely. Mail is still sent through your identity only the visible From header changes.", "toggle_tooltip": "Edit the From name and address freely. Mail is still sent through your identity - only the visible From header changes.",
"name_label": "From name", "name_label": "From name",
"name_placeholder": "Name", "name_placeholder": "Name",
"email_label": "From email address", "email_label": "From email address",
@@ -2867,6 +2867,8 @@
"restart_title": "Introductory tour", "restart_title": "Introductory tour",
"restart_desc": "Replay the guided walkthrough of the interface", "restart_desc": "Replay the guided walkthrough of the interface",
"restart_button": "Restart tour", "restart_button": "Restart tour",
"show_on_new_devices_title": "Show on new devices",
"show_on_new_devices_desc": "Replay the welcome banner and tour the first time you sign in on a new device, even if you've already completed them elsewhere",
"sidebar_title": "Your mailboxes", "sidebar_title": "Your mailboxes",
"sidebar_desc": "This is your folder sidebar. Click any mailbox to view its emails. You can create folders, drag emails between them, and see unread counts at a glance.", "sidebar_desc": "This is your folder sidebar. Click any mailbox to view its emails. You can create folders, drag emails between them, and see unread counts at a glance.",
"compose_title": "Compose an email", "compose_title": "Compose an email",
+3 -1
View File
@@ -565,7 +565,7 @@
"from_override": { "from_override": {
"toggle_off": "Anular", "toggle_off": "Anular",
"toggle_on": "Cancelar anulación", "toggle_on": "Cancelar anulación",
"toggle_tooltip": "Edita libremente el nombre y la dirección del remitente. El correo aún se envía a través de tu identidad solo cambia el encabezado De visible.", "toggle_tooltip": "Edita libremente el nombre y la dirección del remitente. El correo aún se envía a través de tu identidad - solo cambia el encabezado De visible.",
"name_label": "Nombre del remitente", "name_label": "Nombre del remitente",
"name_placeholder": "Nombre", "name_placeholder": "Nombre",
"email_label": "Dirección de correo del remitente", "email_label": "Dirección de correo del remitente",
@@ -2844,6 +2844,8 @@
"restart_title": "Tour introductorio", "restart_title": "Tour introductorio",
"restart_desc": "Repetir el recorrido guiado por la interfaz", "restart_desc": "Repetir el recorrido guiado por la interfaz",
"restart_button": "Reiniciar tour", "restart_button": "Reiniciar tour",
"show_on_new_devices_title": "Mostrar en dispositivos nuevos",
"show_on_new_devices_desc": "Vuelve a mostrar el banner de bienvenida y el tour la primera vez que inicies sesión en un dispositivo nuevo, incluso si ya los completaste en otro lugar",
"sidebar_title": "Tus buzones", "sidebar_title": "Tus buzones",
"sidebar_desc": "Esta es tu barra lateral de carpetas. Haz clic en cualquier buzón para ver sus correos. Puedes crear carpetas, arrastrar correos entre ellas y ver los contadores de no leídos.", "sidebar_desc": "Esta es tu barra lateral de carpetas. Haz clic en cualquier buzón para ver sus correos. Puedes crear carpetas, arrastrar correos entre ellas y ver los contadores de no leídos.",
"compose_title": "Redactar un correo", "compose_title": "Redactar un correo",
+3 -1
View File
@@ -565,7 +565,7 @@
"from_override": { "from_override": {
"toggle_off": "Remplacer", "toggle_off": "Remplacer",
"toggle_on": "Annuler le remplacement", "toggle_on": "Annuler le remplacement",
"toggle_tooltip": "Modifiez librement le nom et l'adresse d'expéditeur. Le courrier est toujours envoyé via votre identité seul l'en-tête De visible change.", "toggle_tooltip": "Modifiez librement le nom et l'adresse d'expéditeur. Le courrier est toujours envoyé via votre identité - seul l'en-tête De visible change.",
"name_label": "Nom de l'expéditeur", "name_label": "Nom de l'expéditeur",
"name_placeholder": "Nom", "name_placeholder": "Nom",
"email_label": "Adresse e-mail de l'expéditeur", "email_label": "Adresse e-mail de l'expéditeur",
@@ -2844,6 +2844,8 @@
"restart_title": "Visite d'introduction", "restart_title": "Visite d'introduction",
"restart_desc": "Rejouer la visite guidée de l'interface", "restart_desc": "Rejouer la visite guidée de l'interface",
"restart_button": "Relancer la visite", "restart_button": "Relancer la visite",
"show_on_new_devices_title": "Afficher sur les nouveaux appareils",
"show_on_new_devices_desc": "Rejouer la bannière d'accueil et la visite lors de votre première connexion sur un nouvel appareil, même si vous les avez déjà terminées ailleurs",
"sidebar_title": "Vos boîtes mail", "sidebar_title": "Vos boîtes mail",
"sidebar_desc": "Voici votre barre latérale de dossiers. Cliquez sur une boîte pour voir ses emails. Vous pouvez créer des dossiers, glisser des emails entre eux et voir les compteurs de non lus.", "sidebar_desc": "Voici votre barre latérale de dossiers. Cliquez sur une boîte pour voir ses emails. Vous pouvez créer des dossiers, glisser des emails entre eux et voir les compteurs de non lus.",
"compose_title": "Rédiger un email", "compose_title": "Rédiger un email",
+3 -1
View File
@@ -565,7 +565,7 @@
"from_override": { "from_override": {
"toggle_off": "Sovrascrivi", "toggle_off": "Sovrascrivi",
"toggle_on": "Annulla sovrascrittura", "toggle_on": "Annulla sovrascrittura",
"toggle_tooltip": "Modifica liberamente nome e indirizzo del mittente. La posta viene comunque inviata tramite la tua identità cambia solo l'intestazione Da visibile.", "toggle_tooltip": "Modifica liberamente nome e indirizzo del mittente. La posta viene comunque inviata tramite la tua identità - cambia solo l'intestazione Da visibile.",
"name_label": "Nome mittente", "name_label": "Nome mittente",
"name_placeholder": "Nome", "name_placeholder": "Nome",
"email_label": "Indirizzo email del mittente", "email_label": "Indirizzo email del mittente",
@@ -2844,6 +2844,8 @@
"restart_title": "Tour introduttivo", "restart_title": "Tour introduttivo",
"restart_desc": "Rivedi la guida dell'interfaccia", "restart_desc": "Rivedi la guida dell'interfaccia",
"restart_button": "Riavvia il tour", "restart_button": "Riavvia il tour",
"show_on_new_devices_title": "Mostra sui nuovi dispositivi",
"show_on_new_devices_desc": "Rivedi il banner di benvenuto e il tour al primo accesso su un nuovo dispositivo, anche se li hai già completati altrove",
"sidebar_title": "Le tue caselle di posta", "sidebar_title": "Le tue caselle di posta",
"sidebar_desc": "Questa è la barra laterale delle cartelle. Clicca su una casella per vedere le email. Puoi creare cartelle, trascinare email tra loro e vedere i conteggi dei non letti.", "sidebar_desc": "Questa è la barra laterale delle cartelle. Clicca su una casella per vedere le email. Puoi creare cartelle, trascinare email tra loro e vedere i conteggi dei non letti.",
"compose_title": "Scrivi un'email", "compose_title": "Scrivi un'email",
+3 -1
View File
@@ -565,7 +565,7 @@
"from_override": { "from_override": {
"toggle_off": "上書き", "toggle_off": "上書き",
"toggle_on": "上書きを取り消す", "toggle_on": "上書きを取り消す",
"toggle_tooltip": "差出人名とアドレスを自由に編集できます。メールは引き続きあなたのアイデンティティ経由で送信されます 表示される差出人ヘッダーのみが変更されます。", "toggle_tooltip": "差出人名とアドレスを自由に編集できます。メールは引き続きあなたのアイデンティティ経由で送信されます - 表示される差出人ヘッダーのみが変更されます。",
"name_label": "差出人名", "name_label": "差出人名",
"name_placeholder": "名前", "name_placeholder": "名前",
"email_label": "差出人メールアドレス", "email_label": "差出人メールアドレス",
@@ -2844,6 +2844,8 @@
"restart_title": "紹介ツアー", "restart_title": "紹介ツアー",
"restart_desc": "インターフェースのガイドツアーを再生する", "restart_desc": "インターフェースのガイドツアーを再生する",
"restart_button": "ツアーを再開", "restart_button": "ツアーを再開",
"show_on_new_devices_title": "新しいデバイスで表示",
"show_on_new_devices_desc": "他のデバイスで完了済みでも、新しいデバイスで初めてサインインしたときにウェルカムバナーとツアーを再表示します",
"sidebar_title": "メールボックス", "sidebar_title": "メールボックス",
"sidebar_desc": "フォルダーサイドバーです。メールボックスをクリックしてメールを表示できます。フォルダーの作成、メールのドラッグ移動、未読数の確認ができます。", "sidebar_desc": "フォルダーサイドバーです。メールボックスをクリックしてメールを表示できます。フォルダーの作成、メールのドラッグ移動、未読数の確認ができます。",
"compose_title": "メールを作成", "compose_title": "メールを作成",
+3 -1
View File
@@ -565,7 +565,7 @@
"from_override": { "from_override": {
"toggle_off": "재정의", "toggle_off": "재정의",
"toggle_on": "재정의 취소", "toggle_on": "재정의 취소",
"toggle_tooltip": "보낸 사람 이름과 주소를 자유롭게 편집하세요. 메일은 여전히 사용자의 ID를 통해 전송되며 표시되는 보낸 사람 헤더만 변경됩니다.", "toggle_tooltip": "보낸 사람 이름과 주소를 자유롭게 편집하세요. 메일은 여전히 사용자의 ID를 통해 전송되며 - 표시되는 보낸 사람 헤더만 변경됩니다.",
"name_label": "보낸 사람 이름", "name_label": "보낸 사람 이름",
"name_placeholder": "이름", "name_placeholder": "이름",
"email_label": "보낸 사람 이메일 주소", "email_label": "보낸 사람 이메일 주소",
@@ -2844,6 +2844,8 @@
"restart_title": "소개 투어", "restart_title": "소개 투어",
"restart_desc": "인터페이스를 설명해 주는 투어를 다시 시작해요", "restart_desc": "인터페이스를 설명해 주는 투어를 다시 시작해요",
"restart_button": "투어 다시 시작", "restart_button": "투어 다시 시작",
"show_on_new_devices_title": "새 기기에서 표시",
"show_on_new_devices_desc": "다른 곳에서 이미 완료했더라도 새 기기에 처음 로그인할 때 환영 배너와 투어를 다시 표시해요",
"sidebar_title": "편지함", "sidebar_title": "편지함",
"sidebar_desc": "여기는 폴더 사이드바예요. 폴더를 클릭하면 그 안의 메일을 볼 수 있어요. 폴더를 만들거나, 메일을 드래그해서 옮길 수 있고 안 읽은 메일 개수도 한눈에 확인돼요.", "sidebar_desc": "여기는 폴더 사이드바예요. 폴더를 클릭하면 그 안의 메일을 볼 수 있어요. 폴더를 만들거나, 메일을 드래그해서 옮길 수 있고 안 읽은 메일 개수도 한눈에 확인돼요.",
"compose_title": "메일 쓰기", "compose_title": "메일 쓰기",
+3 -1
View File
@@ -565,7 +565,7 @@
"from_override": { "from_override": {
"toggle_off": "Pārrakstīt", "toggle_off": "Pārrakstīt",
"toggle_on": "Atcelt pārrakstīšanu", "toggle_on": "Atcelt pārrakstīšanu",
"toggle_tooltip": "Brīvi rediģējiet sūtītāja vārdu un adresi. Pasts joprojām tiek sūtīts caur jūsu identitāti mainās tikai redzamais No galvenes ieraksts.", "toggle_tooltip": "Brīvi rediģējiet sūtītāja vārdu un adresi. Pasts joprojām tiek sūtīts caur jūsu identitāti - mainās tikai redzamais No galvenes ieraksts.",
"name_label": "Sūtītāja vārds", "name_label": "Sūtītāja vārds",
"name_placeholder": "Vārds", "name_placeholder": "Vārds",
"email_label": "Sūtītāja e-pasta adrese", "email_label": "Sūtītāja e-pasta adrese",
@@ -2844,6 +2844,8 @@
"restart_title": "Iepazīšanās ekskursija", "restart_title": "Iepazīšanās ekskursija",
"restart_desc": "Atkārtot soli pa solim pamācību par saskarni", "restart_desc": "Atkārtot soli pa solim pamācību par saskarni",
"restart_button": "Restartēt ekskursiju", "restart_button": "Restartēt ekskursiju",
"show_on_new_devices_title": "Rādīt jaunās ierīcēs",
"show_on_new_devices_desc": "Atkārtot sveiciena reklāmkarogu un ekskursiju, pirmoreiz pierakstoties jaunā ierīcē, pat ja esat tos jau pabeidzis citur",
"sidebar_title": "Jūsu pastkastes", "sidebar_title": "Jūsu pastkastes",
"sidebar_desc": "Šī ir sānu josla ar mapēm. Noklikšķiniet uz jebkuras pastkastes, lai skatītu vēstules. Varat izveidot mapes un pārvietot vēstules.", "sidebar_desc": "Šī ir sānu josla ar mapēm. Noklikšķiniet uz jebkuras pastkastes, lai skatītu vēstules. Varat izveidot mapes un pārvietot vēstules.",
"compose_title": "Rakstīt vēstuli", "compose_title": "Rakstīt vēstuli",
+3 -1
View File
@@ -565,7 +565,7 @@
"from_override": { "from_override": {
"toggle_off": "Overschrijven", "toggle_off": "Overschrijven",
"toggle_on": "Overschrijven annuleren", "toggle_on": "Overschrijven annuleren",
"toggle_tooltip": "Bewerk de naam en het adres van de afzender vrij. E-mail wordt nog steeds via je identiteit verzonden alleen de zichtbare Van-koptekst verandert.", "toggle_tooltip": "Bewerk de naam en het adres van de afzender vrij. E-mail wordt nog steeds via je identiteit verzonden - alleen de zichtbare Van-koptekst verandert.",
"name_label": "Afzendernaam", "name_label": "Afzendernaam",
"name_placeholder": "Naam", "name_placeholder": "Naam",
"email_label": "E-mailadres afzender", "email_label": "E-mailadres afzender",
@@ -2844,6 +2844,8 @@
"restart_title": "Introductietour", "restart_title": "Introductietour",
"restart_desc": "Bekijk de rondleiding door de interface opnieuw", "restart_desc": "Bekijk de rondleiding door de interface opnieuw",
"restart_button": "Tour herstarten", "restart_button": "Tour herstarten",
"show_on_new_devices_title": "Tonen op nieuwe apparaten",
"show_on_new_devices_desc": "Herhaal de welkomstbanner en de rondleiding wanneer je voor het eerst inlogt op een nieuw apparaat, zelfs als je ze elders al hebt voltooid",
"sidebar_title": "Uw mailboxen", "sidebar_title": "Uw mailboxen",
"sidebar_desc": "Dit is uw mappenbalk. Klik op een mailbox om de e-mails te bekijken. U kunt mappen maken, e-mails tussen mappen slepen en ongelezen aantallen zien.", "sidebar_desc": "Dit is uw mappenbalk. Klik op een mailbox om de e-mails te bekijken. U kunt mappen maken, e-mails tussen mappen slepen en ongelezen aantallen zien.",
"compose_title": "E-mail schrijven", "compose_title": "E-mail schrijven",
+3 -1
View File
@@ -565,7 +565,7 @@
"from_override": { "from_override": {
"toggle_off": "Zastąp", "toggle_off": "Zastąp",
"toggle_on": "Anuluj zastąpienie", "toggle_on": "Anuluj zastąpienie",
"toggle_tooltip": "Swobodnie edytuj nazwę i adres nadawcy. Poczta jest nadal wysyłana przez twoją tożsamość zmienia się tylko widoczny nagłówek Od.", "toggle_tooltip": "Swobodnie edytuj nazwę i adres nadawcy. Poczta jest nadal wysyłana przez twoją tożsamość - zmienia się tylko widoczny nagłówek Od.",
"name_label": "Nazwa nadawcy", "name_label": "Nazwa nadawcy",
"name_placeholder": "Nazwa", "name_placeholder": "Nazwa",
"email_label": "Adres e-mail nadawcy", "email_label": "Adres e-mail nadawcy",
@@ -2844,6 +2844,8 @@
"restart_title": "Przewodnik wprowadzający", "restart_title": "Przewodnik wprowadzający",
"restart_desc": "Odtwórz przewodnik po interfejsie krok po kroku", "restart_desc": "Odtwórz przewodnik po interfejsie krok po kroku",
"restart_button": "Uruchom przewodnik ponownie", "restart_button": "Uruchom przewodnik ponownie",
"show_on_new_devices_title": "Pokaż na nowych urządzeniach",
"show_on_new_devices_desc": "Wyświetl ponownie baner powitalny i przewodnik przy pierwszym logowaniu na nowym urządzeniu, nawet jeśli zostały już ukończone w innym miejscu",
"sidebar_title": "Twoje skrzynki pocztowe", "sidebar_title": "Twoje skrzynki pocztowe",
"sidebar_desc": "To jest pasek boczny z folderami. Kliknij dowolną skrzynkę, aby zobaczyć jej wiadomości. Możesz tworzyć foldery, przeciągać między nimi wiadomości i od razu widzieć liczbę nieprzeczytanych.", "sidebar_desc": "To jest pasek boczny z folderami. Kliknij dowolną skrzynkę, aby zobaczyć jej wiadomości. Możesz tworzyć foldery, przeciągać między nimi wiadomości i od razu widzieć liczbę nieprzeczytanych.",
"compose_title": "Napisz wiadomość", "compose_title": "Napisz wiadomość",
+3 -1
View File
@@ -565,7 +565,7 @@
"from_override": { "from_override": {
"toggle_off": "Substituir", "toggle_off": "Substituir",
"toggle_on": "Cancelar substituição", "toggle_on": "Cancelar substituição",
"toggle_tooltip": "Edite livremente o nome e o endereço do remetente. O email ainda é enviado através da sua identidade apenas o cabeçalho De visível muda.", "toggle_tooltip": "Edite livremente o nome e o endereço do remetente. O email ainda é enviado através da sua identidade - apenas o cabeçalho De visível muda.",
"name_label": "Nome do remetente", "name_label": "Nome do remetente",
"name_placeholder": "Nome", "name_placeholder": "Nome",
"email_label": "Endereço de email do remetente", "email_label": "Endereço de email do remetente",
@@ -2844,6 +2844,8 @@
"restart_title": "Tour introdutório", "restart_title": "Tour introdutório",
"restart_desc": "Rever o tour guiado da interface", "restart_desc": "Rever o tour guiado da interface",
"restart_button": "Reiniciar tour", "restart_button": "Reiniciar tour",
"show_on_new_devices_title": "Mostrar em novos dispositivos",
"show_on_new_devices_desc": "Reproduzir o banner de boas-vindas e o tour no primeiro login num novo dispositivo, mesmo que já os tenhas concluído noutro lado",
"sidebar_title": "Suas caixas de correio", "sidebar_title": "Suas caixas de correio",
"sidebar_desc": "Esta é a barra lateral de pastas. Clique em qualquer caixa para ver seus e-mails. Você pode criar pastas, arrastar e-mails entre elas e ver contadores de não lidos.", "sidebar_desc": "Esta é a barra lateral de pastas. Clique em qualquer caixa para ver seus e-mails. Você pode criar pastas, arrastar e-mails entre elas e ver contadores de não lidos.",
"compose_title": "Escrever um e-mail", "compose_title": "Escrever um e-mail",
+3 -1
View File
@@ -565,7 +565,7 @@
"from_override": { "from_override": {
"toggle_off": "Переопределить", "toggle_off": "Переопределить",
"toggle_on": "Отменить переопределение", "toggle_on": "Отменить переопределение",
"toggle_tooltip": "Свободно редактируйте имя и адрес отправителя. Письмо по-прежнему отправляется через вашу учётную запись меняется только видимый заголовок От.", "toggle_tooltip": "Свободно редактируйте имя и адрес отправителя. Письмо по-прежнему отправляется через вашу учётную запись - меняется только видимый заголовок От.",
"name_label": "Имя отправителя", "name_label": "Имя отправителя",
"name_placeholder": "Имя", "name_placeholder": "Имя",
"email_label": "Email отправителя", "email_label": "Email отправителя",
@@ -2844,6 +2844,8 @@
"restart_title": "Ознакомительный тур", "restart_title": "Ознакомительный тур",
"restart_desc": "Повторить пошаговое руководство по интерфейсу", "restart_desc": "Повторить пошаговое руководство по интерфейсу",
"restart_button": "Перезапустить тур", "restart_button": "Перезапустить тур",
"show_on_new_devices_title": "Показывать на новых устройствах",
"show_on_new_devices_desc": "Повторить приветственный баннер и тур при первом входе на новом устройстве, даже если вы уже завершили их в другом месте",
"sidebar_title": "Ваши почтовые ящики", "sidebar_title": "Ваши почтовые ящики",
"sidebar_desc": "Это боковая панель с папками. Нажмите на любой почтовый ящик для просмотра писем. Вы можете создавать папки, перетаскивать письма между ними и видеть количество непрочитанных.", "sidebar_desc": "Это боковая панель с папками. Нажмите на любой почтовый ящик для просмотра писем. Вы можете создавать папки, перетаскивать письма между ними и видеть количество непрочитанных.",
"compose_title": "Написать письмо", "compose_title": "Написать письмо",
+3 -1
View File
@@ -568,7 +568,7 @@
"from_override": { "from_override": {
"toggle_off": "Geçersiz kıl", "toggle_off": "Geçersiz kıl",
"toggle_on": "Geçersiz kılmayı iptal et", "toggle_on": "Geçersiz kılmayı iptal et",
"toggle_tooltip": "Gönderen adını ve adresini serbestçe düzenleyin. Posta hâlâ kimliğiniz üzerinden gönderilir yalnızca görünür Kimden başlığı değişir.", "toggle_tooltip": "Gönderen adını ve adresini serbestçe düzenleyin. Posta hâlâ kimliğiniz üzerinden gönderilir - yalnızca görünür Kimden başlığı değişir.",
"name_label": "Gönderen adı", "name_label": "Gönderen adı",
"name_placeholder": "Ad", "name_placeholder": "Ad",
"email_label": "Gönderen e-posta adresi", "email_label": "Gönderen e-posta adresi",
@@ -2867,6 +2867,8 @@
"restart_title": "Tanıtım turu", "restart_title": "Tanıtım turu",
"restart_desc": "Arayüzün rehberli gezintisini tekrar oynat", "restart_desc": "Arayüzün rehberli gezintisini tekrar oynat",
"restart_button": "Turu yeniden başlat", "restart_button": "Turu yeniden başlat",
"show_on_new_devices_title": "Yeni cihazlarda göster",
"show_on_new_devices_desc": "Başka bir yerde tamamlamış olsanız bile, yeni bir cihazda ilk oturum açtığınızda karşılama afişini ve turu yeniden gösterin",
"sidebar_title": "Posta kutularınız", "sidebar_title": "Posta kutularınız",
"sidebar_desc": "Bu sizin klasör kenar çubuğunuzdur. E-postalarını görüntülemek için herhangi bir posta kutusuna tıklayın. Klasörler oluşturabilir, e-postaları aralarında sürükleyebilir ve okunmamış sayılarını bir bakışta görebilirsiniz.", "sidebar_desc": "Bu sizin klasör kenar çubuğunuzdur. E-postalarını görüntülemek için herhangi bir posta kutusuna tıklayın. Klasörler oluşturabilir, e-postaları aralarında sürükleyebilir ve okunmamış sayılarını bir bakışta görebilirsiniz.",
"compose_title": "E-posta oluştur", "compose_title": "E-posta oluştur",
+3 -1
View File
@@ -565,7 +565,7 @@
"from_override": { "from_override": {
"toggle_off": "Замінити", "toggle_off": "Замінити",
"toggle_on": "Скасувати заміну", "toggle_on": "Скасувати заміну",
"toggle_tooltip": "Вільно редагуйте ім'я та адресу відправника. Пошта все ще надсилається через вашу ідентичність змінюється лише видимий заголовок Від.", "toggle_tooltip": "Вільно редагуйте ім'я та адресу відправника. Пошта все ще надсилається через вашу ідентичність - змінюється лише видимий заголовок Від.",
"name_label": "Ім'я відправника", "name_label": "Ім'я відправника",
"name_placeholder": "Ім'я", "name_placeholder": "Ім'я",
"email_label": "Електронна адреса відправника", "email_label": "Електронна адреса відправника",
@@ -2844,6 +2844,8 @@
"restart_title": "Ознайомчий тур", "restart_title": "Ознайомчий тур",
"restart_desc": "Повторіть покрокове керівництво по інтерфейсу", "restart_desc": "Повторіть покрокове керівництво по інтерфейсу",
"restart_button": "Перезапустити тур", "restart_button": "Перезапустити тур",
"show_on_new_devices_title": "Показувати на нових пристроях",
"show_on_new_devices_desc": "Повторіть привітальний банер і тур під час першого входу на новому пристрої, навіть якщо ви вже завершили їх в іншому місці",
"sidebar_title": "Ваші поштові скриньки", "sidebar_title": "Ваші поштові скриньки",
"sidebar_desc": "Це бічна панель вашої папки. Натисніть будь-яку поштову скриньку, щоб переглянути її електронні листи. Ви можете створювати папки, перетягувати електронні листи між ними та миттєво переглядати кількість непрочитаних.", "sidebar_desc": "Це бічна панель вашої папки. Натисніть будь-яку поштову скриньку, щоб переглянути її електронні листи. Ви можете створювати папки, перетягувати електронні листи між ними та миттєво переглядати кількість непрочитаних.",
"compose_title": "Створіть електронний лист", "compose_title": "Створіть електронний лист",
+3 -1
View File
@@ -565,7 +565,7 @@
"from_override": { "from_override": {
"toggle_off": "覆盖", "toggle_off": "覆盖",
"toggle_on": "取消覆盖", "toggle_on": "取消覆盖",
"toggle_tooltip": "自由编辑发件人姓名和地址。邮件仍通过您的身份发送 仅可见的发件人标题发生变化。", "toggle_tooltip": "自由编辑发件人姓名和地址。邮件仍通过您的身份发送 - 仅可见的发件人标题发生变化。",
"name_label": "发件人姓名", "name_label": "发件人姓名",
"name_placeholder": "姓名", "name_placeholder": "姓名",
"email_label": "发件人电子邮件地址", "email_label": "发件人电子邮件地址",
@@ -2844,6 +2844,8 @@
"restart_title": "新手导览", "restart_title": "新手导览",
"restart_desc": "重新查看界面功能引导", "restart_desc": "重新查看界面功能引导",
"restart_button": "重新开始导览", "restart_button": "重新开始导览",
"show_on_new_devices_title": "在新设备上显示",
"show_on_new_devices_desc": "首次在新设备登录时重新显示欢迎横幅和导览,即使您已在其他设备完成",
"sidebar_title": "邮箱文件夹", "sidebar_title": "邮箱文件夹",
"sidebar_desc": "这里是邮箱文件夹列表。点击任意文件夹即可查看邮件。你可以创建文件夹、拖动邮件进行整理,并快速查看未读邮件数量。", "sidebar_desc": "这里是邮箱文件夹列表。点击任意文件夹即可查看邮件。你可以创建文件夹、拖动邮件进行整理,并快速查看未读邮件数量。",
"compose_title": "写邮件", "compose_title": "写邮件",
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "bulwark-webmail", "name": "bulwark-webmail",
"version": "1.6.5", "version": "1.6.6",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "bulwark-webmail", "name": "bulwark-webmail",
"version": "1.6.5", "version": "1.6.6",
"license": "AGPL-3.0-only", "license": "AGPL-3.0-only",
"dependencies": { "dependencies": {
"@tanstack/react-virtual": "^3.13.24", "@tanstack/react-virtual": "^3.13.24",
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "bulwark-webmail", "name": "bulwark-webmail",
"version": "1.6.5", "version": "1.6.6",
"description": "Bulwark Webmail - a modern webmail client built for Stalwart Mail Server", "description": "Bulwark Webmail - a modern webmail client built for Stalwart Mail Server",
"author": "Bulwark Webmail <bulwark@rbm.systems>", "author": "Bulwark Webmail <bulwark@rbm.systems>",
"license": "AGPL-3.0-only", "license": "AGPL-3.0-only",
+1 -1
View File
@@ -37,7 +37,7 @@ export async function proxy(request: NextRequest) {
pathname === "/api/health" || pathname === "/api/health" ||
pathname.startsWith("/_next/") || pathname.startsWith("/_next/") ||
pathname.startsWith("/branding/") || pathname.startsWith("/branding/") ||
// Public read endpoint serves wizard-uploaded branding assets so // Public read endpoint - serves wizard-uploaded branding assets so
// image previews work during the wizard. No auth on the GET route. // image previews work during the wizard. No auth on the GET route.
pathname.startsWith("/api/admin/branding/") || pathname.startsWith("/api/admin/branding/") ||
/\.[^/]+$/.test(pathname); /\.[^/]+$/.test(pathname);
+1 -1
View File
@@ -770,7 +770,7 @@ export const useAuthStore = create<AuthState>()(
const accountStore = useAccountStore.getState(); const accountStore = useAccountStore.getState();
const slot = accountStore.getNextCookieSlot(); const slot = accountStore.getNextCookieSlot();
// SSO token exchange and config fetch are independent fire both // SSO token exchange and config fetch are independent - fire both
// up front and let them resolve in parallel. // up front and let them resolve in parallel.
const [ssoRes, config] = await Promise.all([ const [ssoRes, config] = await Promise.all([
apiFetch('/api/auth/sso/complete', { apiFetch('/api/auth/sso/complete', {
+20
View File
@@ -225,6 +225,11 @@ interface SettingsState {
sidebarApps: SidebarApp[]; sidebarApps: SidebarApp[];
keepAppsLoaded: boolean; keepAppsLoaded: boolean;
// Onboarding
onboardingCompleted: boolean; // Welcome banner dismissed
tourCompleted: boolean; // Interactive tour completed
showOnboardingOnNewDevices: boolean; // When true, onboarding shows again on each new device
// Advanced // Advanced
debugMode: boolean; debugMode: boolean;
debugCategories: Record<DebugCategory, boolean>; debugCategories: Record<DebugCategory, boolean>;
@@ -404,6 +409,11 @@ const DEFAULT_SETTINGS = {
sidebarApps: [] as SidebarApp[], sidebarApps: [] as SidebarApp[],
keepAppsLoaded: false, keepAppsLoaded: false,
// Onboarding
onboardingCompleted: false,
tourCompleted: false,
showOnboardingOnNewDevices: false,
// Advanced // Advanced
debugMode: false, debugMode: false,
debugCategories: { debugCategories: {
@@ -512,6 +522,9 @@ export const useSettingsStore = create<SettingsState>()(
attachmentImagePreviewsEnabled: state.attachmentImagePreviewsEnabled, attachmentImagePreviewsEnabled: state.attachmentImagePreviewsEnabled,
sidebarApps: state.sidebarApps, sidebarApps: state.sidebarApps,
keepAppsLoaded: state.keepAppsLoaded, keepAppsLoaded: state.keepAppsLoaded,
onboardingCompleted: state.onboardingCompleted,
tourCompleted: state.tourCompleted,
showOnboardingOnNewDevices: state.showOnboardingOnNewDevices,
debugMode: state.debugMode, debugMode: state.debugMode,
debugCategories: state.debugCategories, debugCategories: state.debugCategories,
settingsSyncDisabled: state.settingsSyncDisabled, settingsSyncDisabled: state.settingsSyncDisabled,
@@ -824,6 +837,13 @@ if (typeof window !== 'undefined') {
if (res.status === 404) { if (res.status === 404) {
syncWarn('Settings sync endpoint returned 404, disabling sync'); syncWarn('Settings sync endpoint returned 404, disabling sync');
syncEnabled = false; syncEnabled = false;
} else if (res.status === 403) {
// Identity mismatch — current session cookies don't match the
// username/serverUrl we're syncing for (common in dev mock mode where
// no stalwart-context cookie is written, or when rememberMe is off).
// Retrying won't help for this session; disable to stop the noise.
syncWarn('Settings sync rejected (identity mismatch), disabling sync');
syncEnabled = false;
} else if (res.status >= 500 && retries > 0) { } else if (res.status >= 500 && retries > 0) {
const body = await res.json().catch(() => ({})); const body = await res.json().catch(() => ({}));
syncWarn('Settings sync got server error:', body.error || `status ${res.status}`, '- retrying...'); syncWarn('Settings sync got server error:', body.error || `status ${res.status}`, '- retrying...');