fix: standardize punctuation in tooltips and comments across multiple locales and code files
This commit is contained in:
+12
-12
@@ -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?
|
||||||
|
|||||||
@@ -179,7 +179,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();
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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!',
|
||||||
@@ -1002,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' } },
|
||||||
@@ -1012,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'),
|
||||||
},
|
},
|
||||||
@@ -1050,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.',
|
||||||
@@ -1080,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'),
|
||||||
@@ -1092,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', {
|
||||||
@@ -1110,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', {
|
||||||
@@ -1119,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', {
|
||||||
@@ -1135,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'),
|
||||||
@@ -1218,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!',
|
||||||
@@ -1246,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'),
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -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
@@ -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'm sure this is the right URL — continue anyway.</span>
|
<span className="text-sm text-foreground">I'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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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}</>;
|
||||||
}
|
}
|
||||||
@@ -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;
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import type { ContactCard, AddressBook } from '@/lib/jmap/types';
|
|||||||
|
|
||||||
// randomuser.me serves stable portrait URLs at
|
// randomuser.me serves stable portrait URLs at
|
||||||
// https://randomuser.me/api/portraits/{men|women}/{0..99}.jpg
|
// https://randomuser.me/api/portraits/{men|women}/{0..99}.jpg
|
||||||
// See https://randomuser.me/documentation#howto — we use these directly
|
// See https://randomuser.me/documentation#howto - we use these directly
|
||||||
// rather than hitting the JSON API so the demo works offline.
|
// rather than hitting the JSON API so the demo works offline.
|
||||||
const portrait = (gender: 'men' | 'women', n: number): string =>
|
const portrait = (gender: 'men' | 'women', n: number): string =>
|
||||||
`https://randomuser.me/api/portraits/${gender}/${n}.jpg`;
|
`https://randomuser.me/api/portraits/${gender}/${n}.jpg`;
|
||||||
@@ -160,7 +160,7 @@ export function createDemoContacts(): ContactCard[] {
|
|||||||
name: { components: [{ kind: 'given', value: 'Anna' }, { kind: 'surname', value: 'Kowalski' }] },
|
name: { components: [{ kind: 'given', value: 'Anna' }, { kind: 'surname', value: 'Kowalski' }] },
|
||||||
emails: { e1: { address: 'anna.kowalski@example.com', contexts: { private: true }, pref: 1 } },
|
emails: { e1: { address: 'anna.kowalski@example.com', contexts: { private: true }, pref: 1 } },
|
||||||
phones: { p1: { number: '+48-602-555-0144', features: { cell: true } } },
|
phones: { p1: { number: '+48-602-555-0144', features: { cell: true } } },
|
||||||
notes: { n1: { note: 'Sister — lives in Kraków' } },
|
notes: { n1: { note: 'Sister - lives in Kraków' } },
|
||||||
anniversaries: { a1: { kind: 'birth', date: { month: 11, day: 4 } } },
|
anniversaries: { a1: { kind: 'birth', date: { month: 11, day: 4 } } },
|
||||||
media: photo('women', 47),
|
media: photo('women', 47),
|
||||||
},
|
},
|
||||||
@@ -170,7 +170,7 @@ export function createDemoContacts(): ContactCard[] {
|
|||||||
kind: 'individual',
|
kind: 'individual',
|
||||||
name: { components: [{ kind: 'given', value: 'Marcus' }, { kind: 'surname', value: 'Hughes' }] },
|
name: { components: [{ kind: 'given', value: 'Marcus' }, { kind: 'surname', value: 'Hughes' }] },
|
||||||
emails: { e1: { address: 'marcus.hughes@example.com', pref: 1 } },
|
emails: { e1: { address: 'marcus.hughes@example.com', pref: 1 } },
|
||||||
notes: { n1: { note: 'College friend — book club organiser' } },
|
notes: { n1: { note: 'College friend - book club organiser' } },
|
||||||
media: photo('men', 96),
|
media: photo('men', 96),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
+53
-53
@@ -3,7 +3,7 @@ import { demoDate } from '../demo-utils';
|
|||||||
|
|
||||||
const USER = { name: 'Demo User', email: 'demo@example.com' } as const;
|
const USER = { name: 'Demo User', email: 'demo@example.com' } as const;
|
||||||
|
|
||||||
// Helper to keep the fixtures short — auto-assigns a partId/blobId per body.
|
// Helper to keep the fixtures short - auto-assigns a partId/blobId per body.
|
||||||
let bodyCounter = 0;
|
let bodyCounter = 0;
|
||||||
function body(value: string, type: 'text/plain' | 'text/html' = 'text/plain') {
|
function body(value: string, type: 'text/plain' | 'text/html' = 'text/plain') {
|
||||||
const partId = String(++bodyCounter);
|
const partId = String(++bodyCounter);
|
||||||
@@ -53,7 +53,7 @@ export function createDemoEmails(): Email[] {
|
|||||||
messageId: '<welcome@demo.bulwark.email>',
|
messageId: '<welcome@demo.bulwark.email>',
|
||||||
},
|
},
|
||||||
|
|
||||||
// Mom — personal message, unread
|
// Mom - personal message, unread
|
||||||
{
|
{
|
||||||
id: 'demo-email-mom',
|
id: 'demo-email-mom',
|
||||||
threadId: 'demo-thread-mom',
|
threadId: 'demo-thread-mom',
|
||||||
@@ -65,15 +65,15 @@ export function createDemoEmails(): Email[] {
|
|||||||
to: [USER],
|
to: [USER],
|
||||||
subject: 'when are you coming home?',
|
subject: 'when are you coming home?',
|
||||||
sentAt: demoDate(0, -4, -12),
|
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...',
|
preview: 'Hi sweetie, your father and I were just talking - we miss you. Any chance you can come down for a weekend...',
|
||||||
hasAttachment: false,
|
hasAttachment: false,
|
||||||
...textOnly(
|
...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",
|
"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>',
|
messageId: '<5a8c-mom@example.com>',
|
||||||
},
|
},
|
||||||
|
|
||||||
// GitHub — PR review request
|
// GitHub - PR review request
|
||||||
{
|
{
|
||||||
id: 'demo-email-gh-pr',
|
id: 'demo-email-gh-pr',
|
||||||
threadId: 'demo-thread-gh-pr',
|
threadId: 'demo-thread-gh-pr',
|
||||||
@@ -89,13 +89,13 @@ export function createDemoEmails(): Email[] {
|
|||||||
preview: '@demo-user requested your review on this pull request. Replaces the fixed-window limiter with a leaky token-bucket...',
|
preview: '@demo-user requested your review on this pull request. Replaces the fixed-window limiter with a leaky token-bucket...',
|
||||||
hasAttachment: false,
|
hasAttachment: false,
|
||||||
...bodies(
|
...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.',
|
'@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>',
|
'<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>',
|
messageId: '<acme/api-gateway/pull/1284@github.com>',
|
||||||
},
|
},
|
||||||
|
|
||||||
// Hacker Newsletter — newsletter, read
|
// Hacker Newsletter - newsletter, read
|
||||||
{
|
{
|
||||||
id: 'demo-email-2',
|
id: 'demo-email-2',
|
||||||
threadId: 'demo-thread-2',
|
threadId: 'demo-thread-2',
|
||||||
@@ -105,18 +105,18 @@ export function createDemoEmails(): Email[] {
|
|||||||
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: [USER],
|
to: [USER],
|
||||||
subject: 'Issue #218 — RFC 9844, the second WebAssembly draft, and a quiet announcement from Mozilla',
|
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,
|
||||||
...bodies(
|
...bodies(
|
||||||
'TechDigest #218\n\n— THE WEEK IN STANDARDS —\n\n1. RFC 9844: Per-message TLS extensions are now official. The implications for SMTP delivery reports are surprisingly large — Mike Crispin has a write-up that runs through what changes for transactional senders.\n\n2. WebAssembly 2.0 (second public draft). Tail calls are in. SIMD is in. Component model is *almost* in but punted to a separate spec, which feels like the right call.\n\n3. Mozilla quietly shipped a privacy-preserving telemetry channel to Firefox 132. No, it doesn\'t replace ad tracking. Yes, it\'s a real cryptographic system. Worth reading the post.\n\n— TOOLS —\n\n— Datasette 1.0 is out. Ten years from the first commit.\n— Fly.io published their object store, Tigris-style, written in Go.\n— Linear added an SSO migration tool that actually handles the IdP-initiated case.\n\n— ESSAYS —\n\n* "Postgres is enough" by E. Tan — a long-form rebuttal to the microservices-by-default pattern.\n* "I rewrote my home network in TypeScript so you don\'t have to" — exactly what it sounds like.\n\n— UNSUBSCRIBE —\n\nManage your subscription at techdigest.example/manage.',
|
'TechDigest #218\n\n- THE WEEK IN STANDARDS -\n\n1. RFC 9844: Per-message TLS extensions are now official. The implications for SMTP delivery reports are surprisingly large - Mike Crispin has a write-up that runs through what changes for transactional senders.\n\n2. WebAssembly 2.0 (second public draft). Tail calls are in. SIMD is in. Component model is *almost* in but punted to a separate spec, which feels like the right call.\n\n3. Mozilla quietly shipped a privacy-preserving telemetry channel to Firefox 132. No, it doesn\'t replace ad tracking. Yes, it\'s a real cryptographic system. Worth reading the post.\n\n- TOOLS -\n\n- Datasette 1.0 is out. Ten years from the first commit.\n- Fly.io published their object store, Tigris-style, written in Go.\n- Linear added an SSO migration tool that actually handles the IdP-initiated case.\n\n- ESSAYS -\n\n* "Postgres is enough" by E. Tan - a long-form rebuttal to the microservices-by-default pattern.\n* "I rewrote my home network in TypeScript so you don\'t have to" - exactly what it sounds like.\n\n- UNSUBSCRIBE -\n\nManage your subscription at techdigest.example/manage.',
|
||||||
'<div style="max-width:560px;margin:0 auto;font-family:-apple-system,sans-serif;line-height:1.5"><div style="border-bottom:2px solid #111;padding-bottom:16px"><div style="font-size:11px;letter-spacing:0.12em;text-transform:uppercase;color:#888">TechDigest · Issue #218</div><h1 style="font-size:22px;margin:4px 0 0">RFC 9844, the second WebAssembly draft, and a quiet announcement from Mozilla</h1></div><h2 style="font-size:14px;text-transform:uppercase;letter-spacing:0.08em;color:#666;margin-top:24px">The week in standards</h2><p><strong>1.</strong> RFC 9844: Per-message TLS extensions are now official. The implications for SMTP delivery reports are surprisingly large — Mike Crispin has a <a href="#" style="color:#db2d54">write-up</a> that runs through what changes for transactional senders.</p><p><strong>2.</strong> WebAssembly 2.0 (second public draft). Tail calls are in. SIMD is in. Component model is <em>almost</em> in but punted to a separate spec, which feels like the right call.</p><p><strong>3.</strong> Mozilla quietly shipped a privacy-preserving telemetry channel to Firefox 132. No, it doesn\'t replace ad tracking. Yes, it\'s a real cryptographic system.</p><h2 style="font-size:14px;text-transform:uppercase;letter-spacing:0.08em;color:#666;margin-top:24px">Tools</h2><ul><li>Datasette 1.0 is out. Ten years from the first commit.</li><li>Fly.io published their object store, Tigris-style, written in Go.</li><li>Linear added an SSO migration tool that actually handles the IdP-initiated case.</li></ul><h2 style="font-size:14px;text-transform:uppercase;letter-spacing:0.08em;color:#666;margin-top:24px">Essays</h2><p style="margin:0 0 6px">"Postgres is enough" by E. Tan — a long-form rebuttal to the microservices-by-default pattern.</p><p style="margin:0">"I rewrote my home network in TypeScript so you don\'t have to" — exactly what it sounds like.</p><div style="margin-top:28px;padding-top:16px;border-top:1px solid #eee;font-size:12px;color:#888">Manage your subscription at <a href="#" style="color:#888">techdigest.example/manage</a></div></div>',
|
'<div style="max-width:560px;margin:0 auto;font-family:-apple-system,sans-serif;line-height:1.5"><div style="border-bottom:2px solid #111;padding-bottom:16px"><div style="font-size:11px;letter-spacing:0.12em;text-transform:uppercase;color:#888">TechDigest · Issue #218</div><h1 style="font-size:22px;margin:4px 0 0">RFC 9844, the second WebAssembly draft, and a quiet announcement from Mozilla</h1></div><h2 style="font-size:14px;text-transform:uppercase;letter-spacing:0.08em;color:#666;margin-top:24px">The week in standards</h2><p><strong>1.</strong> RFC 9844: Per-message TLS extensions are now official. The implications for SMTP delivery reports are surprisingly large - Mike Crispin has a <a href="#" style="color:#db2d54">write-up</a> that runs through what changes for transactional senders.</p><p><strong>2.</strong> WebAssembly 2.0 (second public draft). Tail calls are in. SIMD is in. Component model is <em>almost</em> in but punted to a separate spec, which feels like the right call.</p><p><strong>3.</strong> Mozilla quietly shipped a privacy-preserving telemetry channel to Firefox 132. No, it doesn\'t replace ad tracking. Yes, it\'s a real cryptographic system.</p><h2 style="font-size:14px;text-transform:uppercase;letter-spacing:0.08em;color:#666;margin-top:24px">Tools</h2><ul><li>Datasette 1.0 is out. Ten years from the first commit.</li><li>Fly.io published their object store, Tigris-style, written in Go.</li><li>Linear added an SSO migration tool that actually handles the IdP-initiated case.</li></ul><h2 style="font-size:14px;text-transform:uppercase;letter-spacing:0.08em;color:#666;margin-top:24px">Essays</h2><p style="margin:0 0 6px">"Postgres is enough" by E. Tan - a long-form rebuttal to the microservices-by-default pattern.</p><p style="margin:0">"I rewrote my home network in TypeScript so you don\'t have to" - exactly what it sounds like.</p><div style="margin-top:28px;padding-top:16px;border-top:1px solid #eee;font-size:12px;color:#888">Manage your subscription at <a href="#" style="color:#888">techdigest.example/manage</a></div></div>',
|
||||||
),
|
),
|
||||||
messageId: '<weekly-218@techdigest.example>',
|
messageId: '<weekly-218@techdigest.example>',
|
||||||
},
|
},
|
||||||
|
|
||||||
// Thread: Q4 Project Timeline — Alice → Bob → Alice (4 messages)
|
// 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',
|
||||||
@@ -198,7 +198,7 @@ export function createDemoEmails(): Email[] {
|
|||||||
messageId: '<receipt-2451-9928@stripe.com>',
|
messageId: '<receipt-2451-9928@stripe.com>',
|
||||||
},
|
},
|
||||||
|
|
||||||
// Email with attachments — invoice
|
// Email with attachments - invoice
|
||||||
{
|
{
|
||||||
id: 'demo-email-4',
|
id: 'demo-email-4',
|
||||||
threadId: 'demo-thread-4',
|
threadId: 'demo-thread-4',
|
||||||
@@ -213,7 +213,7 @@ export function createDemoEmails(): Email[] {
|
|||||||
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,
|
||||||
...textOnly(
|
...textOnly(
|
||||||
"Hi,\n\nPlease find attached the invoice for October and a screenshot of the latest prototype. I went with Option B for the hero (the one with the asymmetric grid) since you mentioned the symmetrical version felt too flat in our last call.\n\nIf the invoice line items look off, ping me — I had to back out the November pre-payment.\n\nBest regards,\nSarah",
|
"Hi,\n\nPlease find attached the invoice for October and a screenshot of the latest prototype. I went with Option B for the hero (the one with the asymmetric grid) since you mentioned the symmetrical version felt too flat in our last call.\n\nIf the invoice line items look off, ping me - I had to back out the November pre-payment.\n\nBest regards,\nSarah",
|
||||||
),
|
),
|
||||||
attachments: [
|
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' },
|
||||||
@@ -222,7 +222,7 @@ export function createDemoEmails(): Email[] {
|
|||||||
messageId: '<invoice-089@example.com>',
|
messageId: '<invoice-089@example.com>',
|
||||||
},
|
},
|
||||||
|
|
||||||
// Carlos — starred, social
|
// Carlos - starred, social
|
||||||
{
|
{
|
||||||
id: 'demo-email-5',
|
id: 'demo-email-5',
|
||||||
threadId: 'demo-thread-5',
|
threadId: 'demo-thread-5',
|
||||||
@@ -232,17 +232,17 @@ export function createDemoEmails(): Email[] {
|
|||||||
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: [USER],
|
to: [USER],
|
||||||
subject: 'Friday dinner — moved to 7:30 (sorry!)',
|
subject: 'Friday dinner - moved to 7:30 (sorry!)',
|
||||||
sentAt: demoDate(-2, -1),
|
sentAt: demoDate(-2, -1),
|
||||||
preview: 'Quick heads up — had to push the dinner back half an hour. Bistro could only do the late seating...',
|
preview: 'Quick heads up - had to push the dinner back half an hour. Bistro could only do the late seating...',
|
||||||
hasAttachment: false,
|
hasAttachment: false,
|
||||||
...textOnly(
|
...textOnly(
|
||||||
"Quick heads up — had to push the dinner back half an hour. Bistro could only do the late seating.\n\nNew time: Friday, 7:30 PM\nThe Garden Bistro, 123 Oak Street\n\nReservation under my name, 8 people. Let me know if that doesn't work for you and I can try to wrangle something.\n\nCheers,\nCarlos",
|
"Quick heads up - had to push the dinner back half an hour. Bistro could only do the late seating.\n\nNew time: Friday, 7:30 PM\nThe Garden Bistro, 123 Oak Street\n\nReservation under my name, 8 people. Let me know if that doesn't work for you and I can try to wrangle something.\n\nCheers,\nCarlos",
|
||||||
),
|
),
|
||||||
messageId: '<dinner-reminder@example.com>',
|
messageId: '<dinner-reminder@example.com>',
|
||||||
},
|
},
|
||||||
|
|
||||||
// Linear — issue assigned
|
// Linear - issue assigned
|
||||||
{
|
{
|
||||||
id: 'demo-email-linear',
|
id: 'demo-email-linear',
|
||||||
threadId: 'demo-thread-linear',
|
threadId: 'demo-thread-linear',
|
||||||
@@ -252,7 +252,7 @@ export function createDemoEmails(): Email[] {
|
|||||||
receivedAt: demoDate(0, -7, -15),
|
receivedAt: demoDate(0, -7, -15),
|
||||||
from: [{ name: 'Linear', email: 'notifications@linear.app' }],
|
from: [{ name: 'Linear', email: 'notifications@linear.app' }],
|
||||||
to: [USER],
|
to: [USER],
|
||||||
subject: 'BUL-2031 was assigned to you — "Compose: drag-and-drop attachments duplicated on slow networks"',
|
subject: 'BUL-2031 was assigned to you - "Compose: drag-and-drop attachments duplicated on slow networks"',
|
||||||
sentAt: demoDate(0, -7, -15),
|
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...',
|
preview: 'Priya Sharma assigned this issue to you. Repro on a throttled connection (Slow 3G): drop a file twice and...',
|
||||||
hasAttachment: false,
|
hasAttachment: false,
|
||||||
@@ -263,7 +263,7 @@ export function createDemoEmails(): Email[] {
|
|||||||
messageId: '<BUL-2031-assign@linear.app>',
|
messageId: '<BUL-2031-assign@linear.app>',
|
||||||
},
|
},
|
||||||
|
|
||||||
// Anna — sister, photos
|
// Anna - sister, photos
|
||||||
{
|
{
|
||||||
id: 'demo-email-anna',
|
id: 'demo-email-anna',
|
||||||
threadId: 'demo-thread-anna',
|
threadId: 'demo-thread-anna',
|
||||||
@@ -278,7 +278,7 @@ export function createDemoEmails(): Email[] {
|
|||||||
preview: "finally got around to going through these. there are like 600 more on the drive but here's the highlights...",
|
preview: "finally got around to going through these. there are like 600 more on the drive but here's the highlights...",
|
||||||
hasAttachment: true,
|
hasAttachment: true,
|
||||||
...textOnly(
|
...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",
|
"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: [
|
attachments: [
|
||||||
{ partId: 'att-3', blobId: 'demo-blob-att-3', size: 1800000, name: 'wedding-001.jpg', type: 'image/jpeg' },
|
{ partId: 'att-3', blobId: 'demo-blob-att-3', size: 1800000, name: 'wedding-001.jpg', type: 'image/jpeg' },
|
||||||
@@ -298,17 +298,17 @@ export function createDemoEmails(): Email[] {
|
|||||||
receivedAt: demoDate(-2, -3, -45),
|
receivedAt: demoDate(-2, -3, -45),
|
||||||
from: [{ name: 'AWS Billing', email: 'no-reply-aws@amazon.com' }],
|
from: [{ name: 'AWS Billing', email: 'no-reply-aws@amazon.com' }],
|
||||||
to: [USER],
|
to: [USER],
|
||||||
subject: 'Your AWS bill is available — $127.43',
|
subject: 'Your AWS bill is available - $127.43',
|
||||||
sentAt: demoDate(-2, -3, -45),
|
sentAt: demoDate(-2, -3, -45),
|
||||||
preview: 'Your bill for the previous billing period is now available. Total this period: $127.43 (down $4.12)...',
|
preview: 'Your bill for the previous billing period is now available. Total this period: $127.43 (down $4.12)...',
|
||||||
hasAttachment: false,
|
hasAttachment: false,
|
||||||
...textOnly(
|
...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.",
|
"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>',
|
messageId: '<aws-bill-2024-11@amazon.com>',
|
||||||
},
|
},
|
||||||
|
|
||||||
// 2FA code — system, unread
|
// 2FA code - system, unread
|
||||||
{
|
{
|
||||||
id: 'demo-email-2fa',
|
id: 'demo-email-2fa',
|
||||||
threadId: 'demo-thread-2fa',
|
threadId: 'demo-thread-2fa',
|
||||||
@@ -323,12 +323,12 @@ export function createDemoEmails(): Email[] {
|
|||||||
preview: "Use this code within 10 minutes to sign in. If you didn't request it, ignore this email.",
|
preview: "Use this code within 10 minutes to sign in. If you didn't request it, ignore this email.",
|
||||||
hasAttachment: false,
|
hasAttachment: false,
|
||||||
...textOnly(
|
...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.",
|
"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>',
|
messageId: '<otp-814302@1password.com>',
|
||||||
},
|
},
|
||||||
|
|
||||||
// LinkedIn — cold-ish
|
// LinkedIn - cold-ish
|
||||||
{
|
{
|
||||||
id: 'demo-email-linkedin',
|
id: 'demo-email-linkedin',
|
||||||
threadId: 'demo-thread-linkedin',
|
threadId: 'demo-thread-linkedin',
|
||||||
@@ -338,17 +338,17 @@ export function createDemoEmails(): Email[] {
|
|||||||
receivedAt: demoDate(-3, -11),
|
receivedAt: demoDate(-3, -11),
|
||||||
from: [{ name: 'LinkedIn', email: 'jobs-noreply@linkedin.com' }],
|
from: [{ name: 'LinkedIn', email: 'jobs-noreply@linkedin.com' }],
|
||||||
to: [USER],
|
to: [USER],
|
||||||
subject: '5 jobs matching "staff engineer · remote · eu" — including one at Datadog',
|
subject: '5 jobs matching "staff engineer · remote · eu" - including one at Datadog',
|
||||||
sentAt: demoDate(-3, -11),
|
sentAt: demoDate(-3, -11),
|
||||||
preview: "We thought you'd be interested in these jobs based on your profile and search history.",
|
preview: "We thought you'd be interested in these jobs based on your profile and search history.",
|
||||||
hasAttachment: false,
|
hasAttachment: false,
|
||||||
...textOnly(
|
...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.',
|
'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>',
|
messageId: '<jobs-1107@linkedin.com>',
|
||||||
},
|
},
|
||||||
|
|
||||||
// Book club — Marcus
|
// Book club - Marcus
|
||||||
{
|
{
|
||||||
id: 'demo-email-bookclub',
|
id: 'demo-email-bookclub',
|
||||||
threadId: 'demo-thread-bookclub',
|
threadId: 'demo-thread-bookclub',
|
||||||
@@ -358,7 +358,7 @@ export function createDemoEmails(): Email[] {
|
|||||||
receivedAt: demoDate(-1, -14),
|
receivedAt: demoDate(-1, -14),
|
||||||
from: [{ name: 'Marcus Hughes', email: 'marcus.hughes@example.com' }],
|
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' }],
|
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',
|
subject: 'book club thursday - picking the next one',
|
||||||
sentAt: demoDate(-1, -14),
|
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...',
|
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,
|
hasAttachment: false,
|
||||||
@@ -378,7 +378,7 @@ export function createDemoEmails(): Email[] {
|
|||||||
receivedAt: demoDate(0, -9, -30),
|
receivedAt: demoDate(0, -9, -30),
|
||||||
from: [{ name: 'DHL Express', email: 'noreply@dhl.com' }],
|
from: [{ name: 'DHL Express', email: 'noreply@dhl.com' }],
|
||||||
to: [USER],
|
to: [USER],
|
||||||
subject: 'Your package is out for delivery — arriving today',
|
subject: 'Your package is out for delivery - arriving today',
|
||||||
sentAt: demoDate(0, -9, -30),
|
sentAt: demoDate(0, -9, -30),
|
||||||
preview: 'Tracking 1Z 999 AA1 0123 4567 84 · Estimated delivery: today between 14:00 and 18:00.',
|
preview: 'Tracking 1Z 999 AA1 0123 4567 84 · Estimated delivery: today between 14:00 and 18:00.',
|
||||||
hasAttachment: false,
|
hasAttachment: false,
|
||||||
@@ -398,12 +398,12 @@ export function createDemoEmails(): Email[] {
|
|||||||
receivedAt: demoDate(-2, -16),
|
receivedAt: demoDate(-2, -16),
|
||||||
from: [{ name: 'Olivia Bennett (via Notion)', email: 'team@mail.notion.so' }],
|
from: [{ name: 'Olivia Bennett (via Notion)', email: 'team@mail.notion.so' }],
|
||||||
to: [USER],
|
to: [USER],
|
||||||
subject: 'Olivia shared "Q1 2026 — design north star" with you',
|
subject: 'Olivia shared "Q1 2026 - design north star" with you',
|
||||||
sentAt: demoDate(-2, -16),
|
sentAt: demoDate(-2, -16),
|
||||||
preview: 'Olivia Bennett shared a page with you in the Northwind workspace. Open in Notion to view.',
|
preview: 'Olivia Bennett shared a page with you in the Northwind workspace. Open in Notion to view.',
|
||||||
hasAttachment: false,
|
hasAttachment: false,
|
||||||
...textOnly(
|
...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',
|
'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>',
|
messageId: '<share-northwind-q1@mail.notion.so>',
|
||||||
},
|
},
|
||||||
@@ -438,7 +438,7 @@ export function createDemoEmails(): Email[] {
|
|||||||
receivedAt: demoDate(-5, -10),
|
receivedAt: demoDate(-5, -10),
|
||||||
from: [{ name: 'Booking.com', email: 'no-reply@booking.com' }],
|
from: [{ name: 'Booking.com', email: 'no-reply@booking.com' }],
|
||||||
to: [USER],
|
to: [USER],
|
||||||
subject: 'Confirmation 4892-7714-3320 — Hotel Lago, Lake Como (Dec 22–25)',
|
subject: 'Confirmation 4892-7714-3320 - Hotel Lago, Lake Como (Dec 22–25)',
|
||||||
sentAt: demoDate(-5, -10),
|
sentAt: demoDate(-5, -10),
|
||||||
preview: 'Your booking is confirmed. Check-in: Dec 22, after 15:00. Check-out: Dec 25, before 11:00.',
|
preview: 'Your booking is confirmed. Check-in: Dec 22, after 15:00. Check-out: Dec 25, before 11:00.',
|
||||||
hasAttachment: true,
|
hasAttachment: true,
|
||||||
@@ -466,7 +466,7 @@ export function createDemoEmails(): Email[] {
|
|||||||
preview: 'I have been spending the slow weeks of November in the workshop, slowly forging a knife from a piece of...',
|
preview: 'I have been spending the slow weeks of November in the workshop, slowly forging a knife from a piece of...',
|
||||||
hasAttachment: false,
|
hasAttachment: false,
|
||||||
...textOnly(
|
...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.",
|
"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>',
|
messageId: '<nov-2025@robin.substack.com>',
|
||||||
},
|
},
|
||||||
@@ -481,12 +481,12 @@ export function createDemoEmails(): Email[] {
|
|||||||
receivedAt: demoDate(0, -10),
|
receivedAt: demoDate(0, -10),
|
||||||
from: [{ name: 'Jennifer Hayes', email: 'jennifer@talent-partners.example' }],
|
from: [{ name: 'Jennifer Hayes', email: 'jennifer@talent-partners.example' }],
|
||||||
to: [USER],
|
to: [USER],
|
||||||
subject: 'Senior role — Distributed Systems — €180-220k + equity',
|
subject: 'Senior role - Distributed Systems - €180-220k + equity',
|
||||||
sentAt: demoDate(0, -10),
|
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...",
|
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,
|
hasAttachment: false,
|
||||||
...textOnly(
|
...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",
|
"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>',
|
messageId: '<outreach-jh-2025-11@talent-partners.example>',
|
||||||
},
|
},
|
||||||
@@ -501,7 +501,7 @@ export function createDemoEmails(): Email[] {
|
|||||||
receivedAt: demoDate(-1, -2),
|
receivedAt: demoDate(-1, -2),
|
||||||
from: [{ name: "Dr. Smith's Office", email: 'appointments@drsmith.example' }],
|
from: [{ name: "Dr. Smith's Office", email: 'appointments@drsmith.example' }],
|
||||||
to: [USER],
|
to: [USER],
|
||||||
subject: 'Appointment reminder — Tuesday at 10:00',
|
subject: 'Appointment reminder - Tuesday at 10:00',
|
||||||
sentAt: demoDate(-1, -2),
|
sentAt: demoDate(-1, -2),
|
||||||
preview: 'This is a friendly reminder of your upcoming cleaning appointment on Tuesday at 10:00 AM.',
|
preview: 'This is a friendly reminder of your upcoming cleaning appointment on Tuesday at 10:00 AM.',
|
||||||
hasAttachment: false,
|
hasAttachment: false,
|
||||||
@@ -559,10 +559,10 @@ export function createDemoEmails(): Email[] {
|
|||||||
to: [{ name: 'Sofia Russo', email: 'sofia.russo@example.com' }],
|
to: [{ name: 'Sofia Russo', email: 'sofia.russo@example.com' }],
|
||||||
subject: 'Re: when are you coming home?',
|
subject: 'Re: when are you coming home?',
|
||||||
sentAt: demoDate(0, -2, -10),
|
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...",
|
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,
|
hasAttachment: false,
|
||||||
...textOnly(
|
...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.",
|
"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>',
|
messageId: '<re-mom-1@example.com>',
|
||||||
inReplyTo: ['<5a8c-mom@example.com>'],
|
inReplyTo: ['<5a8c-mom@example.com>'],
|
||||||
@@ -597,7 +597,7 @@ export function createDemoEmails(): Email[] {
|
|||||||
receivedAt: demoDate(0, -8),
|
receivedAt: demoDate(0, -8),
|
||||||
from: [USER],
|
from: [USER],
|
||||||
to: [{ name: 'Jennifer Hayes', email: 'jennifer@talent-partners.example' }],
|
to: [{ name: 'Jennifer Hayes', email: 'jennifer@talent-partners.example' }],
|
||||||
subject: 'Re: Senior role — Distributed Systems',
|
subject: 'Re: Senior role - Distributed Systems',
|
||||||
sentAt: demoDate(0, -8),
|
sentAt: demoDate(0, -8),
|
||||||
preview: "Hi Jennifer, thanks for reaching out. I'm not actively looking, but the role sounds interesting enough that...",
|
preview: "Hi Jennifer, thanks for reaching out. I'm not actively looking, but the role sounds interesting enough that...",
|
||||||
hasAttachment: false,
|
hasAttachment: false,
|
||||||
@@ -691,12 +691,12 @@ export function createDemoEmails(): Email[] {
|
|||||||
receivedAt: demoDate(-1, -15),
|
receivedAt: demoDate(-1, -15),
|
||||||
from: [{ name: 'Michael Torres', email: 'michael.torres@company.example' }],
|
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' }],
|
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',
|
subject: '[Project] Q1 2026 roadmap - first cut',
|
||||||
sentAt: demoDate(-1, -15),
|
sentAt: demoDate(-1, -15),
|
||||||
preview: 'Attached is the first cut of the Q1 roadmap. Three themes: reliability, mobile, and the long-promised...',
|
preview: 'Attached is the first cut of the Q1 roadmap. Three themes: reliability, mobile, and the long-promised...',
|
||||||
hasAttachment: true,
|
hasAttachment: true,
|
||||||
...textOnly(
|
...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",
|
"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: [
|
attachments: [
|
||||||
{ partId: 'att-7', blobId: 'demo-blob-att-7', size: 84000, name: 'Q1-2026-roadmap-v0.pdf', type: 'application/pdf' },
|
{ partId: 'att-7', blobId: 'demo-blob-att-7', size: 84000, name: 'Q1-2026-roadmap-v0.pdf', type: 'application/pdf' },
|
||||||
@@ -719,7 +719,7 @@ export function createDemoEmails(): Email[] {
|
|||||||
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,
|
||||||
...textOnly(
|
...textOnly(
|
||||||
'Dear team,\n\nPlease review the updated PTO policy effective January 1st. Key changes include:\n\n- Increased annual allowance from 20 to 25 days\n- Flexible half-day options\n- Rollover limit increased to 10 days\n\nPlease acknowledge receipt.\n\nBest,\nMaria — People Ops',
|
'Dear team,\n\nPlease review the updated PTO policy effective January 1st. Key changes include:\n\n- Increased annual allowance from 20 to 25 days\n- Flexible half-day options\n- Rollover limit increased to 10 days\n\nPlease acknowledge receipt.\n\nBest,\nMaria - People Ops',
|
||||||
),
|
),
|
||||||
messageId: '<hr-policy-1@company.example>',
|
messageId: '<hr-policy-1@company.example>',
|
||||||
},
|
},
|
||||||
@@ -732,12 +732,12 @@ export function createDemoEmails(): Email[] {
|
|||||||
receivedAt: demoDate(-21, -4),
|
receivedAt: demoDate(-21, -4),
|
||||||
from: [{ name: 'Fastmail Support', email: 'support@fastmail.com' }],
|
from: [{ name: 'Fastmail Support', email: 'support@fastmail.com' }],
|
||||||
to: [USER],
|
to: [USER],
|
||||||
subject: 'Re: Ticket #438201 — DKIM signing fails on cross-account aliases',
|
subject: 'Re: Ticket #438201 - DKIM signing fails on cross-account aliases',
|
||||||
sentAt: demoDate(-21, -4),
|
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...",
|
preview: "Thanks for the additional logs. We were able to reproduce on our side - the issue was indeed the alias resolution...",
|
||||||
hasAttachment: false,
|
hasAttachment: false,
|
||||||
...textOnly(
|
...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",
|
"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>',
|
messageId: '<ticket-438201-resolved@fastmail.com>',
|
||||||
},
|
},
|
||||||
@@ -752,7 +752,7 @@ export function createDemoEmails(): Email[] {
|
|||||||
receivedAt: demoDate(-3, -12),
|
receivedAt: demoDate(-3, -12),
|
||||||
from: [{ name: 'Hetzner', email: 'billing@hetzner.com' }],
|
from: [{ name: 'Hetzner', email: 'billing@hetzner.com' }],
|
||||||
to: [USER],
|
to: [USER],
|
||||||
subject: 'Invoice #INV-2024-1042 — €49.99 (paid)',
|
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,
|
hasAttachment: true,
|
||||||
@@ -773,12 +773,12 @@ export function createDemoEmails(): Email[] {
|
|||||||
receivedAt: demoDate(-9, -8),
|
receivedAt: demoDate(-9, -8),
|
||||||
from: [{ name: 'Porkbun', email: 'support@porkbun.com' }],
|
from: [{ name: 'Porkbun', email: 'support@porkbun.com' }],
|
||||||
to: [USER],
|
to: [USER],
|
||||||
subject: 'Renewal confirmation — example.com (1 year)',
|
subject: 'Renewal confirmation - example.com (1 year)',
|
||||||
sentAt: demoDate(-9, -8),
|
sentAt: demoDate(-9, -8),
|
||||||
preview: 'Your domain example.com has been renewed for 1 year. Next renewal: 11 months from today.',
|
preview: 'Your domain example.com has been renewed for 1 year. Next renewal: 11 months from today.',
|
||||||
hasAttachment: false,
|
hasAttachment: false,
|
||||||
...textOnly(
|
...textOnly(
|
||||||
"Hi,\n\nYour domain example.com has been renewed for 1 year.\n\nAmount: $11.06\nNext renewal: 11 months from today\nAutorenew: on\n\nReply to this email if you need a tax-receipt-style invoice.\n\n— Porkbun",
|
"Hi,\n\nYour domain example.com has been renewed for 1 year.\n\nAmount: $11.06\nNext renewal: 11 months from today\nAutorenew: on\n\nReply to this email if you need a tax-receipt-style invoice.\n\n- Porkbun",
|
||||||
),
|
),
|
||||||
messageId: '<renewal-example.com@porkbun.com>',
|
messageId: '<renewal-example.com@porkbun.com>',
|
||||||
},
|
},
|
||||||
@@ -811,12 +811,12 @@ export function createDemoEmails(): Email[] {
|
|||||||
receivedAt: demoDate(-2, -3),
|
receivedAt: demoDate(-2, -3),
|
||||||
from: [{ name: 'Secure Banking', email: 'security-alert@secur1ty-bank.example' }],
|
from: [{ name: 'Secure Banking', email: 'security-alert@secur1ty-bank.example' }],
|
||||||
to: [USER],
|
to: [USER],
|
||||||
subject: 'URGENT: Unusual activity on your account — verify within 24 hours',
|
subject: 'URGENT: Unusual activity on your account - verify within 24 hours',
|
||||||
sentAt: demoDate(-2, -3),
|
sentAt: demoDate(-2, -3),
|
||||||
preview: "We've detected suspicious activity. Click below to verify your identity or your account will be suspended...",
|
preview: "We've detected suspicious activity. Click below to verify your identity or your account will be suspended...",
|
||||||
hasAttachment: false,
|
hasAttachment: false,
|
||||||
...textOnly(
|
...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.]",
|
"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>',
|
messageId: '<phish-1@secur1ty-bank.example>',
|
||||||
},
|
},
|
||||||
@@ -829,7 +829,7 @@ export function createDemoEmails(): Email[] {
|
|||||||
receivedAt: demoDate(-3, -19),
|
receivedAt: demoDate(-3, -19),
|
||||||
from: [{ name: 'CryptoGrowth Daily', email: 'invest@cryptogrowth.example' }],
|
from: [{ name: 'CryptoGrowth Daily', email: 'invest@cryptogrowth.example' }],
|
||||||
to: [USER],
|
to: [USER],
|
||||||
subject: '🚀 The coin Elon won\'t tell you about — 1000x potential',
|
subject: '🚀 The coin Elon won\'t tell you about - 1000x potential',
|
||||||
sentAt: demoDate(-3, -19),
|
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...',
|
preview: 'Three early backers turned $500 into $5M in 90 days. Today, you have a chance to get in even earlier...',
|
||||||
hasAttachment: false,
|
hasAttachment: false,
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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": "差出人メールアドレス",
|
||||||
|
|||||||
@@ -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": "보낸 사람 이메일 주소",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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 отправителя",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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": "Електронна адреса відправника",
|
||||||
|
|||||||
@@ -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": "发件人电子邮件地址",
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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', {
|
||||||
|
|||||||
@@ -824,6 +824,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...');
|
||||||
|
|||||||
Reference in New Issue
Block a user