feat: merge booking parts into one card per booking

This commit is contained in:
Linus Rath
2026-10-30 04:33:57 +01:00
parent 76b0c2fba2
commit fa324170a2
8 changed files with 2045 additions and 8 deletions
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,79 @@
[
{
"id": "flixbus-booking",
"sender": "booking.flixbus.com",
"source": "real",
"mailDate": "2024-05-28T15:18:21Z",
"html": "<!DOCTYPE html><html><head><meta charset=\"utf-8\"><script type=\"application/ld+json\">[{\"@context\":\"http://schema.org\",\"@type\":\"BusReservation\",\"reservationNumber\":\"3165000001\",\"underName\":{\"@type\":\"Person\",\"name\":\"Erika Mustermann\",\"email\":\"kunde@example.org\"},\"reservationStatus\":\"http://schema.org/ReservationConfirmed\",\"reservationFor\":{\"@type\":\"BusTrip\",\"busNumber\":\"FLX30\",\"busName\":\"Berlin Ostbahnhof\",\"busCompany\":{\"@type\":\"Organization\",\"name\":\"Flix\"},\"departureBusStop\":{\"@type\":\"BusStop\",\"name\":\"Hannover Hauptbahnhof (FlixTrain)\",\"address\":{\"@type\":\"PostalAddress\",\"streetAddress\":\"Ernst-August-Platz 1\",\"addressLocality\":\"Hannover\",\"postalCode\":\"30159\",\"addressCountry\":\"DE\"}},\"departureTime\":\"2024-06-04T11:44:00+0200\",\"arrivalBusStop\":{\"@type\":\"BusStop\",\"name\":\"Berlin Ostbahnhof (FlixTrain)\",\"address\":{\"@type\":\"PostalAddress\",\"streetAddress\":\"Koppenstr. 3\",\"addressLocality\":\"Berlin\",\"postalCode\":\"10243\",\"addressCountry\":\"DE\"}},\"arrivalTime\":\"2024-06-04T13:44:00+0200\"},\"reservedTicket\":{\"@type\":\"Ticket\",\"ticketNumber\":\"3165000001\",\"ticketToken\":\"https://shop.global.flixbus.com/pdfqr/3165000001/00000000000000000000000000000000000000000000000000?mfb_source=email\",\"underName\":{\"@type\":\"Person\",\"name\":\"Erika Mustermann\"},\"ticketedSeat\":{\"@type\":\"Seat\",\"seatingType\":\"Coach\",\"seatNumber\":\"6D\"}}},{\"@context\":\"http://schema.org\",\"@type\":\"BusReservation\",\"reservationNumber\":\"3165000001\",\"underName\":{\"@type\":\"Person\",\"name\":\"Max Mustermann\",\"email\":\"kunde@example.org\"},\"reservationStatus\":\"http://schema.org/ReservationConfirmed\",\"reservationFor\":{\"@type\":\"BusTrip\",\"busNumber\":\"FLX30\",\"busName\":\"Berlin Ostbahnhof\",\"busCompany\":{\"@type\":\"Organization\",\"name\":\"Flix\"},\"departureBusStop\":{\"@type\":\"BusStop\",\"name\":\"Hannover Hauptbahnhof (FlixTrain)\",\"address\":{\"@type\":\"PostalAddress\",\"streetAddress\":\"Ernst-August-Platz 1\",\"addressLocality\":\"Hannover\",\"postalCode\":\"30159\",\"addressCountry\":\"DE\"}},\"departureTime\":\"2024-06-04T11:44:00+0200\",\"arrivalBusStop\":{\"@type\":\"BusStop\",\"name\":\"Berlin Ostbahnhof (FlixTrain)\",\"address\":{\"@type\":\"PostalAddress\",\"streetAddress\":\"Koppenstr. 3\",\"addressLocality\":\"Berlin\",\"postalCode\":\"10243\",\"addressCountry\":\"DE\"}},\"arrivalTime\":\"2024-06-04T13:44:00+0200\"},\"reservedTicket\":{\"@type\":\"Ticket\",\"ticketNumber\":\"3165000001\",\"ticketToken\":\"https://shop.global.flixbus.com/pdfqr/3165000001/00000000000000000000000000000000000000000000000000?mfb_source=email\",\"underName\":{\"@type\":\"Person\",\"name\":\"Max Mustermann\"},\"ticketedSeat\":{\"@type\":\"Seat\",\"seatingType\":\"Coach\",\"seatNumber\":\"6C\"}}}]</script></head><body></body></html>"
},
{
"id": "flixbus-invoice",
"sender": "flixbus.com",
"source": "real",
"mailDate": "2024-05-28T15:18:20Z",
"html": "<!DOCTYPE html><html><head><meta charset=\"utf-8\"><script type=\"application/ld+json\">[{\"@context\":\"http:\\/\\/schema.org\",\"@type\":\"BusReservation\",\"reservationNumber\":3165000001,\"underName\":{\"@type\":\"Person\",\"name\":\"Max Mustermann\",\"email\":\"kunde@example.org\"},\"bookingAgent\":{\"@type\":\"Organization\",\"name\":\"Flix SE\",\"url\":\"http:\\/\\/shop.flixbus.de\"},\"bookingTime\":\"2024-05-28T17:18:16+0200\",\"modifiedTime\":\"2024-05-28T17:18:17+0200\",\"reservationStatus\":\"http:\\/\\/schema.org\\/ReservationConfirmed\",\"reservationFor\":{\"@type\":\"BusTrip\",\"busNumber\":\"FLX30\",\"busName\":\"Berlin Ostbahnhof\",\"busCompany\":{\"@type\":\"Organization\",\"name\":\"Flix SE\"},\"departureBusStop\":{\"@type\":\"BusStop\",\"name\":\"Hannover Hauptbahnhof (FlixTrain)\",\"address\":{\"@type\":\"PostalAddress\",\"streetAddress\":\"Ernst-August-Platz 1\",\"addressLocality\":\"Hannover\",\"addressRegion\":\"\",\"postalCode\":\"30159\",\"addressCountry\":\"DE\"},\"directions\":\"Wagennummerierung beginnt an der Zugspitze. Wagen 100 befindet sich in der Zugmitte.\"},\"departureTime\":\"2024-06-04T11:44:00+0200\",\"arrivalBusStop\":{\"@type\":\"BusStop\",\"name\":\"Berlin Ostbahnhof (FlixTrain)\",\"address\":{\"@type\":\"PostalAddress\",\"streetAddress\":\"Koppenstr. 3\",\"addressLocality\":\"Berlin\",\"addressRegion\":\"\",\"postalCode\":\"10243\",\"addressCountry\":\"DE\"},\"directions\":\"\"},\"arrivalTime\":\"2024-06-04T13:44:00+0200\"},\"reservedTicket\":{\"@type\":\"Ticket\",\"ticketNumber\":3165000001,\"ticketToken\":\"https:\\/\\/api.flixbus.com\\/qrcode\\/de\\/3165000001\\/00000000000000000000000000000000000000000000000000\",\"price\":\"14.9700\",\"priceCurrency\":\"EUR\",\"underName\":{\"@type\":\"Person\",\"name\":\"Max Mustermann\"}}},{\"@context\":\"http:\\/\\/schema.org\",\"@type\":\"BusReservation\",\"reservationNumber\":3165000001,\"underName\":{\"@type\":\"Person\",\"name\":\"Erika Mustermann\",\"email\":\"kunde@example.org\"},\"bookingAgent\":{\"@type\":\"Organization\",\"name\":\"Flix SE\",\"url\":\"http:\\/\\/shop.flixbus.de\"},\"bookingTime\":\"2024-05-28T17:18:16+0200\",\"modifiedTime\":\"2024-05-28T17:18:17+0200\",\"reservationStatus\":\"http:\\/\\/schema.org\\/ReservationConfirmed\",\"reservationFor\":{\"@type\":\"BusTrip\",\"busNumber\":\"FLX30\",\"busName\":\"Berlin Ostbahnhof\",\"busCompany\":{\"@type\":\"Organization\",\"name\":\"Flix SE\"},\"departureBusStop\":{\"@type\":\"BusStop\",\"name\":\"Hannover Hauptbahnhof (FlixTrain)\",\"address\":{\"@type\":\"PostalAddress\",\"streetAddress\":\"Ernst-August-Platz 1\",\"addressLocality\":\"Hannover\",\"addressRegion\":\"\",\"postalCode\":\"30159\",\"addressCountry\":\"DE\"},\"directions\":\"Wagennummerierung beginnt an der Zugspitze. Wagen 100 befindet sich in der Zugmitte.\"},\"departureTime\":\"2024-06-04T11:44:00+0200\",\"arrivalBusStop\":{\"@type\":\"BusStop\",\"name\":\"Berlin Ostbahnhof (FlixTrain)\",\"address\":{\"@type\":\"PostalAddress\",\"streetAddress\":\"Koppenstr. 3\",\"addressLocality\":\"Berlin\",\"addressRegion\":\"\",\"postalCode\":\"10243\",\"addressCountry\":\"DE\"},\"directions\":\"\"},\"arrivalTime\":\"2024-06-04T13:44:00+0200\"},\"reservedTicket\":{\"@type\":\"Ticket\",\"ticketNumber\":3165000001,\"ticketToken\":\"https:\\/\\/api.flixbus.com\\/qrcode\\/de\\/3165000001\\/00000000000000000000000000000000000000000000000000\",\"price\":\"14.9700\",\"priceCurrency\":\"EUR\",\"underName\":{\"@type\":\"Person\",\"name\":\"Erika Mustermann\"}}}]</script></head><body></body></html>"
},
{
"id": "kaufland-order-1",
"sender": "kaufland-marktplatz.de",
"source": "real",
"mailDate": "2024-10-07T20:20:57Z",
"html": "<!DOCTYPE html><html><head><meta charset=\"utf-8\"></head><body><span itemscope itemtype=\"http://schema.org/Order\"> <span itemprop=\"merchant\" itemscope itemtype=\"http://schema.org/Organization\"> <meta itemprop=\"name\" content=\"Kaufland.de\"> <link itemprop=\"url\" href=\"https://www.kaufland.de\"> </span> <span itemprop=\"potentialAction\" itemscope itemtype=\"http://schema.org/ViewAction\"> <link itemprop=\"target\" href=\"https://www.kaufland.de/account/orders?utm_medium=email&amp;utm_content=purchase_confirmation_gmu_de-de&amp;utm_source=real&amp;utm_campaign=customer#100000000000001\"> <meta itemprop=\"name\" content=\"Bestellung ansehen\"> <meta itemprop=\"url\" content=\"https://www.kaufland.de/account/orders?utm_medium=email&amp;utm_content=purchase_confirmation_gmu_de-de&amp;utm_source=real&amp;utm_campaign=customer#100000000000001\"> </span> <meta itemprop=\"orderNumber\" content=\"M100AA1\"> <meta itemprop=\"priceCurrency\" content=\"EUR\"> <meta itemprop=\"price\" content=\"399,00 €\"> <span itemprop=\"acceptedOffer\" itemscope itemtype=\"http://schema.org/Offer\"> <span itemprop=\"itemOffered\" itemscope itemtype=\"http://schema.org/Product\"> <meta itemprop=\"name\" content=\"Creality K1 3D-Drucker\"> <meta itemprop=\"image\" content=\"https://media.cdn.kaufland.de/product-images/100x100/4af2c802b184554243cd708e9d83a180.jpg\"> <meta itemprop=\"sku\" content=\"464541313\"> <meta itemprop=\"url\" content=\"https://www.kaufland.de/product/464541313/?utm_medium=email&amp;utm_content=purchase_confirmation_gmu_de-de&amp;utm_source=real&amp;utm_campaign=customer\"> </span> <meta itemprop=\"price\" content=\"399,00 €\"> <meta itemprop=\"priceCurrency\" content=\"EUR\"> <span itemprop=\"eligibleQuantity\" itemscope itemtype=\"http://schema.org/QuantitativeValue\"> <meta itemprop=\"value\" content=\"1\"> </span> </span> <meta itemprop=\"orderStatus\" content=\"\"> <meta itemprop=\"url\" content=\"https://www.kaufland.de/account/orders?utm_medium=email&amp;utm_content=purchase_confirmation_gmu_de-de&amp;utm_source=real&amp;utm_campaign=customer#100000000000001\"> <meta itemprop=\"orderStatus\" content=\"http://schema.org/OrderProcessing\"> <span itemprop=\"priceSpecification\" itemscope itemtype=\"http://schema.org/PriceSpecification\"> <meta itemprop=\"price\" content=\"0,00 €\"> <meta itemprop=\"priceCurrency\" content=\"EUR\"> </span> </span></body></html>"
},
{
"id": "kaufland-parcel-1",
"sender": "kaufland-marktplatz.de",
"source": "real",
"mailDate": "2024-10-08T13:50:25Z",
"html": "<!DOCTYPE html><html><head><meta charset=\"utf-8\"></head><body><span itemscope=\"\" itemtype=\"http://schema.org/ParcelDelivery\"> <span itemprop=\"deliveryAddress\" itemscope=\"\" itemtype=\"http://schema.org/PostalAddress\"> <meta itemprop=\"name\" content=\"Erika Mustermann\"> <meta itemprop=\"streetAddress\" content=\"Musterweg 1\"> <meta itemprop=\"addressLocality\" content=\"Musterstadt\"> <meta itemprop=\"addressRegion\" content=\"Musterstadt\"> <meta itemprop=\"addressCountry\" content=\"DE\"> <meta itemprop=\"postalCode\" content=\"12345\"> </span> <meta itemprop=\"expectedArrivalUntil\" content=\"2024-10-13CEST03:10:23\"> <span itemprop=\"itemShipped\" itemscope=\"\" itemtype=\"http://schema.org/Product\"> <meta itemprop=\"name\" content=\"Creality K1 3D-Drucker\"> <link itemprop=\"url\" href=\"https://www.kaufland.de/product/464541313/?utm_medium=email&amp;utm_content=purchase_sent_confirmation&amp;utm_source=real&amp;utm_campaign=customer\"> <link itemprop=\"image\" href=\"https://media.cdn.kaufland.de/product-images/100x100/4af2c802b184554243cd708e9d83a180.jpg\"> <meta itemprop=\"sku\" content=\"464541313\"> </span> <span itemprop=\"partOfOrder\" itemscope=\"\" itemtype=\"http://schema.org/Order\"> <meta itemprop=\"orderNumber\" content=\"M100AA1\"> <span itemprop=\"merchant\" itemscope=\"\" itemtype=\"http://schema.org/Organization\"> <meta itemprop=\"name\" content=\"Kaufland.de\"> <link itemprop=\"sameAs\" href=\"https://www.kaufland.de\"> </span> <link itemprop=\"orderStatus\" href=\"http://schema.org/OrderInTransit\"> </span> </span></body></html>"
},
{
"id": "kaufland-order-2",
"sender": "kaufland-marktplatz.de",
"source": "real",
"mailDate": "2024-11-11T14:33:42Z",
"html": "<!DOCTYPE html><html><head><meta charset=\"utf-8\"></head><body><span itemscope itemtype=\"http://schema.org/Order\"> <span itemprop=\"merchant\" itemscope itemtype=\"http://schema.org/Organization\"> <meta itemprop=\"name\" content=\"Kaufland.de\"> <link itemprop=\"url\" href=\"https://www.kaufland.de\"> </span> <span itemprop=\"potentialAction\" itemscope itemtype=\"http://schema.org/ViewAction\"> <link itemprop=\"target\" href=\"https://www.kaufland.de/account/orders?utm_medium=email&amp;utm_content=purchase_confirmation_gmu_de-de&amp;utm_source=real&amp;utm_campaign=customer#100000000000002\"> <meta itemprop=\"name\" content=\"Bestellung ansehen\"> <meta itemprop=\"url\" content=\"https://www.kaufland.de/account/orders?utm_medium=email&amp;utm_content=purchase_confirmation_gmu_de-de&amp;utm_source=real&amp;utm_campaign=customer#100000000000002\"> </span> <meta itemprop=\"orderNumber\" content=\"M200BB2\"> <meta itemprop=\"priceCurrency\" content=\"EUR\"> <meta itemprop=\"price\" content=\"29,99 €\"> <span itemprop=\"acceptedOffer\" itemscope itemtype=\"http://schema.org/Offer\"> <span itemprop=\"itemOffered\" itemscope itemtype=\"http://schema.org/Product\"> <meta itemprop=\"name\" content=\"Atomstack Maker AC1 Kamera 5 Megapixel 400x400mm Fotografiebereich für Atomstack NEJE Tool Graviermaschine\"> <meta itemprop=\"image\" content=\"https://media.cdn.kaufland.de/product-images/100x100/29e4eac9c493c96fbe5d122f305ea21b.jpg\"> <meta itemprop=\"sku\" content=\"460132763\"> <meta itemprop=\"url\" content=\"https://www.kaufland.de/product/460132763/?utm_medium=email&amp;utm_content=purchase_confirmation_gmu_de-de&amp;utm_source=real&amp;utm_campaign=customer\"> </span> <meta itemprop=\"price\" content=\"29,99 €\"> <meta itemprop=\"priceCurrency\" content=\"EUR\"> <span itemprop=\"eligibleQuantity\" itemscope itemtype=\"http://schema.org/QuantitativeValue\"> <meta itemprop=\"value\" content=\"1\"> </span> </span> <meta itemprop=\"orderStatus\" content=\"\"> <meta itemprop=\"url\" content=\"https://www.kaufland.de/account/orders?utm_medium=email&amp;utm_content=purchase_confirmation_gmu_de-de&amp;utm_source=real&amp;utm_campaign=customer#100000000000002\"> <meta itemprop=\"orderStatus\" content=\"http://schema.org/OrderProcessing\"> <span itemprop=\"priceSpecification\" itemscope itemtype=\"http://schema.org/PriceSpecification\"> <meta itemprop=\"price\" content=\"0,00 €\"> <meta itemprop=\"priceCurrency\" content=\"EUR\"> </span> </span></body></html>"
},
{
"id": "kaufland-parcel-2",
"sender": "kaufland-marktplatz.de",
"source": "real",
"mailDate": "2024-11-12T10:20:17Z",
"html": "<!DOCTYPE html><html><head><meta charset=\"utf-8\"></head><body><span itemscope=\"\" itemtype=\"http://schema.org/ParcelDelivery\"> <span itemprop=\"deliveryAddress\" itemscope=\"\" itemtype=\"http://schema.org/PostalAddress\"> <meta itemprop=\"name\" content=\"Erika Mustermann\"> <meta itemprop=\"streetAddress\" content=\"Musterweg 1\"> <meta itemprop=\"addressLocality\" content=\"Musterstadt\"> <meta itemprop=\"addressRegion\" content=\"Musterstadt\"> <meta itemprop=\"addressCountry\" content=\"DE\"> <meta itemprop=\"postalCode\" content=\"12345\"> </span> <meta itemprop=\"expectedArrivalUntil\" content=\"2024-11-17CET11:11:17\"> <span itemprop=\"itemShipped\" itemscope=\"\" itemtype=\"http://schema.org/Product\"> <meta itemprop=\"name\" content=\"Atomstack Maker AC1 Kamera 5 Megapixel 400x400mm Fotografiebereich für Atomstack NEJE Tool Graviermaschine\"> <link itemprop=\"url\" href=\"https://www.kaufland.de/product/460132763/?utm_medium=email&amp;utm_content=purchase_sent_confirmation&amp;utm_source=real&amp;utm_campaign=customer\"> <link itemprop=\"image\" href=\"https://media.cdn.kaufland.de/product-images/100x100/29e4eac9c493c96fbe5d122f305ea21b.jpg\"> <meta itemprop=\"sku\" content=\"460132763\"> </span> <span itemprop=\"partOfOrder\" itemscope=\"\" itemtype=\"http://schema.org/Order\"> <meta itemprop=\"orderNumber\" content=\"M200BB2\"> <span itemprop=\"merchant\" itemscope=\"\" itemtype=\"http://schema.org/Organization\"> <meta itemprop=\"name\" content=\"Kaufland.de\"> <link itemprop=\"sameAs\" href=\"https://www.kaufland.de\"> </span> <link itemprop=\"orderStatus\" href=\"http://schema.org/OrderInTransit\"> </span> </span></body></html>"
},
{
"id": "ahoi-event",
"sender": "ahoi.cloud",
"source": "real",
"mailDate": "2021-05-28T13:30:10Z",
"html": "<!DOCTYPE html><html><head><meta charset=\"utf-8\"><script type=\"application/ld+json\">[{\"@context\":\"https:\\/\\/schema.org\",\"@type\":\"EventReservation\",\"underName\":{\"@type\":\"Person\",\"name\":\"Erika Mustermann\"},\"reservationNumber\":\"AB12CD\",\"reservationStatus\":\"http:\\/\\/schema.org\\/Confirmed\",\"modifiedTime\":\"2021-05-28T15:30:04+02:00\",\"modifyReservationUrl\":\"http:\\/\\/default\\/buchung\\/AB12CD\\/Mustermann\",\"ticketDownloadUrl\":\"http:\\/\\/default\\/ticket\\/00000000000000000000000000000000.pdf\",\"ticketToken\":\"http:\\/\\/default\\/ticket\\/00000000000000000000000000000000.pdf\",\"ticketNumber\":\"AB12CD-1\",\"reservationFor\":{\"@type\":\"Event\",\"name\":\"Grafikdesign: Illustrator-Workshop\",\"startDate\":\"2021-07-18T14:00:00+02:00\",\"endDate\":\"2021-07-18T19:00:00+02:00\"}}]</script></head><body></body></html>"
},
{
"id": "k4g-order",
"sender": "k4g.com",
"source": "real",
"mailDate": "2021-12-04T22:05:29Z",
"html": "<!DOCTYPE html><html><head><meta charset=\"utf-8\"><script type=\"application/ld+json\">{\"@context\":\"http:\\/\\/schema.org\",\"@type\":\"Order\",\"merchant\":{\"@type\":\"Organization\",\"name\":\"K4G\"},\"orderNumber\":\"A1B2C3D4\",\"priceCurrency\":\"EUR\",\"price\":25.99,\"acceptedOffer\":[{\"@type\":\"Offer\",\"itemOffered\":{\"@type\":\"Product\",\"name\":\"Farming Simulator 22 Steam CD Key\",\"sku\":\"892E8BE9\",\"url\":\"https:\\/\\/k4g.com\\/product\\/farming-simulator-22-steam-global-cd-key-6C745D16\",\"image\":\"https:\\/\\/cdn.k4g.com\\/files\\/cover\\/3b1c373f87c917c637c21b681a9b5464.jpeg\"},\"price\":25.99,\"priceCurrency\":\"EUR\",\"eligibleQuantity\":{\"@type\":\"QuantitativeValue\",\"value\":1},\"seller\":{\"@type\":\"Organization\",\"name\":\"Fenyx_Entertainment\"}}],\"url\":\"https:\\/\\/k4g.com\\/order?id=A1B2C3D4&token=0000000000000000000000000000000000000000000000000000000000000000\",\"priceSpecification\":{\"@type\":\"PriceSpecification\",\"price\":25.99,\"priceCurrency\":\"EUR\",\"valueAddedTaxIncluded\":true},\"orderStatus\":\"OrderProcessing\"}</script></head><body></body></html>"
},
{
"id": "uber-order-1",
"sender": "uber.com",
"source": "real",
"mailDate": "2025-01-22T22:39:53Z",
"html": "<!DOCTYPE html><html><head><meta charset=\"utf-8\"></head><body><div itemscope itemtype=\"http://schema.org/Order\" style=\"display:none!important\"><div itemprop=\"merchant\" itemscope itemtype=\"http://schema.org/Organization\"><meta itemprop=\"name\" content=\"UberEATS\"></div></div></body></html>"
},
{
"id": "uber-order-2",
"sender": "uber.com",
"source": "real",
"mailDate": "2025-02-06T00:08:10Z",
"html": "<!DOCTYPE html><html><head><meta charset=\"utf-8\"></head><body><div itemscope itemtype=\"http://schema.org/Order\" style=\"display:none!important\"><div itemprop=\"merchant\" itemscope itemtype=\"http://schema.org/Organization\"><meta itemprop=\"name\" content=\"UberEATS\"></div></div></body></html>"
},
{
"id": "zave-newsletter",
"sender": "p.zave.it",
"source": "real",
"mailDate": "2024-11-12T00:27:23Z",
"html": "<!DOCTYPE html><html><head><meta charset=\"utf-8\"><script type=\"application/ld+json\">\r\n{\r\n \"@context\": \"http://schema.org\",\r\n \"@type\": \"FlightReservation\",\r\n \"reservationNumber\": \"RXJ34P\",\r\n \"reservationStatus\": \"http://schema.org/Confirmed\",\r\n \"underName\": {\r\n \"@type\": \"Person\",\r\n \"name\": \"Eva Green\"\r\n },\r\n \"reservationFor\": {\r\n \"@type\": \"Flight\",\r\n \"flightNumber\": \"110\",\r\n \"airline\": {\r\n \"@type\": \"Airline\",\r\n \"name\": \"United\",\r\n \"iataCode\": \"UA\"\r\n },\r\n \"departureAirport\": {\r\n \"@type\": \"Airport\",\r\n \"name\": \"San Francisco Airport\",\r\n \"iataCode\": \"SFO\"\r\n },\r\n \"departureTime\": \"2027-03-04T20:15:00-08:00\",\r\n \"arrivalAirport\": {\r\n \"@type\": \"Airport\",\r\n \"name\": \"John F. Kennedy International Airport\",\r\n \"iataCode\": \"JFK\"\r\n },\r\n \"arrivalTime\": \"2027-03-05T06:30:00-05:00\"\r\n }\r\n}\r\n</script></head><body></body></html>"
}
]
@@ -0,0 +1,335 @@
/**
* Self-written fixtures, modelled on Google's markup reference and on the
* kinds of broken input KItinerary's tests cover (written from scratch, not
* copied). Booking references and names are made up. `expected` lives in
* structured-mail-fixtures.test.ts.
*/
export interface MailFixture {
id: string;
sender: string;
source: 'real' | 'synthetic';
mailDate: string;
html: string;
}
const MAIL_DATE = '2026-10-30T09:00:00Z';
const page = (head: string, body = '') => `<!DOCTYPE html><html><head><meta charset="utf-8">${head}</head><body><p>Thank you for your booking.</p>${body}</body></html>`;
const script = (text: string) => `<script type="application/ld+json">${text}</script>`;
const ld = (value: unknown) => script(JSON.stringify(value));
const C = 'http://schema.org';
const person = (name: string) => ({ '@type': 'Person', name });
const airport = (name: string, iataCode: string) => ({ '@type': 'Airport', name, iataCode });
function flightRes(o: {
ref: string; name: string; seat?: string; airline?: [string, string]; number: string;
from: [string, string]; dep: string; to: [string, string]; arr: string; status?: string; checkin?: string;
}) {
return {
'@context': C, '@type': 'FlightReservation', reservationNumber: o.ref,
reservationStatus: `http://schema.org/${o.status ?? 'ReservationConfirmed'}`,
underName: person(o.name),
...(o.seat ? { airplaneSeat: o.seat } : {}),
...(o.checkin ? { checkinUrl: o.checkin } : {}),
reservationFor: {
'@type': 'Flight', flightNumber: o.number,
airline: { '@type': 'Airline', name: (o.airline ?? ['Lufthansa', 'LH'])[0], iataCode: (o.airline ?? ['Lufthansa', 'LH'])[1] },
departureAirport: airport(...o.from), departureTime: o.dep,
arrivalAirport: airport(...o.to), arrivalTime: o.arr,
},
};
}
const fra: [string, string] = ['Frankfurt Airport', 'FRA'];
const jfk: [string, string] = ['John F. Kennedy International Airport', 'JFK'];
const out1 = { number: '400', from: fra, dep: '2026-12-01T10:15:00+01:00', to: jfk, arr: '2026-12-01T13:05:00-05:00' };
const back1 = { number: '401', from: jfk, dep: '2026-12-08T17:30:00-05:00', to: fra, arr: '2026-12-09T07:20:00+01:00' };
const eurowingsLegs = [
flightRes({ ref: 'EW7Q2K', name: 'Erika Mustermann', airline: ['Eurowings', 'EW'], number: 'EW582', from: ['Köln/Bonn', 'CGN'], dep: '2026-11-20T06:40:00+01:00', to: ['Palma de Mallorca', 'PMI'], arr: '2026-11-20T09:05:00+01:00', seat: '3A' }),
flightRes({ ref: 'EW7Q2K', name: 'Erika Mustermann', airline: ['Eurowings', 'EW'], number: 'EW583', from: ['Palma de Mallorca', 'PMI'], dep: '2026-11-27T10:00:00+01:00', to: ['Köln/Bonn', 'CGN'], arr: '2026-11-27T12:25:00+01:00', seat: '14F' }),
];
const voloteaPax = ['Erika Mustermann', 'Max Mustermann'].map((name, i) => flightRes({
ref: 'V0L8TA', name, airline: ['Volotea', 'V7'], number: '1520', seat: `1${i}C`,
from: ['Venezia', 'VCE'], dep: '2026-12-12T14:10:00+01:00', to: ['Nantes', 'NTE'], arr: '2026-12-12T16:15:00+01:00',
}));
const hotel = {
'@context': C, '@type': 'LodgingReservation', reservationNumber: 'HB-58213',
reservationStatus: 'http://schema.org/Confirmed', underName: person('Erika Mustermann'),
reservationFor: {
'@type': 'Hotel', name: 'Hotel Am Markt', telephone: '+49 30 1234567',
address: { '@type': 'PostalAddress', streetAddress: 'Marktplatz 1', addressLocality: 'Berlin', postalCode: '10178', addressCountry: 'DE' },
},
checkinDate: '2026-12-04T15:00:00+01:00', checkoutDate: '2026-12-06T11:00:00+01:00',
modifyReservationUrl: 'https://www.hotel-booking.example.com/manage/HB-58213',
};
const microdataFlight = (o: { ref: string; name: string; number: string; code: string; airline: string; from: [string, string]; dep: string; to: [string, string]; arr?: string }) => `
<div itemscope itemtype="http://schema.org/FlightReservation">
<meta itemprop="reservationNumber" content="${o.ref}">
<link itemprop="reservationStatus" href="http://schema.org/Confirmed">
<div itemprop="underName" itemscope itemtype="http://schema.org/Person"><meta itemprop="name" content="${o.name}"></div>
<div itemprop="reservationFor" itemscope itemtype="http://schema.org/Flight">
<meta itemprop="flightNumber" content="${o.number}">
<div itemprop="airline" itemscope itemtype="http://schema.org/Airline"><meta itemprop="name" content="${o.airline}"><meta itemprop="iataCode" content="${o.code}"></div>
<div itemprop="departureAirport" itemscope itemtype="http://schema.org/Airport"><meta itemprop="name" content="${o.from[0]}"><meta itemprop="iataCode" content="${o.from[1]}"></div>
<meta itemprop="departureTime" content="${o.dep}">
<div itemprop="arrivalAirport" itemscope itemtype="http://schema.org/Airport"><meta itemprop="name" content="${o.to[0]}"><meta itemprop="iataCode" content="${o.to[1]}"></div>
${o.arr ? `<meta itemprop="arrivalTime" content="${o.arr}">` : ''}
</div>
</div>`;
const parcel = {
'@context': C, '@type': 'ParcelDelivery',
carrier: { '@type': 'Organization', name: 'DHL' }, trackingNumber: '00340434161234567890',
trackingUrl: 'https://www.dhl.de/de/privatkunden/pakete-empfangen/verfolgen.html?piececode=00340434161234567890',
expectedArrivalUntil: '2026-11-03T18:00:00+01:00',
itemShipped: [{ '@type': 'Product', name: 'Wanderschuhe', image: 'https://img.shop.example.com/p/1.jpg' }, { '@type': 'Product', name: 'Socken' }],
partOfOrder: { '@type': 'Order', orderNumber: 'SO-449120', merchant: { '@type': 'Organization', name: 'Outdoor Shop' }, orderStatus: 'http://schema.org/OrderInTransit' },
};
const orderObj = {
'@context': C, '@type': 'Order', merchant: { '@type': 'Organization', name: 'Outdoor Shop' }, orderNumber: 'SO-449120',
orderStatus: 'http://schema.org/OrderProcessing', priceCurrency: 'EUR', price: '129.90',
acceptedOffer: [
{ '@type': 'Offer', itemOffered: { '@type': 'Product', name: 'Wanderschuhe' }, price: '119.90', priceCurrency: 'EUR', eligibleQuantity: { '@type': 'QuantitativeValue', value: '1' } },
{ '@type': 'Offer', itemOffered: { '@type': 'Product', name: 'Socken' }, price: '5.00', priceCurrency: 'EUR', eligibleQuantity: { '@type': 'QuantitativeValue', value: '2' } },
],
url: 'https://www.outdoor-shop.example.com/account/orders/SO-449120',
};
export const syntheticMails: MailFixture[] = [
// Two passengers, two legs: four objects, one booking.
{
id: 'flight-two-passengers-two-legs', sender: 'airline-a.example', source: 'synthetic', mailDate: MAIL_DATE,
html: page(ld([
flightRes({ ref: 'QX7K2M', name: 'Erika Mustermann', seat: '12A', ...out1, checkin: 'https://www.airline-a.example.com/checkin?pnr=QX7K2M' }),
flightRes({ ref: 'QX7K2M', name: 'Max Mustermann', seat: '12B', ...out1, checkin: 'https://www.airline-a.example.com/checkin?pnr=QX7K2M' }),
flightRes({ ref: 'QX7K2M', name: 'Erika Mustermann', seat: '31K', ...back1 }),
flightRes({ ref: 'QX7K2M', name: 'Max Mustermann', seat: '31J', ...back1 }),
])),
},
// Eurowings-style: objects in a top-level array without commas between them.
{
id: 'eurowings-missing-commas', sender: 'airline-b.example', source: 'synthetic', mailDate: MAIL_DATE,
html: page(script(`[${eurowingsLegs.map((l) => JSON.stringify(l, null, 1)).join('\n')}]`)),
},
// Volotea-style: several objects without the array brackets.
{
id: 'volotea-missing-brackets', sender: 'airline-c.example', source: 'synthetic', mailDate: MAIL_DATE,
html: page(script(voloteaPax.map((p) => JSON.stringify(p)).join(',\n'))),
},
// Eventbrite-style trailing commas.
{
id: 'eventbrite-trailing-commas', sender: 'tickets.example', source: 'synthetic', mailDate: MAIL_DATE,
html: page(script(`{
"@context": "http://schema.org", "@type": "EventReservation", "reservationNumber": "EB-1029384756",
"underName": {"@type": "Person", "name": "Erika Mustermann",},
"reservationFor": {"@type": "Event", "name": "Jazz im Park", "startDate": "2026-11-14T19:00:00+01:00", "endDate": "2026-11-14T22:30:00+01:00",
"location": {"@type": "Place", "name": "Stadtpark", "address": {"@type": "PostalAddress", "streetAddress": "Parkweg 2", "addressLocality": "Hamburg", "addressCountry": "DE",},},},
"ticketDownloadUrl": "https://tickets.example.com/t/EB-1029384756",
}`)),
},
// Airbnb-style XML-entity-encoded JSON.
{
id: 'airbnb-entities', sender: 'stays.example', source: 'synthetic', mailDate: MAIL_DATE,
html: page(script(JSON.stringify({
'@context': C, '@type': 'LodgingReservation', reservationNumber: 'HMXK29Q4ZP',
reservationFor: { '@type': 'LodgingBusiness', name: 'Altbauwohnung am Kanal', address: { '@type': 'PostalAddress', addressLocality: 'Amsterdam', addressCountry: 'NL' } },
checkinDate: '2027-01-15', checkoutDate: '2027-01-19', underName: person('Erika Mustermann'),
}).replace(/"/g, '&quot;'))),
},
// @graph wrapper around a hotel reservation and its organisation.
{
id: 'graph-hotel', sender: 'hotel-booking.example.com', source: 'synthetic', mailDate: MAIL_DATE,
html: page(ld({ '@context': C, '@graph': [{ ...hotel, '@context': undefined }, { '@type': 'Organization', name: 'Hotel Booking' }] })),
},
// Several @types; train seat with coach.
{
id: 'train-multiple-types', sender: 'rail.example', source: 'synthetic', mailDate: MAIL_DATE,
html: page(ld({
'@context': C, '@type': ['TrainReservation', 'Thing'], reservationNumber: 'RX4471', underName: 'Erika Mustermann',
reservationStatus: 'ReservationConfirmed',
reservationFor: {
'@type': 'TrainTrip', trainNumber: 'ICE 577', provider: { '@type': 'Organization', name: 'Rail Co' },
departureStation: { '@type': 'TrainStation', name: 'Hamburg Hbf', address: { '@type': 'PostalAddress', addressLocality: 'Hamburg', addressCountry: 'DE' } },
departureTime: '2026-11-18T08:01:00+01:00',
arrivalStation: { '@type': 'TrainStation', name: 'München Hbf', address: { '@type': 'PostalAddress', addressLocality: 'München', addressCountry: 'DE' } },
arrivalTime: '2026-11-18T13:58:00+01:00',
},
reservedTicket: { '@type': 'Ticket', ticketedSeat: { '@type': 'Seat', seatSection: '12', seatNumber: '45' } },
})),
},
// reservationFor as an array: one reservation per event.
{
id: 'event-reservationfor-array', sender: 'tickets.example', source: 'synthetic', mailDate: MAIL_DATE,
html: page(ld({
'@context': C, '@type': 'EventReservation', reservationNumber: 'KT-7781', underName: person('Erika Mustermann'),
reservationFor: [
{ '@type': 'TheaterEvent', name: 'Faust I', startDate: '2026-11-21T19:30:00+01:00', location: { '@type': 'Place', name: 'Schauspielhaus', address: { addressLocality: 'Bochum', addressCountry: 'DE' } } },
{ '@type': 'TheaterEvent', name: 'Faust II', startDate: '2026-11-22T18:00:00+01:00', location: { '@type': 'Place', name: 'Schauspielhaus', address: { addressLocality: 'Bochum', addressCountry: 'DE' } } },
],
})),
},
// Legacy property names: reservationId, checkinTime/checkoutTime, Url; string address.
{
id: 'hotel-legacy-names', sender: 'hotel-chain.example', source: 'synthetic', mailDate: MAIL_DATE,
html: page(ld({
'@context': 'https://schema.org/', '@type': 'LodgingReservation', reservationId: '84123376',
reservationFor: { '@type': 'Resort', name: 'Seehotel Weiß', address: 'Seestraße 3, 83727 Schliersee, Deutschland' },
checkinTime: '2026-12-27', checkoutTime: '2027-01-02', Url: 'https://hotel-chain.example.com/r/84123376',
})),
},
// Restaurant subtype; string address.
{
id: 'restaurant-cafe', sender: 'tables.example', source: 'synthetic', mailDate: MAIL_DATE,
html: page(ld({
'@context': C, '@type': 'FoodEstablishmentReservation', reservationNumber: 'T-2231', reservationStatus: 'http://schema.org/Confirmed',
underName: person('Erika Mustermann'), partySize: 4, startTime: '2026-11-07T19:30:00+01:00',
reservationFor: { '@type': 'CafeOrCoffeeShop', name: 'Café Kranzler', address: 'Kurfürstendamm 18, 10719 Berlin' },
})),
},
// Event with a date-only end (end of that day) and a country object.
{
id: 'event-date-only-end', sender: 'festival.example', source: 'synthetic', mailDate: MAIL_DATE,
html: page(ld({
'@context': C, '@type': 'EventReservation', reservationNumber: 'FS26-0042', underName: 'Erika Mustermann',
reservationFor: { '@type': 'Festival', name: 'Winterfest', startDate: '2026-12-11', endDate: '2026-12-13',
location: { '@type': 'Place', name: 'Messegelände', address: { '@type': 'PostalAddress', addressLocality: 'Wien', addressCountry: { '@type': 'Country', name: 'Austria' } } } },
})),
},
// Lufthansa-style microdata with the arrival time missing: nothing.
{
id: 'microdata-flight-missing-arrival', sender: 'airline-d.example', source: 'synthetic', mailDate: MAIL_DATE,
html: page('', microdataFlight({ ref: 'MD9X1Z', name: 'Erika Mustermann', number: '1234', code: 'LH', airline: 'Lufthansa', from: ['München', 'MUC'], dep: '2026-11-10T07:00:00+01:00', to: ['Hamburg', 'HAM'] })),
},
// Austrian-style microdata, two legs as two items.
{
id: 'microdata-two-legs', sender: 'airline-e.example', source: 'synthetic', mailDate: MAIL_DATE,
html: page('',
microdataFlight({ ref: 'OS4K7P', name: 'Erika Mustermann', number: 'OS122', code: 'OS', airline: 'Austrian', from: ['Wien', 'VIE'], dep: '2026-11-25T06:50:00+01:00', to: ['Frankfurt', 'FRA'], arr: '2026-11-25T08:15:00+01:00' })
+ microdataFlight({ ref: 'OS4K7P', name: 'Erika Mustermann', number: 'OS125', code: 'OS', airline: 'Austrian', from: ['Frankfurt', 'FRA'], dep: '2026-11-27T19:40:00+01:00', to: ['Wien', 'VIE'], arr: '2026-11-27T21:00:00+01:00' })),
},
// Iberia-style microdata: itemprop lists and values from text and datetime.
{
id: 'microdata-itemprop-lists', sender: 'airline-f.example', source: 'synthetic', mailDate: MAIL_DATE,
html: page('', `
<table itemscope itemtype="http://schema.org/FlightReservation"><tr>
<td>Booking <span itemprop="reservationNumber">IB55XQ</span></td>
<td itemprop="underName" itemscope itemtype="http://schema.org/Person"><span itemprop="name">Erika Mustermann</span></td>
<td itemprop="reservationFor" itemscope itemtype="http://schema.org/Flight">
<span itemprop="airline provider" itemscope itemtype="http://schema.org/Airline"><span itemprop="name">Iberia</span> (<span itemprop="iataCode">IB</span>)</span>
<span itemprop="flightNumber">IB 3107</span>
<span itemprop="departureAirport" itemscope itemtype="http://schema.org/Airport"><span itemprop="name">Madrid</span> <span itemprop="iataCode">MAD</span></span>
<time itemprop="departureTime" datetime="2026-11-16T09:30:00+01:00">16 Nov 09:30</time>
<span itemprop="arrivalAirport" itemscope itemtype="http://schema.org/Airport"><span itemprop="name">Barcelona</span> <span itemprop="iataCode">BCN</span></span>
<time itemprop="arrivalTime" datetime="2026-11-16T10:50:00+01:00">10:50</time>
</td></tr></table>`),
},
// Dates-only stay at a Resort, https context.
{
id: 'hotel-dates-only', sender: 'stays.example', source: 'synthetic', mailDate: MAIL_DATE,
html: page(ld({
'@context': 'https://schema.org', '@type': 'LodgingReservation', reservationNumber: 'RS-1188',
reservationFor: { '@type': 'Resort', name: 'Strandresort Usedom', address: { '@type': 'PostalAddress', addressLocality: 'Heringsdorf', addressCountry: 'DE' } },
checkinDate: '2027-06-01', checkoutDate: '2027-06-08',
})),
},
// Rental car.
{
id: 'rental-car', sender: 'cars.example', source: 'synthetic', mailDate: MAIL_DATE,
html: page(ld({
'@context': C, '@type': 'RentalCarReservation', reservationNumber: 'RC-99123', underName: person('Erika Mustermann'),
reservationFor: { '@type': 'Car', name: 'Golf', brand: { '@type': 'Brand', name: 'VW' }, rentalCompany: { '@type': 'Organization', name: 'Car Rental Co' } },
pickupLocation: { '@type': 'Place', name: 'Flughafen BER', address: { '@type': 'PostalAddress', addressLocality: 'Schönefeld', addressCountry: 'DE' } },
pickupTime: '2026-12-04T09:00:00+01:00',
dropoffLocation: { '@type': 'Place', name: 'Flughafen BER', address: { '@type': 'PostalAddress', addressLocality: 'Schönefeld', addressCountry: 'DE' } },
dropoffTime: '2026-12-07T18:00:00+01:00',
})),
},
// A parcel on its own.
{
id: 'parcel', sender: 'outdoor-shop.example.com', source: 'synthetic', mailDate: MAIL_DATE,
html: page(ld(parcel)),
},
// Order and parcel for the same order number in one mail: one card.
{
id: 'order-and-parcel', sender: 'outdoor-shop.example.com', source: 'synthetic', mailDate: MAIL_DATE,
html: page(ld([orderObj, parcel])),
},
// Cancelled flight.
{
id: 'flight-cancelled', sender: 'airline-a.example', source: 'synthetic', mailDate: MAIL_DATE,
html: page(ld(flightRes({ ref: 'QX7K2M', name: 'Erika Mustermann', ...out1, status: 'ReservationCancelled' }))),
},
// Eastbound over the date line: lands earlier on the wall clock.
{
id: 'flight-date-line-eastbound', sender: 'airline-g.example', source: 'synthetic', mailDate: MAIL_DATE,
html: page(ld(flightRes({ ref: 'QF8H2L', name: 'Erika Mustermann', airline: ['Qantas', 'QF'], number: '11', from: ['Sydney', 'SYD'], dep: '2026-12-04T09:50:00+11:00', to: ['Los Angeles', 'LAX'], arr: '2026-12-04T06:20:00-08:00' }))),
},
// ---- doubtful: must give nothing
{
id: 'doubtful-arrival-before-departure', sender: 'airline-a.example', source: 'synthetic', mailDate: MAIL_DATE,
html: page(ld(flightRes({ ref: 'BAD001', name: 'Erika Mustermann', ...out1, arr: '2026-12-01T03:05:00-05:00' }))),
},
{
id: 'doubtful-25-hour-flight', sender: 'airline-a.example', source: 'synthetic', mailDate: MAIL_DATE,
html: page(ld(flightRes({ ref: 'BAD002', name: 'Erika Mustermann', ...out1, arr: '2026-12-02T15:15:00-05:00' }))),
},
{
id: 'doubtful-years-away', sender: 'stays.example', source: 'synthetic', mailDate: MAIL_DATE,
html: page(ld({ ...hotel, reservationNumber: 'BAD003', checkinDate: '2029-05-01', checkoutDate: '2029-05-03' })),
},
{
id: 'doubtful-checkout-before-checkin', sender: 'stays.example', source: 'synthetic', mailDate: MAIL_DATE,
html: page(ld({ ...hotel, reservationNumber: 'BAD004', checkinDate: '2026-12-06', checkoutDate: '2026-12-04' })),
},
{
id: 'doubtful-rental-missing-times', sender: 'cars.example', source: 'synthetic', mailDate: MAIL_DATE,
html: page(ld({ '@context': C, '@type': 'RentalCarReservation', reservationNumber: 'BAD005', reservationFor: { '@type': 'Car', name: 'Golf' }, pickupTime: '2026-12-04', dropoffTime: '2026-12-07' })),
},
{
id: 'doubtful-bare-date-departure', sender: 'airline-a.example', source: 'synthetic', mailDate: MAIL_DATE,
html: page(ld(flightRes({ ref: 'BAD006', name: 'Erika Mustermann', ...out1, dep: '2026-12-01' }))),
},
{
id: 'doubtful-google-example', sender: 'newsletter.example', source: 'synthetic', mailDate: '2026-12-20T09:00:00Z',
html: page(ld(flightRes({ ref: 'RXJ34P', name: 'Eva Green', airline: ['United', 'UA'], number: '110', seat: '9A', from: ['San Francisco Airport', 'SFO'], dep: '2027-03-04T20:15:00-08:00', to: ['John F. Kennedy International Airport', 'JFK'], arr: '2027-03-05T06:30:00-05:00' }))),
},
{
id: 'doubtful-unknown-types-only', sender: 'newsletter.example', source: 'synthetic', mailDate: MAIL_DATE,
html: page(ld([{ '@context': C, '@type': 'PromotionCard', image: 'https://cdn.example.com/a.png' }, { '@context': C, '@type': 'DiscountOffer', description: '20% off' }])),
},
{
id: 'doubtful-json-beyond-repair', sender: 'airline-a.example', source: 'synthetic', mailDate: MAIL_DATE,
html: page(script('{"@context": "http://schema.org", "@type": "FlightReservation", "reservationNumber": "BAD007", "reservationFor": {"@type": "Flight"')),
},
{
id: 'doubtful-foreign-context', sender: 'airline-a.example', source: 'synthetic', mailDate: MAIL_DATE,
html: page(ld({ ...flightRes({ ref: 'BAD008', name: 'Erika Mustermann', ...out1 }), '@context': 'http://vocab.example.com/' })),
},
{
id: 'doubtful-zone-mismatch', sender: 'airline-a.example', source: 'synthetic', mailDate: MAIL_DATE,
html: page(ld(flightRes({ ref: 'BAD009', name: 'Erika Mustermann', ...out1, dep: '2026-12-01T10:15:00+00:00', arr: '2026-12-01T13:05:00-05:00' }))),
},
{
// One leg of a two-leg booking lacks its arrival: the whole booking goes.
id: 'doubtful-one-bad-leg', sender: 'airline-a.example', source: 'synthetic', mailDate: MAIL_DATE,
html: page(ld([
flightRes({ ref: 'BAD010', name: 'Erika Mustermann', ...out1 }),
flightRes({ ref: 'BAD010', name: 'Erika Mustermann', ...back1, arr: '' }),
])),
},
{
// The same leg twice with different times: the parts disagree.
id: 'doubtful-conflicting-parts', sender: 'airline-a.example', source: 'synthetic', mailDate: MAIL_DATE,
html: page(ld([
flightRes({ ref: 'BAD011', name: 'Erika Mustermann', ...out1 }),
flightRes({ ref: 'BAD011', name: 'Max Mustermann', ...out1, arr: '2026-12-01T14:05:00-05:00' }),
])),
},
];
@@ -0,0 +1,91 @@
import { describe, expect, it } from 'vitest';
import { readStructuredMail } from '..';
import type { Shipment, TransportBooking } from '../types';
const MAIL = { mailDate: new Date('2026-10-30T09:00:00Z') };
const C = 'http://schema.org';
const page = (...blocks: unknown[]) => `<html><head>${blocks.map((b) => `<script type="application/ld+json">${JSON.stringify(b)}</script>`).join('')}</head><body></body></html>`;
function leg(name: string, number: string, dep: string, arr: string, status = 'ReservationConfirmed', seat?: string) {
return {
'@context': C, '@type': 'FlightReservation', reservationNumber: 'QX7K2M', underName: { name }, reservationStatus: status,
...(seat ? { airplaneSeat: seat } : {}),
reservationFor: {
'@type': 'Flight', flightNumber: number, airline: { name: 'Lufthansa', iataCode: 'LH' },
departureAirport: { iataCode: number === '400' ? 'FRA' : 'JFK' }, departureTime: dep,
arrivalAirport: { iataCode: number === '400' ? 'JFK' : 'FRA' }, arrivalTime: arr,
},
};
}
const OUT: [string, string] = ['2026-12-01T10:15:00+01:00', '2026-12-01T13:05:00-05:00'];
const BACK: [string, string] = ['2026-12-08T17:30:00-05:00', '2026-12-09T07:20:00+01:00'];
describe('merging', () => {
it('folds two passengers on two legs into one booking with two legs and two names', () => {
const cards = readStructuredMail(page([
leg('Erika Mustermann', '400', ...OUT, undefined, '12A'), leg('Max Mustermann', '400', ...OUT, undefined, '12B'),
leg('Erika Mustermann', '401', ...BACK), leg('Max Mustermann', '401', ...BACK),
]), MAIL) as TransportBooking[];
expect(cards).toHaveLength(1);
expect(cards[0].passengers).toEqual(['Erika Mustermann', 'Max Mustermann']);
expect(cards[0].legs.map((l) => l.number)).toEqual(['400', '401']);
expect(cards[0].legs[0].seats).toEqual([{ passenger: 'Erika Mustermann', seat: '12A' }, { passenger: 'Max Mustermann', seat: '12B' }]);
});
it('sorts legs by departure even when the mail lists them backwards', () => {
const [card] = readStructuredMail(page([leg('Erika Mustermann', '401', ...BACK), leg('Erika Mustermann', '400', ...OUT)]), MAIL) as TransportBooking[];
expect(card.legs.map((l) => l.number)).toEqual(['400', '401']);
});
it('keeps a leg live when only one of its passengers is cancelled', () => {
const [card] = readStructuredMail(page([
leg('Erika Mustermann', '400', ...OUT), leg('Max Mustermann', '400', ...OUT, 'ReservationCancelled'),
]), MAIL) as TransportBooking[];
expect(card.legs[0].status).toBe('confirmed');
expect(card.status).toBe('confirmed');
});
it('cancels a leg, and the booking, only when every part is cancelled', () => {
const [partly] = readStructuredMail(page([
leg('Erika Mustermann', '400', ...OUT, 'ReservationCancelled'), leg('Erika Mustermann', '401', ...BACK),
]), MAIL) as TransportBooking[];
expect(partly.legs.map((l) => l.status)).toEqual(['cancelled', 'confirmed']);
expect(partly.status).toBe('confirmed');
const [all] = readStructuredMail(page([
leg('Erika Mustermann', '400', ...OUT, 'ReservationCancelled'), leg('Erika Mustermann', '401', ...BACK, 'Cancelled'),
]), MAIL) as TransportBooking[];
expect(all.status).toBe('cancelled');
});
it('drops the booking when two parts disagree about one leg, or one part is invalid', () => {
expect(readStructuredMail(page([leg('Erika Mustermann', '400', ...OUT), leg('Max Mustermann', '400', OUT[0], '2026-12-01T14:05:00-05:00')]), MAIL)).toEqual([]);
expect(readStructuredMail(page([leg('Erika Mustermann', '400', ...OUT), leg('Erika Mustermann', '401', BACK[0], '')]), MAIL)).toEqual([]);
});
it('collapses the same booking given as JSON-LD and as microdata', () => {
const microdata = `<div itemscope itemtype="http://schema.org/Order"><meta itemprop="orderNumber" content="SO-1"><div itemprop="merchant" itemscope itemtype="http://schema.org/Organization"><meta itemprop="name" content="Shop"></div></div>`;
const html = page({ '@context': C, '@type': 'Order', orderNumber: 'SO-1', merchant: 'Shop', price: 10, priceCurrency: 'EUR' }).replace('<body></body>', `<body>${microdata}</body>`);
const cards = readStructuredMail(html, MAIL) as Shipment[];
expect(cards).toHaveLength(1);
expect(cards[0].total).toEqual({ amount: '10.00', currency: 'EUR' });
});
it('makes one card of an Order and a ParcelDelivery for the same order number', () => {
const cards = readStructuredMail(page(
{ '@context': C, '@type': 'Order', orderNumber: 'SO-1', merchant: 'Shop', orderStatus: 'OrderProcessing', acceptedOffer: { itemOffered: { name: 'Schuhe' } } },
{ '@context': C, '@type': 'ParcelDelivery', trackingNumber: 'T1', carrier: 'DHL', partOfOrder: { '@type': 'Order', orderNumber: 'SO-1', orderStatus: 'OrderInTransit' } },
{ '@context': C, '@type': 'ParcelDelivery', trackingNumber: 'T2', carrier: 'DHL', partOfOrder: { '@type': 'Order', orderNumber: 'SO-1' } },
), MAIL) as Shipment[];
expect(cards).toHaveLength(1);
expect(cards[0]).toMatchObject({ orderNumber: 'SO-1', merchant: 'Shop', status: 'in_transit', items: [{ name: 'Schuhe' }] });
expect(cards[0].parcels.map((p) => p.trackingNumber)).toEqual(['T1', 'T2']);
});
it('keeps orders with different numbers apart', () => {
const cards = readStructuredMail(page(
{ '@context': C, '@type': 'Order', orderNumber: 'SO-1', merchant: 'Shop' },
{ '@context': C, '@type': 'Order', orderNumber: 'SO-2', merchant: 'Shop' },
), MAIL);
expect(cards).toHaveLength(2);
});
});
@@ -0,0 +1,58 @@
/**
* Every fixture through the whole pipeline (extract, normalise, validate,
* merge, resolve zones), compared field by field with the reviewed cards in
* expected-cards.json. Real samples are the 11 card-relevant mails from
* local-data/schema examples, redacted; synthetic ones are self-written.
* Doubtful fixtures must give no card at all.
*/
import { afterAll, describe, expect, it } from 'vitest';
import { loadAirportZones, needsAirportZones, readStructuredMail, resolveCards, type StructuredCard } from '..';
import expected from './fixtures/expected-cards.json';
import real from './fixtures/real-mails.json';
import { syntheticMails, type MailFixture } from './fixtures/synthetic-mails';
const fixtures: MailFixture[] = [...(real as MailFixture[]), ...syntheticMails];
const expectedById = expected as Record<string, StructuredCard[]>;
async function cardsOf(fixture: MailFixture): Promise<StructuredCard[]> {
const cards = readStructuredMail(fixture.html, { mailDate: new Date(fixture.mailDate) });
return needsAirportZones(cards) ? resolveCards(cards, await loadAirportZones()) : resolveCards(cards);
}
type Score = { mails: number; found: number; correct: number; wrong: number; none: number };
const scores = new Map<string, Score>();
describe('structured mail fixtures', () => {
it('has an expectation for every fixture and no stray ones', () => {
expect(Object.keys(expectedById).sort()).toEqual(fixtures.map((f) => f.id).sort());
});
it.each(fixtures.map((f) => [f.id, f] as const))('%s', async (_id, fixture) => {
const cards = await cardsOf(fixture);
const want = expectedById[fixture.id];
const score = scores.get(fixture.sender) ?? { mails: 0, found: 0, correct: 0, wrong: 0, none: 0 };
score.mails++;
const equal = JSON.stringify(cards) === JSON.stringify(want);
if (cards.length === 0) score.none++;
else {
score.found++;
if (equal) score.correct++;
else score.wrong++;
}
scores.set(fixture.sender, score);
expect(cards).toEqual(want);
});
it('gives nothing for every doubtful fixture', async () => {
for (const fixture of fixtures.filter((f) => f.id.startsWith('doubtful-'))) {
expect(await cardsOf(fixture), fixture.id).toEqual([]);
}
});
afterAll(() => {
const rows = [...scores.entries()].sort(([a], [b]) => a.localeCompare(b));
const lines = rows.map(([sender, s]) => `${sender.padEnd(28)} mails ${s.mails} found ${s.found} correct ${s.correct} wrong ${s.wrong} none ${s.none}`);
const total = rows.reduce((t, [, s]) => ({ found: t.found + s.found, correct: t.correct + s.correct, wrong: t.wrong + s.wrong }), { found: 0, correct: 0, wrong: 0 });
console.log(`structured mail score per sender\n${lines.join('\n')}\ntotal found ${total.found}, correct ${total.correct}, wrong ${total.wrong}`);
});
});
+7 -5
View File
@@ -10,6 +10,7 @@
* : resolveCards(cards);
*/
import { extractStructuredData } from './extract';
import { mergeCandidates } from './merge';
import { normalizeItems } from './normalize';
import type { StructuredCard } from './types';
import { validateCard } from './validate';
@@ -20,18 +21,19 @@ export interface ReadOptions {
parseHtml?: (html: string) => Document;
}
/** Cards from one HTML body, validated but with zones not yet resolved. */
/**
* Cards from one HTML body: validated and merged to one card per booking,
* with zones not yet resolved (resolveCards).
*/
export function readStructuredMail(html: string, options: ReadOptions): StructuredCard[] {
const cards = normalizeItems(extractStructuredData(html, options.parseHtml));
return cards
.map((card) => validateCard(card, options.mailDate))
.filter((candidate) => candidate.problems.length === 0)
.map((candidate) => candidate.card);
return mergeCandidates(cards.map((card) => validateCard(card, options.mailDate)));
}
export { extractStructuredData, mayContainStructuredData } from './extract';
export { normalizeItems } from './normalize';
export { validateCard } from './validate';
export { mergeCandidates } from './merge';
export { needsAirportZones, resolveCards } from './resolve';
export { loadAirportZones } from './time-zones';
export * from './types';
+203
View File
@@ -0,0 +1,203 @@
/**
* One card per booking. Senders write one FlightReservation per passenger
* and leg, an Order next to its ParcelDelivery, or the same booking in both
* JSON-LD and microdata; equal merge keys (normalize.ts cardKey) are folded
* together here. If any part of a booking failed validation, or two parts
* disagree about the same leg, the whole booking is dropped.
*/
import { legKey } from './normalize';
import type {
Booking,
BookingLinks,
Candidate,
Item,
Moment,
OrderStatus,
Parcel,
ReservationStatus,
Seat,
Shipment,
StructuredCard,
TransportBooking,
TransportLeg,
} from './types';
const KIND_ORDER: StructuredCard['kind'][] = ['flight', 'train', 'bus', 'lodging', 'rentalcar', 'event', 'restaurant', 'shipment'];
function sameMoment(a: Moment | undefined, b: Moment | undefined): boolean {
if (!a || !b) return true;
if (a.utc && b.utc) return a.utc === b.utc;
return a.date === b.date && (a.time ?? '') === (b.time ?? '');
}
function mergeLinks(all: BookingLinks[]): BookingLinks {
const out: BookingLinks = {};
for (const links of all) {
out.checkin ??= links.checkin;
out.manage ??= links.manage;
out.ticket ??= links.ticket;
}
for (const key of Object.keys(out) as (keyof BookingLinks)[]) if (!out[key]) delete out[key];
return out;
}
function unique<T>(values: (T | undefined)[]): T[] {
return [...new Set(values.filter((v): v is T => v !== undefined))];
}
function latest(values: (string | undefined)[]): string | undefined {
return unique(values).sort().pop();
}
/**
* Status of a leg or booking several reservations contributed to: cancelled
* only when every one of them says so. One cancelled passenger on a leg the
* others still hold leaves the leg live.
*/
function combinedStatus(statuses: (ReservationStatus | undefined)[]): ReservationStatus | undefined {
if (statuses.length > 0 && statuses.every((s) => s === 'cancelled')) return 'cancelled';
return statuses.find((s) => s && s !== 'cancelled');
}
function mergeTransport(group: TransportBooking[]): TransportBooking | null {
const legs = new Map<string, { legs: TransportLeg[] }>();
for (const booking of group) {
for (const leg of booking.legs) {
const key = legKey(leg);
const entry = legs.get(key) ?? { legs: [] };
entry.legs.push(leg);
legs.set(key, entry);
}
}
const merged: TransportLeg[] = [];
for (const { legs: parts } of legs.values()) {
const [first] = parts;
for (const other of parts.slice(1)) {
if (!sameMoment(first.departure.at, other.departure.at) || !sameMoment(first.arrival?.at, other.arrival?.at)) return null;
}
const seats: Seat[] = [];
const seen = new Set<string>();
for (const seat of parts.flatMap((p) => p.seats)) {
const id = `${seat.passenger ?? ''}|${seat.seat}`;
if (!seen.has(id)) {
seen.add(id);
seats.push(seat);
}
}
const status = combinedStatus(parts.map((p) => p.status));
const withArrival = parts.find((p) => p.arrival?.at) ?? first;
const { status: _ignored, ...rest } = first;
merged.push({ ...rest, ...(withArrival.arrival ? { arrival: withArrival.arrival } : {}), seats, ...(status ? { status } : {}) });
}
merged.sort((a, b) => (a.departure.at.utc ?? `${a.departure.at.date}T${a.departure.at.time}`).localeCompare(b.departure.at.utc ?? `${b.departure.at.date}T${b.departure.at.time}`));
const [head] = group;
const allCancelled = merged.length > 0 && merged.every((leg) => leg.status === 'cancelled');
const bookingStatus = allCancelled ? 'cancelled' : combinedStatus(group.map((b) => b.status).filter((s) => s !== 'cancelled'));
const { status: _s, modified: _m, provider: _p, ...base } = head;
const modified = latest(group.map((b) => b.modified));
const provider = group.find((b) => b.provider)?.provider;
return {
...base,
...(bookingStatus ? { status: bookingStatus } : {}),
passengers: unique(group.flatMap((b) => b.passengers)),
...(provider ? { provider } : {}),
links: mergeLinks(group.map((b) => b.links)),
...(modified ? { modified } : {}),
legs: merged,
} as TransportBooking;
}
function mergeOther(group: Booking[]): Booking {
const [head] = group;
const status = combinedStatus(group.map((b) => b.status));
const modified = latest(group.map((b) => b.modified));
const { status: _s, modified: _m, ...base } = head;
const merged = {
...base,
...(status ? { status } : {}),
passengers: unique(group.flatMap((b) => b.passengers)),
links: mergeLinks(group.map((b) => b.links)),
...(modified ? { modified } : {}),
} as Booking;
if (merged.kind === 'event') merged.tickets = unique(group.flatMap((b) => (b.kind === 'event' ? b.tickets : [])));
return merged;
}
/** Later shipping states win over earlier ones; a problem state over all. */
const ORDER_PROGRESS: OrderStatus[] = ['payment_due', 'processing', 'in_transit', 'pickup_available', 'delivered', 'returned', 'problem', 'cancelled'];
function mergeItems(lists: Item[][]): Item[] {
const byName = new Map<string, Item>();
for (const item of lists.flat()) {
const key = item.name.toLowerCase();
const known = byName.get(key);
byName.set(key, known ? { ...item, ...known, image: known.image ?? item.image } : item);
}
return [...byName.values()].map((item) => {
if (!item.image) delete item.image;
return item;
});
}
function mergeShipments(group: Shipment[]): Shipment {
const parcels: Parcel[] = [];
for (const parcel of group.flatMap((s) => s.parcels)) {
const same = parcel.trackingNumber ? parcels.find((p) => p.trackingNumber === parcel.trackingNumber) : undefined;
if (same) {
same.carrier ??= parcel.carrier;
same.trackingUrl ??= parcel.trackingUrl;
same.expectedBy ??= parcel.expectedBy;
same.items = mergeItems([same.items, parcel.items]);
} else {
parcels.push({ ...parcel, items: [...parcel.items] });
}
}
const statuses = group.map((s) => s.status).filter((s): s is OrderStatus => !!s);
const status = statuses.sort((a, b) => ORDER_PROGRESS.indexOf(a) - ORDER_PROGRESS.indexOf(b)).pop();
const withOrder = group.find((s) => s.items.length > 0 || s.total) ?? group[0];
const merged: Shipment = {
kind: 'shipment',
key: group[0].key,
...(group.find((s) => s.merchant) ? { merchant: group.find((s) => s.merchant)!.merchant } : {}),
...(group.find((s) => s.orderNumber) ? { orderNumber: group.find((s) => s.orderNumber)!.orderNumber } : {}),
...(status ? { status } : {}),
items: mergeItems(group.map((s) => s.items)),
...(withOrder.total ? { total: withOrder.total } : {}),
...(group.find((s) => s.orderUrl) ? { orderUrl: group.find((s) => s.orderUrl)!.orderUrl } : {}),
parcels,
source: group[0].source,
};
return merged;
}
function startOf(card: StructuredCard): string {
switch (card.kind) {
case 'flight': case 'train': case 'bus': return card.legs[0]?.departure.at.utc ?? card.legs[0]?.departure.at.date ?? '';
case 'lodging': return card.checkin.date;
case 'rentalcar': return card.pickup.at.utc ?? card.pickup.at.date;
case 'event': case 'restaurant': return card.start.utc ?? card.start.date;
case 'shipment': return '';
}
}
/** Fold candidates with equal keys into one card each; drop doubtful groups. */
export function mergeCandidates(candidates: Candidate[]): StructuredCard[] {
const groups = new Map<string, Candidate[]>();
for (const candidate of candidates) {
const group = groups.get(candidate.card.key) ?? [];
group.push(candidate);
groups.set(candidate.card.key, group);
}
const out: StructuredCard[] = [];
for (const group of groups.values()) {
if (group.some((c) => c.problems.length > 0)) continue;
const cards = group.map((c) => c.card);
const [head] = cards;
if (head.kind === 'shipment') out.push(mergeShipments(cards as Shipment[]));
else if (head.kind === 'flight' || head.kind === 'train' || head.kind === 'bus') {
const merged = mergeTransport(cards as TransportBooking[]);
if (merged) out.push(merged);
} else out.push(mergeOther(cards as Booking[]));
}
return out.sort((a, b) => KIND_ORDER.indexOf(a.kind) - KIND_ORDER.indexOf(b.kind) || startOf(a).localeCompare(startOf(b)));
}
+12 -3
View File
@@ -231,6 +231,12 @@ function base(kind: BookingKind, res: Obj, source: CardSource) {
const MISSING: Moment = { date: '' };
function offsetSuffix(minutes: number): string {
const sign = minutes < 0 ? '-' : '+';
const abs = Math.abs(minutes);
return `${sign}${String(Math.floor(abs / 60)).padStart(2, '0')}:${String(abs % 60).padStart(2, '0')}`;
}
function reservation(kind: BookingKind, res: Obj, source: CardSource): StructuredCard | undefined {
if (kind === 'flight' || kind === 'train' || kind === 'bus') return transport(kind, res, source);
const target = obj(res.reservationFor) ?? {};
@@ -265,15 +271,18 @@ function reservation(kind: BookingKind, res: Obj, source: CardSource): Structure
case 'event': {
const venue = place(target.location);
const ticketNumbers = [...new Set([str(res.ticketNumber), ...tickets(res).map((t) => str(t.ticketNumber))].filter((t): t is string => !!t))];
const start = moment(target.startDate);
let end = moment(target.endDate);
// An end date without a time means the end of that day.
if (end && !end.time) end = { ...end, time: '23:59:59' };
// An end date without a time means the end of that day, when the
// event starts at a time; with a date-only start it's an all-day span.
if (end && !end.time && start?.time) end = { ...end, time: '23:59:59', ...(start.offsetMinutes !== undefined ? { offsetMinutes: start.offsetMinutes } : {}) };
if (end?.offsetMinutes !== undefined && end.time && !end.utc) end = moment(`${end.date}T${end.time}${offsetSuffix(end.offsetMinutes)}`);
return {
...base(kind, res, source),
kind,
name: name ?? '',
...(venue.name || venue.address ? { venue } : {}),
start: moment(target.startDate) ?? MISSING,
start: start ?? MISSING,
...(end ? { end } : {}),
tickets: ticketNumbers,
...(nameOf(res.provider) ? { provider: nameOf(res.provider) } : {}),