' },
+ { name: 'Received', value: 'from mail.shop.example (mail.shop.example. [192.0.2.1]) by mx.google.com with ESMTPS id 2' },
+ { name: 'Received-SPF', value: 'pass' },
+ { name: 'Authentication-Results', value: 'mx.google.com; dkim=pass header.i=@shop.example' },
+ ])).toBe(true);
+ });
+
+ it('refuses headers above every Received, after a submission hop, or with no Received at all', () => {
+ expect(authenticationResultsOnReceipt([{ name: 'Authentication-Results', value: 'x' }, { name: 'Received', value: 'from a by b with ESMTPS' }])).toBe(false);
+ expect(authenticationResultsOnReceipt([{ name: 'Received', value: 'from a by b with LMTPSA' }, { name: 'Authentication-Results', value: 'x' }])).toBe(false);
+ expect(authenticationResultsOnReceipt([{ name: 'Received', value: 'from a by b with utf8esmtpa' }, { name: 'Authentication-Results', value: 'x' }])).toBe(false);
+ expect(authenticationResultsOnReceipt([{ name: 'From', value: 'x' }, { name: 'Authentication-Results', value: 'x' }])).toBe(false);
+ expect(authenticationResultsOnReceipt([{ name: 'Received', value: 'from a by b with ESMTPS' }])).toBe(false);
+ });
+});
+
+describe('forwarded and personal mail', () => {
+ it('gives no card for a forward prefix anywhere in the prefix chain', () => {
+ expect(reason(mail({ ar: AR.pass, subject: 'Fwd: Your order SO-1' }))).toBe('forwarded');
+ expect(reason(mail({ ar: AR.pass, subject: 'Re: WG: Ihre Bestellung' }))).toBe('forwarded');
+ expect(reason(mail({ ar: AR.pass, subject: 'Re: Your order SO-1' }))).toBe('trusted');
+ });
+
+ it('gives no card when the body carries a forward separator or Outlook header block', () => {
+ const separator = HTML.replace('Thanks', '
---------- Forwarded message ----------
Thanks');
+ expect(reason(mail({ ar: AR.pass, html: separator }))).toBe('forwarded');
+ const outlook = 'FYI\n\nVon: Shop \nGesendet: Freitag, 30. Oktober 2026 10:00\nAn: Erika\nBetreff: Ihre Bestellung\n';
+ expect(reason(mail({ ar: AR.pass, text: outlook }))).toBe('forwarded');
+ expect(reason(mail({ ar: AR.pass, text: 'From: Frankfurt\nTo: New York\nDate: 1 Dec' }))).toBe('trusted');
+ });
+
+ it('gives no card for mail from a personal mailbox, even when it passes', () => {
+ const ar = 'mx.example.org; dkim=pass header.d=gmail.com; spf=pass smtp.mailfrom=friend@gmail.com; dmarc=pass header.from=gmail.com';
+ expect(reason(mail({ from: 'friend@gmail.com', ar }))).toBe('personal_mailbox');
+ expect(isPersonalMailboxDomain('outlook.de')).toBe(true);
+ expect(isPersonalMailboxDomain('yahoo.co.uk')).toBe(true);
+ expect(isPersonalMailboxDomain('lufthansa.com')).toBe(false);
+ });
+
+ it('detects forward prefixes on their own', () => {
+ expect(hasForwardPrefix('FW: x')).toBe(true);
+ expect(hasForwardPrefix('AW: Antw: Fwd: x')).toBe(true);
+ expect(hasForwardPrefix('Forwarding your booking')).toBe(false);
+ expect(hasForwardPrefix(undefined)).toBe(false);
+ });
+});
+
+describe('attached and truncated mail', () => {
+ it('gives no card when the HTML part sits inside an attached message', () => {
+ const bodyStructure = {
+ type: 'multipart/mixed', subParts: [
+ { partId: '1', type: 'text/plain' },
+ { type: 'message/rfc822', partId: '3', subParts: [{ partId: '2', type: 'text/html' }] },
+ ],
+ } as unknown as EmailBodyPart;
+ expect(reason(mail({ ar: AR.pass, bodyStructure }))).toBe('attached_message');
+ });
+
+ it('gives no card when the HTML body value was cut off', () => {
+ expect(reason(mail({ ar: AR.pass, truncated: true }))).toBe('truncated');
+ });
+});
diff --git a/src/lib/structured-mail/__tests__/validate-resolve.test.ts b/src/lib/structured-mail/__tests__/validate-resolve.test.ts
new file mode 100644
index 00000000..f7f9227c
--- /dev/null
+++ b/src/lib/structured-mail/__tests__/validate-resolve.test.ts
@@ -0,0 +1,151 @@
+// Mirrors webmail lib/structured-mail/__tests__/validate-resolve.test.ts at 90d7afc34; keep the two in step.
+import { describe, expect, it } from 'vitest';
+import { normalizeItems } from '../normalize';
+import { needsAirportZones, resolveCards } from '../resolve';
+import { loadAirportZones } from '../time-zones';
+import type { StructuredCard, TransportBooking } from '../types';
+import { validateCard } from '../validate';
+
+const ctx = 'http://schema.org';
+const MAIL = new Date('2026-10-30T10:00:00Z');
+const one = (item: unknown) => normalizeItems([{ item, source: 'json-ld' }])[0];
+const problems = (item: unknown, mailDate = MAIL) => validateCard(one(item), mailDate).problems;
+
+function flight(dep: [string, string, string], arr: [string, string, string], extra: Record = {}) {
+ return {
+ '@context': ctx, '@type': 'FlightReservation', reservationNumber: 'T1', underName: 'Erika Mustermann',
+ reservationFor: {
+ '@type': 'Flight', flightNumber: '100', airline: { name: 'Test Air', iataCode: 'TA' },
+ departureAirport: { name: dep[0], iataCode: dep[1] }, departureTime: dep[2],
+ arrivalAirport: { name: arr[0], iataCode: arr[1] }, arrivalTime: arr[2],
+ },
+ ...extra,
+ };
+}
+
+const sfoJfk = flight(['San Francisco', 'SFO', '2026-12-04T20:15:00-08:00'], ['New York', 'JFK', '2026-12-05T06:30:00-05:00']);
+
+describe('validateCard', () => {
+ it('accepts a complete flight near the mail date', () => {
+ expect(problems(sfoJfk)).toEqual([]);
+ });
+
+ it('rejects flights with missing times or places', () => {
+ const noArrival = flight(['San Francisco', 'SFO', '2026-12-04T20:15:00-08:00'], ['New York', 'JFK', '']);
+ const bareDate = flight(['San Francisco', 'SFO', '2026-12-04'], ['New York', 'JFK', '2026-12-05T06:30:00-05:00']);
+ const noPlace = flight(['', '', '2026-12-04T20:15:00-08:00'], ['New York', 'JFK', '2026-12-05T06:30:00-05:00']);
+ expect(problems(noArrival)).toContain('arrival time missing');
+ expect(problems(bareDate)).toContain('departure time missing');
+ expect(problems(noPlace)).toContain('departure place missing');
+ });
+
+ it('rejects bookings years away from the mail', () => {
+ expect(problems(sfoJfk, new Date('2024-11-12T00:00:00Z'))).toContain('date too far from the mail');
+ expect(problems(sfoJfk, new Date('2028-01-01T00:00:00Z'))).toContain('date too far from the mail');
+ });
+
+ it('rejects copies of Google documentation examples', () => {
+ expect(problems({ ...sfoJfk, reservationNumber: 'RXJ34P' })).toContain('documentation example');
+ expect(problems({ '@context': ctx, '@type': 'ParcelDelivery', trackingNumber: '3453291231', carrier: 'FedEx' })).toContain('documentation example');
+ });
+
+ it('rejects stays that end before they start', () => {
+ const stay = (checkin: string, checkout: string) => ({
+ '@context': ctx, '@type': 'LodgingReservation', reservationNumber: 'H', reservationFor: { '@type': 'Hotel', name: 'Hotel' },
+ checkinDate: checkin, checkoutDate: checkout,
+ });
+ expect(problems(stay('2026-12-04', '2026-12-06'))).toEqual([]);
+ expect(problems(stay('2026-12-06', '2026-12-04'))).toContain('check-out not after check-in');
+ expect(problems(stay('2026-12-04', '2026-12-04'))).toContain('check-out not after check-in');
+ });
+
+ it('rejects orders with nothing to identify them, like Uber\'s empty Order', () => {
+ expect(problems({ '@context': ctx, '@type': 'Order', merchant: { name: 'UberEATS' } })).toContain('neither order number nor tracking');
+ expect(problems({ '@context': ctx, '@type': 'Order', merchant: 'K4G', orderNumber: 'A1' })).toEqual([]);
+ });
+});
+
+describe('resolveCards', () => {
+ const resolved = async (item: unknown): Promise => {
+ const cards = [one(item)];
+ return needsAirportZones(cards) ? resolveCards(cards, await loadAirportZones()) : resolveCards(cards);
+ };
+
+ it('gives flight ends their airport zones and keeps the instants', async () => {
+ const [card] = (await resolved(sfoJfk)) as TransportBooking[];
+ expect(card.legs[0].departure.at).toMatchObject({ zone: 'America/Los_Angeles', utc: '2026-12-05T04:15:00Z' });
+ expect(card.legs[0].arrival?.at).toMatchObject({ zone: 'America/New_York', utc: '2026-12-05T11:30:00Z' });
+ });
+
+ it('accepts eastbound date-line flights that land earlier on the wall clock', async () => {
+ const nrtHnl = flight(['Tokyo Narita', 'NRT', '2026-12-04T21:00:00+09:00'], ['Honolulu', 'HNL', '2026-12-04T09:15:00-10:00']);
+ const sydLax = flight(['Sydney', 'SYD', '2026-12-04T09:50:00+11:00'], ['Los Angeles', 'LAX', '2026-12-04T06:20:00-08:00']);
+ expect(await resolved(nrtHnl)).toHaveLength(1);
+ expect(await resolved(sydLax)).toHaveLength(1);
+ });
+
+ it('rejects arrival before departure and flights over 20 hours', async () => {
+ const backwards = flight(['Frankfurt', 'FRA', '2026-12-01T10:15:00+01:00'], ['New York', 'JFK', '2026-12-01T03:05:00-05:00']);
+ const tooLong = flight(['Frankfurt', 'FRA', '2026-12-01T10:15:00+01:00'], ['Sydney', 'SYD', '2026-12-02T23:00:00+11:00']);
+ expect(await resolved(backwards)).toEqual([]);
+ expect(await resolved(tooLong)).toEqual([]);
+ });
+
+ it('drops a flight whose offset contradicts the airport zone', async () => {
+ const wrongOffset = flight(['Frankfurt', 'FRA', '2026-12-01T10:15:00+00:00'], ['New York', 'JFK', '2026-12-01T13:05:00-05:00']);
+ expect(await resolved(wrongOffset)).toEqual([]);
+ });
+
+ it('reads a flight time without offset in the airport zone, and drops it when the airport is unknown', async () => {
+ const floating = flight(['Frankfurt', 'FRA', '2026-12-01T10:15:00'], ['New York', 'JFK', '2026-12-01T13:05:00']);
+ const [card] = (await resolved(floating)) as TransportBooking[];
+ expect(card.legs[0].departure.at).toMatchObject({ zone: 'Europe/Berlin', utc: '2026-12-01T09:15:00Z' });
+ const unknown = flight(['Nowhere', 'QQQ', '2026-12-01T10:15:00'], ['New York', 'JFK', '2026-12-01T13:05:00']);
+ expect(await resolved(unknown)).toEqual([]);
+ });
+
+ it('never shows a flight before the airport table is there', () => {
+ expect(resolveCards([one(sfoJfk)])).toEqual([]);
+ });
+
+ it('uses a fixed-offset zone where no airport code is known', async () => {
+ const noCode = flight(['San Francisco', '', '2026-12-04T20:15:00-08:00'], ['New York', 'JFK', '2026-12-05T06:30:00-05:00']);
+ const [card] = (await resolved(noCode)) as TransportBooking[];
+ expect(card.legs[0].departure.at.zone).toBe('Etc/GMT+8');
+ });
+
+ it('treats hotel times without offset or known country as dates only', async () => {
+ const [card] = await resolved({
+ '@context': ctx, '@type': 'LodgingReservation', reservationNumber: 'H', reservationFor: { '@type': 'Hotel', name: 'Hotel', address: { addressCountry: 'US' } },
+ checkinDate: '2026-12-04T15:00:00', checkoutDate: '2026-12-06T11:00:00',
+ });
+ expect(card).toMatchObject({ checkin: { date: '2026-12-04' }, checkout: { date: '2026-12-06' } });
+ expect((card as { checkin: { time?: string } }).checkin.time).toBeUndefined();
+ });
+
+ it('ends a date-only event end at midnight local time even across a DST change', async () => {
+ const event = (country?: string) => ({
+ '@context': ctx, '@type': 'EventReservation', reservationNumber: 'EV1',
+ reservationFor: {
+ '@type': 'Event', name: 'Herbstmesse', startDate: '2026-10-23T18:00:00+02:00', endDate: '2026-10-26',
+ location: { '@type': 'Place', name: 'Messe', ...(country ? { address: { addressCountry: country } } : {}) },
+ },
+ });
+ const [withVenue] = await resolved(event('DE'));
+ expect(withVenue).toMatchObject({ end: { date: '2026-10-26', time: '23:59:59', zone: 'Europe/Berlin', utc: '2026-10-26T22:59:59Z' } });
+ const [noVenueCountry] = await resolved(event());
+ expect(noVenueCountry).toMatchObject({ end: { date: '2026-10-26', time: '23:59:59', zone: 'Etc/GMT-2', utc: '2026-10-26T21:59:59Z' } });
+ });
+
+ it('reads bus times in the single zone of the stop\'s country', async () => {
+ const [card] = (await resolved({
+ '@context': ctx, '@type': 'BusReservation', reservationNumber: 'B',
+ reservationFor: {
+ '@type': 'BusTrip', busNumber: 'FLX30',
+ departureBusStop: { name: 'Hannover', address: { addressCountry: 'DE' } }, departureTime: '2026-06-04T11:44:00+0200',
+ arrivalBusStop: { name: 'Berlin', address: { addressCountry: 'DE' } }, arrivalTime: '2026-06-04T13:44:00+0200',
+ },
+ })) as TransportBooking[];
+ expect(card.legs[0].departure.at.zone).toBe('Europe/Berlin');
+ });
+});
diff --git a/src/lib/structured-mail/calendar.ts b/src/lib/structured-mail/calendar.ts
new file mode 100644
index 00000000..55b149ed
--- /dev/null
+++ b/src/lib/structured-mail/calendar.ts
@@ -0,0 +1,697 @@
+// Mirrors webmail lib/structured-mail/calendar.ts at 87d9f8923; keep the two in step.
+/**
+ * Calendar events for booking cards: one JSCalendar event per transport leg
+ * and one per hotel stay, rental car, event or table booking. Plain
+ * TypeScript, no React and no stores, so the native app can reuse it.
+ *
+ * Times: a timed event starts at the departure's wall clock in its zone and
+ * lasts the exact hours and minutes between the two UTC instants (never a
+ * `D` duration, so a DST change in between can't move the end). The arrival
+ * zone goes on the arrival location (`relativeTo: "end"`). Floating times
+ * (no offset, no known zone) stay floating. Every event gets a uid derived
+ * from the sender's organisational domain and the booking's identity, so a
+ * later mail about the same booking finds it again and a mail from another
+ * domain never can.
+ */
+import type { CalendarEvent, EventLocation as CalendarLocation } from '../../api/types';
+import { normalizeCalendarEventLike, sanitizeOutgoingCalendarEventData } from './event-normalization';
+import { fixedOffsetZone, wallClockAt } from './time-zones';
+import type {
+ Address,
+ Booking,
+ Moment,
+ Place,
+ StructuredCard,
+ TransportBooking,
+ TransportLeg,
+} from './types';
+
+const DAY = 86_400_000;
+const UID_DOMAIN = 'structured.bulwark';
+
+// ---------------------------------------------------------------------------
+// uid
+
+/**
+ * Public suffixes of two labels that commercial senders sit under. Not the
+ * whole Public Suffix List: a suffix missing here (`gouv.fr`) makes every
+ * domain under it share one organisational domain. That is acceptable for
+ * uids, which also need the same booking reference (or, without one, the
+ * same leg and date) to match, and booking mail from such domains is rare.
+ */
+const TWO_LABEL_SUFFIXES = new Set([
+ 'co.uk', 'org.uk', 'ac.uk', 'gov.uk', 'me.uk', 'ltd.uk', 'plc.uk',
+ 'com.au', 'net.au', 'org.au',
+ 'co.jp', 'ne.jp', 'or.jp',
+ 'com.br', 'co.nz', 'co.za', 'com.mx', 'com.tr', 'co.in', 'com.cn', 'com.sg', 'com.hk', 'co.kr', 'co.il',
+ 'com.ar', 'com.tw', 'co.th', 'co.id', 'com.my', 'com.ph', 'co.at', 'or.at', 'com.es', 'com.pl', 'com.pt',
+]);
+
+/**
+ * The organisational domain of a sender domain: the last two labels, or three
+ * under a two-label public suffix (`co.uk`, `com.au`).
+ * `booking.flixbus.com` → `flixbus.com`, `shop.example.co.uk` → `example.co.uk`.
+ */
+export function organizationalDomain(fromDomain: string): string {
+ const labels = fromDomain.trim().toLowerCase().replace(/\.+$/, '').split('.').filter(Boolean);
+ const count = labels.length >= 3 && TWO_LABEL_SUFFIXES.has(labels.slice(-2).join('.')) ? 3 : 2;
+ return labels.slice(-count).join('.');
+}
+
+/** The domain part of an address (`orders@booking.flixbus.com` → `booking.flixbus.com`). */
+export function senderDomainOf(address: string | undefined): string {
+ if (!address) return '';
+ const at = address.lastIndexOf('@');
+ return (at >= 0 ? address.slice(at + 1) : address).trim().toLowerCase();
+}
+
+function utf8Bytes(text: string): number[] {
+ const bytes: number[] = [];
+ for (const char of text) {
+ const cp = char.codePointAt(0) ?? 0;
+ if (cp < 0x80) bytes.push(cp);
+ else if (cp < 0x800) bytes.push(0xc0 | (cp >> 6), 0x80 | (cp & 0x3f));
+ else if (cp < 0x10000) bytes.push(0xe0 | (cp >> 12), 0x80 | ((cp >> 6) & 0x3f), 0x80 | (cp & 0x3f));
+ else bytes.push(0xf0 | (cp >> 18), 0x80 | ((cp >> 12) & 0x3f), 0x80 | ((cp >> 6) & 0x3f), 0x80 | (cp & 0x3f));
+ }
+ return bytes;
+}
+
+/**
+ * 64-bit FNV-1a of the UTF-8 bytes, as 16 hex digits. Synchronous and on
+ * plain numbers (four 16-bit limbs), so it runs without crypto.subtle (Lite
+ * on plain http) and without BigInt.
+ */
+export function fnv1a64(text: string): string {
+ // Offset basis 0xcbf29ce484222325, lowest limb first.
+ let h0 = 0x2325, h1 = 0x8422, h2 = 0x9ce4, h3 = 0xcbf2;
+ for (const byte of utf8Bytes(text)) {
+ h0 ^= byte;
+ // Multiply by the prime 0x100000001b3 = 2^40 + 0x1b3, modulo 2^64.
+ let r0 = h0 * 0x1b3;
+ let r1 = h1 * 0x1b3;
+ let r2 = h2 * 0x1b3 + h0 * 0x100;
+ let r3 = h3 * 0x1b3 + h1 * 0x100;
+ r1 += r0 >>> 16; r0 &= 0xffff;
+ r2 += r1 >>> 16; r1 &= 0xffff;
+ r3 += r2 >>> 16; r2 &= 0xffff;
+ r3 &= 0xffff;
+ h0 = r0; h1 = r1; h2 = r2; h3 = r3;
+ }
+ return [h3, h2, h1, h0].map((limb) => limb.toString(16).padStart(4, '0')).join('');
+}
+
+const norm = (value: string | undefined) => (value ?? '').toLowerCase().replace(/\s+/g, ' ').trim();
+
+function uidFrom(kind: Booking['kind'], parts: (string | undefined)[]): string {
+ return `bulwark-sm-${kind}-${fnv1a64(parts.map(norm).join('|'))}@${UID_DOMAIN}`;
+}
+
+/**
+ * A leg's number as an identity: no spaces ("ICE 577" = "ICE577") and no
+ * leading zeros ("LX0072" in the markup is flight 72 on the boarding pass;
+ * legKey in normalize.ts strips them the same way).
+ */
+function legNumber(number: string | undefined): string {
+ return norm(number).replace(/\s+/g, '').replace(/^0+(?=\d)/, '');
+}
+
+/**
+ * The identity of a leg, from the leg alone: reference, carrier IATA code
+ * (the name only without one), number and departure code or name. Without a
+ * reference the departure's local date is added. Never the time, and with a
+ * reference not the date either, so a schedule change (even one that moves
+ * the departure past midnight) updates the same event.
+ *
+ * It never depends on the booking's other legs, so a later mail that lists
+ * only some of them finds the same events. The price: two legs of one
+ * booking with the same reference, flight and departure (the same commuter
+ * flight on two days) share a uid, and since one uid holds one event,
+ * bookingEventSlots offers neither of them.
+ */
+function legParts(booking: TransportBooking, leg: TransportLeg): (string | undefined)[] {
+ return [
+ booking.reference,
+ leg.carrierCode ?? leg.carrier,
+ legNumber(leg.number),
+ leg.departure.place.code ?? leg.departure.place.name,
+ ...(booking.reference ? [] : [leg.departure.at.date]),
+ ];
+}
+
+/**
+ * The uid of the event for a booking (or, for flights, trains and buses, for
+ * one of its legs; the first leg when none is given):
+ * `bulwark-sm--<16 hex>@structured.bulwark`, hashed over a key that
+ * starts with the sender's organisational domain.
+ */
+export function bookingEventUid(booking: Booking, senderDomain: string, leg?: TransportLeg): string {
+ const domain = organizationalDomain(senderDomainOf(senderDomain));
+ // With a reference the date stays out, so a moved booking keeps its event;
+ // without one the date is part of what identifies it.
+ const dateUnlessReference = (date: string) => (booking.reference ? [] : [date]);
+ switch (booking.kind) {
+ case 'flight': case 'train': case 'bus':
+ return uidFrom(booking.kind, [domain, ...legParts(booking, leg ?? booking.legs[0])]);
+ case 'lodging':
+ return uidFrom('lodging', [domain, booking.reference, booking.name, ...dateUnlessReference(booking.checkin.date)]);
+ case 'rentalcar':
+ return uidFrom('rentalcar', [domain, booking.reference, booking.company ?? booking.provider, ...dateUnlessReference(booking.pickup.at.date)]);
+ case 'event': case 'restaurant':
+ // One order can cover several events: the name stays in the key.
+ return uidFrom(booking.kind, [domain, booking.reference, booking.name, ...dateUnlessReference(booking.start.date)]);
+ }
+}
+
+// ---------------------------------------------------------------------------
+// times
+
+interface Instant {
+ /** Local wall clock, `YYYY-MM-DDTHH:mm:ss`, in `timeZone`. */
+ start: string;
+ /** IANA zone, or null for a floating time. */
+ timeZone: string | null;
+ /** UTC instant in ms; null when floating. */
+ utcMs: number | null;
+ /** The wall clock as the mail wrote it, read as if it were UTC (for floating differences). */
+ naiveMs: number;
+}
+
+function naiveMs(m: Moment): number {
+ return Date.parse(`${m.date}T${m.time ?? '00:00:00'}Z`);
+}
+
+/**
+ * Where a timed moment sits: its resolved zone and instant. A moment that
+ * resolveCards hasn't seen but that carries an offset gets the fixed-offset
+ * zone; one without offset or zone stays floating.
+ */
+function instantOf(m: Moment | undefined): Instant | null {
+ if (!m?.time || !/^\d{4}-\d{2}-\d{2}$/.test(m.date)) return null;
+ const naive = naiveMs(m);
+ if (!Number.isFinite(naive)) return null;
+ const zone = m.zone !== undefined ? m.zone : (m.offsetMinutes !== undefined ? fixedOffsetZone(m.offsetMinutes) : null);
+ if (zone === null) return { start: `${m.date}T${m.time}`, timeZone: null, utcMs: null, naiveMs: naive };
+ const utcMs = m.utc ? Date.parse(m.utc) : NaN;
+ if (!Number.isFinite(utcMs)) return null;
+ const wall = wallClockAt(utcMs, zone);
+ return { start: `${wall.date}T${wall.time}`, timeZone: zone, utcMs, naiveMs: naive };
+}
+
+/** Hours, minutes and seconds only (`PT7H15M`, `PT43H`, `PT0S`). */
+function timedDuration(ms: number): string {
+ const total = Math.round(ms / 1000);
+ if (total <= 0) return 'PT0S';
+ const h = Math.floor(total / 3600);
+ const m = Math.floor((total % 3600) / 60);
+ const s = total % 60;
+ return `PT${h ? `${h}H` : ''}${m ? `${m}M` : ''}${s ? `${s}S` : ''}`;
+}
+
+/**
+ * The exact span between two timed moments: from the UTC instants when both
+ * have one, else from the wall clocks as written. Null when the end lies
+ * before the start (resolveCards drops such bookings; this is a guard).
+ */
+function spanBetween(start: Instant, end: Instant): number | null {
+ const ms = start.utcMs !== null && end.utcMs !== null ? end.utcMs - start.utcMs : end.naiveMs - start.naiveMs;
+ return ms >= 0 ? ms : null;
+}
+
+function dayNumber(date: string): number | null {
+ const ms = Date.parse(`${date}T00:00:00Z`);
+ return Number.isFinite(ms) ? Math.round(ms / DAY) : null;
+}
+
+/** An all-day span from `first` to `last`, both days included. */
+function allDaySpan(first: string, last: string): { start: string; duration: string } | null {
+ const a = dayNumber(first);
+ const b = dayNumber(last);
+ if (a === null || b === null || b < a) return null;
+ return { start: `${first}T00:00:00`, duration: `P${b - a + 1}D` };
+}
+
+/**
+ * When a moment is over, in ms: its instant; a floating time read in the
+ * runtime's zone; a date-only value at the end of that day (local).
+ */
+function momentOverAt(m: Moment | undefined): number | null {
+ if (!m || !/^\d{4}-\d{2}-\d{2}$/.test(m.date)) return null;
+ if (m.time && m.utc) return Date.parse(m.utc);
+ const [y, mo, d] = m.date.split('-').map(Number);
+ if (m.time) {
+ const [h, mi, s] = m.time.split(':').map(Number);
+ return new Date(y, mo - 1, d, h, mi, s || 0).getTime();
+ }
+ return new Date(y, mo - 1, d + 1).getTime();
+}
+
+/**
+ * When the booking is over (ms): the last arrival (or departure, for a leg
+ * without arrival), check-out, drop-off, the event's end or start, the
+ * table's time. Null when no date can be read.
+ */
+export function bookingEndsAt(booking: Booking): number | null {
+ const ends: (number | null)[] = [];
+ switch (booking.kind) {
+ case 'flight': case 'train': case 'bus':
+ for (const leg of booking.legs) ends.push(momentOverAt(leg.arrival?.at ?? leg.departure.at));
+ break;
+ case 'lodging': ends.push(momentOverAt(booking.checkout)); break;
+ case 'rentalcar': ends.push(momentOverAt(booking.dropoff.at)); break;
+ case 'event': ends.push(momentOverAt(booking.end ?? booking.start)); break;
+ case 'restaurant': ends.push(momentOverAt(booking.start)); break;
+ }
+ const known = ends.filter((e): e is number => e !== null && Number.isFinite(e));
+ return known.length > 0 ? Math.max(...known) : null;
+}
+
+// ---------------------------------------------------------------------------
+// text
+
+/** Description lines and the one title word that need the user's language. */
+export interface BookingEventLabels {
+ reference: (reference: string) => string;
+ passengers: (names: string) => string;
+ seats: (seats: string) => string;
+ checkin: (url: string) => string;
+ manage: (url: string) => string;
+ ticket: (url: string) => string;
+ phone: (phone: string) => string;
+ car: (car: string) => string;
+ tickets: (numbers: string) => string;
+ partySize: (count: number) => string;
+ rentalCar: (company: string | undefined) => string;
+ addedFrom: (domain: string, subject: string | undefined) => string;
+}
+
+export const ENGLISH_LABELS: BookingEventLabels = {
+ reference: (reference) => `Booking reference: ${reference}`,
+ passengers: (names) => `Passengers: ${names}`,
+ seats: (seats) => `Seats: ${seats}`,
+ checkin: (url) => `Check in: ${url}`,
+ manage: (url) => `Manage booking: ${url}`,
+ ticket: (url) => `Ticket: ${url}`,
+ phone: (phone) => `Phone: ${phone}`,
+ car: (car) => `Car: ${car}`,
+ tickets: (numbers) => `Tickets: ${numbers}`,
+ partySize: (count) => `Party size: ${count}`,
+ rentalCar: (company) => (company ? `Rental car: ${company}` : 'Rental car'),
+ addedFrom: (domain, subject) => (subject ? `Added from the mail "${subject}" from ${domain}` : `Added from a mail from ${domain}`),
+};
+
+function addressLines(address: Address | undefined): string[] {
+ if (!address) return [];
+ const city = [address.postalCode, address.locality].filter(Boolean).join(' ');
+ return [address.street, city].filter((line): line is string => !!line && line.trim() !== '');
+}
+
+/** "Name, Street 1, 12345 City", skipping parts that repeat. */
+function joinParts(parts: (string | undefined)[]): string {
+ const out: string[] = [];
+ for (const part of parts) {
+ const value = part?.trim();
+ if (value && !out.some((p) => p.toLowerCase() === value.toLowerCase())) out.push(value);
+ }
+ return out.join(', ');
+}
+
+/** "Frankfurt Airport (FRA)" for airports, name and address elsewhere. */
+function placeName(place: Place | undefined, kind: Booking['kind']): string {
+ if (!place) return '';
+ if (kind === 'flight') {
+ if (place.name && place.code) return place.name.toUpperCase().includes(`(${place.code})`) ? place.name : `${place.name} (${place.code})`;
+ return place.code ?? place.name ?? '';
+ }
+ return joinParts([place.name, ...addressLines(place.address)]);
+}
+
+function shortPlace(place: Place | undefined, kind: Booking['kind']): string {
+ if (!place) return '';
+ return (kind === 'flight' ? place.code ?? place.name : place.name ?? place.code) ?? '';
+}
+
+function legTitle(kind: TransportBooking['kind'], leg: TransportLeg): string {
+ let service: string;
+ if (kind === 'flight') {
+ service = [leg.carrierCode ?? leg.carrier, leg.number].filter(Boolean).join(' ');
+ } else if (leg.number && /[a-z]/i.test(leg.number)) {
+ service = leg.number;
+ } else {
+ service = [leg.carrier, leg.number].filter(Boolean).join(' ');
+ }
+ const route = [shortPlace(leg.departure.place, kind), shortPlace(leg.arrival?.place, kind)].filter(Boolean).join(' → ');
+ return [service, route].filter(Boolean).join(' ');
+}
+
+function passengerLines(booking: Booking, leg: TransportLeg | undefined, labels: BookingEventLabels): string[] {
+ const seats = leg?.seats ?? [];
+ const seatsOf = (name: string) => seats.filter((s) => s.passenger === name).map((s) => s.seat);
+ const names = booking.passengers.map((name) => {
+ const own = seatsOf(name);
+ return own.length > 0 ? `${name} (${own.join(', ')})` : name;
+ });
+ const unnamed = seats.filter((s) => !s.passenger || !booking.passengers.includes(s.passenger)).map((s) => s.seat);
+ const lines: string[] = [];
+ if (names.length > 0) lines.push(labels.passengers(names.join(', ')));
+ if (unnamed.length > 0) lines.push(labels.seats(unnamed.join(', ')));
+ return lines;
+}
+
+function linkLines(booking: Booking, labels: BookingEventLabels): string[] {
+ const lines: string[] = [];
+ if (booking.links.checkin) lines.push(labels.checkin(booking.links.checkin.url));
+ if (booking.links.manage) lines.push(labels.manage(booking.links.manage.url));
+ if (booking.links.ticket) lines.push(labels.ticket(booking.links.ticket.url));
+ return lines;
+}
+
+// ---------------------------------------------------------------------------
+// events
+
+export interface BookingEventOptions {
+ /** Domain (or address) of the mail's From: names the sender and keys the uid. */
+ senderDomain: string;
+ /** Subject of the mail, named in the description so the user can find it. */
+ mailSubjectOrProvider?: string;
+ /** Translated description lines; English by default. */
+ labels?: Partial;
+}
+
+/** One event the booking wants in the calendar, or, for a cancelled leg, one it wants gone. */
+export interface BookingEventSlot {
+ uid: string;
+ /** The leg or booking is cancelled: never added, removed when present. */
+ cancelled: boolean;
+ /** The event to create or compare with; null when the times can't be made into one. */
+ event: Partial | null;
+}
+
+type Locations = Record;
+
+function location(name: string, timeZone: string | null | undefined, relativeTo?: 'start' | 'end'): CalendarLocation {
+ return {
+ '@type': 'Location',
+ name,
+ ...(timeZone ? { timeZone } : {}),
+ ...(relativeTo ? { relativeTo } : {}),
+ } as CalendarLocation;
+}
+
+function event(
+ uid: string,
+ title: string,
+ when: { start: string; timeZone: string | null; duration: string; showWithoutTime: boolean },
+ locations: Locations,
+ description: string[],
+ extra: Partial = {},
+): Partial {
+ return {
+ '@type': 'Event',
+ uid,
+ title,
+ start: when.start,
+ timeZone: when.timeZone,
+ duration: when.duration,
+ showWithoutTime: when.showWithoutTime,
+ ...(Object.keys(locations).length > 0 ? { locations } : {}),
+ description: description.filter(Boolean).join('\n'),
+ ...extra,
+ };
+}
+
+/** A timed span from `from` to `to` (or zero length without `to`), or null when it can't be told exactly. */
+function timedSpan(from: Moment, to: Moment | undefined) {
+ const start = instantOf(from);
+ if (!start) return null;
+ if (!to) return { start, end: null, when: { start: start.start, timeZone: start.timeZone, duration: 'PT0S', showWithoutTime: false } };
+ const end = instantOf(to);
+ if (!end) return null;
+ const ms = spanBetween(start, end);
+ if (ms === null) return null;
+ return { start, end, when: { start: start.start, timeZone: start.timeZone, duration: timedDuration(ms), showWithoutTime: false } };
+}
+
+function legEvent(booking: TransportBooking, leg: TransportLeg, uid: string, tail: string[], labels: BookingEventLabels): Partial | null {
+ const span = timedSpan(leg.departure.at, leg.arrival?.at);
+ if (!span) return null;
+ const locations: Locations = {
+ dep: location(placeName(leg.departure.place, booking.kind), span.start.timeZone, 'start'),
+ };
+ if (leg.arrival && placeName(leg.arrival.place, booking.kind)) {
+ locations.arr = location(placeName(leg.arrival.place, booking.kind), span.end?.timeZone, 'end');
+ }
+ return event(uid, legTitle(booking.kind, leg), span.when, locations, [
+ booking.reference ? labels.reference(booking.reference) : '',
+ ...passengerLines(booking, leg, labels),
+ ...linkLines(booking, labels),
+ ...tail,
+ ]);
+}
+
+function slotsOf(booking: Booking, options: BookingEventOptions): BookingEventSlot[] {
+ const labels: BookingEventLabels = { ...ENGLISH_LABELS, ...options.labels };
+ const domain = organizationalDomain(senderDomainOf(options.senderDomain));
+ const tail = [labels.addedFrom(domain, options.mailSubjectOrProvider?.trim() || undefined)];
+ const reference = booking.reference ? labels.reference(booking.reference) : '';
+ const cancelled = booking.status === 'cancelled';
+
+ switch (booking.kind) {
+ case 'flight': case 'train': case 'bus': {
+ const uids = booking.legs.map((leg) => bookingEventUid(booking, options.senderDomain, leg));
+ return booking.legs.map((leg, i) => {
+ // Legs sharing a uid can't each have their own event: offer nothing
+ // for them, neither adding nor removing.
+ const shared = uids.indexOf(uids[i]) !== uids.lastIndexOf(uids[i]);
+ return {
+ uid: uids[i],
+ cancelled: !shared && (cancelled || leg.status === 'cancelled'),
+ event: shared ? null : legEvent(booking, leg, uids[i], tail, labels),
+ };
+ });
+ }
+ case 'lodging': {
+ const uid = bookingEventUid(booking, options.senderDomain);
+ const hotel = joinParts([booking.name, ...addressLines(booking.address)]);
+ const description = [
+ reference,
+ ...passengerLines(booking, undefined, labels),
+ booking.phone ? labels.phone(booking.phone) : '',
+ ...linkLines(booking, labels),
+ ...tail,
+ ];
+ const timed = booking.checkin.time && booking.checkout.time ? timedSpan(booking.checkin, booking.checkout) : null;
+ if (timed) {
+ return [{
+ uid, cancelled,
+ event: event(uid, booking.name, timed.when, { hotel: location(hotel, timed.start.timeZone) }, description, { freeBusyStatus: 'free' }),
+ }];
+ }
+ const days = allDaySpan(booking.checkin.date, booking.checkout.date);
+ return [{
+ uid, cancelled,
+ event: days
+ ? event(uid, booking.name, { ...days, timeZone: null, showWithoutTime: true }, { hotel: location(hotel, null) }, description, { freeBusyStatus: 'free' })
+ : null,
+ }];
+ }
+ case 'rentalcar': {
+ const uid = bookingEventUid(booking, options.senderDomain);
+ const span = timedSpan(booking.pickup.at, booking.dropoff.at);
+ const locations: Locations = {};
+ if (span) {
+ const pickup = placeName(booking.pickup.place, booking.kind);
+ const dropoff = placeName(booking.dropoff.place, booking.kind);
+ if (pickup) locations.pickup = location(pickup, span.start.timeZone, 'start');
+ if (dropoff) locations.dropoff = location(dropoff, span.end?.timeZone, 'end');
+ }
+ return [{
+ uid, cancelled,
+ event: span
+ ? event(uid, labels.rentalCar(booking.company ?? booking.provider), span.when, locations, [
+ reference,
+ booking.car ? labels.car(booking.car) : '',
+ ...linkLines(booking, labels),
+ ...tail,
+ ], { freeBusyStatus: 'free' })
+ : null,
+ }];
+ }
+ case 'event': {
+ const uid = bookingEventUid(booking, options.senderDomain);
+ const description = [
+ reference,
+ booking.tickets.length > 0 ? labels.tickets(booking.tickets.join(', ')) : '',
+ ...linkLines(booking, labels),
+ ...tail,
+ ];
+ let made: Partial | null = null;
+ if (booking.start.time) {
+ // An end date without time already became 23:59:59 in the parser; no
+ // end at all is zero length (Stalwart would stretch a missing
+ // duration to the end of the day).
+ const span = timedSpan(booking.start, booking.end);
+ const venue = placeName(booking.venue, booking.kind);
+ if (span) made = event(uid, booking.name, span.when, venue ? { venue: location(venue, span.start.timeZone) } : {}, description);
+ } else {
+ const days = allDaySpan(booking.start.date, booking.end?.date ?? booking.start.date);
+ const venue = placeName(booking.venue, booking.kind);
+ if (days) made = event(uid, booking.name, { ...days, timeZone: null, showWithoutTime: true }, venue ? { venue: location(venue, null) } : {}, description);
+ }
+ return [{ uid, cancelled, event: made }];
+ }
+ case 'restaurant': {
+ const uid = bookingEventUid(booking, options.senderDomain);
+ const span = timedSpan(booking.start, undefined);
+ const place = joinParts([booking.name, ...addressLines(booking.address)]);
+ return [{
+ uid, cancelled,
+ event: span
+ ? event(uid, booking.name, span.when, { restaurant: location(place, span.start.timeZone) }, [
+ reference,
+ booking.partySize ? labels.partySize(booking.partySize) : '',
+ ...linkLines(booking, labels),
+ ...tail,
+ ])
+ : null,
+ }];
+ }
+ }
+}
+
+/**
+ * Every event a booking stands for, cancelled ones included (the caller
+ * offers to remove those). Nothing for shipments.
+ */
+export function bookingEventSlots(card: StructuredCard, options: BookingEventOptions): BookingEventSlot[] {
+ if (card.kind === 'shipment') return [];
+ return slotsOf(card, options);
+}
+
+/**
+ * The events to add for a booking: one per live leg, or one for the stay,
+ * car, event or table. Cancelled legs give none. No calendarIds (the caller
+ * picks the calendar), no participants, never scheduling messages.
+ */
+export function bookingToEvents(booking: Booking, options: BookingEventOptions): Partial[] {
+ return slotsOf(booking, options)
+ .filter((slot) => !slot.cancelled && slot.event)
+ .map((slot) => slot.event as Partial);
+}
+
+// ---------------------------------------------------------------------------
+// comparing with what the calendar holds
+
+const DURATION = /^P(?:(\d+)W)?(?:(\d+)D)?(?:T(?:(\d+)H)?(?:(\d+)M)?(?:(\d+(?:\.\d+)?)S)?)?$/;
+
+/** Nominal days and exact seconds of a duration; P1D and PT24H differ. */
+function durationParts(value: string | undefined | null): string {
+ const match = value ? DURATION.exec(value) : null;
+ if (!match) return value ?? '';
+ const days = Number(match[1] ?? 0) * 7 + Number(match[2] ?? 0);
+ const seconds = Number(match[3] ?? 0) * 3600 + Number(match[4] ?? 0) * 60 + Number(match[5] ?? 0);
+ return `${days}d${seconds}s`;
+}
+
+const localDateTime = (value: string | undefined | null) => {
+ const v = value ?? '';
+ return /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}$/.test(v) ? `${v}:00` : v.slice(0, 19);
+};
+
+/** Location ids this module writes; other locations on the event are the user's. */
+const OWN_LOCATION_IDS = ['dep', 'arr', 'hotel', 'pickup', 'dropoff', 'venue', 'restaurant'];
+
+/**
+ * An event as the calendar store saves it and reads it back: a timed event
+ * from midnight that lasts whole days becomes all-day without a zone
+ * (sanitizeOutgoingCalendarEventData on the way out, normalizeCalendarEventLike
+ * on the way in). Comparing against this keeps such an event from looking
+ * changed for ever.
+ */
+export function asStoredEvent(event: Partial): Partial {
+ return normalizeCalendarEventLike(sanitizeOutgoingCalendarEventData({ ...event }));
+}
+
+/** Start, duration, all-day flag or zone differ (the zone only matters for timed events). */
+function whenDiffers(have: Partial, want: Partial): boolean {
+ if (localDateTime(have.start) !== localDateTime(want.start)) return true;
+ if (durationParts(have.duration ?? 'PT0S') !== durationParts(want.duration ?? 'PT0S')) return true;
+ if (Boolean(have.showWithoutTime) !== Boolean(want.showWithoutTime)) return true;
+ if (want.showWithoutTime) return false;
+ return (have.timeZone ?? null) !== (want.timeZone ?? null);
+}
+
+/** A location this module writes is missing, extra, or has another name, zone or anchor. */
+function locationsDiffer(have: Partial, want: Partial): boolean {
+ const a = have.locations ?? {};
+ const b = want.locations ?? {};
+ return OWN_LOCATION_IDS.some((id) => {
+ const x = a[id];
+ const y = b[id];
+ if (!x && !y) return false;
+ if (!x || !y) return true;
+ return (x.name ?? '') !== (y.name ?? '')
+ || (x.timeZone ?? null) !== (y.timeZone ?? null)
+ || (x.relativeTo ?? null) !== (y.relativeTo ?? null);
+ });
+}
+
+/**
+ * Whether the stored event sits somewhere else than the booking now says:
+ * start, duration, all-day flag, zone (timed events only) and the name, zone
+ * and anchor of each location this module writes. Field by field, because
+ * the server adds defaults (`iCalendar`, nulls). Title and description are
+ * not compared: the user may have renamed the event or written notes, and a
+ * title in another language is no reason to offer "Update".
+ */
+export function eventNeedsUpdate(existing: Partial, wanted: Partial): boolean {
+ const have = normalizeCalendarEventLike({ ...existing });
+ const want = asStoredEvent(wanted);
+ return whenDiffers(have, want) || locationsDiffer(have, want);
+}
+
+/**
+ * Whether a stored description is still the one this module wrote: its last
+ * line names the sender's organisational domain ("Added from a mail from
+ * lufthansa.com"). Notes the user added below it make it theirs.
+ */
+function isOwnDescription(description: string | null | undefined, senderDomain: string | undefined): boolean {
+ if (!description || !senderDomain) return false;
+ const domain = organizationalDomain(senderDomainOf(senderDomain));
+ const lines = description.split(/\r?\n/).map((line) => line.trim()).filter(Boolean);
+ return domain !== '' && (lines[lines.length - 1] ?? '').includes(domain);
+}
+
+/**
+ * The patch that brings a stored event to the booking's current state,
+ * touching only what differs: the times as one group (start, duration, zone,
+ * all-day flag, so the store can't half-convert them), and the locations this
+ * module writes (the user's own locations stay). Title and description change
+ * only while the description is still the generated one (see
+ * isOwnDescription), so user edits survive. `senderDomain` is the mail's From
+ * domain; without it title and description are never touched.
+ */
+export function bookingEventPatch(existing: Partial, wanted: Partial, senderDomain?: string): Partial {
+ const have = normalizeCalendarEventLike({ ...existing });
+ const want = asStoredEvent(wanted);
+ const patch: Partial = {};
+ if (whenDiffers(have, want)) {
+ patch.start = want.start;
+ patch.duration = want.duration;
+ patch.timeZone = want.timeZone ?? null;
+ patch.showWithoutTime = Boolean(want.showWithoutTime);
+ }
+ if (locationsDiffer(have, want)) {
+ const locations: Locations = {};
+ for (const [id, loc] of Object.entries(existing.locations ?? {})) {
+ if (!OWN_LOCATION_IDS.includes(id)) locations[id] = loc;
+ }
+ Object.assign(locations, want.locations ?? {});
+ patch.locations = Object.keys(locations).length > 0 ? locations : null;
+ }
+ if (isOwnDescription(existing.description, senderDomain)) {
+ if (want.title !== undefined && want.title !== existing.title) patch.title = want.title;
+ if (want.description !== undefined && want.description !== existing.description) patch.description = want.description;
+ }
+ return patch;
+}
diff --git a/src/lib/structured-mail/data/airport-timezones.json b/src/lib/structured-mail/data/airport-timezones.json
new file mode 100644
index 00000000..4119332b
--- /dev/null
+++ b/src/lib/structured-mail/data/airport-timezones.json
@@ -0,0 +1 @@
+{"source":"mwgg/Airports@a89e4e0dda9d (MIT)","zones":["Africa/Abidjan","Africa/Accra","Africa/Addis_Ababa","Africa/Algiers","Africa/Asmara","Africa/Bamako","Africa/Bangui","Africa/Banjul","Africa/Bissau","Africa/Blantyre","Africa/Brazzaville","Africa/Bujumbura","Africa/Cairo","Africa/Casablanca","Africa/Conakry","Africa/Dakar","Africa/Dar_es_Salaam","Africa/Djibouti","Africa/Douala","Africa/El_Aaiun","Africa/Freetown","Africa/Gaborone","Africa/Harare","Africa/Johannesburg","Africa/Juba","Africa/Kampala","Africa/Khartoum","Africa/Kigali","Africa/Kinshasa","Africa/Lagos","Africa/Libreville","Africa/Lome","Africa/Luanda","Africa/Lubumbashi","Africa/Lusaka","Africa/Malabo","Africa/Maputo","Africa/Maseru","Africa/Mbabane","Africa/Mogadishu","Africa/Monrovia","Africa/Nairobi","Africa/Ndjamena","Africa/Niamey","Africa/Nouakchott","Africa/Ouagadougou","Africa/Porto-Novo","Africa/Sao_Tome","Africa/Tripoli","Africa/Tunis","Africa/Windhoek","America/Adak","America/Anchorage","America/Anguilla","America/Antigua","America/Araguaina","America/Argentina/Buenos_Aires","America/Argentina/Catamarca","America/Argentina/Cordoba","America/Argentina/Jujuy","America/Argentina/La_Rioja","America/Argentina/Mendoza","America/Argentina/Rio_Gallegos","America/Argentina/Salta","America/Argentina/San_Juan","America/Argentina/San_Luis","America/Argentina/Tucuman","America/Argentina/Ushuaia","America/Aruba","America/Asuncion","America/Atikokan","America/Bahia","America/Bahia_Banderas","America/Barbados","America/Belem","America/Belize","America/Blanc-Sablon","America/Boa_Vista","America/Bogota","America/Boise","America/Cambridge_Bay","America/Campo_Grande","America/Cancun","America/Caracas","America/Cayenne","America/Cayman","America/Chicago","America/Chihuahua","America/Ciudad_Juarez","America/Costa_Rica","America/Cuiaba","America/Curacao","America/Dawson","America/Dawson_Creek","America/Denver","America/Detroit","America/Dominica","America/Edmonton","America/Eirunepe","America/El_Salvador","America/Fort_Nelson","America/Fortaleza","America/Glace_Bay","America/Goose_Bay","America/Grand_Turk","America/Grenada","America/Guadeloupe","America/Guatemala","America/Guayaquil","America/Guyana","America/Halifax","America/Havana","America/Hermosillo","America/Indiana/Indianapolis","America/Indiana/Vincennes","America/Inuvik","America/Iqaluit","America/Jamaica","America/Juneau","America/Kentucky/Louisville","America/Kralendijk","America/La_Paz","America/Lima","America/Los_Angeles","America/Lower_Princes","America/Maceio","America/Managua","America/Manaus","America/Martinique","America/Matamoros","America/Mazatlan","America/Menominee","America/Merida","America/Metlakatla","America/Mexico_City","America/Miquelon","America/Moncton","America/Monterrey","America/Montevideo","America/Montserrat","America/Nassau","America/New_York","America/Nome","America/Noronha","America/Nuuk","America/Panama","America/Paramaribo","America/Phoenix","America/Port-au-Prince","America/Port_of_Spain","America/Porto_Velho","America/Puerto_Rico","America/Punta_Arenas","America/Rankin_Inlet","America/Recife","America/Regina","America/Resolute","America/Rio_Branco","America/Santarem","America/Santiago","America/Santo_Domingo","America/Sao_Paulo","America/Scoresbysund","America/Sitka","America/St_Barthelemy","America/St_Johns","America/St_Kitts","America/St_Lucia","America/St_Thomas","America/St_Vincent","America/Swift_Current","America/Tegucigalpa","America/Thule","America/Tijuana","America/Toronto","America/Tortola","America/Vancouver","America/Whitehorse","America/Winnipeg","America/Yakutat","Arctic/Longyearbyen","Asia/Aden","Asia/Almaty","Asia/Amman","Asia/Anadyr","Asia/Aqtau","Asia/Aqtobe","Asia/Ashgabat","Asia/Atyrau","Asia/Baghdad","Asia/Bahrain","Asia/Baku","Asia/Bangkok","Asia/Barnaul","Asia/Beirut","Asia/Bishkek","Asia/Brunei","Asia/Chita","Asia/Choibalsan","Asia/Colombo","Asia/Damascus","Asia/Dhaka","Asia/Dili","Asia/Dubai","Asia/Dushanbe","Asia/Famagusta","Asia/Ho_Chi_Minh","Asia/Hong_Kong","Asia/Hovd","Asia/Irkutsk","Asia/Jakarta","Asia/Jayapura","Asia/Jerusalem","Asia/Kabul","Asia/Kamchatka","Asia/Karachi","Asia/Kathmandu","Asia/Khandyga","Asia/Kolkata","Asia/Krasnoyarsk","Asia/Kuala_Lumpur","Asia/Kuching","Asia/Kuwait","Asia/Macau","Asia/Magadan","Asia/Makassar","Asia/Manila","Asia/Muscat","Asia/Nicosia","Asia/Novokuznetsk","Asia/Novosibirsk","Asia/Omsk","Asia/Oral","Asia/Phnom_Penh","Asia/Pontianak","Asia/Pyongyang","Asia/Qatar","Asia/Qostanay","Asia/Qyzylorda","Asia/Riyadh","Asia/Sakhalin","Asia/Samarkand","Asia/Seoul","Asia/Shanghai","Asia/Singapore","Asia/Srednekolymsk","Asia/Taipei","Asia/Tashkent","Asia/Tbilisi","Asia/Tehran","Asia/Thimphu","Asia/Tokyo","Asia/Tomsk","Asia/Ulaanbaatar","Asia/Ust-Nera","Asia/Vientiane","Asia/Vladivostok","Asia/Yakutsk","Asia/Yangon","Asia/Yekaterinburg","Asia/Yerevan","Atlantic/Azores","Atlantic/Bermuda","Atlantic/Canary","Atlantic/Cape_Verde","Atlantic/Faroe","Atlantic/Madeira","Atlantic/Reykjavik","Atlantic/St_Helena","Atlantic/Stanley","Australia/Adelaide","Australia/Brisbane","Australia/Broken_Hill","Australia/Darwin","Australia/Eucla","Australia/Hobart","Australia/Lindeman","Australia/Lord_Howe","Australia/Melbourne","Australia/Perth","Australia/Sydney","Europe/Amsterdam","Europe/Astrakhan","Europe/Athens","Europe/Belgrade","Europe/Berlin","Europe/Bratislava","Europe/Brussels","Europe/Bucharest","Europe/Budapest","Europe/Chisinau","Europe/Copenhagen","Europe/Dublin","Europe/Gibraltar","Europe/Guernsey","Europe/Helsinki","Europe/Isle_of_Man","Europe/Istanbul","Europe/Jersey","Europe/Kaliningrad","Europe/Kiev","Europe/Kirov","Europe/Kyiv","Europe/Lisbon","Europe/Ljubljana","Europe/London","Europe/Luxembourg","Europe/Madrid","Europe/Malta","Europe/Mariehamn","Europe/Minsk","Europe/Moscow","Europe/Oslo","Europe/Paris","Europe/Podgorica","Europe/Prague","Europe/Riga","Europe/Rome","Europe/Samara","Europe/Sarajevo","Europe/Saratov","Europe/Simferopol","Europe/Skopje","Europe/Sofia","Europe/Stockholm","Europe/Tallinn","Europe/Tirane","Europe/Ulyanovsk","Europe/Vienna","Europe/Vilnius","Europe/Volgograd","Europe/Warsaw","Europe/Zagreb","Europe/Zurich","Indian/Antananarivo","Indian/Chagos","Indian/Christmas","Indian/Cocos","Indian/Comoro","Indian/Mahe","Indian/Maldives","Indian/Mauritius","Indian/Mayotte","Indian/Reunion","Pacific/Apia","Pacific/Auckland","Pacific/Bougainville","Pacific/Chatham","Pacific/Chuuk","Pacific/Easter","Pacific/Efate","Pacific/Fiji","Pacific/Funafuti","Pacific/Galapagos","Pacific/Gambier","Pacific/Guadalcanal","Pacific/Guam","Pacific/Honolulu","Pacific/Kanton","Pacific/Kiritimati","Pacific/Kosrae","Pacific/Kwajalein","Pacific/Majuro","Pacific/Marquesas","Pacific/Midway","Pacific/Nauru","Pacific/Niue","Pacific/Norfolk","Pacific/Noumea","Pacific/Pago_Pago","Pacific/Palau","Pacific/Pohnpei","Pacific/Port_Moresby","Pacific/Rarotonga","Pacific/Saipan","Pacific/Tahiti","Pacific/Tarawa","Pacific/Tongatapu","Pacific/Wake","Pacific/Wallis"],"airports":{"AAA":375,"AAB":271,"AAC":12,"AAD":39,"AAE":3,"AAF":141,"AAG":161,"AAH":285,"AAI":55,"AAJ":146,"AAK":376,"AAL":291,"AAM":23,"AAN":203,"AAO":83,"AAP":225,"AAQ":311,"AAR":291,"AAT":243,"AAU":344,"AAV":226,"AAW":215,"AAX":161,"AAY":181,"AAZ":107,"ABA":219,"ABB":29,"ABC":307,"ABD":249,"ABE":141,"ABF":376,"ABG":271,"ABH":271,"ABI":86,"ABJ":0,"ABK":2,"ABL":52,"ABM":271,"ABN":84,"ABO":0,"ABQ":94,"ABR":86,"ABS":12,"ABT":239,"ABU":225,"ABV":29,"ABX":278,"ABY":141,"ABZ":305,"ACA":134,"ACB":95,"ACC":1,"ACD":78,"ACE":263,"ACF":243,"ACH":328,"ACI":294,"ACJ":199,"ACK":141,"ACN":129,"ACP":249,"ACR":78,"ACS":219,"ACT":86,"ACV":123,"ACX":243,"ACY":141,"ACZ":249,"ADA":297,"ADB":297,"ADC":372,"ADD":2,"ADE":181,"ADF":297,"ADG":95,"ADH":257,"ADI":50,"ADJ":183,"ADK":51,"ADL":270,"ADM":86,"ADN":78,"ADO":270,"ADP":199,"ADQ":52,"ADR":141,"ADS":86,"ADT":86,"ADU":249,"ADW":141,"ADX":305,"ADY":23,"ADZ":78,"AEA":376,"AEB":243,"AEG":210,"AEH":42,"AEL":86,"AEM":256,"AEO":44,"AEP":56,"AER":311,"AES":312,"AET":52,"AEU":249,"AEX":86,"AEY":267,"AFA":61,"AFD":23,"AFE":163,"AFF":94,"AFI":78,"AFL":90,"AFN":141,"AFO":94,"AFR":372,"AFS":241,"AFT":355,"AFW":86,"AFY":297,"AFZ":249,"AGA":13,"AGB":285,"AGC":141,"AGE":285,"AGF":313,"AGH":324,"AGI":146,"AGJ":251,"AGL":372,"AGN":118,"AGO":86,"AGP":307,"AGQ":283,"AGR":218,"AGS":141,"AGT":69,"AGU":134,"AGV":83,"AGX":218,"AGZ":23,"AHB":239,"AHC":123,"AHE":375,"AHF":86,"AHH":86,"AHI":211,"AHJ":243,"AHL":109,"AHM":123,"AHN":141,"AHO":317,"AHS":171,"AHU":13,"AHZ":313,"AIA":94,"AID":113,"AIE":372,"AIF":161,"AIG":6,"AII":17,"AIK":141,"AIN":52,"AIO":86,"AIP":218,"AIR":90,"AIS":376,"AIT":373,"AIU":373,"AIV":86,"AIZ":86,"AJA":313,"AJF":239,"AJI":297,"AJJ":44,"AJK":249,"AJL":218,"AJN":338,"AJR":324,"AJU":125,"AJY":43,"AKA":243,"AKB":51,"AKC":141,"AKD":218,"AKE":30,"AKF":48,"AKH":239,"AKI":52,"AKJ":251,"AKK":52,"AKL":345,"AKN":52,"AKO":94,"AKP":52,"AKQ":210,"AKR":29,"AKS":355,"AKT":228,"AKU":243,"AKV":116,"AKW":249,"AKX":186,"AKY":258,"ALA":182,"ALB":141,"ALC":307,"ALD":122,"ALF":312,"ALG":3,"ALH":279,"ALI":86,"ALJ":23,"ALL":317,"ALM":94,"ALN":86,"ALO":86,"ALP":200,"ALQ":161,"ALR":345,"ALS":94,"ALT":158,"ALU":39,"ALW":123,"ALX":86,"ALY":12,"AMA":86,"AMB":334,"AMC":42,"AMD":218,"AMH":2,"AMI":225,"AMJ":161,"AMK":94,"AMM":183,"AMN":95,"AMO":42,"AMP":334,"AMQ":211,"AMS":281,"AMT":270,"AMU":372,"AMV":311,"AMW":86,"AMX":273,"AMZ":345,"ANB":86,"ANC":52,"AND":141,"ANE":313,"ANF":159,"ANG":313,"ANI":52,"ANJ":10,"ANK":297,"ANM":334,"ANN":133,"ANO":36,"ANP":141,"ANQ":113,"ANR":287,"ANS":122,"ANU":54,"ANV":52,"ANW":86,"ANX":312,"ANY":86,"AOC":285,"AOE":297,"AOG":243,"AOH":141,"AOI":317,"AOJ":251,"AOK":283,"AOL":58,"AOM":227,"AOO":141,"AOP":122,"AOR":220,"AOT":317,"AOU":255,"AOY":249,"APA":94,"APB":121,"APC":123,"APE":122,"APF":141,"APG":141,"APH":141,"API":78,"APJ":243,"APK":375,"APL":36,"APN":95,"APO":78,"APQ":125,"APS":161,"APT":86,"APU":161,"APV":123,"APW":344,"APX":161,"APY":101,"APZ":63,"AQA":161,"AQB":107,"AQG":243,"AQI":239,"AQJ":183,"AQM":150,"AQP":122,"ARA":86,"ARB":95,"ARC":52,"ARD":225,"ARE":151,"ARG":86,"ARH":311,"ARI":122,"ARJ":211,"ARK":16,"ARL":45,"ARM":280,"ARN":324,"ARR":57,"ARS":90,"ART":141,"ARU":161,"ARV":86,"ARW":288,"ARY":278,"ARZ":32,"ASA":4,"ASB":187,"ASC":121,"ASD":140,"ASE":94,"ASF":282,"ASG":345,"ASH":141,"ASI":268,"ASJ":251,"ASK":0,"ASL":86,"ASM":4,"ASN":86,"ASO":2,"ASP":273,"ASQ":123,"ASR":297,"AST":123,"ASU":69,"ASV":41,"ASW":12,"ASX":86,"ASY":86,"ATA":122,"ATB":26,"ATC":140,"ATD":355,"ATE":86,"ATF":108,"ATH":283,"ATI":138,"ATJ":334,"ATK":52,"ATL":141,"ATM":158,"ATO":141,"ATP":372,"ATQ":218,"ATR":44,"ATS":94,"ATU":51,"ATV":42,"ATW":86,"ATY":86,"ATZ":12,"AUA":68,"AUC":78,"AUD":271,"AUF":313,"AUG":141,"AUH":203,"AUJ":372,"AUK":142,"AUM":86,"AUN":123,"AUO":86,"AUQ":363,"AUR":313,"AUS":86,"AUT":202,"AUU":271,"AUW":86,"AUX":55,"AUY":350,"AUZ":86,"AVA":243,"AVB":317,"AVG":273,"AVI":111,"AVK":253,"AVL":141,"AVN":313,"AVO":141,"AVP":141,"AVR":303,"AVU":355,"AVV":278,"AVW":147,"AVX":123,"AWA":2,"AWB":372,"AWD":350,"AWK":378,"AWM":86,"AWN":270,"AWP":273,"AWZ":249,"AXA":53,"AXC":271,"AXD":283,"AXE":161,"AXF":243,"AXG":86,"AXJ":251,"AXK":181,"AXL":273,"AXM":78,"AXN":86,"AXP":140,"AXR":375,"AXS":86,"AXT":251,"AXU":2,"AXV":141,"AXX":94,"AYG":78,"AYJ":218,"AYK":237,"AYL":273,"AYN":243,"AYO":69,"AYP":122,"AYQ":273,"AYR":271,"AYS":141,"AYT":297,"AYU":372,"AYX":122,"AZA":147,"AZD":249,"AZH":218,"AZI":203,"AZL":90,"AZN":247,"AZO":95,"AZP":134,"AZR":3,"AZS":160,"AZZ":32,"BAA":372,"BAB":123,"BAD":86,"BAE":313,"BAF":141,"BAG":226,"BAH":190,"BAI":89,"BAL":297,"BAM":123,"BAN":33,"BAO":192,"BAQ":78,"BAR":243,"BAS":355,"BAT":161,"BAU":161,"BAV":243,"BAX":193,"BAY":288,"BAZ":127,"BBA":159,"BBB":86,"BBC":86,"BBD":86,"BBE":279,"BBG":376,"BBH":285,"BBI":218,"BBJ":285,"BBK":21,"BBL":271,"BBM":233,"BBN":221,"BBO":39,"BBP":305,"BBQ":54,"BBR":106,"BBS":305,"BBT":6,"BBU":288,"BBV":0,"BBW":86,"BBX":141,"BBY":6,"BBZ":34,"BCA":111,"BCB":141,"BCD":226,"BCE":94,"BCF":6,"BCG":109,"BCH":202,"BCI":271,"BCL":89,"BCM":288,"BCN":307,"BCO":2,"BCR":127,"BCS":86,"BCT":141,"BCX":259,"BDA":262,"BDB":271,"BDC":101,"BDD":271,"BDE":86,"BDF":86,"BDG":94,"BDH":249,"BDI":339,"BDJ":225,"BDK":0,"BDL":141,"BDM":297,"BDN":215,"BDO":210,"BDP":216,"BDQ":218,"BDR":141,"BDS":317,"BDT":28,"BDU":312,"BDV":33,"BDW":279,"BDX":94,"BDY":123,"BDZ":372,"BEB":305,"BEC":86,"BED":141,"BEF":126,"BEG":284,"BEH":95,"BEI":2,"BEJ":225,"BEK":218,"BEL":74,"BEM":13,"BEN":48,"BEO":280,"BEP":218,"BEQ":305,"BER":285,"BES":313,"BET":52,"BEU":271,"BEV":212,"BEW":36,"BEX":305,"BEY":194,"BEZ":376,"BFA":69,"BFD":141,"BFE":285,"BFF":94,"BFG":94,"BFH":161,"BFI":123,"BFJ":243,"BFK":94,"BFL":123,"BFM":86,"BFN":23,"BFO":22,"BFP":141,"BFR":113,"BFS":305,"BFT":141,"BFU":243,"BFV":192,"BFW":3,"BFX":18,"BGA":78,"BGB":30,"BGC":303,"BGD":86,"BGE":141,"BGF":6,"BGG":297,"BGH":44,"BGI":73,"BGJ":267,"BGL":216,"BGM":141,"BGN":245,"BGO":312,"BGQ":52,"BGR":141,"BGS":86,"BGT":147,"BGU":6,"BGV":161,"BGW":189,"BGX":161,"BGY":317,"BGZ":303,"BHA":108,"BHB":141,"BHD":305,"BHE":345,"BHF":78,"BHG":171,"BHH":239,"BHI":56,"BHJ":218,"BHK":241,"BHM":86,"BHN":181,"BHO":218,"BHP":216,"BHQ":272,"BHR":216,"BHS":280,"BHU":218,"BHV":215,"BHW":215,"BHX":305,"BHY":243,"BIA":313,"BIB":39,"BID":141,"BIE":86,"BIF":94,"BIG":52,"BIH":123,"BIK":211,"BIL":94,"BIM":140,"BIN":213,"BIO":307,"BIP":271,"BIQ":313,"BIR":216,"BIS":86,"BIT":216,"BIU":267,"BIV":6,"BIW":279,"BIX":86,"BIY":23,"BJA":3,"BJB":249,"BJC":94,"BJD":267,"BJF":312,"BJH":216,"BJI":86,"BJJ":141,"BJK":211,"BJL":7,"BJM":11,"BJO":63,"BJP":161,"BJR":2,"BJU":216,"BJV":297,"BJW":225,"BJX":134,"BJY":284,"BJZ":307,"BKA":311,"BKB":218,"BKC":52,"BKD":86,"BKE":123,"BKG":86,"BKH":357,"BKI":221,"BKJ":14,"BKK":192,"BKL":141,"BKM":221,"BKN":187,"BKO":5,"BKP":271,"BKQ":271,"BKR":42,"BKS":210,"BKT":141,"BKU":334,"BKW":141,"BKX":86,"BKY":33,"BKZ":16,"BLA":83,"BLB":145,"BLC":18,"BLD":123,"BLE":324,"BLF":141,"BLG":221,"BLH":123,"BLI":123,"BLJ":3,"BLK":305,"BLL":291,"BLM":141,"BLN":278,"BLO":267,"BLP":122,"BLQ":317,"BLR":218,"BLS":271,"BLT":271,"BLU":123,"BLV":86,"BLX":317,"BLY":292,"BLZ":9,"BMA":324,"BMB":28,"BMC":94,"BMD":334,"BME":279,"BMF":6,"BMG":113,"BMI":86,"BMJ":109,"BMK":285,"BML":141,"BMM":30,"BMN":189,"BMO":258,"BMP":271,"BMR":285,"BMS":71,"BMT":86,"BMU":225,"BMV":206,"BMW":3,"BMX":52,"BMY":368,"BNA":86,"BNB":28,"BNC":33,"BND":249,"BNE":271,"BNG":123,"BNI":29,"BNJ":285,"BNK":280,"BNL":141,"BNN":312,"BNO":123,"BNP":215,"BNR":45,"BNS":83,"BNU":161,"BNW":86,"BNX":319,"BNY":355,"BOA":28,"BOB":375,"BOC":145,"BOD":313,"BOE":10,"BOG":78,"BOH":305,"BOI":79,"BOJ":323,"BOL":305,"BOM":218,"BON":120,"BOO":312,"BOP":6,"BOR":313,"BOS":141,"BOU":313,"BOW":141,"BOX":273,"BOY":45,"BOZ":6,"BPC":18,"BPF":355,"BPG":90,"BPH":226,"BPI":94,"BPL":243,"BPM":218,"BPN":225,"BPR":226,"BPS":71,"BPT":86,"BPX":243,"BPY":334,"BQA":226,"BQB":279,"BQD":267,"BQE":8,"BQG":256,"BQH":305,"BQK":141,"BQL":271,"BQN":151,"BQO":0,"BQQ":71,"BQS":257,"BQT":310,"BQU":169,"BQW":279,"BRA":71,"BRB":101,"BRC":63,"BRD":86,"BRE":285,"BRI":317,"BRK":280,"BRL":86,"BRM":83,"BRN":333,"BRO":86,"BRQ":315,"BRR":305,"BRS":305,"BRT":273,"BRU":287,"BRV":285,"BRW":52,"BRX":160,"BRY":141,"BSA":39,"BSB":161,"BSC":78,"BSD":243,"BSE":221,"BSF":357,"BSG":35,"BSJ":278,"BSK":3,"BSL":313,"BSM":249,"BSN":6,"BSO":226,"BSQ":112,"BSR":189,"BSS":101,"BST":213,"BSU":28,"BSW":52,"BSX":258,"BSY":39,"BSZ":195,"BTA":18,"BTB":10,"BTC":199,"BTD":273,"BTE":20,"BTF":94,"BTG":6,"BTH":210,"BTI":52,"BTJ":210,"BTK":209,"BTL":95,"BTM":94,"BTN":141,"BTO":146,"BTP":141,"BTQ":27,"BTR":86,"BTS":286,"BTT":52,"BTU":221,"BTV":141,"BTW":225,"BTX":271,"BTY":123,"BTZ":192,"BUA":346,"BUB":86,"BUC":271,"BUD":289,"BUF":141,"BUG":32,"BUI":211,"BUJ":3,"BUL":372,"BUM":86,"BUN":78,"BUO":39,"BUP":218,"BUQ":22,"BUR":123,"BUS":248,"BUT":250,"BUW":225,"BUX":33,"BUY":279,"BUZ":249,"BVA":313,"BVB":77,"BVC":264,"BVE":313,"BVG":312,"BVH":150,"BVI":271,"BVJ":259,"BVK":121,"BVL":121,"BVM":71,"BVO":86,"BVR":264,"BVS":74,"BVU":52,"BVV":224,"BVX":86,"BVY":141,"BVZ":279,"BWA":216,"BWB":279,"BWC":123,"BWD":86,"BWE":285,"BWF":305,"BWG":86,"BWH":220,"BWI":141,"BWK":332,"BWL":86,"BWM":94,"BWN":196,"BWO":320,"BWQ":280,"BWT":275,"BWU":280,"BWW":111,"BXA":86,"BXB":211,"BXD":211,"BXE":15,"BXF":279,"BXG":278,"BXH":182,"BXI":0,"BXJ":182,"BXK":147,"BXN":297,"BXO":333,"BXP":331,"BXR":249,"BXS":123,"BXT":225,"BXU":226,"BXV":267,"BXY":238,"BYC":121,"BYD":181,"BYF":313,"BYG":94,"BYH":86,"BYI":79,"BYJ":303,"BYK":0,"BYM":111,"BYN":253,"BYO":81,"BYP":279,"BYQ":225,"BYR":291,"BYS":123,"BYT":292,"BYU":285,"BYW":123,"BZA":126,"BZC":161,"BZD":280,"BZE":75,"BZG":331,"BZI":297,"BZK":311,"BZL":201,"BZN":94,"BZO":317,"BZP":271,"BZR":313,"BZU":33,"BZV":10,"BZX":243,"BZY":290,"BZZ":305,"CAA":171,"CAB":32,"CAC":161,"CAD":95,"CAE":141,"CAF":127,"CAG":317,"CAH":206,"CAI":12,"CAJ":83,"CAK":141,"CAL":305,"CAM":121,"CAN":243,"CAO":94,"CAP":148,"CAQ":78,"CAR":141,"CAS":13,"CAT":303,"CAU":154,"CAV":32,"CAW":161,"CAX":305,"CAY":84,"CAZ":280,"CBB":121,"CBD":218,"CBE":141,"CBF":86,"CBG":305,"CBH":3,"CBI":275,"CBJ":160,"CBK":86,"CBL":83,"CBM":86,"CBN":210,"CBO":226,"CBP":303,"CBQ":29,"CBR":280,"CBS":83,"CBT":32,"CBU":285,"CBV":107,"CBW":161,"CBX":280,"CBY":271,"CCB":123,"CCC":111,"CCE":12,"CCF":313,"CCG":86,"CCH":159,"CCI":161,"CCJ":218,"CCK":337,"CCL":271,"CCM":161,"CCN":213,"CCO":78,"CCP":159,"CCR":123,"CCS":83,"CCT":63,"CCU":218,"CCV":350,"CCW":270,"CCX":90,"CCY":86,"CCZ":140,"CDA":273,"CDB":142,"CDC":94,"CDD":171,"CDE":243,"CDF":317,"CDG":313,"CDH":86,"CDI":161,"CDJ":55,"CDK":141,"CDL":52,"CDN":141,"CDO":23,"CDP":218,"CDQ":271,"CDR":94,"CDS":86,"CDT":307,"CDU":280,"CDV":52,"CDW":141,"CDY":226,"CEA":86,"CEB":226,"CEC":123,"CED":270,"CEE":311,"CEF":141,"CEG":305,"CEH":9,"CEI":192,"CEK":259,"CEL":161,"CEM":52,"CEN":112,"CEO":32,"CEP":121,"CEQ":313,"CER":313,"CES":280,"CET":313,"CEU":141,"CEV":113,"CEW":86,"CEX":52,"CEY":86,"CEZ":94,"CFB":161,"CFC":161,"CFD":86,"CFE":313,"CFF":32,"CFG":111,"CFH":270,"CFI":273,"CFK":3,"CFN":292,"CFO":90,"CFR":313,"CFS":280,"CFT":147,"CFU":283,"CFV":86,"CGB":90,"CGC":372,"CGD":243,"CGE":141,"CGF":141,"CGH":161,"CGI":86,"CGJ":34,"CGK":210,"CGM":226,"CGN":285,"CGO":243,"CGP":201,"CGQ":243,"CGR":81,"CGS":141,"CGV":274,"CGY":226,"CGZ":147,"CHA":141,"CHB":215,"CHC":345,"CHF":242,"CHG":243,"CHH":122,"CHJ":22,"CHK":86,"CHL":79,"CHM":122,"CHN":242,"CHO":141,"CHQ":283,"CHR":313,"CHS":141,"CHT":347,"CHU":52,"CHV":303,"CHX":145,"CHY":355,"CIA":317,"CIC":123,"CID":86,"CIE":279,"CIF":243,"CIG":94,"CIH":243,"CII":297,"CIJ":121,"CIK":52,"CIM":78,"CIN":86,"CIO":69,"CIP":34,"CIQ":107,"CIR":86,"CIS":358,"CIT":182,"CIU":95,"CIW":169,"CIX":122,"CIY":317,"CIZ":127,"CJA":122,"CJB":218,"CJC":159,"CJF":279,"CJJ":242,"CJL":215,"CJM":192,"CJS":88,"CJT":134,"CJU":242,"CKA":86,"CKB":141,"CKC":302,"CKG":243,"CKH":245,"CKI":273,"CKK":86,"CKL":311,"CKM":86,"CKN":86,"CKO":161,"CKS":74,"CKT":249,"CKV":86,"CKW":279,"CKY":14,"CKZ":297,"CLA":201,"CLB":292,"CLD":123,"CLE":141,"CLH":280,"CLI":86,"CLJ":288,"CLK":86,"CLL":86,"CLM":123,"CLN":101,"CLO":78,"CLP":52,"CLQ":134,"CLR":123,"CLS":123,"CLT":141,"CLU":113,"CLV":161,"CLW":141,"CLX":58,"CLY":313,"CLZ":83,"CMA":271,"CMB":199,"CMC":101,"CMD":280,"CME":132,"CMF":313,"CMG":81,"CMH":141,"CMI":86,"CMJ":246,"CMK":9,"CML":271,"CMM":107,"CMN":13,"CMO":39,"CMP":74,"CMQ":271,"CMR":313,"CMS":39,"CMU":372,"CMV":345,"CMW":111,"CMX":95,"CMY":86,"CNA":112,"CNB":280,"CNC":271,"CND":288,"CNF":161,"CNG":313,"CNH":141,"CNI":243,"CNJ":271,"CNK":86,"CNL":291,"CNM":94,"CNN":218,"CNO":123,"CNP":162,"CNQ":58,"CNR":159,"CNS":271,"CNU":86,"CNV":71,"CNW":86,"CNX":192,"CNY":94,"COC":58,"COD":94,"COE":123,"COF":141,"COG":78,"COH":218,"COI":141,"COJ":280,"COK":218,"COL":305,"COM":86,"CON":141,"COO":46,"COQ":198,"COR":58,"COS":94,"COT":86,"COU":86,"COV":297,"COW":159,"COX":140,"COY":279,"COZ":160,"CPA":40,"CPB":78,"CPC":63,"CPD":270,"CPE":132,"CPF":210,"CPH":291,"CPL":78,"CPM":123,"CPO":159,"CPP":159,"CPQ":161,"CPR":94,"CPS":86,"CPT":23,"CPU":101,"CPV":101,"CPX":151,"CQA":90,"CQD":249,"CQF":313,"CQM":307,"CQS":150,"CQW":243,"CRA":288,"CRB":280,"CRC":78,"CRD":57,"CRE":141,"CRF":6,"CRG":141,"CRI":140,"CRK":226,"CRL":287,"CRM":226,"CRO":123,"CRP":86,"CRQ":71,"CRR":58,"CRS":86,"CRT":86,"CRU":105,"CRV":317,"CRW":141,"CRX":86,"CRZ":187,"CSA":305,"CSB":288,"CSC":89,"CSF":313,"CSG":141,"CSH":311,"CSI":280,"CSK":15,"CSL":130,"CSM":86,"CSN":123,"CSO":285,"CSQ":86,"CSS":81,"CSU":161,"CSV":86,"CSX":243,"CSY":311,"CSZ":56,"CTA":317,"CTB":94,"CTC":57,"CTD":145,"CTF":107,"CTG":78,"CTH":141,"CTI":32,"CTL":271,"CTM":82,"CTN":271,"CTP":101,"CTQ":161,"CTS":251,"CTT":313,"CTU":243,"CTY":141,"CTZ":141,"CUA":130,"CUB":141,"CUC":78,"CUD":271,"CUE":108,"CUF":317,"CUG":280,"CUH":86,"CUL":130,"CUM":83,"CUN":82,"CUO":78,"CUP":83,"CUQ":271,"CUR":91,"CUT":63,"CUU":87,"CUV":83,"CUY":279,"CUZ":122,"CVC":270,"CVE":78,"CVF":313,"CVG":141,"CVH":63,"CVJ":134,"CVM":137,"CVN":94,"CVO":123,"CVQ":279,"CVS":94,"CVT":305,"CVU":261,"CWA":86,"CWB":161,"CWC":302,"CWE":12,"CWF":86,"CWI":86,"CWK":218,"CWL":305,"CWR":270,"CWT":280,"CWW":278,"CWX":147,"CXA":83,"CXB":201,"CXF":52,"CXH":176,"CXI":359,"CXJ":161,"CXL":123,"CXM":32,"CXN":39,"CXO":86,"CXP":210,"CXQ":279,"CXR":206,"CXT":271,"CXY":140,"CYA":148,"CYB":85,"CYF":142,"CYG":278,"CYI":246,"CYL":171,"CYO":111,"CYP":226,"CYR":138,"CYS":94,"CYT":52,"CYU":226,"CYW":134,"CYX":245,"CYZ":226,"CZA":132,"CZE":83,"CZF":142,"CZL":3,"CZM":82,"CZS":157,"CZT":86,"CZU":78,"CZW":331,"CZX":243,"CZY":271,"DAA":141,"DAB":141,"DAC":201,"DAD":206,"DAG":123,"DAK":12,"DAL":86,"DAM":200,"DAN":141,"DAO":372,"DAP":216,"DAR":16,"DAS":97,"DAT":243,"DAU":372,"DAV":145,"DAX":243,"DAY":141,"DAZ":204,"DBA":215,"DBB":12,"DBD":218,"DBM":2,"DBN":141,"DBO":280,"DBP":372,"DBQ":86,"DBR":218,"DBT":2,"DBV":332,"DBY":271,"DCA":141,"DCF":96,"DCI":317,"DCM":313,"DCN":279,"DCT":140,"DCU":86,"DCY":243,"DDC":86,"DDD":340,"DDG":243,"DDN":271,"DDR":243,"DDU":215,"DEA":215,"DEB":289,"DEC":86,"DED":218,"DEE":224,"DEF":249,"DEH":86,"DEI":339,"DEL":218,"DEM":2,"DEN":94,"DEP":218,"DER":372,"DES":339,"DET":95,"DEZ":200,"DFI":141,"DFP":271,"DFW":86,"DGA":75,"DGD":279,"DGE":280,"DGF":176,"DGH":218,"DGL":112,"DGN":141,"DGO":137,"DGP":316,"DGR":345,"DGT":226,"DGU":45,"DGW":94,"DGX":305,"DHA":239,"DHD":271,"DHF":203,"DHI":216,"DHM":218,"DHN":86,"DHR":281,"DHT":86,"DIA":236,"DIB":218,"DIE":334,"DIG":243,"DIJ":313,"DIK":94,"DIL":202,"DIM":0,"DIN":192,"DIP":45,"DIQ":161,"DIR":2,"DIS":10,"DIU":218,"DIY":297,"DJA":46,"DJB":210,"DJE":49,"DJG":3,"DJJ":211,"DJM":10,"DJO":0,"DJR":271,"DJT":141,"DJU":267,"DKI":271,"DKK":141,"DKR":15,"DKS":219,"DKV":273,"DLA":18,"DLC":243,"DLD":312,"DLE":313,"DLF":86,"DLG":52,"DLH":86,"DLI":206,"DLK":270,"DLL":141,"DLM":297,"DLN":94,"DLS":123,"DLU":243,"DLV":273,"DLY":350,"DLZ":253,"DMA":147,"DMB":182,"DMD":271,"DME":311,"DMK":192,"DMM":239,"DMN":94,"DMO":86,"DMT":90,"DMU":218,"DNA":251,"DNB":271,"DND":305,"DNH":243,"DNK":302,"DNL":141,"DNN":141,"DNO":55,"DNP":216,"DNQ":280,"DNR":313,"DNS":86,"DNV":86,"DNX":26,"DNZ":297,"DOB":211,"DOD":16,"DOE":146,"DOG":26,"DOH":236,"DOK":302,"DOL":313,"DOM":96,"DON":107,"DOP":216,"DOR":45,"DOU":81,"DOV":141,"DOX":279,"DOY":243,"DPA":86,"DPE":313,"DPG":94,"DPL":226,"DPO":275,"DPS":225,"DQA":243,"DQH":52,"DQM":227,"DRA":123,"DRB":279,"DRD":271,"DRE":95,"DRG":142,"DRI":86,"DRJ":146,"DRK":89,"DRN":271,"DRO":94,"DRP":226,"DRR":271,"DRS":285,"DRT":86,"DRW":273,"DRY":279,"DSA":305,"DSC":18,"DSD":106,"DSE":2,"DSI":86,"DSK":215,"DSM":86,"DSN":243,"DSO":235,"DSS":15,"DSV":141,"DTA":94,"DTB":210,"DTD":225,"DTE":226,"DTI":161,"DTL":86,"DTM":285,"DTN":86,"DTR":123,"DTW":95,"DUA":86,"DUB":292,"DUC":86,"DUD":345,"DUE":32,"DUF":141,"DUG":147,"DUJ":141,"DUK":23,"DUM":210,"DUQ":176,"DUR":23,"DUS":285,"DUT":142,"DVK":97,"DVL":86,"DVN":86,"DVO":226,"DVP":271,"DVR":273,"DVT":147,"DWA":9,"DWB":334,"DWC":203,"DWD":239,"DWH":86,"DWN":86,"DXB":203,"DXD":271,"DXE":86,"DXN":218,"DXR":141,"DYA":271,"DYG":243,"DYL":141,"DYR":184,"DYS":86,"DYU":204,"DYW":273,"DZA":342,"DZH":243,"DZN":182,"DZO":138,"EAA":52,"EAB":181,"EAE":350,"EAM":239,"EAN":94,"EAR":86,"EAS":307,"EAT":123,"EAU":86,"EBA":317,"EBB":25,"EBD":26,"EBG":78,"EBH":3,"EBJ":291,"EBL":189,"EBM":49,"EBS":86,"EBU":313,"EBW":18,"ECG":141,"ECH":278,"ECN":205,"ECP":86,"ECS":94,"ECV":307,"EDB":26,"EDE":141,"EDF":52,"EDI":305,"EDK":86,"EDL":41,"EDM":313,"EDO":297,"EDR":271,"EDW":123,"EED":123,"EEK":142,"EEN":141,"EFD":86,"EFK":141,"EFL":283,"EFW":86,"EGC":313,"EGE":94,"EGH":12,"EGI":86,"EGM":355,"EGN":26,"EGO":311,"EGS":267,"EGV":86,"EGX":52,"EHL":63,"EHM":142,"EHU":243,"EIB":285,"EIE":219,"EIH":271,"EIK":311,"EIL":52,"EIN":281,"EIS":175,"EIY":183,"EJA":78,"EJH":239,"EJN":243,"EKA":123,"EKB":182,"EKI":113,"EKN":141,"EKO":123,"EKS":240,"EKT":324,"EKX":141,"ELA":86,"ELB":78,"ELC":273,"ELD":86,"ELF":26,"ELG":3,"ELH":140,"ELI":142,"ELK":86,"ELL":23,"ELM":141,"ELN":123,"ELO":58,"ELP":94,"ELQ":239,"ELS":23,"ELT":12,"ELU":3,"ELY":123,"ELZ":141,"EMA":305,"EMD":271,"EME":285,"EMG":23,"EMK":142,"EML":333,"EMM":94,"EMN":44,"EMP":86,"EMT":123,"EMX":57,"ENA":52,"ENB":279,"ENC":313,"END":86,"ENE":225,"ENF":295,"ENH":243,"ENI":226,"ENK":305,"ENL":86,"ENN":52,"ENO":69,"ENS":281,"ENT":362,"ENU":29,"ENV":94,"ENW":86,"ENY":243,"EOH":78,"EOI":305,"EOK":86,"EOR":83,"EOS":86,"EOZ":83,"EPA":56,"EPH":123,"EPL":313,"EPR":279,"EPS":160,"EPU":325,"EQS":57,"ERA":39,"ERB":270,"ERC":297,"ERD":302,"ERF":285,"ERG":209,"ERH":13,"ERI":141,"ERL":243,"ERM":161,"ERN":98,"ERR":141,"ERS":50,"ERV":86,"ERZ":297,"ESB":297,"ESC":95,"ESD":123,"ESE":173,"ESF":86,"ESG":69,"ESH":305,"ESI":161,"ESK":297,"ESL":311,"ESM":108,"ESN":141,"ESR":159,"ESS":285,"EST":86,"ESU":13,"ESW":123,"ETB":86,"ETD":270,"ETE":2,"ETH":183,"ETM":183,"ETN":86,"ETR":108,"ETS":86,"ETZ":313,"EUA":377,"EUC":274,"EUF":86,"EUG":123,"EUM":285,"EUN":19,"EUQ":226,"EUX":120,"EVD":273,"EVE":312,"EVG":324,"EVH":280,"EVM":86,"EVN":260,"EVV":86,"EVW":94,"EVX":313,"EWB":141,"EWI":211,"EWK":86,"EWN":141,"EWO":10,"EWR":141,"EXM":279,"EXT":305,"EYL":5,"EYP":78,"EYR":123,"EYS":41,"EYW":141,"EZE":56,"EZS":297,"EZV":259,"FAA":14,"FAB":305,"FAC":375,"FAE":265,"FAF":141,"FAG":267,"FAH":213,"FAI":52,"FAJ":151,"FAM":86,"FAN":312,"FAO":303,"FAQ":372,"FAR":86,"FAS":267,"FAT":123,"FAU":227,"FAV":375,"FAY":141,"FAZ":249,"FBA":127,"FBD":213,"FBE":161,"FBG":141,"FBK":52,"FBL":86,"FBM":33,"FBR":94,"FBY":86,"FCA":94,"FCB":23,"FCH":123,"FCM":86,"FCO":317,"FCS":94,"FCY":86,"FDA":78,"FDE":312,"FDF":128,"FDH":285,"FDK":141,"FDO":56,"FDR":86,"FDU":28,"FDY":141,"FEB":216,"FEC":71,"FEG":247,"FEJ":157,"FEK":0,"FEL":285,"FEN":143,"FEP":86,"FET":86,"FEZ":13,"FFA":141,"FFD":305,"FFL":86,"FFM":86,"FFO":141,"FFT":141,"FFU":159,"FGD":44,"FGI":344,"FGU":375,"FHB":141,"FHU":147,"FHZ":375,"FID":141,"FIE":305,"FIG":14,"FIH":28,"FIK":273,"FIL":94,"FIN":372,"FIZ":279,"FJR":203,"FKB":285,"FKI":33,"FKJ":251,"FKL":141,"FKN":141,"FKQ":211,"FKS":251,"FLA":78,"FLB":101,"FLD":86,"FLF":285,"FLG":147,"FLI":267,"FLL":141,"FLM":69,"FLN":161,"FLO":141,"FLP":86,"FLR":317,"FLS":275,"FLV":86,"FLW":261,"FLX":123,"FLY":280,"FLZ":210,"FMA":58,"FMC":52,"FME":141,"FMG":89,"FMH":141,"FMI":33,"FMM":285,"FMN":94,"FMO":285,"FMS":86,"FMU":123,"FMY":141,"FNA":20,"FNB":285,"FNC":266,"FND":340,"FNE":372,"FNG":45,"FNH":2,"FNI":313,"FNJ":235,"FNL":94,"FNT":95,"FNU":317,"FOB":123,"FOC":243,"FOD":86,"FOE":86,"FOG":317,"FOK":141,"FOM":18,"FON":89,"FOO":211,"FOR":101,"FOS":279,"FOT":280,"FOU":30,"FPO":140,"FPR":141,"FPY":141,"FRA":285,"FRB":280,"FRC":161,"FRD":123,"FRE":355,"FRF":285,"FRG":141,"FRH":113,"FRI":86,"FRJ":313,"FRK":339,"FRL":317,"FRM":86,"FRN":52,"FRO":312,"FRR":141,"FRS":107,"FRT":159,"FRW":21,"FRY":141,"FRZ":285,"FSC":313,"FSD":86,"FSI":86,"FSK":86,"FSM":86,"FSP":135,"FSS":305,"FST":86,"FSU":94,"FSZ":251,"FTA":350,"FTE":62,"FTI":369,"FTK":141,"FTU":334,"FTW":86,"FTX":10,"FTY":141,"FUE":263,"FUG":243,"FUJ":251,"FUK":251,"FUL":123,"FUN":352,"FUO":243,"FUT":379,"FVL":279,"FVM":340,"FWA":113,"FWH":86,"FWL":52,"FXE":141,"FXO":36,"FXY":86,"FYM":86,"FYT":42,"FYU":52,"FYV":86,"FZO":305,"GAB":123,"GAC":171,"GAD":86,"GAE":49,"GAF":49,"GAG":86,"GAH":271,"GAI":141,"GAJ":251,"GAL":52,"GAM":142,"GAN":340,"GAO":111,"GAP":372,"GAQ":5,"GAR":372,"GAS":41,"GAT":313,"GAU":218,"GAW":258,"GAY":218,"GBA":305,"GBB":191,"GBD":86,"GBE":21,"GBF":372,"GBG":86,"GBH":52,"GBI":218,"GBJ":106,"GBK":20,"GBL":273,"GBP":271,"GBR":141,"GBT":249,"GBU":26,"GBV":279,"GBW":279,"GBZ":345,"GCC":94,"GCD":123,"GCH":249,"GCI":294,"GCJ":23,"GCK":86,"GCM":85,"GCN":147,"GCW":147,"GCY":141,"GDC":141,"GDD":279,"GDE":2,"GDG":257,"GDI":6,"GDJ":33,"GDL":134,"GDM":141,"GDN":331,"GDO":83,"GDP":101,"GDQ":2,"GDT":104,"GDV":94,"GDW":95,"GDX":224,"GDY":141,"GDZ":311,"GEA":368,"GEB":211,"GED":141,"GEE":275,"GEF":355,"GEG":123,"GEL":161,"GEO":109,"GER":111,"GES":226,"GET":279,"GEV":324,"GEX":278,"GEY":94,"GFD":113,"GFF":280,"GFK":86,"GFL":141,"GFN":280,"GFO":109,"GFR":313,"GFY":50,"GGB":90,"GGD":271,"GGE":141,"GGF":158,"GGG":86,"GGH":161,"GGM":41,"GGN":0,"GGO":0,"GGS":62,"GGT":140,"GGW":94,"GHA":3,"GHB":140,"GHC":140,"GHF":285,"GHM":86,"GHT":48,"GHU":58,"GHV":288,"GIB":293,"GIC":271,"GID":11,"GIF":141,"GIG":161,"GII":14,"GIL":215,"GIR":78,"GIS":345,"GIU":199,"GIY":23,"GIZ":239,"GJA":171,"GJL":3,"GJM":150,"GJR":267,"GJT":94,"GKA":372,"GKD":297,"GKE":281,"GKH":216,"GKK":340,"GKL":271,"GKN":52,"GKT":141,"GLA":305,"GLD":94,"GLE":86,"GLF":89,"GLG":271,"GLH":86,"GLI":280,"GLJ":78,"GLK":39,"GLL":312,"GLM":271,"GLO":305,"GLR":95,"GLS":86,"GLT":271,"GLU":250,"GLV":142,"GLW":86,"GLX":211,"GLZ":281,"GMA":28,"GMB":2,"GMD":13,"GME":310,"GMI":372,"GML":302,"GMM":10,"GMN":345,"GMO":29,"GMP":242,"GMR":354,"GMS":161,"GMT":52,"GMU":141,"GMZ":263,"GNA":310,"GNB":313,"GND":105,"GNF":123,"GNG":79,"GNI":246,"GNJ":191,"GNM":71,"GNN":2,"GNR":63,"GNS":210,"GNT":94,"GNV":141,"GNY":297,"GNZ":21,"GOA":317,"GOB":2,"GOG":50,"GOH":144,"GOI":218,"GOJ":311,"GOK":86,"GOM":27,"GON":141,"GOO":271,"GOP":218,"GOQ":243,"GOR":2,"GOT":324,"GOU":18,"GOV":273,"GOX":218,"GOZ":323,"GPA":283,"GPB":161,"GPI":78,"GPL":89,"GPN":273,"GPO":63,"GPS":353,"GPT":86,"GPZ":86,"GQQ":141,"GRB":86,"GRD":141,"GRE":86,"GRF":123,"GRI":86,"GRJ":23,"GRK":86,"GRL":372,"GRM":86,"GRN":94,"GRO":307,"GRP":55,"GRQ":281,"GRR":95,"GRS":317,"GRU":161,"GRV":311,"GRW":261,"GRX":307,"GRY":267,"GRZ":328,"GSA":221,"GSB":141,"GSC":279,"GSE":324,"GSH":113,"GSI":84,"GSJ":107,"GSM":249,"GSN":270,"GSO":141,"GSP":141,"GSQ":12,"GSR":39,"GSS":23,"GST":118,"GSU":26,"GSV":320,"GTA":355,"GTB":141,"GTE":273,"GTF":94,"GTG":86,"GTI":285,"GTN":345,"GTO":225,"GTP":123,"GTR":86,"GTS":273,"GTT":271,"GTW":315,"GUA":107,"GUB":130,"GUC":94,"GUD":5,"GUF":86,"GUH":280,"GUI":83,"GUJ":161,"GUL":280,"GUM":356,"GUP":94,"GUQ":83,"GUR":372,"GUS":113,"GUT":285,"GUU":267,"GUV":372,"GUW":188,"GUX":218,"GUY":86,"GUZ":161,"GVA":313,"GVE":141,"GVI":372,"GVL":141,"GVN":256,"GVP":271,"GVR":161,"GVT":86,"GVX":324,"GWA":258,"GWD":215,"GWE":22,"GWL":218,"GWO":86,"GWS":94,"GWT":285,"GWY":292,"GXF":181,"GXG":32,"GXQ":159,"GXX":18,"GXY":94,"GYA":121,"GYD":191,"GYE":108,"GYG":257,"GYI":27,"GYL":279,"GYM":112,"GYN":161,"GYP":271,"GYR":147,"GYS":243,"GYU":243,"GYY":86,"GYZ":279,"GZG":243,"GZO":355,"GZP":297,"GZT":297,"GZW":249,"HAA":312,"HAB":86,"HAC":251,"HAD":324,"HAF":123,"HAH":338,"HAI":95,"HAJ":285,"HAK":243,"HAM":285,"HAN":192,"HAO":141,"HAQ":340,"HAR":141,"HAS":239,"HAT":271,"HAU":312,"HAV":111,"HAW":305,"HAY":78,"HBA":275,"HBE":12,"HBG":86,"HBK":147,"HBQ":243,"HBR":86,"HBT":239,"HBU":208,"HBX":218,"HCC":141,"HCM":39,"HCN":246,"HCQ":279,"HCR":52,"HCW":141,"HCZ":243,"HDD":215,"HDE":86,"HDF":285,"HDG":243,"HDH":357,"HDK":340,"HDM":249,"HDN":94,"HDO":218,"HDR":249,"HDS":23,"HDY":192,"HEA":213,"HED":52,"HEE":86,"HEG":26,"HEH":258,"HEI":285,"HEK":243,"HEL":295,"HEM":295,"HER":283,"HES":123,"HET":243,"HEV":307,"HEW":283,"HEZ":86,"HFA":212,"HFD":141,"HFE":243,"HFF":141,"HFN":267,"HFS":324,"HFT":312,"HGA":39,"HGD":271,"HGE":83,"HGH":243,"HGI":218,"HGL":285,"HGN":192,"HGO":0,"HGR":141,"HGS":20,"HGU":372,"HGZ":52,"HHE":251,"HHH":141,"HHI":357,"HHN":285,"HHQ":192,"HHR":123,"HHZ":375,"HIA":243,"HIB":86,"HID":271,"HIE":141,"HIF":94,"HIG":271,"HII":147,"HIJ":251,"HIM":199,"HIN":242,"HIO":123,"HIP":271,"HIR":355,"HIW":251,"HJJ":243,"HJR":218,"HJT":253,"HKA":86,"HKD":251,"HKG":207,"HKK":345,"HKN":372,"HKS":86,"HKT":192,"HKV":323,"HKY":141,"HLA":23,"HLB":113,"HLC":86,"HLD":243,"HLE":268,"HLF":324,"HLG":141,"HLH":243,"HLI":123,"HLJ":329,"HLL":279,"HLM":95,"HLN":94,"HLO":267,"HLP":210,"HLR":86,"HLS":275,"HLT":278,"HLU":368,"HLW":23,"HLY":305,"HLZ":345,"HMA":259,"HMB":12,"HME":3,"HMG":273,"HMI":243,"HMJ":302,"HMN":94,"HMO":112,"HMR":312,"HMT":123,"HMV":324,"HMY":242,"HNA":251,"HNB":114,"HNC":141,"HND":251,"HNE":52,"HNH":118,"HNI":243,"HNL":357,"HNM":357,"HNS":118,"HNY":243,"HOA":41,"HOB":94,"HOD":181,"HOF":239,"HOG":111,"HOH":328,"HOI":375,"HOK":273,"HOM":52,"HON":86,"HOP":86,"HOQ":285,"HOR":261,"HOS":63,"HOT":86,"HOU":86,"HOV":312,"HOX":258,"HPA":377,"HPB":142,"HPH":192,"HPN":141,"HPT":86,"HPY":86,"HQL":243,"HQM":123,"HRB":243,"HRE":22,"HRF":340,"HRG":12,"HRH":218,"HRI":199,"HRK":302,"HRL":86,"HRM":3,"HRO":86,"HRS":23,"HRT":305,"HRY":273,"HRZ":161,"HSA":182,"HSB":86,"HSC":243,"HSG":251,"HSH":123,"HSI":86,"HSK":307,"HSL":52,"HSM":278,"HSN":243,"HSP":141,"HSR":218,"HSS":218,"HST":141,"HSV":86,"HSZ":246,"HTA":197,"HTG":219,"HTH":123,"HTI":276,"HTL":95,"HTN":243,"HTO":141,"HTR":251,"HTS":141,"HTU":278,"HTV":86,"HTW":141,"HTY":297,"HTZ":78,"HUA":86,"HUB":273,"HUE":2,"HUF":113,"HUG":107,"HUH":375,"HUI":206,"HUJ":86,"HUL":136,"HUM":86,"HUN":246,"HUO":243,"HUQ":48,"HUS":52,"HUT":86,"HUU":122,"HUW":127,"HUX":134,"HUY":305,"HUZ":243,"HVA":334,"HVB":271,"HVD":208,"HVE":94,"HVG":312,"HVK":267,"HVM":267,"HVN":141,"HVR":94,"HVS":141,"HWD":123,"HWK":270,"HWN":22,"HWO":141,"HXD":243,"HXX":280,"HYA":141,"HYC":305,"HYD":218,"HYN":243,"HYR":86,"HYS":86,"HYV":295,"HZB":313,"HZG":243,"HZH":243,"HZK":267,"HZL":141,"HZP":97,"IAA":219,"IAB":86,"IAD":141,"IAG":141,"IAH":86,"IAM":3,"IAN":52,"IAO":226,"IAQ":249,"IAR":311,"IAS":288,"IBA":29,"IBB":353,"IBE":78,"IBP":122,"IBR":251,"IBZ":307,"ICA":83,"ICC":83,"ICI":351,"ICK":146,"ICL":86,"ICN":242,"ICR":111,"ICS":79,"ICT":86,"ICY":52,"IDA":79,"IDB":324,"IDF":28,"IDG":86,"IDH":123,"IDI":141,"IDK":270,"IDO":55,"IDP":86,"IDR":218,"IDY":313,"IEG":331,"IEJ":251,"IES":285,"IEV":302,"IFA":86,"IFF":271,"IFH":249,"IFJ":267,"IFL":271,"IFN":249,"IFO":302,"IFP":147,"IFU":340,"IGA":140,"IGB":63,"IGD":297,"IGG":52,"IGH":271,"IGL":297,"IGM":147,"IGN":226,"IGO":78,"IGR":58,"IGS":285,"IGT":311,"IGU":58,"IHC":36,"IHN":181,"IHO":334,"IHR":249,"IIA":292,"IIL":249,"IJK":318,"IJU":161,"IJX":86,"IKA":249,"IKB":141,"IKI":251,"IKK":86,"IKL":28,"IKO":142,"IKP":271,"IKS":257,"IKT":209,"IKU":195,"ILA":211,"ILD":307,"ILE":86,"ILF":178,"ILG":141,"ILH":285,"ILI":52,"ILK":334,"ILL":86,"ILM":141,"ILN":141,"ILO":226,"ILP":368,"ILQ":122,"ILR":29,"ILS":99,"ILU":41,"ILY":305,"ILZ":286,"IMB":109,"IMF":218,"IMK":216,"IML":94,"IMM":141,"IMO":6,"IMP":101,"IMQ":249,"IMT":86,"INA":311,"INC":243,"IND":113,"INF":3,"ING":62,"INH":36,"INI":284,"INJ":271,"INK":86,"INL":86,"INM":270,"INN":328,"INO":28,"INQ":292,"INS":123,"INT":141,"INU":365,"INV":305,"INW":147,"INX":211,"INZ":3,"IOA":283,"IOM":296,"ION":10,"IOR":292,"IOS":71,"IOU":368,"IOW":86,"IPA":350,"IPC":349,"IPE":226,"IPG":78,"IPH":220,"IPI":78,"IPL":123,"IPN":161,"IPT":141,"IPU":71,"IPZ":89,"IQA":189,"IQM":243,"IQN":243,"IQQ":159,"IQT":122,"IRA":355,"IRC":52,"IRD":201,"IRE":71,"IRG":271,"IRI":16,"IRJ":60,"IRK":86,"IRM":259,"IRN":171,"IRO":6,"IRP":33,"IRS":95,"IRZ":127,"ISA":271,"ISB":215,"ISC":305,"ISE":297,"ISG":251,"ISI":271,"ISJ":82,"ISK":218,"ISL":297,"ISM":141,"ISN":86,"ISO":141,"ISP":141,"ISQ":95,"ISS":141,"IST":297,"ISU":189,"ISW":86,"ITA":127,"ITB":158,"ITE":71,"ITH":141,"ITI":74,"ITM":251,"ITO":357,"ITP":161,"ITQ":161,"IUD":236,"IUE":366,"IVA":334,"IVC":345,"IVG":314,"IVL":295,"IVR":280,"IVW":273,"IWA":311,"IWD":131,"IWJ":251,"IWK":251,"IWO":251,"IWS":86,"IXA":218,"IXB":218,"IXC":218,"IXD":218,"IXE":218,"IXG":218,"IXH":201,"IXI":218,"IXJ":218,"IXK":218,"IXL":218,"IXM":218,"IXN":201,"IXP":218,"IXQ":218,"IXR":218,"IXS":218,"IXT":218,"IXU":218,"IXV":218,"IXW":218,"IXY":218,"IXZ":218,"IYK":123,"IYS":52,"IZA":161,"IZO":251,"IZT":134,"JAA":213,"JAB":273,"JAC":94,"JAD":279,"JAE":122,"JAF":199,"JAG":215,"JAI":218,"JAK":148,"JAL":134,"JAM":323,"JAN":86,"JAP":89,"JAR":249,"JAS":86,"JAU":122,"JAV":144,"JAW":154,"JAX":141,"JBQ":160,"JBR":86,"JCB":161,"JCI":86,"JCK":271,"JCM":71,"JCR":158,"JCT":86,"JDA":123,"JDF":161,"JDG":242,"JDH":218,"JDN":94,"JDO":101,"JDR":161,"JDZ":243,"JED":239,"JEE":148,"JEF":86,"JEG":144,"JEK":22,"JEQ":71,"JER":298,"JFK":141,"JFN":141,"JFR":144,"JGA":218,"JGN":243,"JGS":243,"JHB":220,"JHF":161,"JHG":243,"JHL":97,"JHM":357,"JHQ":271,"JHS":144,"JHW":141,"JIA":90,"JIB":17,"JIC":243,"JIJ":2,"JIK":283,"JIL":243,"JIM":2,"JIN":25,"JIP":108,"JIQ":243,"JIR":216,"JIU":243,"JIW":215,"JJD":101,"JJG":161,"JJI":122,"JJM":41,"JJN":243,"JKG":324,"JKH":283,"JKL":283,"JKR":216,"JKV":86,"JLD":324,"JLN":86,"JLR":218,"JLS":161,"JMK":283,"JMO":216,"JMS":86,"JMU":243,"JNA":161,"JNB":23,"JNG":243,"JNH":243,"JNI":56,"JNU":118,"JNX":283,"JNZ":243,"JOE":295,"JOG":210,"JOH":23,"JOI":161,"JOK":311,"JOL":226,"JOM":16,"JOS":29,"JOT":86,"JPA":101,"JPR":150,"JQA":144,"JQE":145,"JRF":357,"JRG":218,"JRH":218,"JRN":90,"JRO":16,"JSA":218,"JSH":283,"JSI":283,"JSM":57,"JSR":201,"JST":141,"JSU":144,"JSY":283,"JTC":161,"JTI":161,"JTR":283,"JTY":283,"JUA":90,"JUB":24,"JUI":285,"JUJ":59,"JUL":122,"JUM":216,"JUN":271,"JUR":279,"JUT":171,"JUV":144,"JUZ":243,"JVA":334,"JVI":141,"JVL":86,"JWA":21,"JWN":249,"JWO":242,"JXA":243,"JXN":95,"JYR":249,"JYV":295,"JZH":243,"KAA":34,"KAB":22,"KAC":200,"KAD":29,"KAG":242,"KAJ":295,"KAL":52,"KAN":29,"KAO":295,"KAP":33,"KAR":109,"KAT":345,"KAU":295,"KAV":83,"KAW":258,"KAX":279,"KAY":351,"KAZ":211,"KBA":20,"KBB":273,"KBG":25,"KBH":215,"KBI":18,"KBJ":273,"KBK":218,"KBL":213,"KBM":372,"KBN":33,"KBO":33,"KBP":302,"KBQ":9,"KBR":220,"KBS":20,"KBU":225,"KBV":192,"KBY":270,"KBZ":345,"KCA":243,"KCB":146,"KCE":271,"KCF":215,"KCH":221,"KCK":209,"KCM":297,"KCO":297,"KCS":273,"KCT":199,"KCU":25,"KCZ":251,"KDA":15,"KDB":279,"KDC":46,"KDD":215,"KDH":213,"KDI":225,"KDJ":30,"KDK":52,"KDL":325,"KDM":340,"KDN":30,"KDO":340,"KDR":372,"KDT":192,"KDU":215,"KDV":351,"KDX":26,"KDY":217,"KEA":187,"KEC":33,"KED":15,"KEE":10,"KEF":267,"KEI":211,"KEJ":229,"KEL":285,"KEM":295,"KEN":20,"KEO":0,"KEP":216,"KEQ":211,"KER":249,"KET":258,"KEU":41,"KEV":295,"KEW":178,"KEY":41,"KFA":44,"KFE":279,"KFG":273,"KFP":142,"KFS":297,"KFZ":326,"KGA":33,"KGC":270,"KGD":299,"KGE":355,"KGF":182,"KGG":15,"KGI":279,"KGJ":9,"KGK":52,"KGL":27,"KGN":33,"KGO":302,"KGP":259,"KGS":283,"KGU":221,"KGY":271,"KHC":321,"KHD":249,"KHE":302,"KHG":243,"KHH":246,"KHI":215,"KHJ":295,"KHK":249,"KHM":258,"KHN":243,"KHR":253,"KHS":227,"KHT":213,"KHV":256,"KHW":21,"KHY":249,"KHZ":375,"KIA":109,"KIC":123,"KID":324,"KIE":346,"KIF":178,"KIH":249,"KIJ":251,"KIK":189,"KIM":23,"KIN":117,"KIP":86,"KIR":292,"KIS":41,"KIT":283,"KIW":34,"KIX":251,"KIY":16,"KJA":219,"KJB":218,"KJH":243,"KJK":287,"KJP":251,"KJT":210,"KKA":52,"KKC":192,"KKD":372,"KKE":345,"KKH":142,"KKJ":251,"KKM":192,"KKN":312,"KKO":345,"KKP":271,"KKQ":259,"KKR":375,"KKS":249,"KKW":28,"KKX":251,"KKY":292,"KKZ":233,"KLB":34,"KLC":15,"KLD":311,"KLE":18,"KLF":311,"KLG":52,"KLH":218,"KLI":28,"KLJ":329,"KLK":41,"KLM":249,"KLN":52,"KLO":226,"KLQ":210,"KLR":324,"KLS":123,"KLU":328,"KLV":315,"KLW":163,"KLX":283,"KLY":33,"KLZ":23,"KMA":372,"KME":27,"KMG":243,"KMH":23,"KMI":251,"KMJ":251,"KMK":10,"KML":271,"KMN":33,"KMO":52,"KMP":50,"KMQ":251,"KMR":372,"KMS":1,"KMU":39,"KMV":258,"KMW":311,"KMX":239,"KMZ":34,"KNA":159,"KNB":94,"KND":33,"KNF":305,"KNG":211,"KNH":246,"KNI":279,"KNJ":10,"KNK":52,"KNM":33,"KNN":14,"KNO":210,"KNQ":368,"KNR":249,"KNS":275,"KNT":86,"KNU":218,"KNW":52,"KNX":279,"KNZ":5,"KOA":357,"KOC":368,"KOE":225,"KOF":23,"KOH":271,"KOI":305,"KOJ":251,"KOK":295,"KOO":33,"KOP":192,"KOQ":285,"KOS":233,"KOT":142,"KOU":30,"KOV":182,"KOW":243,"KOX":211,"KPC":142,"KPI":221,"KPM":372,"KPN":142,"KPO":242,"KPP":271,"KPS":280,"KPV":52,"KPW":184,"KQH":218,"KQT":204,"KRA":278,"KRB":271,"KRC":210,"KRE":11,"KRF":324,"KRG":109,"KRH":305,"KRI":372,"KRJ":372,"KRK":331,"KRL":243,"KRM":109,"KRN":324,"KRO":259,"KRP":291,"KRQ":302,"KRR":311,"KRS":312,"KRT":26,"KRW":187,"KRY":243,"KRZ":28,"KSA":360,"KSC":286,"KSD":324,"KSE":25,"KSF":285,"KSH":249,"KSI":14,"KSJ":283,"KSK":324,"KSL":26,"KSM":142,"KSN":237,"KSO":283,"KSQ":241,"KSS":5,"KST":26,"KSU":312,"KSV":271,"KSW":212,"KSY":297,"KSZ":311,"KTA":279,"KTD":251,"KTE":220,"KTF":345,"KTG":234,"KTI":233,"KTL":41,"KTM":216,"KTN":163,"KTO":109,"KTP":117,"KTQ":295,"KTR":273,"KTS":142,"KTT":295,"KTU":218,"KTW":331,"KTX":5,"KTY":199,"KUA":220,"KUC":376,"KUD":221,"KUF":318,"KUG":271,"KUH":251,"KUK":142,"KUL":220,"KUM":251,"KUN":329,"KUO":295,"KUQ":372,"KUS":144,"KUT":248,"KUU":218,"KUV":242,"KVA":283,"KVB":324,"KVC":142,"KVG":372,"KVK":311,"KVL":142,"KVM":184,"KVX":301,"KWA":361,"KWE":243,"KWG":302,"KWH":204,"KWI":222,"KWJ":242,"KWK":142,"KWL":243,"KWM":271,"KWN":52,"KWO":372,"KWS":355,"KWT":52,"KWZ":33,"KXB":225,"KXD":259,"KXE":23,"KXF":351,"KXK":256,"KXU":375,"KYA":297,"KYD":246,"KYE":194,"KYF":279,"KYI":270,"KYK":52,"KYP":258,"KYS":5,"KYT":258,"KYU":52,"KYZ":219,"KZC":233,"KZF":372,"KZG":285,"KZI":283,"KZN":311,"KZO":238,"KZR":297,"KZS":283,"LAA":94,"LAD":32,"LAE":372,"LAF":113,"LAH":211,"LAI":313,"LAJ":161,"LAK":115,"LAL":141,"LAM":94,"LAN":95,"LAO":226,"LAP":130,"LAQ":48,"LAR":94,"LAS":123,"LAU":41,"LAW":86,"LAX":123,"LAY":23,"LAZ":71,"LBA":305,"LBB":86,"LBC":285,"LBD":204,"LBE":141,"LBF":86,"LBG":313,"LBI":313,"LBJ":225,"LBL":86,"LBO":33,"LBQ":30,"LBR":127,"LBS":351,"LBT":141,"LBU":221,"LBV":30,"LBW":225,"LBX":226,"LBY":313,"LBZ":32,"LCA":228,"LCC":317,"LCD":23,"LCE":171,"LCF":107,"LCG":307,"LCH":86,"LCI":141,"LCJ":331,"LCK":141,"LCL":111,"LCM":58,"LCN":270,"LCO":10,"LCQ":141,"LCV":317,"LCX":243,"LCY":305,"LDA":218,"LDB":161,"LDC":276,"LDE":313,"LDG":311,"LDH":277,"LDI":16,"LDJ":141,"LDK":324,"LDM":95,"LDN":216,"LDO":146,"LDS":243,"LDU":221,"LDV":313,"LDX":84,"LDY":305,"LDZ":23,"LEA":279,"LEB":141,"LEC":71,"LED":311,"LEE":141,"LEF":37,"LEH":313,"LEI":307,"LEJ":285,"LEK":14,"LEL":273,"LEM":94,"LEN":307,"LEP":161,"LEQ":305,"LER":279,"LES":37,"LET":78,"LEU":307,"LEV":351,"LEW":141,"LEX":141,"LEY":281,"LEZ":171,"LFB":36,"LFI":141,"LFK":86,"LFM":249,"LFN":141,"LFO":2,"LFP":271,"LFR":83,"LFT":86,"LFW":31,"LGA":141,"LGB":123,"LGC":141,"LGD":123,"LGF":147,"LGG":287,"LGH":270,"LGI":140,"LGK":220,"LGL":221,"LGO":285,"LGP":226,"LGQ":108,"LGR":159,"LGS":61,"LGT":78,"LGU":94,"LGW":305,"LHA":285,"LHB":305,"LHE":215,"LHG":280,"LHI":211,"LHK":243,"LHR":305,"LHS":62,"LHU":21,"LHV":141,"LHW":243,"LIA":243,"LIC":94,"LIE":28,"LIF":368,"LIG":313,"LIH":357,"LII":211,"LIL":313,"LIM":122,"LIN":317,"LIO":89,"LIP":161,"LIQ":33,"LIR":89,"LIS":303,"LIT":86,"LIW":258,"LIX":9,"LIY":141,"LJA":33,"LJG":243,"LJN":86,"LJU":304,"LKA":225,"LKB":351,"LKD":271,"LKG":41,"LKH":221,"LKK":52,"LKL":312,"LKN":312,"LKO":218,"LKP":141,"LKV":123,"LKW":227,"LKY":16,"LKZ":305,"LLA":324,"LLB":243,"LLE":23,"LLF":243,"LLG":271,"LLI":2,"LLJ":201,"LLK":191,"LLS":58,"LLT":32,"LLW":9,"LLX":141,"LLY":141,"LMA":52,"LMB":9,"LME":313,"LMI":372,"LMM":130,"LMN":196,"LMO":305,"LMP":317,"LMQ":48,"LMR":23,"LMS":86,"LMT":123,"LMV":340,"LMY":372,"LNA":141,"LNB":350,"LND":94,"LNE":350,"LNH":273,"LNI":52,"LNJ":243,"LNK":86,"LNL":243,"LNN":141,"LNO":279,"LNP":141,"LNR":86,"LNS":141,"LNV":372,"LNX":311,"LNY":357,"LNZ":328,"LOA":271,"LOB":159,"LOC":270,"LOD":350,"LOE":192,"LOH":108,"LOI":161,"LOK":41,"LOL":123,"LOO":3,"LOP":225,"LOS":29,"LOT":86,"LOU":119,"LOV":137,"LOW":141,"LOY":41,"LOZ":141,"LPA":263,"LPB":121,"LPC":123,"LPD":78,"LPF":243,"LPG":56,"LPI":324,"LPJ":83,"LPK":311,"LPL":305,"LPM":350,"LPO":86,"LPP":295,"LPQ":255,"LPT":192,"LPU":225,"LPX":316,"LPY":313,"LQK":141,"LQM":78,"LQN":213,"LRA":283,"LRB":23,"LRD":86,"LRE":271,"LRF":86,"LRG":215,"LRH":313,"LRJ":86,"LRL":31,"LRM":160,"LRR":249,"LRS":283,"LRT":313,"LRU":94,"LRV":83,"LSA":372,"LSB":94,"LSC":159,"LSE":86,"LSF":86,"LSH":258,"LSI":305,"LSK":94,"LSL":89,"LSM":221,"LSN":123,"LSO":313,"LSP":83,"LSQ":159,"LSS":106,"LST":275,"LSU":221,"LSV":123,"LSW":210,"LSX":210,"LSY":280,"LSZ":332,"LTA":23,"LTC":42,"LTD":48,"LTG":216,"LTI":208,"LTK":200,"LTL":30,"LTM":77,"LTN":305,"LTO":130,"LTP":271,"LTQ":313,"LTR":292,"LTS":86,"LTT":313,"LTU":218,"LTV":271,"LTX":108,"LUA":216,"LUB":109,"LUC":351,"LUD":50,"LUE":286,"LUF":147,"LUG":333,"LUH":218,"LUJ":23,"LUK":141,"LUL":86,"LUM":243,"LUN":34,"LUO":32,"LUP":357,"LUQ":65,"LUR":142,"LUS":28,"LUT":271,"LUU":271,"LUV":211,"LUW":225,"LUX":306,"LUZ":331,"LVA":313,"LVI":34,"LVK":123,"LVL":141,"LVM":94,"LVO":279,"LVP":249,"LVR":90,"LVS":94,"LWB":141,"LWC":86,"LWH":271,"LWK":305,"LWL":123,"LWM":141,"LWN":260,"LWO":302,"LWR":281,"LWS":123,"LWT":94,"LWV":86,"LWY":221,"LXA":243,"LXG":255,"LXN":86,"LXR":12,"LXS":283,"LXU":34,"LXV":94,"LYA":243,"LYB":85,"LYC":324,"LYE":305,"LYG":243,"LYH":141,"LYI":243,"LYN":313,"LYO":86,"LYP":215,"LYR":180,"LYS":313,"LYU":86,"LYX":305,"LZA":33,"LZC":134,"LZG":243,"LZH":243,"LZI":28,"LZM":32,"LZN":246,"LZO":243,"LZR":271,"LZU":141,"LZY":243,"MAA":218,"MAB":74,"MAC":141,"MAD":307,"MAE":123,"MAF":86,"MAG":372,"MAH":307,"MAI":9,"MAJ":362,"MAK":24,"MAL":211,"MAM":129,"MAN":305,"MAO":127,"MAQ":192,"MAR":83,"MAS":372,"MAT":28,"MAU":375,"MAW":86,"MAX":15,"MAY":140,"MAZ":151,"MBA":41,"MBB":279,"MBC":30,"MBD":23,"MBE":251,"MBF":278,"MBG":86,"MBH":271,"MBI":16,"MBJ":117,"MBK":90,"MBL":95,"MBO":226,"MBP":122,"MBQ":25,"MBS":95,"MBT":226,"MBU":355,"MBW":278,"MBX":304,"MBY":86,"MBZ":127,"MCA":14,"MCB":86,"MCC":123,"MCD":95,"MCE":123,"MCF":141,"MCG":52,"MCH":108,"MCI":86,"MCJ":78,"MCK":86,"MCL":52,"MCN":141,"MCO":141,"MCP":74,"MCQ":289,"MCR":75,"MCS":58,"MCT":227,"MCU":313,"MCV":273,"MCW":86,"MCX":311,"MCY":271,"MCZ":125,"MDC":225,"MDD":86,"MDE":78,"MDF":86,"MDG":243,"MDH":86,"MDI":29,"MDK":28,"MDL":258,"MDN":113,"MDO":52,"MDP":211,"MDQ":56,"MDS":104,"MDT":141,"MDU":372,"MDW":86,"MDX":58,"MDY":364,"MDZ":61,"MEA":161,"MEB":278,"MEC":108,"MED":239,"MEE":368,"MEG":32,"MEH":312,"MEI":86,"MEJ":141,"MEK":13,"MEL":278,"MEM":86,"MEN":313,"MEO":141,"MEP":220,"MEQ":210,"MER":123,"MES":210,"MET":271,"MEU":158,"MEV":123,"MEW":33,"MEX":134,"MEY":216,"MEZ":23,"MFA":16,"MFC":37,"MFD":141,"MFE":86,"MFF":30,"MFG":215,"MFI":86,"MFJ":351,"MFK":246,"MFM":223,"MFN":345,"MFO":372,"MFP":273,"MFQ":43,"MFR":123,"MFS":78,"MFU":34,"MFV":141,"MFX":313,"MGA":126,"MGB":270,"MGC":86,"MGD":121,"MGE":141,"MGF":161,"MGH":23,"MGJ":141,"MGK":258,"MGL":285,"MGM":86,"MGN":78,"MGQ":39,"MGR":141,"MGS":373,"MGT":273,"MGU":258,"MGV":279,"MGW":141,"MGX":30,"MGY":141,"MGZ":258,"MHA":109,"MHC":159,"MHD":249,"MHE":86,"MHG":285,"MHH":140,"MHI":17,"MHK":86,"MHL":86,"MHO":279,"MHP":310,"MHQ":309,"MHR":123,"MHS":123,"MHT":141,"MHU":278,"MHV":123,"MHW":121,"MHX":373,"MHZ":305,"MIA":141,"MIB":86,"MIC":86,"MID":132,"MIE":113,"MIG":243,"MIH":279,"MII":161,"MIJ":362,"MIK":295,"MIM":280,"MIN":270,"MIO":86,"MIP":212,"MIQ":86,"MIR":49,"MIS":372,"MIT":123,"MIU":29,"MIV":141,"MIW":86,"MJA":334,"MJC":0,"MJD":215,"MJF":312,"MJG":111,"MJI":48,"MJK":279,"MJL":30,"MJM":33,"MJN":334,"MJO":50,"MJP":279,"MJQ":86,"MJR":56,"MJT":283,"MJU":225,"MJV":307,"MJX":141,"MJZ":257,"MKA":315,"MKB":30,"MKC":86,"MKE":86,"MKG":95,"MKH":37,"MKI":6,"MKJ":10,"MKK":357,"MKL":86,"MKM":221,"MKO":86,"MKP":375,"MKQ":211,"MKR":279,"MKT":86,"MKU":30,"MKV":273,"MKW":211,"MKY":271,"MKZ":220,"MLA":308,"MLB":141,"MLC":86,"MLD":79,"MLE":340,"MLF":94,"MLG":210,"MLH":313,"MLI":86,"MLJ":141,"MLL":52,"MLM":134,"MLN":13,"MLO":283,"MLP":226,"MLR":270,"MLS":94,"MLT":141,"MLU":86,"MLV":271,"MLW":40,"MLX":297,"MLY":52,"MLZ":138,"MMB":251,"MMC":137,"MMD":251,"MME":305,"MMF":18,"MMG":279,"MMH":123,"MMI":141,"MMJ":251,"MMK":311,"MML":86,"MMM":271,"MMN":141,"MMO":264,"MMP":78,"MMQ":34,"MMS":86,"MMT":141,"MMU":141,"MMX":324,"MMY":251,"MMZ":213,"MNA":225,"MNB":28,"MNC":36,"MNE":270,"MNF":351,"MNG":273,"MNH":227,"MNI":139,"MNJ":334,"MNK":376,"MNL":226,"MNM":86,"MNN":141,"MNO":33,"MNQ":271,"MNR":34,"MNS":34,"MNU":258,"MNW":273,"MNX":127,"MNY":355,"MNZ":141,"MOA":111,"MOB":86,"MOC":161,"MOD":123,"MOE":258,"MOF":225,"MOG":258,"MOI":373,"MOJ":146,"MOK":241,"MOL":312,"MOM":44,"MON":345,"MOO":270,"MOP":95,"MOQ":334,"MOR":141,"MOT":86,"MOU":142,"MOV":271,"MOX":86,"MOZ":375,"MPA":50,"MPC":210,"MPD":215,"MPH":226,"MPJ":86,"MPL":313,"MPM":36,"MPN":269,"MPO":141,"MPR":86,"MPS":86,"MPT":202,"MPV":141,"MPW":302,"MPY":84,"MPZ":86,"MQA":279,"MQB":86,"MQC":135,"MQD":63,"MQE":273,"MQF":259,"MQH":161,"MQJ":245,"MQK":90,"MQL":278,"MQM":297,"MQN":312,"MQP":23,"MQQ":42,"MQS":169,"MQT":95,"MQU":78,"MQW":141,"MQX":2,"MQY":86,"MQZ":279,"MRB":141,"MRC":86,"MRD":83,"MRE":41,"MRF":86,"MRG":271,"MRI":52,"MRK":141,"MRN":141,"MRO":345,"MRP":270,"MRQ":226,"MRR":108,"MRS":313,"MRU":341,"MRV":311,"MRW":291,"MRX":249,"MRY":123,"MRZ":280,"MSA":178,"MSC":147,"MSE":305,"MSF":273,"MSG":37,"MSH":227,"MSI":161,"MSJ":251,"MSL":86,"MSM":28,"MSN":86,"MSO":94,"MSP":86,"MSQ":310,"MSR":297,"MSS":141,"MST":281,"MSU":37,"MSV":141,"MSW":4,"MSX":10,"MSY":86,"MSZ":32,"MTA":345,"MTB":78,"MTC":95,"MTD":273,"MTE":158,"MTF":2,"MTG":90,"MTH":141,"MTI":264,"MTJ":94,"MTK":376,"MTL":280,"MTN":141,"MTO":86,"MTP":141,"MTQ":271,"MTR":78,"MTS":38,"MTT":134,"MTV":350,"MTW":86,"MTY":137,"MTZ":183,"MUA":355,"MUB":21,"MUC":285,"MUD":36,"MUE":357,"MUG":130,"MUH":12,"MUI":141,"MUK":373,"MUL":141,"MUN":83,"MUO":79,"MUP":273,"MUQ":279,"MUR":221,"MUS":251,"MUT":86,"MUW":3,"MUX":215,"MUY":10,"MUZ":16,"MVA":267,"MVB":30,"MVC":86,"MVD":138,"MVE":86,"MVF":101,"MVK":270,"MVL":141,"MVN":86,"MVO":42,"MVP":78,"MVQ":310,"MVR":18,"MVS":71,"MVT":375,"MVU":271,"MVV":313,"MVW":123,"MVX":30,"MVY":141,"MVZ":22,"MWA":86,"MWB":279,"MWC":86,"MWD":215,"MWE":26,"MWF":350,"MWH":123,"MWJ":109,"MWK":210,"MWL":86,"MWM":86,"MWN":16,"MWO":141,"MWQ":258,"MWT":270,"MWV":233,"MWX":242,"MWY":280,"MWZ":16,"MXA":86,"MXB":225,"MXD":271,"MXE":141,"MXF":86,"MXH":372,"MXI":226,"MXJ":29,"MXK":372,"MXL":173,"MXM":334,"MXN":313,"MXO":86,"MXP":317,"MXR":300,"MXS":344,"MXT":334,"MXU":279,"MXV":253,"MXX":324,"MXY":52,"MXZ":243,"MYA":280,"MYB":30,"MYC":83,"MYD":41,"MYE":251,"MYF":123,"MYG":140,"MYI":271,"MYJ":251,"MYL":79,"MYM":109,"MYN":181,"MYO":279,"MYP":187,"MYQ":218,"MYR":141,"MYT":258,"MYU":142,"MYV":123,"MYW":16,"MYX":372,"MYY":221,"MYZ":9,"MZA":122,"MZB":36,"MZC":30,"MZG":246,"MZH":297,"MZI":5,"MZJ":147,"MZK":376,"MZL":78,"MZM":313,"MZO":111,"MZP":345,"MZQ":23,"MZR":213,"MZT":130,"MZU":218,"MZV":221,"MZW":3,"MZX":2,"MZY":23,"MZZ":113,"NAA":280,"NAC":270,"NAE":46,"NAG":218,"NAH":225,"NAI":109,"NAJ":191,"NAK":192,"NAL":311,"NAM":211,"NAN":351,"NAO":243,"NAP":317,"NAQ":172,"NAR":78,"NAS":140,"NAT":101,"NAU":375,"NAV":297,"NAW":192,"NAY":243,"NBC":311,"NBE":49,"NBG":86,"NBH":280,"NBJ":32,"NBL":145,"NBO":41,"NBS":243,"NBW":111,"NBX":211,"NCA":104,"NCE":313,"NCG":87,"NCH":16,"NCI":78,"NCJ":58,"NCL":305,"NCN":52,"NCO":141,"NCR":126,"NCS":23,"NCT":89,"NCU":241,"NCY":313,"NDA":210,"NDB":19,"NDC":218,"NDD":32,"NDE":2,"NDG":243,"NDJ":42,"NDL":6,"NDM":2,"NDR":13,"NDS":279,"NDU":50,"NDY":305,"NDZ":285,"NEC":56,"NEF":259,"NEG":117,"NEJ":2,"NEK":2,"NEL":141,"NEN":141,"NER":257,"NEU":255,"NEV":166,"NEW":86,"NFG":259,"NFL":123,"NFR":48,"NGA":280,"NGB":243,"NGD":175,"NGE":18,"NGF":357,"NGI":351,"NGL":23,"NGO":251,"NGP":86,"NGQ":243,"NGS":251,"NGU":141,"NGW":86,"NGX":216,"NHA":206,"NHD":203,"NHF":26,"NHK":141,"NHS":215,"NHT":305,"NHV":363,"NHX":86,"NHZ":141,"NIA":40,"NIB":52,"NIF":279,"NIG":376,"NIM":43,"NIO":28,"NIP":141,"NIS":372,"NIT":313,"NIU":377,"NIX":5,"NJA":251,"NJC":259,"NJF":189,"NJK":123,"NKC":44,"NKG":243,"NKL":28,"NKM":251,"NKS":18,"NKU":37,"NKW":335,"NKX":123,"NKY":10,"NLA":34,"NLC":123,"NLD":129,"NLF":271,"NLG":52,"NLI":256,"NLK":367,"NLL":279,"NLO":28,"NLP":23,"NLS":279,"NLU":134,"NLV":302,"NMA":247,"NMB":218,"NMC":140,"NME":142,"NMF":340,"NMI":218,"NML":97,"NMR":271,"NMS":258,"NMT":258,"NNA":13,"NNB":355,"NNG":243,"NNI":50,"NNL":52,"NNM":311,"NNR":292,"NNT":192,"NNU":161,"NNX":221,"NNY":243,"NOA":280,"NOB":89,"NOC":292,"NOD":285,"NOG":112,"NOJ":259,"NOK":90,"NON":376,"NOP":297,"NOR":267,"NOS":334,"NOT":123,"NOU":368,"NOV":32,"NOZ":229,"NPA":86,"NPE":345,"NPL":345,"NPO":234,"NPR":158,"NPT":141,"NQA":86,"NQI":86,"NQL":161,"NQN":63,"NQT":305,"NQU":78,"NQX":141,"NQY":305,"NQZ":182,"NRA":280,"NRB":141,"NRD":285,"NRE":211,"NRG":279,"NRK":324,"NRL":305,"NRM":5,"NRN":281,"NRR":151,"NRS":123,"NRT":251,"NSE":86,"NSH":249,"NSI":18,"NSK":219,"NSL":86,"NSM":279,"NSN":345,"NSO":280,"NSP":226,"NSR":101,"NST":192,"NSV":271,"NSY":317,"NTB":312,"NTD":123,"NTE":313,"NTG":243,"NTI":211,"NTL":280,"NTN":271,"NTO":264,"NTQ":251,"NTR":137,"NTT":377,"NTU":141,"NTX":210,"NTY":23,"NUB":273,"NUD":26,"NUE":285,"NUI":52,"NUJ":249,"NUK":375,"NUL":52,"NUM":239,"NUN":86,"NUQ":123,"NUR":270,"NUS":350,"NUU":41,"NUW":123,"NUX":259,"NVA":78,"NVD":86,"NVG":126,"NVI":241,"NVK":312,"NVN":123,"NVP":127,"NVS":313,"NVT":161,"NWA":338,"NWI":305,"NYA":259,"NYE":41,"NYG":141,"NYI":1,"NYK":41,"NYM":259,"NYN":280,"NYO":324,"NYR":257,"NYS":141,"NYT":258,"NYU":258,"NYW":258,"NZA":32,"NZC":122,"NZE":14,"NZH":243,"NZL":243,"NZY":123,"OAG":280,"OAH":213,"OAI":213,"OAJ":141,"OAK":123,"OAL":150,"OAM":345,"OAN":171,"OAR":123,"OAS":213,"OAX":134,"OAZ":213,"OBC":17,"OBE":141,"OBF":285,"OBI":158,"OBL":287,"OBN":305,"OBO":251,"OBS":313,"OBU":52,"OCC":108,"OCE":141,"OCF":141,"OCH":86,"OCJ":117,"OCM":279,"OCN":123,"OCV":78,"OCW":141,"ODA":6,"ODB":307,"ODC":123,"ODD":270,"ODE":291,"ODH":305,"ODJ":6,"ODL":270,"ODN":221,"ODO":209,"ODR":279,"ODS":302,"ODW":123,"ODY":255,"OEC":202,"OEL":311,"OEM":146,"OEO":86,"OER":324,"OES":63,"OFF":86,"OFI":0,"OFJ":267,"OFK":86,"OFU":369,"OGA":94,"OGB":141,"OGD":94,"OGE":372,"OGG":357,"OGL":109,"OGN":251,"OGO":0,"OGR":42,"OGS":141,"OGU":297,"OGX":3,"OGZ":311,"OHA":345,"OHB":334,"OHD":322,"OHE":243,"OHH":240,"OHO":256,"OHR":285,"OHS":227,"OHT":215,"OIA":74,"OIC":141,"OIM":251,"OIR":251,"OIT":251,"OIZ":49,"OJC":86,"OKA":251,"OKC":86,"OKD":251,"OKE":251,"OKF":50,"OKH":305,"OKI":251,"OKJ":251,"OKK":113,"OKL":211,"OKM":86,"OKN":30,"OKO":251,"OKQ":211,"OKR":271,"OKS":94,"OKT":259,"OKU":50,"OKY":271,"OLA":312,"OLB":317,"OLC":127,"OLD":141,"OLE":141,"OLF":94,"OLI":267,"OLJ":350,"OLK":69,"OLM":123,"OLN":57,"OLO":315,"OLP":270,"OLS":147,"OLU":86,"OLV":86,"OLY":86,"OLZ":257,"OMA":86,"OMB":30,"OMC":226,"OMD":23,"OME":142,"OMF":183,"OMG":50,"OMH":249,"OMI":249,"OMK":123,"OMM":227,"OMN":247,"OMO":319,"OMR":288,"OMS":231,"ONA":86,"OND":50,"ONG":271,"ONI":211,"ONJ":251,"ONK":257,"ONL":86,"ONM":94,"ONO":79,"ONP":123,"ONQ":297,"ONR":271,"ONS":279,"ONT":123,"ONU":351,"ONX":145,"ONY":86,"OOA":86,"OOD":279,"OOK":142,"OOL":271,"OOM":280,"OOR":271,"OOT":376,"OPA":267,"OPF":141,"OPI":273,"OPL":86,"OPO":303,"OPP":74,"OPS":90,"OPU":372,"OQN":247,"ORA":63,"ORB":324,"ORC":78,"ORD":86,"ORE":313,"ORF":141,"ORG":146,"ORH":141,"ORJ":77,"ORK":292,"ORL":141,"ORM":305,"ORN":3,"ORP":21,"ORR":270,"ORT":52,"ORU":121,"ORV":52,"ORW":215,"ORX":158,"ORY":313,"OSB":189,"OSC":95,"OSD":324,"OSE":372,"OSF":311,"OSH":86,"OSI":332,"OSK":324,"OSL":312,"OSN":242,"OSO":271,"OSR":315,"OSS":195,"OST":287,"OSU":141,"OSW":259,"OSX":86,"OSY":312,"OSZ":331,"OTC":42,"OTG":86,"OTH":123,"OTI":211,"OTJ":50,"OTK":123,"OTL":44,"OTM":86,"OTP":288,"OTR":89,"OTU":78,"OTZ":142,"OUA":45,"OUD":13,"OUE":10,"OUG":45,"OUH":23,"OUI":192,"OUL":295,"OUN":86,"OUR":18,"OUS":161,"OUT":42,"OUZ":44,"OVA":334,"OVB":230,"OVD":307,"OVE":123,"OVG":23,"OVL":159,"OVR":56,"OVS":259,"OWA":86,"OWB":86,"OWD":141,"OWK":141,"OXB":8,"OXC":141,"OXD":141,"OXF":305,"OXP":74,"OXR":123,"OXY":271,"OYA":58,"OYE":30,"OYK":74,"OYL":41,"OYN":278,"OYO":56,"OZA":86,"OZC":226,"OZG":13,"OZH":302,"OZP":307,"OZR":86,"OZZ":13,"PAA":258,"PAB":218,"PAC":145,"PAD":285,"PAE":123,"PAF":25,"PAG":226,"PAH":86,"PAJ":215,"PAK":357,"PAM":86,"PAN":192,"PAO":123,"PAP":148,"PAQ":52,"PAS":283,"PAT":218,"PAU":258,"PAV":71,"PAX":148,"PAY":221,"PAZ":134,"PBA":71,"PBB":81,"PBC":134,"PBD":218,"PBE":78,"PBF":86,"PBG":141,"PBH":250,"PBJ":350,"PBL":83,"PBM":146,"PBN":32,"PBO":279,"PBP":89,"PBQ":150,"PBR":107,"PBU":258,"PBV":90,"PBX":90,"PBZ":23,"PCA":52,"PCB":210,"PCD":86,"PCF":23,"PCG":107,"PCH":171,"PCL":122,"PCN":345,"PCO":130,"PCP":47,"PCQ":255,"PCR":78,"PCS":101,"PDA":78,"PDC":368,"PDD":36,"PDE":270,"PDF":71,"PDG":210,"PDI":372,"PDK":141,"PDL":261,"PDN":270,"PDO":210,"PDP":138,"PDS":129,"PDT":123,"PDU":138,"PDV":323,"PDX":123,"PDZ":83,"PEA":270,"PED":315,"PEE":259,"PEF":285,"PEG":317,"PEH":56,"PEI":78,"PEK":243,"PEL":37,"PEM":122,"PEN":220,"PEQ":86,"PER":279,"PES":311,"PET":161,"PEU":171,"PEV":289,"PEW":215,"PEX":311,"PEZ":311,"PFB":161,"PFC":123,"PFJ":267,"PFN":86,"PFO":228,"PFQ":249,"PFR":33,"PGA":147,"PGC":141,"PGD":141,"PGF":313,"PGH":218,"PGI":32,"PGK":210,"PGL":86,"PGO":94,"PGR":86,"PGU":249,"PGV":141,"PGX":313,"PGZ":161,"PHA":206,"PHB":101,"PHC":29,"PHD":141,"PHE":279,"PHF":141,"PHH":216,"PHI":101,"PHK":141,"PHL":141,"PHN":95,"PHO":142,"PHP":94,"PHQ":271,"PHS":192,"PHT":86,"PHW":23,"PHX":147,"PHY":192,"PIA":86,"PIB":86,"PIC":104,"PID":140,"PIE":141,"PIF":246,"PIH":79,"PIK":305,"PIL":58,"PIM":141,"PIN":127,"PIO":122,"PIP":52,"PIR":86,"PIS":313,"PIT":141,"PIU":122,"PIV":161,"PIW":178,"PIX":261,"PIZ":142,"PJA":324,"PJB":147,"PJC":69,"PJG":215,"PJM":89,"PKA":52,"PKB":141,"PKC":214,"PKD":86,"PKE":280,"PKF":86,"PKG":220,"PKH":283,"PKJ":107,"PKK":258,"PKN":234,"PKO":46,"PKP":375,"PKR":216,"PKT":273,"PKU":210,"PKV":311,"PKW":21,"PKX":243,"PKY":234,"PKZ":255,"PLD":89,"PLF":42,"PLH":305,"PLK":86,"PLL":127,"PLM":210,"PLN":95,"PLO":270,"PLP":145,"PLQ":329,"PLR":86,"PLS":104,"PLT":78,"PLU":161,"PLV":302,"PLW":225,"PLX":182,"PLZ":23,"PMA":16,"PMB":86,"PMC":159,"PMD":123,"PMF":317,"PMG":69,"PMH":141,"PMI":307,"PMK":271,"PML":52,"PMO":317,"PMQ":62,"PMR":345,"PMS":200,"PMV":83,"PMW":55,"PMY":57,"PMZ":89,"PNA":307,"PNB":55,"PNC":86,"PNE":141,"PNG":161,"PNH":233,"PNI":371,"PNK":234,"PNL":317,"PNN":141,"PNP":372,"PNQ":218,"PNR":10,"PNS":86,"PNT":152,"PNV":329,"PNX":86,"PNY":218,"PNZ":154,"POA":161,"POB":141,"POC":123,"POD":15,"POE":86,"POF":86,"POG":30,"POH":86,"POI":121,"POJ":161,"POL":36,"POM":372,"PON":107,"POO":161,"POP":160,"POR":295,"POS":149,"POT":117,"POU":141,"POV":286,"POW":304,"POX":313,"POY":94,"POZ":331,"PPA":86,"PPB":161,"PPC":52,"PPE":112,"PPF":86,"PPG":369,"PPH":83,"PPI":270,"PPJ":210,"PPK":182,"PPL":216,"PPM":141,"PPN":78,"PPP":271,"PPQ":345,"PPR":210,"PPS":226,"PPT":375,"PPU":258,"PPW":305,"PPY":161,"PQC":206,"PQE":78,"PQI":141,"PQM":134,"PQQ":280,"PRA":58,"PRB":123,"PRC":147,"PRD":279,"PRG":315,"PRH":192,"PRI":339,"PRK":23,"PRM":303,"PRN":284,"PRO":86,"PRP":313,"PRQ":58,"PRR":109,"PRS":355,"PRU":258,"PRV":315,"PRX":86,"PRY":23,"PSA":317,"PSB":141,"PSC":123,"PSD":12,"PSE":151,"PSF":141,"PSG":163,"PSH":285,"PSI":215,"PSJ":225,"PSK":141,"PSL":305,"PSM":141,"PSN":86,"PSO":78,"PSP":123,"PSR":317,"PSS":58,"PSU":234,"PSW":161,"PSX":86,"PSY":269,"PSZ":121,"PTA":52,"PTB":141,"PTF":351,"PTG":23,"PTH":52,"PTJ":278,"PTK":95,"PTM":83,"PTN":86,"PTO":161,"PTP":106,"PTQ":74,"PTS":86,"PTT":86,"PTU":52,"PTV":123,"PTW":141,"PTX":78,"PTY":145,"PTZ":108,"PUB":94,"PUC":94,"PUD":62,"PUE":145,"PUF":313,"PUG":270,"PUJ":160,"PUK":375,"PUN":33,"PUP":45,"PUQ":152,"PUR":121,"PUS":242,"PUU":78,"PUV":368,"PUW":123,"PUX":159,"PUY":332,"PUZ":126,"PVA":78,"PVC":141,"PVD":141,"PVE":145,"PVF":123,"PVG":243,"PVH":150,"PVI":161,"PVK":283,"PVL":141,"PVN":323,"PVO":108,"PVR":72,"PVS":184,"PVU":94,"PVW":86,"PWA":86,"PWD":94,"PWE":184,"PWK":86,"PWM":141,"PWN":140,"PWO":33,"PWQ":182,"PWT":123,"PWY":94,"PXM":134,"PXO":266,"PXR":192,"PXU":206,"PYA":78,"PYB":218,"PYE":373,"PYG":218,"PYH":78,"PYJ":257,"PYK":249,"PYM":141,"PYO":108,"PYR":283,"PYY":192,"PZA":78,"PZB":23,"PZH":215,"PZI":243,"PZL":23,"PZO":83,"PZU":26,"PZY":286,"QAC":161,"QAK":161,"QAM":313,"QAQ":317,"QAR":281,"QBC":176,"QBQ":313,"QBX":101,"QCB":285,"QCH":161,"QCJ":161,"QCN":285,"QCO":111,"QCP":101,"QCR":161,"QCY":305,"QDB":161,"QDC":161,"QDF":161,"QDJ":3,"QEF":285,"QEO":331,"QFD":3,"QFO":305,"QFR":317,"QGA":81,"QGB":161,"QGC":161,"QGF":161,"QGP":154,"QGS":71,"QGU":251,"QGY":289,"QHA":287,"QHB":161,"QHN":55,"QHP":161,"QHR":2,"QHU":285,"QHV":161,"QID":161,"QIE":313,"QIG":101,"QIQ":161,"QIT":71,"QJB":239,"QJD":280,"QKX":312,"QLA":305,"QLC":331,"QLD":3,"QLF":295,"QLS":333,"QLT":317,"QLU":331,"QMF":161,"QMJ":249,"QMM":317,"QMZ":285,"QNC":333,"QND":284,"QNJ":313,"QNM":287,"QNS":161,"QNV":161,"QNX":313,"QOA":161,"QOB":285,"QOE":285,"QOJ":161,"QOW":29,"QPA":317,"QPD":111,"QPG":244,"QPH":21,"QPM":331,"QPS":161,"QPZ":317,"QRA":23,"QRC":159,"QRM":280,"QRO":134,"QRR":280,"QRT":317,"QRZ":161,"QSA":307,"QSC":161,"QSF":3,"QSI":16,"QSN":111,"QSR":317,"QSX":109,"QTJ":313,"QUG":305,"QUN":242,"QUS":29,"QUT":251,"QUY":305,"QVA":317,"QVB":161,"QVE":295,"QVP":161,"QWS":331,"QWV":284,"QXB":313,"QXC":125,"QYD":331,"QYR":313,"QYY":331,"QZD":289,"QZN":3,"QZO":317,"RAB":372,"RAC":86,"RAE":239,"RAF":58,"RAG":345,"RAH":239,"RAI":264,"RAJ":218,"RAK":13,"RAL":123,"RAM":273,"RAN":317,"RAO":161,"RAP":94,"RAR":373,"RAS":249,"RAV":78,"RAZ":215,"RBA":13,"RBB":127,"RBC":278,"RBD":86,"RBE":233,"RBG":123,"RBJ":251,"RBL":123,"RBM":285,"RBO":121,"RBQ":121,"RBR":157,"RBS":278,"RBT":41,"RBU":279,"RBV":355,"RBW":141,"RBX":24,"RBY":52,"RCA":94,"RCB":23,"RCH":78,"RCK":86,"RCL":350,"RCM":271,"RCO":313,"RCQ":58,"RCR":113,"RCS":305,"RCT":95,"RCU":58,"RCY":140,"RDB":142,"RDC":74,"RDD":123,"RDE":211,"RDG":141,"RDM":123,"RDN":220,"RDO":331,"RDR":86,"RDS":61,"RDT":15,"RDU":141,"RDZ":313,"REA":375,"REB":285,"REC":154,"RED":141,"REG":317,"REI":84,"REL":57,"REN":259,"REO":79,"REP":233,"REQ":122,"RER":107,"RES":58,"RET":312,"REU":307,"REX":129,"REY":121,"RFA":6,"RFD":86,"RFG":86,"RFN":267,"RFP":375,"RFR":89,"RFS":126,"RGA":67,"RGH":218,"RGI":375,"RGK":193,"RGL":62,"RGN":258,"RGO":235,"RGS":307,"RGT":210,"RHA":267,"RHD":58,"RHE":313,"RHG":27,"RHI":86,"RHL":279,"RHN":50,"RHO":283,"RHP":216,"RHT":243,"RHV":123,"RIA":161,"RIB":121,"RIC":141,"RID":113,"RIE":86,"RIF":94,"RIG":161,"RIH":145,"RIJ":122,"RIK":89,"RIL":94,"RIM":122,"RIN":355,"RIR":123,"RIS":251,"RIV":123,"RIW":94,"RIX":316,"RIY":181,"RJA":218,"RJB":216,"RJH":201,"RJK":332,"RJL":307,"RJN":249,"RKA":375,"RKC":123,"RKD":141,"RKE":291,"RKH":141,"RKO":210,"RKP":86,"RKR":86,"RKS":94,"RKT":203,"RKV":267,"RKW":86,"RLD":123,"RLG":285,"RLK":243,"RLO":65,"RLT":43,"RMA":271,"RMB":203,"RME":141,"RMF":12,"RMG":141,"RMI":317,"RMK":270,"RML":199,"RMN":372,"RMO":290,"RMQ":246,"RMS":285,"RMU":307,"RMY":123,"RNA":355,"RNB":324,"RNC":86,"RND":86,"RNE":313,"RNH":86,"RNI":126,"RNJ":251,"RNL":355,"RNM":227,"RNN":291,"RNO":123,"RNS":313,"RNT":123,"RNU":221,"RNZ":86,"ROA":141,"ROB":40,"ROC":141,"ROD":23,"ROG":86,"ROH":271,"ROI":192,"ROK":271,"RON":78,"ROO":90,"ROP":374,"ROR":370,"ROS":58,"ROT":345,"ROV":311,"ROW":94,"ROX":86,"ROY":57,"ROZ":307,"RPA":216,"RPB":273,"RPM":273,"RPN":212,"RPR":218,"RPX":94,"RQA":243,"RQO":86,"RQW":189,"RQY":218,"RRE":270,"RRG":341,"RRK":218,"RRL":86,"RRR":375,"RRS":312,"RRT":86,"RSA":63,"RSB":271,"RSD":140,"RSH":52,"RSI":239,"RSK":211,"RSL":86,"RSN":86,"RSS":26,"RST":86,"RSU":242,"RSW":141,"RTA":351,"RTB":171,"RTC":218,"RTE":161,"RTG":225,"RTM":281,"RTN":94,"RTP":271,"RTS":279,"RTU":225,"RTW":320,"RTY":270,"RUA":25,"RUD":249,"RUE":33,"RUG":243,"RUH":239,"RUI":94,"RUK":216,"RUL":340,"RUM":216,"RUN":343,"RUP":218,"RUR":375,"RUS":355,"RUT":141,"RUV":107,"RUY":171,"RVA":334,"RVD":161,"RVE":78,"RVI":311,"RVK":312,"RVN":295,"RVO":23,"RVR":94,"RVS":86,"RVT":279,"RVV":375,"RVY":138,"RWF":86,"RWI":141,"RWL":94,"RWN":302,"RXE":79,"RXS":226,"RYB":311,"RYG":312,"RYK":215,"RYN":313,"RYO":62,"RZA":62,"RZE":331,"RZN":311,"RZP":226,"RZR":249,"RZV":297,"RZZ":141,"SAA":94,"SAB":120,"SAC":123,"SAD":147,"SAF":94,"SAG":218,"SAH":181,"SAI":233,"SAK":267,"SAL":99,"SAN":123,"SAP":171,"SAQ":140,"SAR":86,"SAS":123,"SAT":86,"SAU":225,"SAV":141,"SAW":297,"SAY":317,"SAZ":40,"SBA":123,"SBB":83,"SBD":123,"SBE":372,"SBG":210,"SBH":164,"SBI":14,"SBJ":161,"SBK":313,"SBL":121,"SBM":86,"SBN":113,"SBP":123,"SBQ":215,"SBR":271,"SBS":94,"SBT":259,"SBU":23,"SBW":221,"SBX":94,"SBY":141,"SBZ":288,"SCB":86,"SCC":52,"SCD":171,"SCE":141,"SCF":147,"SCG":271,"SCH":141,"SCI":83,"SCK":123,"SCL":159,"SCM":142,"SCN":285,"SCO":185,"SCP":313,"SCQ":307,"SCR":324,"SCS":305,"SCT":181,"SCU":111,"SCV":288,"SCW":311,"SCY":353,"SCZ":355,"SDB":23,"SDD":32,"SDE":58,"SDF":119,"SDG":249,"SDH":171,"SDJ":251,"SDK":221,"SDL":324,"SDM":123,"SDN":312,"SDP":52,"SDQ":160,"SDR":307,"SDS":251,"SDT":215,"SDU":161,"SDV":212,"SDX":147,"SDY":94,"SEA":123,"SEB":48,"SEE":123,"SEF":141,"SEG":141,"SEH":211,"SEM":86,"SEN":305,"SEO":0,"SEP":86,"SEQ":210,"SER":113,"SEU":16,"SEV":302,"SEW":12,"SEY":44,"SEZ":339,"SFA":49,"SFB":141,"SFC":106,"SFD":83,"SFE":226,"SFF":123,"SFG":124,"SFH":173,"SFI":13,"SFJ":144,"SFK":74,"SFL":264,"SFM":141,"SFN":58,"SFO":123,"SFQ":297,"SFS":226,"SFT":324,"SFZ":141,"SGA":204,"SGC":259,"SGD":291,"SGE":285,"SGF":86,"SGG":221,"SGH":141,"SGI":215,"SGN":206,"SGO":271,"SGP":279,"SGQ":225,"SGR":86,"SGS":226,"SGT":86,"SGU":94,"SGV":63,"SGX":16,"SGY":118,"SGZ":192,"SHA":243,"SHB":251,"SHC":2,"SHD":141,"SHE":243,"SHG":52,"SHH":142,"SHI":251,"SHJ":203,"SHK":37,"SHL":218,"SHM":251,"SHN":123,"SHO":38,"SHP":243,"SHQ":271,"SHR":94,"SHS":243,"SHT":278,"SHU":273,"SHV":86,"SHW":239,"SHX":52,"SHY":16,"SHZ":37,"SIA":243,"SIB":10,"SID":264,"SIE":303,"SIF":216,"SIG":151,"SIH":216,"SII":13,"SIJ":267,"SIK":86,"SIL":372,"SIM":372,"SIN":244,"SIO":275,"SIP":321,"SIQ":210,"SIR":333,"SIS":23,"SIT":163,"SIU":126,"SIV":113,"SIW":210,"SIX":280,"SIY":123,"SJA":122,"SJB":121,"SJC":123,"SJD":130,"SJE":78,"SJI":226,"SJJ":319,"SJK":161,"SJL":127,"SJN":147,"SJO":89,"SJP":161,"SJQ":34,"SJS":121,"SJT":86,"SJU":151,"SJV":121,"SJW":243,"SJY":295,"SJZ":261,"SKA":123,"SKB":166,"SKC":372,"SKD":241,"SKE":312,"SKF":86,"SKG":283,"SKH":216,"SKK":52,"SKL":305,"SKN":312,"SKO":29,"SKP":322,"SKQ":37,"SKS":291,"SKT":215,"SKU":283,"SKV":12,"SKW":52,"SKX":311,"SKZ":215,"SLA":63,"SLB":86,"SLC":94,"SLD":286,"SLE":123,"SLF":239,"SLG":86,"SLH":350,"SLI":34,"SLJ":279,"SLK":141,"SLL":227,"SLM":307,"SLN":86,"SLO":86,"SLP":134,"SLQ":52,"SLR":86,"SLS":323,"SLT":94,"SLU":167,"SLV":218,"SLW":137,"SLX":104,"SLY":259,"SLZ":101,"SMA":261,"SMB":152,"SMD":113,"SME":141,"SMF":123,"SMG":122,"SMI":283,"SMK":142,"SML":140,"SMM":221,"SMN":79,"SMO":123,"SMQ":234,"SMR":78,"SMS":334,"SMU":52,"SMV":333,"SMW":19,"SMX":123,"SMY":15,"SMZ":146,"SNA":123,"SNB":273,"SNC":108,"SNE":264,"SNF":83,"SNG":121,"SNH":271,"SNI":40,"SNJ":111,"SNK":86,"SNL":86,"SNM":121,"SNN":292,"SNO":192,"SNP":142,"SNR":313,"SNS":123,"SNU":111,"SNV":83,"SNW":258,"SNX":249,"SNY":94,"SNZ":161,"SOB":289,"SOC":210,"SOD":161,"SOE":10,"SOF":323,"SOG":312,"SOJ":312,"SOK":37,"SOM":83,"SON":350,"SOO":324,"SOP":141,"SOQ":211,"SOT":295,"SOU":305,"SOV":52,"SOW":147,"SOX":78,"SOY":305,"SOZ":313,"SPA":141,"SPC":263,"SPD":201,"SPE":221,"SPF":94,"SPG":141,"SPI":86,"SPJ":283,"SPM":285,"SPN":374,"SPP":32,"SPS":86,"SPU":332,"SPW":86,"SPX":12,"SPY":0,"SPZ":86,"SQA":123,"SQC":279,"SQD":122,"SQG":234,"SQH":192,"SQI":86,"SQJ":243,"SQL":123,"SQM":161,"SQN":211,"SQO":324,"SQQ":329,"SQR":225,"SQU":122,"SQW":291,"SQX":161,"SQY":161,"SQZ":305,"SRA":161,"SRB":121,"SRC":86,"SRD":121,"SRE":121,"SRG":210,"SRH":42,"SRJ":121,"SRN":275,"SRP":312,"SRQ":141,"SRT":25,"SRW":141,"SRX":48,"SRY":249,"SRZ":121,"SSA":71,"SSC":141,"SSD":159,"SSE":218,"SSF":86,"SSG":35,"SSH":12,"SSI":141,"SSJ":312,"SSN":242,"SSO":161,"SSR":350,"SST":56,"SSX":297,"SSY":32,"SSZ":161,"STA":291,"STB":83,"STC":86,"STD":83,"STE":86,"STG":142,"STH":271,"STI":160,"STJ":86,"STK":94,"STL":86,"STM":158,"STN":305,"STP":86,"STQ":141,"STR":285,"STS":123,"STT":168,"STV":218,"STW":311,"STX":168,"STY":138,"STZ":55,"SUA":141,"SUB":210,"SUD":86,"SUE":86,"SUF":317,"SUG":226,"SUH":227,"SUI":248,"SUJ":288,"SUL":215,"SUM":141,"SUN":79,"SUO":123,"SUP":210,"SUQ":108,"SUR":174,"SUS":86,"SUT":16,"SUU":123,"SUV":351,"SUW":86,"SUX":86,"SUY":257,"SVA":142,"SVB":334,"SVC":94,"SVD":169,"SVE":123,"SVF":46,"SVG":312,"SVH":141,"SVI":78,"SVJ":312,"SVL":295,"SVN":141,"SVO":311,"SVP":32,"SVQ":307,"SVT":21,"SVU":351,"SVW":52,"SVX":259,"SVZ":78,"SWA":243,"SWC":278,"SWD":52,"SWF":141,"SWH":278,"SWJ":350,"SWN":215,"SWO":86,"SWP":50,"SWQ":225,"SWS":305,"SWT":252,"SWU":242,"SWV":224,"SWW":86,"SWX":21,"SWY":220,"SXB":313,"SXE":278,"SXG":34,"SXI":249,"SXJ":243,"SXK":211,"SXL":292,"SXM":124,"SXN":21,"SXO":90,"SXQ":52,"SXR":218,"SXS":221,"SXT":220,"SXU":2,"SXV":218,"SXX":74,"SXZ":297,"SYA":51,"SYC":122,"SYD":280,"SYE":181,"SYI":86,"SYJ":249,"SYK":267,"SYM":243,"SYN":86,"SYO":251,"SYP":145,"SYQ":89,"SYR":141,"SYS":257,"SYT":313,"SYU":271,"SYV":141,"SYW":215,"SYX":243,"SYY":305,"SYZ":249,"SZA":32,"SZB":220,"SZE":2,"SZF":297,"SZG":285,"SZH":243,"SZJ":111,"SZK":23,"SZL":86,"SZM":50,"SZN":123,"SZP":123,"SZR":323,"SZS":345,"SZT":134,"SZV":243,"SZW":285,"SZX":243,"SZY":331,"SZZ":331,"TAB":149,"TAC":226,"TAD":94,"TAE":242,"TAF":3,"TAG":226,"TAH":350,"TAI":181,"TAJ":372,"TAK":251,"TAL":52,"TAM":137,"TAN":271,"TAO":243,"TAP":134,"TAQ":270,"TAR":317,"TAS":247,"TAT":286,"TAU":78,"TAW":138,"TAX":211,"TAY":325,"TAZ":187,"TBB":206,"TBF":376,"TBG":372,"TBH":226,"TBI":140,"TBJ":49,"TBK":273,"TBL":279,"TBN":86,"TBO":16,"TBP":122,"TBR":141,"TBS":248,"TBT":78,"TBU":377,"TBW":311,"TBY":21,"TBZ":249,"TCA":273,"TCB":140,"TCC":94,"TCE":288,"TCG":243,"TCH":30,"TCL":86,"TCM":123,"TCN":134,"TCO":78,"TCP":12,"TCQ":122,"TCR":218,"TCS":94,"TCU":23,"TCW":280,"TCX":249,"TCZ":243,"TDA":78,"TDD":121,"TDG":226,"TDJ":17,"TDK":182,"TDL":56,"TDN":279,"TDO":123,"TDP":122,"TDR":271,"TDS":372,"TDT":23,"TDV":334,"TDW":86,"TDX":192,"TDZ":141,"TEA":171,"TEB":141,"TEC":161,"TED":291,"TEE":3,"TEF":279,"TEG":45,"TEI":218,"TEL":221,"TEM":280,"TEN":243,"TEQ":297,"TER":261,"TES":4,"TET":36,"TEU":345,"TEV":307,"TEX":94,"TEY":267,"TEZ":218,"TFF":127,"TFI":372,"TFL":161,"TFM":372,"TFN":263,"TFS":263,"TFT":215,"TFU":243,"TGA":244,"TGC":221,"TGD":314,"TGG":220,"TGH":350,"TGI":122,"TGJ":368,"TGK":311,"TGM":288,"TGN":278,"TGO":243,"TGP":219,"TGQ":90,"TGR":3,"TGT":16,"TGU":171,"TGV":323,"TGZ":134,"THA":86,"THB":37,"THC":40,"THD":192,"THE":101,"THF":285,"THG":271,"THH":345,"THI":44,"THK":192,"THL":258,"THM":94,"THN":324,"THO":267,"THQ":243,"THR":249,"THS":192,"THT":44,"THU":172,"THV":141,"THX":219,"THY":23,"THZ":43,"TIA":326,"TIB":78,"TID":3,"TIE":2,"TIF":239,"TIH":375,"TII":213,"TIJ":123,"TIK":86,"TIM":211,"TIN":3,"TIO":258,"TIP":48,"TIQ":374,"TIR":218,"TIU":345,"TIV":314,"TIW":123,"TIX":141,"TIY":44,"TIZ":372,"TJA":121,"TJB":210,"TJG":225,"TJH":251,"TJI":171,"TJK":297,"TJL":81,"TJM":259,"TJN":375,"TJQ":210,"TJS":225,"TJU":204,"TJV":218,"TKA":52,"TKC":18,"TKD":1,"TKF":123,"TKG":210,"TKH":192,"TKJ":52,"TKK":348,"TKN":251,"TKO":37,"TKP":375,"TKQ":16,"TKR":201,"TKS":251,"TKT":192,"TKU":295,"TKV":375,"TKW":372,"TKX":375,"TKY":279,"TKZ":345,"TLA":142,"TLB":215,"TLC":134,"TLD":21,"TLE":334,"TLH":141,"TLI":225,"TLJ":52,"TLK":257,"TLL":325,"TLM":3,"TLN":313,"TLQ":243,"TLR":123,"TLS":313,"TLU":78,"TLV":212,"TLX":159,"TLY":256,"TLZ":161,"TMA":141,"TMB":141,"TMC":225,"TMD":44,"TME":78,"TMF":340,"TMG":221,"TMH":211,"TMI":216,"TMJ":241,"TML":1,"TMM":334,"TMN":376,"TMO":83,"TMP":295,"TMQ":45,"TMR":3,"TMS":47,"TMT":158,"TMU":89,"TMW":280,"TMX":3,"TMZ":345,"TNA":243,"TNB":225,"TNC":142,"TND":111,"TNE":251,"TNF":313,"TNG":13,"TNH":243,"TNI":218,"TNJ":210,"TNL":302,"TNM":152,"TNN":246,"TNO":89,"TNP":123,"TNR":334,"TNT":141,"TNU":86,"TNV":359,"TNX":233,"TNZ":253,"TOA":123,"TOB":48,"TOC":141,"TOD":220,"TOE":49,"TOF":252,"TOG":52,"TOH":350,"TOI":86,"TOJ":307,"TOL":141,"TOM":5,"TOO":89,"TOP":86,"TOQ":159,"TOR":94,"TOS":312,"TOT":146,"TOU":368,"TOW":161,"TOX":259,"TOY":251,"TPA":141,"TPC":108,"TPE":246,"TPF":141,"TPG":220,"TPH":123,"TPI":372,"TPJ":216,"TPK":210,"TPL":86,"TPN":108,"TPP":122,"TPQ":130,"TPR":279,"TPS":317,"TPU":216,"TQD":189,"TQL":259,"TQN":213,"TQO":82,"TQP":271,"TQQ":225,"TQS":78,"TRA":251,"TRB":78,"TRC":137,"TRD":312,"TRE":305,"TRF":312,"TRG":345,"TRI":141,"TRK":225,"TRL":86,"TRM":123,"TRN":317,"TRO":280,"TRQ":157,"TRR":199,"TRS":317,"TRU":122,"TRV":218,"TRW":376,"TRX":86,"TRY":25,"TRZ":218,"TSA":246,"TSB":50,"TSC":108,"TSF":317,"TSH":33,"TSJ":251,"TSL":134,"TSM":94,"TSN":243,"TSP":123,"TSQ":161,"TSR":288,"TST":192,"TSU":376,"TSV":271,"TSX":225,"TSY":210,"TTA":13,"TTB":317,"TTC":159,"TTD":123,"TTE":211,"TTG":63,"TTH":227,"TTI":375,"TTJ":251,"TTN":141,"TTO":86,"TTQ":89,"TTR":225,"TTS":334,"TTT":246,"TTU":13,"TTX":279,"TUA":108,"TUB":375,"TUC":66,"TUD":15,"TUF":313,"TUG":226,"TUI":239,"TUJ":2,"TUK":215,"TUL":86,"TUM":280,"TUN":49,"TUO":345,"TUP":86,"TUQ":45,"TUR":74,"TUS":147,"TUU":239,"TUV":83,"TVA":334,"TVC":95,"TVF":86,"TVI":141,"TVL":123,"TVS":243,"TVU":351,"TVY":258,"TWB":271,"TWC":243,"TWE":142,"TWF":79,"TWU":221,"TWZ":345,"TXA":134,"TXF":71,"TXG":246,"TXK":86,"TXL":285,"TXM":211,"TXN":243,"TXU":0,"TYB":280,"TYD":257,"TYF":324,"TYG":271,"TYL":122,"TYM":140,"TYN":243,"TYP":273,"TYR":86,"TYS":141,"TYT":138,"TYZ":147,"TZC":95,"TZL":319,"TZR":289,"TZX":297,"UAB":297,"UAH":363,"UAI":202,"UAK":144,"UAL":32,"UAM":356,"UAP":363,"UAQ":64,"UAR":13,"UAS":41,"UAX":107,"UBA":161,"UBB":271,"UBJ":251,"UBN":253,"UBP":192,"UBR":211,"UBS":86,"UBT":161,"UBU":279,"UCB":243,"UCK":302,"UCN":40,"UCT":311,"UCY":86,"UCZ":122,"UDA":271,"UDD":123,"UDE":281,"UDI":161,"UDJ":286,"UDN":317,"UDR":218,"UEE":275,"UEL":36,"UEN":259,"UEO":251,"UES":86,"UET":215,"UFA":259,"UGA":253,"UGC":241,"UGN":86,"UGO":32,"UGT":253,"UHE":315,"UIB":78,"UIH":206,"UII":171,"UIK":209,"UIL":123,"UIN":86,"UIO":108,"UIP":313,"UIQ":350,"UIR":280,"UJE":362,"UKA":41,"UKB":251,"UKG":256,"UKI":123,"UKK":182,"UKS":321,"UKT":141,"UKU":372,"UKX":209,"ULA":62,"ULB":350,"ULD":23,"ULG":208,"ULH":239,"ULK":257,"ULM":86,"ULN":253,"ULO":208,"ULP":271,"ULQ":78,"ULU":25,"ULV":327,"ULX":23,"ULY":327,"UMA":111,"UME":324,"UMI":122,"UMM":52,"UMR":270,"UMS":217,"UMT":52,"UMU":161,"UMY":302,"UNA":71,"UND":213,"UNE":23,"UNG":372,"UNI":169,"UNK":52,"UNN":192,"UNT":305,"UNU":86,"UOA":375,"UOL":225,"UOS":86,"UOX":86,"UPB":111,"UPG":225,"UPL":89,"UPN":134,"UPP":357,"UPV":305,"URA":232,"URC":243,"URD":285,"URE":325,"URG":161,"URJ":259,"URM":83,"URO":313,"URR":78,"URS":311,"URT":192,"URY":239,"USA":141,"USH":67,"USI":109,"USJ":182,"USK":311,"USL":279,"USM":192,"USN":242,"USQ":297,"USR":254,"USS":111,"UST":141,"USU":226,"UTA":22,"UTB":271,"UTE":23,"UTG":37,"UTH":192,"UTI":295,"UTM":86,"UTN":23,"UTO":52,"UTP":192,"UTR":192,"UTS":311,"UTT":23,"UTW":23,"UUA":311,"UUD":209,"UUK":52,"UUN":198,"UUS":240,"UVA":86,"UVE":368,"UVF":167,"UVL":12,"UYL":26,"UYN":243,"UYU":121,"UZC":284,"UZU":58,"VAA":295,"VAC":285,"VAD":141,"VAF":313,"VAG":161,"VAH":121,"VAI":372,"VAK":142,"VAL":71,"VAM":340,"VAN":297,"VAO":355,"VAP":159,"VAR":323,"VAS":297,"VAT":334,"VAU":351,"VAV":377,"VAW":312,"VBA":258,"VBC":258,"VBG":123,"VBP":258,"VBS":317,"VBV":351,"VBY":324,"VCA":206,"VCD":273,"VCE":317,"VCH":138,"VCL":206,"VCP":161,"VCR":83,"VCS":206,"VCT":86,"VCV":123,"VDA":212,"VDB":312,"VDC":71,"VDE":263,"VDH":192,"VDI":141,"VDM":63,"VDO":192,"VDP":83,"VDR":58,"VDS":312,"VDY":218,"VDZ":52,"VEE":52,"VEL":94,"VER":134,"VEV":355,"VEY":267,"VFA":22,"VGA":218,"VGD":311,"VGO":307,"VGT":123,"VGZ":78,"VHC":32,"VHM":324,"VHN":86,"VHV":257,"VHY":313,"VHZ":375,"VIA":161,"VIC":317,"VID":323,"VIE":328,"VIG":83,"VIH":86,"VII":192,"VIJ":175,"VIL":19,"VIN":302,"VIP":333,"VIQ":202,"VIR":23,"VIS":123,"VIT":307,"VIX":161,"VIY":313,"VJB":36,"VJI":141,"VKG":206,"VKO":311,"VKS":86,"VKT":311,"VKV":311,"VKX":141,"VLA":86,"VLC":307,"VLD":141,"VLG":56,"VLI":350,"VLK":311,"VLL":307,"VLM":121,"VLN":83,"VLP":90,"VLR":159,"VLS":350,"VLU":311,"VLV":83,"VME":65,"VMU":372,"VNA":255,"VNC":141,"VND":334,"VNE":313,"VNO":329,"VNR":271,"VNS":218,"VNT":316,"VNX":36,"VNY":123,"VOD":315,"VOG":330,"VOH":334,"VOI":40,"VOK":86,"VOL":283,"VOT":161,"VOZ":311,"VPE":32,"VPN":267,"VPS":86,"VPY":36,"VPZ":86,"VQQ":141,"VQS":151,"VRA":111,"VRB":141,"VRC":226,"VRE":23,"VRI":311,"VRK":295,"VRL":303,"VRN":317,"VRO":111,"VRU":23,"VRZ":161,"VSA":134,"VSE":303,"VSF":141,"VSG":302,"VSK":123,"VST":324,"VSV":218,"VTB":310,"VTE":255,"VTF":351,"VTG":206,"VTL":313,"VTM":212,"VTN":86,"VTU":111,"VTZ":218,"VUP":78,"VUS":311,"VVB":334,"VVC":78,"VVI":121,"VVK":324,"VVO":256,"VVZ":3,"VXC":36,"VXE":264,"VXO":324,"VYD":23,"VYI":257,"VYS":86,"WAA":142,"WAC":2,"WAE":239,"WAF":215,"WAG":345,"WAH":86,"WAI":334,"WAK":334,"WAL":141,"WAM":334,"WAO":372,"WAP":159,"WAQ":334,"WAR":211,"WAT":292,"WAV":273,"WAW":331,"WAX":48,"WAY":141,"WAZ":271,"WBA":211,"WBD":334,"WBG":285,"WBM":372,"WBO":334,"WBQ":52,"WBR":95,"WBU":94,"WBW":141,"WCA":159,"WCH":159,"WCR":52,"WDG":86,"WDH":50,"WDI":271,"WDR":141,"WEF":243,"WEH":243,"WEI":271,"WEL":23,"WET":211,"WEW":280,"WEX":292,"WFI":334,"WFK":141,"WGA":280,"WGB":215,"WGC":218,"WGE":280,"WGO":141,"WGP":225,"WGT":278,"WHA":243,"WHF":26,"WHK":345,"WHO":345,"WHP":123,"WHS":305,"WHT":86,"WHU":243,"WIC":305,"WIE":285,"WIK":345,"WIL":41,"WIN":271,"WIO":280,"WIR":345,"WIT":279,"WIX":134,"WJF":123,"WJR":41,"WJU":242,"WKA":345,"WKB":278,"WKF":23,"WKI":22,"WKJ":251,"WKR":140,"WLA":279,"WLC":280,"WLD":86,"WLE":271,"WLG":345,"WLH":350,"WLK":52,"WLL":273,"WLO":273,"WLP":279,"WLS":379,"WLW":123,"WMA":334,"WMB":278,"WMC":123,"WMD":334,"WME":279,"WMH":86,"WMI":331,"WML":334,"WMN":334,"WMO":142,"WMP":334,"WMR":334,"WMT":243,"WMX":211,"WNA":52,"WND":279,"WNN":178,"WNP":226,"WNR":271,"WNS":215,"WNZ":243,"WOA":372,"WOE":281,"WOL":280,"WON":271,"WOT":246,"WOW":52,"WPA":159,"WPB":334,"WPC":97,"WPK":271,"WPR":152,"WPU":67,"WRB":141,"WRE":345,"WRG":163,"WRI":141,"WRL":94,"WRO":331,"WRT":305,"WRW":279,"WRY":305,"WRZ":199,"WSD":94,"WSF":142,"WSG":141,"WSH":141,"WSI":280,"WSK":243,"WSN":52,"WSO":109,"WSP":126,"WSR":211,"WST":141,"WSU":372,"WSY":271,"WSZ":345,"WTA":334,"WTB":271,"WTD":140,"WTK":142,"WTN":305,"WTP":372,"WTS":334,"WTZ":345,"WUA":243,"WUD":270,"WUG":372,"WUH":243,"WUI":279,"WUN":279,"WUS":243,"WUU":24,"WUX":243,"WUZ":243,"WVB":50,"WVI":123,"WVK":334,"WVL":141,"WVN":285,"WWD":141,"WWI":279,"WWK":372,"WWR":86,"WWT":142,"WWY":280,"WXF":305,"WXN":243,"WYA":270,"WYE":20,"WYN":279,"WYS":94,"XAB":313,"XAC":313,"XAI":243,"XAN":313,"XAP":161,"XAR":45,"XAS":313,"XAU":84,"XAV":313,"XBE":178,"XBG":45,"XBJ":249,"XBK":313,"XBO":45,"XBQ":313,"XBV":313,"XCB":313,"XCD":313,"XCH":336,"XCM":174,"XCO":278,"XCP":313,"XCR":313,"XCX":313,"XCZ":313,"XDA":313,"XDE":45,"XDJ":45,"XDK":287,"XEN":243,"XFN":243,"XFW":285,"XGA":45,"XGG":45,"XGN":32,"XGR":174,"XGT":313,"XIC":243,"XIJ":222,"XIL":243,"XIN":243,"XIY":243,"XJM":215,"XKA":45,"XKH":255,"XKS":178,"XKY":45,"XLN":313,"XLR":313,"XLS":15,"XLU":45,"XLW":285,"XMC":278,"XMD":86,"XME":313,"XMF":313,"XMG":216,"XMH":375,"XMI":16,"XMJ":313,"XMK":313,"XML":270,"XMN":243,"XMS":108,"XMU":313,"XMW":313,"XMY":271,"XNA":86,"XNN":243,"XNU":45,"XOG":313,"XPA":45,"XPL":171,"XPR":94,"XQP":89,"XRH":280,"XRY":307,"XSB":203,"XSC":104,"XSD":123,"XSE":45,"XSI":178,"XSJ":313,"XSL":313,"XSP":220,"XSU":313,"XTB":313,"XTG":271,"XTL":178,"XTO":271,"XTR":271,"XUZ":243,"XVF":313,"XVN":313,"XVO":313,"XVS":313,"XWA":86,"XWP":324,"XXN":239,"XYA":355,"XYE":258,"XYR":372,"XZA":45,"YAA":176,"YAB":153,"YAC":178,"YAG":178,"YAH":174,"YAI":159,"YAK":179,"YAL":176,"YAM":95,"YAN":33,"YAO":18,"YAP":348,"YAR":174,"YAS":351,"YAT":174,"YAU":174,"YAV":178,"YAW":110,"YAX":178,"YAY":165,"YAZ":176,"YBA":97,"YBB":80,"YBC":174,"YBE":170,"YBG":174,"YBI":165,"YBK":153,"YBL":176,"YBO":176,"YBP":243,"YBR":178,"YBT":178,"YBV":178,"YBW":97,"YBX":76,"YBY":97,"YCB":80,"YCC":174,"YCD":176,"YCE":174,"YCG":176,"YCH":136,"YCK":115,"YCL":136,"YCM":174,"YCN":174,"YCO":80,"YCQ":93,"YCR":178,"YCS":153,"YCT":97,"YCU":243,"YCW":176,"YCY":116,"YCZ":97,"YDA":92,"YDB":92,"YDC":176,"YDF":165,"YDG":110,"YDL":176,"YDM":177,"YDN":178,"YDO":174,"YDP":103,"YDQ":93,"YDT":174,"YDV":178,"YEB":174,"YEC":242,"YEE":174,"YEG":97,"YEI":297,"YEK":153,"YEL":174,"YEM":174,"YEN":155,"YEO":305,"YER":174,"YES":249,"YET":97,"YEU":70,"YEV":115,"YEY":174,"YFA":174,"YFB":116,"YFC":136,"YFD":174,"YFE":174,"YFH":174,"YFI":97,"YFJ":97,"YFO":178,"YFR":97,"YFS":115,"YFX":165,"YGB":176,"YGD":174,"YGE":97,"YGH":115,"YGJ":251,"YGK":174,"YGL":174,"YGM":178,"YGO":178,"YGP":174,"YGQ":174,"YGR":110,"YGT":116,"YGV":174,"YGW":116,"YGX":178,"YGZ":116,"YHA":165,"YHB":155,"YHD":178,"YHE":176,"YHF":174,"YHI":97,"YHJ":243,"YHK":80,"YHM":174,"YHN":174,"YHO":103,"YHP":178,"YHR":76,"YHT":177,"YHU":174,"YHY":97,"YHZ":110,"YIA":210,"YIB":70,"YIE":243,"YIF":76,"YIH":243,"YIK":116,"YIN":243,"YIO":116,"YIP":95,"YIV":178,"YIW":243,"YJA":97,"YJF":115,"YJM":176,"YJN":174,"YJS":235,"YJT":165,"YKA":176,"YKC":155,"YKF":174,"YKG":174,"YKH":243,"YKJ":155,"YKL":174,"YKM":123,"YKN":86,"YKO":297,"YKQ":116,"YKS":257,"YKU":174,"YKX":174,"YKY":170,"YKZ":174,"YLB":97,"YLC":116,"YLD":174,"YLE":97,"YLG":279,"YLH":174,"YLI":295,"YLJ":170,"YLK":174,"YLL":97,"YLQ":174,"YLR":178,"YLT":116,"YLV":191,"YLW":176,"YLX":243,"YLY":176,"YMA":177,"YME":174,"YMG":174,"YMH":165,"YMJ":155,"YMK":259,"YML":174,"YMM":97,"YMN":103,"YMO":174,"YMS":122,"YMT":174,"YMW":174,"YMX":174,"YNA":76,"YNB":239,"YNC":116,"YND":174,"YNE":178,"YNG":141,"YNH":93,"YNJ":243,"YNL":155,"YNM":174,"YNN":178,"YNO":178,"YNS":174,"YNT":243,"YNX":97,"YNY":242,"YNZ":243,"YOA":97,"YOC":92,"YOD":97,"YOG":174,"YOH":178,"YOJ":97,"YOL":29,"YOO":174,"YOP":97,"YOS":174,"YOT":212,"YOW":174,"YPA":155,"YPC":115,"YPD":174,"YPE":97,"YPG":178,"YPH":174,"YPJ":174,"YPL":70,"YPM":178,"YPN":174,"YPO":174,"YPQ":174,"YPR":176,"YPS":102,"YPW":176,"YPX":116,"YPY":97,"YPZ":176,"YQA":174,"YQB":174,"YQC":116,"YQD":178,"YQF":97,"YQG":174,"YQH":177,"YQI":110,"YQK":178,"YQL":97,"YQM":136,"YQN":174,"YQQ":176,"YQR":155,"YQS":174,"YQT":174,"YQU":97,"YQV":155,"YQW":170,"YQX":165,"YQY":102,"YQZ":176,"YRA":97,"YRB":156,"YRF":103,"YRG":103,"YRI":174,"YRJ":174,"YRL":178,"YRM":97,"YRO":174,"YRP":174,"YRQ":174,"YRS":178,"YRT":153,"YRV":176,"YSA":110,"YSB":174,"YSC":174,"YSE":176,"YSF":155,"YSG":97,"YSH":174,"YSJ":136,"YSK":116,"YSL":136,"YSM":97,"YSN":176,"YSO":103,"YSP":174,"YSQ":243,"YST":178,"YSU":110,"YSY":115,"YTA":174,"YTD":178,"YTE":116,"YTF":174,"YTH":178,"YTL":178,"YTM":174,"YTQ":174,"YTR":174,"YTS":174,"YTW":243,"YTY":243,"YTZ":174,"YUB":115,"YUD":116,"YUE":273,"YUL":174,"YUM":147,"YUS":243,"YUT":153,"YUX":116,"YUY":174,"YVA":338,"YVB":174,"YVC":155,"YVE":176,"YVG":97,"YVM":116,"YVO":174,"YVP":174,"YVQ":115,"YVR":176,"YVT":170,"YVV":174,"YVZ":178,"YWA":174,"YWB":174,"YWG":178,"YWH":176,"YWJ":115,"YWK":103,"YWL":176,"YWM":165,"YWP":174,"YWY":115,"YXC":97,"YXD":97,"YXE":155,"YXH":97,"YXJ":93,"YXK":174,"YXL":178,"YXN":153,"YXP":116,"YXQ":92,"YXR":174,"YXS":176,"YXT":176,"YXU":174,"YXX":123,"YXY":177,"YXZ":174,"YYA":243,"YYB":174,"YYC":97,"YYD":176,"YYE":100,"YYF":176,"YYG":110,"YYH":80,"YYJ":176,"YYL":178,"YYM":97,"YYN":170,"YYQ":178,"YYR":103,"YYT":165,"YYU":174,"YYW":174,"YYY":174,"YYZ":174,"YZD":174,"YZE":174,"YZF":97,"YZG":174,"YZH":97,"YZP":176,"YZR":174,"YZS":70,"YZT":176,"YZU":97,"YZV":174,"YZW":177,"YZX":110,"YZY":176,"YZZ":176,"ZAC":178,"ZAD":332,"ZAG":332,"ZAH":249,"ZAJ":213,"ZAL":159,"ZAM":226,"ZAO":313,"ZAR":29,"ZAT":243,"ZAZ":307,"ZBD":176,"ZBE":315,"ZBF":136,"ZBL":271,"ZBM":174,"ZBO":271,"ZBR":249,"ZBY":255,"ZCA":285,"ZCL":134,"ZCN":285,"ZCO":159,"ZEC":23,"ZEE":178,"ZEL":176,"ZEM":116,"ZER":218,"ZFA":177,"ZFD":170,"ZFG":178,"ZFL":243,"ZFM":115,"ZFN":115,"ZGF":123,"ZGI":178,"ZGL":271,"ZGM":34,"ZGR":178,"ZGU":350,"ZHA":243,"ZHI":333,"ZHM":201,"ZHP":97,"ZHY":243,"ZHZ":285,"ZIA":311,"ZIC":159,"ZIG":15,"ZIH":134,"ZIN":333,"ZIX":257,"ZJG":178,"ZJI":333,"ZJN":178,"ZKB":34,"ZKE":174,"ZKG":76,"ZKM":30,"ZKP":245,"ZLO":134,"ZLR":159,"ZLT":76,"ZLX":26,"ZMG":285,"ZMH":176,"ZMM":134,"ZMT":176,"ZND":43,"ZNE":279,"ZNF":285,"ZNG":178,"ZNV":285,"ZNZ":16,"ZOJ":285,"ZOS":159,"ZOW":285,"ZPB":178,"ZPC":159,"ZPH":141,"ZPO":155,"ZPQ":285,"ZQC":285,"ZQF":285,"ZQL":285,"ZQN":345,"ZQW":285,"ZRE":284,"ZRH":333,"ZRI":211,"ZRJ":178,"ZRM":211,"ZSA":140,"ZSE":343,"ZSJ":178,"ZSP":97,"ZSS":0,"ZST":176,"ZTA":375,"ZTB":76,"ZTH":283,"ZTM":178,"ZTR":302,"ZTU":191,"ZUC":178,"ZUD":159,"ZUH":243,"ZUL":239,"ZUM":103,"ZVA":334,"ZVK":192,"ZWA":334,"ZWH":178,"ZWK":331,"ZWL":155,"ZXB":312,"ZYI":243,"ZYL":201,"ZZE":191,"ZZO":240,"ZZU":9,"ZZV":141}}
diff --git a/src/lib/structured-mail/event-normalization.ts b/src/lib/structured-mail/event-normalization.ts
new file mode 100644
index 00000000..ad8fdf8b
--- /dev/null
+++ b/src/lib/structured-mail/event-normalization.ts
@@ -0,0 +1,161 @@
+// Mirrors webmail lib/calendar-event-normalization.ts at 49beaef85; keep the two in step.
+import { parseISO } from 'date-fns';
+import type { CalendarEvent } from '../../api/types';
+
+const DURATION_RE = /^P(?:(\d+)W)?(?:(\d+)D)?(?:T(?:(\d+)H)?(?:(\d+)M)?(?:(\d+)S)?)?$/;
+
+function isDateOnlyValue(value: string): boolean {
+ return /^\d{4}-\d{2}-\d{2}$/.test(value);
+}
+
+function isMidnightValue(value: string): boolean {
+ if (isDateOnlyValue(value)) {
+ return true;
+ }
+
+ const match = value.match(/T(\d{2}):(\d{2})(?::(\d{2})(?:\.\d+)?)?(?:Z|[+-]\d{2}:\d{2})?$/);
+ if (!match) {
+ return false;
+ }
+
+ return match[1] === '00' && match[2] === '00' && (match[3] ?? '00') === '00';
+}
+
+export function parseDurationSeconds(duration: string | undefined): number | null {
+ if (!duration) {
+ return null;
+ }
+
+ const match = DURATION_RE.exec(duration);
+ if (!match) {
+ return null;
+ }
+
+ const weeks = parseInt(match[1] || '0', 10);
+ const days = parseInt(match[2] || '0', 10);
+ const hours = parseInt(match[3] || '0', 10);
+ const minutes = parseInt(match[4] || '0', 10);
+ const seconds = parseInt(match[5] || '0', 10);
+
+ return (((weeks * 7 + days) * 24 + hours) * 60 + minutes) * 60 + seconds;
+}
+
+export function normalizeAllDayDurationValue(duration: string | undefined): string | undefined {
+ const totalSeconds = parseDurationSeconds(duration);
+ if (totalSeconds === null || totalSeconds < 86400 || totalSeconds % 86400 !== 0) {
+ return duration;
+ }
+
+ return `P${totalSeconds / 86400}D`;
+}
+
+export function isAllDayEventLike(event: Pick, 'start' | 'duration' | 'showWithoutTime'>): boolean {
+ if (event.showWithoutTime) {
+ return true;
+ }
+
+ if (!event.start || !event.duration || !isMidnightValue(event.start)) {
+ return false;
+ }
+
+ const totalSeconds = parseDurationSeconds(event.duration);
+ if (totalSeconds === null || totalSeconds < 86400 || totalSeconds % 86400 !== 0) {
+ return false;
+ }
+
+ const start = parseISO(event.start);
+ if (Number.isNaN(start.getTime())) {
+ return false;
+ }
+
+ const end = new Date(start.getTime() + totalSeconds * 1000);
+ return end.getHours() === 0
+ && end.getMinutes() === 0
+ && end.getSeconds() === 0
+ && end.getMilliseconds() === 0;
+}
+
+/**
+ * Stalwart returns "recurrenceRule" (singular) instead of RFC 8984 "recurrenceRules" (plural).
+ * Normalize server responses to match the client's internal type.
+ */
+function normalizeStalwartPropertyNames>(event: T): T {
+ const raw = event as Record;
+ let patched = false;
+ const updates: Partial = {};
+
+ if ('recurrenceRule' in raw && !('recurrenceRules' in raw)) {
+ // JSCalendar 2.0 (jscalendarbis-15) defines recurrenceRule as a single object,
+ // but Stalwart may also return it as an array (for JMAP-created events).
+ // Normalize both forms to our internal array type.
+ const val = raw.recurrenceRule;
+ if (val != null && !Array.isArray(val) && typeof val === 'object') {
+ updates.recurrenceRules = [val] as CalendarEvent['recurrenceRules'];
+ } else {
+ updates.recurrenceRules = val as CalendarEvent['recurrenceRules'];
+ }
+ patched = true;
+ }
+ if ('excludedRecurrenceRule' in raw && !('excludedRecurrenceRules' in raw)) {
+ const val = raw.excludedRecurrenceRule;
+ if (val != null && !Array.isArray(val) && typeof val === 'object') {
+ updates.excludedRecurrenceRules = [val] as CalendarEvent['excludedRecurrenceRules'];
+ } else {
+ updates.excludedRecurrenceRules = val as CalendarEvent['excludedRecurrenceRules'];
+ }
+ patched = true;
+ }
+
+ if (!patched) return event;
+
+ const result = { ...event, ...updates } as T;
+ delete (result as Record).recurrenceRule;
+ delete (result as Record).excludedRecurrenceRule;
+ return result;
+}
+
+/**
+ * A timed event without a duration lasts zero seconds (RFC 8984 default
+ * PT0S). Stalwart drops a DTEND equal to DTSTART and then ends such an
+ * event at 23:59:59 of its start day, so its utcEnd is wrong. Only server
+ * objects carry utcStart, which keeps partial update patches untouched. (#837)
+ */
+function withZeroDurationEnd>(event: T): T {
+ if (event.showWithoutTime || event.duration || !event.start || !event.utcStart) {
+ return event;
+ }
+ return { ...event, duration: 'PT0S', utcEnd: event.utcStart } as T;
+}
+
+export function normalizeCalendarEventLike>(event: T): T {
+ // First normalize Stalwart's singular property names to RFC 8984 plural forms
+ const normalized = normalizeStalwartPropertyNames(event);
+
+ if (!isAllDayEventLike(normalized)) {
+ return withZeroDurationEnd(normalized);
+ }
+
+ return {
+ ...normalized,
+ showWithoutTime: true,
+ duration: normalizeAllDayDurationValue(normalized.duration),
+ } as T;
+}
+
+export function sanitizeOutgoingCalendarEventData>(event: T): T {
+ const normalized = normalizeCalendarEventLike(event);
+ if (!normalized.showWithoutTime) {
+ return normalized;
+ }
+
+ const normalizedStart = normalized.start
+ ? `${normalized.start.slice(0, 10)}T00:00:00`
+ : normalized.start;
+
+ return {
+ ...normalized,
+ start: normalizedStart,
+ duration: normalizeAllDayDurationValue(normalized.duration),
+ timeZone: null,
+ } as T;
+}
\ No newline at end of file
diff --git a/src/lib/structured-mail/extract.ts b/src/lib/structured-mail/extract.ts
new file mode 100644
index 00000000..0e7f6bef
--- /dev/null
+++ b/src/lib/structured-mail/extract.ts
@@ -0,0 +1,126 @@
+// Mirrors webmail lib/structured-mail/extract.ts at 76b0c2fba; keep the two in step.
+/**
+ * Pull schema.org data out of a mail's raw HTML: JSON-LD blocks and
+ * microdata. The HTML is parsed into a detached document (DOMParser runs no
+ * scripts and loads nothing) that is never attached to the page; the sanitizer
+ * that renders the mail is a separate path and isn't touched.
+ */
+import { parseHtmlDocument } from './html-parser';
+import { parseLenientJson } from './json-repair';
+
+export type RawItem = Record;
+export interface ExtractedItem {
+ item: unknown;
+ source: 'json-ld' | 'microdata';
+}
+
+const MAX_JSONLD_BLOCKS = 50;
+const MAX_MICRODATA_ITEMS = 500;
+const MAX_MICRODATA_DEPTH = 12;
+const MAX_HTML_LENGTH = 8_000_000;
+
+/** Cheap test that a body may hold markup this feature reads. */
+const TYPE_HINT = /Reservation|ParcelDelivery|Order/;
+const SCHEMA_ITEMTYPE = /^https?:\/\/(?:www\.)?schema\.org\/([A-Za-z]+)\/?$/i;
+
+/**
+ * Mail clients mark what they quote or forward with these containers;
+ * markup inside them belongs to someone else's mail.
+ */
+const QUOTED_CONTAINERS = 'blockquote, .gmail_quote, .moz-forward-container, #appendonsend, #divRplyFwdMsg';
+
+// App: React Native has no DOMParser; html-parser.ts builds the detached tree.
+export function defaultParseHtml(html: string): Document {
+ return parseHtmlDocument(html);
+}
+
+export function mayContainStructuredData(html: string): boolean {
+ return TYPE_HINT.test(html);
+}
+
+/**
+ * Elements that sit inside a quoted or forwarded part: inside one of the
+ * containers, or after Outlook's `#divRplyFwdMsg` separator (Outlook puts
+ * the forwarded message after it, not inside it).
+ */
+function isQuoted(element: Element, doc: Document): boolean {
+ if (element.closest(QUOTED_CONTAINERS)) return true;
+ const separator = doc.getElementById('divRplyFwdMsg');
+ return !!separator && !!(separator.compareDocumentPosition(element) & 4 /* FOLLOWING */);
+}
+
+function itemTypes(element: Element): string[] {
+ return (element.getAttribute('itemtype') ?? '')
+ .split(/\s+/)
+ .map((value) => SCHEMA_ITEMTYPE.exec(value)?.[1])
+ .filter((value): value is string => !!value);
+}
+
+function propertyValue(element: Element, depth: number): unknown {
+ if (element.hasAttribute('itemscope')) return microdataItem(element, depth + 1);
+ const content = element.getAttribute('content');
+ if (content !== null) return content.trim();
+ const tag = element.tagName.toLowerCase();
+ const attr = (name: string) => (element.getAttribute(name) ?? '').trim();
+ switch (tag) {
+ case 'a': case 'link': case 'area': return attr('href');
+ case 'img': case 'audio': case 'video': case 'source': case 'embed': case 'iframe': case 'track': return attr('src');
+ case 'object': return attr('data');
+ case 'time': return element.hasAttribute('datetime') ? attr('datetime') : (element.textContent ?? '').trim();
+ case 'data': case 'meter': return attr('value');
+ default: return (element.textContent ?? '').replace(/\s+/g, ' ').trim();
+ }
+}
+
+/**
+ * One microdata item: properties are descendants with `itemprop` whose
+ * nearest `itemscope` ancestor is this item. A space-separated itemprop gives
+ * the value to each name; a repeated name becomes an array.
+ */
+function microdataItem(scope: Element, depth: number): RawItem | undefined {
+ if (depth > MAX_MICRODATA_DEPTH) return undefined;
+ const item: RawItem = {};
+ const types = itemTypes(scope);
+ if (types.length > 0) item['@type'] = types.length === 1 ? types[0] : types;
+ const add = (name: string, value: unknown) => {
+ if (value === undefined || value === '') return;
+ const existing = item[name];
+ if (existing === undefined) item[name] = value;
+ else if (Array.isArray(existing)) existing.push(value);
+ else item[name] = [existing, value];
+ };
+ for (const element of Array.from(scope.querySelectorAll('[itemprop]'))) {
+ const owner = element.parentElement?.closest('[itemscope]');
+ if (owner !== scope) continue;
+ const value = propertyValue(element, depth);
+ for (const name of (element.getAttribute('itemprop') ?? '').split(/\s+/).filter(Boolean)) add(name, value);
+ }
+ return item;
+}
+
+export function extractStructuredData(
+ html: string,
+ parseHtml: (html: string) => Document = defaultParseHtml,
+): ExtractedItem[] {
+ if (!html || html.length > MAX_HTML_LENGTH || !mayContainStructuredData(html)) return [];
+ const doc = parseHtml(html);
+ const found: ExtractedItem[] = [];
+
+ const scripts = Array.from(doc.querySelectorAll('script')).filter(
+ (script) => (script.getAttribute('type') ?? '').trim().toLowerCase() === 'application/ld+json',
+ );
+ for (const script of scripts.slice(0, MAX_JSONLD_BLOCKS)) {
+ if (isQuoted(script, doc)) continue;
+ const value = parseLenientJson(script.textContent ?? '');
+ if (value !== undefined && value !== null) found.push({ item: value, source: 'json-ld' });
+ }
+
+ const tops = Array.from(doc.querySelectorAll('[itemscope]')).filter((element) => !element.hasAttribute('itemprop'));
+ for (const scope of tops.slice(0, MAX_MICRODATA_ITEMS)) {
+ // Only items typed on schema.org count; an untyped or foreign item is noise.
+ if (itemTypes(scope).length === 0 || isQuoted(scope, doc)) continue;
+ const item = microdataItem(scope, 0);
+ if (item) found.push({ item: { '@context': 'https://schema.org', ...item }, source: 'microdata' });
+ }
+ return found;
+}
diff --git a/src/lib/structured-mail/format.ts b/src/lib/structured-mail/format.ts
new file mode 100644
index 00000000..0aca126f
--- /dev/null
+++ b/src/lib/structured-mail/format.ts
@@ -0,0 +1,261 @@
+// Mirrors webmail lib/structured-mail/format.ts at b0782af2d; keep the two in step.
+/**
+ * Text for the reader cards. Plain TypeScript, no React.
+ *
+ * A card shows a time the way the mail gave it: the wall clock at the place
+ * (departure airport, hotel, venue), never converted to the reader's zone.
+ * The local date and time are formatted as if they were UTC, with
+ * `timeZone: 'UTC'`, so neither the browser's zone nor DST can move them.
+ */
+import { zoneOffsetAt } from './time-zones';
+import type { Address, Item, Moment, Place, Shipment, TransportBooking, TransportLeg } from './types';
+
+const MINUTE = 60_000;
+const DAY = 24 * 60 * MINUTE;
+
+const DATE_RE = /^\d{4}-\d{2}-\d{2}$/;
+const TIME_RE = /^\d{2}:\d{2}(?::\d{2})?$/;
+
+export interface FormatOptions {
+ /** UI locale ("en", "de", ...). */
+ locale: string;
+ /** true for a 12-hour clock, false for 24 hours, undefined for the locale's own. */
+ hour12?: boolean;
+ /** "Now", to decide whether the year is shown. Defaults to the current date. */
+ now?: Date;
+}
+
+/** The moment's local wall clock as a Date whose UTC fields hold it; null when malformed. */
+export function wallClockDate(m: Moment): Date | null {
+ if (!DATE_RE.test(m.date)) return null;
+ if (m.time !== undefined && !TIME_RE.test(m.time)) return null;
+ const time = m.time ? (m.time.length === 5 ? `${m.time}:00` : m.time) : '00:00:00';
+ const d = new Date(`${m.date}T${time}Z`);
+ return Number.isNaN(d.getTime()) ? null : d;
+}
+
+function formatter(options: Intl.DateTimeFormatOptions, opts: FormatOptions): Intl.DateTimeFormat {
+ try {
+ return new Intl.DateTimeFormat(opts.locale, { ...options, timeZone: 'UTC' });
+ } catch {
+ return new Intl.DateTimeFormat('en', { ...options, timeZone: 'UTC' });
+ }
+}
+
+function clockOptions(opts: FormatOptions): Intl.DateTimeFormatOptions {
+ if (opts.hour12 === true) return { hour: 'numeric', minute: '2-digit', hour12: true };
+ if (opts.hour12 === false) return { hour: '2-digit', minute: '2-digit', hourCycle: 'h23' };
+ return { hour: 'numeric', minute: '2-digit' };
+}
+
+function dateOptions(m: Moment, opts: FormatOptions): Intl.DateTimeFormatOptions {
+ const year = Number(m.date.slice(0, 4));
+ const currentYear = (opts.now ?? new Date()).getUTCFullYear();
+ return { weekday: 'short', day: 'numeric', month: 'short', ...(year !== currentYear ? { year: 'numeric' } : {}) };
+}
+
+/** "Tue, Dec 1" (the year only when it isn't this year). */
+export function formatMomentDate(m: Moment, opts: FormatOptions): string {
+ const d = wallClockDate(m);
+ return d ? formatter(dateOptions(m, opts), opts).format(d) : m.date;
+}
+
+/** "10:15", or undefined for a date-only moment. */
+export function formatMomentTime(m: Moment, opts: FormatOptions): string | undefined {
+ if (!m.time) return undefined;
+ const d = wallClockDate(m);
+ return d ? formatter(clockOptions(opts), opts).format(d) : m.time.slice(0, 5);
+}
+
+/** "Tue, Dec 1, 10:15" in the locale's own order; the date alone for a date-only moment. */
+export function formatMoment(m: Moment, opts: FormatOptions): string {
+ if (!m.time) return formatMomentDate(m, opts);
+ const d = wallClockDate(m);
+ if (!d) return `${m.date} ${m.time.slice(0, 5)}`;
+ return formatter({ ...dateOptions(m, opts), ...clockOptions(opts) }, opts).format(d);
+}
+
+/** Whether two moments fall on the same local date. */
+export function sameLocalDate(a: Moment, b: Moment): boolean {
+ return a.date === b.date;
+}
+
+/**
+ * The moment's UTC offset in minutes east of UTC: the one the mail gave, or
+ * the one its resolved zone implies. Undefined for a floating time (shown as
+ * written, no zone known) or a date-only moment.
+ */
+export function momentOffsetMinutes(m: Moment): number | undefined {
+ if (!m.time) return undefined;
+ if (m.offsetMinutes !== undefined) return m.offsetMinutes;
+ if (!m.utc) return undefined;
+ const wall = wallClockDate(m);
+ const instant = Date.parse(m.utc);
+ if (!wall || Number.isNaN(instant)) return undefined;
+ return Math.round((wall.getTime() - instant) / MINUTE);
+}
+
+function instantOf(m: Moment, offset: number): number | undefined {
+ if (m.utc) {
+ const t = Date.parse(m.utc);
+ if (!Number.isNaN(t)) return t;
+ }
+ const wall = wallClockDate(m);
+ return wall ? wall.getTime() - offset * MINUTE : undefined;
+}
+
+/**
+ * Whether a timed moment's place keeps a different clock than the reader
+ * at that instant, so the card marks the time as local to the place.
+ * `readerOffsetAt` gives the reader's offset (minutes east of UTC) at an
+ * instant; by default the offset of `readerZone`.
+ */
+export function isOtherZone(m: Moment, readerZone: string, readerOffsetAt?: (utcMs: number) => number): boolean {
+ const offset = momentOffsetMinutes(m);
+ if (offset === undefined) return false;
+ const instant = instantOf(m, offset);
+ if (instant === undefined) return false;
+ try {
+ const reader = readerOffsetAt ? readerOffsetAt(instant) : zoneOffsetAt(readerZone, instant);
+ return reader !== offset;
+ } catch {
+ return false;
+ }
+}
+
+/** Nights between check-in and check-out dates; undefined when it doesn't add up. */
+export function nightsBetween(checkin: Moment, checkout: Moment): number | undefined {
+ const a = wallClockDate({ date: checkin.date });
+ const b = wallClockDate({ date: checkout.date });
+ if (!a || !b) return undefined;
+ const nights = Math.round((b.getTime() - a.getTime()) / DAY);
+ return nights > 0 ? nights : undefined;
+}
+
+/** "€129.90" in the UI locale; amount and code as given when Intl can't format them. */
+export function formatMoney(total: { amount: string; currency: string }, locale: string): string {
+ const n = Number(total.amount);
+ if (!Number.isFinite(n)) return `${total.amount} ${total.currency}`;
+ try {
+ return new Intl.NumberFormat(locale, { style: 'currency', currency: total.currency }).format(n);
+ } catch {
+ return `${total.amount} ${total.currency}`;
+ }
+}
+
+function regionName(code: string | undefined, locale: string): string | undefined {
+ if (!code) return undefined;
+ try {
+ return new Intl.DisplayNames([locale], { type: 'region' }).of(code) ?? code;
+ } catch {
+ return code;
+ }
+}
+
+/** One line: "Marktplatz 1, 10178 Berlin, Germany". */
+export function formatAddress(address: Address | undefined, locale: string): string | undefined {
+ if (!address) return undefined;
+ const town = [address.postalCode, address.locality].filter(Boolean).join(' ');
+ const country = address.countryName ?? regionName(address.country, locale);
+ const parts = [address.street, town, address.region, country].filter((p): p is string => !!p && p.trim() !== '');
+ // A street given as one string may already hold the town.
+ const unique = parts.filter((p, i) => !parts.slice(0, i).some((q) => q.includes(p)));
+ return unique.length ? unique.join(', ') : undefined;
+}
+
+/** A place on one line: "Stadtpark, Hamburg"; the name alone when it already names the town. */
+export function formatPlace(place: Place | undefined): string | undefined {
+ if (!place) return undefined;
+ const name = place.name?.trim();
+ const town = place.address?.locality?.trim();
+ if (name && town && !name.toLowerCase().includes(town.toLowerCase())) return `${name}, ${town}`;
+ return name || town || place.code;
+}
+
+/** An airport or station in a leg row: "Frankfurt Airport (FRA)", "Hamburg Hbf". */
+export function legPlaceLabel(place: Place, kind: TransportBooking['kind']): string {
+ const name = place.name?.trim() || place.address?.locality?.trim();
+ if (kind === 'flight' && place.code) {
+ if (!name) return place.code;
+ // "Frankfurt" holds the letters FRA; only a code standing on its own counts.
+ const code = place.code.toUpperCase().replace(/[^A-Z0-9]/g, '');
+ const hasCode = new RegExp(`(^|[^A-Z0-9])${code}([^A-Z0-9]|$)`).test(name.toUpperCase());
+ return hasCode ? name : `${name} (${place.code})`;
+ }
+ return name || place.code || '';
+}
+
+/** Carrier and number: "Lufthansa LH 400", "Rail Co ICE 577". */
+export function legService(leg: TransportLeg): string | undefined {
+ const number = leg.carrierCode && leg.number && !leg.number.toUpperCase().startsWith(leg.carrierCode.toUpperCase())
+ ? `${leg.carrierCode} ${leg.number}`
+ : leg.number;
+ const parts = [leg.carrier, number].filter((p): p is string => !!p);
+ return parts.length ? parts.join(' ') : undefined;
+}
+
+/**
+ * The stops of a route, for its title: airport codes for flights, city or
+ * station names otherwise. Connecting legs form one run (FRA, JFK, FRA);
+ * a leg that doesn't connect starts the next run.
+ */
+export function routeRuns(booking: TransportBooking): string[][] {
+ const legs = booking.legs;
+ const towns = legs.flatMap((leg) => [leg.departure.place, leg.arrival?.place]).filter((p): p is Place => !!p)
+ .map((p) => p.address?.locality?.trim());
+ const useTowns = booking.kind !== 'flight' && towns.every(Boolean)
+ && legs.every((leg) => !leg.arrival || leg.departure.place.address?.locality !== leg.arrival.place.address?.locality);
+ const label = (place: Place): string => {
+ if (booking.kind === 'flight' && place.code) return place.code;
+ if (useTowns && place.address?.locality) return place.address.locality.trim();
+ return place.name?.trim() || place.address?.locality?.trim() || place.code || '?';
+ };
+ const runs: string[][] = [];
+ for (const leg of legs) {
+ const from = label(leg.departure.place);
+ const to = leg.arrival ? label(leg.arrival.place) : undefined;
+ const run = runs[runs.length - 1];
+ if (run && run[run.length - 1] === from) {
+ if (to) run.push(to);
+ } else {
+ runs.push(to ? [from, to] : [from]);
+ }
+ }
+ return runs;
+}
+
+/** The route as plain text, "FRA → JFK → FRA"; runs separated by a comma. */
+export function routeTitle(booking: TransportBooking): string {
+ return routeRuns(booking).map((run) => run.join(' → ')).join(', ');
+}
+
+/** The items a shipment card lists: the order's, else the parcels', with images from either. */
+export function shipmentItems(shipment: Shipment): Item[] {
+ const parcelItems = shipment.parcels.flatMap((p) => p.items);
+ if (shipment.items.length === 0) {
+ const seen = new Set();
+ return parcelItems.filter((item) => (seen.has(item.name) ? false : (seen.add(item.name), true)));
+ }
+ return shipment.items.map((item) => {
+ if (item.image) return item;
+ const image = parcelItems.find((p) => p.name === item.name && p.image)?.image;
+ return image ? { ...item, image } : item;
+ });
+}
+
+/** The first `max` item names, how many more there are, and the number of pieces. */
+export function summarizeItems(items: Item[], max = 3): { shown: Item[]; more: number; count: number } {
+ const count = items.reduce((sum, item) => sum + (item.quantity && item.quantity > 0 ? item.quantity : 1), 0);
+ return { shown: items.slice(0, max), more: Math.max(0, items.length - max), count };
+}
+
+/** Whether a link may be shown and opened: http(s) only, checked again here. */
+export function isWebLink(link: { url: string } | undefined): boolean {
+ if (!link) return false;
+ try {
+ const { protocol } = new URL(link.url);
+ return protocol === 'http:' || protocol === 'https:';
+ } catch {
+ return false;
+ }
+}
diff --git a/src/lib/structured-mail/forward-prefix.ts b/src/lib/structured-mail/forward-prefix.ts
new file mode 100644
index 00000000..50878e74
--- /dev/null
+++ b/src/lib/structured-mail/forward-prefix.ts
@@ -0,0 +1,85 @@
+// Mirrors hasForwardPrefix and the prefix tokens of webmail lib/subject-prefix.ts
+// at 8098039d6; keep the two in step. The app's own src/lib/subject-prefix.ts
+// has the same token lists but no forward check.
+
+const REPLY_TOKENS = [
+ "Re", // English, Italian, French (also generic ISO)
+ "RE", // Outlook variant
+ "AW", // German (Antwort)
+ "Antw", // German verbose
+ "Sv", // Danish / Swedish / Norwegian (Svar)
+ "Yn", // Turkish (Yanit)
+ "Yanit", // Turkish verbose
+ "Odp", // Polish (Odpowiedz)
+ "Ответ", // Russian
+ "Resp", // Spanish/Portuguese variant
+ "Vá", // Hungarian
+ "回复", // Chinese
+ "回覆", // Chinese traditional
+ "답장", // Korean
+ // NB: deliberately no bare "R" token — a single letter would strip the first
+ // word of legitimate subjects like "R: budget 2024". The full "Re" covers
+ // the common Italian/English case anyway.
+];
+
+const FORWARD_TOKENS = [
+ "Fwd", // English standard
+ "Fw", // English short / Polish / German short
+ "WG", // German (Weitergeleitet)
+ "Tr", // French (Transfert)
+ "Vs", // Danish (Videresend)
+ "Enc", // Portuguese (Encaminhar)
+ "ENC", // Portuguese caps
+ "Rv", // Spanish (Reenviar)
+ "RV", // Spanish caps
+ "Rvf", // Spanish variant
+ "Inol", // Italian (Inoltro)
+ // NB: deliberately no bare "I" token — see the REPLY_TOKENS note above.
+ "PD", // Polish (Przekazane Dalej)
+ "PR", // Czech (Preposlat)
+ "İlt", // Turkish (Ilet)
+ "Ilt", // Turkish ASCII
+ "Пересл", // Russian (Peresylka)
+ "Пер", // Russian short
+ "转发", // Chinese
+ "轉寄", // Chinese traditional
+ "전달", // Korean
+];
+
+// Match a single prefix token + optional [N] counter (Outlook) or *N (Eudora)
+// + colon + whitespace. Case-insensitive. The non-capturing groups keep the
+// regex composable for stripping multiple prefixes in a row.
+function buildPrefixRegex(tokens: string[]): RegExp {
+ // Escape regex specials in tokens (none currently, but be defensive)
+ const escaped = tokens.map((t) => t.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"));
+ // Sort by length DESC so longer tokens (e.g. "Пересл") win over their
+ // shorter prefixes (e.g. "Пер") during alternation matching.
+ escaped.sort((a, b) => b.length - a.length);
+ // Accept both the ASCII colon and the full-width colon ":" (U+FF1A) that CJK
+ // mail clients emit after a localized prefix (e.g. "回复:foo").
+ return new RegExp(
+ `^\\s*(?:${escaped.join("|")})(?:\\[\\d+\\]|\\*\\d*)?\\s*[:\\uFF1A]\\s*`,
+ "i",
+ );
+}
+
+const FORWARD_RE = buildPrefixRegex(FORWARD_TOKENS);
+const REPLY_RE = buildPrefixRegex(REPLY_TOKENS);
+
+/**
+ * Whether the subject's leading prefix chain marks a forward anywhere in it
+ * ("Fwd:", "Re: Fwd:", "AW: WG:"), case-insensitively. Booking cards use it:
+ * a forwarded booking mail was authenticated for the forwarder, not for the
+ * airline or shop it quotes.
+ */
+export function hasForwardPrefix(subject: string | undefined | null): boolean {
+ let rest = (subject ?? "").trimStart();
+ for (let i = 0; i < 20; i++) {
+ const forward = FORWARD_RE.exec(rest);
+ if (forward && forward[0].length > 0) return true;
+ const reply = REPLY_RE.exec(rest);
+ if (!reply || reply[0].length === 0) return false;
+ rest = rest.slice(reply[0].length).trimStart();
+ }
+ return false;
+}
diff --git a/src/lib/structured-mail/gate.ts b/src/lib/structured-mail/gate.ts
new file mode 100644
index 00000000..5445873e
--- /dev/null
+++ b/src/lib/structured-mail/gate.ts
@@ -0,0 +1,16 @@
+// Mirrors webmail lib/structured-mail-gate.ts at 8098039d6, which reads the
+// admin policy's `structuredMailEnabled`. Off: no booking, parcel or order
+// cards, no parsing of markup and no .pkpass download.
+//
+// App: there is no org policy in the app yet, so the gate is on. When the
+// policy arrives, read it here; every caller already goes through this.
+
+/** Whether booking cards are shown, outside React. */
+export function isStructuredMailEnabled(): boolean {
+ return true;
+}
+
+/** Whether booking cards are shown, for components. */
+export function useStructuredMailEnabled(): boolean {
+ return isStructuredMailEnabled();
+}
diff --git a/src/lib/structured-mail/index.ts b/src/lib/structured-mail/index.ts
new file mode 100644
index 00000000..567e2811
--- /dev/null
+++ b/src/lib/structured-mail/index.ts
@@ -0,0 +1,60 @@
+// Mirrors webmail lib/structured-mail/index.ts at ba6d5e448; keep the two in step.
+/**
+ * Structured mail: read the schema.org markup that airlines, hotels, shops
+ * and carriers put into their HTML mail, and turn it into cards. Plain
+ * TypeScript; the only DOM dependency is the HTML parser, which a caller
+ * without DOMParser (the native app) passes in.
+ *
+ * const cards = readStructuredMail(html, { mailDate });
+ * const shown = needsAirportZones(cards)
+ * ? resolveCards(cards, await loadAirportZones())
+ * : resolveCards(cards);
+ */
+import { extractStructuredData } from './extract';
+import { mergeCandidates } from './merge';
+import { hasReservation, normalizeItems } from './normalize';
+import type { StructuredCard } from './types';
+import { validateCard } from './validate';
+
+export interface ReadOptions {
+ /** When the mail was sent (else received): bookings far from it are doubtful. */
+ mailDate: Date;
+ parseHtml?: (html: string) => Document;
+}
+
+export interface DetailedRead {
+ /** Validated cards, merged to one per booking, zones not yet resolved. */
+ cards: StructuredCard[];
+ /**
+ * Whether the markup holds any reservation, valid or rejected. Such a mail
+ * is read from its markup alone: a pass must not bring back a booking the
+ * markup rejected or cancelled, nor add a second card for it.
+ */
+ hadBookingMarkup: boolean;
+}
+
+/** readStructuredMail, plus whether the markup described a booking at all. */
+export function readStructuredMailDetailed(html: string, options: ReadOptions): DetailedRead {
+ const extracted = extractStructuredData(html, options.parseHtml);
+ const cards = normalizeItems(extracted);
+ return {
+ cards: mergeCandidates(cards.map((card) => validateCard(card, options.mailDate))),
+ hadBookingMarkup: hasReservation(extracted),
+ };
+}
+
+/**
+ * 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[] {
+ return readStructuredMailDetailed(html, options).cards;
+}
+
+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/src/lib/structured-mail/json-repair.ts b/src/lib/structured-mail/json-repair.ts
new file mode 100644
index 00000000..ff7b5ede
--- /dev/null
+++ b/src/lib/structured-mail/json-repair.ts
@@ -0,0 +1,130 @@
+// Mirrors webmail lib/structured-mail/json-repair.ts at 76b0c2fba; keep the two in step.
+/**
+ * Parse the JSON-LD a sender wrote, including the broken forms real booking
+ * mail carries: missing commas between objects (Eurowings), several objects
+ * without the array brackets (Volotea), trailing commas (Eventbrite) and
+ * XML-entity-encoded JSON (Airbnb). Returns undefined when nothing sensible
+ * comes out; never throws.
+ */
+
+const MAX_BLOCK_LENGTH = 200_000;
+
+function tryParse(text: string): { ok: true; value: unknown } | { ok: false } {
+ try {
+ return { ok: true, value: JSON.parse(text) };
+ } catch {
+ return { ok: false };
+ }
+}
+
+function stripWrappers(text: string): string {
+ return text
+ .trim()
+ .replace(/^$/, '')
+ .replace(/^$/, '')
+ .trim();
+}
+
+const NAMED_ENTITIES: Record = { quot: '"', amp: '&', lt: '<', gt: '>', apos: "'", nbsp: ' ' };
+
+function decodeEntities(text: string): string {
+ return text.replace(/&(#x[0-9a-f]+|#\d+|[a-z]+);/gi, (match, body: string) => {
+ if (body[0] === '#') {
+ const code = body[1] === 'x' || body[1] === 'X' ? parseInt(body.slice(2), 16) : parseInt(body.slice(1), 10);
+ return Number.isFinite(code) && code > 0 && code <= 0x10ffff ? String.fromCodePoint(code) : match;
+ }
+ return NAMED_ENTITIES[body.toLowerCase()] ?? match;
+ });
+}
+
+/**
+ * One string-aware pass: drop commas right before `}` or `]`, put a comma
+ * between a closing `}` or `]` and an opening `{` or `[` (only where a
+ * comma or nothing could go), and escape raw control characters inside
+ * strings. Returns the text and how many values sit at the top level.
+ */
+function repairStructure(text: string): { text: string; topLevel: number } {
+ let out = '';
+ let inString = false;
+ let depth = 0;
+ let topLevel = 0;
+ for (let i = 0; i < text.length; i++) {
+ const c = text[i];
+ if (inString) {
+ if (c === '\\') {
+ out += c + (text[i + 1] ?? '');
+ i++;
+ continue;
+ }
+ if (c === '"') inString = false;
+ if (c < ' ') {
+ out += c === '\n' ? '\\n' : c === '\r' ? '\\r' : c === '\t' ? '\\t' : '';
+ continue;
+ }
+ out += c;
+ continue;
+ }
+ if (c === '"') {
+ if (depth === 0) topLevel++;
+ inString = true;
+ out += c;
+ continue;
+ }
+ if (c === ',') {
+ let j = i + 1;
+ while (j < text.length && /\s/.test(text[j])) j++;
+ if (text[j] === '}' || text[j] === ']' || j >= text.length) continue;
+ out += c;
+ continue;
+ }
+ if (c === '{' || c === '[') {
+ if (depth === 0) {
+ topLevel++;
+ } else {
+ // `}{` or `]{` inside an array: the comma is missing.
+ const prev = out.trimEnd().slice(-1);
+ if (prev === '}' || prev === ']') out = out.trimEnd() + ',';
+ }
+ depth++;
+ out += c;
+ continue;
+ }
+ if (c === '}' || c === ']') {
+ depth = Math.max(0, depth - 1);
+ out += c;
+ continue;
+ }
+ out += c;
+ }
+ return { text: out, topLevel };
+}
+
+export function parseLenientJson(input: string): unknown {
+ if (!input || input.length > MAX_BLOCK_LENGTH) return undefined;
+ const direct = tryParse(input);
+ if (direct.ok) return direct.value;
+
+ let text = stripWrappers(input);
+ let attempt = tryParse(text);
+ if (attempt.ok) return attempt.value;
+
+ if (/&(quot|#34|#x22);/i.test(text)) {
+ text = decodeEntities(text);
+ attempt = tryParse(text);
+ if (attempt.ok) return attempt.value;
+ }
+
+ const repaired = repairStructure(text);
+ attempt = tryParse(repaired.text);
+ if (attempt.ok) return attempt.value;
+
+ if (repaired.topLevel > 1) {
+ // Several top-level values: the brackets are missing. The commas between
+ // them may be missing too, which the pass inside brackets adds.
+ attempt = tryParse(repairStructure(`[${repaired.text}]`).text);
+ if (attempt.ok) return attempt.value;
+ }
+ return undefined;
+}
diff --git a/src/lib/structured-mail/merge.ts b/src/lib/structured-mail/merge.ts
new file mode 100644
index 00000000..29fde94d
--- /dev/null
+++ b/src/lib/structured-mail/merge.ts
@@ -0,0 +1,204 @@
+// Mirrors webmail lib/structured-mail/merge.ts at fa324170a; keep the two in step.
+/**
+ * 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/src/lib/structured-mail/normalize.ts b/src/lib/structured-mail/normalize.ts
new file mode 100644
index 00000000..daf169ea
--- /dev/null
+++ b/src/lib/structured-mail/normalize.ts
@@ -0,0 +1,426 @@
+// Mirrors webmail lib/structured-mail/normalize.ts at 916434924; keep the two in step.
+/**
+ * Turn extracted schema.org objects into the card model. Unknown types and
+ * properties are dropped; validation (validate.ts) decides afterwards which
+ * cards are believable.
+ */
+import type { ExtractedItem } from './extract';
+import type {
+ BookingKind,
+ BookingLinks,
+ CardSource,
+ Item,
+ Moment,
+ Parcel,
+ Place,
+ Seat,
+ Shipment,
+ StructuredCard,
+ TransportBooking,
+ TransportKind,
+ TransportLeg,
+} from './types';
+import {
+ address,
+ all,
+ amount,
+ currency,
+ hasType,
+ isObj,
+ isSchemaContext,
+ link,
+ moment,
+ lenientDate,
+ nameOf,
+ obj,
+ orderStatus,
+ people,
+ positiveInt,
+ reservationStatus,
+ str,
+ typesOf,
+ type Obj,
+} from './values';
+
+const RESERVATION_KINDS: Record = {
+ FlightReservation: 'flight',
+ TrainReservation: 'train',
+ BusReservation: 'bus',
+ LodgingReservation: 'lodging',
+ RentalCarReservation: 'rentalcar',
+ EventReservation: 'event',
+ FoodEstablishmentReservation: 'restaurant',
+};
+
+const MAX_OBJECTS = 200;
+
+interface Found {
+ object: Obj;
+ source: CardSource;
+}
+
+/**
+ * Flatten what the extractor found into typed top-level objects: arrays,
+ * `@graph`, ReservationPackage `subReservation`, and `reservationFor` arrays
+ * (one reservation per element). Only objects under a schema.org context
+ * count; nested objects inherit it.
+ */
+function flatten(extracted: ExtractedItem[]): Found[] {
+ const out: Found[] = [];
+ const visit = (value: unknown, source: CardSource, inherited: boolean, depth: number) => {
+ if (out.length >= MAX_OBJECTS || depth > 6) return;
+ if (Array.isArray(value)) {
+ for (const v of value) visit(v, source, inherited, depth + 1);
+ return;
+ }
+ if (!isObj(value)) return;
+ const inContext = inherited || isSchemaContext(value['@context']);
+ if (Array.isArray(value['@graph'])) {
+ visit(value['@graph'], source, inContext, depth + 1);
+ return;
+ }
+ if (!inContext) return;
+ if (hasType(value, ['ReservationPackage'])) {
+ visit(value.subReservation, source, true, depth + 1);
+ return;
+ }
+ const reservationFor = value.reservationFor;
+ if (Array.isArray(reservationFor) && reservationFor.length > 1) {
+ for (const one of reservationFor) out.push({ object: { ...value, reservationFor: one }, source });
+ return;
+ }
+ out.push({ object: value, source });
+ };
+ for (const { item, source } of extracted) visit(item, source, false, 0);
+ return out;
+}
+
+const lower = (s: string | undefined) => (s ?? '').toLowerCase().replace(/\s+/g, ' ').trim();
+
+function place(value: unknown): Place {
+ const v = obj(value);
+ if (!v) {
+ const name = str(value);
+ return name ? { name } : {};
+ }
+ const result: Place = {};
+ const name = str(v.name);
+ if (name) result.name = name;
+ const code = str(v.iataCode)?.toUpperCase();
+ if (code && /^[A-Z]{3}$/.test(code)) result.code = code;
+ const addr = address(v.address);
+ if (addr) result.address = addr;
+ return result;
+}
+
+function bookingLinks(res: Obj, extra: Obj | undefined): BookingLinks {
+ const links: BookingLinks = {};
+ const checkin = link(res.checkinUrl);
+ const manage = link(res.modifyReservationUrl) ?? link(res.url) ?? link(res.Url);
+ const ticket = link(res.ticketDownloadUrl) ?? link(res.ticketPrintUrl) ?? link(extra?.ticketDownloadUrl);
+ if (checkin) links.checkin = checkin;
+ if (manage) links.manage = manage;
+ if (ticket) links.ticket = ticket;
+ return links;
+}
+
+function reference(res: Obj): string | undefined {
+ return str(res.reservationNumber) ?? str(res.reservationId) ?? str(res.confirmationNumber);
+}
+
+function tickets(res: Obj): Obj[] {
+ return all(res.reservedTicket).filter(isObj);
+}
+
+function seatOf(res: Obj): string | undefined {
+ const direct = str(res.airplaneSeat);
+ if (direct) return direct;
+ for (const ticket of tickets(res)) {
+ const seat = obj(ticket.ticketedSeat);
+ const number = str(seat?.seatNumber) ?? str(ticket.ticketedSeat);
+ if (number) {
+ const section = str(seat?.seatSection);
+ return section ? `${section}/${number}` : number;
+ }
+ }
+ return undefined;
+}
+
+function splitFlightNumber(raw: string | undefined, airlineCode: string | undefined): { code?: string; number?: string } {
+ if (!raw) return { code: airlineCode };
+ const compact = raw.replace(/\s+/g, '').toUpperCase();
+ if (airlineCode && compact.startsWith(airlineCode) && /^\d{1,4}[A-Z]?$/.test(compact.slice(airlineCode.length))) {
+ return { code: airlineCode, number: compact.slice(airlineCode.length) };
+ }
+ const match = /^([A-Z0-9]{2})(\d{1,4}[A-Z]?)$/.exec(compact);
+ if (match && /[A-Z]/.test(match[1]) && (!airlineCode || airlineCode === match[1])) return { code: match[1], number: match[2] };
+ return { code: airlineCode, number: compact };
+}
+
+function transport(kind: TransportKind, res: Obj, source: CardSource): TransportBooking {
+ const trip = obj(res.reservationFor) ?? {};
+ const passengers = people(res.underName);
+ let carrier: string | undefined;
+ let carrierCode: string | undefined;
+ let number: string | undefined;
+ let departure: Place;
+ let arrival: Place;
+ if (kind === 'flight') {
+ const airline = obj(trip.airline);
+ const airlineCode = str(airline?.iataCode)?.toUpperCase();
+ carrier = nameOf(trip.airline) ?? nameOf(trip.provider) ?? nameOf(res.provider);
+ const split = splitFlightNumber(str(trip.flightNumber), airlineCode && /^[A-Z0-9]{2}$/.test(airlineCode) ? airlineCode : undefined);
+ carrierCode = split.code;
+ number = split.number;
+ departure = place(trip.departureAirport);
+ arrival = place(trip.arrivalAirport);
+ } else if (kind === 'train') {
+ carrier = nameOf(trip.provider) ?? nameOf(trip.trainCompany) ?? nameOf(res.provider);
+ number = str(trip.trainNumber) ?? str(trip.trainName);
+ departure = place(trip.departureStation);
+ arrival = place(trip.arrivalStation);
+ } else {
+ carrier = nameOf(trip.busCompany) ?? nameOf(trip.provider) ?? nameOf(res.provider);
+ // busName is ignored: FlixBus puts the destination there.
+ number = str(trip.busNumber);
+ departure = place(trip.departureBusStop ?? trip.departureStation);
+ arrival = place(trip.arrivalBusStop ?? trip.arrivalStation);
+ }
+ const depAt = moment(trip.departureTime);
+ const arrAt = moment(trip.arrivalTime);
+ const seat = seatOf(res);
+ const status = reservationStatus(res.reservationStatus);
+ const seats: Seat[] = seat ? [{ ...(passengers[0] ? { passenger: passengers[0] } : {}), seat }] : [];
+ const leg: TransportLeg = {
+ ...(carrier ? { carrier } : {}),
+ ...(carrierCode ? { carrierCode } : {}),
+ ...(number ? { number } : {}),
+ departure: { place: departure, at: depAt ?? { date: '' } },
+ arrival: { place: arrival, ...(arrAt ? { at: arrAt } : {}) },
+ seats,
+ ...(status ? { status } : {}),
+ };
+ const ref = reference(res);
+ return {
+ key: '',
+ kind,
+ ...(ref ? { reference: ref } : {}),
+ ...(status ? { status } : {}),
+ passengers,
+ ...(carrier ? { provider: carrier } : {}),
+ links: bookingLinks(res, tickets(res)[0]),
+ ...(str(res.modifiedTime) ? { modified: str(res.modifiedTime) } : {}),
+ source,
+ legs: [leg],
+ };
+}
+
+function base(kind: BookingKind, res: Obj, source: CardSource) {
+ const ref = reference(res);
+ const status = reservationStatus(res.reservationStatus);
+ return {
+ key: '',
+ kind,
+ ...(ref ? { reference: ref } : {}),
+ ...(status ? { status } : {}),
+ passengers: people(res.underName),
+ links: bookingLinks(res, tickets(res)[0]),
+ ...(str(res.modifiedTime) ? { modified: str(res.modifiedTime) } : {}),
+ source,
+ };
+}
+
+const MISSING: Moment = { date: '' };
+
+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) ?? {};
+ const name = str(target.name) ?? (typeof res.reservationFor === 'string' ? str(res.reservationFor) : undefined);
+ switch (kind) {
+ case 'lodging': {
+ const addr = address(target.address);
+ const phone = str(target.telephone);
+ return {
+ ...base(kind, res, source),
+ kind,
+ name: name ?? '',
+ ...(name ? { provider: name } : {}),
+ ...(addr ? { address: addr } : {}),
+ ...(phone ? { phone } : {}),
+ checkin: moment(res.checkinDate) ?? moment(res.checkinTime) ?? MISSING,
+ checkout: moment(res.checkoutDate) ?? moment(res.checkoutTime) ?? MISSING,
+ };
+ }
+ case 'rentalcar': {
+ const company = nameOf(target.rentalCompany) ?? nameOf(res.provider) ?? nameOf(target.brand);
+ const car = [nameOf(target.brand), str(target.model) ?? str(target.name)].filter(Boolean).join(' ') || undefined;
+ return {
+ ...base(kind, res, source),
+ kind,
+ ...(company ? { company, provider: company } : {}),
+ ...(car ? { car } : {}),
+ pickup: { place: place(res.pickupLocation), at: moment(res.pickupTime) ?? MISSING },
+ dropoff: { place: place(res.dropoffLocation), at: moment(res.dropoffTime) ?? MISSING },
+ };
+ }
+ 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, when the
+ // event starts at a time; with a date-only start it's an all-day span.
+ // The end carries no offset: the day may end on the other side of a
+ // DST change, so resolve.ts reads it in the zone the start resolves to.
+ if (end && !end.time && start?.time) end = { ...end, time: '23:59:59' };
+ return {
+ ...base(kind, res, source),
+ kind,
+ name: name ?? '',
+ ...(venue.name || venue.address ? { venue } : {}),
+ start: start ?? MISSING,
+ ...(end ? { end } : {}),
+ tickets: ticketNumbers,
+ ...(nameOf(res.provider) ? { provider: nameOf(res.provider) } : {}),
+ };
+ }
+ case 'restaurant': {
+ const addr = address(target.address);
+ const partySize = positiveInt(res.partySize);
+ return {
+ ...base(kind, res, source),
+ kind,
+ name: name ?? '',
+ ...(name ? { provider: name } : {}),
+ ...(addr ? { address: addr } : {}),
+ start: moment(res.startTime) ?? moment(res.startDate) ?? MISSING,
+ ...(partySize ? { partySize } : {}),
+ };
+ }
+ }
+}
+
+function items(products: unknown, quantityOf?: (o: Obj) => number | undefined): Item[] {
+ const result: Item[] = [];
+ for (const p of all(products)) {
+ const name = nameOf(p);
+ if (!name) continue;
+ const product = isObj(p) ? p : undefined;
+ const image = product ? link(isObj(product.image) ? product.image.url ?? product.image.contentUrl : product.image) : undefined;
+ const quantity = product && quantityOf ? quantityOf(product) : undefined;
+ result.push({ name, ...(quantity ? { quantity } : {}), ...(image ? { image } : {}) });
+ }
+ return result;
+}
+
+function orderItems(order: Obj): Item[] {
+ const result: Item[] = [];
+ for (const offer of all(order.acceptedOffer).filter(isObj)) {
+ const quantity = positiveInt(offer.eligibleQuantity);
+ for (const item of items(offer.itemOffered)) result.push(quantity ? { ...item, quantity } : item);
+ }
+ for (const entry of all(order.orderedItem).filter(isObj)) {
+ if (hasType(entry, ['OrderItem'])) {
+ const quantity = positiveInt(entry.orderQuantity);
+ for (const item of items(entry.orderedItem)) result.push(quantity ? { ...item, quantity } : item);
+ } else {
+ result.push(...items(entry));
+ }
+ }
+ return result;
+}
+
+function order(o: Obj, source: CardSource): Shipment {
+ const merchant = nameOf(o.merchant) ?? nameOf(o.seller) ?? nameOf(o.broker);
+ const orderNumber = str(o.orderNumber);
+ const status = orderStatus(o.orderStatus);
+ const total = amount(o.price) ?? amount(o.totalPrice);
+ const cur = currency(o.priceCurrency);
+ const action = all(o.potentialAction).filter(isObj).find((a) => hasType(a, ['ViewAction']));
+ const orderUrl = link(o.url) ?? link(o.Url) ?? link(action?.target) ?? link(action?.url);
+ return {
+ kind: 'shipment',
+ key: '',
+ ...(merchant ? { merchant } : {}),
+ ...(orderNumber ? { orderNumber } : {}),
+ ...(status ? { status } : {}),
+ items: orderItems(o),
+ ...(total && cur ? { total: { amount: total, currency: cur } } : {}),
+ ...(orderUrl ? { orderUrl } : {}),
+ parcels: [],
+ source,
+ };
+}
+
+function parcel(p: Obj, source: CardSource): Shipment {
+ const partOf = obj(p.partOfOrder);
+ const base: Shipment = partOf ? order(partOf, source) : { kind: 'shipment', key: '', items: [], parcels: [], source };
+ const carrier = nameOf(p.carrier) ?? nameOf(p.provider);
+ const trackingNumber = str(p.trackingNumber);
+ const trackingUrl = link(p.trackingUrl);
+ // Only the latest bound is an "arrives by" date; expectedArrivalFrom is the
+ // earliest one and would promise too much.
+ const expectedBy = lenientDate(p.expectedArrivalUntil);
+ const shipped: Parcel = {
+ ...(carrier ? { carrier } : {}),
+ ...(trackingNumber ? { trackingNumber } : {}),
+ ...(trackingUrl ? { trackingUrl } : {}),
+ ...(expectedBy ? { expectedBy } : {}),
+ items: items(p.itemShipped),
+ };
+ return { ...base, parcels: [shipped] };
+}
+
+/** One card per top-level object, unmerged and unvalidated, with a merge key. */
+export function normalizeItems(extracted: ExtractedItem[]): StructuredCard[] {
+ const cards: StructuredCard[] = [];
+ for (const { object, source } of flatten(extracted)) {
+ const types = typesOf(object);
+ const kindType = types.find((t) => RESERVATION_KINDS[t]);
+ let card: StructuredCard | undefined;
+ if (kindType) card = reservation(RESERVATION_KINDS[kindType], object, source);
+ else if (types.includes('ParcelDelivery')) card = parcel(object, source);
+ else if (types.includes('Order')) card = order(object, source);
+ if (card) cards.push({ ...card, key: cardKey(card) });
+ }
+ return cards;
+}
+
+/**
+ * Whether the markup holds a reservation of any type (known or not, valid or
+ * not) under a schema.org context. A mail that describes its booking in
+ * markup is read from the markup alone; its Wallet passes are not consulted.
+ */
+export function hasReservation(extracted: ExtractedItem[]): boolean {
+ return flatten(extracted).some(({ object }) => typesOf(object).some((t) => /Reservation$/.test(t)));
+}
+
+/** The merge key: equal keys describe the same booking or order. */
+export function cardKey(card: StructuredCard): string {
+ switch (card.kind) {
+ case 'flight': case 'train': case 'bus': {
+ const leg = card.legs[0];
+ return card.reference ? `${card.kind}|ref:${lower(card.reference)}` : `${card.kind}|leg:${legKey(leg)}`;
+ }
+ case 'lodging':
+ return `lodging|${lower(card.reference)}|${lower(card.name)}|${card.checkin.date}`;
+ case 'rentalcar':
+ return `rentalcar|${lower(card.reference)}|${lower(card.company)}|${card.pickup.at.date}`;
+ case 'event': case 'restaurant':
+ return `${card.kind}|${lower(card.reference)}|${lower(card.name)}|${card.start.date}`;
+ case 'shipment': {
+ if (card.orderNumber) return `shipment|order:${lower(card.orderNumber)}`;
+ const tracking = card.parcels.find((p) => p.trackingNumber)?.trackingNumber;
+ return tracking ? `shipment|parcel:${lower(tracking)}` : `shipment|anon:${lower(card.merchant)}|${card.parcels.length}`;
+ }
+ }
+}
+
+/**
+ * A leg's identity inside a booking: carrier, number, departure day and place.
+ * Leading zeros of the number don't count (BA0123 in a mail, 123 in its
+ * Wallet pass).
+ */
+export function legKey(leg: TransportLeg): string {
+ return [lower(leg.carrierCode ?? leg.carrier), lower(leg.number).replace(/^0+(?=\d)/, ''), leg.departure.at.date, lower(leg.departure.place.code ?? leg.departure.place.name)].join('|');
+}
diff --git a/src/lib/structured-mail/receipt.ts b/src/lib/structured-mail/receipt.ts
new file mode 100644
index 00000000..5d222fd4
--- /dev/null
+++ b/src/lib/structured-mail/receipt.ts
@@ -0,0 +1,35 @@
+// Mirrors authenticationResultsOnReceipt of webmail lib/email-headers.ts at
+// 609464471; keep the two in step. The app's src/lib/email-headers.ts has
+// no receipt check yet.
+
+/**
+ * `with` protocols (RFC 3848) that record an authenticated submission: the
+ * client handed the message in after logging in, so whatever
+ * Authentication-Results it carries below that hop are the client's own text.
+ */
+const SUBMISSION_PROTOCOL_RE = /\bwith\s+(?:UTF8)?(?:E?SMTP|LMTP)S?A\b/i;
+
+/**
+ * Whether the topmost Authentication-Results header looks like it was
+ * written by a server that received the message, rather than by whoever
+ * composed it. A receiving server writes its Received line first and its
+ * Authentication-Results below it (Stalwart and Gmail both do), so: a
+ * Received header must sit above the topmost Authentication-Results, and the
+ * nearest one must not record an authenticated submission. Takes the headers
+ * in message order.
+ *
+ * This only catches a forged header on its own. Stalwart (0.16 and 1.0)
+ * keeps both headers when a signed-in user writes them into a mail they
+ * submit over JMAP, and adds nothing above them, so a forged Received line
+ * followed by a forged "dkim=pass" arrives exactly like mail from outside.
+ * Only the server can tell them apart.
+ */
+export function authenticationResultsOnReceipt(headers: readonly { name: string; value: string }[]): boolean {
+ const index = headers.findIndex((header) => header.name.toLowerCase() === 'authentication-results');
+ if (index < 0) return false;
+ for (let i = index - 1; i >= 0; i--) {
+ if (headers[i].name.toLowerCase() !== 'received') continue;
+ return !SUBMISSION_PROTOCOL_RE.test(headers[i].value);
+ }
+ return false;
+}
diff --git a/src/lib/structured-mail/resolve.ts b/src/lib/structured-mail/resolve.ts
new file mode 100644
index 00000000..17096530
--- /dev/null
+++ b/src/lib/structured-mail/resolve.ts
@@ -0,0 +1,154 @@
+// Mirrors webmail lib/structured-mail/resolve.ts at 90d7afc34; keep the two in step.
+/**
+ * Second validation pass, once zones can be looked up: give every time its
+ * zone and instant, then check order and durations on UTC instants (an
+ * eastbound flight over the date line lands "earlier" on the wall clock).
+ * A booking whose times don't hold together is dropped.
+ */
+import { countryZone, resolveZone, wallClockAt, zoneOffsetAt, zonedTimeToUtc, type ResolvedZone } from './time-zones';
+import type { Moment, Place, StructuredCard, TransportBooking, TransportLeg } from './types';
+
+type ZoneOf = (code: string | undefined) => string | undefined;
+
+const MINUTE = 60_000;
+const HOUR = 60 * MINUTE;
+const LIMITS = {
+ flight: { min: 10 * MINUTE, max: 20 * HOUR },
+ train: { min: MINUTE, max: 48 * HOUR },
+ bus: { min: MINUTE, max: 48 * HOUR },
+} as const;
+
+/** Whether resolving these cards needs the airport table. */
+export function needsAirportZones(cards: StructuredCard[]): boolean {
+ return cards.some((card) => card.kind === 'flight');
+}
+
+interface Named { zone: string; source: 'airport' | 'country' }
+
+function namedZone(place: Place | undefined, kind: StructuredCard['kind'], zoneOf: ZoneOf | undefined, floating: boolean): Named | undefined {
+ if (kind === 'flight') {
+ const zone = zoneOf?.(place?.code);
+ return zone ? { zone, source: 'airport' } : undefined;
+ }
+ const zone = countryZone(place?.address?.country, { floating });
+ return zone ? { zone, source: 'country' } : undefined;
+}
+
+/**
+ * Attach zone and instant to a timed moment. 'drop' when table and mail
+ * disagree, or when a floating local time falls into a DST gap or overlap.
+ * An instant without a known wall clock (a pass's UTC time, `instantOnly`)
+ * takes date, time and offset from the place's zone, or is dropped without one.
+ */
+function resolveMoment(m: Moment, named: Named | undefined): Moment | 'drop' {
+ if (m.instantOnly) {
+ if (!named || !m.utc) return 'drop';
+ const ms = Date.parse(m.utc);
+ const local = wallClockAt(ms, named.zone);
+ return { date: local.date, time: local.time, offsetMinutes: zoneOffsetAt(named.zone, ms), utc: m.utc, zone: named.zone };
+ }
+ if (!m.time) return m;
+ const utcMs = m.utc ? Date.parse(m.utc) : undefined;
+ const resolved: ResolvedZone | 'mismatch' = resolveZone({ named, offsetMinutes: m.offsetMinutes, utcMs });
+ if (resolved === 'mismatch') return 'drop';
+ if (m.utc || resolved.zone === null) return { ...m, zone: resolved.zone };
+ const utc = zonedTimeToUtc(m.date, m.time, resolved.zone);
+ if (utc === null) return 'drop';
+ return { ...m, zone: resolved.zone, utc: new Date(utc).toISOString().replace('.000Z', 'Z') };
+}
+
+function resolveLeg(kind: TransportBooking['kind'], leg: TransportLeg, zoneOf: ZoneOf | undefined): TransportLeg | null {
+ const dep = resolveMoment(leg.departure.at, namedZone(leg.departure.place, kind, zoneOf, leg.departure.at.offsetMinutes === undefined));
+ if (dep === 'drop' || !dep.utc) return null;
+ let arrivalAt: Moment | undefined;
+ if (leg.arrival?.at) {
+ const arr = resolveMoment(leg.arrival.at, namedZone(leg.arrival.place, kind, zoneOf, leg.arrival.at.offsetMinutes === undefined));
+ if (arr === 'drop' || !arr.utc) return null;
+ const duration = Date.parse(arr.utc) - Date.parse(dep.utc);
+ if (duration < LIMITS[kind].min || duration > LIMITS[kind].max) return null;
+ arrivalAt = arr;
+ }
+ return {
+ ...leg,
+ departure: { ...leg.departure, at: dep },
+ ...(leg.arrival ? { arrival: { ...leg.arrival, ...(arrivalAt ? { at: arrivalAt } : {}) } } : {}),
+ };
+}
+
+/** Order two resolved moments: by instant when both have one, else by wall clock. */
+function notBefore(later: Moment, earlier: Moment): boolean {
+ if (later.utc && earlier.utc) return Date.parse(later.utc) >= Date.parse(earlier.utc);
+ return `${later.date}T${later.time ?? '00:00:00'}` >= `${earlier.date}T${earlier.time ?? '00:00:00'}`;
+}
+
+function resolveCard(card: StructuredCard, zoneOf: ZoneOf | undefined): StructuredCard | null {
+ switch (card.kind) {
+ case 'flight': case 'train': case 'bus': {
+ const legs: TransportLeg[] = [];
+ for (const leg of card.legs) {
+ const resolved = resolveLeg(card.kind, leg, zoneOf);
+ if (!resolved) return null;
+ legs.push(resolved);
+ }
+ return { ...card, legs };
+ }
+ case 'lodging': {
+ const place: Place = { address: card.address };
+ const resolveEnd = (m: Moment): Moment | 'drop' => {
+ const named = namedZone(place, card.kind, zoneOf, m.offsetMinutes === undefined);
+ // A time with neither offset nor a known zone says nothing reliable:
+ // keep the date only.
+ if (m.time && m.offsetMinutes === undefined && !named) return { date: m.date };
+ return resolveMoment(m, named);
+ };
+ const checkin = resolveEnd(card.checkin);
+ const checkout = resolveEnd(card.checkout);
+ if (checkin === 'drop' || checkout === 'drop') return null;
+ if (checkin.time && checkout.time && !notBefore(checkout, checkin)) return null;
+ return { ...card, checkin, checkout };
+ }
+ case 'rentalcar': {
+ const pickupAt = resolveMoment(card.pickup.at, namedZone(card.pickup.place, card.kind, zoneOf, card.pickup.at.offsetMinutes === undefined));
+ const dropoffAt = resolveMoment(card.dropoff.at, namedZone(card.dropoff.place, card.kind, zoneOf, card.dropoff.at.offsetMinutes === undefined));
+ if (pickupAt === 'drop' || dropoffAt === 'drop') return null;
+ if (!notBefore(dropoffAt, pickupAt) || notBefore(pickupAt, dropoffAt)) return null;
+ return { ...card, pickup: { ...card.pickup, at: pickupAt }, dropoff: { ...card.dropoff, at: dropoffAt } };
+ }
+ case 'event': {
+ const named = (m: Moment) => namedZone(card.venue, card.kind, zoneOf, m.offsetMinutes === undefined);
+ const start = resolveMoment(card.start, named(card.start));
+ if (start === 'drop') return null;
+ // An end without its own offset (a date-only end turned into the end of
+ // that day) belongs to the start's zone when the venue names none.
+ const endNamed = card.end && card.end.offsetMinutes === undefined && !named(card.end) && start.zone
+ ? { zone: start.zone, source: 'country' as const }
+ : card.end ? named(card.end) : undefined;
+ const end = card.end ? resolveMoment(card.end, endNamed) : undefined;
+ if (end === 'drop') return null;
+ if (end && !notBefore(end, start)) return null;
+ return { ...card, start, ...(end ? { end } : {}) };
+ }
+ case 'restaurant': {
+ const start = resolveMoment(card.start, namedZone({ address: card.address }, card.kind, zoneOf, card.start.offsetMinutes === undefined));
+ if (start === 'drop') return null;
+ return { ...card, start };
+ }
+ case 'shipment':
+ return card;
+ }
+}
+
+/**
+ * Resolve zones and drop cards whose times don't hold together. `zoneOf` is
+ * the airport lookup from loadAirportZones(); without it, flight cards are
+ * dropped (the card must not show before its zones are known).
+ */
+export function resolveCards(cards: StructuredCard[], zoneOf?: ZoneOf): StructuredCard[] {
+ const out: StructuredCard[] = [];
+ for (const card of cards) {
+ if (card.kind === 'flight' && !zoneOf) continue;
+ const resolved = resolveCard(card, zoneOf);
+ if (resolved) out.push(resolved);
+ }
+ return out;
+}
diff --git a/src/lib/structured-mail/time-zones.ts b/src/lib/structured-mail/time-zones.ts
new file mode 100644
index 00000000..42f10d65
--- /dev/null
+++ b/src/lib/structured-mail/time-zones.ts
@@ -0,0 +1,195 @@
+// Mirrors webmail lib/structured-mail/time-zones.ts at a529e6e57; keep the two in step.
+/**
+ * Time zones for booking cards. Markup gives local times with a UTC offset
+ * and never an IANA zone; the card shows the place's local time and the
+ * calendar wants a zone that stays right across DST changes. Plain
+ * TypeScript on Intl, no React, so the native app can reuse it.
+ */
+
+const MINUTE = 60_000;
+
+/**
+ * Countries that use one IANA zone throughout, so an address in them names
+ * the zone. Countries whose territory spans zones (US, CA, RU, BR, AU, MX,
+ * ID, ES with the Canaries, PT with the Azores, ...) are left out on purpose.
+ */
+const SINGLE_ZONE_COUNTRIES: Record = {
+ AD: 'Europe/Andorra', AE: 'Asia/Dubai', AL: 'Europe/Tirane', AR: 'America/Argentina/Buenos_Aires',
+ AT: 'Europe/Vienna', BA: 'Europe/Sarajevo', BE: 'Europe/Brussels', BG: 'Europe/Sofia',
+ BY: 'Europe/Minsk', CH: 'Europe/Zurich', CN: 'Asia/Shanghai', CO: 'America/Bogota',
+ CR: 'America/Costa_Rica', CU: 'America/Havana', CY: 'Asia/Nicosia', CZ: 'Europe/Prague',
+ DE: 'Europe/Berlin', DK: 'Europe/Copenhagen', DO: 'America/Santo_Domingo', EE: 'Europe/Tallinn',
+ EG: 'Africa/Cairo', FI: 'Europe/Helsinki', FR: 'Europe/Paris', GB: 'Europe/London',
+ GR: 'Europe/Athens', HK: 'Asia/Hong_Kong', HR: 'Europe/Zagreb', HU: 'Europe/Budapest',
+ IE: 'Europe/Dublin', IL: 'Asia/Jerusalem', IN: 'Asia/Kolkata', IS: 'Atlantic/Reykjavik',
+ IT: 'Europe/Rome', JM: 'America/Jamaica', JP: 'Asia/Tokyo', KE: 'Africa/Nairobi',
+ KR: 'Asia/Seoul', LI: 'Europe/Vaduz', LK: 'Asia/Colombo', LT: 'Europe/Vilnius',
+ LU: 'Europe/Luxembourg', LV: 'Europe/Riga', MA: 'Africa/Casablanca', MC: 'Europe/Monaco',
+ MD: 'Europe/Chisinau', ME: 'Europe/Podgorica', MK: 'Europe/Skopje', MT: 'Europe/Malta',
+ MY: 'Asia/Kuala_Lumpur', NL: 'Europe/Amsterdam', NO: 'Europe/Oslo', NP: 'Asia/Kathmandu',
+ NZ: 'Pacific/Auckland', PA: 'America/Panama', PE: 'America/Lima', PH: 'Asia/Manila',
+ PL: 'Europe/Warsaw', QA: 'Asia/Qatar', RO: 'Europe/Bucharest', RS: 'Europe/Belgrade',
+ SE: 'Europe/Stockholm', SG: 'Asia/Singapore', SI: 'Europe/Ljubljana', SK: 'Europe/Bratislava',
+ SM: 'Europe/San_Marino', TH: 'Asia/Bangkok', TN: 'Africa/Tunis', TR: 'Europe/Istanbul',
+ TW: 'Asia/Taipei', UY: 'America/Montevideo', VA: 'Europe/Vatican', VN: 'Asia/Ho_Chi_Minh',
+ ZA: 'Africa/Johannesburg',
+};
+
+/**
+ * Countries in the table whose postal addresses also cover overseas places
+ * in other zones (a hotel on Réunion often writes "France"). Good enough
+ * when the mail's offset confirms the zone, not for a time without one.
+ */
+const NOT_FOR_FLOATING_TIMES = new Set(['FR']);
+
+/** The single IANA zone of a country (ISO 3166-1 alpha-2), if it has one. */
+export function countryZone(country: string | undefined, opts: { floating?: boolean } = {}): string | undefined {
+ const code = country?.trim().toUpperCase();
+ if (!code || !/^[A-Z]{2}$/.test(code)) return undefined;
+ if (opts.floating && NOT_FOR_FLOATING_TIMES.has(code)) return undefined;
+ return SINGLE_ZONE_COUNTRIES[code];
+}
+
+const offsetFormatters = new Map();
+
+function partsFormatter(zone: string): Intl.DateTimeFormat {
+ let formatter = offsetFormatters.get(zone);
+ if (!formatter) {
+ formatter = new Intl.DateTimeFormat('en-US', {
+ timeZone: zone,
+ hourCycle: 'h23',
+ year: 'numeric', month: '2-digit', day: '2-digit',
+ hour: '2-digit', minute: '2-digit', second: '2-digit',
+ });
+ offsetFormatters.set(zone, formatter);
+ }
+ return formatter;
+}
+
+export function isValidTimeZone(zone: string): boolean {
+ try {
+ partsFormatter(zone);
+ return true;
+ } catch {
+ return false;
+ }
+}
+
+interface WallClock { year: number; month: number; day: number; hour: number; minute: number; second: number }
+
+function wallClockParts(utcMs: number, zone: string): WallClock {
+ const parts: Record = {};
+ for (const part of partsFormatter(zone).formatToParts(new Date(utcMs))) {
+ if (part.type !== 'literal') parts[part.type] = Number(part.value);
+ }
+ return { year: parts.year, month: parts.month, day: parts.day, hour: parts.hour % 24, minute: parts.minute, second: parts.second };
+}
+
+/** UTC offset of `zone` at `utcMs`, in minutes east of UTC (Berlin in winter: 60). */
+export function zoneOffsetAt(zone: string, utcMs: number): number {
+ const w = wallClockParts(utcMs, zone);
+ const asUtc = Date.UTC(w.year, w.month - 1, w.day, w.hour, w.minute, w.second);
+ return Math.round((asUtc - Math.floor(utcMs / 1000) * 1000) / MINUTE);
+}
+
+const pad = (n: number, width = 2) => String(n).padStart(width, '0');
+
+/** Local date and time in `zone` at `utcMs`: `{ date: 'YYYY-MM-DD', time: 'HH:mm:ss' }`. */
+export function wallClockAt(utcMs: number, zone: string): { date: string; time: string } {
+ const w = wallClockParts(utcMs, zone);
+ return {
+ date: `${pad(w.year, 4)}-${pad(w.month)}-${pad(w.day)}`,
+ time: `${pad(w.hour)}:${pad(w.minute)}:${pad(w.second)}`,
+ };
+}
+
+/**
+ * The UTC instant of a local wall-clock time in `zone`. A time that falls in
+ * a DST gap or overlap has no single answer; returns null then, so a caller
+ * never shows a guessed instant.
+ */
+export function zonedTimeToUtc(date: string, time: string, zone: string): number | null {
+ const d = /^(\d{4})-(\d{2})-(\d{2})$/.exec(date);
+ const t = /^(\d{2}):(\d{2})(?::(\d{2}))?$/.exec(time);
+ if (!d || !t) return null;
+ const naive = Date.UTC(+d[1], +d[2] - 1, +d[3], +t[1], +t[2], +(t[3] ?? 0));
+ const check = new Date(naive);
+ if (check.getUTCMonth() !== +d[2] - 1 || check.getUTCDate() !== +d[3] || check.getUTCHours() !== +t[1] || +t[2] > 59 || +(t[3] ?? 0) > 59) {
+ return null;
+ }
+ const candidates = new Set();
+ for (const probe of [naive - 14 * 60 * MINUTE, naive, naive + 14 * 60 * MINUTE]) {
+ const offset = zoneOffsetAt(zone, probe);
+ const utc = naive - offset * MINUTE;
+ if (zoneOffsetAt(zone, utc) === offset) candidates.add(utc);
+ }
+ return candidates.size === 1 ? [...candidates][0] : null;
+}
+
+/**
+ * A zone that has a fixed UTC offset. Whole hours map to `Etc/GMT∓N`, whose
+ * sign is inverted (UTC−8 is `Etc/GMT+8`). Anything else, a half-hour offset
+ * or one outside `Etc/GMT-14` … `Etc/GMT+12`, has no Etc zone: the caller
+ * keeps the UTC instant and gets `Etc/UTC`.
+ */
+export function fixedOffsetZone(offsetMinutes: number): string {
+ if (offsetMinutes === 0 || offsetMinutes % 60 !== 0) return 'Etc/UTC';
+ const hours = offsetMinutes / 60;
+ if (hours > 14 || hours < -12) return 'Etc/UTC';
+ return hours > 0 ? `Etc/GMT-${hours}` : `Etc/GMT+${-hours}`;
+}
+
+type AirportTable = { zones: string[]; airports: Record };
+let airportTable: Promise | null = null;
+
+/**
+ * Load the IATA → IANA table (scripts/generate-airport-timezones.mjs). It is
+ * its own chunk, fetched from the app's origin the first time a flight card
+ * needs it. A failed load is forgotten so the next card can retry.
+ */
+export function loadAirportZones(): Promise<(code: string | undefined) => string | undefined> {
+ if (!airportTable) {
+ airportTable = import('./data/airport-timezones.json').then((mod) => (mod.default ?? mod) as AirportTable);
+ airportTable.catch(() => { airportTable = null; });
+ }
+ return airportTable.then((table) => (code) => {
+ const key = code?.trim().toUpperCase();
+ if (!key || !/^[A-Z]{3}$/.test(key)) return undefined;
+ const index = table.airports[key];
+ return index === undefined ? undefined : table.zones[index];
+ });
+}
+
+export type ZoneSource = 'airport' | 'country' | 'offset' | 'utc' | 'floating';
+
+export interface ResolvedZone {
+ /** IANA zone, or null for a floating time that stays as written. */
+ zone: string | null;
+ source: ZoneSource;
+}
+
+/**
+ * Pick the zone for one end of a booking. `named` is the zone the place
+ * implies (airport table or single-zone country). With an offset from the
+ * mail, the named zone must give that same offset at that instant, otherwise
+ * the mail and the table disagree and the result is `mismatch` (the caller
+ * drops the booking: we can't tell which of the two is wrong). Without a
+ * named zone the offset gives a fixed-offset zone. Without an offset the
+ * time is read in the named zone, or stays floating.
+ */
+export function resolveZone(input: {
+ named?: { zone: string; source: 'airport' | 'country' };
+ offsetMinutes?: number;
+ utcMs?: number;
+}): ResolvedZone | 'mismatch' {
+ const { named, offsetMinutes, utcMs } = input;
+ if (offsetMinutes !== undefined && utcMs !== undefined) {
+ if (named) {
+ return zoneOffsetAt(named.zone, utcMs) === offsetMinutes ? { zone: named.zone, source: named.source } : 'mismatch';
+ }
+ const zone = fixedOffsetZone(offsetMinutes);
+ return { zone, source: zone === 'Etc/UTC' && offsetMinutes !== 0 ? 'utc' : 'offset' };
+ }
+ if (named) return { zone: named.zone, source: named.source };
+ return { zone: null, source: 'floating' };
+}
diff --git a/src/lib/structured-mail/trust.ts b/src/lib/structured-mail/trust.ts
new file mode 100644
index 00000000..4183d2d7
--- /dev/null
+++ b/src/lib/structured-mail/trust.ts
@@ -0,0 +1,156 @@
+// Mirrors webmail lib/structured-mail/trust.ts at 8098039d6; keep the two in step.
+/**
+ * Whether a message may show booking cards at all. A card looks official and
+ * a calendar entry outlives the mail, so the bar is high: the receiving
+ * server must have tied the mail to its From domain, the mail must not be a
+ * forward, and the markup must come from the message itself. Plain
+ * TypeScript on the Email shape; the admin gate is checked by the caller.
+ */
+// App: the app's Email carries only the raw `headers` list, so the
+// Authentication-Results are parsed here (src/lib/email-headers.ts) unless
+// the caller passes them, as the webmail's full message fetch does.
+import {
+ getSenderVerification,
+ hasAlignedAuthenticationPass,
+ headerValues,
+ parseAuthenticationResults,
+ type AuthenticationResults,
+} from '../email-headers';
+import type { BodyPart, Email } from '../../api/types';
+import { hasForwardPrefix } from './forward-prefix';
+import { authenticationResultsOnReceipt } from './receipt';
+
+/** A body part with its children (RFC 8621 bodyStructure), which the app's BodyPart leaves out. */
+export type EmailBodyPart = BodyPart & { subParts?: EmailBodyPart[] };
+
+export type UntrustedReason =
+ | 'no_authentication_results'
+ | 'not_written_on_receipt'
+ | 'sender'
+ | 'sender_not_verified'
+ | 'personal_mailbox'
+ | 'forwarded'
+ | 'attached_message'
+ | 'truncated';
+
+export type StructuredMailTrust = { trusted: true } | { trusted: false; reason: UntrustedReason };
+
+export type TrustInput = Pick & {
+ bodyStructure?: EmailBodyPart;
+ /** Parsed Authentication-Results; read from `headers` when absent. */
+ authenticationResults?: AuthenticationResults;
+ /** See authenticationResultsOnReceipt; read from `headers` when absent. */
+ authenticationResultsOnReceipt?: boolean;
+};
+
+/** The message's Authentication-Results, in message order, parsed; undefined without any. */
+function authenticationResultsOf(email: TrustInput): AuthenticationResults | undefined {
+ if (email.authenticationResults) return email.authenticationResults;
+ const values = headerValues(headerList(email.headers), 'Authentication-Results');
+ return values.length > 0 ? parseAuthenticationResults(values) : undefined;
+}
+
+/**
+ * Personal mailbox providers. A booking mail from one of these is someone
+ * forwarding it (perhaps with the "Fwd:" removed); airlines and shops don't
+ * send from them.
+ */
+const PERSONAL_MAILBOX_DOMAINS = [
+ 'gmail.com', 'googlemail.com', 'msn.com', 'aol.com', 'icloud.com', 'me.com', 'mac.com',
+ 'web.de', 't-online.de', 'freenet.de', 'posteo.de', 'posteo.net', 'mailbox.org', 'proton.me',
+ 'protonmail.com', 'protonmail.ch', 'pm.me', 'mail.ru', 'gmx.com', 'gmx.net', 'gmx.de', 'gmx.at', 'gmx.ch',
+ 'tutanota.com', 'tuta.io', 'zoho.com', 'fastmail.com', 'hey.com',
+];
+const PERSONAL_MAILBOX_FAMILIES = /^(?:outlook|hotmail|live|yahoo|ymail|yandex)\.[a-z.]+$/;
+
+export function isPersonalMailboxDomain(domain: string): boolean {
+ const d = domain.toLowerCase();
+ return PERSONAL_MAILBOX_DOMAINS.includes(d) || PERSONAL_MAILBOX_FAMILIES.test(d);
+}
+
+/** Separator lines mail clients put above a forwarded message. */
+const FORWARD_SEPARATOR_RE = new RegExp([
+ 'forwarded message', 'begin forwarded message', 'original message', 'weitergeleitete nachricht',
+ 'ursprüngliche nachricht', 'message transféré', 'message d\'origine', 'mensaje reenviado', 'mensaje original',
+ 'messaggio inoltrato', 'messaggio originale', 'doorgestuurd bericht', 'oorspronkelijk bericht', 'mensagem encaminhada',
+].map((s) => `-{2,}\\s*${s}\\s*-{2,}|^\\s*${s}:?\\s*$`).join('|'), 'im');
+
+/** Outlook's forward header block: From / Sent / To ... Subject, in a few languages. */
+const OUTLOOK_HEADER_RE = /^\s*(?:from|von|de|da|van|od):[^\n]*\n\s*(?:sent|gesendet|envoyé|inviato|enviado|verzonden|wysłano):[^\n]*\n(?:[^\n]*\n){0,3}\s*(?:subject|betreff|objet|oggetto|asunto|onderwerp|temat):/im;
+
+function htmlToLines(html: string): string {
+ return html
+ .replace(/<(?:script|style)[\s\S]*?<\/(?:script|style)>/gi, ' ')
+ .replace(/<(?:br|\/p|\/div|\/tr|\/li|\/h\d|\/td)\b[^>]*>/gi, '\n')
+ .replace(/<[^>]+>/g, ' ')
+ .replace(/ /gi, ' ')
+ .replace(/[ \t]+/g, ' ');
+}
+
+function looksForwarded(text: string): boolean {
+ return FORWARD_SEPARATOR_RE.test(text) || OUTLOOK_HEADER_RE.test(text);
+}
+
+function partValue(email: TrustInput, part: EmailBodyPart | undefined): { value: string; truncated: boolean } | undefined {
+ if (!part?.partId) return undefined;
+ const value = email.bodyValues?.[part.partId];
+ return value ? { value: value.value ?? '', truncated: !!value.isTruncated } : undefined;
+}
+
+/** The HTML part a card reads: the message's own first text/html part. */
+export function structuredHtmlPart(email: Pick): { value: string; truncated: boolean } | undefined {
+ const part = email.htmlBody?.find((p) => p.type === 'text/html');
+ if (!part?.partId) return undefined;
+ const value = email.bodyValues?.[part.partId];
+ return value ? { value: value.value ?? '', truncated: !!value.isTruncated } : undefined;
+}
+
+/** Whether `partId` sits inside an attached message (message/rfc822) in the body tree. */
+function insideAttachedMessage(root: EmailBodyPart | undefined, partId: string | undefined): boolean {
+ if (!root || !partId) return false;
+ const walk = (part: EmailBodyPart, inMessage: boolean): boolean | undefined => {
+ const nested = inMessage || (part !== root && part.type?.toLowerCase() === 'message/rfc822');
+ if (part.partId === partId) return nested;
+ for (const child of part.subParts ?? []) {
+ const found = walk(child, nested);
+ if (found !== undefined) return found;
+ }
+ return undefined;
+ };
+ return walk(root, false) ?? false;
+}
+
+function headerList(headers: TrustInput['headers']): { name: string; value: string }[] | undefined {
+ return Array.isArray(headers) ? (headers as unknown as { name: string; value: string }[]) : undefined;
+}
+
+export function getStructuredMailTrust(email: TrustInput): StructuredMailTrust {
+ const auth = authenticationResultsOf(email);
+ if (!auth) return { trusted: false, reason: 'no_authentication_results' };
+
+ const list = headerList(email.headers);
+ const onReceipt = email.authenticationResultsOnReceipt ?? (list ? authenticationResultsOnReceipt(list) : false);
+ if (!onReceipt) return { trusted: false, reason: 'not_written_on_receipt' };
+
+ const senders = email.from ?? [];
+ const from = senders[0]?.email ?? '';
+ const domain = from.slice(from.lastIndexOf('@') + 1).toLowerCase();
+ if (senders.length !== 1 || !from.includes('@') || !domain.includes('.')) return { trusted: false, reason: 'sender' };
+
+ if (getSenderVerification(auth, from) !== null || !hasAlignedAuthenticationPass(auth, from)) {
+ return { trusted: false, reason: 'sender_not_verified' };
+ }
+ if (isPersonalMailboxDomain(domain)) return { trusted: false, reason: 'personal_mailbox' };
+
+ const html = structuredHtmlPart(email);
+ const htmlPartId = email.htmlBody?.find((p) => p.type === 'text/html')?.partId;
+ if (insideAttachedMessage(email.bodyStructure, htmlPartId)) return { trusted: false, reason: 'attached_message' };
+ if (html?.truncated) return { trusted: false, reason: 'truncated' };
+
+ if (hasForwardPrefix(email.subject)) return { trusted: false, reason: 'forwarded' };
+ const text = partValue(email, email.textBody?.find((p) => p.type === 'text/plain'));
+ if ((html && looksForwarded(htmlToLines(html.value))) || (text && looksForwarded(text.value))) {
+ return { trusted: false, reason: 'forwarded' };
+ }
+ return { trusted: true };
+}
diff --git a/src/lib/structured-mail/types.ts b/src/lib/structured-mail/types.ts
new file mode 100644
index 00000000..b8985380
--- /dev/null
+++ b/src/lib/structured-mail/types.ts
@@ -0,0 +1,183 @@
+// Mirrors webmail lib/structured-mail/types.ts at 1d4cae961; keep the two in step.
+/**
+ * The model booking, shipping and order mail is read into. Plain data, no
+ * React and no app imports, so the native app can reuse the parser.
+ */
+
+export type BookingKind = 'flight' | 'train' | 'bus' | 'lodging' | 'rentalcar' | 'event' | 'restaurant';
+export type TransportKind = 'flight' | 'train' | 'bus';
+export type ReservationStatus = 'confirmed' | 'pending' | 'hold' | 'cancelled';
+export type OrderStatus =
+ | 'payment_due' | 'processing' | 'problem' | 'in_transit'
+ | 'pickup_available' | 'returned' | 'delivered' | 'cancelled';
+
+/** A time as the mail wrote it, local to the place it belongs to. */
+export interface Moment {
+ /** YYYY-MM-DD, local. */
+ date: string;
+ /** HH:mm:ss, local; absent for a date-only value. */
+ time?: string;
+ /** Minutes east of UTC, from the value; absent when the mail gave none. */
+ offsetMinutes?: number;
+ /** ISO instant; set when the time and its zone or offset are known. */
+ utc?: string;
+ /**
+ * IANA zone after resolution (resolveCards): airport, country or a
+ * fixed-offset zone; null for a time that stays floating. Absent before.
+ */
+ zone?: string | null;
+ /**
+ * Set by the .pkpass reader for a UTC time with no zone for its place:
+ * `utc` is the instant, but date, time and offset are UTC, not local.
+ * Resolution takes the wall clock from the airport's zone and clears it.
+ */
+ instantOnly?: true;
+}
+
+export interface Address {
+ street?: string;
+ locality?: string;
+ region?: string;
+ postalCode?: string;
+ /** ISO 3166-1 alpha-2, when the mail gave one. */
+ country?: string;
+ /** Country as the mail named it, when that wasn't a code. */
+ countryName?: string;
+}
+
+export interface Place {
+ name?: string;
+ /** IATA airport code for airports, upper case. */
+ code?: string;
+ address?: Address;
+}
+
+/** A link a card may show: http(s) only, host shown next to it. */
+export interface Link {
+ url: string;
+ host: string;
+}
+
+export interface Seat {
+ passenger?: string;
+ seat: string;
+}
+
+export interface TransportLeg {
+ carrier?: string;
+ /** IATA airline code for flights. */
+ carrierCode?: string;
+ number?: string;
+ departure: { place: Place; at: Moment };
+ arrival?: { place: Place; at?: Moment };
+ seats: Seat[];
+ status?: ReservationStatus;
+}
+
+export interface BookingLinks {
+ checkin?: Link;
+ manage?: Link;
+ ticket?: Link;
+}
+
+export type CardSource = 'json-ld' | 'microdata' | 'pkpass';
+
+interface BookingBase {
+ /** Merge key: equal keys are the same booking. */
+ key: string;
+ kind: BookingKind;
+ reference?: string;
+ status?: ReservationStatus;
+ passengers: string[];
+ provider?: string;
+ links: BookingLinks;
+ modified?: string;
+ source: CardSource;
+}
+
+export interface TransportBooking extends BookingBase {
+ kind: TransportKind;
+ legs: TransportLeg[];
+}
+
+export interface LodgingBooking extends BookingBase {
+ kind: 'lodging';
+ name: string;
+ address?: Address;
+ phone?: string;
+ checkin: Moment;
+ checkout: Moment;
+}
+
+export interface RentalCarBooking extends BookingBase {
+ kind: 'rentalcar';
+ company?: string;
+ car?: string;
+ pickup: { place: Place; at: Moment };
+ dropoff: { place: Place; at: Moment };
+}
+
+export interface EventBooking extends BookingBase {
+ kind: 'event';
+ name: string;
+ venue?: Place;
+ start: Moment;
+ end?: Moment;
+ tickets: string[];
+}
+
+export interface RestaurantBooking extends BookingBase {
+ kind: 'restaurant';
+ name: string;
+ address?: Address;
+ start: Moment;
+ partySize?: number;
+}
+
+export type Booking = TransportBooking | LodgingBooking | RentalCarBooking | EventBooking | RestaurantBooking;
+
+export interface Item {
+ name: string;
+ quantity?: number;
+ /** Remote image; a card shows it only when remote content may load. */
+ image?: Link;
+}
+
+export interface Parcel {
+ carrier?: string;
+ trackingNumber?: string;
+ trackingUrl?: Link;
+ expectedBy?: Moment;
+ items: Item[];
+}
+
+/** An Order and/or the ParcelDeliveries for one order number. */
+export interface Shipment {
+ kind: 'shipment';
+ key: string;
+ merchant?: string;
+ orderNumber?: string;
+ status?: OrderStatus;
+ items: Item[];
+ /** Amount normalised to a dot decimal ("399.00"). */
+ total?: { amount: string; currency: string };
+ orderUrl?: Link;
+ parcels: Parcel[];
+ source: CardSource;
+}
+
+export type StructuredCard = Booking | Shipment;
+
+/** A card plus the reasons validation found to doubt it (empty = fine). */
+export interface Candidate {
+ card: StructuredCard;
+ problems: string[];
+}
+
+export function isBooking(card: StructuredCard): card is Booking {
+ return card.kind !== 'shipment';
+}
+
+export function isTransport(card: StructuredCard): card is TransportBooking {
+ return card.kind === 'flight' || card.kind === 'train' || card.kind === 'bus';
+}
diff --git a/src/lib/structured-mail/validate.ts b/src/lib/structured-mail/validate.ts
new file mode 100644
index 00000000..d673b0d5
--- /dev/null
+++ b/src/lib/structured-mail/validate.ts
@@ -0,0 +1,134 @@
+// Mirrors webmail lib/structured-mail/validate.ts at 76b0c2fba; keep the two in step.
+/**
+ * Plausibility checks that don't need time zones. A card with any problem is
+ * dropped, together with every other part of the same booking (merge.ts):
+ * a wrong card is worse than no card. Time order and durations are checked
+ * once the zones are known (resolve.ts).
+ */
+import type { Candidate, Moment, StructuredCard, TransportLeg } from './types';
+
+const DAY = 86_400_000;
+const PAST_WINDOW_DAYS = 366;
+const TRANSPORT_FUTURE_DAYS = 400;
+const OTHER_FUTURE_DAYS = 760;
+
+/**
+ * Values from Google's markup documentation examples. Senders paste those
+ * examples into newsletters (zave.it sent the "Eva Green" flight in a
+ * cashback mail, with a passing DKIM signature); they are never real.
+ */
+const DOCUMENTATION_VALUES = new Set([
+ 'rxj34p', 'abc456', 'e123456789', 'ot12345', '546323', '3453291231', '176057', '123-4567890-1234567',
+]);
+
+function dayNumber(date: string): number | undefined {
+ const ms = Date.parse(`${date}T00:00:00Z`);
+ return Number.isFinite(ms) ? Math.floor(ms / DAY) : undefined;
+}
+
+function hasDate(m: Moment | undefined): m is Moment {
+ return !!m && /^\d{4}-\d{2}-\d{2}$/.test(m.date);
+}
+
+function hasTime(m: Moment | undefined): boolean {
+ return hasDate(m) && !!m.time;
+}
+
+function firstDate(card: StructuredCard): string | undefined {
+ switch (card.kind) {
+ case 'flight': case 'train': case 'bus': return card.legs[0]?.departure.at.date;
+ case 'lodging': return card.checkin.date;
+ case 'rentalcar': return card.pickup.at.date;
+ case 'event': case 'restaurant': return card.start.date;
+ case 'shipment': return undefined;
+ }
+}
+
+function legProblems(kind: 'flight' | 'train' | 'bus', leg: TransportLeg, fromPass: boolean): string[] {
+ const problems: string[] = [];
+ if (!hasTime(leg.departure.at)) problems.push('departure time missing');
+ if (!fromPass && !hasTime(leg.arrival?.at)) problems.push('arrival time missing');
+ const named = (p: { code?: string; name?: string } | undefined) => !!(p && (kind === 'flight' ? p.code || p.name : p.name));
+ if (!named(leg.departure.place)) problems.push('departure place missing');
+ if (!named(leg.arrival?.place)) problems.push('arrival place missing');
+ if (kind === 'flight' && !(leg.carrierCode || leg.carrier)) problems.push('airline missing');
+ if (kind === 'flight' && !leg.number) problems.push('flight number missing');
+ return problems;
+}
+
+function cardProblems(card: StructuredCard): string[] {
+ switch (card.kind) {
+ case 'flight': case 'train': case 'bus':
+ if (card.legs.length === 0) return ['no legs'];
+ return card.legs.flatMap((leg) => legProblems(card.kind as 'flight' | 'train' | 'bus', leg, card.source === 'pkpass'));
+ case 'lodging': {
+ const problems: string[] = [];
+ if (!card.name) problems.push('hotel name missing');
+ if (!hasDate(card.checkin) || !hasDate(card.checkout)) problems.push('stay dates missing');
+ else {
+ const nights = dayNumber(card.checkout.date)! - dayNumber(card.checkin.date)!;
+ if (nights < 1) problems.push('check-out not after check-in');
+ if (nights > 365) problems.push('stay longer than a year');
+ }
+ return problems;
+ }
+ case 'rentalcar': {
+ const problems: string[] = [];
+ if (!hasTime(card.pickup.at) || !hasTime(card.dropoff.at)) problems.push('rental times missing');
+ else {
+ const days = dayNumber(card.dropoff.at.date)! - dayNumber(card.pickup.at.date)!;
+ if (days < 0) problems.push('dropoff before pickup');
+ if (days > 365) problems.push('rental longer than a year');
+ }
+ return problems;
+ }
+ case 'event': {
+ const problems: string[] = [];
+ if (!card.name) problems.push('event name missing');
+ if (!hasDate(card.start)) problems.push('event start missing');
+ else if (card.end && hasDate(card.end)) {
+ const days = dayNumber(card.end.date)! - dayNumber(card.start.date)!;
+ if (days < 0) problems.push('event ends before it starts');
+ if (days > 31) problems.push('event longer than a month');
+ }
+ return problems;
+ }
+ case 'restaurant': {
+ const problems: string[] = [];
+ if (!card.name) problems.push('restaurant name missing');
+ if (!hasTime(card.start)) problems.push('reservation time missing');
+ return problems;
+ }
+ case 'shipment': {
+ const problems: string[] = [];
+ const tracked = card.parcels.some((p) => p.trackingNumber || p.trackingUrl);
+ if (!card.orderNumber && !tracked) problems.push('neither order number nor tracking');
+ const showable = card.merchant || card.items.length > 0 || card.parcels.some((p) => p.carrier || p.items.length > 0 || p.expectedBy);
+ if (!showable) problems.push('nothing to show');
+ return problems;
+ }
+ }
+}
+
+function identifiers(card: StructuredCard): string[] {
+ if (card.kind === 'shipment') {
+ return [card.orderNumber, ...card.parcels.map((p) => p.trackingNumber)].filter((v): v is string => !!v);
+ }
+ return card.reference ? [card.reference] : [];
+}
+
+/** Check one card against the mail's date. */
+export function validateCard(card: StructuredCard, mailDate: Date): Candidate {
+ const problems = cardProblems(card);
+ if (identifiers(card).some((id) => DOCUMENTATION_VALUES.has(id.toLowerCase()))) {
+ problems.push('documentation example');
+ }
+ const date = firstDate(card);
+ const mailDay = Math.floor(mailDate.getTime() / DAY);
+ const day = date ? dayNumber(date) : undefined;
+ if (date !== undefined && day !== undefined && Number.isFinite(mailDay)) {
+ const future = card.kind === 'flight' || card.kind === 'train' || card.kind === 'bus' ? TRANSPORT_FUTURE_DAYS : OTHER_FUTURE_DAYS;
+ if (day < mailDay - PAST_WINDOW_DAYS || day > mailDay + future) problems.push('date too far from the mail');
+ }
+ return { card, problems };
+}
diff --git a/src/lib/structured-mail/values.ts b/src/lib/structured-mail/values.ts
new file mode 100644
index 00000000..d5141099
--- /dev/null
+++ b/src/lib/structured-mail/values.ts
@@ -0,0 +1,255 @@
+// Mirrors webmail lib/structured-mail/values.ts at 76b0c2fba; keep the two in step.
+/**
+ * Readers for single schema.org values, tolerant of the shapes real senders
+ * use: a string where an object belongs, an array where one value belongs,
+ * numbers for identifiers, German prices, offsets without a colon.
+ */
+import type { Address, Link, Moment, OrderStatus, ReservationStatus } from './types';
+
+export type Obj = Record;
+
+export function isObj(value: unknown): value is Obj {
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
+}
+
+/** The values of a property: an array as is, anything else as one element. */
+export function all(value: unknown): unknown[] {
+ if (value === undefined || value === null) return [];
+ return Array.isArray(value) ? value : [value];
+}
+
+/** The first object among the values. */
+export function obj(value: unknown): Obj | undefined {
+ return all(value).find(isObj);
+}
+
+/** The first non-empty string (or number, as a string). */
+export function str(value: unknown): string | undefined {
+ for (const v of all(value)) {
+ if (typeof v === 'number' && Number.isFinite(v)) return String(v);
+ if (typeof v === 'string') {
+ const trimmed = v.replace(/\s+/g, ' ').trim();
+ if (trimmed) return trimmed;
+ }
+ }
+ return undefined;
+}
+
+/** A name: a plain string, or an object's `name` (Organization, Person, Place). */
+export function nameOf(value: unknown): string | undefined {
+ for (const v of all(value)) {
+ if (isObj(v)) {
+ const name = str(v.name) ?? [str(v.givenName), str(v.familyName)].filter(Boolean).join(' ');
+ if (name) return name;
+ } else {
+ const s = str(v);
+ if (s) return s;
+ }
+ }
+ return undefined;
+}
+
+/** Types of an object, as their last path segment ("FlightReservation"). */
+export function typesOf(value: unknown): string[] {
+ if (!isObj(value)) return [];
+ return all(value['@type'])
+ .map((t) => (typeof t === 'string' ? t.trim().replace(/^schema:/i, '').split(/[/#]/).pop() ?? '' : ''))
+ .filter(Boolean);
+}
+
+export function hasType(value: unknown, names: readonly string[]): boolean {
+ return typesOf(value).some((t) => names.includes(t));
+}
+
+const SCHEMA_CONTEXT = /^https?:\/\/(?:www\.)?schema\.org\/?$/i;
+
+/** Whether an `@context` names schema.org (string, array or `@vocab`). */
+export function isSchemaContext(context: unknown): boolean {
+ return all(context).some((c) =>
+ (typeof c === 'string' && SCHEMA_CONTEXT.test(c.trim()))
+ || (isObj(c) && typeof c['@vocab'] === 'string' && SCHEMA_CONTEXT.test(c['@vocab'].trim())));
+}
+
+/** The last segment of an enum value: a URL or a bare token. */
+function enumToken(value: unknown): string | undefined {
+ const s = str(value);
+ if (!s) return undefined;
+ return s.split(/[/#:]/).filter(Boolean).pop();
+}
+
+const RESERVATION_STATUS: Record = {
+ confirmed: 'confirmed', cancelled: 'cancelled', canceled: 'cancelled', pending: 'pending', hold: 'hold',
+};
+
+/** ReservationConfirmed / Confirmed, full URL (http/https) or token. The first value that maps wins. */
+export function reservationStatus(value: unknown): ReservationStatus | undefined {
+ for (const v of all(value)) {
+ const token = enumToken(isObj(v) ? v['@id'] ?? v.name : v)?.toLowerCase().replace(/^reservation/, '');
+ if (token && RESERVATION_STATUS[token]) return RESERVATION_STATUS[token];
+ }
+ return undefined;
+}
+
+const ORDER_STATUS: Record = {
+ paymentdue: 'payment_due', processing: 'processing', problem: 'problem', intransit: 'in_transit',
+ pickupavailable: 'pickup_available', returned: 'returned', delivered: 'delivered',
+ cancelled: 'cancelled', canceled: 'cancelled',
+};
+
+export function orderStatus(value: unknown): OrderStatus | undefined {
+ for (const v of all(value)) {
+ const token = enumToken(isObj(v) ? v['@id'] ?? v.name : v)?.toLowerCase().replace(/^order(status)?/, '');
+ if (token && ORDER_STATUS[token]) return ORDER_STATUS[token];
+ }
+ return undefined;
+}
+
+const ISO_MOMENT = /^(\d{4})-(\d{2})-(\d{2})(?:[T ](\d{2}):(\d{2})(?::(\d{2})(?:[.,]\d+)?)?\s*(Z|[+-]\d{2}(?::?\d{2})?)?)?$/i;
+
+function validDate(y: number, m: number, d: number): boolean {
+ const date = new Date(Date.UTC(y, m - 1, d));
+ return date.getUTCFullYear() === y && date.getUTCMonth() === m - 1 && date.getUTCDate() === d;
+}
+
+function parseOffset(text: string): number | undefined {
+ if (/^z$/i.test(text)) return 0;
+ const match = /^([+-])(\d{2}):?(\d{2})?$/.exec(text);
+ if (!match) return undefined;
+ const hours = Number(match[2]);
+ const minutes = Number(match[3] ?? 0);
+ if (hours > 14 || minutes > 59) return undefined;
+ return (match[1] === '-' ? -1 : 1) * (hours * 60 + minutes);
+}
+
+/**
+ * An ISO 8601 date or date-time. Offsets `Z`, `+01:00`, `+0100` and `+01`;
+ * fractional seconds dropped. Anything else is undefined, so a doubtful value
+ * counts as missing.
+ */
+export function moment(value: unknown): Moment | undefined {
+ const s = str(value);
+ if (!s) return undefined;
+ const match = ISO_MOMENT.exec(s);
+ if (!match) return undefined;
+ const [, y, mo, d, h, mi, se, off] = match;
+ if (!validDate(+y, +mo, +d)) return undefined;
+ const result: Moment = { date: `${y}-${mo}-${d}` };
+ if (h === undefined) return result;
+ if (+h > 23 || +mi > 59 || +(se ?? 0) > 59) return undefined;
+ result.time = `${h}:${mi}:${se ?? '00'}`;
+ if (off !== undefined) {
+ const offset = parseOffset(off);
+ if (offset === undefined) return undefined;
+ result.offsetMinutes = offset;
+ const local = Date.UTC(+y, +mo - 1, +d, +h, +mi, +(se ?? 0));
+ result.utc = new Date(local - offset * 60_000).toISOString().replace('.000Z', 'Z');
+ }
+ return result;
+}
+
+/**
+ * Only the date of a value whose time part is broken
+ * (`2024-10-13CEST03:10:23`, Kaufland). Used where a date is all a card
+ * shows (expected parcel arrival); nowhere else.
+ */
+export function lenientDate(value: unknown): Moment | undefined {
+ const parsed = moment(value);
+ if (parsed) return { date: parsed.date };
+ const s = str(value);
+ const match = s ? /^(\d{4})-(\d{2})-(\d{2})(?![\d])/.exec(s) : null;
+ if (!match || !validDate(+match[1], +match[2], +match[3])) return undefined;
+ return { date: `${match[1]}-${match[2]}-${match[3]}` };
+}
+
+/** A PostalAddress, or a plain string kept as the street line. */
+export function address(value: unknown): Address | undefined {
+ const v = all(value)[0];
+ if (typeof v === 'string') {
+ const street = str(v);
+ return street ? { street } : undefined;
+ }
+ if (!isObj(v)) return undefined;
+ const result: Address = {};
+ const set = (key: keyof Address, raw: unknown) => {
+ const s = str(raw);
+ if (s) result[key] = s;
+ };
+ set('street', v.streetAddress);
+ set('locality', v.addressLocality);
+ set('region', v.addressRegion);
+ set('postalCode', v.postalCode);
+ const country = isObj(v.addressCountry) ? str(v.addressCountry.identifier) ?? nameOf(v.addressCountry) : str(v.addressCountry);
+ if (country) {
+ if (/^[A-Za-z]{2}$/.test(country)) result.country = country.toUpperCase();
+ else result.countryName = country;
+ }
+ return Object.keys(result).length > 0 ? result : undefined;
+}
+
+const IPV4 = /^\d{1,3}(?:\.\d{1,3}){3}$/;
+
+/**
+ * A link a card may show: absolute http(s), a real host name (no single
+ * label like Ahoi's `http://default/`, no IP literal, no localhost), no
+ * credentials. Anything else is dropped.
+ */
+export function link(value: unknown): Link | undefined {
+ for (const v of all(value)) {
+ const raw = isObj(v) ? str(v.url) ?? str(v.target) ?? str(v['@id']) : str(v);
+ if (!raw) continue;
+ let url: URL;
+ try {
+ url = new URL(raw);
+ } catch {
+ continue;
+ }
+ if (url.protocol !== 'https:' && url.protocol !== 'http:') continue;
+ if (url.username || url.password) continue;
+ const host = url.hostname.toLowerCase();
+ if (!host.includes('.') || host.startsWith('[') || IPV4.test(host) || host === 'localhost' || host.endsWith('.localhost')) continue;
+ if (!/^[a-z0-9.-]+$/.test(host) || host.endsWith('.')) continue;
+ return { url: url.href, host: host.replace(/^www\./, '') };
+ }
+ return undefined;
+}
+
+/**
+ * A price as a dot-decimal string: numbers, "29.99", "14.9700", and German
+ * "399,00 €" / "1.299,00 EUR". Ambiguous values ("1,299") give undefined.
+ */
+export function amount(value: unknown): string | undefined {
+ for (const v of all(value)) {
+ if (typeof v === 'number' && Number.isFinite(v) && v >= 0) return v.toFixed(2);
+ if (typeof v !== 'string') continue;
+ const s = v.replace(/[^\d.,]/g, '');
+ if (!s) continue;
+ let normalised: string | undefined;
+ if (/^\d+(\.\d+)?$/.test(s)) normalised = s;
+ else if (/^\d{1,3}(\.\d{3})+,\d{1,2}$/.test(s) || /^\d+,\d{1,2}$/.test(s)) normalised = s.replace(/\./g, '').replace(',', '.');
+ else if (/^\d{1,3}(,\d{3})+\.\d{1,2}$/.test(s)) normalised = s.replace(/,/g, '');
+ if (normalised === undefined) continue;
+ const n = Number(normalised);
+ if (Number.isFinite(n)) return n.toFixed(2);
+ }
+ return undefined;
+}
+
+export function currency(value: unknown): string | undefined {
+ const s = str(value);
+ return s && /^[A-Za-z]{3}$/.test(s) ? s.toUpperCase() : undefined;
+}
+
+export function positiveInt(value: unknown): number | undefined {
+ for (const v of all(value)) {
+ const raw = isObj(v) ? v.value : v;
+ const n = typeof raw === 'number' ? raw : typeof raw === 'string' && /^\s*\d+\s*$/.test(raw) ? Number(raw) : NaN;
+ if (Number.isInteger(n) && n > 0 && n < 10_000) return n;
+ }
+ return undefined;
+}
+
+/** Names of people: Person objects, strings, arrays; duplicates dropped. */
+export function people(value: unknown): string[] {
+ const names = all(value).map((v) => nameOf(v)).filter((n): n is string => !!n);
+ return [...new Set(names)];
+}