diff --git a/lib/__tests__/jmap-imip-message-id.test.ts b/lib/__tests__/jmap-imip-message-id.test.ts new file mode 100644 index 000000000..00d41b5a6 --- /dev/null +++ b/lib/__tests__/jmap-imip-message-id.test.ts @@ -0,0 +1,106 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { JMAPClient } from '../jmap/client'; +import type { CalendarEvent } from '../jmap/types'; + +// iMIP mail is created with its own Email/set; without a messageId the server +// synthesizes one from its hostname, which on a multi-domain server names +// another domain. It gets the same sender-domain Message-ID as other mail. + +type MethodCall = [string, Record, string]; + +function createClient(): JMAPClient { + const client = new JMAPClient('https://jmap.example.com', 'user@example.com', 'pass'); + Object.assign(client, { + apiUrl: 'https://jmap.example.com/api', + accountId: 'account-1', + username: 'user@example.com', + }); + return client; +} + +/** Answers the requests the iMIP senders make and records each created Email. */ +function mockServer() { + const emails: Array> = []; + vi.spyOn(globalThis, 'fetch').mockImplementation(async (_url, init) => { + const { methodCalls } = JSON.parse((init as { body: string }).body) as { methodCalls: MethodCall[] }; + const methodResponses = methodCalls.map(([name, args, callId]) => { + if (name === 'Mailbox/get') { + return [name, { list: [{ id: 'mb-drafts', name: 'Drafts', role: 'drafts' }, { id: 'mb-sent', name: 'Sent', role: 'sent' }] }, callId]; + } + if (name === 'Identity/get') { + return [name, { list: [{ id: 'identity-1', email: 'organizer@example.org' }, { id: 'identity-2', email: 'guest@example.net' }] }, callId]; + } + if (name === 'Email/set') { + const create = (args.create ?? {}) as Record>; + emails.push(...Object.values(create)); + return [name, { created: Object.fromEntries(Object.keys(create).map((id) => [id, { id: 'email-1' }])) }, callId]; + } + if (name === 'EmailSubmission/set') { + const created = Object.keys((args.create ?? {}) as object); + return [name, { created: Object.fromEntries(created.map((id) => [id, { id: 'sub-1' }])) }, callId]; + } + return ['error', { type: 'unknownMethod' }, callId]; + }); + const payload = { methodResponses }; + return { + ok: true, + status: 200, + text: () => Promise.resolve(JSON.stringify(payload)), + json: () => Promise.resolve(payload), + } as Response; + }); + return emails; +} + +const EVENT = { + id: 'event-1', + uid: 'uid-1@example.org', + title: 'Meet', + start: '2026-10-20T10:00:00', + duration: 'PT1H', + timeZone: 'Europe/London', + participants: { + o: { email: 'organizer@example.org', name: 'Organizer', roles: { owner: true, attendee: true } }, + g: { email: 'guest@example.net', name: 'Guest', roles: { attendee: true } }, + }, +} as unknown as CalendarEvent; + +function expectMessageIdIn(email: Record | undefined, domain: string) { + const ids = email?.messageId as string[] | undefined; + expect(ids).toHaveLength(1); + expect(ids![0]).toMatch(new RegExp(`^[^<>@\\s]+@${domain.replace(/\./g, '\\.')}$`)); +} + +describe('iMIP Message-ID', () => { + beforeEach(() => { + vi.restoreAllMocks(); + }); + + it('sets it in the organizer\'s domain on invitations', async () => { + const emails = mockServer(); + await createClient().sendImipInvitation(EVENT); + expect(emails).toHaveLength(1); + expectMessageIdIn(emails[0], 'example.org'); + }); + + it('sets it in the organizer\'s domain on cancellations', async () => { + const emails = mockServer(); + await createClient().sendImipCancellation({ ...EVENT, status: 'cancelled' } as CalendarEvent); + expect(emails).toHaveLength(1); + expectMessageIdIn(emails[0], 'example.org'); + }); + + it('sets it in the attendee\'s domain on replies', async () => { + const emails = mockServer(); + await createClient().sendImipReply({ + organizerEmail: 'organizer@example.org', + attendeeEmail: 'guest@example.net', + uid: 'uid-1@example.org', + summary: 'Meet', + dtStart: '2026-10-20T10:00:00', + status: 'ACCEPTED', + }); + expect(emails).toHaveLength(1); + expectMessageIdIn(emails[0], 'example.net'); + }); +}); diff --git a/lib/jmap/client.ts b/lib/jmap/client.ts index 5feca38d8..da90a1cf7 100644 --- a/lib/jmap/client.ts +++ b/lib/jmap/client.ts @@ -4457,6 +4457,7 @@ export class JMAPClient implements IJMAPClient { from: [{ name: opts.attendeeName || undefined, email: opts.attendeeEmail }], to: [{ name: opts.organizerName || undefined, email: opts.organizerEmail }], subject, + messageId: [generateMessageId(opts.attendeeEmail)], keywords: { "$seen": true, "$draft": true }, mailboxIds: { [draftsMailbox.id]: true }, bodyStructure: { @@ -4649,6 +4650,7 @@ export class JMAPClient implements IJMAPClient { from: [{ name: organizerName || undefined, email: organizerEmail }], to: toAddresses, subject, + messageId: [generateMessageId(organizerEmail)], keywords: { "$seen": true, "$draft": true }, mailboxIds: { [draftsMailbox.id]: true }, bodyStructure: { @@ -4805,6 +4807,7 @@ export class JMAPClient implements IJMAPClient { from: [{ name: organizerName || undefined, email: organizerEmail }], to: toAddresses, subject, + messageId: [generateMessageId(organizerEmail)], keywords: { "$seen": true, "$draft": true }, mailboxIds: { [draftsMailbox.id]: true }, bodyStructure: {