From fa324170a24d1f2dddea40d168c7251a06a2ac0c Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Fri, 30 Oct 2026 04:33:57 +0100 Subject: [PATCH] feat: merge booking parts into one card per booking --- .../__tests__/fixtures/expected-cards.json | 1260 +++++++++++++++++ .../__tests__/fixtures/real-mails.json | 79 ++ .../__tests__/fixtures/synthetic-mails.ts | 335 +++++ lib/structured-mail/__tests__/merge.test.ts | 91 ++ .../structured-mail-fixtures.test.ts | 58 + lib/structured-mail/index.ts | 12 +- lib/structured-mail/merge.ts | 203 +++ lib/structured-mail/normalize.ts | 15 +- 8 files changed, 2045 insertions(+), 8 deletions(-) create mode 100644 lib/structured-mail/__tests__/fixtures/expected-cards.json create mode 100644 lib/structured-mail/__tests__/fixtures/real-mails.json create mode 100644 lib/structured-mail/__tests__/fixtures/synthetic-mails.ts create mode 100644 lib/structured-mail/__tests__/merge.test.ts create mode 100644 lib/structured-mail/__tests__/structured-mail-fixtures.test.ts create mode 100644 lib/structured-mail/merge.ts 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 IB55XQErika 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) } : {}),