mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-11-01 08:12:43 +00:00
151 lines
9.3 KiB
TypeScript
151 lines
9.3 KiB
TypeScript
/**
|
|
* Booking, parcel and order mails for the structured-mail spec. They are put
|
|
* into the mailbox with blob upload + Email/import (SMTP on the shared test
|
|
* servers doesn't work), so the Authentication-Results each mail needs is
|
|
* written at the top of the raw message, below a Received line: Email/import
|
|
* adds no header of its own, and Bulwark only believes an
|
|
* Authentication-Results header that a Received header sits above.
|
|
*/
|
|
import { JMAP_URL } from './config';
|
|
|
|
export const SUBJECT_PREFIX = 'sm-e2e';
|
|
export const AIRLINE_DOMAIN = 'air.example.com';
|
|
export const SHOP_DOMAIN = 'shop.example.com';
|
|
|
|
const pad = (n: number) => String(n).padStart(2, '0');
|
|
|
|
/** Wall clock of an instant in a zone: { date: 'YYYY-MM-DD', time: 'HH:mm', offset: '+01:00' }. */
|
|
export function wallClock(utcMs: number, zone: string) {
|
|
const parts = Object.fromEntries(new Intl.DateTimeFormat('en-US', {
|
|
timeZone: zone, hourCycle: 'h23', year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit',
|
|
}).formatToParts(new Date(utcMs)).map((p) => [p.type, p.value]));
|
|
const date = `${parts.year}-${parts.month}-${parts.day}`;
|
|
const time = `${parts.hour}:${parts.minute}`;
|
|
const offsetMinutes = Math.round((Date.parse(`${date}T${time}:00Z`) - Math.floor(utcMs / 60000) * 60000) / 60000);
|
|
const sign = offsetMinutes < 0 ? '-' : '+';
|
|
const abs = Math.abs(offsetMinutes);
|
|
return { date, time, offset: `${sign}${pad(Math.floor(abs / 60))}:${pad(abs % 60)}` };
|
|
}
|
|
|
|
/** The test flight: LH 400 FRA → JFK, 8 h 50 min, about a month from now. */
|
|
export function testFlight(departureLocal: string) {
|
|
const day = new Date(Date.now() + 30 * 86_400_000);
|
|
const date = `${day.getUTCFullYear()}-${pad(day.getUTCMonth() + 1)}-${pad(day.getUTCDate())}`;
|
|
// Find the instant whose Berlin wall clock is `date departureLocal`.
|
|
const guess = Date.parse(`${date}T${departureLocal}:00Z`);
|
|
const offset = wallClock(guess, 'Europe/Berlin').offset;
|
|
const depUtc = Date.parse(`${date}T${departureLocal}:00${offset}`);
|
|
const arrUtc = depUtc + (8 * 60 + 50) * 60_000;
|
|
const dep = wallClock(depUtc, 'Europe/Berlin');
|
|
const arr = wallClock(arrUtc, 'America/New_York');
|
|
return {
|
|
depUtc, arrUtc, dep, arr,
|
|
departureTime: `${dep.date}T${dep.time}:00${dep.offset}`,
|
|
arrivalTime: `${arr.date}T${arr.time}:00${arr.offset}`,
|
|
};
|
|
}
|
|
|
|
function flightJsonLd(departureLocal: string, status: 'ReservationConfirmed' | 'ReservationCancelled') {
|
|
const f = testFlight(departureLocal);
|
|
return {
|
|
'@context': 'http://schema.org', '@type': 'FlightReservation', reservationNumber: 'E2EQ7K',
|
|
reservationStatus: `http://schema.org/${status}`, underName: { '@type': 'Person', name: 'Erika Mustermann' },
|
|
airplaneSeat: '12A', checkinUrl: `https://www.${AIRLINE_DOMAIN}/checkin?pnr=E2EQ7K`,
|
|
reservationFor: {
|
|
'@type': 'Flight', flightNumber: '400', airline: { '@type': 'Airline', name: 'Lufthansa', iataCode: 'LH' },
|
|
departureAirport: { '@type': 'Airport', name: 'Frankfurt Airport', iataCode: 'FRA' }, departureTime: f.departureTime,
|
|
arrivalAirport: { '@type': 'Airport', name: 'John F. Kennedy International Airport', iataCode: 'JFK' }, arrivalTime: f.arrivalTime,
|
|
},
|
|
};
|
|
}
|
|
|
|
const passing = (domain: string) =>
|
|
`Authentication-Results: mx.example.org; dkim=pass header.d=${domain} header.s=s1; spf=pass smtp.mailfrom=bounce@${domain}; dmarc=pass header.from=${domain}`;
|
|
const failing = (domain: string) =>
|
|
`Authentication-Results: mx.example.org; dkim=fail header.d=${domain}; spf=fail smtp.mailfrom=${domain}; dmarc=fail header.from=${domain}`;
|
|
const received = (domain: string) =>
|
|
`Received: from mail.${domain} (mail.${domain} [192.0.2.10]) by mx.example.org (Stalwart SMTP) with ESMTPS id e2e`;
|
|
|
|
interface MailSpec {
|
|
subject: string;
|
|
from: string;
|
|
to: string;
|
|
topHeaders: string[];
|
|
html: string;
|
|
text: string;
|
|
}
|
|
|
|
function raw(spec: MailSpec): string {
|
|
const boundary = `sm-${Math.random().toString(36).slice(2)}`;
|
|
const id = `${Math.random().toString(36).slice(2)}@${spec.from.split('@').pop()!.replace('>', '')}`;
|
|
return [
|
|
...spec.topHeaders,
|
|
`From: ${spec.from}`, `To: ${spec.to}`, `Subject: ${spec.subject}`,
|
|
`Date: ${new Date().toUTCString().replace('GMT', '+0000')}`, `Message-ID: <${id}>`, 'MIME-Version: 1.0',
|
|
`Content-Type: multipart/alternative; boundary="${boundary}"`, '',
|
|
`--${boundary}`, 'Content-Type: text/plain; charset=utf-8', '', spec.text, '',
|
|
`--${boundary}`, 'Content-Type: text/html; charset=utf-8', 'Content-Transfer-Encoding: 8bit', '', spec.html, '',
|
|
`--${boundary}--`, '',
|
|
].join('\r\n');
|
|
}
|
|
|
|
const page = (markup: unknown | null, text: string) =>
|
|
`<!DOCTYPE html><html><head><meta charset="utf-8">${markup ? `<script type="application/ld+json">${JSON.stringify(markup)}</script>` : ''}</head>`
|
|
+ `<body><p>${text}</p></body></html>`;
|
|
|
|
export type FixtureName = 'flight' | 'flightUpdate' | 'flightCancel' | 'forged' | 'localForged' | 'parcel' | 'order' | 'plain';
|
|
|
|
/** Raw messages by name, addressed to `to`; subjects are unique per run via `tag`. */
|
|
export function fixtureMails(to: string, tag: string): Record<FixtureName, { subject: string; raw: string }> {
|
|
const airline = `Example Air <bookings@${AIRLINE_DOMAIN}>`;
|
|
const shop = `Outdoor Shop <orders@${SHOP_DOMAIN}>`;
|
|
const order = {
|
|
'@context': 'http://schema.org', '@type': 'Order', merchant: { '@type': 'Organization', name: 'Outdoor Shop' },
|
|
orderNumber: 'SO-E2E-1', orderStatus: 'http://schema.org/OrderProcessing', price: '129.90', priceCurrency: 'EUR',
|
|
acceptedOffer: { '@type': 'Offer', itemOffered: { '@type': 'Product', name: 'Wanderschuhe' }, eligibleQuantity: { value: '1' } },
|
|
url: `https://www.${SHOP_DOMAIN}/account/orders/SO-E2E-1`,
|
|
};
|
|
const parcel = {
|
|
'@context': 'http://schema.org', '@type': 'ParcelDelivery', carrier: { '@type': 'Organization', name: 'DHL' },
|
|
trackingNumber: '00340434169999000001', trackingUrl: 'https://www.dhl.de/de/privatkunden/pakete-empfangen/verfolgen.html?piececode=00340434169999000001',
|
|
itemShipped: { '@type': 'Product', name: 'Wanderschuhe' },
|
|
partOfOrder: { '@type': 'Order', orderNumber: 'SO-E2E-2', merchant: { '@type': 'Organization', name: 'Outdoor Shop' }, orderStatus: 'http://schema.org/OrderInTransit' },
|
|
};
|
|
const mails: Record<FixtureName, Omit<MailSpec, 'text'>> = {
|
|
flight: { subject: 'Flight booking LH 400', from: airline, to, topHeaders: [received(AIRLINE_DOMAIN), passing(AIRLINE_DOMAIN)], html: page(flightJsonLd('10:15', 'ReservationConfirmed'), 'Your booking E2EQ7K is confirmed.') },
|
|
flightUpdate: { subject: 'Schedule change LH 400', from: airline, to, topHeaders: [received(AIRLINE_DOMAIN), passing(AIRLINE_DOMAIN)], html: page(flightJsonLd('11:00', 'ReservationConfirmed'), 'Your flight now leaves at 11:00.') },
|
|
flightCancel: { subject: 'Flight cancelled LH 400', from: airline, to, topHeaders: [received(AIRLINE_DOMAIN), passing(AIRLINE_DOMAIN)], html: page(flightJsonLd('11:00', 'ReservationCancelled'), 'Your flight was cancelled.') },
|
|
forged: { subject: 'Forged flight booking', from: airline, to, topHeaders: [received(AIRLINE_DOMAIN), failing(AIRLINE_DOMAIN)], html: page(flightJsonLd('10:15', 'ReservationConfirmed'), 'Your booking E2EQ7K is confirmed.') },
|
|
// As a signed-in local user can submit it: passing header, no Received above it.
|
|
localForged: { subject: 'Locally forged flight booking', from: airline, to, topHeaders: [passing(AIRLINE_DOMAIN)], html: page(flightJsonLd('10:15', 'ReservationConfirmed'), 'Your booking E2EQ7K is confirmed.') },
|
|
parcel: { subject: 'Your parcel is on its way', from: shop, to, topHeaders: [received(SHOP_DOMAIN), passing(SHOP_DOMAIN)], html: page(parcel, 'Your order SO-E2E-2 has shipped.') },
|
|
order: { subject: 'Your order SO-E2E-1', from: shop, to, topHeaders: [received(SHOP_DOMAIN), passing(SHOP_DOMAIN)], html: page(order, 'Thanks for your order.') },
|
|
plain: { subject: 'Your order without markup', from: shop, to, topHeaders: [received(SHOP_DOMAIN), passing(SHOP_DOMAIN)], html: page(null, 'Thanks for your order.') },
|
|
};
|
|
return Object.fromEntries(Object.entries(mails).map(([name, spec]) => {
|
|
const subject = `${SUBJECT_PREFIX} ${tag} ${spec.subject}`;
|
|
const text = /<p>(.*?)<\/p>/.exec(spec.html)?.[1] ?? spec.subject;
|
|
return [name, { subject, raw: raw({ ...spec, subject, text }) }];
|
|
})) as Record<FixtureName, { subject: string; raw: string }>;
|
|
}
|
|
|
|
/** Upload a raw message and Email/import it into the Inbox. Returns the email id. */
|
|
export async function importMail(
|
|
client: { request: (calls: [string, Record<string, unknown>, string][], using?: string[]) => Promise<any>; accountId: string },
|
|
auth: string,
|
|
rawMessage: string,
|
|
inboxId: string,
|
|
): Promise<string> {
|
|
const upload = await fetch(`${JMAP_URL}/jmap/upload/${client.accountId}/`, {
|
|
method: 'POST', headers: { Authorization: auth, 'Content-Type': 'message/rfc822' }, body: rawMessage,
|
|
});
|
|
if (!upload.ok) throw new Error(`upload failed: ${upload.status} ${await upload.text()}`);
|
|
const { blobId } = await upload.json();
|
|
const r = await client.request([['Email/import', {
|
|
accountId: client.accountId, emails: { m: { blobId, mailboxIds: { [inboxId]: true }, keywords: {} } },
|
|
}, '0']]);
|
|
const id = r.methodResponses[0][1].created?.m?.id;
|
|
if (!id) throw new Error(`Email/import failed: ${JSON.stringify(r.methodResponses[0][1])}`);
|
|
return id;
|
|
}
|