diff --git a/lib/structured-mail/__tests__/fixtures/expected-cards.json b/lib/structured-mail/__tests__/fixtures/expected-cards.json
new file mode 100644
index 000000000..a14b9582c
--- /dev/null
+++ b/lib/structured-mail/__tests__/fixtures/expected-cards.json
@@ -0,0 +1,1260 @@
+{
+ "flixbus-booking": [
+ {
+ "key": "bus|ref:3165000001",
+ "kind": "bus",
+ "reference": "3165000001",
+ "passengers": [
+ "Erika Mustermann",
+ "Max Mustermann"
+ ],
+ "links": {},
+ "source": "json-ld",
+ "legs": [
+ {
+ "carrier": "Flix",
+ "number": "FLX30",
+ "departure": {
+ "place": {
+ "name": "Hannover Hauptbahnhof (FlixTrain)",
+ "address": {
+ "street": "Ernst-August-Platz 1",
+ "locality": "Hannover",
+ "postalCode": "30159",
+ "country": "DE"
+ }
+ },
+ "at": {
+ "date": "2024-06-04",
+ "time": "11:44:00",
+ "offsetMinutes": 120,
+ "utc": "2024-06-04T09:44:00Z",
+ "zone": "Europe/Berlin"
+ }
+ },
+ "arrival": {
+ "place": {
+ "name": "Berlin Ostbahnhof (FlixTrain)",
+ "address": {
+ "street": "Koppenstr. 3",
+ "locality": "Berlin",
+ "postalCode": "10243",
+ "country": "DE"
+ }
+ },
+ "at": {
+ "date": "2024-06-04",
+ "time": "13:44:00",
+ "offsetMinutes": 120,
+ "utc": "2024-06-04T11:44:00Z",
+ "zone": "Europe/Berlin"
+ }
+ },
+ "seats": [
+ {
+ "passenger": "Erika Mustermann",
+ "seat": "6D"
+ },
+ {
+ "passenger": "Max Mustermann",
+ "seat": "6C"
+ }
+ ],
+ "status": "confirmed"
+ }
+ ],
+ "status": "confirmed",
+ "provider": "Flix"
+ }
+ ],
+ "flixbus-invoice": [
+ {
+ "key": "bus|ref:3165000001",
+ "kind": "bus",
+ "reference": "3165000001",
+ "passengers": [
+ "Max Mustermann",
+ "Erika Mustermann"
+ ],
+ "links": {},
+ "source": "json-ld",
+ "legs": [
+ {
+ "carrier": "Flix SE",
+ "number": "FLX30",
+ "departure": {
+ "place": {
+ "name": "Hannover Hauptbahnhof (FlixTrain)",
+ "address": {
+ "street": "Ernst-August-Platz 1",
+ "locality": "Hannover",
+ "postalCode": "30159",
+ "country": "DE"
+ }
+ },
+ "at": {
+ "date": "2024-06-04",
+ "time": "11:44:00",
+ "offsetMinutes": 120,
+ "utc": "2024-06-04T09:44:00Z",
+ "zone": "Europe/Berlin"
+ }
+ },
+ "arrival": {
+ "place": {
+ "name": "Berlin Ostbahnhof (FlixTrain)",
+ "address": {
+ "street": "Koppenstr. 3",
+ "locality": "Berlin",
+ "postalCode": "10243",
+ "country": "DE"
+ }
+ },
+ "at": {
+ "date": "2024-06-04",
+ "time": "13:44:00",
+ "offsetMinutes": 120,
+ "utc": "2024-06-04T11:44:00Z",
+ "zone": "Europe/Berlin"
+ }
+ },
+ "seats": [],
+ "status": "confirmed"
+ }
+ ],
+ "status": "confirmed",
+ "provider": "Flix SE",
+ "modified": "2024-05-28T17:18:17+0200"
+ }
+ ],
+ "kaufland-order-1": [
+ {
+ "kind": "shipment",
+ "key": "shipment|order:m100aa1",
+ "merchant": "Kaufland.de",
+ "orderNumber": "M100AA1",
+ "status": "processing",
+ "items": [
+ {
+ "name": "Creality K1 3D-Drucker",
+ "image": {
+ "url": "https://media.cdn.kaufland.de/product-images/100x100/4af2c802b184554243cd708e9d83a180.jpg",
+ "host": "media.cdn.kaufland.de"
+ },
+ "quantity": 1
+ }
+ ],
+ "total": {
+ "amount": "399.00",
+ "currency": "EUR"
+ },
+ "orderUrl": {
+ "url": "https://www.kaufland.de/account/orders?utm_medium=email&utm_content=purchase_confirmation_gmu_de-de&utm_source=real&utm_campaign=customer#100000000000001",
+ "host": "kaufland.de"
+ },
+ "parcels": [],
+ "source": "microdata"
+ }
+ ],
+ "kaufland-parcel-1": [
+ {
+ "kind": "shipment",
+ "key": "shipment|order:m100aa1",
+ "merchant": "Kaufland.de",
+ "orderNumber": "M100AA1",
+ "status": "in_transit",
+ "items": [],
+ "parcels": [
+ {
+ "expectedBy": {
+ "date": "2024-10-13"
+ },
+ "items": [
+ {
+ "name": "Creality K1 3D-Drucker",
+ "image": {
+ "url": "https://media.cdn.kaufland.de/product-images/100x100/4af2c802b184554243cd708e9d83a180.jpg",
+ "host": "media.cdn.kaufland.de"
+ }
+ }
+ ]
+ }
+ ],
+ "source": "microdata"
+ }
+ ],
+ "kaufland-order-2": [
+ {
+ "kind": "shipment",
+ "key": "shipment|order:m200bb2",
+ "merchant": "Kaufland.de",
+ "orderNumber": "M200BB2",
+ "status": "processing",
+ "items": [
+ {
+ "name": "Atomstack Maker AC1 Kamera 5 Megapixel 400x400mm Fotografiebereich für Atomstack NEJE Tool Graviermaschine",
+ "image": {
+ "url": "https://media.cdn.kaufland.de/product-images/100x100/29e4eac9c493c96fbe5d122f305ea21b.jpg",
+ "host": "media.cdn.kaufland.de"
+ },
+ "quantity": 1
+ }
+ ],
+ "total": {
+ "amount": "29.99",
+ "currency": "EUR"
+ },
+ "orderUrl": {
+ "url": "https://www.kaufland.de/account/orders?utm_medium=email&utm_content=purchase_confirmation_gmu_de-de&utm_source=real&utm_campaign=customer#100000000000002",
+ "host": "kaufland.de"
+ },
+ "parcels": [],
+ "source": "microdata"
+ }
+ ],
+ "kaufland-parcel-2": [
+ {
+ "kind": "shipment",
+ "key": "shipment|order:m200bb2",
+ "merchant": "Kaufland.de",
+ "orderNumber": "M200BB2",
+ "status": "in_transit",
+ "items": [],
+ "parcels": [
+ {
+ "expectedBy": {
+ "date": "2024-11-17"
+ },
+ "items": [
+ {
+ "name": "Atomstack Maker AC1 Kamera 5 Megapixel 400x400mm Fotografiebereich für Atomstack NEJE Tool Graviermaschine",
+ "image": {
+ "url": "https://media.cdn.kaufland.de/product-images/100x100/29e4eac9c493c96fbe5d122f305ea21b.jpg",
+ "host": "media.cdn.kaufland.de"
+ }
+ }
+ ]
+ }
+ ],
+ "source": "microdata"
+ }
+ ],
+ "ahoi-event": [
+ {
+ "key": "event|ab12cd|grafikdesign: illustrator-workshop|2021-07-18",
+ "kind": "event",
+ "reference": "AB12CD",
+ "passengers": [
+ "Erika Mustermann"
+ ],
+ "links": {},
+ "source": "json-ld",
+ "name": "Grafikdesign: Illustrator-Workshop",
+ "start": {
+ "date": "2021-07-18",
+ "time": "14:00:00",
+ "offsetMinutes": 120,
+ "utc": "2021-07-18T12:00:00Z",
+ "zone": "Etc/GMT-2"
+ },
+ "end": {
+ "date": "2021-07-18",
+ "time": "19:00:00",
+ "offsetMinutes": 120,
+ "utc": "2021-07-18T17:00:00Z",
+ "zone": "Etc/GMT-2"
+ },
+ "tickets": [
+ "AB12CD-1"
+ ],
+ "status": "confirmed",
+ "modified": "2021-05-28T15:30:04+02:00"
+ }
+ ],
+ "k4g-order": [
+ {
+ "kind": "shipment",
+ "key": "shipment|order:a1b2c3d4",
+ "merchant": "K4G",
+ "orderNumber": "A1B2C3D4",
+ "status": "processing",
+ "items": [
+ {
+ "name": "Farming Simulator 22 Steam CD Key",
+ "image": {
+ "url": "https://cdn.k4g.com/files/cover/3b1c373f87c917c637c21b681a9b5464.jpeg",
+ "host": "cdn.k4g.com"
+ },
+ "quantity": 1
+ }
+ ],
+ "total": {
+ "amount": "25.99",
+ "currency": "EUR"
+ },
+ "orderUrl": {
+ "url": "https://k4g.com/order?id=A1B2C3D4&token=0000000000000000000000000000000000000000000000000000000000000000",
+ "host": "k4g.com"
+ },
+ "parcels": [],
+ "source": "json-ld"
+ }
+ ],
+ "uber-order-1": [],
+ "uber-order-2": [],
+ "zave-newsletter": [],
+ "flight-two-passengers-two-legs": [
+ {
+ "key": "flight|ref:qx7k2m",
+ "kind": "flight",
+ "reference": "QX7K2M",
+ "passengers": [
+ "Erika Mustermann",
+ "Max Mustermann"
+ ],
+ "links": {
+ "checkin": {
+ "url": "https://www.airline-a.example.com/checkin?pnr=QX7K2M",
+ "host": "airline-a.example.com"
+ }
+ },
+ "source": "json-ld",
+ "legs": [
+ {
+ "carrier": "Lufthansa",
+ "carrierCode": "LH",
+ "number": "400",
+ "departure": {
+ "place": {
+ "name": "Frankfurt Airport",
+ "code": "FRA"
+ },
+ "at": {
+ "date": "2026-12-01",
+ "time": "10:15:00",
+ "offsetMinutes": 60,
+ "utc": "2026-12-01T09:15:00Z",
+ "zone": "Europe/Berlin"
+ }
+ },
+ "arrival": {
+ "place": {
+ "name": "John F. Kennedy International Airport",
+ "code": "JFK"
+ },
+ "at": {
+ "date": "2026-12-01",
+ "time": "13:05:00",
+ "offsetMinutes": -300,
+ "utc": "2026-12-01T18:05:00Z",
+ "zone": "America/New_York"
+ }
+ },
+ "seats": [
+ {
+ "passenger": "Erika Mustermann",
+ "seat": "12A"
+ },
+ {
+ "passenger": "Max Mustermann",
+ "seat": "12B"
+ }
+ ],
+ "status": "confirmed"
+ },
+ {
+ "carrier": "Lufthansa",
+ "carrierCode": "LH",
+ "number": "401",
+ "departure": {
+ "place": {
+ "name": "John F. Kennedy International Airport",
+ "code": "JFK"
+ },
+ "at": {
+ "date": "2026-12-08",
+ "time": "17:30:00",
+ "offsetMinutes": -300,
+ "utc": "2026-12-08T22:30:00Z",
+ "zone": "America/New_York"
+ }
+ },
+ "arrival": {
+ "place": {
+ "name": "Frankfurt Airport",
+ "code": "FRA"
+ },
+ "at": {
+ "date": "2026-12-09",
+ "time": "07:20:00",
+ "offsetMinutes": 60,
+ "utc": "2026-12-09T06:20:00Z",
+ "zone": "Europe/Berlin"
+ }
+ },
+ "seats": [
+ {
+ "passenger": "Erika Mustermann",
+ "seat": "31K"
+ },
+ {
+ "passenger": "Max Mustermann",
+ "seat": "31J"
+ }
+ ],
+ "status": "confirmed"
+ }
+ ],
+ "status": "confirmed",
+ "provider": "Lufthansa"
+ }
+ ],
+ "eurowings-missing-commas": [
+ {
+ "key": "flight|ref:ew7q2k",
+ "kind": "flight",
+ "reference": "EW7Q2K",
+ "passengers": [
+ "Erika Mustermann"
+ ],
+ "links": {},
+ "source": "json-ld",
+ "legs": [
+ {
+ "carrier": "Eurowings",
+ "carrierCode": "EW",
+ "number": "582",
+ "departure": {
+ "place": {
+ "name": "Köln/Bonn",
+ "code": "CGN"
+ },
+ "at": {
+ "date": "2026-11-20",
+ "time": "06:40:00",
+ "offsetMinutes": 60,
+ "utc": "2026-11-20T05:40:00Z",
+ "zone": "Europe/Berlin"
+ }
+ },
+ "arrival": {
+ "place": {
+ "name": "Palma de Mallorca",
+ "code": "PMI"
+ },
+ "at": {
+ "date": "2026-11-20",
+ "time": "09:05:00",
+ "offsetMinutes": 60,
+ "utc": "2026-11-20T08:05:00Z",
+ "zone": "Europe/Madrid"
+ }
+ },
+ "seats": [
+ {
+ "passenger": "Erika Mustermann",
+ "seat": "3A"
+ }
+ ],
+ "status": "confirmed"
+ },
+ {
+ "carrier": "Eurowings",
+ "carrierCode": "EW",
+ "number": "583",
+ "departure": {
+ "place": {
+ "name": "Palma de Mallorca",
+ "code": "PMI"
+ },
+ "at": {
+ "date": "2026-11-27",
+ "time": "10:00:00",
+ "offsetMinutes": 60,
+ "utc": "2026-11-27T09:00:00Z",
+ "zone": "Europe/Madrid"
+ }
+ },
+ "arrival": {
+ "place": {
+ "name": "Köln/Bonn",
+ "code": "CGN"
+ },
+ "at": {
+ "date": "2026-11-27",
+ "time": "12:25:00",
+ "offsetMinutes": 60,
+ "utc": "2026-11-27T11:25:00Z",
+ "zone": "Europe/Berlin"
+ }
+ },
+ "seats": [
+ {
+ "passenger": "Erika Mustermann",
+ "seat": "14F"
+ }
+ ],
+ "status": "confirmed"
+ }
+ ],
+ "status": "confirmed",
+ "provider": "Eurowings"
+ }
+ ],
+ "volotea-missing-brackets": [
+ {
+ "key": "flight|ref:v0l8ta",
+ "kind": "flight",
+ "reference": "V0L8TA",
+ "passengers": [
+ "Erika Mustermann",
+ "Max Mustermann"
+ ],
+ "links": {},
+ "source": "json-ld",
+ "legs": [
+ {
+ "carrier": "Volotea",
+ "carrierCode": "V7",
+ "number": "1520",
+ "departure": {
+ "place": {
+ "name": "Venezia",
+ "code": "VCE"
+ },
+ "at": {
+ "date": "2026-12-12",
+ "time": "14:10:00",
+ "offsetMinutes": 60,
+ "utc": "2026-12-12T13:10:00Z",
+ "zone": "Europe/Rome"
+ }
+ },
+ "arrival": {
+ "place": {
+ "name": "Nantes",
+ "code": "NTE"
+ },
+ "at": {
+ "date": "2026-12-12",
+ "time": "16:15:00",
+ "offsetMinutes": 60,
+ "utc": "2026-12-12T15:15:00Z",
+ "zone": "Europe/Paris"
+ }
+ },
+ "seats": [
+ {
+ "passenger": "Erika Mustermann",
+ "seat": "10C"
+ },
+ {
+ "passenger": "Max Mustermann",
+ "seat": "11C"
+ }
+ ],
+ "status": "confirmed"
+ }
+ ],
+ "status": "confirmed",
+ "provider": "Volotea"
+ }
+ ],
+ "eventbrite-trailing-commas": [
+ {
+ "key": "event|eb-1029384756|jazz im park|2026-11-14",
+ "kind": "event",
+ "reference": "EB-1029384756",
+ "passengers": [
+ "Erika Mustermann"
+ ],
+ "links": {
+ "ticket": {
+ "url": "https://tickets.example.com/t/EB-1029384756",
+ "host": "tickets.example.com"
+ }
+ },
+ "source": "json-ld",
+ "name": "Jazz im Park",
+ "venue": {
+ "name": "Stadtpark",
+ "address": {
+ "street": "Parkweg 2",
+ "locality": "Hamburg",
+ "country": "DE"
+ }
+ },
+ "start": {
+ "date": "2026-11-14",
+ "time": "19:00:00",
+ "offsetMinutes": 60,
+ "utc": "2026-11-14T18:00:00Z",
+ "zone": "Europe/Berlin"
+ },
+ "end": {
+ "date": "2026-11-14",
+ "time": "22:30:00",
+ "offsetMinutes": 60,
+ "utc": "2026-11-14T21:30:00Z",
+ "zone": "Europe/Berlin"
+ },
+ "tickets": []
+ }
+ ],
+ "airbnb-entities": [
+ {
+ "key": "lodging|hmxk29q4zp|altbauwohnung am kanal|2027-01-15",
+ "kind": "lodging",
+ "reference": "HMXK29Q4ZP",
+ "passengers": [
+ "Erika Mustermann"
+ ],
+ "links": {},
+ "source": "json-ld",
+ "name": "Altbauwohnung am Kanal",
+ "provider": "Altbauwohnung am Kanal",
+ "address": {
+ "locality": "Amsterdam",
+ "country": "NL"
+ },
+ "checkin": {
+ "date": "2027-01-15"
+ },
+ "checkout": {
+ "date": "2027-01-19"
+ }
+ }
+ ],
+ "graph-hotel": [
+ {
+ "key": "lodging|hb-58213|hotel am markt|2026-12-04",
+ "kind": "lodging",
+ "reference": "HB-58213",
+ "passengers": [
+ "Erika Mustermann"
+ ],
+ "links": {
+ "manage": {
+ "url": "https://www.hotel-booking.example.com/manage/HB-58213",
+ "host": "hotel-booking.example.com"
+ }
+ },
+ "source": "json-ld",
+ "name": "Hotel Am Markt",
+ "provider": "Hotel Am Markt",
+ "address": {
+ "street": "Marktplatz 1",
+ "locality": "Berlin",
+ "postalCode": "10178",
+ "country": "DE"
+ },
+ "phone": "+49 30 1234567",
+ "checkin": {
+ "date": "2026-12-04",
+ "time": "15:00:00",
+ "offsetMinutes": 60,
+ "utc": "2026-12-04T14:00:00Z",
+ "zone": "Europe/Berlin"
+ },
+ "checkout": {
+ "date": "2026-12-06",
+ "time": "11:00:00",
+ "offsetMinutes": 60,
+ "utc": "2026-12-06T10:00:00Z",
+ "zone": "Europe/Berlin"
+ },
+ "status": "confirmed"
+ }
+ ],
+ "train-multiple-types": [
+ {
+ "key": "train|ref:rx4471",
+ "kind": "train",
+ "reference": "RX4471",
+ "passengers": [
+ "Erika Mustermann"
+ ],
+ "links": {},
+ "source": "json-ld",
+ "legs": [
+ {
+ "carrier": "Rail Co",
+ "number": "ICE 577",
+ "departure": {
+ "place": {
+ "name": "Hamburg Hbf",
+ "address": {
+ "locality": "Hamburg",
+ "country": "DE"
+ }
+ },
+ "at": {
+ "date": "2026-11-18",
+ "time": "08:01:00",
+ "offsetMinutes": 60,
+ "utc": "2026-11-18T07:01:00Z",
+ "zone": "Europe/Berlin"
+ }
+ },
+ "arrival": {
+ "place": {
+ "name": "München Hbf",
+ "address": {
+ "locality": "München",
+ "country": "DE"
+ }
+ },
+ "at": {
+ "date": "2026-11-18",
+ "time": "13:58:00",
+ "offsetMinutes": 60,
+ "utc": "2026-11-18T12:58:00Z",
+ "zone": "Europe/Berlin"
+ }
+ },
+ "seats": [
+ {
+ "passenger": "Erika Mustermann",
+ "seat": "12/45"
+ }
+ ],
+ "status": "confirmed"
+ }
+ ],
+ "status": "confirmed",
+ "provider": "Rail Co"
+ }
+ ],
+ "event-reservationfor-array": [
+ {
+ "key": "event|kt-7781|faust i|2026-11-21",
+ "kind": "event",
+ "reference": "KT-7781",
+ "passengers": [
+ "Erika Mustermann"
+ ],
+ "links": {},
+ "source": "json-ld",
+ "name": "Faust I",
+ "venue": {
+ "name": "Schauspielhaus",
+ "address": {
+ "locality": "Bochum",
+ "country": "DE"
+ }
+ },
+ "start": {
+ "date": "2026-11-21",
+ "time": "19:30:00",
+ "offsetMinutes": 60,
+ "utc": "2026-11-21T18:30:00Z",
+ "zone": "Europe/Berlin"
+ },
+ "tickets": []
+ },
+ {
+ "key": "event|kt-7781|faust ii|2026-11-22",
+ "kind": "event",
+ "reference": "KT-7781",
+ "passengers": [
+ "Erika Mustermann"
+ ],
+ "links": {},
+ "source": "json-ld",
+ "name": "Faust II",
+ "venue": {
+ "name": "Schauspielhaus",
+ "address": {
+ "locality": "Bochum",
+ "country": "DE"
+ }
+ },
+ "start": {
+ "date": "2026-11-22",
+ "time": "18:00:00",
+ "offsetMinutes": 60,
+ "utc": "2026-11-22T17:00:00Z",
+ "zone": "Europe/Berlin"
+ },
+ "tickets": []
+ }
+ ],
+ "hotel-legacy-names": [
+ {
+ "key": "lodging|84123376|seehotel weiß|2026-12-27",
+ "kind": "lodging",
+ "reference": "84123376",
+ "passengers": [],
+ "links": {
+ "manage": {
+ "url": "https://hotel-chain.example.com/r/84123376",
+ "host": "hotel-chain.example.com"
+ }
+ },
+ "source": "json-ld",
+ "name": "Seehotel Weiß",
+ "provider": "Seehotel Weiß",
+ "address": {
+ "street": "Seestraße 3, 83727 Schliersee, Deutschland"
+ },
+ "checkin": {
+ "date": "2026-12-27"
+ },
+ "checkout": {
+ "date": "2027-01-02"
+ }
+ }
+ ],
+ "restaurant-cafe": [
+ {
+ "key": "restaurant|t-2231|café kranzler|2026-11-07",
+ "kind": "restaurant",
+ "reference": "T-2231",
+ "passengers": [
+ "Erika Mustermann"
+ ],
+ "links": {},
+ "source": "json-ld",
+ "name": "Café Kranzler",
+ "provider": "Café Kranzler",
+ "address": {
+ "street": "Kurfürstendamm 18, 10719 Berlin"
+ },
+ "start": {
+ "date": "2026-11-07",
+ "time": "19:30:00",
+ "offsetMinutes": 60,
+ "utc": "2026-11-07T18:30:00Z",
+ "zone": "Etc/GMT-1"
+ },
+ "partySize": 4,
+ "status": "confirmed"
+ }
+ ],
+ "event-date-only-end": [
+ {
+ "key": "event|fs26-0042|winterfest|2026-12-11",
+ "kind": "event",
+ "reference": "FS26-0042",
+ "passengers": [
+ "Erika Mustermann"
+ ],
+ "links": {},
+ "source": "json-ld",
+ "name": "Winterfest",
+ "venue": {
+ "name": "Messegelände",
+ "address": {
+ "locality": "Wien",
+ "countryName": "Austria"
+ }
+ },
+ "start": {
+ "date": "2026-12-11"
+ },
+ "end": {
+ "date": "2026-12-13"
+ },
+ "tickets": []
+ }
+ ],
+ "microdata-flight-missing-arrival": [],
+ "microdata-two-legs": [
+ {
+ "key": "flight|ref:os4k7p",
+ "kind": "flight",
+ "reference": "OS4K7P",
+ "passengers": [
+ "Erika Mustermann"
+ ],
+ "links": {},
+ "source": "microdata",
+ "legs": [
+ {
+ "carrier": "Austrian",
+ "carrierCode": "OS",
+ "number": "122",
+ "departure": {
+ "place": {
+ "name": "Wien",
+ "code": "VIE"
+ },
+ "at": {
+ "date": "2026-11-25",
+ "time": "06:50:00",
+ "offsetMinutes": 60,
+ "utc": "2026-11-25T05:50:00Z",
+ "zone": "Europe/Vienna"
+ }
+ },
+ "arrival": {
+ "place": {
+ "name": "Frankfurt",
+ "code": "FRA"
+ },
+ "at": {
+ "date": "2026-11-25",
+ "time": "08:15:00",
+ "offsetMinutes": 60,
+ "utc": "2026-11-25T07:15:00Z",
+ "zone": "Europe/Berlin"
+ }
+ },
+ "seats": [],
+ "status": "confirmed"
+ },
+ {
+ "carrier": "Austrian",
+ "carrierCode": "OS",
+ "number": "125",
+ "departure": {
+ "place": {
+ "name": "Frankfurt",
+ "code": "FRA"
+ },
+ "at": {
+ "date": "2026-11-27",
+ "time": "19:40:00",
+ "offsetMinutes": 60,
+ "utc": "2026-11-27T18:40:00Z",
+ "zone": "Europe/Berlin"
+ }
+ },
+ "arrival": {
+ "place": {
+ "name": "Wien",
+ "code": "VIE"
+ },
+ "at": {
+ "date": "2026-11-27",
+ "time": "21:00:00",
+ "offsetMinutes": 60,
+ "utc": "2026-11-27T20:00:00Z",
+ "zone": "Europe/Vienna"
+ }
+ },
+ "seats": [],
+ "status": "confirmed"
+ }
+ ],
+ "status": "confirmed",
+ "provider": "Austrian"
+ }
+ ],
+ "microdata-itemprop-lists": [
+ {
+ "key": "flight|ref:ib55xq",
+ "kind": "flight",
+ "reference": "IB55XQ",
+ "passengers": [
+ "Erika Mustermann"
+ ],
+ "links": {},
+ "source": "microdata",
+ "legs": [
+ {
+ "carrier": "Iberia",
+ "carrierCode": "IB",
+ "number": "3107",
+ "departure": {
+ "place": {
+ "name": "Madrid",
+ "code": "MAD"
+ },
+ "at": {
+ "date": "2026-11-16",
+ "time": "09:30:00",
+ "offsetMinutes": 60,
+ "utc": "2026-11-16T08:30:00Z",
+ "zone": "Europe/Madrid"
+ }
+ },
+ "arrival": {
+ "place": {
+ "name": "Barcelona",
+ "code": "BCN"
+ },
+ "at": {
+ "date": "2026-11-16",
+ "time": "10:50:00",
+ "offsetMinutes": 60,
+ "utc": "2026-11-16T09:50:00Z",
+ "zone": "Europe/Madrid"
+ }
+ },
+ "seats": []
+ }
+ ],
+ "provider": "Iberia"
+ }
+ ],
+ "hotel-dates-only": [
+ {
+ "key": "lodging|rs-1188|strandresort usedom|2027-06-01",
+ "kind": "lodging",
+ "reference": "RS-1188",
+ "passengers": [],
+ "links": {},
+ "source": "json-ld",
+ "name": "Strandresort Usedom",
+ "provider": "Strandresort Usedom",
+ "address": {
+ "locality": "Heringsdorf",
+ "country": "DE"
+ },
+ "checkin": {
+ "date": "2027-06-01"
+ },
+ "checkout": {
+ "date": "2027-06-08"
+ }
+ }
+ ],
+ "rental-car": [
+ {
+ "key": "rentalcar|rc-99123|car rental co|2026-12-04",
+ "kind": "rentalcar",
+ "reference": "RC-99123",
+ "passengers": [
+ "Erika Mustermann"
+ ],
+ "links": {},
+ "source": "json-ld",
+ "company": "Car Rental Co",
+ "provider": "Car Rental Co",
+ "car": "VW Golf",
+ "pickup": {
+ "place": {
+ "name": "Flughafen BER",
+ "address": {
+ "locality": "Schönefeld",
+ "country": "DE"
+ }
+ },
+ "at": {
+ "date": "2026-12-04",
+ "time": "09:00:00",
+ "offsetMinutes": 60,
+ "utc": "2026-12-04T08:00:00Z",
+ "zone": "Europe/Berlin"
+ }
+ },
+ "dropoff": {
+ "place": {
+ "name": "Flughafen BER",
+ "address": {
+ "locality": "Schönefeld",
+ "country": "DE"
+ }
+ },
+ "at": {
+ "date": "2026-12-07",
+ "time": "18:00:00",
+ "offsetMinutes": 60,
+ "utc": "2026-12-07T17:00:00Z",
+ "zone": "Europe/Berlin"
+ }
+ }
+ }
+ ],
+ "parcel": [
+ {
+ "kind": "shipment",
+ "key": "shipment|order:so-449120",
+ "merchant": "Outdoor Shop",
+ "orderNumber": "SO-449120",
+ "status": "in_transit",
+ "items": [],
+ "parcels": [
+ {
+ "carrier": "DHL",
+ "trackingNumber": "00340434161234567890",
+ "trackingUrl": {
+ "url": "https://www.dhl.de/de/privatkunden/pakete-empfangen/verfolgen.html?piececode=00340434161234567890",
+ "host": "dhl.de"
+ },
+ "expectedBy": {
+ "date": "2026-11-03"
+ },
+ "items": [
+ {
+ "name": "Wanderschuhe",
+ "image": {
+ "url": "https://img.shop.example.com/p/1.jpg",
+ "host": "img.shop.example.com"
+ }
+ },
+ {
+ "name": "Socken"
+ }
+ ]
+ }
+ ],
+ "source": "json-ld"
+ }
+ ],
+ "order-and-parcel": [
+ {
+ "kind": "shipment",
+ "key": "shipment|order:so-449120",
+ "merchant": "Outdoor Shop",
+ "orderNumber": "SO-449120",
+ "status": "in_transit",
+ "items": [
+ {
+ "name": "Wanderschuhe",
+ "quantity": 1
+ },
+ {
+ "name": "Socken",
+ "quantity": 2
+ }
+ ],
+ "total": {
+ "amount": "129.90",
+ "currency": "EUR"
+ },
+ "orderUrl": {
+ "url": "https://www.outdoor-shop.example.com/account/orders/SO-449120",
+ "host": "outdoor-shop.example.com"
+ },
+ "parcels": [
+ {
+ "carrier": "DHL",
+ "trackingNumber": "00340434161234567890",
+ "trackingUrl": {
+ "url": "https://www.dhl.de/de/privatkunden/pakete-empfangen/verfolgen.html?piececode=00340434161234567890",
+ "host": "dhl.de"
+ },
+ "expectedBy": {
+ "date": "2026-11-03"
+ },
+ "items": [
+ {
+ "name": "Wanderschuhe",
+ "image": {
+ "url": "https://img.shop.example.com/p/1.jpg",
+ "host": "img.shop.example.com"
+ }
+ },
+ {
+ "name": "Socken"
+ }
+ ]
+ }
+ ],
+ "source": "json-ld"
+ }
+ ],
+ "flight-cancelled": [
+ {
+ "key": "flight|ref:qx7k2m",
+ "kind": "flight",
+ "reference": "QX7K2M",
+ "passengers": [
+ "Erika Mustermann"
+ ],
+ "links": {},
+ "source": "json-ld",
+ "legs": [
+ {
+ "carrier": "Lufthansa",
+ "carrierCode": "LH",
+ "number": "400",
+ "departure": {
+ "place": {
+ "name": "Frankfurt Airport",
+ "code": "FRA"
+ },
+ "at": {
+ "date": "2026-12-01",
+ "time": "10:15:00",
+ "offsetMinutes": 60,
+ "utc": "2026-12-01T09:15:00Z",
+ "zone": "Europe/Berlin"
+ }
+ },
+ "arrival": {
+ "place": {
+ "name": "John F. Kennedy International Airport",
+ "code": "JFK"
+ },
+ "at": {
+ "date": "2026-12-01",
+ "time": "13:05:00",
+ "offsetMinutes": -300,
+ "utc": "2026-12-01T18:05:00Z",
+ "zone": "America/New_York"
+ }
+ },
+ "seats": [],
+ "status": "cancelled"
+ }
+ ],
+ "status": "cancelled",
+ "provider": "Lufthansa"
+ }
+ ],
+ "flight-date-line-eastbound": [
+ {
+ "key": "flight|ref:qf8h2l",
+ "kind": "flight",
+ "reference": "QF8H2L",
+ "passengers": [
+ "Erika Mustermann"
+ ],
+ "links": {},
+ "source": "json-ld",
+ "legs": [
+ {
+ "carrier": "Qantas",
+ "carrierCode": "QF",
+ "number": "11",
+ "departure": {
+ "place": {
+ "name": "Sydney",
+ "code": "SYD"
+ },
+ "at": {
+ "date": "2026-12-04",
+ "time": "09:50:00",
+ "offsetMinutes": 660,
+ "utc": "2026-12-03T22:50:00Z",
+ "zone": "Australia/Sydney"
+ }
+ },
+ "arrival": {
+ "place": {
+ "name": "Los Angeles",
+ "code": "LAX"
+ },
+ "at": {
+ "date": "2026-12-04",
+ "time": "06:20:00",
+ "offsetMinutes": -480,
+ "utc": "2026-12-04T14:20:00Z",
+ "zone": "America/Los_Angeles"
+ }
+ },
+ "seats": [],
+ "status": "confirmed"
+ }
+ ],
+ "status": "confirmed",
+ "provider": "Qantas"
+ }
+ ],
+ "doubtful-arrival-before-departure": [],
+ "doubtful-25-hour-flight": [],
+ "doubtful-years-away": [],
+ "doubtful-checkout-before-checkin": [],
+ "doubtful-rental-missing-times": [],
+ "doubtful-bare-date-departure": [],
+ "doubtful-google-example": [],
+ "doubtful-unknown-types-only": [],
+ "doubtful-json-beyond-repair": [],
+ "doubtful-foreign-context": [],
+ "doubtful-zone-mismatch": [],
+ "doubtful-one-bad-leg": [],
+ "doubtful-conflicting-parts": []
+}
diff --git a/lib/structured-mail/__tests__/fixtures/real-mails.json b/lib/structured-mail/__tests__/fixtures/real-mails.json
new file mode 100644
index 000000000..a056bc44a
--- /dev/null
+++ b/lib/structured-mail/__tests__/fixtures/real-mails.json
@@ -0,0 +1,79 @@
+[
+ {
+ "id": "flixbus-booking",
+ "sender": "booking.flixbus.com",
+ "source": "real",
+ "mailDate": "2024-05-28T15:18:21Z",
+ "html": "
"
+ },
+ {
+ "id": "flixbus-invoice",
+ "sender": "flixbus.com",
+ "source": "real",
+ "mailDate": "2024-05-28T15:18:20Z",
+ "html": ""
+ },
+ {
+ "id": "kaufland-order-1",
+ "sender": "kaufland-marktplatz.de",
+ "source": "real",
+ "mailDate": "2024-10-07T20:20:57Z",
+ "html": " "
+ },
+ {
+ "id": "kaufland-parcel-1",
+ "sender": "kaufland-marktplatz.de",
+ "source": "real",
+ "mailDate": "2024-10-08T13:50:25Z",
+ "html": " "
+ },
+ {
+ "id": "kaufland-order-2",
+ "sender": "kaufland-marktplatz.de",
+ "source": "real",
+ "mailDate": "2024-11-11T14:33:42Z",
+ "html": " "
+ },
+ {
+ "id": "kaufland-parcel-2",
+ "sender": "kaufland-marktplatz.de",
+ "source": "real",
+ "mailDate": "2024-11-12T10:20:17Z",
+ "html": " "
+ },
+ {
+ "id": "ahoi-event",
+ "sender": "ahoi.cloud",
+ "source": "real",
+ "mailDate": "2021-05-28T13:30:10Z",
+ "html": ""
+ },
+ {
+ "id": "k4g-order",
+ "sender": "k4g.com",
+ "source": "real",
+ "mailDate": "2021-12-04T22:05:29Z",
+ "html": ""
+ },
+ {
+ "id": "uber-order-1",
+ "sender": "uber.com",
+ "source": "real",
+ "mailDate": "2025-01-22T22:39:53Z",
+ "html": ""
+ },
+ {
+ "id": "uber-order-2",
+ "sender": "uber.com",
+ "source": "real",
+ "mailDate": "2025-02-06T00:08:10Z",
+ "html": ""
+ },
+ {
+ "id": "zave-newsletter",
+ "sender": "p.zave.it",
+ "source": "real",
+ "mailDate": "2024-11-12T00:27:23Z",
+ "html": ""
+ }
+]
\ No newline at end of file
diff --git a/lib/structured-mail/__tests__/fixtures/synthetic-mails.ts b/lib/structured-mail/__tests__/fixtures/synthetic-mails.ts
new file mode 100644
index 000000000..eec814a64
--- /dev/null
+++ b/lib/structured-mail/__tests__/fixtures/synthetic-mails.ts
@@ -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 = '') => `${head}Thank you for your booking.
${body}`;
+const script = (text: string) => ``;
+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 }) => `
+
+
+
+
+
+
+
+
+
+
+ ${o.arr ? `
` : ''}
+
+
`;
+
+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, '"'))),
+ },
+ // @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('', `
+
+ | Booking IB55XQ |
+ Erika Mustermann |
+
+ Iberia (IB)
+ IB 3107
+ Madrid MAD
+
+ Barcelona BCN
+
+ |
`),
+ },
+ // 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' }),
+ ])),
+ },
+];
diff --git a/lib/structured-mail/__tests__/merge.test.ts b/lib/structured-mail/__tests__/merge.test.ts
new file mode 100644
index 000000000..3683765bd
--- /dev/null
+++ b/lib/structured-mail/__tests__/merge.test.ts
@@ -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[]) => `${blocks.map((b) => ``).join('')}`;
+
+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 = ``;
+ const html = page({ '@context': C, '@type': 'Order', orderNumber: 'SO-1', merchant: 'Shop', price: 10, priceCurrency: 'EUR' }).replace('', `${microdata}`);
+ 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);
+ });
+});
diff --git a/lib/structured-mail/__tests__/structured-mail-fixtures.test.ts b/lib/structured-mail/__tests__/structured-mail-fixtures.test.ts
new file mode 100644
index 000000000..c97a4d382
--- /dev/null
+++ b/lib/structured-mail/__tests__/structured-mail-fixtures.test.ts
@@ -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;
+
+async function cardsOf(fixture: MailFixture): Promise {
+ 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();
+
+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}`);
+ });
+});
diff --git a/lib/structured-mail/index.ts b/lib/structured-mail/index.ts
index b4461555d..aead6b622 100644
--- a/lib/structured-mail/index.ts
+++ b/lib/structured-mail/index.ts
@@ -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';
diff --git a/lib/structured-mail/merge.ts b/lib/structured-mail/merge.ts
new file mode 100644
index 000000000..77f9e0199
--- /dev/null
+++ b/lib/structured-mail/merge.ts
@@ -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(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();
+ 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();
+ 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();
+ 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();
+ 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)));
+}
diff --git a/lib/structured-mail/normalize.ts b/lib/structured-mail/normalize.ts
index 9a650c6bf..381564c65 100644
--- a/lib/structured-mail/normalize.ts
+++ b/lib/structured-mail/normalize.ts
@@ -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) } : {}),