\n x drops @import inside a nested style tag\n expected 0 matches for /@import/, got 1\n\nLogs: https://ci.fjord-systems.example/runs/482' },
+ p2: { value: `
+
+
+
+
+ | Build #482 failed |
+ main · 42s |
+
+ |
+
+
+
+ | Repository | bulwark-webmail |
+ | Commit | a3f9c21 fix: sanitize CSS in email bodies |
+ | Author | Élise Moreau |
+ | Runner | ubuntu-24.04, node 22 |
+
+ |
+
+
+
+
+ | 247 passed |
+ 2 failed |
+ 0 skipped |
+
+
+ |
+
+ |
+
+ FAIL lib/__tests__/email-sanitization.test.ts
+ × strips javascript: URLs from style attributes
+ expected <div style="">
+ received <div style="background:url(javascript:alert(1))">
+ × drops @import inside a nested style tag
+ expected 0 matches for /@import/, got 1
+
+ |
+
+ |
+ View the run
+ Re-run failed tests
+ |
+
+ |
` },
},
},
// =====================================================================
@@ -448,92 +660,92 @@ const emails: MockEmail[] = [
id: 'email-006', threadId: 'thread-003', mailboxIds: { 'mb-sent': true }, keywords: { $seen: true }, size: 1800, receivedAt: daysAgo(2),
from: [{ name: 'Dev User', email: 'dev@localhost' }],
to: [{ name: 'Chiara Rossi', email: 'chiara@rossi.example' }], cc: [],
- subject: 'Re: Pranzo domani?',
- preview: 'Perfetto! Let\'s meet at noon.',
+ subject: 'Re: Lunch tomorrow?',
+ preview: '12:30 works. I will be the one already sitting down.',
hasAttachment: false,
- textBody: [{ partId: 'p1', blobId: 'blob-011', size: 80, type: 'text/plain' }],
+ textBody: [{ partId: 'p1', blobId: 'blob-011', size: 110, type: 'text/plain' }],
htmlBody: [],
bodyValues: {
- p1: { value: 'Perfetto! Let\'s meet at noon by the Herengracht.\n\n- Dev User' },
+ p1: { value: '12:30 works. I will be the one already sitting down.\n\nIf the risotto is bad we are never speaking of this again.' },
},
},
{
id: 'email-007', threadId: 'thread-006', mailboxIds: { 'mb-sent': true }, keywords: { $seen: true }, size: 2200, receivedAt: daysAgo(3),
from: [{ name: 'Dev User', email: 'dev@localhost' }],
to: [{ name: 'Pierre Dubois', email: 'pierre@dubois.example' }], cc: [],
- subject: 'Re: Project Update - Q1 Review',
- preview: 'Merci Pierre, the numbers look great. I\'ll prepare the board presentation.',
+ subject: 'Re: Q1 numbers, and the part I want to talk about',
+ preview: 'Twenty minutes is fine. Can you send the funnel numbers per step beforehand?',
hasAttachment: false,
- textBody: [{ partId: 'p1', blobId: 'blob-012', size: 150, type: 'text/plain' }],
+ textBody: [{ partId: 'p1', blobId: 'blob-012', size: 210, type: 'text/plain' }],
htmlBody: [],
bodyValues: {
- p1: { value: 'Merci Pierre, the numbers look great. I\'ll prepare the board presentation.\n\nCheers,\nDev User' },
+ p1: { value: 'Twenty minutes is fine, put it at the front of the call.\n\nCan you send the funnel numbers per step beforehand? If the drop is at payment details I would like to know whether it is the form or the card.\n\nDev' },
},
},
{
id: 'email-008', threadId: 'thread-007', mailboxIds: { 'mb-sent': true }, keywords: { $seen: true }, size: 3100, receivedAt: daysAgo(5),
from: [{ name: 'Dev User', email: 'dev@localhost' }],
- to: [{ name: 'Sophie Example', email: 'sophie@eurotech.example' }], cc: [],
- subject: 'Design review feedback',
- preview: 'Hallo Sophie, I reviewed the new mockups and have a few suggestions.',
+ to: [{ name: 'Sophie Müller', email: 'sophie@eurotech.example' }], cc: [],
+ subject: 'Mockup feedback',
+ preview: 'Three notes on the new screens, none of them blocking.',
hasAttachment: false,
- textBody: [{ partId: 'p1', blobId: 'blob-013', size: 300, type: 'text/plain' }],
+ textBody: [{ partId: 'p1', blobId: 'blob-013', size: 380, type: 'text/plain' }],
htmlBody: [],
bodyValues: {
- p1: { value: 'Hallo Sophie,\n\nI reviewed the new mockups and have a few suggestions:\n\n1. The sidebar could use more contrast\n2. Consider adding breadcrumbs to the settings page\n3. The compose button placement looks good\n\nOverall great work!\n\nDev User' },
+ p1: { value: 'Three notes, none of them blocking.\n\nThe sidebar labels sit at about 3:1 against the background. On my laptop outdoors they disappear.\n\nSettings needs a way back out. Breadcrumbs or a title with the section name, either is fine.\n\nThe compose button where it is now is right. I was wrong about that in the last round.\n\nDev' },
},
},
{
id: 'email-027', threadId: 'thread-013', mailboxIds: { 'mb-sent': true }, keywords: { $seen: true }, size: 1900, receivedAt: hoursAgo(0.5),
from: [{ name: 'Dev User', email: 'dev@localhost' }],
to: [{ name: 'Lars Johansson', email: 'lars.johansson@fjord-systems.example' }],
- cc: [{ name: 'Sophie Example', email: 'sophie@eurotech.example' }, { name: 'Élise Moreau', email: 'elise.moreau@fjord-systems.example' }],
- subject: 'Re: Sprint planning - next week priorities',
- preview: 'Great suggestions Sophie. 10:30 works for me. I\'ll update the calendar invite.',
+ cc: [{ name: 'Sophie Müller', email: 'sophie@eurotech.example' }, { name: 'Élise Moreau', email: 'elise.moreau@fjord-systems.example' }],
+ subject: 'Re: Sprint priorities for next week',
+ preview: '10:30 works. Signature editor goes in as item six, below the accessibility follow-ups.',
hasAttachment: false,
- textBody: [{ partId: 'p1', blobId: 'blob-048', size: 150, type: 'text/plain' }],
+ textBody: [{ partId: 'p1', blobId: 'blob-048', size: 220, type: 'text/plain' }],
htmlBody: [],
bodyValues: {
- p1: { value: 'Great suggestions Sophie. 10:30 works for me. I\'ll update the calendar invite.\n\nLars - let\'s also add a stretch goal for the email template system if we finish early.\n\n- Dev User' },
+ p1: { value: '10:30 works, I moved the invitation.\n\nSignature editor goes in as item six, below the accessibility follow-ups. Sophie, if it really is two days, it lands. If it turns into four, it comes back out.\n\nDev' },
},
},
{
id: 'email-028', threadId: 'thread-015', mailboxIds: { 'mb-sent': true }, keywords: { $seen: true }, size: 2100, receivedAt: daysAgo(1),
from: [{ name: 'Dev User', email: 'dev@localhost' }],
to: [{ name: 'Élise Moreau', email: 'elise.moreau@fjord-systems.example' }], cc: [],
- subject: 'Re: Code review request: JMAP-342 contact import',
- preview: 'Nice work on the vCard parser! Left a few comments on the PR. Main concern is memory usage for large imports.',
+ subject: 'Re: JMAP-342 is up: vCard import',
+ preview: 'Comments are on the PR. Keep the merge dialogue as it is, but the import needs to stream.',
hasAttachment: false,
- textBody: [{ partId: 'p1', blobId: 'blob-049', size: 280, type: 'text/plain' }],
+ textBody: [{ partId: 'p1', blobId: 'blob-049', size: 340, type: 'text/plain' }],
htmlBody: [],
bodyValues: {
- p1: { value: 'Nice work on the vCard parser! Left a few comments on the PR.\n\nMain concern: memory usage for large imports (1000+ contacts). Consider using a streaming parser instead of loading the entire file.\n\nAlso, the duplicate detection logic looks solid. Approved with minor changes.\n\n- Dev User' },
+ p1: { value: 'Comments are on the PR.\n\nKeep the merge dialogue as it is. "Keep both, mark one primary" is recoverable, a wrong field pick is not, and people will click through the picker without reading it.\n\nThe import reads the whole file into memory first. At 1.200 contacts my tab used 380 MB. Stream it and I will approve.\n\nDev' },
},
},
{
id: 'email-029', threadId: 'thread-018', mailboxIds: { 'mb-sent': true }, keywords: { $seen: true }, size: 1600, receivedAt: daysAgo(2),
from: [{ name: 'Dev User', email: 'dev@localhost' }],
to: [{ name: 'Astrid van der Berg', email: 'astrid@berglabs.example' }], cc: [],
- subject: 'Re: Meeting notes - API design review',
- preview: 'Thanks for the thorough notes Astrid. I\'ll have the OpenAPI spec draft ready by Friday.',
+ subject: 'Re: Notes from the API design review',
+ preview: 'One correction: the rate limit is per token, not per key. Draft lands Friday.',
hasAttachment: false,
- textBody: [{ partId: 'p1', blobId: 'blob-050', size: 120, type: 'text/plain' }],
+ textBody: [{ partId: 'p1', blobId: 'blob-050', size: 190, type: 'text/plain' }],
htmlBody: [],
bodyValues: {
- p1: { value: 'Bedankt for the thorough notes Astrid. I\'ll have the OpenAPI spec draft ready by Friday.\n\n- Dev User' },
+ p1: { value: 'One correction: we said per token, and a customer can hold several. That matters for the enterprise ticket that is open right now.\n\nOpenAPI draft lands Friday.\n\nDev' },
},
},
{
id: 'email-030', threadId: 'thread-025', mailboxIds: { 'mb-sent': true }, keywords: { $seen: true }, size: 4800, receivedAt: daysAgo(4),
from: [{ name: 'Dev User', email: 'dev@localhost' }],
to: [{ name: 'Team', email: 'team@fjord-systems.example' }], cc: [],
- subject: 'Proposal: Migrate from REST to JMAP for mail backend',
- preview: 'I\'ve been researching JMAP as a replacement for our current REST-based mail backend. Here\'s the proposal.',
+ subject: 'Proposal: move the mail backend to JMAP',
+ preview: 'Our REST layer is a worse version of a protocol that already exists. The proposal is to stop maintaining it.',
hasAttachment: true,
- textBody: [{ partId: 'p1', blobId: 'blob-051', size: 900, type: 'text/plain' }],
+ textBody: [{ partId: 'p1', blobId: 'blob-051', size: 1100, type: 'text/plain' }],
htmlBody: [],
bodyValues: {
- p1: { value: 'Hej team,\n\nI\'ve been researching JMAP (RFC 8620/8621) as a replacement for our current REST-based mail backend. Here\'s a summary:\n\nWhy JMAP?\n- Eliminates N+1 query problems with batch requests\n- Built-in push notifications via EventSource\n- Efficient delta sync reduces bandwidth by 60-80%\n- Standardized protocol with growing ecosystem\n\nProposed timeline:\n- Phase 1 (Mar): Proof of concept with mock server\n- Phase 2 (Apr): Core email operations\n- Phase 3 (May): Calendar & contacts integration\n- Phase 4 (Jun): Migration from legacy API\n\nFull proposal document attached.\n\n- Dev User' },
+ p1: { value: 'Our REST layer is a worse version of a protocol that already exists. The proposal is to stop maintaining it and speak JMAP (RFC 8620 and 8621) directly.\n\nWhat we get:\n\n- One request instead of the current fan-out. Opening a 40-message thread costs us 41 calls today.\n- Push over EventSource, so we can delete the polling worker and the Redis key it uses to deduplicate.\n- Delta sync. In the prototype, a warm sync of a 12.000-message mailbox moved 240 KB instead of 3,1 MB.\n\nWhat it costs:\n\n- Two people for roughly ten weeks.\n- A migration path for the three integrations that read our REST endpoints. Two are internal, one is a customer and needs notice.\n\nRough plan: prototype in March against a mock server, core mail in April, calendar and contacts in May, cut over in June with the old endpoints kept read-only until September.\n\nFull write-up attached, including the numbers behind the sync figure.\n\nDev' },
},
attachments: [
{ partId: 'att5', blobId: 'blob-att-005', size: 67000, name: 'JMAP-Migration-Proposal.pdf', type: 'application/pdf' },
@@ -546,39 +758,39 @@ const emails: MockEmail[] = [
id: 'email-009', threadId: 'thread-008', mailboxIds: { 'mb-drafts': true }, keywords: { $draft: true }, size: 1200, receivedAt: daysAgo(0),
from: [{ name: 'Dev User', email: 'dev@localhost' }],
to: [{ name: 'Team', email: 'team@fjord-systems.example' }], cc: [],
- subject: 'Meeting notes (draft)',
- preview: 'Notes from today\'s standup meeting...',
+ subject: 'Standup notes',
+ preview: 'Blocked on the CalendarEvent/set override question...',
hasAttachment: false,
- textBody: [{ partId: 'p1', blobId: 'blob-014', size: 200, type: 'text/plain' }],
+ textBody: [{ partId: 'p1', blobId: 'blob-014', size: 220, type: 'text/plain' }],
htmlBody: [],
bodyValues: {
- p1: { value: 'Notes from today\'s standup meeting:\n\n- TODO: fill in details\n- Action items: ...' },
+ p1: { value: 'Yesterday: threading bug, no cause yet\nToday: CalendarEvent/set overrides\nBlocked on: whether we keep the old override when the recurrence rule changes\n\nTODO: ask Lars before sending this' },
},
},
{
id: 'email-031', threadId: 'thread-026', mailboxIds: { 'mb-drafts': true }, keywords: { $draft: true }, size: 2400, receivedAt: hoursAgo(6),
from: [{ name: 'Dev User', email: 'dev@localhost' }],
to: [], cc: [],
- subject: 'Blog post: Building a JMAP client from scratch (draft)',
- preview: 'Introduction: JMAP is a modern, efficient protocol for email, calendar, and contacts...',
+ subject: 'Blog post: a JMAP client in 200 lines',
+ preview: 'IMAP makes you ask twelve times. JMAP lets you ask once. That is most of the difference...',
hasAttachment: false,
- textBody: [{ partId: 'p1', blobId: 'blob-052', size: 500, type: 'text/plain' }],
+ textBody: [{ partId: 'p1', blobId: 'blob-052', size: 560, type: 'text/plain' }],
htmlBody: [],
bodyValues: {
- p1: { value: 'Building a JMAP Client from Scratch\n\nIntroduction:\nJMAP is a modern, efficient protocol for email, calendar, and contacts. Unlike IMAP, it uses JSON over HTTP, making it much easier to work with in web applications.\n\nIn this post, we\'ll build a minimal JMAP client in TypeScript that can:\n- Authenticate and discover capabilities\n- List mailboxes and messages\n- Send emails\n\n[TODO: Add code examples]\n[TODO: Add section on error handling]\n[TODO: Conclusion]' },
+ p1: { value: 'A JMAP client in 200 lines\n\nIMAP makes you ask twelve times. JMAP lets you ask once, and that is most of the difference. The rest is JSON over HTTP, which means the whole thing fits in a file you can read on a train.\n\nWe will get a session, list mailboxes, page through an inbox and send one message.\n\n[TODO: session discovery, mention the .well-known redirect trap]\n[TODO: back-references, this is the part people miss]\n[TODO: error handling, the "notCreated" shape is unusual]\n[TODO: closing, do not turn it into a manifesto]' },
},
},
{
id: 'email-032', threadId: 'thread-027', mailboxIds: { 'mb-drafts': true }, keywords: { $draft: true }, size: 1800, receivedAt: daysAgo(1),
from: [{ name: 'Dev User', email: 'dev@localhost' }],
to: [{ name: 'CFP Committee', email: 'cfp@fosdem.example' }], cc: [],
- subject: 'Talk proposal: Modern email clients with JMAP',
- preview: 'Title: Modern Email Clients with JMAP - From Protocol to Production...',
+ subject: 'Talk proposal: webmail on JMAP',
+ preview: 'Title: What a webmail client looks like when the protocol is on your side...',
hasAttachment: false,
- textBody: [{ partId: 'p1', blobId: 'blob-053', size: 400, type: 'text/plain' }],
+ textBody: [{ partId: 'p1', blobId: 'blob-053', size: 460, type: 'text/plain' }],
htmlBody: [],
bodyValues: {
- p1: { value: 'Title: Modern Email Clients with JMAP - From Protocol to Production\n\nAbstract:\nThis talk explores building a full-featured webmail client using the JMAP protocol. We\'ll cover session negotiation, efficient data sync, real-time push notifications, and lessons learned.\n\nConference: FOSDEM 2027\nFormat: 30-minute talk\nLevel: Intermediate\n\n[TODO: Add speaker bio]\n[TODO: Complete outline]' },
+ p1: { value: 'Title: What a webmail client looks like when the protocol is on your side\n\nAbstract:\nWe built a webmail client on JMAP instead of an IMAP bridge. This talk covers what got easier (sync, push, search), what got harder (nothing else speaks it yet), and the three places where the spec left us to decide for ourselves.\n\nTrack: Modern Email\nFormat: 30 minutes\nLevel: intermediate\n\n[TODO: speaker bio, keep it to three lines]\n[TODO: outline, five bullets is enough]' },
},
},
// =====================================================================
@@ -586,41 +798,41 @@ const emails: MockEmail[] = [
// =====================================================================
{
id: 'email-010', threadId: 'thread-009', mailboxIds: { 'mb-junk': true }, keywords: {}, size: 4500, receivedAt: daysAgo(1),
- from: [{ name: 'Totally Real Prince', email: 'prince@scam.example' }],
+ from: [{ name: 'EuroMillions Claims Dept', email: 'claims@euro-lotto-payout.example' }],
to: [{ name: 'Dev User', email: 'dev@localhost' }], cc: [],
- subject: 'You have won €1.000.000!!!',
- preview: 'Congratulations! You have been selected as the winner of our international lottery.',
+ subject: 'FINAL NOTICE: your prize of €1.000.000 is waiting',
+ preview: 'Your email address was drawn in our international promotional draw. To release the funds we require your bank details.',
hasAttachment: false,
- textBody: [{ partId: 'p1', blobId: 'blob-015', size: 500, type: 'text/plain' }],
+ textBody: [{ partId: 'p1', blobId: 'blob-015', size: 520, type: 'text/plain' }],
htmlBody: [],
bodyValues: {
- p1: { value: 'Congratulations!\n\nYou have been selected as the winner of our international lottery. To claim your prize, please send your IBAN details to...\n\nCeci n\'est pas un spam.' },
+ p1: { value: 'ATTENTION BENEFICIARY,\n\nYour email address was drawn in our international promotional draw held in Madrid. Prize: ONE MILLION EURO (€1.000.000,00).\n\nTo release the funds our processing office requires:\n1. Full name and address\n2. Copy of passport\n3. IBAN and BIC\n4. Processing fee of €450 (refundable)\n\nReply within 72 hours or the prize passes to the next beneficiary.\n\nMrs. Elizabeth Okon\nClaims Officer' },
},
},
{
id: 'email-033', threadId: 'thread-028', mailboxIds: { 'mb-junk': true }, keywords: {}, size: 2200, receivedAt: hoursAgo(8),
from: [{ name: 'HTCPCP Service', email: 'noreply@teapot.example' }],
to: [{ name: 'Dev User', email: 'dev@localhost' }], cc: [],
- subject: '418 I\'m a Teapot - Your coffee request was denied',
- preview: 'Per RFC 2324, this server is a teapot and cannot brew coffee. Please try a coffee pot instead.',
+ subject: '418 I am a teapot',
+ preview: 'Per RFC 2324 this server is a teapot. Your BREW request has been declined.',
hasAttachment: false,
textBody: [{ partId: 'p1', blobId: 'blob-054', size: 250, type: 'text/plain' }],
htmlBody: [],
bodyValues: {
- p1: { value: 'HTTP/1.1 418 I\'m a Teapot\n\nPer RFC 2324 (Hyper Text Coffee Pot Control Protocol), this server is, in fact, a teapot. It is short and stout. It cannot brew coffee.\n\nPlease redirect your BREW request to a proper coffee pot.\n\nContent-Type: message/coffeepot\n\nThe teapot abides.' },
+ p1: { value: 'HTTP/1.1 418 I am a teapot\nContent-Type: message/coffeepot\n\nPer RFC 2324, this server is a teapot. It is short and stout. Your BREW request has been declined.\n\nPlease direct it at a device that can actually make coffee.' },
},
},
{
id: 'email-034', threadId: 'thread-029', mailboxIds: { 'mb-junk': true }, keywords: {}, size: 1900, receivedAt: daysAgo(2),
from: [{ name: 'CryptoTrader Pro', email: 'earn@crypto-gains.example' }],
to: [{ name: 'Dev User', email: 'dev@localhost' }], cc: [],
- subject: 'Turn €100 into €10.000 in just 7 days! 🚀',
- preview: 'Our AI trading bot has a 99.9% success rate. Start earning today!',
+ subject: 'Turn €100 into €10.000 in 7 days 🚀',
+ preview: 'Our trading bot closed 99,9% of positions in profit last month. Places are limited.',
hasAttachment: false,
textBody: [{ partId: 'p1', blobId: 'blob-055', size: 300, type: 'text/plain' }],
htmlBody: [],
bodyValues: {
- p1: { value: 'LIMITED TIME OFFER!\n\nOur revolutionary AI trading bot:\n- 99.9% success rate\n- Guaranteed returns\n- No experience needed\n\nSign up now at crypto-gains.example!' },
+ p1: { value: 'LIMITED PLACES!!!\n\nOur trading bot closed 99,9% of positions in profit last month.\n\n- No experience needed\n- Withdraw any time*\n- Start with only €100\n\nSign up today at crypto-gains.example\n\n*after the 90 day qualifying period' },
},
},
// =====================================================================
@@ -628,41 +840,41 @@ const emails: MockEmail[] = [
// =====================================================================
{
id: 'email-011', threadId: 'thread-010', mailboxIds: { 'mb-archive': true }, keywords: { $seen: true }, size: 3800, receivedAt: daysAgo(14),
- from: [{ name: 'HR Department', email: 'hr@fjord-systems.example' }],
+ from: [{ name: 'People & Culture', email: 'hr@fjord-systems.example' }],
to: [{ name: 'Dev User', email: 'dev@localhost' }], cc: [],
- subject: 'Updated Holiday Policy - EU Directive Compliance',
- preview: 'Please review the updated paid leave policy effective next month, now with 30 days minimum.',
+ subject: 'Leave policy from 1 April',
+ preview: 'Annual leave goes to 30 days for everyone, and approval moves out of email and into the HR system.',
hasAttachment: false,
- textBody: [{ partId: 'p1', blobId: 'blob-016', size: 600, type: 'text/plain' }],
+ textBody: [{ partId: 'p1', blobId: 'blob-016', size: 620, type: 'text/plain' }],
htmlBody: [],
bodyValues: {
- p1: { value: 'Hej team,\n\nPlease review the updated paid leave policy effective next month. Key changes include:\n\n- Minimum annual leave: 30 days (EU directive compliance)\n- New flexible Friday policy - Freitags um 14:00 Schluss\n- Simplified approval workflow\n- Fika breaks are now officially protected time\n\nFull details in the employee handbook.\n\nBästa hälsningar,\nHR Department' },
+ p1: { value: 'The leave policy changes on 1 April.\n\nAnnual leave goes to 30 days for everyone, including the two contracts that were on 25. Days already booked keep their approval.\n\nCarry-over is capped at 10 days and expires on 31 March of the following year. This is new, and it is the part people will read too late.\n\nApproval moves out of email and into the HR system. Your manager gets a notification, you get a calendar entry when it is approved.\n\nThe handbook has the full text. Questions go to hr@, not to your manager, we would rather answer once.\n\nPeople & Culture' },
},
},
{
id: 'email-012', threadId: 'thread-011', mailboxIds: { 'mb-archive': true }, keywords: { $seen: true, $flagged: true }, size: 2600, receivedAt: daysAgo(30),
- from: [{ name: 'Sophie Example', email: 'sophie@eurotech.example' }],
+ from: [{ name: 'Sophie Müller', email: 'sophie@eurotech.example' }],
to: [{ name: 'Dev User', email: 'dev@localhost' }], cc: [],
- subject: 'Conference talk accepted!',
- preview: 'Toll! Your talk proposal for the JMAP Conf has been accepted!',
+ subject: 'Your talk was accepted',
+ preview: 'Day one, 14:00, main hall. Thirty minutes plus ten for questions.',
hasAttachment: false,
- textBody: [{ partId: 'p1', blobId: 'blob-017', size: 350, type: 'text/plain' }],
+ textBody: [{ partId: 'p1', blobId: 'blob-017', size: 380, type: 'text/plain' }],
htmlBody: [],
bodyValues: {
- p1: { value: 'Toll!\n\nYour talk proposal "Building Modern Webmail with JMAP" for the JMAP Conf in Amsterdam has been accepted!\n\nThe conference is scheduled for next month at the RAI. More details to follow.\n\nHerzlichen Glückwunsch!\nSophie' },
+ p1: { value: 'The committee took "Building modern webmail with JMAP" for the Amsterdam conference.\n\nDay one, 14:00, main hall. Thirty minutes plus ten for questions. They want slides by the Friday before, in PDF, because the last speaker who brought Keynote cost them twenty minutes.\n\nTravel is booked, hotel is not. Tell me if you want the one next to the RAI or the quiet one twenty minutes away.\n\nSophie' },
},
},
{
id: 'email-035', threadId: 'thread-030', mailboxIds: { 'mb-archive': true }, keywords: { $seen: true }, size: 4200, receivedAt: daysAgo(60),
- from: [{ name: 'IT Abteilung', email: 'it@fjord-systems.example' }],
+ from: [{ name: 'IT', email: 'it@fjord-systems.example' }],
to: [{ name: 'Dev User', email: 'dev@localhost' }], cc: [],
- subject: 'Välkommen! Your development environment setup guide',
- preview: 'Welcome to the team! Here\'s everything you need to set up your development environment.',
+ subject: 'Your development setup',
+ preview: 'Everything you need for the first day, in the order that works.',
hasAttachment: true,
- textBody: [{ partId: 'p1', blobId: 'blob-056', size: 800, type: 'text/plain' }],
+ textBody: [{ partId: 'p1', blobId: 'blob-056', size: 820, type: 'text/plain' }],
htmlBody: [],
bodyValues: {
- p1: { value: 'Välkommen till laget!\n\nHere\'s your development environment setup guide:\n\n1. Clone the monorepo: git clone git@gitlab.example:fjord/monorepo.git\n2. Install dependencies: npm install\n3. Set up local database: docker-compose up -d\n4. Configure environment variables (see .env.example)\n5. Run the test suite: npm test\n\nAccess credentials:\n- Jira: your-email (SSO)\n- GitLab: your-email (SSO)\n- Hetzner Console: IAM user created, check Bitwarden\n\nQuestions? Reach out on #dev-onboarding in Mattermost.\n\nBästa hälsningar,\nIT Abteilung' },
+ p1: { value: 'Welcome. Everything you need for the first day, in the order that works:\n\n1. git clone git@gitlab.example:fjord/monorepo.git\n2. npm install (node 22, the repo pins it)\n3. docker compose up -d for Postgres and the mail server\n4. cp .env.example .env, then ask in #dev-onboarding for the two secrets that are not in it\n5. npm test, which should be green before you change anything\n\nAccounts: GitLab and Jira are behind SSO, so your login already works. The hosting console needs an IAM user, it is in your Bitwarden collection.\n\nThe setup guide is attached. It is a year old and mostly right. Where it is wrong, edit it, that is what it is for.\n\nIT' },
},
attachments: [
{ partId: 'att6', blobId: 'blob-att-006', size: 125000, name: 'Entwicklung-Setup-Guide.pdf', type: 'application/pdf' },
@@ -672,13 +884,13 @@ const emails: MockEmail[] = [
id: 'email-036', threadId: 'thread-031', mailboxIds: { 'mb-archive': true }, keywords: { $seen: true, $flagged: true }, size: 3100, receivedAt: daysAgo(45),
from: [{ name: 'ELSTER Online', email: 'noreply@elster.example' }],
to: [{ name: 'Dev User', email: 'dev@localhost' }], cc: [],
- subject: 'Ihre Steuererklärung 2025 - Dokumente bereit',
- preview: 'Ihre Lohnsteuerbescheinigung und Steuerbescheid sind zum Download bereit.',
+ subject: 'Ihre Dokumente für die Steuererklärung 2025 stehen bereit',
+ preview: 'Lohnsteuerbescheinigung und Bescheinigung über gezahlte Kirchensteuer liegen im Postfach bereit.',
hasAttachment: true,
- textBody: [{ partId: 'p1', blobId: 'blob-057', size: 300, type: 'text/plain' }],
+ textBody: [{ partId: 'p1', blobId: 'blob-057', size: 380, type: 'text/plain' }],
htmlBody: [],
bodyValues: {
- p1: { value: 'Sehr geehrte/r Steuerpflichtige/r,\n\nIhre Steuerdokumente für 2025 sind jetzt verfügbar:\n\n- Lohnsteuerbescheinigung\n- Steuerbescheid\n- Bescheinigung über Kirchensteuer\n\nAbgabefrist: 31. Juli 2026\n\nMelden Sie sich bei elster.example an, um Ihre Erklärung einzureichen.\n\nMit freundlichen Grüßen,\nFinanzamt' },
+ p1: { value: 'Sehr geehrte Steuerpflichtige, sehr geehrter Steuerpflichtiger,\n\nfolgende Dokumente stehen in Ihrem Postfach bereit:\n\n- Lohnsteuerbescheinigung 2025\n- Bescheinigung über gezahlte Kirchensteuer\n- Vorausgefüllte Steuererklärung (Entwurf)\n\nAbgabefrist ohne steuerliche Beratung: 31. Juli 2026.\n\nBitte melden Sie sich mit Ihrem Zertifikat unter elster.example an. Wir fordern Sie niemals per E-Mail zur Eingabe Ihrer Zugangsdaten auf.\n\nMit freundlichen Grüßen\nIhr Finanzamt' },
},
attachments: [
{ partId: 'att7', blobId: 'blob-att-007', size: 89000, name: 'Steuerdokumente-2025.pdf', type: 'application/pdf' },
@@ -689,16 +901,16 @@ const emails: MockEmail[] = [
from: [{ name: 'Chiara Rossi', email: 'chiara@rossi.example' }],
to: [{ name: 'Dev User', email: 'dev@localhost' }],
cc: [{ name: 'Pierre Dubois', email: 'pierre@dubois.example' }],
- subject: 'Team building photos from last Friday',
- preview: 'Che bella serata! Sharing the photos from our team building event at the Biergarten.',
+ subject: 'Photos from Friday',
+ preview: 'Everything from Friday evening, unsorted. Tell me if you want one taken down.',
hasAttachment: true,
- textBody: [{ partId: 'p1', blobId: 'blob-058', size: 150, type: 'text/plain' }],
+ textBody: [{ partId: 'p1', blobId: 'blob-058', size: 200, type: 'text/plain' }],
htmlBody: [],
bodyValues: {
- p1: { value: 'Che bella serata! 🎉\n\nSharing the photos from our team building event at the Biergarten am Prinsengracht. The Bretzel eating contest was legendary!\n\nPhotos attached. Feel free to share.\n\nChiara' },
+ p1: { value: 'Everything from Friday evening, unsorted, 84 of them.\n\nThere are four where Pierre is mid-sentence and looks furious. I kept them.\n\nTell me if you want one taken down before I put the album anywhere else.\n\nChiara' },
},
attachments: [
- { partId: 'att8', blobId: 'blob-att-008', size: 2400000, name: 'teambuilding-fotos.zip', type: 'application/zip' },
+ { partId: 'att8', blobId: 'blob-att-008', size: 2400000, name: 'fotos-vrijdag.zip', type: 'application/zip' },
],
},
// =====================================================================
@@ -706,15 +918,16 @@ const emails: MockEmail[] = [
// =====================================================================
{
id: 'email-038', threadId: 'thread-033', mailboxIds: { 'mb-trash': true }, keywords: { $seen: true }, size: 3500, receivedAt: daysAgo(1),
- from: [{ name: 'SaaS Product', email: 'marketing@saas-product.example' }],
+ from: [{ name: 'Kanbanist', email: 'hello@kanbanist.example' }],
to: [{ name: 'Dev User', email: 'dev@localhost' }], cc: [],
- subject: '🎉 50% off annual plans - limited time!',
- preview: 'Upgrade to our annual plan and save 50%. Offer expires this Sunday.',
+ subject: 'Your trial ends Sunday',
+ preview: 'Annual plans are 30% off until Sunday. After that your workspace goes read-only.',
hasAttachment: false,
- textBody: [{ partId: 'p1', blobId: 'blob-059', size: 400, type: 'text/plain' }],
- htmlBody: [],
+ textBody: [{ partId: 'p1', blobId: 'blob-059', size: 420, type: 'text/plain' }],
+ htmlBody: [{ partId: 'p2', blobId: 'blob-065', size: 1200, type: 'text/html' }],
bodyValues: {
- p1: { value: 'Spring sale is here!\n\nUpgrade to our annual plan and save 50%.\n\nWhat you get:\n- Unlimited users\n- Priority support\n- Advanced analytics\n- Custom integrations\n\nOffer expires Sunday, March 15, 2026.\n\nUpgrade now at saas-product.example/pricing' },
+ p1: { value: 'Your trial ends on Sunday 15 March.\n\nAnnual plans are 30% off until then: €84 per user per year instead of €120.\n\nAfter Sunday your workspace stays readable for 30 days, then it is deleted. Exports are in Settings > Data.\n\nkanbanist.example/billing' },
+ p2: { value: '
Your trial ends on Sunday 15 March.
Annual plans are 30% off until then: €84 per user per year instead of €120.
After Sunday your workspace stays readable for 30 days, then it is deleted. Exports live in Settings › Data.
Choose a planKanbanist BV, Keizersgracht 62, 1015 CS Amsterdam · Unsubscribe
' },
},
},
];
@@ -752,8 +965,8 @@ const IDENTITIES: MockIdentity[] = [
// ---------------------------------------------------------------------------
const addressBooks = [
- { id: 'ab-1', name: 'Persönlich', isDefault: true },
- { id: 'ab-2', name: 'Arbeit / Work', isDefault: false },
+ { id: 'ab-1', name: 'Personal', isDefault: true },
+ { id: 'ab-2', name: 'Work', isDefault: false },
];
// Profile photos served straight from randomuser.me's CDN; the API at
@@ -770,7 +983,7 @@ const contacts = [
phones: { p1: { number: '+49 30 8844 2200' } },
organizations: { o1: { name: 'EuroTech GmbH' } },
addresses: { a1: { street: [{ value: 'Kurfürstendamm 42' }], locality: 'Berlin', region: '', country: 'Germany', postcode: '10719' } },
- notes: { n1: { note: 'Frontend lead. Always brings Kuchen to the office.' } },
+ notes: { n1: { note: 'Frontend lead at EuroTech. Reviews quickly, comments at length.' } },
media: { photo1: { kind: 'photo' as const, uri: PORTRAIT('women', 14), mediaType: 'image/jpeg' } },
},
{ id: 'contact-002', uid: 'urn:uuid:c0000002-0000-0000-0000-000000000002', addressBookIds: { 'ab-1': true }, kind: 'individual',
@@ -779,7 +992,7 @@ const contacts = [
phones: { p1: { number: '+33 1 42 68 53 00' } },
organizations: { o1: { name: 'Dubois Consulting' } },
addresses: { a1: { street: [{ value: '42 Rue de Rivoli' }], locality: 'Paris', country: 'France', postcode: '75001' } },
- notes: { n1: { note: 'Product manager. Knows every boulangerie in Paris.' } },
+ notes: { n1: { note: 'Product manager. Would rather have a call than a thread.' } },
media: { photo1: { kind: 'photo' as const, uri: PORTRAIT('men', 23), mediaType: 'image/jpeg' } },
},
{ id: 'contact-003', uid: 'urn:uuid:c0000003-0000-0000-0000-000000000003', addressBookIds: { 'ab-1': true }, kind: 'individual',
@@ -788,7 +1001,7 @@ const contacts = [
phones: { p1: { number: '+39 02 7634 5678' } },
organizations: { o1: { name: 'Rossi Design Studio' } },
addresses: { a1: { street: [{ value: 'Via Montenapoleone 8' }], locality: 'Milano', country: 'Italy', postcode: '20121' } },
- notes: { n1: { note: 'UX designer. Her risotto recipes are legendary.' } },
+ notes: { n1: { note: 'UX designer. Sends mockups as PDFs and will not be talked out of it.' } },
media: { photo1: { kind: 'photo' as const, uri: PORTRAIT('women', 40), mediaType: 'image/jpeg' } },
},
{ id: 'contact-004', uid: 'urn:uuid:c0000004-0000-0000-0000-000000000004', addressBookIds: { 'ab-1': true }, kind: 'individual',
@@ -796,7 +1009,7 @@ const contacts = [
emails: { e1: { address: 'karel@devries.example' } },
phones: { p1: { number: '+31 20 555 0142' } },
addresses: { a1: { street: [{ value: 'Herengracht 142' }], locality: 'Amsterdam', country: 'Netherlands', postcode: '1015 BN' } },
- notes: { n1: { note: 'Backend developer. Cycles to work rain or shine - true Dutchman.' } },
+ notes: { n1: { note: 'Backend developer. Filed half of our open issues, most of them valid.' } },
media: { photo1: { kind: 'photo' as const, uri: PORTRAIT('men', 45), mediaType: 'image/jpeg' } },
},
{ id: 'contact-005', uid: 'urn:uuid:c0000005-0000-0000-0000-000000000005', addressBookIds: { 'ab-1': true }, kind: 'individual',
@@ -805,7 +1018,7 @@ const contacts = [
phones: { p1: { number: '+46 8 123 456 78' } },
organizations: { o1: { name: 'Fjord Systems AB' } },
addresses: { a1: { street: [{ value: 'Drottninggatan 42' }], locality: 'Stockholm', country: 'Sweden', postcode: '111 51' } },
- notes: { n1: { note: 'Tech lead. FIKA is sacred. Do not schedule meetings during fika.' } },
+ notes: { n1: { note: 'Tech lead in Stockholm. Nothing after 15:00 his time.' } },
media: { photo1: { kind: 'photo' as const, uri: PORTRAIT('men', 61), mediaType: 'image/jpeg' } },
},
{ id: 'contact-006', uid: 'urn:uuid:c0000006-0000-0000-0000-000000000006', addressBookIds: { 'ab-1': true }, kind: 'individual',
@@ -814,7 +1027,7 @@ const contacts = [
phones: { p1: { number: '+33 6 12 34 56 78' } },
organizations: { o1: { name: 'Fjord Systems AB' } },
addresses: { a1: { street: [{ value: '15 Boulevard Saint-Germain' }], locality: 'Paris', country: 'France', postcode: '75005' } },
- notes: { n1: { note: 'Backend dev. Remote from Paris. Once fixed a production bug from a café terrace.' } },
+ notes: { n1: { note: 'Backend developer, remote from Paris. Overlaps with Stockholm until 17:00.' } },
media: { photo1: { kind: 'photo' as const, uri: PORTRAIT('women', 29), mediaType: 'image/jpeg' } },
},
{ id: 'contact-007', uid: 'urn:uuid:c0000007-0000-0000-0000-000000000007', addressBookIds: { 'ab-1': true }, kind: 'individual',
@@ -822,7 +1035,7 @@ const contacts = [
emails: { e1: { address: 'francesco@bianchi.example' } },
phones: { p1: { number: '+39 06 9876 5432' } },
addresses: { a1: { street: [{ value: 'Via dei Condotti 22' }], locality: 'Roma', country: 'Italy', postcode: '00187' } },
- notes: { n1: { note: 'Old university friend. Once tried to implement RFC 2549 (IP over Avian Carriers) with actual pigeons. It did not scale.' } },
+ notes: { n1: { note: 'Old university friend. Runs a bookshop in Rome and still argues about type systems.' } },
media: { photo1: { kind: 'photo' as const, uri: PORTRAIT('men', 72), mediaType: 'image/jpeg' } },
},
{ id: 'contact-008', uid: 'urn:uuid:c0000008-0000-0000-0000-000000000008', addressBookIds: { 'ab-1': true }, kind: 'individual',
@@ -831,7 +1044,7 @@ const contacts = [
phones: { p1: { number: '+31 70 362 4242' } },
organizations: { o1: { name: 'BergLabs' } },
addresses: { a1: { street: [{ value: 'Prinsengracht 263' }], locality: 'Amsterdam', country: 'Netherlands', postcode: '1016 GV' } },
- notes: { n1: { note: 'Solutions architect. Her whiteboard diagrams belong in a museum.' } },
+ notes: { n1: { note: 'Solutions architect. Keeps the service diagram, ask her before drawing another one.' } },
media: { photo1: { kind: 'photo' as const, uri: PORTRAIT('women', 58), mediaType: 'image/jpeg' } },
},
{ id: 'contact-009', uid: 'urn:uuid:c0000009-0000-0000-0000-000000000009', addressBookIds: { 'ab-1': true }, kind: 'individual',
@@ -840,7 +1053,7 @@ const contacts = [
phones: { p1: { number: '+45 33 42 42 42' } },
organizations: { o1: { name: 'Nielsen Konsult' } },
addresses: { a1: { street: [{ value: 'Nyhavn 42' }], locality: 'København', country: 'Denmark', postcode: '1051' } },
- notes: { n1: { note: 'Freelance DevOps. Speaks 5 languages. Kubernetes kubectl alias: k → kansen.' } },
+ notes: { n1: { note: 'Freelance SRE. On call for our deployment windows, invoices monthly.' } },
media: { photo1: { kind: 'photo' as const, uri: PORTRAIT('men', 35), mediaType: 'image/jpeg' } },
},
{ id: 'contact-010', uid: 'urn:uuid:c0000010-0000-0000-0000-000000000010', addressBookIds: { 'ab-1': true }, kind: 'individual',
@@ -849,7 +1062,7 @@ const contacts = [
phones: { p1: { number: '+33 1 44 27 42 42' } },
organizations: { o1: { name: 'Sorbonne Université' } },
addresses: { a1: { street: [{ value: '21 Rue de l\'École de Médecine' }], locality: 'Paris', country: 'France', postcode: '75006' } },
- notes: { n1: { note: 'Professor of computer science. Thesis on formal verification of email protocols.' } },
+ notes: { n1: { note: 'Professor of computer science. Works on formal verification of mail protocols.' } },
media: { photo1: { kind: 'photo' as const, uri: PORTRAIT('women', 63), mediaType: 'image/jpeg' } },
},
// --- Work address book ---
@@ -859,7 +1072,7 @@ const contacts = [
phones: { p1: { number: '+33 1 53 67 42 00' } },
organizations: { o1: { name: 'Lefèvre & Associés' } },
addresses: { a1: { street: [{ value: '8 Avenue de l\'Opéra' }], locality: 'Paris', country: 'France', postcode: '75001' } },
- notes: { n1: { note: 'Lawyer. Specializes in IP and tech law. Always replies within 42 minutes.' } },
+ notes: { n1: { note: 'Contract and IP law. Bills in six-minute units, so keep the email short.' } },
media: { photo1: { kind: 'photo' as const, uri: PORTRAIT('men', 81), mediaType: 'image/jpeg' } },
},
{ id: 'contact-012', uid: 'urn:uuid:c0000012-0000-0000-0000-000000000012', addressBookIds: { 'ab-2': true }, kind: 'individual',
@@ -868,7 +1081,7 @@ const contacts = [
phones: { p1: { number: '+49 30 450 570 000' } },
organizations: { o1: { name: 'Charité Klinik Berlin' } },
addresses: { a1: { street: [{ value: 'Charitéplatz 1' }], locality: 'Berlin', country: 'Germany', postcode: '10117' } },
- notes: { n1: { note: 'Medical center admin. Organizes the best team events in Berlin.' } },
+ notes: { n1: { note: 'Organises the Berlin team evenings. Books everything three months ahead.' } },
media: { photo1: { kind: 'photo' as const, uri: PORTRAIT('women', 26), mediaType: 'image/jpeg' } },
},
{ id: 'contact-013', uid: 'urn:uuid:c0000013-0000-0000-0000-000000000013', addressBookIds: { 'ab-2': true }, kind: 'individual',
@@ -877,7 +1090,7 @@ const contacts = [
phones: { p1: { number: '+353 1 677 4242' } },
organizations: { o1: { name: 'Finanz Dublin' } },
addresses: { a1: { street: [{ value: '42 St. Stephen\'s Green' }], locality: 'Dublin', country: 'Ireland', postcode: 'D02 HX65' } },
- notes: { n1: { note: 'Finance lead. Can explain SEPA regulations over a pint of Guinness.' } },
+ notes: { n1: { note: 'Finance lead in Dublin. Wants the numbers before the meeting, not during it.' } },
media: { photo1: { kind: 'photo' as const, uri: PORTRAIT('men', 19), mediaType: 'image/jpeg' } },
},
{ id: 'contact-014', uid: 'urn:uuid:c0000014-0000-0000-0000-000000000014', addressBookIds: { 'ab-2': true }, kind: 'individual',
@@ -886,16 +1099,16 @@ const contacts = [
phones: { p1: { number: '+34 91 420 4242' } },
organizations: { o1: { name: 'García Design Studio' } },
addresses: { a1: { street: [{ value: 'Calle Gran Vía 42' }], locality: 'Madrid', country: 'Spain', postcode: '28013' } },
- notes: { n1: { note: 'Brand designer. Her color palettes are pure art. Siesta enthusiast.' } },
+ notes: { n1: { note: 'Brand designer. Owns the Figma library, ask before inventing a shade.' } },
media: { photo1: { kind: 'photo' as const, uri: PORTRAIT('women', 50), mediaType: 'image/jpeg' } },
},
{ id: 'contact-015', uid: 'urn:uuid:c0000015-0000-0000-0000-000000000015', addressBookIds: { 'ab-2': true }, kind: 'individual',
name: { components: [{ kind: 'given', value: 'Nils' }, { kind: 'surname', value: 'Andersson' }] },
emails: { e1: { address: 'nils@digitaal.example' } },
- phones: { p1: { number: '+31 20 624 1337' } },
+ phones: { p1: { number: '+31 20 624 8815' } },
organizations: { o1: { name: 'Digitaal BV' } },
addresses: { a1: { street: [{ value: 'Vijzelstraat 42' }], locality: 'Amsterdam', country: 'Netherlands', postcode: '1017 HK' } },
- notes: { n1: { note: 'Platform engineer. fika buddy. Appreciates a good kanelbulle.' } },
+ notes: { n1: { note: 'Platform engineer. Knows where the old DNS records are buried.' } },
media: { photo1: { kind: 'photo' as const, uri: PORTRAIT('men', 57), mediaType: 'image/jpeg' } },
},
{ id: 'contact-016', uid: 'urn:uuid:c0000016-0000-0000-0000-000000000016', addressBookIds: { 'ab-2': true }, kind: 'individual',
@@ -904,7 +1117,7 @@ const contacts = [
phones: { p1: { number: '+48 22 505 4242' } },
organizations: { o1: { name: 'Kowalska Marketing' } },
addresses: { a1: { street: [{ value: 'ul. Nowy Świat 42' }], locality: 'Warszawa', country: 'Poland', postcode: '00-363' } },
- notes: { n1: { note: 'Marketing strategist. Her campaign analytics dashboards are works of art.' } },
+ notes: { n1: { note: 'Marketing strategist. Runs the campaign reporting.' } },
media: { photo1: { kind: 'photo' as const, uri: PORTRAIT('women', 71), mediaType: 'image/jpeg' } },
},
{ id: 'contact-017', uid: 'urn:uuid:c0000017-0000-0000-0000-000000000017', addressBookIds: { 'ab-2': true }, kind: 'individual',
@@ -913,7 +1126,7 @@ const contacts = [
phones: { p1: { number: '+353 86 123 4242' } },
organizations: { o1: { name: 'Murphy Bau GmbH' } },
addresses: { a1: { street: [{ value: 'Grafton Street 42' }], locality: 'Dublin', country: 'Ireland', postcode: 'D02 R296' } },
- notes: { n1: { note: 'Construction project manager. Irish-German bilingual. Builds things that last.' } },
+ notes: { n1: { note: 'Runs the Dublin office fit-out. Reachable by phone, not by email.' } },
media: { photo1: { kind: 'photo' as const, uri: PORTRAIT('men', 93), mediaType: 'image/jpeg' } },
},
{ id: 'contact-018', uid: 'urn:uuid:c0000018-0000-0000-0000-000000000018', addressBookIds: { 'ab-2': true }, kind: 'individual',
@@ -922,7 +1135,7 @@ const contacts = [
phones: { p1: { number: '+351 21 342 4242' } },
organizations: { o1: { name: 'Ferreira Media' } },
addresses: { a1: { street: [{ value: 'Rua Augusta 42' }], locality: 'Lisboa', country: 'Portugal', postcode: '1100-053' } },
- notes: { n1: { note: 'Media consultant. Can turn any press release into poetry. Loves pastéis de nata.' } },
+ notes: { n1: { note: 'Media consultant. Handles press for the Lisbon launch.' } },
media: { photo1: { kind: 'photo' as const, uri: PORTRAIT('women', 82), mediaType: 'image/jpeg' } },
},
{ id: 'contact-019', uid: 'urn:uuid:c0000019-0000-0000-0000-000000000019', addressBookIds: { 'ab-2': true }, kind: 'individual',
@@ -931,7 +1144,7 @@ const contacts = [
phones: { p1: { number: '+32 2 555 4242' } },
organizations: { o1: { name: 'Dumont Conseil' } },
addresses: { a1: { street: [{ value: 'Avenue Louise 42' }], locality: 'Bruxelles', country: 'Belgium', postcode: '1050' } },
- notes: { n1: { note: 'Strategy consultant. Knows the difference between Belgian and French chocolate. Will argue passionately about it.' } },
+ notes: { n1: { note: 'Strategy consultant in Brussels. Good on procurement questions.' } },
media: { photo1: { kind: 'photo' as const, uri: PORTRAIT('men', 4), mediaType: 'image/jpeg' } },
},
{ id: 'contact-020', uid: 'urn:uuid:c0000020-0000-0000-0000-000000000020', addressBookIds: { 'ab-2': true }, kind: 'individual',
@@ -941,7 +1154,7 @@ const contacts = [
organizations: { o1: { name: 'Lindgren Consulting' } },
addresses: { a1: { street: [{ value: 'Strandvägen 42' }], locality: 'Stockholm', country: 'Sweden', postcode: '114 56' } },
nicknames: { n1: { name: 'Anni' } },
- notes: { n1: { note: 'Independent consultant specializing in GDPR compliance. Yes, she has opinions about cookie banners.' } },
+ notes: { n1: { note: 'Data protection consultant. Reviewed our privacy notice in January.' } },
media: { photo1: { kind: 'photo' as const, uri: PORTRAIT('women', 36), mediaType: 'image/jpeg' } },
},
// --- Groups ---
@@ -967,11 +1180,11 @@ const contacts = [
// ---------------------------------------------------------------------------
const mockCalendars = [
- { id: 'cal-1', name: 'Persönlich', color: '#4285f4', isVisible: true, isDefault: true },
- { id: 'cal-2', name: 'Arbeit', color: '#0b8043', isVisible: true, isDefault: false },
+ { id: 'cal-1', name: 'Personal', color: '#4285f4', isVisible: true, isDefault: true },
+ { id: 'cal-2', name: 'Work', color: '#0b8043', isVisible: true, isDefault: false },
{ id: 'cal-3', name: 'Team', color: '#8e24aa', isVisible: true, isDefault: false },
- { id: 'cal-4', name: 'Feiertage (EU)', color: '#f4511e', isVisible: true, isDefault: false },
- { id: 'cal-5', name: 'Geburtstage', color: '#e67c73', isVisible: true, isDefault: false },
+ { id: 'cal-4', name: 'Public holidays', color: '#f4511e', isVisible: true, isDefault: false },
+ { id: 'cal-5', name: 'Birthdays', color: '#e67c73', isVisible: true, isDefault: false },
];
function makeEvent(
@@ -1015,41 +1228,41 @@ const calendarEvents = [
participants: {
p1: participant('Dev User', 'dev@localhost', 'owner'),
p2: participant('Lars Johansson', 'lars.johansson@fjord-systems.example'),
- p3: participant('Sophie Example', 'sophie@eurotech.example'),
+ p3: participant('Sophie Müller', 'sophie@eurotech.example'),
p4: participant('Élise Moreau', 'elise.moreau@fjord-systems.example'),
},
alerts: { a1: { trigger: { '@type': 'OffsetTrigger', offset: '-PT5M', relativeTo: 'start' }, action: 'display' } },
}),
makeEvent('evt-002', 'cal-2', 'Sprint Planning', localDateTime(1, 10, 30), 'PT1H30M', {
- location: 'Konferenzsaal A',
+ location: 'Room A',
participants: {
p1: participant('Dev User', 'dev@localhost', 'owner'),
p2: participant('Lars Johansson', 'lars.johansson@fjord-systems.example'),
- p3: participant('Sophie Example', 'sophie@eurotech.example'),
+ p3: participant('Sophie Müller', 'sophie@eurotech.example'),
p4: participant('Élise Moreau', 'elise.moreau@fjord-systems.example'),
p5: participant('Astrid van der Berg', 'astrid@berglabs.example'),
},
recurrence: [{ frequency: 'weekly', byDay: [{ day: 'mo' }], interval: 2 }],
alerts: { a1: { trigger: { '@type': 'OffsetTrigger', offset: '-PT10M', relativeTo: 'start' }, action: 'display' } },
}),
- makeEvent('evt-003', 'cal-2', '1:1 with Lars', localDateTime(0, 14, 0), 'PT42M', {
+ makeEvent('evt-003', 'cal-2', '1:1 with Lars', localDateTime(0, 14, 0), 'PT30M', {
virtualLocations: { vl1: { uri: 'https://meet.example/lars-dev', name: 'Zoom' } },
participants: {
p1: participant('Dev User', 'dev@localhost', 'owner'),
p2: participant('Lars Johansson', 'lars.johansson@fjord-systems.example'),
},
- description: 'Weekly catch-up. Duration: exactly 42 minutes - the answer to everything.',
+ description: 'Weekly catch-up.',
}),
makeEvent('evt-004', 'cal-2', 'Code Review Session', localDateTime(0, 16, 0), 'PT1H', {
- location: 'Konferenzsaal B',
+ location: 'Room B',
participants: {
p1: participant('Dev User', 'dev@localhost', 'owner'),
p2: participant('Élise Moreau', 'elise.moreau@fjord-systems.example'),
},
- description: 'Review JMAP-342 contact import PR.',
+ description: 'Walk through the vCard import PR, mainly the merge dialogue.',
}),
makeEvent('evt-005', 'cal-2', 'Architecture Review', localDateTime(2, 11, 0), 'PT1H30M', {
- location: 'Konferenzsaal A',
+ location: 'Room A',
participants: {
p1: participant('Dev User', 'dev@localhost', 'owner'),
p2: participant('Astrid van der Berg', 'astrid@berglabs.example'),
@@ -1063,13 +1276,13 @@ const calendarEvents = [
virtualLocations: { vl1: { uri: 'https://meet.example/eurotech', name: 'Teams' } },
participants: {
p1: participant('Dev User', 'dev@localhost', 'owner'),
- p2: participant('Sophie Example', 'sophie@eurotech.example'),
+ p2: participant('Sophie Müller', 'sophie@eurotech.example'),
p3: participant('Pierre Dubois', 'pierre@dubois.example'),
},
- description: 'Discuss API rate limit escalation for EuroTech enterprise account.',
+ description: 'Rate limit escalation on the EuroTech account, ticket #4521.',
}),
makeEvent('evt-007', 'cal-2', 'Deployment Window', localDateTime(3, 22, 0), 'PT2H', {
- description: 'Production deployment: JMAP calendar integration v2.3.\nRollback plan in Confluence.\nOn-call: Henrik Nielsen.',
+ description: 'Calendar integration v2.3 goes to production. The rollback plan is in the runbook, Henrik is on call.',
participants: {
p1: participant('Dev User', 'dev@localhost', 'owner'),
p2: participant('Henrik Nielsen', 'henrik@nielsen-konsult.example'),
@@ -1080,7 +1293,7 @@ const calendarEvents = [
},
}),
makeEvent('evt-008', 'cal-2', 'Q1 Budget Review', localDateTime(3, 14, 0), 'PT1H', {
- location: 'Konferenzsaal B',
+ location: 'Room B',
participants: {
p1: participant('Liam Ó Donaill', 'liam.odonaill@finanz.example', 'owner'),
p2: participant('Dev User', 'dev@localhost'),
@@ -1088,12 +1301,12 @@ const calendarEvents = [
},
}),
makeEvent('evt-009', 'cal-2', 'Retro & Demo', localDateTime(4, 15, 0), 'PT1H30M', {
- location: 'Konferenzsaal A',
+ location: 'Room A',
virtualLocations: { vl1: { uri: 'https://meet.example/retro', name: 'Google Meet' } },
participants: {
p1: participant('Dev User', 'dev@localhost', 'owner'),
p2: participant('Lars Johansson', 'lars.johansson@fjord-systems.example'),
- p3: participant('Sophie Example', 'sophie@eurotech.example'),
+ p3: participant('Sophie Müller', 'sophie@eurotech.example'),
p4: participant('Élise Moreau', 'elise.moreau@fjord-systems.example'),
p5: participant('Astrid van der Berg', 'astrid@berglabs.example'),
p6: participant('Pierre Dubois', 'pierre@dubois.example'),
@@ -1105,25 +1318,25 @@ const calendarEvents = [
participants: {
p1: participant('Dev User', 'dev@localhost'),
p2: participant('María García', 'maria@garcia-design.example', 'owner'),
- p3: participant('Sophie Example', 'sophie@eurotech.example'),
+ p3: participant('Sophie Müller', 'sophie@eurotech.example'),
},
}),
makeEvent('evt-011', 'cal-2', 'API Deprecation Deadline', localDateTime(30, 0, 0), 'P1D', {
showWithoutTime: true,
- description: 'Stripe API v2023-10 deprecated. Must be on v2025-01 by today.',
+ description: 'Payment API v2023-10 stops answering today. Both keys have to be on v2025-01.',
color: '#d50000',
}),
// ===== Team calendar (cal-3) - social & team =====
- makeEvent('evt-012', 'cal-3', 'Biergarten Abend 🍺', localDateTime(5, 18, 0), 'PT3H', {
- location: 'Biergarten am Prinsengracht, Amsterdam',
- description: 'Monthly team social. Bretzel buffet included.\nVegetarian options: Käsespätzle, Kartoffelsalat.\nBring your own Dirndl/Lederhosen (optional but encouraged).',
+ makeEvent('evt-012', 'cal-3', 'Team evening', localDateTime(5, 18, 0), 'PT3H', {
+ location: 'Restaurante Fado, Zeedijk 62, Amsterdam',
+ description: 'Set menu, paid by the company. Vegetarian option has to be flagged by Wednesday.',
participants: {
p1: participant('Katrin Bauer', 'katrin.bauer@charite.example', 'owner'),
p2: participant('Dev User', 'dev@localhost'),
p3: participant('Pierre Dubois', 'pierre@dubois.example'),
p4: participant('Chiara Rossi', 'chiara@rossi.example'),
- p5: participant('Sophie Example', 'sophie@eurotech.example'),
+ p5: participant('Sophie Müller', 'sophie@eurotech.example'),
},
}),
makeEvent('evt-013', 'cal-3', 'Team Retro: What went well?', localDateTime(-2, 16, 0), 'PT1H', {
@@ -1132,23 +1345,23 @@ const calendarEvents = [
p1: participant('Dev User', 'dev@localhost', 'owner'),
p2: participant('Lars Johansson', 'lars.johansson@fjord-systems.example'),
p3: participant('Élise Moreau', 'elise.moreau@fjord-systems.example'),
- p4: participant('Sophie Example', 'sophie@eurotech.example'),
+ p4: participant('Sophie Müller', 'sophie@eurotech.example'),
},
}),
- makeEvent('evt-014', 'cal-3', 'Lunch & Learn: JMAP Protocol Deep Dive', localDateTime(4, 12, 0), 'PT1H', {
- location: 'Kantine, 2. OG',
- description: 'Presenter: Dev User\nTopic: How JMAP solves the N+1 problem and why it\'s better than IMAP for modern clients.\nPizza will be provided.',
+ makeEvent('evt-014', 'cal-3', 'Lunch & learn: how JMAP batches requests', localDateTime(4, 12, 0), 'PT1H', {
+ location: 'Canteen, second floor',
+ description: 'Dev User walks through batching and back-references, with the numbers from the prototype. Pizza at 12:00, talk at 12:15.',
participants: {
p1: participant('Dev User', 'dev@localhost', 'owner'),
p2: participant('Astrid van der Berg', 'astrid@berglabs.example'),
p3: participant('Isabelle Martin', 'isabelle.martin@sorbonne.example'),
},
}),
- makeEvent('evt-015', 'cal-3', 'Eurovision Watch Party 🎤✨', localDateTime(60, 20, 0), 'PT4H', {
- 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.',
+ makeEvent('evt-015', 'cal-3', 'Quarterly all-hands', localDateTime(60, 15, 0), 'PT1H30M', {
+ location: 'Room A, and streamed',
+ description: 'Numbers, roadmap, questions. Send questions in advance if you want an answer that has been thought about.',
participants: {
- p1: participant('Sophie Example', 'sophie@eurotech.example', 'owner'),
+ p1: participant('Sophie Müller', 'sophie@eurotech.example', 'owner'),
p2: participant('Dev User', 'dev@localhost'),
p3: participant('Pierre Dubois', 'pierre@dubois.example'),
p4: participant('Chiara Rossi', 'chiara@rossi.example'),
@@ -1156,9 +1369,9 @@ const calendarEvents = [
p6: participant('Nils Andersson', 'nils@digitaal.example'),
},
}),
- makeEvent('evt-016', 'cal-3', 'Cooking Class - Pasta Fresca', localDateTime(12, 18, 30), 'PT2H30M', {
- location: 'La Cucina Cooking School, Jordaan, Amsterdam',
- description: 'Team cooking class: fresh pasta from scratch.\nMenu: tagliatelle al ragù, ravioli ricotta e spinaci.\nChef: Chiara Rossi (guest instructor)',
+ makeEvent('evt-016', 'cal-3', 'Pasta course', localDateTime(12, 18, 30), 'PT2H30M', {
+ location: 'La Cucina, Jordaan, Amsterdam',
+ description: 'Three hours, you eat what you make. Chiara is teaching, which she volunteered for and may come to regret.',
participants: {
p1: participant('Chiara Rossi', 'chiara@rossi.example', 'owner'),
p2: participant('Dev User', 'dev@localhost'),
@@ -1169,27 +1382,27 @@ const calendarEvents = [
// ===== Personal calendar (cal-1) =====
makeEvent('evt-017', 'cal-1', 'Fika with Nils', localDateTime(2, 15, 30), 'PT1H', {
- location: 'Café de Flore, Prinsengracht, Amsterdam',
- description: 'Catch-up over coffee and kanelbullar.',
+ location: 'Koffiehuis Prinsengracht, Amsterdam',
+ description: 'Catch-up over coffee.',
}),
makeEvent('evt-018', 'cal-1', 'Lake Como Weekend', localDateTime(14, 10, 0), 'P2D', {
location: 'Villa sul Lago, Bellagio, Lake Como',
- description: 'Weekend getaway.\nConfirmation: EU42GDPR\nCheck-in: 15:00\nCheck-out: 11:00',
+ description: 'Reservation LS-4419-BG.\nCheck-in from 15:00, check-out by 11:00.\nThe key box code is in the voucher.',
showWithoutTime: true,
}),
makeEvent('evt-019', 'cal-1', 'Tandarts (Dentist)', localDateTime(7, 9, 30), 'PT45M', {
location: 'Tandartspraktijk Centrum, Reguliersgracht 12, Amsterdam',
- description: 'Regular check-up. Don\'t forget to floss!',
+ description: 'Six-month check-up.',
alerts: { a1: { trigger: { '@type': 'OffsetTrigger', offset: '-PT1H', relativeTo: 'start' }, action: 'display' } },
}),
makeEvent('evt-020', 'cal-1', 'Albert Cuyp Markt', localDateTime(6, 10, 0), 'PT2H', {
location: 'Albert Cuypstraat, Amsterdam',
- description: 'Saturday market run.\nShopping list: stroopwafels, Gouda, tulips, fresh bread, olives.',
+ description: 'Market run. Bread, cheese, olives, and whatever looks good.',
showWithoutTime: false,
}),
makeEvent('evt-021', 'cal-1', 'Cycling to Vondelpark', localDateTime(6, 14, 0), 'PT1H30M', {
location: 'Vondelpark, Amsterdam',
- description: 'Afternoon bike ride. Meet at the main entrance.',
+ description: 'Meet at the main entrance.',
}),
makeEvent('evt-022', 'cal-1', 'Yoga Class', localDateTime(0, 7, 0), 'PT1H', {
location: 'De Nieuwe Yogaschool, Laurierstraat, Amsterdam',
@@ -1198,7 +1411,7 @@ const calendarEvents = [
makeEvent('evt-023', 'cal-1', 'Dutch Language Lesson', localDateTime(1, 19, 0), 'PT1H30M', {
location: 'Taleninstituut, Plantage Middenlaan, Amsterdam',
recurrence: [{ frequency: 'weekly', byDay: [{ day: 'tu' }] }],
- description: 'Semester 3 - past tense and separable verbs. Ik heb geprobeerd...',
+ description: 'Semester 3: past tense and separable verbs.',
}),
makeEvent('evt-024', 'cal-1', 'Call with Mum', localDateTime(0, 18, 30), 'PT30M', {
recurrence: [{ frequency: 'weekly', byDay: [{ day: 'su' }] }],
@@ -1207,65 +1420,64 @@ const calendarEvents = [
// Overlapping personal events
makeEvent('evt-025', 'cal-1', 'Haircut', localDateTime(6, 14, 30), 'PT45M', {
location: 'Kapper de Luxe, Utrechtsestraat, Amsterdam',
- description: 'Overlaps with Vondelpark bike ride - need to reschedule one!',
+ description: 'Overlaps with the bike ride. One of them has to move.',
}),
// ===== Holiday calendar (cal-4) - all-day events =====
- makeEvent('evt-026', 'cal-4', 'Koningsdag 🧡', localDateTime(42, 0, 0), 'P1D', {
+ makeEvent('evt-026', 'cal-4', 'Koningsdag', localDateTime(42, 0, 0), 'P1D', {
showWithoutTime: true,
- description: 'King\'s Day - national holiday in the Netherlands.\nWear orange! Visit a vrijmarkt. Eat tompouce.',
+ description: 'Public holiday in the Netherlands. Shops shut, the city centre is closed to cars.',
color: '#ff6d00',
}),
makeEvent('evt-027', 'cal-4', 'Tag der Arbeit', localDateTime(48, 0, 0), 'P1D', {
showWithoutTime: true,
- description: 'Labour Day - public holiday in most EU countries.',
+ description: 'Public holiday in most of Europe. Stockholm and Berlin are closed.',
}),
- makeEvent('evt-028', 'cal-4', 'Europe Day 🇪🇺', localDateTime(55, 0, 0), 'P1D', {
+ makeEvent('evt-028', 'cal-4', 'Hemelvaartsdag', localDateTime(55, 0, 0), 'P1D', {
showWithoutTime: true,
- description: 'Anniversary of the Schuman Declaration (1950). The foundation of European integration.',
- color: '#003399',
+ description: 'Public holiday in the Netherlands. Most people take the Friday as well.',
}),
makeEvent('evt-029', 'cal-4', 'Bevrijdingsdag', localDateTime(52, 0, 0), 'P1D', {
showWithoutTime: true,
- description: 'Liberation Day - Dutch national holiday commemorating the end of WWII occupation.',
+ description: 'Liberation Day. A holiday for us, not for every employer in the country.',
}),
// ===== Birthday calendar (cal-5) =====
- makeEvent('evt-030', 'cal-5', '🎂 Sophie Example', localDateTime(8, 0, 0), 'P1D', {
+ makeEvent('evt-030', 'cal-5', '🎂 Sophie Müller', localDateTime(8, 0, 0), 'P1D', {
showWithoutTime: true,
recurrence: [{ frequency: 'yearly' }],
- description: 'Don\'t forget to bring Kuchen!',
+ description: 'She has said twice that she wants nothing. Bring cake anyway.',
}),
makeEvent('evt-031', 'cal-5', '🎂 Chiara Rossi', localDateTime(21, 0, 0), 'P1D', {
showWithoutTime: true,
recurrence: [{ frequency: 'yearly' }],
- description: 'She prefers tiramisu over cake.',
+ description: 'Tiramisu, not cake.',
}),
makeEvent('evt-032', 'cal-5', '🎂 Pierre Dubois', localDateTime(45, 0, 0), 'P1D', {
showWithoutTime: true,
recurrence: [{ frequency: 'yearly' }],
- description: 'Likes a good Bordeaux.',
+ description: 'Wine, and he will notice which one.',
}),
makeEvent('evt-033', 'cal-5', '🎂 Lars Johansson', localDateTime(-3, 0, 0), 'P1D', {
showWithoutTime: true,
recurrence: [{ frequency: 'yearly' }],
- description: 'Just passed! Hope you remembered.',
+ description: 'Was last week. It was not remembered.',
}),
// ===== JMAP Conf & travel (work calendar) =====
makeEvent('evt-034', 'cal-2', 'JMAP Conf Amsterdam', localDateTime(28, 9, 0), 'P2D', {
location: 'RAI Amsterdam Convention Centre',
showWithoutTime: true,
- description: 'Your talk: "Building Modern Webmail with JMAP" - Day 1, 14:00, Main Hall.\nDon\'t forget slide deck!',
+ description: 'Your talk is day one, 14:00, main hall. Slides as PDF to the organisers by the Friday before.',
participants: {
p1: participant('Dev User', 'dev@localhost'),
- p2: participant('Sophie Example', 'sophie@eurotech.example'),
+ p2: participant('Sophie Müller', 'sophie@eurotech.example'),
p3: participant('Isabelle Martin', 'isabelle.martin@sorbonne.example'),
},
}),
makeEvent('evt-035', 'cal-2', 'FOSDEM Talk Prep', localDateTime(10, 13, 0), 'PT2H', {
virtualLocations: { vl1: { uri: 'https://meet.example/fosdem-prep', name: 'Meet' } },
- description: 'Rehearse FOSDEM 2027 talk proposal.\nTitle: "Modern Email Clients with JMAP - From Protocol to Production"',
+ description: 'Run through the FOSDEM proposal end to end and cut it down to 30 minutes.',
}),
];