mirror of
https://github.com/bulwarkmail/native.git
synced 2026-11-02 00:06:23 +00:00
Merge branch 'cu-cards' into catch-up-2026-11
This commit is contained in:
@@ -24,7 +24,7 @@ Classification: `bug` = fixed in the webmail, still live in the app; `missing`;
|
|||||||
- What RN does: MessageHeader works out the verdict (`src/components/email/MessageHeader.tsx:118`) but renders SenderAvatar with the claimed address (`:175`); the same in thread cards (`src/components/email/ThreadMessageCard.tsx:50`) and list rows (`src/screens/EmailListScreen.tsx:213`). SenderAvatar picks a contact photo or the domain favicon (`src/components/SenderAvatar.tsx:62-65`). List rows have no auth data (`src/api/email.ts:23-26`).
|
- What RN does: MessageHeader works out the verdict (`src/components/email/MessageHeader.tsx:118`) but renders SenderAvatar with the claimed address (`:175`); the same in thread cards (`src/components/email/ThreadMessageCard.tsx:50`) and list rows (`src/screens/EmailListScreen.tsx:213`). SenderAvatar picks a contact photo or the domain favicon (`src/components/SenderAvatar.tsx:62-65`). List rows have no auth data (`src/api/email.ts:23-26`).
|
||||||
- Fix hint: pass a sender warning from getSenderVerification into SenderAvatar and draw a shield; for list rows add `header:Authentication-Results:all` to the list properties.
|
- Fix hint: pass a sender warning from getSenderVerification into SenderAvatar and draw a shield; for list rows add `header:Authentication-Results:all` to the list properties.
|
||||||
|
|
||||||
- [ ] **No booking, parcel or order cards from schema.org markup** — `P2` — `missing`
|
- [x] **No booking, parcel or order cards from schema.org markup** — `P2` — `missing` — fixed in 5f2ef4c1, a6066a09, cd677e77, 2055df8c, 982a30ef, c90a35c4 (mount at merge)
|
||||||
- What WEB does: reads JSON-LD/microdata booking markup and attached Wallet passes and shows a card above the message (flight, train, bus, hotel, rental car, event, restaurant, parcel tracking, order status) with copy buttons and links that show where they lead. Bookings go into a calendar the user picks with stable UIDs, so a later mail updates the event and a cancellation removes it. Gate: the receiving server verified the From domain, not a forward, not a personal mailbox provider, not turned off by the admin (`lib/structured-mail/index.ts:36`, `trust.ts:104`, `pkpass.ts`, `calendar.ts:147`, `components/email/structured-mail/use-booking-calendar.ts:115`, `lib/structured-mail-gate.ts:7`). a529e6e57..859d368a1.
|
- What WEB does: reads JSON-LD/microdata booking markup and attached Wallet passes and shows a card above the message (flight, train, bus, hotel, rental car, event, restaurant, parcel tracking, order status) with copy buttons and links that show where they lead. Bookings go into a calendar the user picks with stable UIDs, so a later mail updates the event and a cancellation removes it. Gate: the receiving server verified the From domain, not a forward, not a personal mailbox provider, not turned off by the admin (`lib/structured-mail/index.ts:36`, `trust.ts:104`, `pkpass.ts`, `calendar.ts:147`, `components/email/structured-mail/use-booking-calendar.ts:115`, `lib/structured-mail-gate.ts:7`). a529e6e57..859d368a1.
|
||||||
- What RN does: nothing; the only banners above the body are unsubscribe and invitation (`src/components/email/MessageContent.tsx:104`, `:112`).
|
- What RN does: nothing; the only banners above the body are unsubscribe and invitation (`src/components/email/MessageContent.tsx:104`, `:112`).
|
||||||
- Fix hint: port `lib/structured-mail/` with an RN-side HTML parser passed through `parseHtml` (`lib/structured-mail/index.ts:4-5`, `:21`) and build RN cards. The admin switch needs the org policy, so default on.
|
- Fix hint: port `lib/structured-mail/` with an RN-side HTML parser passed through `parseHtml` (`lib/structured-mail/index.ts:4-5`, `:21`) and build RN cards. The admin switch needs the org policy, so default on.
|
||||||
|
|||||||
@@ -576,6 +576,69 @@
|
|||||||
"spam_short": "Spam",
|
"spam_short": "Spam",
|
||||||
"still_scheduled": "The message is still scheduled.",
|
"still_scheduled": "The message is still scheduled.",
|
||||||
"still_scheduled_at": "The message is still scheduled for {date}.",
|
"still_scheduled_at": "The message is still scheduled for {date}.",
|
||||||
|
"structured_mail": {
|
||||||
|
"check_in_label": "Check-in",
|
||||||
|
"check_out_label": "Check-out",
|
||||||
|
"copied_order_number": "Order number copied",
|
||||||
|
"copied_reference": "Booking reference copied",
|
||||||
|
"copied_tracking_number": "Tracking number copied",
|
||||||
|
"copy_failed": "Could not copy to the clipboard",
|
||||||
|
"copy_order_number": "Copy order number {value}",
|
||||||
|
"copy_reference": "Copy booking reference {value}",
|
||||||
|
"copy_tracking_number": "Copy tracking number {value}",
|
||||||
|
"dropoff": "Return",
|
||||||
|
"expected_by": "Arrives by {date}",
|
||||||
|
"from_time": "from {time}",
|
||||||
|
"items_count": "{count, plural, one {# item} other {# items}}",
|
||||||
|
"items_more": "and {count} more",
|
||||||
|
"kind_bus": "Bus",
|
||||||
|
"kind_event": "Event",
|
||||||
|
"kind_flight": "Flight",
|
||||||
|
"kind_lodging": "Hotel",
|
||||||
|
"kind_order": "Order",
|
||||||
|
"kind_parcel": "Parcel",
|
||||||
|
"kind_rentalcar": "Rental car",
|
||||||
|
"kind_restaurant": "Restaurant",
|
||||||
|
"kind_train": "Train",
|
||||||
|
"link_check_in": "Check in",
|
||||||
|
"link_manage": "Manage booking",
|
||||||
|
"link_ticket": "Ticket",
|
||||||
|
"link_track": "Track",
|
||||||
|
"link_view_order": "View order",
|
||||||
|
"local_time": "local time",
|
||||||
|
"local_time_title": "Local time at the place, not your time zone",
|
||||||
|
"nights": "{count, plural, one {# night} other {# nights}}",
|
||||||
|
"order_number": "Order number",
|
||||||
|
"order_status_cancelled": "Cancelled",
|
||||||
|
"order_status_delivered": "Delivered",
|
||||||
|
"order_status_in_transit": "Shipped",
|
||||||
|
"order_status_payment_due": "Payment due",
|
||||||
|
"order_status_pickup_available": "Ready for pickup",
|
||||||
|
"order_status_problem": "Problem",
|
||||||
|
"order_status_processing": "Processing",
|
||||||
|
"order_status_returned": "Returned",
|
||||||
|
"party_size": "{count, plural, one {Table for #} other {Table for #}}",
|
||||||
|
"passengers": "Passengers",
|
||||||
|
"pickup": "Pick-up",
|
||||||
|
"reference": "Booking reference",
|
||||||
|
"region_bus": "Bus booking",
|
||||||
|
"region_event": "Event booking",
|
||||||
|
"region_flight": "Flight booking",
|
||||||
|
"region_lodging": "Hotel booking",
|
||||||
|
"region_rentalcar": "Rental car booking",
|
||||||
|
"region_restaurant": "Restaurant reservation",
|
||||||
|
"region_shipment": "Order and delivery",
|
||||||
|
"region_train": "Train booking",
|
||||||
|
"route": "{from} to {to}",
|
||||||
|
"seats": "Seats",
|
||||||
|
"status_cancelled": "Cancelled",
|
||||||
|
"status_hold": "On hold",
|
||||||
|
"status_pending": "Pending",
|
||||||
|
"tickets": "Tickets",
|
||||||
|
"total": "Total {total}",
|
||||||
|
"tracking_number": "Tracking number",
|
||||||
|
"until_time": "until {time}"
|
||||||
|
},
|
||||||
"thread_loading": "Loading conversation...",
|
"thread_loading": "Loading conversation...",
|
||||||
"undo_send_failed": "Undo send failed",
|
"undo_send_failed": "Undo send failed",
|
||||||
"unsubscribe_banner": {
|
"unsubscribe_banner": {
|
||||||
@@ -1559,6 +1622,36 @@
|
|||||||
"sign_out_confirm_title_account": "Sign out of {account}?",
|
"sign_out_confirm_title_account": "Sign out of {account}?",
|
||||||
"tab_unavailable": "{name} (unavailable)"
|
"tab_unavailable": "{name} (unavailable)"
|
||||||
},
|
},
|
||||||
|
"structured_mail_calendar": {
|
||||||
|
"action_failed": "Couldn't change your calendar. Try again.",
|
||||||
|
"add_to_calendar": "Add to calendar",
|
||||||
|
"added": "Added to your calendar",
|
||||||
|
"description": {
|
||||||
|
"added_from": "Added from a mail from {domain}",
|
||||||
|
"added_from_subject": "Added from the mail “{subject}” from {domain}",
|
||||||
|
"car": "Car: {car}",
|
||||||
|
"checkin": "Check in: {url}",
|
||||||
|
"manage": "Manage booking: {url}",
|
||||||
|
"party_size": "Party size: {count}",
|
||||||
|
"passengers": "Passengers: {names}",
|
||||||
|
"phone": "Phone: {phone}",
|
||||||
|
"reference": "Booking reference: {reference}",
|
||||||
|
"seats": "Seats: {seats}",
|
||||||
|
"ticket": "Ticket: {url}",
|
||||||
|
"tickets": "Tickets: {numbers}"
|
||||||
|
},
|
||||||
|
"in_calendar": "In your calendar",
|
||||||
|
"remove_from_calendar": "Remove from calendar",
|
||||||
|
"removed": "Removed from your calendar",
|
||||||
|
"select_calendar": "Choose a calendar",
|
||||||
|
"title": {
|
||||||
|
"rental_car": "Rental car",
|
||||||
|
"rental_car_company": "Rental car: {company}"
|
||||||
|
},
|
||||||
|
"update_in_calendar": "Update in calendar",
|
||||||
|
"updated": "Calendar updated",
|
||||||
|
"view_in_calendar": "View in calendar"
|
||||||
|
},
|
||||||
"templates": {
|
"templates": {
|
||||||
"manage": "Manage templates"
|
"manage": "Manage templates"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1099,4 +1099,120 @@ live('live Stalwart calendar', () => {
|
|||||||
}
|
}
|
||||||
}, 60_000);
|
}, 60_000);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('a booking mail in the calendar: added, moved by a later mail, removed by a cancellation', () => {
|
||||||
|
// A train booking 2.5 months ahead (within what the cards accept), from a
|
||||||
|
// sender whose Authentication-Results pass. Imported, the headers stay
|
||||||
|
// as written, which is enough for the trust gate (see receipt.ts).
|
||||||
|
const reference = `R${RUN.slice(-8).toUpperCase()}`;
|
||||||
|
const imported: string[] = [];
|
||||||
|
let uid = '';
|
||||||
|
|
||||||
|
function bookingMail(departure: string, arrival: string, status = 'ReservationConfirmed'): string {
|
||||||
|
const markup = {
|
||||||
|
'@context': 'http://schema.org', '@type': 'TrainReservation', reservationNumber: reference,
|
||||||
|
reservationStatus: `http://schema.org/${status}`, underName: { '@type': 'Person', name: 'Erika Mustermann' },
|
||||||
|
reservationFor: {
|
||||||
|
'@type': 'TrainTrip', trainNumber: 'ICE 577', provider: { '@type': 'Organization', name: 'Rail Co' },
|
||||||
|
departureStation: { '@type': 'TrainStation', name: 'Hamburg Hbf', address: { addressLocality: 'Hamburg', addressCountry: 'DE' } },
|
||||||
|
departureTime: departure,
|
||||||
|
arrivalStation: { '@type': 'TrainStation', name: 'Berlin Hbf', address: { addressLocality: 'Berlin', addressCountry: 'DE' } },
|
||||||
|
arrivalTime: arrival,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
return [
|
||||||
|
'Received: from mail.rail.example (mail.rail.example [192.0.2.7]) by mx.example.org (Stalwart SMTP) with ESMTPS id 1',
|
||||||
|
'Authentication-Results: mx.example.org; dkim=pass header.d=rail.example header.s=s1; spf=pass smtp.mailfrom=bounce@rail.example; dmarc=pass header.from=rail.example',
|
||||||
|
'From: Rail Co <booking@rail.example>',
|
||||||
|
`To: ${USER}`,
|
||||||
|
`Subject: ${RUN} booking ${reference} ${status}`,
|
||||||
|
`Date: ${new Date().toUTCString()}`,
|
||||||
|
`Message-ID: <${RUN}-${status}-${departure.slice(11, 13)}@rail.example>`,
|
||||||
|
'MIME-Version: 1.0',
|
||||||
|
'Content-Type: text/html; charset=utf-8',
|
||||||
|
'',
|
||||||
|
`<html><head><script type="application/ld+json">${JSON.stringify(markup)}</script></head><body><p>Your booking ${reference}</p></body></html>`,
|
||||||
|
'',
|
||||||
|
].join('\r\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Import a booking mail, read it as the reader does and hand back its one card. */
|
||||||
|
async function receive(message: string) {
|
||||||
|
const [{ uploadBytes }, mail, sm, trust] = await Promise.all([
|
||||||
|
import('../blob'), import('../email'), import('../../lib/structured-mail'), import('../../lib/structured-mail/trust'),
|
||||||
|
]);
|
||||||
|
const [[, boxes]] = await raw(client, [['Mailbox/get', { accountId: client.accountId, properties: ['id', 'role'] }, '0']], MAIL_USING);
|
||||||
|
const inbox = (boxes.list as { id: string; role?: string }[]).find((b) => b.role === 'inbox')!.id;
|
||||||
|
const { blobId } = await uploadBytes(new TextEncoder().encode(message), 'message/rfc822');
|
||||||
|
const id = await mail.importEmailBlob(blobId, inbox, { $seen: true });
|
||||||
|
imported.push(id);
|
||||||
|
const email = await mail.getFullEmail(id);
|
||||||
|
expect(trust.getStructuredMailTrust(email)).toEqual({ trusted: true });
|
||||||
|
const cards = sm.resolveCards(sm.readStructuredMail(trust.structuredHtmlPart(email)!.value, { mailDate: new Date(email.sentAt ?? email.receivedAt) }));
|
||||||
|
expect(cards).toHaveLength(1);
|
||||||
|
return { email, card: cards[0] };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The booking calendar hook of the card, settled on `status`. */
|
||||||
|
async function cardCalendar(message: string, status: string) {
|
||||||
|
const [{ useBookingCalendar }, harness] = await Promise.all([
|
||||||
|
import('../../components/email/structured-mail/use-booking-calendar'),
|
||||||
|
import('../../components/email/structured-mail/__tests__/render-native'),
|
||||||
|
]);
|
||||||
|
const { email, card } = await receive(message);
|
||||||
|
const hook = harness.renderHook(() => useBookingCalendar(card, email));
|
||||||
|
await harness.waitFor(() => expect(hook.current.status).toBe(status), { timeout: 15_000 });
|
||||||
|
return hook;
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
await useCalendarStore.getState().fetchCalendars();
|
||||||
|
}, 30_000);
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
const c = await singleton();
|
||||||
|
if (imported.length > 0) {
|
||||||
|
await raw(c, [['Email/set', { accountId: c.accountId, destroy: imported }, '0']], MAIL_USING).catch(() => undefined);
|
||||||
|
}
|
||||||
|
if (uid) {
|
||||||
|
const { findEventsByUid } = await import('../calendar');
|
||||||
|
const left = await findEventsByUid(uid).catch(() => [] as CalendarEvent[]);
|
||||||
|
if (left.length > 0) await deleteEvents(left.map((e) => e.id)).catch(() => undefined);
|
||||||
|
}
|
||||||
|
}, 60_000);
|
||||||
|
|
||||||
|
it('adds the booking, moves its event for the changed mail and removes it for the cancellation', async () => {
|
||||||
|
const { findEventsByUid } = await import('../calendar');
|
||||||
|
|
||||||
|
const first = await cardCalendar(bookingMail('2027-01-15T08:46:00+01:00', '2027-01-15T12:50:00+01:00'), 'add');
|
||||||
|
await first.current.add();
|
||||||
|
expect(first.current.error).toBeNull();
|
||||||
|
expect(first.current.status).toBe('in-calendar');
|
||||||
|
const created = useCalendarStore.getState().events.find((e) => e.description?.includes(reference));
|
||||||
|
expect(created, 'the store holds the new event').toBeTruthy();
|
||||||
|
uid = created!.uid;
|
||||||
|
let onServer = await findEventsByUid(uid);
|
||||||
|
expect(onServer).toHaveLength(1);
|
||||||
|
expect(onServer[0]).toMatchObject({ start: '2027-01-15T08:46:00', timeZone: 'Europe/Berlin', duration: 'PT4H4M' });
|
||||||
|
first.unmount();
|
||||||
|
|
||||||
|
// A later mail moves the train by two hours: same booking, same uid.
|
||||||
|
const moved = await cardCalendar(bookingMail('2027-01-15T10:46:00+01:00', '2027-01-15T14:50:00+01:00'), 'update');
|
||||||
|
await moved.current.update();
|
||||||
|
expect(moved.current.error).toBeNull();
|
||||||
|
expect(moved.current.status).toBe('in-calendar');
|
||||||
|
onServer = await findEventsByUid(uid);
|
||||||
|
expect(onServer).toHaveLength(1);
|
||||||
|
expect(onServer[0]).toMatchObject({ start: '2027-01-15T10:46:00', timeZone: 'Europe/Berlin', duration: 'PT4H4M' });
|
||||||
|
moved.unmount();
|
||||||
|
|
||||||
|
// The cancellation takes it out.
|
||||||
|
const cancelled = await cardCalendar(bookingMail('2027-01-15T10:46:00+01:00', '2027-01-15T14:50:00+01:00', 'ReservationCancelled'), 'remove');
|
||||||
|
await cancelled.current.remove();
|
||||||
|
expect(cancelled.current.error).toBeNull();
|
||||||
|
expect(cancelled.current.status).toBe('cancelled');
|
||||||
|
expect(await findEventsByUid(uid)).toEqual([]);
|
||||||
|
cancelled.unmount();
|
||||||
|
}, 90_000);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,52 @@
|
|||||||
|
// App only: the booking, parcel and order cards for MessageContent, in one
|
||||||
|
// component so the reader mounts them with one line (the webmail wires the
|
||||||
|
// same pieces in email-viewer.tsx).
|
||||||
|
import React from 'react';
|
||||||
|
import type { Email } from '../../../api/types';
|
||||||
|
import type { Booking } from '../../../lib/structured-mail';
|
||||||
|
import { isSenderContentTrusted, isTrustedSendersSyncOn } from '../../../lib/trusted-senders';
|
||||||
|
import { useHasContacts } from '../../../lib/capabilities';
|
||||||
|
import { useContactsStore } from '../../../stores/contacts-store';
|
||||||
|
import { useSettingsStore } from '../../../stores/settings-store';
|
||||||
|
import { BookingCalendarActions } from './booking-calendar-actions';
|
||||||
|
import { StructuredMailCards } from './structured-mail-cards';
|
||||||
|
import { useStructuredMail } from './use-structured-mail';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
email: Email;
|
||||||
|
/** Account the message lives in (a shared mailbox's owner); undefined for the user's own. */
|
||||||
|
jmapAccountId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cards from the message's schema.org markup and Wallet passes, for a sender
|
||||||
|
* the receiving server verified. Nothing for any other message. Product
|
||||||
|
* images load only when the body's remote content would load without a tap:
|
||||||
|
* the policy allows it or the sender is trusted. Each booking card offers to
|
||||||
|
* add, update or remove its events in the calendar.
|
||||||
|
*/
|
||||||
|
export function StructuredMailBanner({ email, jmapAccountId }: Props) {
|
||||||
|
const cards = useStructuredMail(email, jmapAccountId);
|
||||||
|
const policy = useSettingsStore((s) => s.externalContentPolicy);
|
||||||
|
const trustedSenders = useSettingsStore((s) => s.trustedSenders);
|
||||||
|
const isSenderTrusted = useSettingsStore((s) => s.isSenderTrusted);
|
||||||
|
const trustedSendersAddressBook = useSettingsStore((s) => s.trustedSendersAddressBook);
|
||||||
|
const trustedBookEmails = useContactsStore((s) => s.trustedSenderEmails);
|
||||||
|
const hasContacts = useHasContacts();
|
||||||
|
const sender = email.from?.[0]?.email;
|
||||||
|
const allowRemoteImages = React.useMemo(
|
||||||
|
() => policy === 'allow' || isSenderContentTrusted(sender, {
|
||||||
|
isLocallyTrusted: isSenderTrusted,
|
||||||
|
syncEnabled: isTrustedSendersSyncOn(trustedSendersAddressBook, hasContacts),
|
||||||
|
trustedBookEmails,
|
||||||
|
}),
|
||||||
|
// trustedSenders changes what isSenderTrusted answers.
|
||||||
|
[policy, sender, isSenderTrusted, trustedSenders, trustedSendersAddressBook, hasContacts, trustedBookEmails], // eslint-disable-line react-hooks/exhaustive-deps
|
||||||
|
);
|
||||||
|
const calendarActions = React.useCallback(
|
||||||
|
(booking: Booking) => <BookingCalendarActions booking={booking} email={email} />,
|
||||||
|
[email],
|
||||||
|
);
|
||||||
|
if (cards.length === 0) return null;
|
||||||
|
return <StructuredMailCards cards={cards} allowRemoteImages={allowRemoteImages} calendarActions={calendarActions} />;
|
||||||
|
}
|
||||||
@@ -0,0 +1,126 @@
|
|||||||
|
// Port of webmail components/email/structured-mail/__tests__/
|
||||||
|
// pkpass-source.test.ts at 66c73d0f4, plus the app's download (the JMAP
|
||||||
|
// client mocked): the cap is enforced on the announced and the real size.
|
||||||
|
import { describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
|
const fetchMock = vi.hoisted(() => ({ respond: (_url: string): Response => new Response(null, { status: 404 }) }));
|
||||||
|
vi.mock('../../../../api/jmap-client', () => ({
|
||||||
|
BLOB_TIMEOUT_MS: 300_000,
|
||||||
|
jmapClient: {
|
||||||
|
getBlobDownloadUrl: (blobId: string, name?: string, type?: string, accountId?: string) => `https://jmap.example/${accountId ?? 'primary'}/${blobId}/${name}?type=${type}`,
|
||||||
|
authenticatedFetch: vi.fn(async (url: string) => fetchMock.respond(url)),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
import type { Attachment, Email } from '../../../../api/types';
|
||||||
|
import { jmapClient } from '../../../../api/jmap-client';
|
||||||
|
import { MAIL_DATE, boardingPass, eventTicket, zipPass } from '../../../../lib/structured-mail/__tests__/pkpass-fixtures';
|
||||||
|
import { fetchPassBytes, makePkpassSource, pkpassSource, type FetchPassBytes } from '../pkpass-source';
|
||||||
|
import { CARD_SOURCES } from '../sources';
|
||||||
|
|
||||||
|
const PASS_ATT: Attachment = { blobId: 'blob-pass', size: 3000, name: 'BoardingPass.pkpass', type: 'application/octet-stream' };
|
||||||
|
const PDF_ATT: Attachment = { blobId: 'blob-pdf', size: 3000, name: 'invoice.pdf', type: 'application/pdf' };
|
||||||
|
|
||||||
|
function mail(fields: Partial<Email>): Email {
|
||||||
|
return { id: 'm1', threadId: 't', mailboxIds: {}, keywords: {}, size: 1, hasAttachment: true, receivedAt: MAIL_DATE.toISOString(), ...fields } as Email;
|
||||||
|
}
|
||||||
|
|
||||||
|
const toBuffer = (bytes: Uint8Array) => bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength) as ArrayBuffer;
|
||||||
|
|
||||||
|
function fetcherFor(blobs: Record<string, Uint8Array | Error>) {
|
||||||
|
return vi.fn<FetchPassBytes>(async (blobId: string) => {
|
||||||
|
const blob = blobs[blobId];
|
||||||
|
if (!blob) throw new Error(`no blob ${blobId}`);
|
||||||
|
if (blob instanceof Error) throw blob;
|
||||||
|
return toBuffer(blob);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function run(email: Email, fetchBytes: FetchPassBytes, signal = new AbortController().signal) {
|
||||||
|
return makePkpassSource(fetchBytes)({ email, mailDate: MAIL_DATE, accountId: 'acc-7', signal });
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('pkpassSource', () => {
|
||||||
|
it('is registered as a card source', () => {
|
||||||
|
expect(CARD_SOURCES).toContain(pkpassSource);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('downloads the pass attachment from the message account and returns its card', async () => {
|
||||||
|
const fetchBytes = fetcherFor({ 'blob-pass': await zipPass(boardingPass()) });
|
||||||
|
const cards = await run(mail({ attachments: [PDF_ATT, PASS_ATT] }), fetchBytes);
|
||||||
|
expect(cards).toMatchObject([{ kind: 'flight', source: 'pkpass', reference: 'PK7Q2Z' }]);
|
||||||
|
expect(fetchBytes).toHaveBeenCalledTimes(1);
|
||||||
|
expect(fetchBytes).toHaveBeenCalledWith('blob-pass', 'BoardingPass.pkpass', 'application/octet-stream', 'acc-7', 5 * 1024 * 1024 + 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('downloads nothing that is not a pass, and at most three passes', async () => {
|
||||||
|
const bytes = await zipPass(eventTicket());
|
||||||
|
const passes = [1, 2, 3, 4].map((i) => ({ ...PASS_ATT, blobId: `p${i}` }));
|
||||||
|
const fetchBytes = fetcherFor({ p1: bytes, p2: bytes, p3: bytes, p4: bytes });
|
||||||
|
await run(mail({ attachments: [PDF_ATT, { ...PASS_ATT, blobId: 'big', size: 6 * 1024 * 1024 }, ...passes] }), fetchBytes);
|
||||||
|
expect(fetchBytes.mock.calls.map((c) => c[0])).toEqual(['p1', 'p2', 'p3']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reads email.attachments only, never a pass inside an attached message', async () => {
|
||||||
|
const fetchBytes = fetcherFor({ 'blob-pass': await zipPass(boardingPass()) });
|
||||||
|
const embedded = mail({
|
||||||
|
attachments: [{ blobId: 'blob-eml', size: 9000, name: 'forwarded.eml', type: 'message/rfc822' }],
|
||||||
|
bodyStructure: {
|
||||||
|
partId: '0', blobId: 'root', size: 9000, type: 'multipart/mixed',
|
||||||
|
subParts: [{ partId: '2', blobId: 'blob-eml', size: 9000, type: 'message/rfc822', subParts: [{ ...PASS_ATT }] }],
|
||||||
|
} as Email['bodyStructure'],
|
||||||
|
textBody: [{ ...PASS_ATT }] as Email['textBody'],
|
||||||
|
});
|
||||||
|
expect(await run(embedded, fetchBytes)).toEqual([]);
|
||||||
|
expect(fetchBytes).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('stops between downloads once the message is closed', async () => {
|
||||||
|
const bytes = await zipPass(boardingPass());
|
||||||
|
const controller = new AbortController();
|
||||||
|
const fetchBytes = fetcherFor({ p1: bytes, p2: bytes });
|
||||||
|
fetchBytes.mockImplementationOnce(async () => {
|
||||||
|
controller.abort();
|
||||||
|
return toBuffer(bytes);
|
||||||
|
});
|
||||||
|
const cards = await run(mail({ attachments: [{ ...PASS_ATT, blobId: 'p1' }, { ...PASS_ATT, blobId: 'p2' }] }), fetchBytes, controller.signal);
|
||||||
|
expect(cards).toEqual([]);
|
||||||
|
expect(fetchBytes).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
|
const late = new AbortController();
|
||||||
|
fetchBytes.mockImplementationOnce(async () => {
|
||||||
|
late.abort();
|
||||||
|
return toBuffer(bytes);
|
||||||
|
});
|
||||||
|
expect(await run(mail({ attachments: [{ ...PASS_ATT, blobId: 'p1' }] }), fetchBytes, late.signal)).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('gives an empty list, without throwing, when the download fails', async () => {
|
||||||
|
const fetchBytes = fetcherFor({ 'blob-pass': new Error('Failed to fetch blob: 500') });
|
||||||
|
await expect(run(mail({ attachments: [PASS_ATT] }), fetchBytes)).resolves.toEqual([]);
|
||||||
|
expect(fetchBytes).toHaveBeenCalledTimes(1);
|
||||||
|
await expect(run(mail({ attachments: [{ ...PASS_ATT, blobId: '' }] }), fetchBytes)).resolves.toEqual([]);
|
||||||
|
expect(fetchBytes).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('fetchPassBytes', () => {
|
||||||
|
it('downloads the blob from the message account with the client', async () => {
|
||||||
|
const bytes = await zipPass(boardingPass());
|
||||||
|
fetchMock.respond = () => new Response(toBuffer(bytes), { status: 200, headers: { 'content-length': String(bytes.byteLength) } });
|
||||||
|
const got = await fetchPassBytes('blob-pass', 'BoardingPass.pkpass', 'application/vnd.apple.pkpass', 'acc-7', 5 * 1024 * 1024 + 1);
|
||||||
|
expect(new Uint8Array(got)).toEqual(bytes);
|
||||||
|
expect(vi.mocked(jmapClient.authenticatedFetch)).toHaveBeenLastCalledWith(
|
||||||
|
'https://jmap.example/acc-7/blob-pass/BoardingPass.pkpass?type=application/vnd.apple.pkpass', undefined, { timeoutMs: 300_000 },
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('refuses a blob the server says is larger than the cap, and one that turns out larger', async () => {
|
||||||
|
fetchMock.respond = () => new Response(new Uint8Array(10), { status: 200, headers: { 'content-length': '5000' } });
|
||||||
|
await expect(fetchPassBytes('b', 'x.pkpass', 'application/vnd.apple.pkpass', undefined, 100)).rejects.toThrow(/larger/);
|
||||||
|
fetchMock.respond = () => new Response(new Uint8Array(200), { status: 200 });
|
||||||
|
await expect(fetchPassBytes('b', 'x.pkpass', 'application/vnd.apple.pkpass', undefined, 100)).rejects.toThrow(/larger/);
|
||||||
|
fetchMock.respond = () => new Response(null, { status: 500 });
|
||||||
|
await expect(fetchPassBytes('b', 'x.pkpass', 'application/vnd.apple.pkpass', undefined, 100)).rejects.toThrow(/500/);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,348 @@
|
|||||||
|
// A small renderer for the card tests. The app has no React renderer in its
|
||||||
|
// test setup (no react-test-renderer, no testing-library), so this walks the
|
||||||
|
// element tree, calls each function component with a stand-in hook
|
||||||
|
// dispatcher, and returns the host elements (View, Text, Pressable ...) as
|
||||||
|
// plain objects. One pass, no commits: state setters and effects do nothing
|
||||||
|
// here, which is what a snapshot of a card needs. renderHook below keeps
|
||||||
|
// state and runs effects, for hooks.
|
||||||
|
import React from 'react';
|
||||||
|
import { vi } from 'vitest';
|
||||||
|
|
||||||
|
type Internals = { H: unknown };
|
||||||
|
const internals = (React as unknown as { __CLIENT_INTERNALS_DO_NOT_USE_OR_WARN_USERS_THEY_CANNOT_UPGRADE: Internals })
|
||||||
|
.__CLIENT_INTERNALS_DO_NOT_USE_OR_WARN_USERS_THEY_CANNOT_UPGRADE;
|
||||||
|
|
||||||
|
export interface HostNode {
|
||||||
|
type: string;
|
||||||
|
props: Record<string, any>;
|
||||||
|
children: Array<HostNode | string>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const MEMO = Symbol.for('react.memo');
|
||||||
|
const FORWARD_REF = Symbol.for('react.forward_ref');
|
||||||
|
const FRAGMENT = Symbol.for('react.fragment');
|
||||||
|
|
||||||
|
const oneShotDispatcher = {
|
||||||
|
useState: (init: unknown) => [typeof init === 'function' ? (init as () => unknown)() : init, () => undefined],
|
||||||
|
useReducer: (_r: unknown, init: unknown, fn?: (a: unknown) => unknown) => [fn ? fn(init) : init, () => undefined],
|
||||||
|
useMemo: (fn: () => unknown) => fn(),
|
||||||
|
useCallback: (fn: unknown) => fn,
|
||||||
|
useRef: (current: unknown) => ({ current }),
|
||||||
|
useEffect: () => undefined,
|
||||||
|
useLayoutEffect: () => undefined,
|
||||||
|
useInsertionEffect: () => undefined,
|
||||||
|
useImperativeHandle: () => undefined,
|
||||||
|
useContext: (context: { _currentValue: unknown }) => context._currentValue,
|
||||||
|
useSyncExternalStore: (_subscribe: unknown, getSnapshot: () => unknown) => getSnapshot(),
|
||||||
|
useDebugValue: () => undefined,
|
||||||
|
useId: () => ':r:',
|
||||||
|
useTransition: () => [false, (fn: () => void) => fn()],
|
||||||
|
useDeferredValue: (value: unknown) => value,
|
||||||
|
};
|
||||||
|
|
||||||
|
function renderNode(node: unknown, out: Array<HostNode | string>): void {
|
||||||
|
if (node === null || node === undefined || typeof node === 'boolean') return;
|
||||||
|
if (typeof node === 'string' || typeof node === 'number') {
|
||||||
|
out.push(String(node));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (Array.isArray(node)) {
|
||||||
|
for (const child of node) renderNode(child, out);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const element = node as { type: any; props: Record<string, any> };
|
||||||
|
const { type, props } = element;
|
||||||
|
if (type === FRAGMENT) {
|
||||||
|
renderNode(props.children, out);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (typeof type === 'string') {
|
||||||
|
const children: Array<HostNode | string> = [];
|
||||||
|
renderNode(props.children, children);
|
||||||
|
out.push({ type, props, children });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (type && type.$$typeof === MEMO) {
|
||||||
|
renderNode({ type: type.type, props }, out);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (type && type.$$typeof === FORWARD_REF) {
|
||||||
|
renderNode(callComponent(() => type.render(props, null)), out);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (typeof type === 'function') {
|
||||||
|
if (type.prototype?.isReactComponent) throw new Error('class components are not supported');
|
||||||
|
renderNode(callComponent(() => type(props)), out);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
throw new Error(`cannot render ${String(type)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function callComponent(fn: () => unknown): unknown {
|
||||||
|
const previous = internals.H;
|
||||||
|
internals.H = oneShotDispatcher;
|
||||||
|
try {
|
||||||
|
return fn();
|
||||||
|
} finally {
|
||||||
|
internals.H = previous;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Render `element` once into host nodes; the root holds them as children. */
|
||||||
|
export function render(element: React.ReactElement): HostNode {
|
||||||
|
const children: Array<HostNode | string> = [];
|
||||||
|
renderNode(element, children);
|
||||||
|
return { type: 'root', props: {}, children };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Every host node below `node` (itself included) that `test` accepts, in tree order. */
|
||||||
|
export function findAll(node: HostNode, test: (n: HostNode) => boolean): HostNode[] {
|
||||||
|
const out: HostNode[] = [];
|
||||||
|
const walk = (n: HostNode) => {
|
||||||
|
if (test(n)) out.push(n);
|
||||||
|
for (const child of n.children) if (typeof child !== 'string') walk(child);
|
||||||
|
};
|
||||||
|
walk(node);
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function byTestId(node: HostNode, id: string): HostNode[] {
|
||||||
|
return findAll(node, (n) => n.props.testID === id);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function oneByTestId(node: HostNode, id: string): HostNode {
|
||||||
|
const found = byTestId(node, id);
|
||||||
|
if (found.length !== 1) throw new Error(`expected one ${id}, found ${found.length}`);
|
||||||
|
return found[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function byLabel(node: HostNode, label: string | RegExp): HostNode[] {
|
||||||
|
return findAll(node, (n) => typeof n.props.accessibilityLabel === 'string'
|
||||||
|
&& (typeof label === 'string' ? n.props.accessibilityLabel === label : label.test(n.props.accessibilityLabel)));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The text a node shows, its strings joined, Unicode isolates removed. */
|
||||||
|
export function textOf(node: HostNode | string): string {
|
||||||
|
const raw = typeof node === 'string' ? node : node.children.map(textOf).join('');
|
||||||
|
return raw.replace(/[-]/g, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The text with its isolates, to check direction marks. */
|
||||||
|
export function rawTextOf(node: HostNode | string): string {
|
||||||
|
return typeof node === 'string' ? node : node.children.map(rawTextOf).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Flattened style of a host node. */
|
||||||
|
export function styleOf(node: HostNode, state: { pressed?: boolean } = {}): Record<string, any> {
|
||||||
|
const flatten = (style: unknown): Record<string, any> => {
|
||||||
|
if (!style) return {};
|
||||||
|
if (typeof style === 'function') return flatten(style({ pressed: !!state.pressed }));
|
||||||
|
if (Array.isArray(style)) return Object.assign({}, ...style.map(flatten));
|
||||||
|
return style as Record<string, any>;
|
||||||
|
};
|
||||||
|
return flatten(node.props.style);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// hooks
|
||||||
|
|
||||||
|
function depsChanged(prev: unknown[] | undefined, next: unknown[] | undefined): boolean {
|
||||||
|
if (!prev || !next) return true;
|
||||||
|
return prev.length !== next.length || prev.some((value, i) => !Object.is(value, next[i]));
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface HookResult<T> {
|
||||||
|
readonly current: T;
|
||||||
|
rerender: () => void;
|
||||||
|
unmount: () => void;
|
||||||
|
renders: () => number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Run `hook` like a component would: state survives renders, a state change
|
||||||
|
* or store update renders again, effects run after each render and clean up
|
||||||
|
* on deps change and unmount.
|
||||||
|
*/
|
||||||
|
export function renderHook<T>(hook: () => T): HookResult<T> {
|
||||||
|
const slots: any[] = [];
|
||||||
|
let index = 0;
|
||||||
|
let result: T;
|
||||||
|
let rendering = false;
|
||||||
|
let dirty = false;
|
||||||
|
let mounted = true;
|
||||||
|
let count = 0;
|
||||||
|
const pending: Array<() => void> = [];
|
||||||
|
|
||||||
|
const schedule = () => {
|
||||||
|
if (!mounted) return;
|
||||||
|
if (rendering) {
|
||||||
|
dirty = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
run();
|
||||||
|
};
|
||||||
|
|
||||||
|
const effect = (fn: () => void | (() => void), deps?: unknown[]) => {
|
||||||
|
const i = index++;
|
||||||
|
const prev = slots[i] as { deps?: unknown[]; cleanup?: () => void } | undefined;
|
||||||
|
if (prev && deps && !depsChanged(prev.deps, deps)) return;
|
||||||
|
pending.push(() => {
|
||||||
|
prev?.cleanup?.();
|
||||||
|
const cleanup = fn();
|
||||||
|
slots[i] = { deps, cleanup: typeof cleanup === 'function' ? cleanup : undefined };
|
||||||
|
});
|
||||||
|
if (!prev) slots[i] = { deps: undefined };
|
||||||
|
};
|
||||||
|
|
||||||
|
const dispatcher = {
|
||||||
|
useState: (init: unknown) => {
|
||||||
|
const i = index++;
|
||||||
|
if (!(i in slots)) slots[i] = { value: typeof init === 'function' ? (init as () => unknown)() : init };
|
||||||
|
const slot = slots[i];
|
||||||
|
if (!slot.set) {
|
||||||
|
slot.set = (value: unknown) => {
|
||||||
|
const next = typeof value === 'function' ? (value as (v: unknown) => unknown)(slot.value) : value;
|
||||||
|
if (Object.is(next, slot.value)) return;
|
||||||
|
slot.value = next;
|
||||||
|
schedule();
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return [slot.value, slot.set];
|
||||||
|
},
|
||||||
|
useReducer: (reducer: (s: unknown, a: unknown) => unknown, init: unknown, fn?: (a: unknown) => unknown) => {
|
||||||
|
const i = index++;
|
||||||
|
if (!(i in slots)) slots[i] = { value: fn ? fn(init) : init };
|
||||||
|
const slot = slots[i];
|
||||||
|
slot.dispatch ??= (action: unknown) => {
|
||||||
|
const next = reducer(slot.value, action);
|
||||||
|
if (Object.is(next, slot.value)) return;
|
||||||
|
slot.value = next;
|
||||||
|
schedule();
|
||||||
|
};
|
||||||
|
return [slot.value, slot.dispatch];
|
||||||
|
},
|
||||||
|
useMemo: (fn: () => unknown, deps?: unknown[]) => {
|
||||||
|
const i = index++;
|
||||||
|
const prev = slots[i];
|
||||||
|
if (prev && deps && !depsChanged(prev.deps, deps)) return prev.value;
|
||||||
|
const value = fn();
|
||||||
|
slots[i] = { deps, value };
|
||||||
|
return value;
|
||||||
|
},
|
||||||
|
useCallback: (fn: unknown, deps?: unknown[]) => dispatcher.useMemo(() => fn, deps),
|
||||||
|
useRef: (current: unknown) => {
|
||||||
|
const i = index++;
|
||||||
|
if (!(i in slots)) slots[i] = { current };
|
||||||
|
return slots[i];
|
||||||
|
},
|
||||||
|
useEffect: effect,
|
||||||
|
useLayoutEffect: effect,
|
||||||
|
useInsertionEffect: effect,
|
||||||
|
useImperativeHandle: () => { index++; },
|
||||||
|
useContext: (context: { _currentValue: unknown }) => context._currentValue,
|
||||||
|
useSyncExternalStore: (subscribe: (cb: () => void) => () => void, getSnapshot: () => unknown) => {
|
||||||
|
const i = index++;
|
||||||
|
const value = getSnapshot();
|
||||||
|
if (!slots[i]) {
|
||||||
|
const slot = { value, unsubscribe: () => undefined as void };
|
||||||
|
slots[i] = slot;
|
||||||
|
slot.unsubscribe = subscribe(() => {
|
||||||
|
const next = getSnapshot();
|
||||||
|
if (Object.is(next, slot.value)) return;
|
||||||
|
slot.value = next;
|
||||||
|
schedule();
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
slots[i].value = value;
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
},
|
||||||
|
useDebugValue: () => undefined,
|
||||||
|
useId: () => `:r${index++}:`,
|
||||||
|
useTransition: () => [false, (fn: () => void) => fn()],
|
||||||
|
useDeferredValue: (value: unknown) => value,
|
||||||
|
};
|
||||||
|
|
||||||
|
function run() {
|
||||||
|
let loops = 0;
|
||||||
|
do {
|
||||||
|
if (++loops > 50) throw new Error('renderHook: too many re-renders (a selector that returns a new object?)');
|
||||||
|
dirty = false;
|
||||||
|
rendering = true;
|
||||||
|
index = 0;
|
||||||
|
const previous = internals.H;
|
||||||
|
internals.H = dispatcher;
|
||||||
|
try {
|
||||||
|
result = hook();
|
||||||
|
count++;
|
||||||
|
} finally {
|
||||||
|
internals.H = previous;
|
||||||
|
rendering = false;
|
||||||
|
}
|
||||||
|
while (pending.length > 0) {
|
||||||
|
rendering = true;
|
||||||
|
try {
|
||||||
|
pending.shift()!();
|
||||||
|
} finally {
|
||||||
|
rendering = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} while (dirty);
|
||||||
|
}
|
||||||
|
|
||||||
|
run();
|
||||||
|
return {
|
||||||
|
get current() { return result; },
|
||||||
|
rerender: () => run(),
|
||||||
|
unmount: () => {
|
||||||
|
mounted = false;
|
||||||
|
for (const slot of slots) {
|
||||||
|
slot?.cleanup?.();
|
||||||
|
slot?.unsubscribe?.();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
renders: () => count,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Wait until `check` passes (it throws until then), flushing promises and timers in between. */
|
||||||
|
export async function waitFor(check: () => void, { timeout = 2000 } = {}): Promise<void> {
|
||||||
|
// performance.now: tests fake Date.
|
||||||
|
const started = performance.now();
|
||||||
|
let last: unknown;
|
||||||
|
for (;;) {
|
||||||
|
try {
|
||||||
|
check();
|
||||||
|
return;
|
||||||
|
} catch (err) {
|
||||||
|
last = err;
|
||||||
|
}
|
||||||
|
if (performance.now() - started > timeout) throw last;
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 5));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The react-native surface the cards use, as host components. */
|
||||||
|
export function reactNativeMock() {
|
||||||
|
const host = (name: string) => name;
|
||||||
|
return {
|
||||||
|
View: host('View'),
|
||||||
|
Text: host('Text'),
|
||||||
|
Pressable: host('Pressable'),
|
||||||
|
Image: host('Image'),
|
||||||
|
ActivityIndicator: host('ActivityIndicator'),
|
||||||
|
ScrollView: host('ScrollView'),
|
||||||
|
Modal: host('Modal'),
|
||||||
|
StyleSheet: { create: <T,>(styles: T) => styles, hairlineWidth: 1, flatten: (s: unknown) => s },
|
||||||
|
Platform: { OS: 'android', Version: 33, select: <T,>(spec: { default?: T; android?: T; ios?: T }) => spec.android ?? spec.default ?? spec.ios },
|
||||||
|
I18nManager: { isRTL: false, allowRTL: () => undefined, forceRTL: () => undefined },
|
||||||
|
Alert: { alert: vi.fn() },
|
||||||
|
Linking: { openURL: vi.fn(async () => undefined) },
|
||||||
|
Appearance: { getColorScheme: () => 'light', addChangeListener: () => ({ remove: () => undefined }) },
|
||||||
|
AppState: { currentState: 'active', addEventListener: () => ({ remove: () => undefined }) },
|
||||||
|
useColorScheme: () => 'light',
|
||||||
|
useWindowDimensions: () => ({ width: 400, height: 800, scale: 2, fontScale: 1 }),
|
||||||
|
NativeModules: {},
|
||||||
|
NativeEventEmitter: class { addListener() { return { remove: () => undefined }; } removeAllListeners() {} },
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
// The one-line mount for MessageContent: cards for a verified sender's
|
||||||
|
// booking or order mail, nothing for anything else, and product images only
|
||||||
|
// when the body's remote content would load.
|
||||||
|
import { createElement } from 'react';
|
||||||
|
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
|
vi.mock('react-native', async () => (await import('./render-native')).reactNativeMock());
|
||||||
|
vi.mock('react-native-svg', () => ({ default: 'Svg', Path: 'Path' }));
|
||||||
|
vi.mock('expo-clipboard', () => ({ setStringAsync: vi.fn(async () => true) }));
|
||||||
|
// The calendar row has its own tests (use-booking-calendar.test.ts); here only
|
||||||
|
// whether each booking gets one.
|
||||||
|
vi.mock('../booking-calendar-actions', async () => {
|
||||||
|
const { createElement: h } = await import('react');
|
||||||
|
return { BookingCalendarActions: ({ booking }: { booking: { kind: string } }) => h('Text', { testID: 'calendar-actions' }, booking.kind) };
|
||||||
|
});
|
||||||
|
|
||||||
|
import type { Email } from '../../../../api/types';
|
||||||
|
import { useSettingsStore } from '../../../../stores/settings-store';
|
||||||
|
import { StructuredMailBanner } from '../StructuredMailBanner';
|
||||||
|
import { byTestId, render, textOf } from './render-native';
|
||||||
|
|
||||||
|
const ORDER = {
|
||||||
|
'@context': 'http://schema.org', '@type': 'Order', orderNumber: 'SO-1', merchant: 'Shop',
|
||||||
|
orderedItem: { '@type': 'Product', name: 'Boots', image: 'https://img.shop.example/boots.jpg' },
|
||||||
|
};
|
||||||
|
const PASS = 'mx.example.org; dkim=pass header.d=shop.example; spf=pass smtp.mailfrom=bounce@shop.example; dmarc=pass header.from=shop.example';
|
||||||
|
const RECEIVED = 'from mail.shop.example (mail.shop.example [192.0.2.1]) by mx.example.org (Stalwart SMTP) with ESMTPS id 1';
|
||||||
|
|
||||||
|
function mail(ar: string | null, subject = 'Your order'): Email {
|
||||||
|
return {
|
||||||
|
id: 'm1', threadId: 't', mailboxIds: {}, keywords: {}, size: 1, hasAttachment: false,
|
||||||
|
receivedAt: '2026-10-30T09:00:00Z', subject, from: [{ email: 'orders@shop.example' }],
|
||||||
|
headers: [{ name: 'Received', value: RECEIVED }, ...(ar ? [{ name: 'Authentication-Results', value: ar }] : [])],
|
||||||
|
htmlBody: [{ partId: '1', type: 'text/html' }],
|
||||||
|
bodyValues: { '1': { value: `<script type="application/ld+json">${JSON.stringify(ORDER)}</script><p>Thanks</p>` } },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const initialSettings = useSettingsStore.getState();
|
||||||
|
afterEach(() => useSettingsStore.setState(initialSettings, true));
|
||||||
|
|
||||||
|
describe('StructuredMailBanner', () => {
|
||||||
|
it('shows the order card of a verified mail', () => {
|
||||||
|
const tree = render(createElement(StructuredMailBanner, { email: mail(PASS) }));
|
||||||
|
const [card] = byTestId(tree, 'structured-mail-card-shipment');
|
||||||
|
expect(textOf(card)).toContain('Order number');
|
||||||
|
expect(textOf(card)).toContain('SO-1');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows nothing for an unverified sender or a forward', () => {
|
||||||
|
expect(render(createElement(StructuredMailBanner, { email: mail(null) })).children).toEqual([]);
|
||||||
|
expect(render(createElement(StructuredMailBanner, { email: mail(PASS, 'Fwd: Your order') })).children).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('gives a booking card the calendar row, and an order none', () => {
|
||||||
|
const train = {
|
||||||
|
'@context': 'http://schema.org', '@type': 'TrainReservation', reservationNumber: 'ZUG1',
|
||||||
|
reservationFor: {
|
||||||
|
'@type': 'TrainTrip', trainNumber: 'ICE 1', departureStation: { name: 'Hamburg Hbf' }, departureTime: '2026-12-05T08:46:00+01:00',
|
||||||
|
arrivalStation: { name: 'Berlin Hbf' }, arrivalTime: '2026-12-05T10:50:00+01:00',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const email = mail(PASS);
|
||||||
|
email.bodyValues = { '1': { value: `<script type="application/ld+json">${JSON.stringify(train)}</script>` } };
|
||||||
|
expect(byTestId(render(createElement(StructuredMailBanner, { email })), 'calendar-actions').map(textOf)).toEqual(['train']);
|
||||||
|
expect(byTestId(render(createElement(StructuredMailBanner, { email: mail(PASS) })), 'calendar-actions')).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('loads product images only when remote content would load', () => {
|
||||||
|
useSettingsStore.setState({ externalContentPolicy: 'block', trustedSenders: [] });
|
||||||
|
expect(byTestId(render(createElement(StructuredMailBanner, { email: mail(PASS) })), 'structured-mail-item-image')).toHaveLength(0);
|
||||||
|
useSettingsStore.setState({ trustedSenders: ['orders@shop.example'] });
|
||||||
|
expect(byTestId(render(createElement(StructuredMailBanner, { email: mail(PASS) })), 'structured-mail-item-image')).toHaveLength(1);
|
||||||
|
useSettingsStore.setState({ externalContentPolicy: 'allow', trustedSenders: [] });
|
||||||
|
expect(byTestId(render(createElement(StructuredMailBanner, { email: mail(PASS) })), 'structured-mail-item-image')).toHaveLength(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,329 @@
|
|||||||
|
// Port of webmail components/email/structured-mail/__tests__/
|
||||||
|
// structured-mail-cards.test.tsx at 972e5f621, on the app's views: the same
|
||||||
|
// cards from the reviewed fixtures, read back from the rendered tree. The
|
||||||
|
// strings are the English fallbacks (the vendored catalog predates these
|
||||||
|
// keys), so the assertions read like the cards do.
|
||||||
|
import { createElement } from 'react';
|
||||||
|
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
|
vi.mock('react-native', async () => (await import('./render-native')).reactNativeMock());
|
||||||
|
vi.mock('react-native-svg', () => ({ default: 'Svg', Path: 'Path' }));
|
||||||
|
const setStringAsync = vi.fn(async (_value: string) => true);
|
||||||
|
vi.mock('expo-clipboard', () => ({ setStringAsync: (value: string) => setStringAsync(value) }));
|
||||||
|
const toastSuccess = vi.fn();
|
||||||
|
vi.mock('../../../../stores/toast-store', () => ({ toast: { success: (m: string) => toastSuccess(m), error: vi.fn() } }));
|
||||||
|
const openExternalUrl = vi.fn(async (_url: string) => true);
|
||||||
|
vi.mock('../../../../lib/open-url', () => ({ openExternalUrl: (url: string) => openExternalUrl(url) }));
|
||||||
|
|
||||||
|
import { I18nManager } from 'react-native';
|
||||||
|
import expectedCards from '../../../../lib/structured-mail/__tests__/fixtures/expected-cards.json';
|
||||||
|
import type { Booking, Shipment, StructuredCard, TransportBooking } from '../../../../lib/structured-mail/types';
|
||||||
|
import { useSettingsStore } from '../../../../stores/settings-store';
|
||||||
|
import { StructuredMailCards, type StructuredMailCardsProps } from '../structured-mail-cards';
|
||||||
|
import { byLabel, byTestId, findAll, oneByTestId, rawTextOf, render, styleOf, textOf, type HostNode } from './render-native';
|
||||||
|
|
||||||
|
const fixtures = expectedCards as unknown as Record<string, StructuredCard[]>;
|
||||||
|
const card = <T extends StructuredCard = StructuredCard>(id: string, index = 0) => structuredClone(fixtures[id][index]) as T;
|
||||||
|
const initialSettings = useSettingsStore.getState();
|
||||||
|
|
||||||
|
beforeAll(() => {
|
||||||
|
// Only Date is faked: "now" decides whether a year is shown.
|
||||||
|
vi.useFakeTimers({ toFake: ['Date'] });
|
||||||
|
vi.setSystemTime(new Date('2026-10-30T12:00:00Z'));
|
||||||
|
});
|
||||||
|
afterAll(() => {
|
||||||
|
vi.useRealTimers();
|
||||||
|
});
|
||||||
|
beforeEach(() => {
|
||||||
|
useSettingsStore.setState({ timeFormat: '24h', calendarTimeZone: 'Europe/Berlin' });
|
||||||
|
});
|
||||||
|
afterEach(() => {
|
||||||
|
useSettingsStore.setState(initialSettings, true);
|
||||||
|
setStringAsync.mockClear();
|
||||||
|
toastSuccess.mockClear();
|
||||||
|
openExternalUrl.mockClear();
|
||||||
|
(I18nManager as { isRTL: boolean }).isRTL = false;
|
||||||
|
});
|
||||||
|
|
||||||
|
function show(cards: StructuredCard[], extra: Partial<StructuredMailCardsProps> = {}): HostNode {
|
||||||
|
return render(createElement(StructuredMailCards, { cards, allowRemoteImages: false, ...extra }));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The card with this accessible name (one of them). */
|
||||||
|
function region(tree: HostNode, name: string, index = 0): HostNode {
|
||||||
|
const found = findAll(tree, (n) => n.type === 'View' && n.props.accessibilityLabel === name && /^structured-mail-card-/.test(n.props.testID ?? ''));
|
||||||
|
if (!found[index]) throw new Error(`no card named ${name}`);
|
||||||
|
return found[index];
|
||||||
|
}
|
||||||
|
|
||||||
|
const title = (node: HostNode) => oneByTestId(node, 'structured-mail-title');
|
||||||
|
const pressables = (node: HostNode) => findAll(node, (n) => n.type === 'Pressable');
|
||||||
|
|
||||||
|
describe('StructuredMailCards', () => {
|
||||||
|
it('renders nothing for an empty list', () => {
|
||||||
|
expect(show([]).children).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows a flight with its route, legs at local time, passengers and seats', () => {
|
||||||
|
const r = region(show([card('flight-two-passengers-two-legs')]), 'Flight booking');
|
||||||
|
expect(title(r).props.accessibilityLabel).toBe('FRA to JFK to FRA');
|
||||||
|
expect(textOf(title(r))).toBe('FRA → JFK → FRA');
|
||||||
|
expect(textOf(r)).toContain('Flight · Lufthansa');
|
||||||
|
const legs = byTestId(r, 'structured-mail-leg');
|
||||||
|
expect(legs).toHaveLength(2);
|
||||||
|
// The mail's wall clock at each airport: 10:15 in Frankfurt, 13:05 in New York.
|
||||||
|
expect(textOf(legs[0])).toContain('Lufthansa LH 400');
|
||||||
|
expect(textOf(legs[0])).toContain('Tue, Dec 1, 10:15 · Frankfurt Airport (FRA)');
|
||||||
|
expect(textOf(legs[0])).toContain('13:05 local time · John F. Kennedy International Airport (JFK)');
|
||||||
|
// Overnight return: the arrival date is written out.
|
||||||
|
expect(textOf(legs[1])).toContain('Tue, Dec 8, 17:30');
|
||||||
|
expect(textOf(legs[1])).toContain('Wed, Dec 9, 07:20 · Frankfurt Airport (FRA)');
|
||||||
|
expect(textOf(legs[0])).toContain('Seats: Erika Mustermann 12A, Max Mustermann 12B');
|
||||||
|
expect(textOf(r)).toContain('Erika Mustermann, Max Mustermann');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('marks only the times whose place keeps another clock than the reader', () => {
|
||||||
|
const [outbound] = byTestId(show([card('flight-two-passengers-two-legs')]), 'structured-mail-leg');
|
||||||
|
// Reader in Berlin: Frankfurt needs no mark, New York does.
|
||||||
|
expect(byTestId(outbound, 'local-time-hint')).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('never shifts the time to the reader zone', () => {
|
||||||
|
useSettingsStore.setState({ calendarTimeZone: 'Asia/Tokyo' });
|
||||||
|
const [outbound] = byTestId(show([card('flight-two-passengers-two-legs')]), 'structured-mail-leg');
|
||||||
|
expect(textOf(outbound)).toContain('Tue, Dec 1, 10:15');
|
||||||
|
expect(textOf(outbound)).toContain('13:05');
|
||||||
|
expect(byTestId(outbound, 'local-time-hint')).toHaveLength(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('writes times on the 12-hour clock when the user picked it', () => {
|
||||||
|
useSettingsStore.setState({ timeFormat: '12h' });
|
||||||
|
const [outbound] = byTestId(show([card('flight-two-passengers-two-legs')]), 'structured-mail-leg');
|
||||||
|
expect(textOf(outbound)).toMatch(/Tue, Dec 1, 10:15\sAM/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('copies the booking reference', async () => {
|
||||||
|
const tree = show([card('flight-two-passengers-two-legs')]);
|
||||||
|
const [chip] = byLabel(tree, 'Copy booking reference QX7K2M');
|
||||||
|
expect(chip.type).toBe('Pressable');
|
||||||
|
chip.props.onPress();
|
||||||
|
await vi.waitFor(() => expect(setStringAsync).toHaveBeenCalledWith('QX7K2M'));
|
||||||
|
await vi.waitFor(() => expect(toastSuccess).toHaveBeenCalledWith('Booking reference copied'));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows the check-in link with its host and opens it through the URL guard', () => {
|
||||||
|
const tree = show([card('flight-two-passengers-two-legs')]);
|
||||||
|
const [link] = byTestId(tree, 'structured-mail-link');
|
||||||
|
expect(link.props.accessibilityRole).toBe('link');
|
||||||
|
expect(textOf(link)).toBe('Check in· airline-a.example.com');
|
||||||
|
link.props.onPress();
|
||||||
|
expect(openExternalUrl).toHaveBeenCalledWith('https://www.airline-a.example.com/checkin?pnr=QX7K2M');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('never renders a link that is not http(s)', () => {
|
||||||
|
const flight = card<TransportBooking>('flight-two-passengers-two-legs');
|
||||||
|
flight.links = { checkin: { url: 'javascript:alert(1)', host: 'evil.example' }, manage: { url: 'data:text/html,x', host: 'x' } };
|
||||||
|
const tree = show([flight]);
|
||||||
|
expect(byTestId(tree, 'structured-mail-link')).toHaveLength(0);
|
||||||
|
expect(JSON.stringify(textOf(tree))).not.toContain('javascript:');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows a cancelled booking struck through with a Cancelled pill', () => {
|
||||||
|
const r = region(show([card('flight-cancelled')]), 'Flight booking');
|
||||||
|
expect(byTestId(r, 'structured-mail-pill').map(textOf)).toEqual(['Cancelled']);
|
||||||
|
expect(styleOf(title(r)).textDecorationLine).toBe('line-through');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('marks a single cancelled leg of a live booking', () => {
|
||||||
|
const flight = card<TransportBooking>('flight-two-passengers-two-legs');
|
||||||
|
flight.legs[1].status = 'cancelled';
|
||||||
|
const tree = show([flight]);
|
||||||
|
expect(byTestId(tree, 'structured-mail-leg')).toHaveLength(1);
|
||||||
|
const [cancelled] = byTestId(tree, 'structured-mail-leg-cancelled');
|
||||||
|
expect(byTestId(cancelled, 'structured-mail-pill').map(textOf)).toEqual(['Cancelled']);
|
||||||
|
expect(styleOf(title(tree)).textDecorationLine).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows Pending and On hold', () => {
|
||||||
|
const pending = card<TransportBooking>('train-multiple-types');
|
||||||
|
pending.status = 'pending';
|
||||||
|
const hold = card<Booking>('graph-hotel');
|
||||||
|
hold.status = 'hold';
|
||||||
|
const tree = show([pending, hold]);
|
||||||
|
expect(textOf(region(tree, 'Train booking'))).toContain('Pending');
|
||||||
|
expect(textOf(region(tree, 'Hotel booking'))).toContain('On hold');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('titles a train with its towns and lists the stations', () => {
|
||||||
|
const r = region(show([card('train-multiple-types')]), 'Train booking');
|
||||||
|
expect(title(r).props.accessibilityLabel).toBe('Hamburg to München');
|
||||||
|
expect(textOf(r)).toContain('Wed, Nov 18, 08:01 · Hamburg Hbf');
|
||||||
|
expect(textOf(r)).toContain('13:58 · München Hbf');
|
||||||
|
expect(textOf(r)).toContain('Rail Co ICE 577');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows a hotel stay with address, check-in and check-out times, nights and the manage link', () => {
|
||||||
|
const r = region(show([card('graph-hotel')]), 'Hotel booking');
|
||||||
|
expect(textOf(title(r))).toBe('Hotel Am Markt');
|
||||||
|
expect(textOf(r)).toContain('Marktplatz 1, 10178 Berlin');
|
||||||
|
expect(textOf(r)).toContain('Check-in Fri, Dec 4, from 15:00');
|
||||||
|
expect(textOf(r)).toContain('Check-out Sun, Dec 6, until 11:00');
|
||||||
|
expect(textOf(r)).toContain('2 nights');
|
||||||
|
expect(textOf(byLabel(r, /^Manage booking, /)[0])).toContain('hotel-booking.example.com');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows a dates-only stay without times', () => {
|
||||||
|
const r = region(show([card('hotel-dates-only')]), 'Hotel booking');
|
||||||
|
expect(textOf(r)).toContain('Check-in Tue, Jun 1, 2027');
|
||||||
|
expect(textOf(r)).toContain('Check-out Tue, Jun 8, 2027');
|
||||||
|
expect(textOf(r)).not.toContain('from ');
|
||||||
|
expect(textOf(r)).toContain('7 nights');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows a rental car with pick-up, return and car', () => {
|
||||||
|
const r = region(show([card('rental-car')]), 'Rental car booking');
|
||||||
|
expect(textOf(title(r))).toBe('Car Rental Co');
|
||||||
|
expect(textOf(r)).toContain('Pick-up Fri, Dec 4, 09:00 · Flughafen BER, Schönefeld');
|
||||||
|
expect(textOf(r)).toContain('Return Mon, Dec 7, 18:00 · Flughafen BER, Schönefeld');
|
||||||
|
expect(textOf(r)).toContain('VW Golf');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows an event with its times, venue, tickets and ticket link', () => {
|
||||||
|
const tree = show([card('eventbrite-trailing-commas'), card('ahoi-event')]);
|
||||||
|
const eventbrite = region(tree, 'Event booking', 0);
|
||||||
|
const workshop = region(tree, 'Event booking', 1);
|
||||||
|
expect(textOf(title(eventbrite))).toBe('Jazz im Park');
|
||||||
|
expect(textOf(eventbrite)).toContain('Sat, Nov 14, 19:00 – 22:30');
|
||||||
|
expect(textOf(eventbrite)).toContain('Stadtpark, Parkweg 2, Hamburg');
|
||||||
|
expect(textOf(byLabel(eventbrite, /^Ticket, /)[0])).toContain('tickets.example.com');
|
||||||
|
expect(textOf(workshop)).toContain('Sun, Jul 18, 2021, 14:00');
|
||||||
|
expect(textOf(workshop)).toContain('Tickets: AB12CD-1');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows a restaurant table with time, address and party size', () => {
|
||||||
|
const r = region(show([card('restaurant-cafe')]), 'Restaurant reservation');
|
||||||
|
expect(textOf(title(r))).toBe('Café Kranzler');
|
||||||
|
expect(textOf(r)).toContain('Sat, Nov 7, 19:30');
|
||||||
|
expect(textOf(r)).toContain('Kurfürstendamm 18, 10719 Berlin');
|
||||||
|
expect(textOf(r)).toContain('Table for 4');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows an order with its parcel: number, status, items, total, tracking and links', async () => {
|
||||||
|
const r = region(show([card('order-and-parcel')]), 'Order and delivery');
|
||||||
|
expect(textOf(title(r))).toBe('Outdoor Shop');
|
||||||
|
expect(textOf(r)).toContain('Order · DHL');
|
||||||
|
expect(byTestId(r, 'structured-mail-pill').map(textOf)).toEqual(['Shipped']);
|
||||||
|
expect(textOf(r)).toContain('Wanderschuhe, Socken');
|
||||||
|
expect(textOf(oneByTestId(r, 'structured-mail-summary'))).toBe('3 items · Total €129.90');
|
||||||
|
expect(textOf(r)).toContain('Arrives by Tue, Nov 3');
|
||||||
|
expect(textOf(byLabel(r, /^Track, /)[0])).toBe('Track· dhl.de');
|
||||||
|
expect(textOf(byLabel(r, /^View order, /)[0])).toContain('outdoor-shop.example.com');
|
||||||
|
|
||||||
|
byLabel(r, 'Copy tracking number 00340434161234567890')[0].props.onPress();
|
||||||
|
await vi.waitFor(() => expect(setStringAsync).toHaveBeenCalledWith('00340434161234567890'));
|
||||||
|
byLabel(r, 'Copy order number SO-449120')[0].props.onPress();
|
||||||
|
await vi.waitFor(() => expect(setStringAsync).toHaveBeenCalledWith('SO-449120'));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('drops the expected date once the order is delivered', () => {
|
||||||
|
const shipment = card<Shipment>('order-and-parcel');
|
||||||
|
shipment.status = 'delivered';
|
||||||
|
const r = region(show([shipment]), 'Order and delivery');
|
||||||
|
expect(textOf(r)).toContain('Delivered');
|
||||||
|
expect(textOf(r)).not.toContain('Arrives by');
|
||||||
|
// Carrier and tracking number stay.
|
||||||
|
expect(textOf(r)).toContain('00340434161234567890');
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('right-to-left place names and values', () => {
|
||||||
|
const hebrewTrain = (): TransportBooking => {
|
||||||
|
const train = card<TransportBooking>('train-multiple-types');
|
||||||
|
train.legs[0].departure.place = { name: 'תל אביב סבידור מרכז', address: { locality: 'תל אביב', country: 'IL' } };
|
||||||
|
train.legs[0].arrival!.place = { name: 'חיפה מרכז השמונה', address: { locality: 'חיפה', country: 'IL' } };
|
||||||
|
return train;
|
||||||
|
};
|
||||||
|
const FSI = String.fromCharCode(0x2068);
|
||||||
|
const LRI = String.fromCharCode(0x2066);
|
||||||
|
const PDI = String.fromCharCode(0x2069);
|
||||||
|
|
||||||
|
it.each([false, true])('isolates each stop and points the arrow the reading direction (RTL %s)', (rtl) => {
|
||||||
|
(I18nManager as { isRTL: boolean }).isRTL = rtl;
|
||||||
|
const r = region(show([hebrewTrain()]), 'Train booking');
|
||||||
|
// Spoken in reading order, whatever the script.
|
||||||
|
expect(title(r).props.accessibilityLabel).toBe('תל אביב to חיפה');
|
||||||
|
const arrow = rtl ? '←' : '→';
|
||||||
|
expect(rawTextOf(title(r))).toBe(`${FSI}תל אביב${PDI} ${arrow} ${FSI}חיפה${PDI}`);
|
||||||
|
// Station names in the leg rows are isolated too.
|
||||||
|
const leg = oneByTestId(r, 'structured-mail-leg');
|
||||||
|
expect(rawTextOf(leg)).toContain(`${FSI}תל אביב סבידור מרכז${PDI}`);
|
||||||
|
expect(rawTextOf(leg)).toContain(`${FSI}חיפה מרכז השמונה${PDI}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps references, tracking and order numbers, seats and tickets left to right', () => {
|
||||||
|
(I18nManager as { isRTL: boolean }).isRTL = true;
|
||||||
|
const tree = show([card('flight-two-passengers-two-legs'), card('ahoi-event'), card('order-and-parcel')]);
|
||||||
|
const all = rawTextOf(tree);
|
||||||
|
for (const value of ['QX7K2M', 'AB12CD', 'SO-449120', '00340434161234567890']) {
|
||||||
|
expect(all).toContain(`${LRI}${value}${PDI}`);
|
||||||
|
}
|
||||||
|
const seats = byTestId(tree, 'structured-mail-seats').map(rawTextOf).join(' ');
|
||||||
|
for (const seat of ['12A', '12B', '31K', '31J']) expect(seats).toContain(`${LRI}${seat}${PDI}`);
|
||||||
|
expect(rawTextOf(oneByTestId(tree, 'structured-mail-tickets'))).toContain(`${LRI}AB12CD-1${PDI}`);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows no action row when a card has nothing it would put there', () => {
|
||||||
|
const restaurant = card<Booking>('restaurant-cafe');
|
||||||
|
delete restaurant.reference;
|
||||||
|
// A restaurant card shows no ticket link, so this alone gives no row.
|
||||||
|
restaurant.links = { ticket: { url: 'https://tables.example.com/t/1', host: 'tables.example.com' } };
|
||||||
|
const r = region(show([restaurant]), 'Restaurant reservation');
|
||||||
|
expect(byTestId(r, 'structured-mail-actions')).toHaveLength(0);
|
||||||
|
expect(byTestId(r, 'structured-mail-link')).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows one Track button per tracking page', () => {
|
||||||
|
const shipment = card<Shipment>('order-and-parcel');
|
||||||
|
const parcel = shipment.parcels[0];
|
||||||
|
// The same parcel twice (same number and page), and a second one with its own page.
|
||||||
|
shipment.parcels = [parcel, { ...parcel }, { ...parcel, trackingNumber: '00340434169999999999', trackingUrl: { url: 'https://www.dhl.de/x?piececode=00340434169999999999', host: 'dhl.de' } }];
|
||||||
|
const r = region(show([shipment]), 'Order and delivery');
|
||||||
|
const tracks = byLabel(r, /^Track, /);
|
||||||
|
tracks.forEach((track) => track.props.onPress());
|
||||||
|
expect(openExternalUrl.mock.calls.map(([url]) => url)).toEqual([parcel.trackingUrl!.url, 'https://www.dhl.de/x?piececode=00340434169999999999']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('lists three items and says how many more there are', () => {
|
||||||
|
const shipment: Shipment = {
|
||||||
|
kind: 'shipment', key: 's', merchant: 'Shop', orderNumber: 'A-1', source: 'json-ld', parcels: [],
|
||||||
|
items: ['One', 'Two', 'Three', 'Four', 'Five'].map((name) => ({ name })),
|
||||||
|
total: { amount: '1299.00', currency: 'USD' },
|
||||||
|
};
|
||||||
|
const r = region(show([shipment]), 'Order and delivery');
|
||||||
|
expect(textOf(r)).toContain('One, Two, Three and 2 more');
|
||||||
|
expect(textOf(r)).not.toContain('Four');
|
||||||
|
expect(textOf(r)).toContain('Total $1,299.00');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('loads item images only when remote content is allowed', () => {
|
||||||
|
expect(byTestId(show([card('order-and-parcel')]), 'structured-mail-item-image')).toHaveLength(0);
|
||||||
|
const images = byTestId(show([card('order-and-parcel')], { allowRemoteImages: true }), 'structured-mail-item-image');
|
||||||
|
expect(images).toHaveLength(1);
|
||||||
|
expect(images[0].props.source).toEqual({ uri: 'https://img.shop.example.com/p/1.jpg' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('gives each booking to the calendar slot, and none for a shipment', () => {
|
||||||
|
const slot = vi.fn((booking: Booking) => createElement('Text', { testID: 'slot' }, `add ${booking.kind}`));
|
||||||
|
const tree = show([card('flight-two-passengers-two-legs'), card('graph-hotel'), card('order-and-parcel')], { calendarActions: slot });
|
||||||
|
expect(slot).toHaveBeenCalledTimes(2);
|
||||||
|
expect(slot.mock.calls.map(([b]) => b.kind)).toEqual(['flight', 'lodging']);
|
||||||
|
expect(slot.mock.calls[0][0]).toMatchObject({ reference: 'QX7K2M' });
|
||||||
|
expect(byTestId(region(tree, 'Flight booking'), 'slot').map(textOf)).toEqual(['add flight']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders no calendar items in the action row when the slot is undefined', () => {
|
||||||
|
const r = region(show([card('restaurant-cafe')]), 'Restaurant reservation');
|
||||||
|
// Only the reference chip.
|
||||||
|
expect(pressables(r)).toHaveLength(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,404 @@
|
|||||||
|
// Port of webmail components/email/structured-mail/__tests__/
|
||||||
|
// use-booking-calendar.test.tsx at 3aac5a5c4, against the app's calendar
|
||||||
|
// store (its write functions swapped for mocks) and api/calendar's
|
||||||
|
// findEventsByUid (mocked). The webmail's plugin "blocked" cases and its
|
||||||
|
// keyboard and viewport cases for the floating picker have no app
|
||||||
|
// counterpart; the account switch cases are the app's own.
|
||||||
|
import { createElement } from 'react';
|
||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
|
vi.mock('react-native', async () => (await import('./render-native')).reactNativeMock());
|
||||||
|
vi.mock('react-native-svg', () => ({ default: 'Svg', Path: 'Path' }));
|
||||||
|
const findEventsByUid = vi.fn();
|
||||||
|
vi.mock('../../../../api/calendar', async (importOriginal) => ({
|
||||||
|
...(await importOriginal<typeof import('../../../../api/calendar')>()),
|
||||||
|
findEventsByUid: (uid: string) => findEventsByUid(uid),
|
||||||
|
}));
|
||||||
|
const navigate = vi.fn();
|
||||||
|
vi.mock('@react-navigation/native', () => ({ useNavigation: () => ({ navigate }) }));
|
||||||
|
|
||||||
|
import type { Calendar, CalendarEvent, Email } from '../../../../api/types';
|
||||||
|
import { loadAirportZones, readStructuredMail, resolveCards, type Booking, type StructuredCard } from '../../../../lib/structured-mail';
|
||||||
|
import { bookingToEvents } from '../../../../lib/structured-mail/calendar';
|
||||||
|
import { usePendingCalendarOpen } from '../../../../navigation/pending-calendar-open';
|
||||||
|
import { CalendarAccountChangedError, useCalendarStore } from '../../../../stores/calendar-store';
|
||||||
|
import { useCalendarSubscriptionsStore } from '../../../../stores/calendar-subscriptions-store';
|
||||||
|
import { BookingCalendarActions } from '../booking-calendar-actions';
|
||||||
|
import { resetRemovedBookingUids, useBookingCalendar } from '../use-booking-calendar';
|
||||||
|
import { byTestId, findAll, render, renderHook, textOf, waitFor, type HostNode } from './render-native';
|
||||||
|
|
||||||
|
const ctx = 'http://schema.org';
|
||||||
|
const NOW = new Date('2026-11-01T12:00:00Z');
|
||||||
|
const EMAIL = { from: [{ email: 'booking@mail.lufthansa.com' }], subject: 'Your booking QX7K2M' } as unknown as Email;
|
||||||
|
|
||||||
|
function leg(number: string, dep: [string, string], arr: [string, string], status?: string) {
|
||||||
|
return {
|
||||||
|
'@context': ctx, '@type': 'FlightReservation', reservationNumber: 'QX7K2M', underName: 'Erika Mustermann',
|
||||||
|
...(status ? { reservationStatus: `http://schema.org/${status}` } : {}),
|
||||||
|
reservationFor: {
|
||||||
|
'@type': 'Flight', flightNumber: number, airline: { name: 'Lufthansa', iataCode: 'LH' },
|
||||||
|
departureAirport: { iataCode: dep[0] }, departureTime: dep[1],
|
||||||
|
arrivalAirport: { iataCode: arr[0] }, arrivalTime: arr[1],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const OUT = leg('400', ['FRA', '2026-12-01T10:15:00+01:00'], ['JFK', '2026-12-01T13:05:00-05:00']);
|
||||||
|
const OUT_MOVED = leg('400', ['FRA', '2026-12-01T12:40:00+01:00'], ['JFK', '2026-12-01T15:35:00-05:00']);
|
||||||
|
const BACK = leg('401', ['JFK', '2026-12-08T17:30:00-05:00'], ['FRA', '2026-12-09T07:20:00+01:00']);
|
||||||
|
const BACK_CANCELLED = leg('401', ['JFK', '2026-12-08T17:30:00-05:00'], ['FRA', '2026-12-09T07:20:00+01:00'], 'ReservationCancelled');
|
||||||
|
const OUT_CANCELLED = leg('400', ['FRA', '2026-12-01T10:15:00+01:00'], ['JFK', '2026-12-01T13:05:00-05:00'], 'ReservationCancelled');
|
||||||
|
|
||||||
|
async function bookingFrom(items: unknown[], mailDate = '2026-10-30T10:00:00Z'): Promise<Booking> {
|
||||||
|
const html = `<html><head>${items.map((i) => `<script type="application/ld+json">${JSON.stringify(i)}</script>`).join('')}</head></html>`;
|
||||||
|
const cards = resolveCards(readStructuredMail(html, { mailDate: new Date(mailDate) }), await loadAirportZones());
|
||||||
|
expect(cards).toHaveLength(1);
|
||||||
|
return cards[0] as Booking;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** CalendarEvent/get's shape for an event made from `wanted`: id, calendar, server defaults. */
|
||||||
|
function stored(wanted: Partial<CalendarEvent>, id: string, calendarId = 'cal-default'): CalendarEvent {
|
||||||
|
const locations = Object.fromEntries(Object.entries(wanted.locations ?? {}).map(([key, loc]) => [key, { ...loc, iCalendar: { name: 'vlocation' } }]));
|
||||||
|
return {
|
||||||
|
...wanted, id, calendarIds: { [calendarId]: true }, utcStart: '2026-12-01T09:15:00Z', participants: null,
|
||||||
|
description: 'whatever the server keeps', locations,
|
||||||
|
} as unknown as CalendarEvent;
|
||||||
|
}
|
||||||
|
|
||||||
|
const rights = (write: boolean) => ({ mayReadFreeBusy: true, mayReadItems: true, mayWriteAll: write, mayWriteOwn: write, mayUpdatePrivate: true, mayRSVP: true, mayShare: false, mayDelete: write });
|
||||||
|
const cal = (id: string, extra: Partial<Calendar> = {}): Calendar => ({
|
||||||
|
id, name: id, color: '#3366ff', isDefault: false, isSubscribed: true, isVisible: true, sortOrder: 0, description: null,
|
||||||
|
shareWith: null, myRights: rights(true), ...extra,
|
||||||
|
});
|
||||||
|
|
||||||
|
const createEvent = vi.fn();
|
||||||
|
const updateEvent = vi.fn();
|
||||||
|
const deleteEvent = vi.fn();
|
||||||
|
const initialCalendar = useCalendarStore.getState();
|
||||||
|
const initialSubscriptions = useCalendarSubscriptionsStore.getState();
|
||||||
|
|
||||||
|
/** The calendar holds these events, found by uid. */
|
||||||
|
function calendarHolds(events: CalendarEvent[]) {
|
||||||
|
findEventsByUid.mockImplementation(async (uid: string) => events.filter((e) => e.uid === uid));
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.useFakeTimers({ toFake: ['Date'] });
|
||||||
|
vi.setSystemTime(NOW);
|
||||||
|
findEventsByUid.mockReset();
|
||||||
|
calendarHolds([]);
|
||||||
|
resetRemovedBookingUids();
|
||||||
|
createEvent.mockReset().mockImplementation(async (event: Partial<CalendarEvent>, calendarId: string) => {
|
||||||
|
const created = { ...event, id: `new-${event.uid}`, calendarIds: { [calendarId]: true } } as CalendarEvent;
|
||||||
|
useCalendarStore.setState((s) => ({ events: [...s.events, created] }));
|
||||||
|
return created;
|
||||||
|
});
|
||||||
|
updateEvent.mockReset().mockImplementation(async (id: string, patch: Partial<CalendarEvent>) => {
|
||||||
|
useCalendarStore.setState((s) => ({ events: s.events.map((e) => (e.id === id ? { ...e, ...patch } : e)) }));
|
||||||
|
});
|
||||||
|
deleteEvent.mockReset().mockResolvedValue(undefined);
|
||||||
|
navigate.mockReset();
|
||||||
|
useCalendarStore.setState({
|
||||||
|
calendars: [cal('cal-default', { isDefault: true })],
|
||||||
|
events: [],
|
||||||
|
taskOnlyCalendarIds: [],
|
||||||
|
createEvent, updateEvent, deleteEvent,
|
||||||
|
});
|
||||||
|
useCalendarSubscriptionsStore.setState({ subscriptions: [] });
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.useRealTimers();
|
||||||
|
useCalendarStore.setState(initialCalendar, true);
|
||||||
|
useCalendarSubscriptionsStore.setState(initialSubscriptions, true);
|
||||||
|
usePendingCalendarOpen.setState({ target: null });
|
||||||
|
});
|
||||||
|
|
||||||
|
async function settled(card: StructuredCard | null) {
|
||||||
|
const hook = renderHook(() => useBookingCalendar(card, EMAIL));
|
||||||
|
await waitFor(() => expect(hook.current.status).not.toBe('loading'));
|
||||||
|
return hook;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('useBookingCalendar', () => {
|
||||||
|
it('offers to add a booking the calendar lacks, and adds each live leg once', async () => {
|
||||||
|
const booking = await bookingFrom([OUT, BACK]);
|
||||||
|
const hook = await settled(booking);
|
||||||
|
expect(hook.current.status).toBe('add');
|
||||||
|
expect(findEventsByUid).toHaveBeenCalledTimes(2);
|
||||||
|
|
||||||
|
await hook.current.add('cal-default');
|
||||||
|
expect(createEvent).toHaveBeenCalledTimes(2);
|
||||||
|
const [first, calendarId] = createEvent.mock.calls[0];
|
||||||
|
const wanted = bookingToEvents(booking, { senderDomain: 'lufthansa.com' })[0];
|
||||||
|
expect(calendarId).toBe('cal-default');
|
||||||
|
expect(first).toMatchObject({
|
||||||
|
uid: wanted.uid, title: 'LH 400 FRA → JFK',
|
||||||
|
start: '2026-12-01T10:15:00', timeZone: 'Europe/Berlin', duration: 'PT8H50M',
|
||||||
|
});
|
||||||
|
expect(first).not.toHaveProperty('participants');
|
||||||
|
// No options: never scheduling messages.
|
||||||
|
expect(createEvent.mock.calls[0]).toHaveLength(2);
|
||||||
|
expect(hook.current.status).toBe('in-calendar');
|
||||||
|
expect(hook.current.notice).toBe('added');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('says the booking is in the calendar when every leg is there unchanged', async () => {
|
||||||
|
const booking = await bookingFrom([OUT, BACK]);
|
||||||
|
const [a, b] = bookingToEvents(booking, { senderDomain: 'lufthansa.com' });
|
||||||
|
calendarHolds([stored(a, 'e1'), stored(b, 'e2')]);
|
||||||
|
const hook = await settled(booking);
|
||||||
|
expect(hook.current.status).toBe('in-calendar');
|
||||||
|
expect(useCalendarStore.getState().events.map((e) => e.id).sort()).toEqual(['e1', 'e2']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('updates the found event after a schedule change and never creates a second one', async () => {
|
||||||
|
const [old] = bookingToEvents(await bookingFrom([OUT]), { senderDomain: 'lufthansa.com' });
|
||||||
|
calendarHolds([stored(old, 'e1')]);
|
||||||
|
const hook = await settled(await bookingFrom([OUT_MOVED]));
|
||||||
|
expect(hook.current.status).toBe('update');
|
||||||
|
|
||||||
|
await hook.current.update();
|
||||||
|
expect(createEvent).not.toHaveBeenCalled();
|
||||||
|
expect(updateEvent).toHaveBeenCalledTimes(1);
|
||||||
|
const [id, patch, options] = updateEvent.mock.calls[0];
|
||||||
|
expect(id).toBe('e1');
|
||||||
|
expect(patch).toMatchObject({ start: '2026-12-01T12:40:00', timeZone: 'Europe/Berlin', duration: 'PT8H55M' });
|
||||||
|
// The stored description isn't the generated one any more: title and notes stay the user's.
|
||||||
|
expect(patch).not.toHaveProperty('title');
|
||||||
|
expect(patch).not.toHaveProperty('description');
|
||||||
|
expect(options).toBeUndefined();
|
||||||
|
expect(hook.current.status).toBe('in-calendar');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('adds a missing leg next to the others, in their calendar, as an update', async () => {
|
||||||
|
useCalendarStore.setState({ calendars: [cal('cal-default', { isDefault: true }), cal('cal-travel')] });
|
||||||
|
const booking = await bookingFrom([OUT, BACK]);
|
||||||
|
const [a] = bookingToEvents(booking, { senderDomain: 'lufthansa.com' });
|
||||||
|
calendarHolds([stored(a, 'e1', 'cal-travel')]);
|
||||||
|
const hook = await settled(booking);
|
||||||
|
expect(hook.current.status).toBe('update');
|
||||||
|
await hook.current.update();
|
||||||
|
expect(createEvent).toHaveBeenCalledTimes(1);
|
||||||
|
expect(createEvent.mock.calls[0][0]).toMatchObject({ title: 'LH 401 JFK → FRA' });
|
||||||
|
expect(createEvent.mock.calls[0][1]).toBe('cal-travel');
|
||||||
|
expect(updateEvent).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('offers to remove a cancelled booking that is in the calendar, and removes it', async () => {
|
||||||
|
const [old] = bookingToEvents(await bookingFrom([OUT]), { senderDomain: 'lufthansa.com' });
|
||||||
|
calendarHolds([stored(old, 'e1')]);
|
||||||
|
const hook = await settled(await bookingFrom([OUT_CANCELLED]));
|
||||||
|
expect(hook.current.status).toBe('remove');
|
||||||
|
await hook.current.remove();
|
||||||
|
expect(deleteEvent).toHaveBeenCalledWith('e1');
|
||||||
|
expect(createEvent).not.toHaveBeenCalled();
|
||||||
|
expect(hook.current.status).toBe('cancelled');
|
||||||
|
expect(hook.current.notice).toBe('removed');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('offers nothing for a cancelled booking the calendar never had', async () => {
|
||||||
|
const hook = await settled(await bookingFrom([OUT_CANCELLED]));
|
||||||
|
expect(hook.current.status).toBe('cancelled');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('never adds a cancelled leg, and removes its event along with the update', async () => {
|
||||||
|
const booking = await bookingFrom([OUT, BACK_CANCELLED]);
|
||||||
|
const hook = await settled(booking);
|
||||||
|
expect(hook.current.status).toBe('add');
|
||||||
|
await hook.current.add();
|
||||||
|
expect(createEvent).toHaveBeenCalledTimes(1);
|
||||||
|
expect(createEvent.mock.calls[0][0]).toMatchObject({ title: 'LH 400 FRA → JFK' });
|
||||||
|
|
||||||
|
const [a, b] = bookingToEvents(await bookingFrom([OUT, BACK]), { senderDomain: 'lufthansa.com' });
|
||||||
|
calendarHolds([stored(a, 'e1'), stored(b, 'e2')]);
|
||||||
|
useCalendarStore.setState({ events: [] });
|
||||||
|
createEvent.mockClear();
|
||||||
|
const second = await settled(booking);
|
||||||
|
expect(second.current.status).toBe('update');
|
||||||
|
await second.current.update();
|
||||||
|
expect(deleteEvent).toHaveBeenCalledWith('e2');
|
||||||
|
expect(createEvent).not.toHaveBeenCalled();
|
||||||
|
expect(updateEvent).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not find events another sender domain made', async () => {
|
||||||
|
const booking = await bookingFrom([OUT]);
|
||||||
|
const [foreign] = bookingToEvents(booking, { senderDomain: 'lufthansa-deals.com' });
|
||||||
|
calendarHolds([stored(foreign, 'e9')]);
|
||||||
|
const hook = await settled(booking);
|
||||||
|
expect(hook.current.status).toBe('add');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows nothing for a booking that is over', async () => {
|
||||||
|
vi.setSystemTime(new Date('2026-12-02T12:00:00Z'));
|
||||||
|
expect(renderHook(() => useBookingCalendar(null, EMAIL)).current.status).toBe('hidden');
|
||||||
|
const booking = await bookingFrom([OUT]);
|
||||||
|
expect(renderHook(() => useBookingCalendar(booking, EMAIL)).current.status).toBe('hidden');
|
||||||
|
expect(findEventsByUid).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows nothing for shipments', () => {
|
||||||
|
const shipment: StructuredCard = { kind: 'shipment', key: 'shipment|order:1', orderNumber: '1', items: [], parcels: [], source: 'json-ld' };
|
||||||
|
expect(renderHook(() => useBookingCalendar(shipment, EMAIL)).current.status).toBe('hidden');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows nothing when the lookup fails, rather than risk a duplicate', async () => {
|
||||||
|
findEventsByUid.mockRejectedValue(new Error('offline'));
|
||||||
|
const hook = await settled(await bookingFrom([OUT]));
|
||||||
|
expect(hook.current.status).toBe('hidden');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows nothing to add without a calendar it may write to', async () => {
|
||||||
|
useCalendarStore.setState({ calendars: [cal('ro', { myRights: rights(false) })] });
|
||||||
|
const hook = await settled(await bookingFrom([OUT]));
|
||||||
|
expect(hook.current.status).toBe('hidden');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('says why an add failed and looks the events up again', async () => {
|
||||||
|
createEvent.mockRejectedValue(new Error('Failed to create event'));
|
||||||
|
const hook = await settled(await bookingFrom([OUT]));
|
||||||
|
await hook.current.add();
|
||||||
|
expect(hook.current.error).toBe("Couldn't change your calendar. Try again.");
|
||||||
|
expect(hook.current.status).toBe('add');
|
||||||
|
await waitFor(() => expect(findEventsByUid).toHaveBeenCalledTimes(2));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows a booking added from another card for the same booking as in the calendar', async () => {
|
||||||
|
const first = await settled(await bookingFrom([OUT]));
|
||||||
|
const second = await settled(await bookingFrom([OUT]));
|
||||||
|
expect(first.current.status).toBe('add');
|
||||||
|
expect(second.current.status).toBe('add');
|
||||||
|
await second.current.add();
|
||||||
|
expect(second.current.status).toBe('in-calendar');
|
||||||
|
await waitFor(() => expect(first.current.status).toBe('in-calendar'));
|
||||||
|
expect(createEvent).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows a booking removed from another card as gone', async () => {
|
||||||
|
const [old] = bookingToEvents(await bookingFrom([OUT]), { senderDomain: 'lufthansa.com' });
|
||||||
|
calendarHolds([stored(old, 'e1')]);
|
||||||
|
deleteEvent.mockImplementation(async (id: string) => {
|
||||||
|
useCalendarStore.setState((s) => ({ events: s.events.filter((e) => e.id !== id) }));
|
||||||
|
});
|
||||||
|
const first = await settled(await bookingFrom([OUT_CANCELLED]));
|
||||||
|
const second = await settled(await bookingFrom([OUT_CANCELLED]));
|
||||||
|
expect(first.current.status).toBe('remove');
|
||||||
|
await second.current.remove();
|
||||||
|
await waitFor(() => expect(first.current.status).toBe('cancelled'));
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('account switches', () => {
|
||||||
|
it('drops a lookup that lands after the account changed', async () => {
|
||||||
|
let answer: (events: CalendarEvent[]) => void = () => undefined;
|
||||||
|
findEventsByUid.mockImplementation(() => new Promise<CalendarEvent[]>((resolve) => { answer = resolve; }));
|
||||||
|
const hook = renderHook(() => useBookingCalendar(OUT_BOOKING!, EMAIL));
|
||||||
|
expect(hook.current.status).toBe('loading');
|
||||||
|
useCalendarStore.getState().reset();
|
||||||
|
useCalendarStore.setState({ calendars: [cal('cal-default', { isDefault: true })], createEvent, updateEvent, deleteEvent });
|
||||||
|
answer([]);
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 20));
|
||||||
|
expect(hook.current.status).toBe('loading');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('stops a multi-leg add at the switch, and says nothing failed', async () => {
|
||||||
|
const hook = await settled(await bookingFrom([OUT, BACK]));
|
||||||
|
createEvent.mockImplementationOnce(async (event: Partial<CalendarEvent>, calendarId: string) => {
|
||||||
|
const created = { ...event, id: 'new-1', calendarIds: { [calendarId]: true } } as CalendarEvent;
|
||||||
|
// The account changes while the first leg is being written.
|
||||||
|
useCalendarStore.getState().reset();
|
||||||
|
useCalendarStore.setState({ createEvent, updateEvent, deleteEvent });
|
||||||
|
return created;
|
||||||
|
});
|
||||||
|
await hook.current.add('cal-default');
|
||||||
|
// The second leg never went out to the next account.
|
||||||
|
expect(createEvent).toHaveBeenCalledTimes(1);
|
||||||
|
expect(hook.current.error).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows no error when the store refuses a write the switch overtook', async () => {
|
||||||
|
createEvent.mockRejectedValue(new CalendarAccountChangedError());
|
||||||
|
const hook = await settled(await bookingFrom([OUT]));
|
||||||
|
await hook.current.add();
|
||||||
|
expect(hook.current.error).toBeNull();
|
||||||
|
expect(hook.current.notice).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
let OUT_BOOKING: Booking | null = null;
|
||||||
|
beforeEach(async () => {
|
||||||
|
OUT_BOOKING ??= await bookingFrom([OUT]);
|
||||||
|
});
|
||||||
|
|
||||||
|
/** BookingCalendarActions with its own state; the tree is rendered from the latest output. */
|
||||||
|
function mountActions(booking: Booking) {
|
||||||
|
const hook = renderHook(() => BookingCalendarActions({ booking, email: EMAIL }));
|
||||||
|
return { hook, tree: (): HostNode => render(hook.current ?? createElement('View')) };
|
||||||
|
}
|
||||||
|
|
||||||
|
const buttonText = (tree: HostNode) => findAll(tree, (n) => n.type === 'Pressable').map(textOf);
|
||||||
|
|
||||||
|
describe('BookingCalendarActions', () => {
|
||||||
|
it('lists only writable event calendars of the own account, the default first', async () => {
|
||||||
|
useCalendarStore.setState({
|
||||||
|
calendars: [
|
||||||
|
cal('Work'),
|
||||||
|
cal('Personal', { isDefault: true }),
|
||||||
|
cal('Tasks'),
|
||||||
|
cal('Holidays', { myRights: rights(false) }),
|
||||||
|
cal('Team', { isShared: true, accountId: 'other' }),
|
||||||
|
cal('Feed'),
|
||||||
|
],
|
||||||
|
taskOnlyCalendarIds: ['Tasks'],
|
||||||
|
});
|
||||||
|
useCalendarSubscriptionsStore.setState({ subscriptions: [{ id: 'sub', calendarId: 'Feed' } as never] });
|
||||||
|
const { hook, tree } = mountActions(await bookingFrom([OUT]));
|
||||||
|
await waitFor(() => expect(byTestId(tree(), 'booking-calendar-add')).toHaveLength(1));
|
||||||
|
byTestId(tree(), 'booking-calendar-add')[0].props.onPress();
|
||||||
|
const choices = byTestId(tree(), 'booking-calendar-choice');
|
||||||
|
expect(choices.map(textOf)).toEqual(['Personal', 'Work']);
|
||||||
|
choices[1].props.onPress();
|
||||||
|
await waitFor(() => expect(createEvent).toHaveBeenCalledTimes(1));
|
||||||
|
expect(createEvent.mock.calls[0][1]).toBe('Work');
|
||||||
|
await waitFor(() => expect(textOf(byTestId(tree(), 'booking-calendar-notice')[0])).toBe('Added to your calendar'));
|
||||||
|
hook.unmount();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('adds straight away when there is one calendar to choose, then shows the pill and the way to the calendar', async () => {
|
||||||
|
const booking = await bookingFrom([OUT]);
|
||||||
|
const { tree } = mountActions(booking);
|
||||||
|
await waitFor(() => expect(byTestId(tree(), 'booking-calendar-add')).toHaveLength(1));
|
||||||
|
byTestId(tree(), 'booking-calendar-add')[0].props.onPress();
|
||||||
|
expect(byTestId(tree(), 'booking-calendar-picker')).toHaveLength(0);
|
||||||
|
await waitFor(() => expect(createEvent).toHaveBeenCalledTimes(1));
|
||||||
|
expect(createEvent.mock.calls[0][1]).toBe('cal-default');
|
||||||
|
|
||||||
|
const [wanted] = bookingToEvents(booking, { senderDomain: 'lufthansa.com' });
|
||||||
|
calendarHolds([stored(wanted, 'e1')]);
|
||||||
|
useCalendarStore.setState({ events: [] });
|
||||||
|
const again = mountActions({ ...booking });
|
||||||
|
await waitFor(() => expect(textOf(again.tree())).toContain('In your calendar'));
|
||||||
|
expect(buttonText(again.tree())).toEqual(['View in calendar']);
|
||||||
|
byTestId(again.tree(), 'booking-calendar-view')[0].props.onPress();
|
||||||
|
expect(usePendingCalendarOpen.getState().target).toMatchObject({ kind: 'event', eventId: 'e1', serverId: 'e1', startMs: Date.parse('2026-12-01T09:15:00Z') });
|
||||||
|
expect(navigate).toHaveBeenCalledWith('MainTabs', { screen: 'Calendar' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows the error under the buttons when a change fails', async () => {
|
||||||
|
createEvent.mockRejectedValue(new Error('nope'));
|
||||||
|
const { tree } = mountActions(await bookingFrom([OUT]));
|
||||||
|
await waitFor(() => expect(byTestId(tree(), 'booking-calendar-add')).toHaveLength(1));
|
||||||
|
byTestId(tree(), 'booking-calendar-add')[0].props.onPress();
|
||||||
|
await waitFor(() => expect(textOf(byTestId(tree(), 'booking-calendar-error')[0])).toBe("Couldn't change your calendar. Try again."));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders nothing for a booking that is over', async () => {
|
||||||
|
const booking = await bookingFrom([OUT]);
|
||||||
|
vi.setSystemTime(new Date('2026-12-02T12:00:00Z'));
|
||||||
|
const { hook } = mountActions(booking);
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||||
|
expect(hook.current).toBeNull();
|
||||||
|
expect(findEventsByUid).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,163 @@
|
|||||||
|
// Port of webmail components/email/structured-mail/__tests__/
|
||||||
|
// use-structured-mail-pkpass.test.tsx at 66c73d0f4. App: messages carry
|
||||||
|
// raw headers only, the gate is mocked, and the download goes through the
|
||||||
|
// JMAP client (mocked) from the account the reader passes.
|
||||||
|
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
|
const gate = vi.hoisted(() => ({ on: true }));
|
||||||
|
vi.mock('../../../../lib/structured-mail/gate', () => ({
|
||||||
|
isStructuredMailEnabled: () => gate.on,
|
||||||
|
useStructuredMailEnabled: () => gate.on,
|
||||||
|
}));
|
||||||
|
const blobs = vi.hoisted(() => ({ bytes: new Uint8Array() as Uint8Array<ArrayBufferLike>, urls: [] as string[] }));
|
||||||
|
vi.mock('../../../../api/jmap-client', () => ({
|
||||||
|
BLOB_TIMEOUT_MS: 300_000,
|
||||||
|
jmapClient: {
|
||||||
|
getBlobDownloadUrl: (blobId: string, name?: string, type?: string, accountId?: string) => `${accountId}|${blobId}|${name}|${type}`,
|
||||||
|
authenticatedFetch: async (url: string) => {
|
||||||
|
blobs.urls.push(url);
|
||||||
|
const b = blobs.bytes;
|
||||||
|
return new Response(b.buffer.slice(b.byteOffset, b.byteOffset + b.byteLength) as ArrayBuffer, { status: 200 });
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
import type { Email } from '../../../../api/types';
|
||||||
|
import type { TransportBooking } from '../../../../lib/structured-mail';
|
||||||
|
import { AIR_SEMANTICS, MAIL_DATE, TRAIN_SEMANTICS, boardingPass, zipPass } from '../../../../lib/structured-mail/__tests__/pkpass-fixtures';
|
||||||
|
import { useStructuredMail } from '../use-structured-mail';
|
||||||
|
import { renderHook, waitFor } from './render-native';
|
||||||
|
|
||||||
|
const PASS = 'mx.example.org; dkim=pass header.d=swiss.example; spf=pass smtp.mailfrom=bounce@swiss.example; dmarc=pass header.from=swiss.example';
|
||||||
|
const RECEIVED = 'from mail.swiss.example (mail.swiss.example [192.0.2.1]) by mx.example.org (Stalwart SMTP) with ESMTPS id 1';
|
||||||
|
const PLAIN = '<html><body><p>Your boarding pass is attached.</p></body></html>';
|
||||||
|
const ld = (...blocks: unknown[]) => `<html><head>${blocks.map((b) => `<script type="application/ld+json">${JSON.stringify(b)}</script>`).join('')}</head><body><p>Your boarding pass is attached.</p></body></html>`;
|
||||||
|
|
||||||
|
/** The flight of AIR_SEMANTICS as the booking mail's markup. */
|
||||||
|
function flightMarkup(fields: { departureTime?: string; status?: string } = {}) {
|
||||||
|
const { departureTime = '2026-12-03T07:40:00+01:00', status = 'http://schema.org/ReservationConfirmed' } = fields;
|
||||||
|
return {
|
||||||
|
'@context': 'http://schema.org', '@type': 'FlightReservation', reservationNumber: 'PK7Q2Z',
|
||||||
|
reservationStatus: status, underName: { '@type': 'Person', name: 'Jana Beispiel' },
|
||||||
|
reservationFor: {
|
||||||
|
'@type': 'Flight', flightNumber: 'LX1072', airline: { '@type': 'Airline', name: 'Swiss', iataCode: 'LX' },
|
||||||
|
departureAirport: { '@type': 'Airport', name: 'Zurich Airport', iataCode: 'ZRH' }, departureTime,
|
||||||
|
arrivalAirport: { '@type': 'Airport', name: 'Berlin Brandenburg', iataCode: 'BER' }, arrivalTime: '2026-12-03T09:05:00+01:00',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The train of TRAIN_SEMANTICS, as the markup writes it ("ICE597", full station names). */
|
||||||
|
const TRAIN_MARKUP = {
|
||||||
|
'@context': 'http://schema.org', '@type': 'TrainReservation', reservationNumber: 'ZUG4471', underName: { '@type': 'Person', name: 'Jana Beispiel' },
|
||||||
|
reservationFor: {
|
||||||
|
'@type': 'TrainTrip', trainNumber: 'ICE597', provider: { '@type': 'Organization', name: 'DB Fernverkehr' },
|
||||||
|
departureStation: { '@type': 'TrainStation', name: 'Hamburg Hauptbahnhof' }, departureTime: '2026-12-05T08:46:00+01:00',
|
||||||
|
arrivalStation: { '@type': 'TrainStation', name: 'Köln Hauptbahnhof' }, arrivalTime: '2026-12-05T12:50:00+01:00',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
function mail(id: string, options: { ar?: string | null; html?: string | null; htmlLoaded?: boolean } = {}): Email {
|
||||||
|
const { ar = PASS, html = PLAIN, htmlLoaded = true } = options;
|
||||||
|
return {
|
||||||
|
id, threadId: 't', mailboxIds: {}, keywords: {}, size: 1, hasAttachment: true,
|
||||||
|
receivedAt: MAIL_DATE.toISOString(), subject: 'Your boarding pass',
|
||||||
|
from: [{ email: 'boarding@swiss.example' }],
|
||||||
|
headers: [{ name: 'Received', value: RECEIVED }, ...(ar ? [{ name: 'Authentication-Results', value: ar }] : [])],
|
||||||
|
htmlBody: [{ partId: '1', type: html === null ? 'text/plain' : 'text/html' }],
|
||||||
|
bodyValues: htmlLoaded ? { '1': { value: html ?? 'Your boarding pass is attached.', isTruncated: false } } : {},
|
||||||
|
attachments: [{ blobId: 'blob-pass', size: 3000, name: 'BoardingPass.pkpass', type: 'application/vnd.apple.pkpass' }],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function passBlob(semantics: Record<string, unknown> = AIR_SEMANTICS, transitType = 'PKTransitTypeAir') {
|
||||||
|
blobs.bytes = await zipPass(boardingPass(semantics, transitType));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Let effects and the airport-table import settle when nothing is expected to show. */
|
||||||
|
const settle = () => new Promise((resolve) => setTimeout(resolve, 50));
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
gate.on = true;
|
||||||
|
blobs.urls = [];
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('useStructuredMail with a Wallet pass attached', () => {
|
||||||
|
it('shows the resolved flight card of a trusted mail without booking markup, and downloads nothing for an untrusted one', async () => {
|
||||||
|
await passBlob();
|
||||||
|
const untrusted = renderHook(() => useStructuredMail(mail('u', { ar: null }), 'acc-1'));
|
||||||
|
const hook = renderHook(() => useStructuredMail(mail('t'), 'acc-1'));
|
||||||
|
await waitFor(() => expect(hook.current).toHaveLength(1));
|
||||||
|
const [card] = hook.current as TransportBooking[];
|
||||||
|
expect(card).toMatchObject({ kind: 'flight', reference: 'PK7Q2Z', source: 'pkpass' });
|
||||||
|
expect(card.legs[0].departure.at).toMatchObject({ time: '07:40:00', zone: 'Europe/Zurich' });
|
||||||
|
expect(card.legs[0].arrival?.at).toMatchObject({ time: '09:05:00', zone: 'Europe/Berlin' });
|
||||||
|
expect(blobs.urls).toEqual(['acc-1|blob-pass|BoardingPass.pkpass|application/vnd.apple.pkpass']);
|
||||||
|
expect(untrusted.current).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reads the pass of a plain-text mail too', async () => {
|
||||||
|
await passBlob();
|
||||||
|
const hook = renderHook(() => useStructuredMail(mail('p', { html: null })));
|
||||||
|
await waitFor(() => expect(hook.current).toMatchObject([{ kind: 'flight', source: 'pkpass' }]));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('downloads nothing and shows nothing with the gate off', async () => {
|
||||||
|
gate.on = false;
|
||||||
|
await passBlob();
|
||||||
|
const hook = renderHook(() => useStructuredMail(mail('g')));
|
||||||
|
await settle();
|
||||||
|
expect(hook.current).toEqual([]);
|
||||||
|
expect(blobs.urls).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('waits for the HTML before asking for passes, as it may hold booking markup', async () => {
|
||||||
|
await passBlob();
|
||||||
|
const hook = renderHook(() => useStructuredMail(mail('w', { htmlLoaded: false })));
|
||||||
|
await settle();
|
||||||
|
expect(hook.current).toEqual([]);
|
||||||
|
expect(blobs.urls).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('when the HTML has booking markup, the markup alone counts and nothing is downloaded', () => {
|
||||||
|
it('keeps a cancelled markup booking cancelled', async () => {
|
||||||
|
await passBlob();
|
||||||
|
const hook = renderHook(() => useStructuredMail(mail('c', { html: ld(flightMarkup({ status: 'http://schema.org/ReservationCancelled' })) })));
|
||||||
|
await waitFor(() => expect(hook.current).toHaveLength(1));
|
||||||
|
await settle();
|
||||||
|
expect(hook.current).toMatchObject([{ kind: 'flight', status: 'cancelled', source: 'json-ld' }]);
|
||||||
|
expect(blobs.urls).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows nothing when the markup booking was rejected', async () => {
|
||||||
|
await passBlob();
|
||||||
|
const html = ld(flightMarkup(), flightMarkup({ departureTime: '2026-12-03T08:10:00+01:00' }));
|
||||||
|
const hook = renderHook(() => useStructuredMail(mail('r', { html })));
|
||||||
|
await settle();
|
||||||
|
expect(hook.current).toEqual([]);
|
||||||
|
expect(blobs.urls).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows one train card with the markup leg when the pass writes number and station differently', async () => {
|
||||||
|
await passBlob(TRAIN_SEMANTICS, 'PKTransitTypeTrain');
|
||||||
|
const hook = renderHook(() => useStructuredMail(mail('i', { html: ld(TRAIN_MARKUP) })));
|
||||||
|
await waitFor(() => expect(hook.current).toHaveLength(1));
|
||||||
|
await settle();
|
||||||
|
const cards = hook.current as TransportBooking[];
|
||||||
|
expect(cards).toHaveLength(1);
|
||||||
|
expect(cards[0].legs).toHaveLength(1);
|
||||||
|
expect(cards[0].legs[0]).toMatchObject({ number: 'ICE597', departure: { place: { name: 'Hamburg Hauptbahnhof' } } });
|
||||||
|
expect(blobs.urls).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows one flight card when the pass has no confirmation number', async () => {
|
||||||
|
const { confirmationNumber: _ref, ...noRef } = AIR_SEMANTICS;
|
||||||
|
await passBlob(noRef);
|
||||||
|
const hook = renderHook(() => useStructuredMail(mail('f', { html: ld(flightMarkup()) })));
|
||||||
|
await waitFor(() => expect(hook.current).toHaveLength(1));
|
||||||
|
await settle();
|
||||||
|
expect(hook.current).toMatchObject([{ kind: 'flight', source: 'json-ld', reference: 'PK7Q2Z' }]);
|
||||||
|
expect(blobs.urls).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
// Port of webmail components/email/structured-mail/__tests__/
|
||||||
|
// use-structured-mail.test.tsx at ba6d5e448. App: messages carry raw
|
||||||
|
// headers only, and the gate is the app's (on until an org policy exists),
|
||||||
|
// switched here through a mock.
|
||||||
|
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
|
const gate = vi.hoisted(() => ({ on: true }));
|
||||||
|
vi.mock('../../../../lib/structured-mail/gate', () => ({
|
||||||
|
isStructuredMailEnabled: () => gate.on,
|
||||||
|
useStructuredMailEnabled: () => gate.on,
|
||||||
|
}));
|
||||||
|
|
||||||
|
import type { Email } from '../../../../api/types';
|
||||||
|
import { CARD_SOURCES } from '../sources';
|
||||||
|
import { useStructuredMail } from '../use-structured-mail';
|
||||||
|
import { renderHook, waitFor } from './render-native';
|
||||||
|
|
||||||
|
const ld = (value: unknown) => `<script type="application/ld+json">${JSON.stringify(value)}</script>`;
|
||||||
|
const ORDER = { '@context': 'http://schema.org', '@type': 'Order', orderNumber: 'SO-1', merchant: 'Shop' };
|
||||||
|
const FLIGHT = {
|
||||||
|
'@context': 'http://schema.org', '@type': 'FlightReservation', reservationNumber: 'QX7K2M', underName: 'Erika Mustermann',
|
||||||
|
reservationFor: {
|
||||||
|
'@type': 'Flight', flightNumber: '400', airline: { name: 'Lufthansa', iataCode: 'LH' },
|
||||||
|
departureAirport: { iataCode: 'FRA' }, departureTime: '2026-12-01T10:15:00+01:00',
|
||||||
|
arrivalAirport: { iataCode: 'JFK' }, arrivalTime: '2026-12-01T13:05:00-05:00',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const PASS = 'mx.example.org; dkim=pass header.d=shop.example; spf=pass smtp.mailfrom=bounce@shop.example; dmarc=pass header.from=shop.example';
|
||||||
|
const RECEIVED = 'from mail.shop.example (mail.shop.example [192.0.2.1]) by mx.example.org (Stalwart SMTP) with ESMTPS id 1';
|
||||||
|
|
||||||
|
function mail(id: string, body: string, ar: string | null = PASS): Email {
|
||||||
|
return {
|
||||||
|
id, threadId: 't', mailboxIds: {}, keywords: {}, size: 1, hasAttachment: false,
|
||||||
|
receivedAt: '2026-10-30T09:00:00Z', subject: 'Booking',
|
||||||
|
from: [{ email: 'orders@shop.example' }],
|
||||||
|
headers: [{ name: 'Received', value: RECEIVED }, ...(ar ? [{ name: 'Authentication-Results', value: ar }] : [])],
|
||||||
|
htmlBody: [{ partId: '1', type: 'text/html' }],
|
||||||
|
bodyValues: { '1': { value: `<html><head>${body}</head><body></body></html>`, isTruncated: false } },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
gate.on = true;
|
||||||
|
CARD_SOURCES.length = 0;
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('useStructuredMail', () => {
|
||||||
|
it('returns the cards of a trusted message', () => {
|
||||||
|
const hook = renderHook(() => useStructuredMail(mail('a', ld(ORDER))));
|
||||||
|
expect(hook.current).toMatchObject([{ kind: 'shipment', orderNumber: 'SO-1' }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns nothing for an untrusted message or with the gate off', () => {
|
||||||
|
expect(renderHook(() => useStructuredMail(mail('b', ld(ORDER), null))).current).toEqual([]);
|
||||||
|
gate.on = false;
|
||||||
|
expect(renderHook(() => useStructuredMail(mail('c', ld(ORDER)))).current).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows a flight only once the airport zones are resolved', async () => {
|
||||||
|
const hook = renderHook(() => useStructuredMail(mail('d', ld(FLIGHT))));
|
||||||
|
expect(hook.current).toEqual([]);
|
||||||
|
await waitFor(() => expect(hook.current).toHaveLength(1));
|
||||||
|
expect(hook.current[0]).toMatchObject({ kind: 'flight', legs: [{ departure: { at: { zone: 'Europe/Berlin' } } }] });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('merges cards from attachment sources, and never asks them about untrusted mail', async () => {
|
||||||
|
let asked = 0;
|
||||||
|
const accounts: (string | undefined)[] = [];
|
||||||
|
CARD_SOURCES.push(async ({ accountId }) => {
|
||||||
|
asked++;
|
||||||
|
accounts.push(accountId);
|
||||||
|
return [{ kind: 'shipment', key: 'shipment|order:so-2', orderNumber: 'SO-2', merchant: 'Shop', items: [], parcels: [], source: 'pkpass' }];
|
||||||
|
});
|
||||||
|
const hook = renderHook(() => useStructuredMail(mail('e', ld(ORDER)), 'shared-acc'));
|
||||||
|
await waitFor(() => expect(hook.current).toHaveLength(2));
|
||||||
|
renderHook(() => useStructuredMail(mail('f', ld(ORDER), null)));
|
||||||
|
expect(asked).toBe(1);
|
||||||
|
// The blobs are fetched from the account the message lives in.
|
||||||
|
expect(accounts).toEqual(['shared-acc']);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,198 @@
|
|||||||
|
// Port of webmail components/email/structured-mail/booking-calendar-actions.tsx
|
||||||
|
// at 3aac5a5c4, on React Native views. App: the calendar picker opens as a
|
||||||
|
// list under the button (CalendarInvitationBanner's picker) instead of a
|
||||||
|
// floating menu, and "View in calendar" opens the event on the Calendar tab.
|
||||||
|
import React from 'react';
|
||||||
|
import { ActivityIndicator, Pressable, StyleSheet, Text, View } from 'react-native';
|
||||||
|
import { useNavigation } from '@react-navigation/native';
|
||||||
|
import type { NativeStackNavigationProp } from '@react-navigation/native-stack';
|
||||||
|
import type { Email } from '../../../api/types';
|
||||||
|
import { getCalendarColor } from '../../../lib/calendar-utils';
|
||||||
|
import type { StructuredCard } from '../../../lib/structured-mail';
|
||||||
|
import type { RootStackParamList } from '../../../navigation/types';
|
||||||
|
import { useLocaleStore } from '../../../stores/locale-store';
|
||||||
|
import { useColors } from '../../../theme/colors';
|
||||||
|
import { radius, spacing, typography, type ThemePalette } from '../../../theme/tokens';
|
||||||
|
import { ArrowRight, CalendarDays, CalendarPlus, ChevronDown } from '../../icons';
|
||||||
|
import { CalendarCheck, CalendarX } from './icons';
|
||||||
|
import { useBookingCalendar } from './use-booking-calendar';
|
||||||
|
|
||||||
|
type Nav = NativeStackNavigationProp<RootStackParamList>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The calendar part of a booking card's action row: "Add to calendar" (with
|
||||||
|
* a calendar picker when there is more than one calendar to choose from),
|
||||||
|
* "Update in calendar", "Remove from calendar", the "In your calendar" pill
|
||||||
|
* and "View in calendar". Renders nothing when the booking has no calendar
|
||||||
|
* action (see useBookingCalendar).
|
||||||
|
*/
|
||||||
|
export function BookingCalendarActions({ booking, email }: { booking: StructuredCard; email: Pick<Email, 'from' | 'subject'> }) {
|
||||||
|
const c = useColors();
|
||||||
|
const styles = React.useMemo(() => makeStyles(c), [c]);
|
||||||
|
const t = useLocaleStore((s) => s.t);
|
||||||
|
const navigation = useNavigation<Nav>();
|
||||||
|
const openCalendar = React.useCallback(() => {
|
||||||
|
navigation.navigate('MainTabs', { screen: 'Calendar' } as never);
|
||||||
|
}, [navigation]);
|
||||||
|
const calendar = useBookingCalendar(booking, email, { openCalendar });
|
||||||
|
const { status, calendars, busy, error, notice } = calendar;
|
||||||
|
const [pickerOpen, setPickerOpen] = React.useState(false);
|
||||||
|
|
||||||
|
if (status === 'hidden' || status === 'loading') return null;
|
||||||
|
// A cancelled booking with nothing in the calendar has no action; only the
|
||||||
|
// confirmation of a removal just done stays.
|
||||||
|
if (status === 'cancelled' && notice !== 'removed') return null;
|
||||||
|
|
||||||
|
const onAdd = () => {
|
||||||
|
if (calendars.length <= 1) {
|
||||||
|
void calendar.add(calendars[0]?.id);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setPickerOpen((open) => !open);
|
||||||
|
};
|
||||||
|
|
||||||
|
const noticeText = notice === 'added'
|
||||||
|
? t('structured_mail_calendar.added', 'Added to your calendar')
|
||||||
|
: notice === 'updated'
|
||||||
|
? t('structured_mail_calendar.updated', 'Calendar updated')
|
||||||
|
: notice === 'removed'
|
||||||
|
? t('structured_mail_calendar.removed', 'Removed from your calendar')
|
||||||
|
: null;
|
||||||
|
const showView = status === 'in-calendar' || status === 'update';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={styles.row} testID="booking-calendar-actions">
|
||||||
|
{status === 'add' ? (
|
||||||
|
<Pressable
|
||||||
|
onPress={onAdd}
|
||||||
|
disabled={busy}
|
||||||
|
style={({ pressed }) => [styles.button, pressed && styles.pressed, busy && styles.disabled]}
|
||||||
|
accessibilityRole="button"
|
||||||
|
accessibilityState={{ disabled: busy, expanded: calendars.length > 1 ? pickerOpen : undefined }}
|
||||||
|
testID="booking-calendar-add"
|
||||||
|
>
|
||||||
|
<CalendarPlus size={14} color={c.textSecondary} />
|
||||||
|
<Text style={styles.buttonText}>{t('structured_mail_calendar.add_to_calendar', 'Add to calendar')}</Text>
|
||||||
|
{calendars.length > 1 ? <ChevronDown size={12} color={c.textSecondary} /> : null}
|
||||||
|
</Pressable>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{status === 'update' ? (
|
||||||
|
<Pressable
|
||||||
|
onPress={() => { void calendar.update(); }}
|
||||||
|
disabled={busy}
|
||||||
|
style={({ pressed }) => [styles.button, pressed && styles.pressed, busy && styles.disabled]}
|
||||||
|
accessibilityRole="button"
|
||||||
|
accessibilityState={{ disabled: busy }}
|
||||||
|
testID="booking-calendar-update"
|
||||||
|
>
|
||||||
|
<CalendarCheck size={14} color={c.textSecondary} />
|
||||||
|
<Text style={styles.buttonText}>{t('structured_mail_calendar.update_in_calendar', 'Update in calendar')}</Text>
|
||||||
|
</Pressable>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{status === 'remove' ? (
|
||||||
|
<Pressable
|
||||||
|
onPress={() => { void calendar.remove(); }}
|
||||||
|
disabled={busy}
|
||||||
|
style={({ pressed }) => [styles.button, pressed && styles.pressedDestructive, busy && styles.disabled]}
|
||||||
|
accessibilityRole="button"
|
||||||
|
accessibilityState={{ disabled: busy }}
|
||||||
|
testID="booking-calendar-remove"
|
||||||
|
>
|
||||||
|
<CalendarX size={14} color={c.textSecondary} />
|
||||||
|
<Text style={styles.buttonText}>{t('structured_mail_calendar.remove_from_calendar', 'Remove from calendar')}</Text>
|
||||||
|
</Pressable>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{status === 'in-calendar' && !noticeText ? (
|
||||||
|
<View style={styles.pill}><Text style={styles.pillText}>{t('structured_mail_calendar.in_calendar', 'In your calendar')}</Text></View>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{noticeText ? (
|
||||||
|
<View style={styles.pill} accessibilityLiveRegion="polite" testID="booking-calendar-notice">
|
||||||
|
<Text style={styles.pillText}>{noticeText}</Text>
|
||||||
|
</View>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{showView ? (
|
||||||
|
<Pressable
|
||||||
|
onPress={calendar.viewInCalendar}
|
||||||
|
style={({ pressed }) => [styles.link, pressed && styles.pressed]}
|
||||||
|
accessibilityRole="button"
|
||||||
|
testID="booking-calendar-view"
|
||||||
|
>
|
||||||
|
<CalendarDays size={14} color={c.textSecondary} />
|
||||||
|
<Text style={styles.buttonText}>{t('structured_mail_calendar.view_in_calendar', 'View in calendar')}</Text>
|
||||||
|
<ArrowRight size={12} color={c.textSecondary} />
|
||||||
|
</Pressable>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{busy ? <ActivityIndicator size="small" color={c.textMuted} /> : null}
|
||||||
|
|
||||||
|
{status === 'add' && pickerOpen && calendars.length > 1 ? (
|
||||||
|
<View style={styles.picker} accessibilityLabel={t('structured_mail_calendar.select_calendar', 'Choose a calendar')} testID="booking-calendar-picker">
|
||||||
|
<Text style={styles.pickerTitle}>{t('structured_mail_calendar.select_calendar', 'Choose a calendar')}</Text>
|
||||||
|
{calendars.map((cal) => (
|
||||||
|
<Pressable
|
||||||
|
key={cal.id}
|
||||||
|
onPress={() => {
|
||||||
|
setPickerOpen(false);
|
||||||
|
void calendar.add(cal.id);
|
||||||
|
}}
|
||||||
|
style={({ pressed }) => [styles.pickerRow, pressed && styles.pressed]}
|
||||||
|
accessibilityRole="menuitem"
|
||||||
|
accessibilityLabel={cal.name}
|
||||||
|
testID="booking-calendar-choice"
|
||||||
|
>
|
||||||
|
<View style={[styles.swatch, { backgroundColor: getCalendarColor(cal) }]} />
|
||||||
|
<Text style={styles.pickerText} numberOfLines={1}>{cal.name}</Text>
|
||||||
|
</Pressable>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{error ? (
|
||||||
|
<Text style={styles.error} accessibilityRole="alert" accessibilityLiveRegion="assertive" testID="booking-calendar-error">{error}</Text>
|
||||||
|
) : null}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeStyles(c: ThemePalette) {
|
||||||
|
return StyleSheet.create({
|
||||||
|
row: { flexDirection: 'row', flexWrap: 'wrap', alignItems: 'center', gap: 6 },
|
||||||
|
button: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 6,
|
||||||
|
minHeight: 36,
|
||||||
|
paddingHorizontal: spacing.md,
|
||||||
|
paddingVertical: 6,
|
||||||
|
borderRadius: radius.md,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: c.border,
|
||||||
|
backgroundColor: c.background,
|
||||||
|
},
|
||||||
|
link: { flexDirection: 'row', alignItems: 'center', gap: 6, minHeight: 36, paddingHorizontal: spacing.md, borderRadius: radius.md },
|
||||||
|
pressed: { backgroundColor: c.surfaceHover },
|
||||||
|
pressedDestructive: { backgroundColor: c.errorBg, borderColor: c.errorBorder },
|
||||||
|
disabled: { opacity: 0.5 },
|
||||||
|
buttonText: { ...typography.body, color: c.textSecondary },
|
||||||
|
pill: { borderRadius: radius.full, paddingHorizontal: 8, paddingVertical: 2, backgroundColor: c.successBg },
|
||||||
|
pillText: { ...typography.small, color: c.success },
|
||||||
|
picker: {
|
||||||
|
flexBasis: '100%',
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: c.border,
|
||||||
|
borderRadius: radius.sm,
|
||||||
|
backgroundColor: c.background,
|
||||||
|
overflow: 'hidden',
|
||||||
|
},
|
||||||
|
pickerTitle: { ...typography.captionMedium, color: c.textMuted, paddingHorizontal: spacing.md, paddingVertical: 6 },
|
||||||
|
pickerRow: { flexDirection: 'row', alignItems: 'center', gap: spacing.sm, minHeight: 40, paddingHorizontal: spacing.md },
|
||||||
|
pickerText: { ...typography.body, color: c.text, flex: 1 },
|
||||||
|
swatch: { width: 12, height: 12, borderRadius: 6 },
|
||||||
|
error: { ...typography.caption, color: c.error, flexBasis: '100%' },
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,360 @@
|
|||||||
|
// Port of webmail components/email/structured-mail/booking-cards.tsx at
|
||||||
|
// b0782af2d (same cards and rows, React Native views). App difference: the
|
||||||
|
// route title is one line of text with isolated stops and an arrow that
|
||||||
|
// points the reading direction, where the webmail draws arrow icons.
|
||||||
|
import React from 'react';
|
||||||
|
import { I18nManager, Text, View } from 'react-native';
|
||||||
|
import { ArrowRight, Clock, MapPin, Phone, Users } from '../../icons';
|
||||||
|
import {
|
||||||
|
formatAddress,
|
||||||
|
formatMomentDate,
|
||||||
|
formatMomentTime,
|
||||||
|
formatPlace,
|
||||||
|
isOtherZone,
|
||||||
|
isWebLink,
|
||||||
|
legPlaceLabel,
|
||||||
|
legService,
|
||||||
|
nightsBetween,
|
||||||
|
routeRuns,
|
||||||
|
routeTitle,
|
||||||
|
sameLocalDate,
|
||||||
|
} from '../../../lib/structured-mail/format';
|
||||||
|
import type {
|
||||||
|
Booking,
|
||||||
|
EventBooking,
|
||||||
|
Link,
|
||||||
|
LodgingBooking,
|
||||||
|
Moment,
|
||||||
|
RentalCarBooking,
|
||||||
|
RestaurantBooking,
|
||||||
|
TransportBooking,
|
||||||
|
TransportLeg,
|
||||||
|
} from '../../../lib/structured-mail/types';
|
||||||
|
import { useLocaleStore } from '../../../stores/locale-store';
|
||||||
|
import {
|
||||||
|
BookingStatusPill,
|
||||||
|
CardLink,
|
||||||
|
CardShell,
|
||||||
|
LocalTimeHint,
|
||||||
|
MetaRow,
|
||||||
|
Pill,
|
||||||
|
When,
|
||||||
|
eyebrowText,
|
||||||
|
isolate,
|
||||||
|
ltr,
|
||||||
|
useCardFormat,
|
||||||
|
useCardStyles,
|
||||||
|
} from './card-parts';
|
||||||
|
import { CopyValueChip } from './copy-value-chip';
|
||||||
|
import { Bed, Bus, Car, Plane, Restaurant, Ticket, Train } from './icons';
|
||||||
|
|
||||||
|
export interface BookingCardProps<B extends Booking = Booking> {
|
||||||
|
booking: B;
|
||||||
|
/** Slot in the action row, for "Add to calendar". */
|
||||||
|
calendarActions?: (booking: Booking) => React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
const TRANSPORT_ICON = { flight: Plane, train: Train, bus: Bus } as const;
|
||||||
|
|
||||||
|
interface CardLinkSpec {
|
||||||
|
link?: Link;
|
||||||
|
label: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The action row: reference chip, the links this card shows and the
|
||||||
|
* calendar slot. Undefined when there is none of them, so no empty row.
|
||||||
|
*/
|
||||||
|
function bookingActions(booking: Booking, calendarActions: BookingCardProps['calendarActions'], links: CardLinkSpec[]): React.ReactNode {
|
||||||
|
const shown = links.filter((l) => isWebLink(l.link));
|
||||||
|
if (!booking.reference && !calendarActions && shown.length === 0) return undefined;
|
||||||
|
return (
|
||||||
|
<BookingActions booking={booking} calendarActions={calendarActions}>
|
||||||
|
{shown.map((l) => <CardLink key={l.label} link={l.link} label={l.label} />)}
|
||||||
|
</BookingActions>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function BookingActions({ booking, calendarActions, children }: Pick<BookingCardProps, 'booking' | 'calendarActions'> & { children: React.ReactNode }) {
|
||||||
|
const t = useLocaleStore((s) => s.t);
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{booking.reference ? (
|
||||||
|
<CopyValueChip
|
||||||
|
value={booking.reference}
|
||||||
|
label={t('email_viewer.structured_mail.reference', 'Booking reference')}
|
||||||
|
copyLabel={t('email_viewer.structured_mail.copy_reference', 'Copy booking reference {value}', { value: booking.reference })}
|
||||||
|
copiedMessage={t('email_viewer.structured_mail.copied_reference', 'Booking reference copied')}
|
||||||
|
inActions
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
{children}
|
||||||
|
{calendarActions?.(booking)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function PassengersRow({ names }: { names: string[] }) {
|
||||||
|
const t = useLocaleStore((s) => s.t);
|
||||||
|
if (names.length === 0) return null;
|
||||||
|
return (
|
||||||
|
<View accessibilityLabel={`${t('email_viewer.structured_mail.passengers', 'Passengers')}: ${names.join(', ')}`}>
|
||||||
|
<MetaRow icon={Users}>{names.map(isolate).join(', ')}</MetaRow>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Leg({ leg, kind, bookingCancelled }: { leg: TransportLeg; kind: TransportBooking['kind']; bookingCancelled: boolean }) {
|
||||||
|
const t = useLocaleStore((s) => s.t);
|
||||||
|
const { c, styles } = useCardStyles();
|
||||||
|
const cancelled = leg.status === 'cancelled';
|
||||||
|
const service = legService(leg);
|
||||||
|
const Icon = TRANSPORT_ICON[kind];
|
||||||
|
const arrival = leg.arrival;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={styles.leg} testID={cancelled ? 'structured-mail-leg-cancelled' : 'structured-mail-leg'}>
|
||||||
|
{(service || (cancelled && !bookingCancelled)) ? (
|
||||||
|
<View style={styles.legHead}>
|
||||||
|
{service ? (
|
||||||
|
<View style={styles.legService}>
|
||||||
|
<Icon size={14} color={cancelled ? c.textMuted : c.text} />
|
||||||
|
<Text style={[styles.legServiceText, cancelled && styles.titleCancelled]}>{service}</Text>
|
||||||
|
</View>
|
||||||
|
) : null}
|
||||||
|
{cancelled && !bookingCancelled ? (
|
||||||
|
<Pill label={t('email_viewer.structured_mail.status_cancelled', 'Cancelled')} tone="error" />
|
||||||
|
) : null}
|
||||||
|
</View>
|
||||||
|
) : null}
|
||||||
|
<MetaRow icon={Clock}>
|
||||||
|
<When at={leg.departure.at} struck={cancelled} />
|
||||||
|
<Text style={cancelled ? styles.struck : undefined}> · {isolate(legPlaceLabel(leg.departure.place, kind))}</Text>
|
||||||
|
</MetaRow>
|
||||||
|
{arrival ? (
|
||||||
|
<MetaRow icon={ArrowRight}>
|
||||||
|
{arrival.at ? <When at={arrival.at} timeOnly={sameLocalDate(arrival.at, leg.departure.at)} struck={cancelled} /> : null}
|
||||||
|
<Text style={cancelled ? styles.struck : undefined}>{arrival.at ? ' · ' : ''}{isolate(legPlaceLabel(arrival.place, kind))}</Text>
|
||||||
|
</MetaRow>
|
||||||
|
) : null}
|
||||||
|
{leg.seats.length > 0 ? (
|
||||||
|
<MetaRow icon={Ticket} testID="structured-mail-seats">
|
||||||
|
{t('email_viewer.structured_mail.seats', 'Seats')}:{' '}
|
||||||
|
{leg.seats.map((seat) => `${seat.passenger ? `${isolate(seat.passenger)} ` : ''}${ltr(seat.seat)}`).join(', ')}
|
||||||
|
</MetaRow>
|
||||||
|
) : null}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** "FRA → JFK → FRA", every stop isolated, the arrow pointing the reading direction. */
|
||||||
|
export function routeText(runs: string[][], rtl: boolean): string {
|
||||||
|
const arrow = rtl ? ' ← ' : ' → ';
|
||||||
|
return runs.map((run) => run.map(isolate).join(arrow)).join(', ');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TransportCard({ booking, calendarActions }: BookingCardProps<TransportBooking>) {
|
||||||
|
const t = useLocaleStore((s) => s.t);
|
||||||
|
const { styles } = useCardStyles();
|
||||||
|
const kindLabel = booking.kind === 'flight'
|
||||||
|
? t('email_viewer.structured_mail.kind_flight', 'Flight')
|
||||||
|
: booking.kind === 'train'
|
||||||
|
? t('email_viewer.structured_mail.kind_train', 'Train')
|
||||||
|
: t('email_viewer.structured_mail.kind_bus', 'Bus');
|
||||||
|
const name = booking.kind === 'flight'
|
||||||
|
? t('email_viewer.structured_mail.region_flight', 'Flight booking')
|
||||||
|
: booking.kind === 'train'
|
||||||
|
? t('email_viewer.structured_mail.region_train', 'Train booking')
|
||||||
|
: t('email_viewer.structured_mail.region_bus', 'Bus booking');
|
||||||
|
const runs = routeRuns(booking);
|
||||||
|
const title = routeText(runs, I18nManager.isRTL);
|
||||||
|
const cancelled = booking.status === 'cancelled';
|
||||||
|
return (
|
||||||
|
<CardShell
|
||||||
|
kind={booking.kind}
|
||||||
|
name={name}
|
||||||
|
icon={TRANSPORT_ICON[booking.kind]}
|
||||||
|
cancelled={cancelled}
|
||||||
|
eyebrow={eyebrowText(kindLabel, booking.provider, routeTitle(booking))}
|
||||||
|
title={title}
|
||||||
|
titleLabel={runs.map((run) => run.reduce((from, to) => t('email_viewer.structured_mail.route', '{from} to {to}', { from, to }))).join(', ')}
|
||||||
|
pills={booking.status && booking.status !== 'confirmed' ? <BookingStatusPill status={booking.status} /> : undefined}
|
||||||
|
actions={bookingActions(booking, calendarActions, [
|
||||||
|
{ link: booking.links.checkin, label: t('email_viewer.structured_mail.link_check_in', 'Check in') },
|
||||||
|
{ link: booking.links.manage, label: t('email_viewer.structured_mail.link_manage', 'Manage booking') },
|
||||||
|
{ link: booking.links.ticket, label: t('email_viewer.structured_mail.link_ticket', 'Ticket') },
|
||||||
|
])}
|
||||||
|
>
|
||||||
|
<View style={styles.legs}>
|
||||||
|
{booking.legs.map((leg, i) => (
|
||||||
|
<Leg key={`${leg.departure.at.date}-${leg.number ?? ''}-${i}`} leg={leg} kind={booking.kind} bookingCancelled={cancelled} />
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
<PassengersRow names={booking.passengers} />
|
||||||
|
</CardShell>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** "Fri, Dec 4, from 15:00" / "Sun, Dec 6, until 11:00" / the date alone. */
|
||||||
|
function StayEnd({ label, at, edge }: { label: string; at: Moment; edge: 'from' | 'until' }) {
|
||||||
|
const t = useLocaleStore((s) => s.t);
|
||||||
|
const { styles } = useCardStyles();
|
||||||
|
const { opts, readerZone } = useCardFormat();
|
||||||
|
const time = formatMomentTime(at, opts);
|
||||||
|
return (
|
||||||
|
<MetaRow icon={Clock}>
|
||||||
|
{label}{' '}
|
||||||
|
<Text style={styles.strong}>{formatMomentDate(at, opts)}</Text>
|
||||||
|
{time ? (
|
||||||
|
<>
|
||||||
|
{', '}
|
||||||
|
<Text style={styles.strong}>
|
||||||
|
{edge === 'from'
|
||||||
|
? t('email_viewer.structured_mail.from_time', 'from {time}', { time })
|
||||||
|
: t('email_viewer.structured_mail.until_time', 'until {time}', { time })}
|
||||||
|
</Text>
|
||||||
|
{isOtherZone(at, readerZone) ? <LocalTimeHint /> : null}
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
</MetaRow>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function LodgingCard({ booking, calendarActions }: BookingCardProps<LodgingBooking>) {
|
||||||
|
const t = useLocaleStore((s) => s.t);
|
||||||
|
const { opts } = useCardFormat();
|
||||||
|
const nights = nightsBetween(booking.checkin, booking.checkout);
|
||||||
|
const address = formatAddress(booking.address, opts.locale);
|
||||||
|
return (
|
||||||
|
<CardShell
|
||||||
|
kind="lodging"
|
||||||
|
name={t('email_viewer.structured_mail.region_lodging', 'Hotel booking')}
|
||||||
|
icon={Bed}
|
||||||
|
cancelled={booking.status === 'cancelled'}
|
||||||
|
eyebrow={eyebrowText(t('email_viewer.structured_mail.kind_lodging', 'Hotel'), booking.provider, booking.name)}
|
||||||
|
title={booking.name}
|
||||||
|
pills={booking.status && booking.status !== 'confirmed' ? <BookingStatusPill status={booking.status} /> : undefined}
|
||||||
|
actions={bookingActions(booking, calendarActions, [
|
||||||
|
{ link: booking.links.manage, label: t('email_viewer.structured_mail.link_manage', 'Manage booking') },
|
||||||
|
{ link: booking.links.checkin, label: t('email_viewer.structured_mail.link_check_in', 'Check in') },
|
||||||
|
])}
|
||||||
|
>
|
||||||
|
{address ? <MetaRow icon={MapPin}>{address}</MetaRow> : null}
|
||||||
|
<StayEnd label={t('email_viewer.structured_mail.check_in_label', 'Check-in')} at={booking.checkin} edge="from" />
|
||||||
|
<StayEnd label={t('email_viewer.structured_mail.check_out_label', 'Check-out')} at={booking.checkout} edge="until" />
|
||||||
|
{nights !== undefined ? (
|
||||||
|
<MetaRow icon={Bed}>{t('email_viewer.structured_mail.nights', '{count, plural, one {# night} other {# nights}}', { count: nights })}</MetaRow>
|
||||||
|
) : null}
|
||||||
|
{booking.phone ? <MetaRow icon={Phone}>{ltr(booking.phone)}</MetaRow> : null}
|
||||||
|
<PassengersRow names={booking.passengers} />
|
||||||
|
</CardShell>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function RentalCarCard({ booking, calendarActions }: BookingCardProps<RentalCarBooking>) {
|
||||||
|
const t = useLocaleStore((s) => s.t);
|
||||||
|
const title = booking.company?.trim() || t('email_viewer.structured_mail.kind_rentalcar', 'Rental car');
|
||||||
|
const pickupPlace = formatPlace(booking.pickup.place);
|
||||||
|
const dropoffPlace = formatPlace(booking.dropoff.place);
|
||||||
|
return (
|
||||||
|
<CardShell
|
||||||
|
kind="rentalcar"
|
||||||
|
name={t('email_viewer.structured_mail.region_rentalcar', 'Rental car booking')}
|
||||||
|
icon={Car}
|
||||||
|
cancelled={booking.status === 'cancelled'}
|
||||||
|
eyebrow={eyebrowText(t('email_viewer.structured_mail.kind_rentalcar', 'Rental car'), booking.provider, title)}
|
||||||
|
title={title}
|
||||||
|
pills={booking.status && booking.status !== 'confirmed' ? <BookingStatusPill status={booking.status} /> : undefined}
|
||||||
|
actions={bookingActions(booking, calendarActions, [
|
||||||
|
{ link: booking.links.manage, label: t('email_viewer.structured_mail.link_manage', 'Manage booking') },
|
||||||
|
])}
|
||||||
|
>
|
||||||
|
<MetaRow icon={Clock}>
|
||||||
|
{t('email_viewer.structured_mail.pickup', 'Pick-up')}{' '}
|
||||||
|
<When at={booking.pickup.at} />
|
||||||
|
{pickupPlace ? ` · ${pickupPlace}` : null}
|
||||||
|
</MetaRow>
|
||||||
|
<MetaRow icon={ArrowRight}>
|
||||||
|
{t('email_viewer.structured_mail.dropoff', 'Return')}{' '}
|
||||||
|
<When at={booking.dropoff.at} />
|
||||||
|
{dropoffPlace ? ` · ${dropoffPlace}` : null}
|
||||||
|
</MetaRow>
|
||||||
|
{booking.car ? <MetaRow icon={Car}>{booking.car}</MetaRow> : null}
|
||||||
|
<PassengersRow names={booking.passengers} />
|
||||||
|
</CardShell>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function EventCard({ booking, calendarActions }: BookingCardProps<EventBooking>) {
|
||||||
|
const t = useLocaleStore((s) => s.t);
|
||||||
|
const { styles } = useCardStyles();
|
||||||
|
const { opts } = useCardFormat();
|
||||||
|
const venueName = booking.venue?.name?.trim();
|
||||||
|
const venueAddress = formatAddress(booking.venue?.address, opts.locale);
|
||||||
|
// "Stadtpark, Parkweg 2, Hamburg"; the name alone, or the address alone, when that's all there is.
|
||||||
|
const venue = venueName && venueAddress
|
||||||
|
? (venueAddress.includes(venueName) ? venueAddress : `${venueName}, ${venueAddress}`)
|
||||||
|
: venueName || venueAddress || formatPlace(booking.venue);
|
||||||
|
const end = booking.end;
|
||||||
|
return (
|
||||||
|
<CardShell
|
||||||
|
kind="event"
|
||||||
|
name={t('email_viewer.structured_mail.region_event', 'Event booking')}
|
||||||
|
icon={Ticket}
|
||||||
|
cancelled={booking.status === 'cancelled'}
|
||||||
|
eyebrow={eyebrowText(t('email_viewer.structured_mail.kind_event', 'Event'), booking.provider, booking.name)}
|
||||||
|
title={booking.name}
|
||||||
|
pills={booking.status && booking.status !== 'confirmed' ? <BookingStatusPill status={booking.status} /> : undefined}
|
||||||
|
actions={bookingActions(booking, calendarActions, [
|
||||||
|
{ link: booking.links.ticket, label: t('email_viewer.structured_mail.link_ticket', 'Ticket') },
|
||||||
|
{ link: booking.links.manage, label: t('email_viewer.structured_mail.link_manage', 'Manage booking') },
|
||||||
|
])}
|
||||||
|
>
|
||||||
|
<MetaRow icon={Clock}>
|
||||||
|
<When at={booking.start} />
|
||||||
|
{end && (end.time || !sameLocalDate(end, booking.start)) ? (
|
||||||
|
<>
|
||||||
|
{' – '}
|
||||||
|
<When at={end} timeOnly={sameLocalDate(end, booking.start) && !!booking.start.time} />
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
</MetaRow>
|
||||||
|
{venue ? <MetaRow icon={MapPin}>{venue}</MetaRow> : null}
|
||||||
|
{booking.tickets.length > 0 ? (
|
||||||
|
<MetaRow icon={Ticket} testID="structured-mail-tickets">
|
||||||
|
{t('email_viewer.structured_mail.tickets', 'Tickets')}:{' '}
|
||||||
|
<Text style={styles.mono}>{ltr(booking.tickets.join(', '))}</Text>
|
||||||
|
</MetaRow>
|
||||||
|
) : null}
|
||||||
|
<PassengersRow names={booking.passengers} />
|
||||||
|
</CardShell>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function RestaurantCard({ booking, calendarActions }: BookingCardProps<RestaurantBooking>) {
|
||||||
|
const t = useLocaleStore((s) => s.t);
|
||||||
|
const { opts } = useCardFormat();
|
||||||
|
const address = formatAddress(booking.address, opts.locale);
|
||||||
|
return (
|
||||||
|
<CardShell
|
||||||
|
kind="restaurant"
|
||||||
|
name={t('email_viewer.structured_mail.region_restaurant', 'Restaurant reservation')}
|
||||||
|
icon={Restaurant}
|
||||||
|
cancelled={booking.status === 'cancelled'}
|
||||||
|
eyebrow={eyebrowText(t('email_viewer.structured_mail.kind_restaurant', 'Restaurant'), booking.provider, booking.name)}
|
||||||
|
title={booking.name}
|
||||||
|
pills={booking.status && booking.status !== 'confirmed' ? <BookingStatusPill status={booking.status} /> : undefined}
|
||||||
|
actions={bookingActions(booking, calendarActions, [
|
||||||
|
{ link: booking.links.manage, label: t('email_viewer.structured_mail.link_manage', 'Manage booking') },
|
||||||
|
])}
|
||||||
|
>
|
||||||
|
<MetaRow icon={Clock}><When at={booking.start} /></MetaRow>
|
||||||
|
{address ? <MetaRow icon={MapPin}>{address}</MetaRow> : null}
|
||||||
|
{booking.partySize !== undefined && booking.partySize > 0 ? (
|
||||||
|
<MetaRow icon={Users}>
|
||||||
|
{t('email_viewer.structured_mail.party_size', '{count, plural, one {Table for #} other {Table for #}}', { count: booking.partySize })}
|
||||||
|
</MetaRow>
|
||||||
|
) : null}
|
||||||
|
</CardShell>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,260 @@
|
|||||||
|
// Port of webmail components/email/structured-mail/card-parts.tsx at
|
||||||
|
// b0782af2d (same parts, React Native views). App differences: links open
|
||||||
|
// through the URL guard (lib/open-url), the reader's zone is the calendar
|
||||||
|
// zone setting, and <bdi> isolation is done with Unicode isolates.
|
||||||
|
import React from 'react';
|
||||||
|
import { Pressable, StyleSheet, Text, View } from 'react-native';
|
||||||
|
import { ExternalLink, type AppIcon } from '../../icons';
|
||||||
|
import { resolveTimeZone } from '../../../lib/calendar-timezone';
|
||||||
|
import { openExternalUrl } from '../../../lib/open-url';
|
||||||
|
import { formatMoment, formatMomentTime, isOtherZone, isWebLink, type FormatOptions } from '../../../lib/structured-mail/format';
|
||||||
|
import type { Link, Moment, ReservationStatus } from '../../../lib/structured-mail/types';
|
||||||
|
import { useLocaleStore } from '../../../stores/locale-store';
|
||||||
|
import { useSettingsStore } from '../../../stores/settings-store';
|
||||||
|
import { useColors } from '../../../theme/colors';
|
||||||
|
import { radius, spacing, typography, type ThemePalette } from '../../../theme/tokens';
|
||||||
|
|
||||||
|
/** First strong isolate / left-to-right isolate / pop: what <bdi> and <bdi dir="ltr"> do. */
|
||||||
|
const FSI = String.fromCharCode(0x2068);
|
||||||
|
const LRI = String.fromCharCode(0x2066);
|
||||||
|
const PDI = String.fromCharCode(0x2069);
|
||||||
|
|
||||||
|
/** Text that keeps its own direction inside a sentence in the other one (a <bdi>). */
|
||||||
|
export function isolate(text: string): string {
|
||||||
|
return `${FSI}${text}${PDI}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Digits and codes that read left to right in a right-to-left UI (<bdi dir="ltr">). */
|
||||||
|
export function ltr(text: string): string {
|
||||||
|
return `${LRI}${text}${PDI}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function makeCardStyles(c: ThemePalette) {
|
||||||
|
return StyleSheet.create({
|
||||||
|
list: { gap: spacing.md, marginHorizontal: spacing.lg, marginBottom: spacing.md },
|
||||||
|
card: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'flex-start',
|
||||||
|
gap: spacing.md,
|
||||||
|
padding: spacing.md,
|
||||||
|
borderRadius: radius.md,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: c.border,
|
||||||
|
backgroundColor: c.surface,
|
||||||
|
},
|
||||||
|
badge: {
|
||||||
|
width: 40, height: 40, borderRadius: 20,
|
||||||
|
alignItems: 'center', justifyContent: 'center',
|
||||||
|
backgroundColor: c.primaryBg,
|
||||||
|
},
|
||||||
|
badgeCancelled: { backgroundColor: c.errorBg },
|
||||||
|
body: { flex: 1, minWidth: 0, gap: spacing.sm },
|
||||||
|
eyebrow: { ...typography.small, color: c.textMuted, fontWeight: '600' },
|
||||||
|
title: { ...typography.bodySemibold, color: c.text },
|
||||||
|
titleCancelled: { color: c.textMuted, textDecorationLine: 'line-through' },
|
||||||
|
pills: { flexDirection: 'row', flexWrap: 'wrap', alignItems: 'center', gap: 6 },
|
||||||
|
details: { gap: 4 },
|
||||||
|
actions: { flexDirection: 'row', flexWrap: 'wrap', alignItems: 'center', gap: 6, paddingTop: 2 },
|
||||||
|
metaRow: { flexDirection: 'row', alignItems: 'flex-start', gap: 6 },
|
||||||
|
metaIcon: { marginTop: 3 },
|
||||||
|
metaText: { flex: 1, minWidth: 0, ...typography.body, color: c.textSecondary },
|
||||||
|
strong: { color: c.text },
|
||||||
|
struck: { textDecorationLine: 'line-through' },
|
||||||
|
hint: { ...typography.caption, color: c.textMuted },
|
||||||
|
pill: { borderRadius: radius.full, paddingHorizontal: 8, paddingVertical: 2 },
|
||||||
|
pillText: { ...typography.small },
|
||||||
|
action: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
flexWrap: 'wrap',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 6,
|
||||||
|
minHeight: 36,
|
||||||
|
maxWidth: '100%',
|
||||||
|
paddingHorizontal: spacing.md,
|
||||||
|
paddingVertical: 6,
|
||||||
|
borderRadius: radius.md,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: c.border,
|
||||||
|
backgroundColor: c.background,
|
||||||
|
},
|
||||||
|
actionPressed: { backgroundColor: c.surfaceHover },
|
||||||
|
actionText: { ...typography.body, color: c.textSecondary },
|
||||||
|
actionHost: { ...typography.body, color: c.textMuted, flexShrink: 1 },
|
||||||
|
legs: { gap: spacing.sm },
|
||||||
|
leg: { gap: 2 },
|
||||||
|
legHead: { flexDirection: 'row', flexWrap: 'wrap', alignItems: 'center', columnGap: spacing.sm, rowGap: 4 },
|
||||||
|
legService: { flexDirection: 'row', alignItems: 'center', gap: 6, flexShrink: 1 },
|
||||||
|
legServiceText: { ...typography.bodyMedium, color: c.text, flexShrink: 1 },
|
||||||
|
mono: { fontFamily: 'monospace' },
|
||||||
|
images: { flexDirection: 'row', flexWrap: 'wrap', gap: 6, paddingTop: 2 },
|
||||||
|
image: { width: 40, height: 40, borderRadius: radius.md, borderWidth: 1, borderColor: c.border, backgroundColor: c.background },
|
||||||
|
summary: { ...typography.body, color: c.textSecondary, paddingStart: 20 },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export type CardStyles = ReturnType<typeof makeCardStyles>;
|
||||||
|
|
||||||
|
/** The palette and the card styles for it. */
|
||||||
|
export function useCardStyles(): { c: ThemePalette; styles: CardStyles } {
|
||||||
|
const c = useColors();
|
||||||
|
const styles = React.useMemo(() => makeCardStyles(c), [c]);
|
||||||
|
return { c, styles };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Locale, clock and the reader's zone, for formatting a card's times. */
|
||||||
|
export function useCardFormat(): { opts: FormatOptions; readerZone: string } {
|
||||||
|
const locale = useLocaleStore((s) => s.locale);
|
||||||
|
const timeFormat = useSettingsStore((s) => s.timeFormat);
|
||||||
|
const timeZone = useSettingsStore((s) => s.calendarTimeZone);
|
||||||
|
return {
|
||||||
|
opts: { locale, hour12: timeFormat === '12h' ? true : timeFormat === '24h' ? false : undefined },
|
||||||
|
readerZone: resolveTimeZone(timeZone),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A time as the mail gave it, at the place, as nested text for a MetaRow.
|
||||||
|
* `timeOnly` leaves out the date (an arrival on the departure day). Marked
|
||||||
|
* "local time" when the place's clock differs from the reader's at that
|
||||||
|
* instant.
|
||||||
|
*/
|
||||||
|
export function When({ at, timeOnly = false, struck = false }: { at: Moment; timeOnly?: boolean; struck?: boolean }) {
|
||||||
|
const { styles } = useCardStyles();
|
||||||
|
const { opts, readerZone } = useCardFormat();
|
||||||
|
const text = timeOnly && at.time ? formatMomentTime(at, opts) : formatMoment(at, opts);
|
||||||
|
const other = isOtherZone(at, readerZone);
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Text style={[styles.strong, struck && styles.struck]}>{text}</Text>
|
||||||
|
{other && <LocalTimeHint />}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The "local time" mark after a time written at the place. */
|
||||||
|
export function LocalTimeHint() {
|
||||||
|
const t = useLocaleStore((s) => s.t);
|
||||||
|
const { styles } = useCardStyles();
|
||||||
|
return (
|
||||||
|
<Text
|
||||||
|
style={styles.hint}
|
||||||
|
testID="local-time-hint"
|
||||||
|
accessibilityHint={t('email_viewer.structured_mail.local_time_title', 'Local time at the place, not your time zone')}
|
||||||
|
>
|
||||||
|
{' '}{t('email_viewer.structured_mail.local_time', 'local time')}
|
||||||
|
</Text>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One meta row: a small icon and text that wraps instead of overflowing. */
|
||||||
|
export function MetaRow({ icon: Icon, children, testID }: { icon: AppIcon; children: React.ReactNode; testID?: string }) {
|
||||||
|
const { c, styles } = useCardStyles();
|
||||||
|
return (
|
||||||
|
<View style={styles.metaRow} testID={testID}>
|
||||||
|
<Icon size={14} color={c.textSecondary} style={styles.metaIcon} />
|
||||||
|
<Text style={styles.metaText}>{children}</Text>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A small rounded label in one of the status tones. */
|
||||||
|
export function Pill({ label, tone }: { label: string; tone: 'error' | 'warning' | 'success' | 'info' | 'muted' }) {
|
||||||
|
const { c, styles } = useCardStyles();
|
||||||
|
const colors = {
|
||||||
|
error: { bg: c.errorBg, fg: c.error },
|
||||||
|
warning: { bg: c.warningBg, fg: c.warning },
|
||||||
|
success: { bg: c.successBg, fg: c.success },
|
||||||
|
info: { bg: c.primaryBg, fg: c.info },
|
||||||
|
muted: { bg: c.surfaceHover, fg: c.textSecondary },
|
||||||
|
}[tone];
|
||||||
|
return (
|
||||||
|
<View style={[styles.pill, { backgroundColor: colors.bg }]} testID="structured-mail-pill">
|
||||||
|
<Text style={[styles.pillText, { color: colors.fg }]} numberOfLines={1}>{label}</Text>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Cancelled, Pending or On hold; nothing for a confirmed booking. */
|
||||||
|
export function BookingStatusPill({ status }: { status?: ReservationStatus }) {
|
||||||
|
const t = useLocaleStore((s) => s.t);
|
||||||
|
if (!status || status === 'confirmed') return null;
|
||||||
|
const label = status === 'cancelled'
|
||||||
|
? t('email_viewer.structured_mail.status_cancelled', 'Cancelled')
|
||||||
|
: status === 'pending'
|
||||||
|
? t('email_viewer.structured_mail.status_pending', 'Pending')
|
||||||
|
: t('email_viewer.structured_mail.status_hold', 'On hold');
|
||||||
|
return <Pill label={label} tone={status === 'cancelled' ? 'error' : 'warning'} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A link from the mail with its host beside the label ("Track · dhl.de").
|
||||||
|
* Opens through the app's URL guard, which only hands http(s) to the OS.
|
||||||
|
*/
|
||||||
|
export function CardLink({ link, label, icon: Icon = ExternalLink }: { link?: Link; label: string; icon?: AppIcon }) {
|
||||||
|
const { c, styles } = useCardStyles();
|
||||||
|
if (!link || !isWebLink(link)) return null;
|
||||||
|
return (
|
||||||
|
<Pressable
|
||||||
|
onPress={() => { void openExternalUrl(link.url); }}
|
||||||
|
style={({ pressed }) => [styles.action, pressed && styles.actionPressed]}
|
||||||
|
accessibilityRole="link"
|
||||||
|
accessibilityLabel={`${label}, ${link.host}`}
|
||||||
|
testID="structured-mail-link"
|
||||||
|
>
|
||||||
|
<Icon size={14} color={c.textSecondary} />
|
||||||
|
<Text style={styles.actionText} numberOfLines={1}>{label}</Text>
|
||||||
|
{/* The host is how a reader judges the link: wrapped, never cut off. */}
|
||||||
|
<Text style={styles.actionHost}>· {link.host}</Text>
|
||||||
|
</Pressable>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CardShellProps {
|
||||||
|
/** Accessible name of the card ("Flight booking"). */
|
||||||
|
name: string;
|
||||||
|
icon: AppIcon;
|
||||||
|
/** Destructive tone and a struck-through title. */
|
||||||
|
cancelled?: boolean;
|
||||||
|
eyebrow: string;
|
||||||
|
title: string;
|
||||||
|
/** What screen readers hear instead of the title (a route written with arrows). */
|
||||||
|
titleLabel?: string;
|
||||||
|
pills?: React.ReactNode;
|
||||||
|
children?: React.ReactNode;
|
||||||
|
actions?: React.ReactNode;
|
||||||
|
kind: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The banner layout every card shares (CalendarInvitationBanner's look). */
|
||||||
|
export function CardShell({ name, icon: Icon, cancelled = false, eyebrow, title, titleLabel, pills, children, actions, kind }: CardShellProps) {
|
||||||
|
const { c, styles } = useCardStyles();
|
||||||
|
return (
|
||||||
|
<View style={styles.card} accessibilityLabel={name} testID={`structured-mail-card-${kind}`}>
|
||||||
|
<View style={[styles.badge, cancelled && styles.badgeCancelled]} importantForAccessibility="no-hide-descendants">
|
||||||
|
<Icon size={20} color={cancelled ? c.error : c.primary} />
|
||||||
|
</View>
|
||||||
|
<View style={styles.body}>
|
||||||
|
<View>
|
||||||
|
<Text style={styles.eyebrow}>{eyebrow}</Text>
|
||||||
|
<Text
|
||||||
|
style={[styles.title, cancelled && styles.titleCancelled]}
|
||||||
|
accessibilityRole="header"
|
||||||
|
accessibilityLabel={titleLabel}
|
||||||
|
testID="structured-mail-title"
|
||||||
|
>
|
||||||
|
{title}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
{pills ? <View style={styles.pills}>{pills}</View> : null}
|
||||||
|
{children ? <View style={styles.details}>{children}</View> : null}
|
||||||
|
{actions ? <View style={styles.actions} testID="structured-mail-actions">{actions}</View> : null}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** "Flight · Lufthansa"; the kind alone when the provider is missing or already the title. */
|
||||||
|
export function eyebrowText(kind: string, provider: string | undefined, title: string): string {
|
||||||
|
const p = provider?.trim();
|
||||||
|
return p && p.toLowerCase() !== title.trim().toLowerCase() ? `${kind} · ${p}` : kind;
|
||||||
|
}
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
// Port of webmail components/email/structured-mail/copy-value-chip.tsx at
|
||||||
|
// b0782af2d, on expo-clipboard like the app's VerificationCodeChip.
|
||||||
|
import React from 'react';
|
||||||
|
import { Pressable, StyleSheet, Text } from 'react-native';
|
||||||
|
import * as Clipboard from 'expo-clipboard';
|
||||||
|
import { Check, Copy } from '../../icons';
|
||||||
|
import { useLocaleStore } from '../../../stores/locale-store';
|
||||||
|
import { toast } from '../../../stores/toast-store';
|
||||||
|
import { useColors } from '../../../theme/colors';
|
||||||
|
import { radius, spacing, typography, type ThemePalette } from '../../../theme/tokens';
|
||||||
|
import { ltr } from './card-parts';
|
||||||
|
|
||||||
|
interface CopyValueChipProps {
|
||||||
|
/** What a tap copies: a booking reference, order or tracking number. */
|
||||||
|
value: string;
|
||||||
|
/** The button's accessible name, naming what is copied ("Copy booking reference QX7K2M"). */
|
||||||
|
copyLabel: string;
|
||||||
|
/** The toast after copying ("Booking reference copied"). */
|
||||||
|
copiedMessage: string;
|
||||||
|
/** A visible label in front of the value ("Booking reference"). */
|
||||||
|
label?: string;
|
||||||
|
/** As tall as the buttons beside it, in an action row. */
|
||||||
|
inActions?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A number from a booking or parcel card; a tap copies it. Looks like the verification code chip. */
|
||||||
|
export function CopyValueChip({ value, copyLabel, copiedMessage, label, inActions }: CopyValueChipProps) {
|
||||||
|
const c = useColors();
|
||||||
|
const styles = React.useMemo(() => makeStyles(c), [c]);
|
||||||
|
const t = useLocaleStore((s) => s.t);
|
||||||
|
const [copied, setCopied] = React.useState(false);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (!copied) return undefined;
|
||||||
|
const timer = setTimeout(() => setCopied(false), 2000);
|
||||||
|
return () => clearTimeout(timer);
|
||||||
|
}, [copied]);
|
||||||
|
|
||||||
|
const copy = () => {
|
||||||
|
Clipboard.setStringAsync(value).then(
|
||||||
|
() => {
|
||||||
|
setCopied(true);
|
||||||
|
toast.success(copiedMessage);
|
||||||
|
},
|
||||||
|
() => toast.error(t('email_viewer.structured_mail.copy_failed', 'Could not copy to the clipboard')),
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const Icon = copied ? Check : Copy;
|
||||||
|
return (
|
||||||
|
<Pressable
|
||||||
|
onPress={copy}
|
||||||
|
hitSlop={6}
|
||||||
|
style={({ pressed }) => [styles.chip, inActions && styles.chipInActions, pressed && styles.chipPressed]}
|
||||||
|
accessibilityRole="button"
|
||||||
|
accessibilityLabel={copyLabel}
|
||||||
|
testID="copy-value-chip"
|
||||||
|
>
|
||||||
|
<Icon size={12} color={copied ? c.success : c.textMuted} />
|
||||||
|
{label ? <Text style={styles.label}>{label}</Text> : null}
|
||||||
|
{/* Digit groups keep their order in a right-to-left UI. */}
|
||||||
|
<Text style={styles.value} selectable>{ltr(value)}</Text>
|
||||||
|
</Pressable>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeStyles(c: ThemePalette) {
|
||||||
|
return StyleSheet.create({
|
||||||
|
chip: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
flexShrink: 1,
|
||||||
|
gap: 4,
|
||||||
|
paddingHorizontal: spacing.sm,
|
||||||
|
paddingVertical: 3,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: c.border,
|
||||||
|
borderRadius: radius.sm,
|
||||||
|
backgroundColor: c.background,
|
||||||
|
},
|
||||||
|
chipInActions: { minHeight: 36, paddingHorizontal: spacing.md, borderRadius: radius.md },
|
||||||
|
chipPressed: { backgroundColor: c.surfaceHover },
|
||||||
|
label: { ...typography.caption, color: c.textMuted },
|
||||||
|
value: { ...typography.caption, color: c.text, fontWeight: '600', fontFamily: 'monospace', letterSpacing: 0.5, flexShrink: 1 },
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
// The card glyphs the app's generated icon set (src/components/icons, from
|
||||||
|
// scripts/generate-icons.mjs) doesn't ship yet, from the same Tabler outline
|
||||||
|
// data (MIT) under the webmail's names. Once they are added to MAP in that
|
||||||
|
// script, import them from components/icons and delete this file.
|
||||||
|
|
||||||
|
import { makeIcon } from '../../icons/icon';
|
||||||
|
|
||||||
|
export const Bed = makeIcon('Bed', ["M5 9a2 2 0 1 0 4 0a2 2 0 1 0 -4 0", "M22 17v-3h-20", "M2 8v9", "M12 14h10v-2a3 3 0 0 0 -3 -3h-7v5"]);
|
||||||
|
export const Bus = makeIcon('Bus', ["M4 17a2 2 0 1 0 4 0a2 2 0 1 0 -4 0", "M16 17a2 2 0 1 0 4 0a2 2 0 1 0 -4 0", "M4 17h-2v-11a1 1 0 0 1 1 -1h14a5 7 0 0 1 5 7v5h-2m-4 0h-8", "M16 5l1.5 7l4.5 0", "M2 10l15 0", "M7 5l0 5", "M12 5l0 5"]);
|
||||||
|
export const CalendarCheck = makeIcon('CalendarCheck', ["M11.5 21h-5.5a2 2 0 0 1 -2 -2v-12a2 2 0 0 1 2 -2h12a2 2 0 0 1 2 2v6", "M16 3v4", "M8 3v4", "M4 11h16", "M15 19l2 2l4 -4"]);
|
||||||
|
export const CalendarX = makeIcon('CalendarX', ["M13 21h-7a2 2 0 0 1 -2 -2v-12a2 2 0 0 1 2 -2h12a2 2 0 0 1 2 2v6.5", "M16 3v4", "M8 3v4", "M4 11h16", "M22 22l-5 -5", "M17 22l5 -5"]);
|
||||||
|
export const Car = makeIcon('Car', ["M5 17a2 2 0 1 0 4 0a2 2 0 1 0 -4 0", "M15 17a2 2 0 1 0 4 0a2 2 0 1 0 -4 0", "M5 17h-2v-6l2 -5h9l4 5h1a2 2 0 0 1 2 2v4h-2m-4 0h-6m-6 -6h15m-6 0v-5"]);
|
||||||
|
export const Package = makeIcon('Package', ["M12 3l8 4.5l0 9l-8 4.5l-8 -4.5l0 -9l8 -4.5", "M12 12l8 -4.5", "M12 12l0 9", "M12 12l-8 -4.5", "M16 5.25l-8 4.5"]);
|
||||||
|
export const Plane = makeIcon('Plane', ["M16 10h4a2 2 0 0 1 0 4h-4l-4 7h-3l2 -7h-4l-2 2h-3l2 -4l-2 -4h3l2 2h4l-2 -7h3l4 7"]);
|
||||||
|
export const Restaurant = makeIcon('Restaurant', ["M19 3v12h-5c-.023 -3.681 .184 -7.406 5 -12m0 12v6h-1v-3m-10 -14v17m-3 -17v3a3 3 0 1 0 6 0v-3"]);
|
||||||
|
export const ShoppingBag = makeIcon('ShoppingBag', ["M6.331 8h11.339a2 2 0 0 1 1.977 2.304l-1.255 8.152a3 3 0 0 1 -2.966 2.544h-6.852a3 3 0 0 1 -2.965 -2.544l-1.255 -8.152a2 2 0 0 1 1.977 -2.304", "M9 11v-5a3 3 0 0 1 6 0v5"]);
|
||||||
|
export const Ticket = makeIcon('Ticket', ["M15 5l0 2", "M15 11l0 2", "M15 17l0 2", "M5 5h14a2 2 0 0 1 2 2v3a2 2 0 0 0 0 4v3a2 2 0 0 1 -2 2h-14a2 2 0 0 1 -2 -2v-3a2 2 0 0 0 0 -4v-3a2 2 0 0 1 2 -2"]);
|
||||||
|
export const Train = makeIcon('Train', ["M21 13c0 -3.87 -3.37 -7 -10 -7h-8", "M3 15h16a2 2 0 0 0 2 -2", "M3 6v5h17.5", "M3 11v4", "M8 11v-5", "M13 11v-4.5", "M3 19h18"]);
|
||||||
|
export const Truck = makeIcon('Truck', ["M5 17a2 2 0 1 0 4 0a2 2 0 1 0 -4 0", "M15 17a2 2 0 1 0 4 0a2 2 0 1 0 -4 0", "M5 17h-2v-11a1 1 0 0 1 1 -1h9v12m-4 0h6m4 0h2v-6h-8m0 -5h5l3 5"]);
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
// Mirrors webmail components/email/structured-mail/pkpass-source.ts at
|
||||||
|
// 66c73d0f4; keep the two in step. App: the webmail's client reads the
|
||||||
|
// blob as a stream and stops one byte past the cap; React Native's fetch
|
||||||
|
// can't stream, so the download is refused when the server announces more
|
||||||
|
// than the cap and the bytes are counted once they are in.
|
||||||
|
import { PKPASS_MAX_BYTES, PKPASS_MAX_PER_MAIL, isPkpassAttachment, readPkpass } from '../../../lib/structured-mail/pkpass';
|
||||||
|
import type { StructuredCard } from '../../../lib/structured-mail';
|
||||||
|
import type { CardSource } from './sources';
|
||||||
|
|
||||||
|
/** Download a blob of at most `maxBytes` from the message's JMAP account. */
|
||||||
|
export type FetchPassBytes = (blobId: string, name: string | undefined, type: string, accountId: string | undefined, maxBytes: number) => Promise<ArrayBuffer>;
|
||||||
|
|
||||||
|
/** The app's download: the JMAP client's authenticated blob fetch, capped. */
|
||||||
|
export const fetchPassBytes: FetchPassBytes = async (blobId, name, type, accountId, maxBytes) => {
|
||||||
|
// Lazy: the client pulls in native modules the pure parts and their tests must not load.
|
||||||
|
const { jmapClient, BLOB_TIMEOUT_MS } = await import('../../../api/jmap-client');
|
||||||
|
const url = jmapClient.getBlobDownloadUrl(blobId, name, type, accountId);
|
||||||
|
const response = await jmapClient.authenticatedFetch(url, undefined, { timeoutMs: BLOB_TIMEOUT_MS });
|
||||||
|
if (!response.ok) throw new Error(`Failed to fetch blob: ${response.status}`);
|
||||||
|
const announced = Number(response.headers.get('content-length'));
|
||||||
|
if (Number.isFinite(announced) && announced > maxBytes) throw new Error('blob larger than its listed size');
|
||||||
|
const bytes = await response.arrayBuffer();
|
||||||
|
if (bytes.byteLength > maxBytes) throw new Error('blob larger than its listed size');
|
||||||
|
return bytes;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cards from Apple Wallet passes attached to a trusted message. Reads only
|
||||||
|
* `email.attachments`, the message's own parts: never the parts the viewer
|
||||||
|
* unwraps from an attached .eml or a TNEF winmail.dat, which the sender's
|
||||||
|
* authentication says nothing about. At most three passes, each downloaded
|
||||||
|
* from the message's own JMAP account and capped at 5 MB; stops between
|
||||||
|
* downloads once the message is closed. Never throws.
|
||||||
|
*/
|
||||||
|
export function makePkpassSource(fetchBytes: FetchPassBytes): CardSource {
|
||||||
|
return async ({ email, mailDate, accountId, signal }) => {
|
||||||
|
try {
|
||||||
|
if (signal.aborted) return [];
|
||||||
|
const attachments = (Array.isArray(email.attachments) ? email.attachments : [])
|
||||||
|
.filter((att) => !!att && typeof att.blobId === 'string' && att.blobId !== '' && isPkpassAttachment(att))
|
||||||
|
.slice(0, PKPASS_MAX_PER_MAIL);
|
||||||
|
const cards: StructuredCard[] = [];
|
||||||
|
for (const att of attachments) {
|
||||||
|
if (signal.aborted) return [];
|
||||||
|
let bytes: ArrayBuffer;
|
||||||
|
try {
|
||||||
|
// One byte past the cap, so a blob larger than its listed size is noticed and refused.
|
||||||
|
bytes = await fetchBytes(att.blobId, att.name, att.type, accountId, PKPASS_MAX_BYTES + 1);
|
||||||
|
} catch {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
cards.push(...await readPkpass(bytes, { mailDate }));
|
||||||
|
}
|
||||||
|
return signal.aborted ? [] : cards;
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export const pkpassSource: CardSource = makePkpassSource(fetchPassBytes);
|
||||||
@@ -0,0 +1,171 @@
|
|||||||
|
// Port of webmail components/email/structured-mail/shipment-card.tsx at
|
||||||
|
// b0782af2d (same rows, React Native views).
|
||||||
|
import React from 'react';
|
||||||
|
import { Image, Text, View } from 'react-native';
|
||||||
|
import { Clock, ExternalLink } from '../../icons';
|
||||||
|
import { formatMomentDate, formatMoney, isWebLink, shipmentItems, summarizeItems } from '../../../lib/structured-mail/format';
|
||||||
|
import type { Item, Link, OrderStatus, Parcel, Shipment } from '../../../lib/structured-mail/types';
|
||||||
|
import { useLocaleStore } from '../../../stores/locale-store';
|
||||||
|
import { CardLink, CardShell, MetaRow, Pill, useCardFormat, useCardStyles } from './card-parts';
|
||||||
|
import { CopyValueChip } from './copy-value-chip';
|
||||||
|
import { Package, ShoppingBag, Truck } from './icons';
|
||||||
|
|
||||||
|
const STATUS_TONE: Record<OrderStatus, 'error' | 'warning' | 'success' | 'info' | 'muted'> = {
|
||||||
|
payment_due: 'warning',
|
||||||
|
processing: 'muted',
|
||||||
|
problem: 'error',
|
||||||
|
in_transit: 'info',
|
||||||
|
pickup_available: 'success',
|
||||||
|
returned: 'muted',
|
||||||
|
delivered: 'success',
|
||||||
|
cancelled: 'error',
|
||||||
|
};
|
||||||
|
|
||||||
|
/** States in which an expected arrival date is out of date. */
|
||||||
|
const ARRIVED_OR_STOPPED = new Set<OrderStatus>(['delivered', 'returned', 'cancelled']);
|
||||||
|
|
||||||
|
function OrderStatusPill({ status }: { status: OrderStatus }) {
|
||||||
|
const t = useLocaleStore((s) => s.t);
|
||||||
|
const labels: Record<OrderStatus, string> = {
|
||||||
|
payment_due: t('email_viewer.structured_mail.order_status_payment_due', 'Payment due'),
|
||||||
|
processing: t('email_viewer.structured_mail.order_status_processing', 'Processing'),
|
||||||
|
problem: t('email_viewer.structured_mail.order_status_problem', 'Problem'),
|
||||||
|
in_transit: t('email_viewer.structured_mail.order_status_in_transit', 'Shipped'),
|
||||||
|
pickup_available: t('email_viewer.structured_mail.order_status_pickup_available', 'Ready for pickup'),
|
||||||
|
returned: t('email_viewer.structured_mail.order_status_returned', 'Returned'),
|
||||||
|
delivered: t('email_viewer.structured_mail.order_status_delivered', 'Delivered'),
|
||||||
|
cancelled: t('email_viewer.structured_mail.order_status_cancelled', 'Cancelled'),
|
||||||
|
};
|
||||||
|
return <Pill label={labels[status]} tone={STATUS_TONE[status]} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Product images, only when remote content may load for this message; https only. */
|
||||||
|
function ItemImages({ items }: { items: Item[] }) {
|
||||||
|
const { styles } = useCardStyles();
|
||||||
|
const shown = items.filter((item) => item.image && isWebLink(item.image) && item.image.url.toLowerCase().startsWith('https:'));
|
||||||
|
if (shown.length === 0) return null;
|
||||||
|
return (
|
||||||
|
<View style={styles.images}>
|
||||||
|
{shown.map((item, i) => (
|
||||||
|
<Image
|
||||||
|
key={`${item.image!.url}-${i}`}
|
||||||
|
source={{ uri: item.image!.url }}
|
||||||
|
accessibilityIgnoresInvertColors
|
||||||
|
style={styles.image}
|
||||||
|
testID="structured-mail-item-image"
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Carrier, tracking number and expected date; no date once the order is delivered, returned or cancelled. */
|
||||||
|
function ParcelRows({ parcel, showExpected }: { parcel: Parcel; showExpected: boolean }) {
|
||||||
|
const t = useLocaleStore((s) => s.t);
|
||||||
|
const { c, styles } = useCardStyles();
|
||||||
|
const { opts } = useCardFormat();
|
||||||
|
const expectedBy = showExpected ? parcel.expectedBy : undefined;
|
||||||
|
if (!parcel.carrier && !parcel.trackingNumber && !expectedBy) return null;
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{(parcel.carrier || parcel.trackingNumber) ? (
|
||||||
|
<View style={[styles.metaRow, { alignItems: 'center' }]} testID="structured-mail-parcel">
|
||||||
|
<Truck size={14} color={c.textSecondary} />
|
||||||
|
<View style={[styles.pills, { flex: 1 }]}>
|
||||||
|
{parcel.carrier ? <Text style={[styles.metaText, styles.strong, { flex: 0 }]}>{parcel.carrier}</Text> : null}
|
||||||
|
{!parcel.carrier && parcel.trackingNumber ? (
|
||||||
|
<Text style={[styles.metaText, { flex: 0 }]}>{t('email_viewer.structured_mail.tracking_number', 'Tracking number')}</Text>
|
||||||
|
) : null}
|
||||||
|
{parcel.trackingNumber ? (
|
||||||
|
<CopyValueChip
|
||||||
|
value={parcel.trackingNumber}
|
||||||
|
copyLabel={t('email_viewer.structured_mail.copy_tracking_number', 'Copy tracking number {value}', { value: parcel.trackingNumber })}
|
||||||
|
copiedMessage={t('email_viewer.structured_mail.copied_tracking_number', 'Tracking number copied')}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
) : null}
|
||||||
|
{expectedBy ? (
|
||||||
|
<MetaRow icon={Clock}>{t('email_viewer.structured_mail.expected_by', 'Arrives by {date}', { date: formatMomentDate(expectedBy, opts) })}</MetaRow>
|
||||||
|
) : null}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ShipmentCardProps {
|
||||||
|
shipment: Shipment;
|
||||||
|
allowRemoteImages: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ShipmentCard({ shipment, allowRemoteImages }: ShipmentCardProps) {
|
||||||
|
const t = useLocaleStore((s) => s.t);
|
||||||
|
const { c, styles } = useCardStyles();
|
||||||
|
const { opts } = useCardFormat();
|
||||||
|
const carrier = shipment.parcels.find((p) => p.carrier)?.carrier;
|
||||||
|
const isParcel = !shipment.orderNumber && !shipment.merchant && shipment.parcels.length > 0;
|
||||||
|
const kindLabel = isParcel
|
||||||
|
? t('email_viewer.structured_mail.kind_parcel', 'Parcel')
|
||||||
|
: t('email_viewer.structured_mail.kind_order', 'Order');
|
||||||
|
const title = shipment.merchant?.trim() || carrier || kindLabel;
|
||||||
|
const eyebrow = shipment.merchant && carrier ? `${kindLabel} · ${carrier}` : kindLabel;
|
||||||
|
const items = shipmentItems(shipment);
|
||||||
|
const { shown, more, count } = summarizeItems(items);
|
||||||
|
const total = shipment.total ? formatMoney(shipment.total, opts.locale) : undefined;
|
||||||
|
// One Track button per distinct tracking page.
|
||||||
|
const trackLinks = shipment.parcels.map((p) => p.trackingUrl)
|
||||||
|
.filter((l, i, all): l is Link => isWebLink(l) && all.findIndex((o) => o?.url === l!.url) === i);
|
||||||
|
const hasOrderLink = isWebLink(shipment.orderUrl);
|
||||||
|
const cancelled = shipment.status === 'cancelled';
|
||||||
|
const summary = [
|
||||||
|
items.length > 0 ? t('email_viewer.structured_mail.items_count', '{count, plural, one {# item} other {# items}}', { count }) : undefined,
|
||||||
|
total ? t('email_viewer.structured_mail.total', 'Total {total}', { total }) : undefined,
|
||||||
|
].filter(Boolean).join(' · ');
|
||||||
|
|
||||||
|
return (
|
||||||
|
<CardShell
|
||||||
|
kind="shipment"
|
||||||
|
name={t('email_viewer.structured_mail.region_shipment', 'Order and delivery')}
|
||||||
|
icon={isParcel ? Truck : ShoppingBag}
|
||||||
|
cancelled={cancelled}
|
||||||
|
eyebrow={eyebrow}
|
||||||
|
title={title}
|
||||||
|
pills={shipment.status ? <OrderStatusPill status={shipment.status} /> : undefined}
|
||||||
|
actions={trackLinks.length > 0 || hasOrderLink ? (
|
||||||
|
<>
|
||||||
|
{trackLinks.map((link, i) => (
|
||||||
|
<CardLink key={`${link.url}-${i}`} link={link} label={t('email_viewer.structured_mail.link_track', 'Track')} icon={Truck} />
|
||||||
|
))}
|
||||||
|
<CardLink link={shipment.orderUrl} label={t('email_viewer.structured_mail.link_view_order', 'View order')} icon={ExternalLink} />
|
||||||
|
</>
|
||||||
|
) : undefined}
|
||||||
|
>
|
||||||
|
{shipment.orderNumber ? (
|
||||||
|
<View style={[styles.metaRow, { alignItems: 'center' }]} testID="structured-mail-order-number">
|
||||||
|
<ShoppingBag size={14} color={c.textSecondary} />
|
||||||
|
<View style={[styles.pills, { flex: 1 }]}>
|
||||||
|
<Text style={[styles.metaText, { flex: 0 }]}>{t('email_viewer.structured_mail.order_number', 'Order number')}</Text>
|
||||||
|
<CopyValueChip
|
||||||
|
value={shipment.orderNumber}
|
||||||
|
copyLabel={t('email_viewer.structured_mail.copy_order_number', 'Copy order number {value}', { value: shipment.orderNumber })}
|
||||||
|
copiedMessage={t('email_viewer.structured_mail.copied_order_number', 'Order number copied')}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
) : null}
|
||||||
|
{shown.length > 0 ? (
|
||||||
|
<View>
|
||||||
|
<MetaRow icon={Package}>
|
||||||
|
<Text style={styles.strong}>{shown.map((item) => item.name).join(', ')}</Text>
|
||||||
|
{more > 0 ? ` ${t('email_viewer.structured_mail.items_more', 'and {count} more', { count: more })}` : null}
|
||||||
|
</MetaRow>
|
||||||
|
{allowRemoteImages ? <ItemImages items={shown} /> : null}
|
||||||
|
</View>
|
||||||
|
) : null}
|
||||||
|
{summary ? <Text style={styles.summary} testID="structured-mail-summary">{summary}</Text> : null}
|
||||||
|
{shipment.parcels.map((parcel, i) => (
|
||||||
|
<ParcelRows key={`${parcel.trackingNumber ?? ''}-${i}`} parcel={parcel} showExpected={!ARRIVED_OR_STOPPED.has(shipment.status ?? 'processing')} />
|
||||||
|
))}
|
||||||
|
</CardShell>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
// Mirrors webmail components/email/structured-mail/sources.ts at 66c73d0f4;
|
||||||
|
// keep the two in step. App: the blob context is the JMAP account id alone,
|
||||||
|
// since the app has one client (api/jmap-client).
|
||||||
|
import type { Email } from '../../../api/types';
|
||||||
|
import type { StructuredCard } from '../../../lib/structured-mail';
|
||||||
|
import { pkpassSource } from './pkpass-source';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Card sources beyond the HTML markup, read asynchronously once a trusted
|
||||||
|
* message is open (Apple Wallet passes among its attachments). Each gets the
|
||||||
|
* message and returns cards that are validated but not zone-resolved; the
|
||||||
|
* hook merges them with the markup cards and resolves zones for all. A
|
||||||
|
* source must read only `email.attachments` (never parts unwrapped from an
|
||||||
|
* attached message) and must not throw.
|
||||||
|
*/
|
||||||
|
export interface CardSourceContext {
|
||||||
|
email: Email;
|
||||||
|
mailDate: Date;
|
||||||
|
/** JMAP account the message's blobs live in; undefined for the primary account. */
|
||||||
|
accountId: string | undefined;
|
||||||
|
signal: AbortSignal;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type CardSource = (context: CardSourceContext) => Promise<StructuredCard[]>;
|
||||||
|
|
||||||
|
/** Registered sources: Apple Wallet passes. */
|
||||||
|
export const CARD_SOURCES: CardSource[] = [pkpassSource];
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
// Port of webmail components/email/structured-mail/structured-mail-cards.tsx
|
||||||
|
// at 972e5f621. App: links need no message id (the app has no plugin link
|
||||||
|
// hook), so the cards don't take the email.
|
||||||
|
import React from 'react';
|
||||||
|
import { View } from 'react-native';
|
||||||
|
import type { Booking, StructuredCard } from '../../../lib/structured-mail/types';
|
||||||
|
import { EventCard, LodgingCard, RentalCarCard, RestaurantCard, TransportCard } from './booking-cards';
|
||||||
|
import { useCardStyles } from './card-parts';
|
||||||
|
import { ShipmentCard } from './shipment-card';
|
||||||
|
|
||||||
|
export interface StructuredMailCardsProps {
|
||||||
|
/** Resolved cards from useStructuredMail; nothing renders for an empty list. */
|
||||||
|
cards: StructuredCard[];
|
||||||
|
/** Whether remote content may load for this message: product images show only then. */
|
||||||
|
allowRemoteImages: boolean;
|
||||||
|
/** Rendered in each booking card's action row ("Add to calendar"); nothing when undefined. */
|
||||||
|
calendarActions?: (booking: Booking) => React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The booking, parcel and order cards shown above an open message. */
|
||||||
|
export function StructuredMailCards({ cards, allowRemoteImages, calendarActions }: StructuredMailCardsProps) {
|
||||||
|
const { styles } = useCardStyles();
|
||||||
|
if (cards.length === 0) return null;
|
||||||
|
return (
|
||||||
|
<View style={styles.list} testID="structured-mail-cards">
|
||||||
|
{cards.map((card, i) => {
|
||||||
|
const key = `${card.key}-${i}`;
|
||||||
|
switch (card.kind) {
|
||||||
|
case 'flight':
|
||||||
|
case 'train':
|
||||||
|
case 'bus':
|
||||||
|
return <TransportCard key={key} booking={card} calendarActions={calendarActions} />;
|
||||||
|
case 'lodging':
|
||||||
|
return <LodgingCard key={key} booking={card} calendarActions={calendarActions} />;
|
||||||
|
case 'rentalcar':
|
||||||
|
return <RentalCarCard key={key} booking={card} calendarActions={calendarActions} />;
|
||||||
|
case 'event':
|
||||||
|
return <EventCard key={key} booking={card} calendarActions={calendarActions} />;
|
||||||
|
case 'restaurant':
|
||||||
|
return <RestaurantCard key={key} booking={card} calendarActions={calendarActions} />;
|
||||||
|
case 'shipment':
|
||||||
|
return <ShipmentCard key={key} shipment={card} allowRemoteImages={allowRemoteImages} />;
|
||||||
|
default:
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
})}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,347 @@
|
|||||||
|
// Port of webmail components/email/structured-mail/use-booking-calendar.ts
|
||||||
|
// at 3aac5a5c4. App differences:
|
||||||
|
// - lookups go through api/calendar findEventsByUid (CalendarEvent/query by
|
||||||
|
// uid in the primary account) and writes through the calendar store, whose
|
||||||
|
// account guard (writeScope) stops a write that an account switch overtook;
|
||||||
|
// an action of several writes also checks calendarOperationScope between
|
||||||
|
// them, and a lookup that lands after a switch is dropped;
|
||||||
|
// - the store's writes throw instead of answering false, and no plugin can
|
||||||
|
// stop them, so there is no "blocked" notice;
|
||||||
|
// - an add goes through sanitizeOutgoingCalendarEventData first, as the
|
||||||
|
// webmail's calendar store does on create;
|
||||||
|
// - "View in calendar" opens the event on the Calendar tab the way a tapped
|
||||||
|
// reminder does (pending-calendar-open), through the caller's navigator.
|
||||||
|
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||||
|
import { findEventsByUid } from '../../../api/calendar';
|
||||||
|
import type { Calendar, CalendarEvent, Email } from '../../../api/types';
|
||||||
|
import { canCreateEventsIn } from '../../../lib/calendar-editability';
|
||||||
|
import { useHasCalendar } from '../../../lib/capabilities';
|
||||||
|
import {
|
||||||
|
bookingEndsAt,
|
||||||
|
bookingEventPatch,
|
||||||
|
bookingEventSlots,
|
||||||
|
eventNeedsUpdate,
|
||||||
|
senderDomainOf,
|
||||||
|
type BookingEventLabels,
|
||||||
|
type BookingEventSlot,
|
||||||
|
} from '../../../lib/structured-mail/calendar';
|
||||||
|
import { sanitizeOutgoingCalendarEventData } from '../../../lib/structured-mail/event-normalization';
|
||||||
|
import type { StructuredCard } from '../../../lib/structured-mail';
|
||||||
|
import { setPendingCalendarOpen } from '../../../navigation/pending-calendar-open';
|
||||||
|
import {
|
||||||
|
CalendarAccountChangedError,
|
||||||
|
calendarOperationScope,
|
||||||
|
calendarStoreGeneration,
|
||||||
|
useCalendarStore,
|
||||||
|
} from '../../../stores/calendar-store';
|
||||||
|
import { useCalendarSubscriptionsStore } from '../../../stores/calendar-subscriptions-store';
|
||||||
|
import { useLocaleStore } from '../../../stores/locale-store';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What the card offers for its booking:
|
||||||
|
* - `hidden`: nothing (no calendar, shipment, booking over, lookup failed)
|
||||||
|
* - `loading`: looking the events up; nothing to show yet
|
||||||
|
* - `add`: none of the booking's events is in the calendar
|
||||||
|
* - `in-calendar`: every live event is there and up to date
|
||||||
|
* - `update`: some are there, and others are missing, changed or cancelled
|
||||||
|
* - `remove`: the booking is cancelled and its events are still there
|
||||||
|
* - `cancelled`: the booking is cancelled and nothing of it is in the calendar
|
||||||
|
*/
|
||||||
|
export type BookingCalendarStatus = 'hidden' | 'loading' | 'add' | 'in-calendar' | 'update' | 'remove' | 'cancelled';
|
||||||
|
|
||||||
|
export interface BookingCalendar {
|
||||||
|
status: BookingCalendarStatus;
|
||||||
|
/** Calendars an add can go into: own account, writable, not tasks-only; the default first. */
|
||||||
|
calendars: Calendar[];
|
||||||
|
busy: boolean;
|
||||||
|
/** What went wrong with the last action, in the user's language. */
|
||||||
|
error: string | null;
|
||||||
|
/** What the last action did, for a short confirmation. */
|
||||||
|
notice: 'added' | 'updated' | 'removed' | null;
|
||||||
|
/** Add every live event, into `calendarId` or the default calendar. */
|
||||||
|
add: (calendarId?: string) => Promise<void>;
|
||||||
|
/** Add the missing live events, update the changed ones, remove the cancelled ones. */
|
||||||
|
update: () => Promise<void>;
|
||||||
|
/** Remove the events of cancelled legs or of the cancelled booking. */
|
||||||
|
remove: () => Promise<void>;
|
||||||
|
/** Open the booking's event on the Calendar tab (through `openCalendar`). */
|
||||||
|
viewInCalendar: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Uids whose events a card removed in this session. Another card for the
|
||||||
|
* same booking (a conversation showing two of its mails) may still hold the
|
||||||
|
* event from its own lookup; this tells it the event is gone.
|
||||||
|
*/
|
||||||
|
const removedUids = new Set<string>();
|
||||||
|
|
||||||
|
/** For tests: forget the uids removed so far. */
|
||||||
|
export function resetRemovedBookingUids(): void {
|
||||||
|
removedUids.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
type Lookup = { key: string; failed: false; events: Record<string, CalendarEvent | null> } | { key: string; failed: true };
|
||||||
|
|
||||||
|
interface Plan {
|
||||||
|
status: BookingCalendarStatus;
|
||||||
|
/** Live slots and the stored event of each (null: not in the calendar). */
|
||||||
|
live: { slot: BookingEventSlot; existing: CalendarEvent | null }[];
|
||||||
|
/** Stored events of cancelled slots. */
|
||||||
|
stale: { slot: BookingEventSlot; existing: CalendarEvent }[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Own calendars an event can be created in, the default first. */
|
||||||
|
export function bookingTargetCalendars(
|
||||||
|
calendars: Calendar[],
|
||||||
|
isSubscriptionCalendar: (calendarId: string) => boolean,
|
||||||
|
taskOnlyCalendarIds: readonly string[],
|
||||||
|
): Calendar[] {
|
||||||
|
const own = calendars.filter((c) =>
|
||||||
|
!c.isShared
|
||||||
|
&& !taskOnlyCalendarIds.includes(c.id)
|
||||||
|
&& canCreateEventsIn(c, isSubscriptionCalendar));
|
||||||
|
const defaultCalendar = own.find((c) => c.isDefault);
|
||||||
|
return defaultCalendar ? [defaultCalendar, ...own.filter((c) => c !== defaultCalendar)] : own;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function planFor(slots: BookingEventSlot[], existingOf: (uid: string) => CalendarEvent | null): Plan {
|
||||||
|
const live = slots
|
||||||
|
.filter((slot) => !slot.cancelled && slot.event)
|
||||||
|
.map((slot) => ({ slot, existing: existingOf(slot.uid) }));
|
||||||
|
const stale = slots
|
||||||
|
.filter((slot) => slot.cancelled)
|
||||||
|
.map((slot) => ({ slot, existing: existingOf(slot.uid) }))
|
||||||
|
.filter((entry): entry is { slot: BookingEventSlot; existing: CalendarEvent } => entry.existing !== null);
|
||||||
|
let status: BookingCalendarStatus;
|
||||||
|
if (live.length === 0) {
|
||||||
|
status = slots.some((s) => s.cancelled) ? (stale.length > 0 ? 'remove' : 'cancelled') : 'hidden';
|
||||||
|
} else {
|
||||||
|
const found = live.filter((entry) => entry.existing);
|
||||||
|
const changed = found.some((entry) => eventNeedsUpdate(entry.existing as CalendarEvent, entry.slot.event as Partial<CalendarEvent>));
|
||||||
|
if (found.length === 0 && stale.length === 0) status = 'add';
|
||||||
|
else if (found.length < live.length || changed || stale.length > 0) status = 'update';
|
||||||
|
else status = 'in-calendar';
|
||||||
|
}
|
||||||
|
return { status, live, stale };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calendar state and actions for one booking card. Looks each of the
|
||||||
|
* booking's event uids up in the primary calendar account, merges what it
|
||||||
|
* finds into the calendar store, and never creates an event whose uid it
|
||||||
|
* found. Hidden unless the account has calendars and the booking isn't over.
|
||||||
|
*/
|
||||||
|
export function useBookingCalendar(
|
||||||
|
booking: StructuredCard | null | undefined,
|
||||||
|
email: Pick<Email, 'from' | 'subject'> | null | undefined,
|
||||||
|
options: { openCalendar?: () => void } = {},
|
||||||
|
): BookingCalendar {
|
||||||
|
const t = useLocaleStore((s) => s.t);
|
||||||
|
const hasCalendar = useHasCalendar();
|
||||||
|
const calendars = useCalendarStore((s) => s.calendars);
|
||||||
|
const storeEvents = useCalendarStore((s) => s.events);
|
||||||
|
const taskOnlyCalendarIds = useCalendarStore((s) => s.taskOnlyCalendarIds);
|
||||||
|
const createEvent = useCalendarStore((s) => s.createEvent);
|
||||||
|
const updateEvent = useCalendarStore((s) => s.updateEvent);
|
||||||
|
const deleteEvent = useCalendarStore((s) => s.deleteEvent);
|
||||||
|
const subscriptions = useCalendarSubscriptionsStore((s) => s.subscriptions);
|
||||||
|
const { openCalendar } = options;
|
||||||
|
|
||||||
|
const [lookup, setLookup] = useState<Lookup | null>(null);
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [notice, setNotice] = useState<BookingCalendar['notice']>(null);
|
||||||
|
const [refresh, setRefresh] = useState(0);
|
||||||
|
|
||||||
|
const senderDomain = senderDomainOf(email?.from?.[0]?.email);
|
||||||
|
const subject = email?.subject;
|
||||||
|
const labels = useMemo<Partial<BookingEventLabels>>(() => ({
|
||||||
|
reference: (reference) => t('structured_mail_calendar.description.reference', 'Booking reference: {reference}', { reference }),
|
||||||
|
passengers: (names) => t('structured_mail_calendar.description.passengers', 'Passengers: {names}', { names }),
|
||||||
|
seats: (seats) => t('structured_mail_calendar.description.seats', 'Seats: {seats}', { seats }),
|
||||||
|
checkin: (url) => t('structured_mail_calendar.description.checkin', 'Check in: {url}', { url }),
|
||||||
|
manage: (url) => t('structured_mail_calendar.description.manage', 'Manage booking: {url}', { url }),
|
||||||
|
ticket: (url) => t('structured_mail_calendar.description.ticket', 'Ticket: {url}', { url }),
|
||||||
|
phone: (phone) => t('structured_mail_calendar.description.phone', 'Phone: {phone}', { phone }),
|
||||||
|
car: (car) => t('structured_mail_calendar.description.car', 'Car: {car}', { car }),
|
||||||
|
tickets: (numbers) => t('structured_mail_calendar.description.tickets', 'Tickets: {numbers}', { numbers }),
|
||||||
|
partySize: (count) => t('structured_mail_calendar.description.party_size', 'Party size: {count}', { count }),
|
||||||
|
rentalCar: (company) => (company
|
||||||
|
? t('structured_mail_calendar.title.rental_car_company', 'Rental car: {company}', { company })
|
||||||
|
: t('structured_mail_calendar.title.rental_car', 'Rental car')),
|
||||||
|
addedFrom: (domain, mailSubject) => (mailSubject
|
||||||
|
? t('structured_mail_calendar.description.added_from_subject', 'Added from the mail “{subject}” from {domain}', { domain, subject: mailSubject })
|
||||||
|
: t('structured_mail_calendar.description.added_from', 'Added from a mail from {domain}', { domain })),
|
||||||
|
}), [t]);
|
||||||
|
|
||||||
|
const slots = useMemo(
|
||||||
|
() => (booking && senderDomain ? bookingEventSlots(booking, { senderDomain, mailSubjectOrProvider: subject, labels }) : []),
|
||||||
|
[booking, senderDomain, subject, labels],
|
||||||
|
);
|
||||||
|
const uidKey = slots.map((s) => s.uid).join(' ');
|
||||||
|
|
||||||
|
const endsAt = booking && booking.kind !== 'shipment' ? bookingEndsAt(booking) : null;
|
||||||
|
const over = endsAt === null || endsAt < Date.now();
|
||||||
|
const active = hasCalendar && !over && slots.length > 0;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!active || !uidKey) return;
|
||||||
|
let current = true;
|
||||||
|
const generation = calendarStoreGeneration();
|
||||||
|
const uids = uidKey.split(' ');
|
||||||
|
// The primary calendar account, the only one the picker offers.
|
||||||
|
Promise.all(uids.map((uid) => findEventsByUid(uid)))
|
||||||
|
.then((results) => {
|
||||||
|
// An account switch since: what came back is the previous account's.
|
||||||
|
if (!current || generation !== calendarStoreGeneration()) return;
|
||||||
|
const events: Record<string, CalendarEvent | null> = {};
|
||||||
|
uids.forEach((uid, i) => {
|
||||||
|
events[uid] = results[i].find((e) => e.uid === uid && !e.recurrenceId) ?? null;
|
||||||
|
if (events[uid]) removedUids.delete(uid);
|
||||||
|
});
|
||||||
|
setLookup({ key: uidKey, failed: false, events });
|
||||||
|
const hits = Object.values(events).filter((e): e is CalendarEvent => e !== null);
|
||||||
|
if (hits.length > 0) {
|
||||||
|
useCalendarStore.setState((s) => {
|
||||||
|
const ids = new Set(s.events.map((e) => e.id));
|
||||||
|
const added = hits.filter((e) => !ids.has(e.id));
|
||||||
|
return added.length > 0 ? { events: [...s.events, ...added] } : s;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
// Not knowing what the calendar holds, offering "Add" could duplicate: show nothing.
|
||||||
|
if (current) setLookup({ key: uidKey, failed: true });
|
||||||
|
});
|
||||||
|
return () => { current = false; };
|
||||||
|
}, [active, uidKey, refresh]);
|
||||||
|
|
||||||
|
const targets = useMemo(() => {
|
||||||
|
const isSubscriptionCalendar = (id: string) => subscriptions.some((s) => s.calendarId === id);
|
||||||
|
return bookingTargetCalendars(calendars, isSubscriptionCalendar, taskOnlyCalendarIds);
|
||||||
|
}, [calendars, subscriptions, taskOnlyCalendarIds]);
|
||||||
|
|
||||||
|
const known = lookup && lookup.key === uidKey && !lookup.failed ? lookup.events : null;
|
||||||
|
const existingOf = useCallback((uid: string): CalendarEvent | null => {
|
||||||
|
if (removedUids.has(uid)) return null;
|
||||||
|
const stored = known?.[uid];
|
||||||
|
// The store's copy of the same event is fresher after a change elsewhere.
|
||||||
|
if (stored) return storeEvents.find((e) => e.id === stored.id) ?? stored;
|
||||||
|
// Added since the lookup, maybe by another card for the same booking.
|
||||||
|
return storeEvents.find((e) => e.uid === uid && !e.recurrenceId && !e.isShared) ?? null;
|
||||||
|
}, [known, storeEvents]);
|
||||||
|
|
||||||
|
const plan = useMemo<Plan>(() => {
|
||||||
|
if (!active) return { status: 'hidden', live: [], stale: [] };
|
||||||
|
if (!lookup || lookup.key !== uidKey) return { status: 'loading', live: [], stale: [] };
|
||||||
|
if (lookup.failed) return { status: 'hidden', live: [], stale: [] };
|
||||||
|
return planFor(slots, existingOf);
|
||||||
|
}, [active, lookup, uidKey, slots, existingOf]);
|
||||||
|
|
||||||
|
const status: BookingCalendarStatus = plan.status === 'add' && targets.length === 0 ? 'hidden' : plan.status;
|
||||||
|
|
||||||
|
const remember = useCallback((uid: string, event: CalendarEvent | null) => {
|
||||||
|
setLookup((prev) => (prev && !prev.failed && prev.key === uidKey ? { ...prev, events: { ...prev.events, [uid]: event } } : prev));
|
||||||
|
}, [uidKey]);
|
||||||
|
|
||||||
|
/** Run one action; on failure say why and look the events up again. */
|
||||||
|
const run = useCallback(async (done: NonNullable<BookingCalendar['notice']>, work: (assertScope: () => void) => Promise<void>) => {
|
||||||
|
if (busy) return;
|
||||||
|
setBusy(true);
|
||||||
|
setError(null);
|
||||||
|
setNotice(null);
|
||||||
|
const scope = calendarOperationScope();
|
||||||
|
try {
|
||||||
|
await work(scope.assert);
|
||||||
|
setNotice(done);
|
||||||
|
} catch (err) {
|
||||||
|
// The account changed under the action: nothing failed in the account shown now.
|
||||||
|
if (err instanceof CalendarAccountChangedError) return;
|
||||||
|
setError(t('structured_mail_calendar.action_failed', "Couldn't change your calendar. Try again."));
|
||||||
|
setRefresh((n) => n + 1);
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
}, [busy, t]);
|
||||||
|
|
||||||
|
const createMissing = useCallback(async (calendarId: string, assertScope: () => void): Promise<void> => {
|
||||||
|
for (const { slot, existing } of plan.live) {
|
||||||
|
if (existing) continue;
|
||||||
|
assertScope();
|
||||||
|
const created = await createEvent(sanitizeOutgoingCalendarEventData({ ...slot.event }), calendarId);
|
||||||
|
removedUids.delete(slot.uid);
|
||||||
|
remember(slot.uid, created);
|
||||||
|
}
|
||||||
|
}, [plan.live, createEvent, remember]);
|
||||||
|
|
||||||
|
const removeStale = useCallback(async (assertScope: () => void): Promise<void> => {
|
||||||
|
for (const { slot, existing } of plan.stale) {
|
||||||
|
assertScope();
|
||||||
|
// Marked before the store drops the event, so other cards that
|
||||||
|
// re-render on that change already see it gone.
|
||||||
|
removedUids.add(slot.uid);
|
||||||
|
try {
|
||||||
|
await deleteEvent(existing.id);
|
||||||
|
} catch (err) {
|
||||||
|
removedUids.delete(slot.uid);
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
remember(slot.uid, null);
|
||||||
|
}
|
||||||
|
}, [plan.stale, deleteEvent, remember]);
|
||||||
|
|
||||||
|
const add = useCallback((calendarId?: string) => run('added', async (assertScope) => {
|
||||||
|
const target = calendarId ?? targets[0]?.id;
|
||||||
|
if (!target || !targets.some((c) => c.id === target)) throw new Error('no writable calendar');
|
||||||
|
await createMissing(target, assertScope);
|
||||||
|
}), [run, targets, createMissing]);
|
||||||
|
|
||||||
|
const update = useCallback(() => run('updated', async (assertScope) => {
|
||||||
|
for (const { slot, existing } of plan.live) {
|
||||||
|
if (!existing || !eventNeedsUpdate(existing, slot.event as Partial<CalendarEvent>)) continue;
|
||||||
|
// Only what moved; title and notes the user changed stay.
|
||||||
|
const patch = bookingEventPatch(existing, slot.event as Partial<CalendarEvent>, senderDomain);
|
||||||
|
if (Object.keys(patch).length === 0) continue;
|
||||||
|
assertScope();
|
||||||
|
await updateEvent(existing.id, patch);
|
||||||
|
// The store merged the patch into its copy, which the card reads first.
|
||||||
|
remember(slot.uid, { ...existing, ...patch } as CalendarEvent);
|
||||||
|
}
|
||||||
|
if (plan.live.some((entry) => !entry.existing)) {
|
||||||
|
// Missing legs go where the booking's other events are.
|
||||||
|
const used = plan.live.flatMap((entry) => Object.keys(entry.existing?.calendarIds ?? {}));
|
||||||
|
const target = targets.find((c) => used.includes(c.id))?.id ?? targets[0]?.id;
|
||||||
|
if (!target) throw new Error('no writable calendar');
|
||||||
|
await createMissing(target, assertScope);
|
||||||
|
}
|
||||||
|
await removeStale(assertScope);
|
||||||
|
}), [run, plan.live, senderDomain, targets, updateEvent, remember, createMissing, removeStale]);
|
||||||
|
|
||||||
|
const remove = useCallback(() => run('removed', removeStale), [run, removeStale]);
|
||||||
|
|
||||||
|
const viewInCalendar = useCallback(() => {
|
||||||
|
const target = plan.live.find((entry) => entry.existing)?.existing ?? plan.stale[0]?.existing;
|
||||||
|
if (target) {
|
||||||
|
const startMs = target.utcStart ? Date.parse(target.utcStart) : NaN;
|
||||||
|
setPendingCalendarOpen({
|
||||||
|
kind: 'event',
|
||||||
|
eventId: target.id,
|
||||||
|
serverId: target.originalId ?? target.id,
|
||||||
|
...(Number.isFinite(startMs) ? { startMs } : {}),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
openCalendar?.();
|
||||||
|
}, [plan, openCalendar]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
status,
|
||||||
|
calendars: targets,
|
||||||
|
busy,
|
||||||
|
error: status === 'hidden' ? null : error,
|
||||||
|
notice: status === 'hidden' ? null : notice,
|
||||||
|
add,
|
||||||
|
update,
|
||||||
|
remove,
|
||||||
|
viewInCalendar,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
// Mirrors webmail components/email/structured-mail/use-structured-mail.ts at
|
||||||
|
// ba6d5e448; keep the two in step. App: the blob account is the reader's
|
||||||
|
// `jmapAccountId` (a shared mailbox's owner), else the message's own stamp.
|
||||||
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
|
import type { Email } from '../../../api/types';
|
||||||
|
import {
|
||||||
|
loadAirportZones,
|
||||||
|
mergeCandidates,
|
||||||
|
needsAirportZones,
|
||||||
|
readStructuredMailDetailed,
|
||||||
|
resolveCards,
|
||||||
|
type StructuredCard,
|
||||||
|
} from '../../../lib/structured-mail';
|
||||||
|
import { useStructuredMailEnabled } from '../../../lib/structured-mail/gate';
|
||||||
|
import { getStructuredMailTrust, structuredHtmlPart } from '../../../lib/structured-mail/trust';
|
||||||
|
import { CARD_SOURCES } from './sources';
|
||||||
|
|
||||||
|
function mailDateOf(email: Pick<Email, 'sentAt' | 'receivedAt'>): Date {
|
||||||
|
return new Date(email.sentAt ?? email.receivedAt);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function resolveAll(cards: StructuredCard[]): Promise<StructuredCard[]> {
|
||||||
|
if (!needsAirportZones(cards)) return resolveCards(cards);
|
||||||
|
try {
|
||||||
|
return resolveCards(cards, await loadAirportZones());
|
||||||
|
} catch {
|
||||||
|
// Without the table no flight card can be checked, so none is shown.
|
||||||
|
return resolveCards(cards);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The booking, parcel and order cards of an open message: empty unless the
|
||||||
|
* gate is on and the message passes getStructuredMailTrust. Cards that
|
||||||
|
* need nothing asynchronous show at once; flights wait for the airport time
|
||||||
|
* zone table, and cards from attachment sources (CARD_SOURCES) are merged in
|
||||||
|
* once read, so a card never shows with unchecked times. A message whose
|
||||||
|
* markup describes a booking (even one validation rejected) is read from the
|
||||||
|
* markup alone: the sources aren't asked, so nothing is downloaded and a pass
|
||||||
|
* can't revive a rejected or cancelled booking or show one booking twice.
|
||||||
|
*/
|
||||||
|
export function useStructuredMail(email: Email | null | undefined, jmapAccountId?: string): StructuredCard[] {
|
||||||
|
const enabled = useStructuredMailEnabled();
|
||||||
|
const trusted = useMemo(() => !!email && enabled && getStructuredMailTrust(email).trusted, [email, enabled]);
|
||||||
|
const html = email ? structuredHtmlPart(email)?.value : undefined;
|
||||||
|
// An HTML part whose text hasn't loaded yet may still hold booking markup.
|
||||||
|
const htmlPending = html === undefined && !!email?.htmlBody?.some((part) => part.type === 'text/html');
|
||||||
|
|
||||||
|
const { cards: markup, hadBookingMarkup } = useMemo(() => {
|
||||||
|
if (!trusted || !email || !html) return { cards: [] as StructuredCard[], hadBookingMarkup: false };
|
||||||
|
return readStructuredMailDetailed(html, { mailDate: mailDateOf(email) });
|
||||||
|
// email.id and the HTML identify the input; the rest of the object may change identity freely.
|
||||||
|
}, [trusted, email?.id, html]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||||
|
const askSources = !hadBookingMarkup && !htmlPending;
|
||||||
|
|
||||||
|
const immediate = useMemo(() => resolveCards(markup.filter((card) => card.kind !== 'flight')), [markup]);
|
||||||
|
const [settled, setSettled] = useState<{ key: string; cards: StructuredCard[] } | null>(null);
|
||||||
|
const accountId = jmapAccountId ?? email?.jmapAccountId;
|
||||||
|
const settleKey = `${email?.id ?? ''}|${accountId ?? ''}|${trusted}|${markup.length}|${askSources}`;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!trusted || !email) return;
|
||||||
|
const sources = askSources ? CARD_SOURCES : [];
|
||||||
|
const needsWork = needsAirportZones(markup) || sources.length > 0;
|
||||||
|
if (!needsWork) return;
|
||||||
|
const controller = new AbortController();
|
||||||
|
(async () => {
|
||||||
|
const fromSources = (await Promise.all(sources.map((source) =>
|
||||||
|
source({ email, mailDate: mailDateOf(email), accountId, signal: controller.signal }).catch(() => [] as StructuredCard[]),
|
||||||
|
))).flat();
|
||||||
|
const combined = fromSources.length > 0
|
||||||
|
? mergeCandidates([...markup, ...fromSources].map((card) => ({ card, problems: [] })))
|
||||||
|
: markup;
|
||||||
|
const cards = await resolveAll(combined);
|
||||||
|
if (!controller.signal.aborted) setSettled({ key: settleKey, cards });
|
||||||
|
})();
|
||||||
|
return () => controller.abort();
|
||||||
|
// markup, askSources and settleKey change together with the message.
|
||||||
|
}, [trusted, markup, settleKey]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||||
|
|
||||||
|
if (!trusted) return [];
|
||||||
|
return settled?.key === settleKey ? settled.cards : immediate;
|
||||||
|
}
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
// Mirrors webmail lib/structured-mail/__tests__/booking-markup.test.ts at ba6d5e448; keep the two in step.
|
||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { readStructuredMail, readStructuredMailDetailed } from '..';
|
||||||
|
|
||||||
|
const CTX = { mailDate: new Date('2026-11-20T08:00:00Z') };
|
||||||
|
const page = (...blocks: unknown[]) => `<html><head>${blocks.map((b) => `<script type="application/ld+json">${JSON.stringify(b)}</script>`).join('')}</head><body><p>Hello</p></body></html>`;
|
||||||
|
|
||||||
|
function flight(departureTime: string, status = 'http://schema.org/ReservationConfirmed') {
|
||||||
|
return {
|
||||||
|
'@context': 'http://schema.org', '@type': 'FlightReservation', reservationNumber: 'PK7Q2Z', reservationStatus: status,
|
||||||
|
underName: { '@type': 'Person', name: 'Jana Beispiel' },
|
||||||
|
reservationFor: {
|
||||||
|
'@type': 'Flight', flightNumber: '1072', airline: { '@type': 'Airline', name: 'Swiss', iataCode: 'LX' },
|
||||||
|
departureAirport: { '@type': 'Airport', iataCode: 'ZRH' }, departureTime,
|
||||||
|
arrivalAirport: { '@type': 'Airport', iataCode: 'BER' }, arrivalTime: '2026-12-03T09:05:00+01:00',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const ORDER = { '@context': 'http://schema.org', '@type': 'Order', orderNumber: 'SO-77', merchant: 'Shop' };
|
||||||
|
|
||||||
|
describe('readStructuredMailDetailed', () => {
|
||||||
|
it('reports booking markup for a valid booking, with the same cards as readStructuredMail', () => {
|
||||||
|
const html = page(flight('2026-12-03T07:40:00+01:00'));
|
||||||
|
const detailed = readStructuredMailDetailed(html, CTX);
|
||||||
|
expect(detailed.hadBookingMarkup).toBe(true);
|
||||||
|
expect(detailed.cards).toEqual(readStructuredMail(html, CTX));
|
||||||
|
expect(detailed.cards).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reports booking markup for a cancelled booking', () => {
|
||||||
|
const detailed = readStructuredMailDetailed(page(flight('2026-12-03T07:40:00+01:00', 'http://schema.org/ReservationCancelled')), CTX);
|
||||||
|
expect(detailed).toMatchObject({ hadBookingMarkup: true, cards: [{ status: 'cancelled' }] });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reports booking markup that validation or merging rejected', () => {
|
||||||
|
const conflicting = readStructuredMailDetailed(page(flight('2026-12-03T07:40:00+01:00'), flight('2026-12-03T08:10:00+01:00')), CTX);
|
||||||
|
expect(conflicting).toEqual({ cards: [], hadBookingMarkup: true });
|
||||||
|
const incomplete = readStructuredMailDetailed(page(flight('2026-12-03')), CTX);
|
||||||
|
expect(incomplete).toEqual({ cards: [], hadBookingMarkup: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reports booking markup for reservation types it has no card for', () => {
|
||||||
|
const boat = { '@context': 'https://schema.org', '@type': 'BoatReservation', reservationNumber: 'B-1' };
|
||||||
|
expect(readStructuredMailDetailed(page(boat), CTX)).toEqual({ cards: [], hadBookingMarkup: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reports none for shipments only, foreign contexts and plain mail', () => {
|
||||||
|
expect(readStructuredMailDetailed(page(ORDER), CTX)).toMatchObject({ hadBookingMarkup: false, cards: [{ kind: 'shipment' }] });
|
||||||
|
expect(readStructuredMailDetailed(page({ ...flight('2026-12-03T07:40:00+01:00'), '@context': 'http://example.org' }), CTX).hadBookingMarkup).toBe(false);
|
||||||
|
expect(readStructuredMailDetailed('<html><body>Your Reservation is attached.</body></html>', CTX)).toEqual({ cards: [], hadBookingMarkup: false });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,556 @@
|
|||||||
|
// Mirrors webmail lib/structured-mail/__tests__/calendar.test.ts at 87d9f8923; keep the two in step.
|
||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import type { CalendarEvent } from '../../../api/types';
|
||||||
|
import {
|
||||||
|
bookingEndsAt,
|
||||||
|
bookingEventPatch,
|
||||||
|
bookingEventSlots,
|
||||||
|
bookingEventUid,
|
||||||
|
bookingToEvents,
|
||||||
|
eventNeedsUpdate,
|
||||||
|
fnv1a64,
|
||||||
|
organizationalDomain,
|
||||||
|
} from '../calendar';
|
||||||
|
import { normalizeCalendarEventLike, sanitizeOutgoingCalendarEventData } from '../event-normalization';
|
||||||
|
import { loadAirportZones, readStructuredMail, resolveCards, type Booking, type StructuredCard, type TransportBooking } from '..';
|
||||||
|
|
||||||
|
const ctx = 'http://schema.org';
|
||||||
|
|
||||||
|
/** Cards as the reader gets them: parsed, validated, merged and zone-resolved. */
|
||||||
|
async function cards(items: unknown[], mailDate = '2026-10-30T10:00:00Z'): Promise<StructuredCard[]> {
|
||||||
|
const html = `<html><head>${items.map((item) => `<script type="application/ld+json">${JSON.stringify(item)}</script>`).join('')}</head><body></body></html>`;
|
||||||
|
return resolveCards(readStructuredMail(html, { mailDate: new Date(mailDate) }), await loadAirportZones());
|
||||||
|
}
|
||||||
|
|
||||||
|
async function booking(item: unknown, mailDate?: string): Promise<Booking> {
|
||||||
|
const found = await cards([item], mailDate);
|
||||||
|
expect(found).toHaveLength(1);
|
||||||
|
return found[0] as Booking;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface FlightSpec {
|
||||||
|
ref?: string;
|
||||||
|
airline?: { name?: string; iataCode?: string };
|
||||||
|
number?: string;
|
||||||
|
dep: [name: string, code: string, time: string];
|
||||||
|
arr: [name: string, code: string, time: string];
|
||||||
|
status?: string;
|
||||||
|
passenger?: string;
|
||||||
|
seat?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function flight(spec: FlightSpec) {
|
||||||
|
return {
|
||||||
|
'@context': ctx, '@type': 'FlightReservation',
|
||||||
|
...(spec.ref !== undefined ? { reservationNumber: spec.ref } : { reservationNumber: 'KT4M9Z' }),
|
||||||
|
underName: { '@type': 'Person', name: spec.passenger ?? 'Erika Mustermann' },
|
||||||
|
...(spec.seat ? { airplaneSeat: spec.seat } : {}),
|
||||||
|
...(spec.status ? { reservationStatus: `http://schema.org/${spec.status}` } : {}),
|
||||||
|
checkinUrl: 'https://checkin.example-air.test/KT4M9Z',
|
||||||
|
reservationFor: {
|
||||||
|
'@type': 'Flight', flightNumber: spec.number ?? '110', airline: spec.airline ?? { name: 'United', iataCode: 'UA' },
|
||||||
|
departureAirport: { '@type': 'Airport', name: spec.dep[0], iataCode: spec.dep[1] }, departureTime: spec.dep[2],
|
||||||
|
arrivalAirport: { '@type': 'Airport', name: spec.arr[0], iataCode: spec.arr[1] }, arrivalTime: spec.arr[2],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const SENDER = { senderDomain: 'united.com' };
|
||||||
|
const SFO_JFK = flight({
|
||||||
|
dep: ['San Francisco International Airport', 'SFO', '2026-12-04T20:15:00-08:00'],
|
||||||
|
arr: ['John F. Kennedy International Airport', 'JFK', '2026-12-05T06:30:00-05:00'],
|
||||||
|
seat: '9A',
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('organizationalDomain', () => {
|
||||||
|
it('keeps the last two labels, three under a listed two-label public suffix', () => {
|
||||||
|
expect(organizationalDomain('booking.flixbus.com')).toBe('flixbus.com');
|
||||||
|
expect(organizationalDomain('flixbus.com')).toBe('flixbus.com');
|
||||||
|
expect(organizationalDomain('mail.Lufthansa.COM.')).toBe('lufthansa.com');
|
||||||
|
expect(organizationalDomain('news.easyjet.co.uk')).toBe('easyjet.co.uk');
|
||||||
|
expect(organizationalDomain('shop.example.co.uk')).toBe('example.co.uk');
|
||||||
|
expect(organizationalDomain('mail.qantas.com.au')).toBe('qantas.com.au');
|
||||||
|
expect(organizationalDomain('info.bahn.de')).toBe('bahn.de');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('treats short brand names under country domains as brands, not suffixes', () => {
|
||||||
|
expect(organizationalDomain('mail.klm.nl')).toBe('klm.nl');
|
||||||
|
expect(organizationalDomain('info.klm.nl')).toBe('klm.nl');
|
||||||
|
expect(organizationalDomain('news.dhl.de')).toBe('dhl.de');
|
||||||
|
// Not listed, so both collapse; the reference still has to match (see the list's comment).
|
||||||
|
expect(organizationalDomain('x.gouv.fr')).toBe(organizationalDomain('y.gouv.fr'));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('fnv1a64', () => {
|
||||||
|
it('matches the published FNV-1a 64-bit test vectors', () => {
|
||||||
|
expect(fnv1a64('')).toBe('cbf29ce484222325');
|
||||||
|
expect(fnv1a64('a')).toBe('af63dc4c8601ec8c');
|
||||||
|
expect(fnv1a64('foobar')).toBe('85944171f73967e8');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('bookingToEvents: flights', () => {
|
||||||
|
it('maps SFO 20:15 −08:00 → JFK 06:30 −05:00 to a Los Angeles start and an exact duration', async () => {
|
||||||
|
const card = await booking(SFO_JFK);
|
||||||
|
const [event] = bookingToEvents(card, SENDER);
|
||||||
|
expect(event).toMatchObject({
|
||||||
|
'@type': 'Event',
|
||||||
|
title: 'UA 110 SFO → JFK',
|
||||||
|
start: '2026-12-04T20:15:00',
|
||||||
|
timeZone: 'America/Los_Angeles',
|
||||||
|
duration: 'PT7H15M',
|
||||||
|
showWithoutTime: false,
|
||||||
|
locations: {
|
||||||
|
dep: { '@type': 'Location', name: 'San Francisco International Airport (SFO)', timeZone: 'America/Los_Angeles', relativeTo: 'start' },
|
||||||
|
arr: { '@type': 'Location', name: 'John F. Kennedy International Airport (JFK)', timeZone: 'America/New_York', relativeTo: 'end' },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(event.uid).toMatch(/^bulwark-sm-flight-[0-9a-f]{16}@structured\.bulwark$/);
|
||||||
|
expect(event.description).toBe([
|
||||||
|
'Booking reference: KT4M9Z',
|
||||||
|
'Passengers: Erika Mustermann (9A)',
|
||||||
|
'Check in: https://checkin.example-air.test/KT4M9Z',
|
||||||
|
'Added from a mail from united.com',
|
||||||
|
].join('\n'));
|
||||||
|
expect(event).not.toHaveProperty('participants');
|
||||||
|
expect(event).not.toHaveProperty('calendarIds');
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
// Westbound over the date line: the local arrival date is a day later.
|
||||||
|
['HNL → NRT', ['Honolulu', 'HNL', '2026-12-04T11:00:00-10:00'], ['Tokyo Narita', 'NRT', '2026-12-05T15:30:00+09:00'], '2026-12-04T11:00:00', 'Pacific/Honolulu', 'Asia/Tokyo', 'PT9H30M'],
|
||||||
|
['LAX → SYD', ['Los Angeles', 'LAX', '2026-12-04T22:30:00-08:00'], ['Sydney', 'SYD', '2026-12-06T08:45:00+11:00'], '2026-12-04T22:30:00', 'America/Los_Angeles', 'Australia/Sydney', 'PT15H15M'],
|
||||||
|
// Eastbound: the local arrival time is earlier than the departure.
|
||||||
|
['NRT → HNL', ['Tokyo Narita', 'NRT', '2026-12-04T21:00:00+09:00'], ['Honolulu', 'HNL', '2026-12-04T09:15:00-10:00'], '2026-12-04T21:00:00', 'Asia/Tokyo', 'Pacific/Honolulu', 'PT7H15M'],
|
||||||
|
['SYD → LAX', ['Sydney', 'SYD', '2026-12-04T09:50:00+11:00'], ['Los Angeles', 'LAX', '2026-12-04T06:20:00-08:00'], '2026-12-04T09:50:00', 'Australia/Sydney', 'America/Los_Angeles', 'PT15H30M'],
|
||||||
|
] as const)('crosses the date line %s', async (_name, dep, arr, start, depZone, arrZone, duration) => {
|
||||||
|
const card = await booking(flight({ dep: [...dep], arr: [...arr] }));
|
||||||
|
const [event] = bookingToEvents(card, SENDER);
|
||||||
|
expect(event).toMatchObject({
|
||||||
|
start, timeZone: depZone, duration,
|
||||||
|
locations: { dep: { timeZone: depZone, relativeTo: 'start' }, arr: { timeZone: arrZone, relativeTo: 'end' } },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('counts the real hours of a flight that lands after a DST change', async () => {
|
||||||
|
// New York moves to EDT at 02:00 on 14 March 2027; 23:00 GMT → 03:30 EDT is 8 h 30.
|
||||||
|
const card = await booking(flight({
|
||||||
|
airline: { name: 'British Airways', iataCode: 'BA' }, number: '117',
|
||||||
|
dep: ['London Heathrow', 'LHR', '2027-03-13T23:00:00+00:00'],
|
||||||
|
arr: ['New York JFK', 'JFK', '2027-03-14T03:30:00-04:00'],
|
||||||
|
}), '2027-03-01T10:00:00Z');
|
||||||
|
const [event] = bookingToEvents(card, { senderDomain: 'ba.com' });
|
||||||
|
expect(event).toMatchObject({ start: '2027-03-13T23:00:00', timeZone: 'Europe/London', duration: 'PT8H30M' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('adds no event for a cancelled leg but still names its uid for removal', async () => {
|
||||||
|
const outbound = flight({ dep: ['Frankfurt', 'FRA', '2026-12-01T10:15:00+01:00'], arr: ['New York JFK', 'JFK', '2026-12-01T13:05:00-05:00'], airline: { name: 'Lufthansa', iataCode: 'LH' }, number: '400', ref: 'QX7K2M' });
|
||||||
|
const back = { ...flight({ dep: ['New York JFK', 'JFK', '2026-12-08T17:30:00-05:00'], arr: ['Frankfurt', 'FRA', '2026-12-09T07:20:00+01:00'], airline: { name: 'Lufthansa', iataCode: 'LH' }, number: '401', ref: 'QX7K2M', status: 'ReservationCancelled' }) };
|
||||||
|
const [card] = (await cards([outbound, back])) as TransportBooking[];
|
||||||
|
expect(card.legs).toHaveLength(2);
|
||||||
|
const events = bookingToEvents(card, { senderDomain: 'lufthansa.com' });
|
||||||
|
expect(events.map((e) => e.title)).toEqual(['LH 400 FRA → JFK']);
|
||||||
|
const slots = bookingEventSlots(card, { senderDomain: 'lufthansa.com' });
|
||||||
|
expect(slots.map((s) => s.cancelled)).toEqual([false, true]);
|
||||||
|
expect(slots[1].uid).toBe(bookingEventUid(card, 'lufthansa.com', card.legs[1]));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('gives a pass leg without arrival a zero-length event', async () => {
|
||||||
|
const pass: TransportBooking = {
|
||||||
|
key: 'flight|ref:pq1', kind: 'flight', reference: 'PQ1RST', passengers: ['Erika Mustermann'], links: {}, source: 'pkpass',
|
||||||
|
legs: [{
|
||||||
|
carrierCode: 'LH', number: '400', seats: [{ passenger: 'Erika Mustermann', seat: '31C' }],
|
||||||
|
departure: { place: { code: 'FRA', name: 'Frankfurt' }, at: { date: '2026-12-01', time: '10:15:00', offsetMinutes: 60, utc: '2026-12-01T09:15:00Z' } },
|
||||||
|
}],
|
||||||
|
};
|
||||||
|
const [resolved] = resolveCards([pass], await loadAirportZones()) as TransportBooking[];
|
||||||
|
const [event] = bookingToEvents(resolved, { senderDomain: 'lufthansa.com' });
|
||||||
|
expect(event).toMatchObject({ start: '2026-12-01T10:15:00', timeZone: 'Europe/Berlin', duration: 'PT0S', title: 'LH 400 FRA' });
|
||||||
|
expect(event.locations).not.toHaveProperty('arr');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('bookingToEvents: fixed offsets', () => {
|
||||||
|
const bus = (dep: string, arr: string) => ({
|
||||||
|
'@context': ctx, '@type': 'BusReservation', reservationNumber: 'BX20471', underName: 'Erika Mustermann',
|
||||||
|
reservationFor: {
|
||||||
|
'@type': 'BusTrip', busNumber: 'N12', busCompany: { name: 'Coastline Coaches' },
|
||||||
|
departureBusStop: { '@type': 'BusStation', name: 'Harbour Terminal' }, departureTime: dep,
|
||||||
|
arrivalBusStop: { '@type': 'BusStation', name: 'Airport Stop 3' }, arrivalTime: arr,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses Etc/GMT+8 for a −08:00 time without a known zone, keeping the wall clock', async () => {
|
||||||
|
const card = await booking(bus('2026-12-04T20:15:00-08:00', '2026-12-04T22:45:00-08:00'));
|
||||||
|
const [event] = bookingToEvents(card, { senderDomain: 'coastline.test' });
|
||||||
|
expect(event).toMatchObject({
|
||||||
|
title: 'N12 Harbour Terminal → Airport Stop 3',
|
||||||
|
start: '2026-12-04T20:15:00', timeZone: 'Etc/GMT+8', duration: 'PT2H30M',
|
||||||
|
locations: { dep: { timeZone: 'Etc/GMT+8', relativeTo: 'start' }, arr: { timeZone: 'Etc/GMT+8', relativeTo: 'end' } },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('falls back to the UTC instant in Etc/UTC for a half-hour offset', async () => {
|
||||||
|
const card = await booking(bus('2026-12-04T10:00:00+05:30', '2026-12-04T13:15:00+05:30'));
|
||||||
|
const [event] = bookingToEvents(card, { senderDomain: 'coastline.test' });
|
||||||
|
expect(event).toMatchObject({ start: '2026-12-04T04:30:00', timeZone: 'Etc/UTC', duration: 'PT3H15M' });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('bookingToEvents: trains, stays, cars, events, tables', () => {
|
||||||
|
it('names stations with their addresses and counts the extra hour of a DST change', async () => {
|
||||||
|
// Berlin falls back from CEST to CET at 03:00 on 25 October 2026: 22:00 → 08:00 is 11 hours.
|
||||||
|
const card = await booking({
|
||||||
|
'@context': ctx, '@type': 'TrainReservation', reservationNumber: 'ZUG4711', underName: 'Erika Mustermann',
|
||||||
|
reservationFor: {
|
||||||
|
'@type': 'TrainTrip', trainNumber: 'NJ 40421', provider: { name: 'Nachtzug' },
|
||||||
|
departureStation: { '@type': 'TrainStation', name: 'München Hbf', address: { streetAddress: 'Bayerstraße 10A', postalCode: '80335', addressLocality: 'München', addressCountry: 'DE' } },
|
||||||
|
departureTime: '2026-10-24T22:00:00+02:00',
|
||||||
|
arrivalStation: { '@type': 'TrainStation', name: 'Hamburg Hbf', address: { streetAddress: 'Hachmannplatz 16', postalCode: '20099', addressLocality: 'Hamburg', addressCountry: 'DE' } },
|
||||||
|
arrivalTime: '2026-10-25T08:00:00+01:00',
|
||||||
|
},
|
||||||
|
}, '2026-10-20T10:00:00Z');
|
||||||
|
const [event] = bookingToEvents(card, { senderDomain: 'nachtzug.test' });
|
||||||
|
expect(event).toMatchObject({
|
||||||
|
title: 'NJ 40421 München Hbf → Hamburg Hbf',
|
||||||
|
start: '2026-10-24T22:00:00', timeZone: 'Europe/Berlin', duration: 'PT11H',
|
||||||
|
locations: {
|
||||||
|
dep: { name: 'München Hbf, Bayerstraße 10A, 80335 München', timeZone: 'Europe/Berlin', relativeTo: 'start' },
|
||||||
|
arr: { name: 'Hamburg Hbf, Hachmannplatz 16, 20099 Hamburg', timeZone: 'Europe/Berlin', relativeTo: 'end' },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const hotel = (checkin: string, checkout: string) => ({
|
||||||
|
'@context': ctx, '@type': 'LodgingReservation', reservationNumber: 'HB-88213', underName: 'Erika Mustermann',
|
||||||
|
reservationFor: {
|
||||||
|
'@type': 'Hotel', name: 'Hotel Adlon Kempinski', telephone: '+49 30 22610',
|
||||||
|
address: { streetAddress: 'Unter den Linden 77', postalCode: '10117', addressLocality: 'Berlin', addressCountry: 'DE' },
|
||||||
|
},
|
||||||
|
checkinDate: checkin, checkoutDate: checkout,
|
||||||
|
});
|
||||||
|
|
||||||
|
it('makes a stay with dates only an all-day event that includes the check-out day', async () => {
|
||||||
|
const card = await booking(hotel('2026-12-04', '2026-12-06'));
|
||||||
|
const [event] = bookingToEvents(card, { senderDomain: 'kempinski.com' });
|
||||||
|
expect(event).toMatchObject({
|
||||||
|
title: 'Hotel Adlon Kempinski', start: '2026-12-04T00:00:00', showWithoutTime: true, timeZone: null,
|
||||||
|
duration: 'P3D', freeBusyStatus: 'free',
|
||||||
|
locations: { hotel: { name: 'Hotel Adlon Kempinski, Unter den Linden 77, 10117 Berlin' } },
|
||||||
|
});
|
||||||
|
expect(event.locations?.hotel).not.toHaveProperty('timeZone');
|
||||||
|
expect(event.description).toContain('Phone: +49 30 22610');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('makes a stay with times a timed, free event in the hotel zone', async () => {
|
||||||
|
const card = await booking(hotel('2026-12-04T15:00:00+01:00', '2026-12-06T11:00:00+01:00'));
|
||||||
|
const [event] = bookingToEvents(card, { senderDomain: 'kempinski.com' });
|
||||||
|
expect(event).toMatchObject({
|
||||||
|
start: '2026-12-04T15:00:00', timeZone: 'Europe/Berlin', duration: 'PT44H', showWithoutTime: false, freeBusyStatus: 'free',
|
||||||
|
locations: { hotel: { timeZone: 'Europe/Berlin' } },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('maps a rental car from pickup to drop-off, marked free', async () => {
|
||||||
|
const card = await booking({
|
||||||
|
'@context': ctx, '@type': 'RentalCarReservation', reservationNumber: 'RC-55120', underName: 'Erika Mustermann',
|
||||||
|
reservationFor: { '@type': 'RentalCar', name: 'Golf', brand: { name: 'VW' }, rentalCompany: { name: 'Sixt' } },
|
||||||
|
pickupLocation: { '@type': 'Place', name: 'Sixt Berlin Hauptbahnhof', address: { addressLocality: 'Berlin', addressCountry: 'DE' } },
|
||||||
|
pickupTime: '2026-12-04T09:00:00+01:00',
|
||||||
|
dropoffLocation: { '@type': 'Place', name: 'Sixt Berlin Hauptbahnhof', address: { addressLocality: 'Berlin', addressCountry: 'DE' } },
|
||||||
|
dropoffTime: '2026-12-07T18:00:00+01:00',
|
||||||
|
});
|
||||||
|
const [event] = bookingToEvents(card, { senderDomain: 'sixt.de' });
|
||||||
|
expect(event).toMatchObject({
|
||||||
|
title: 'Rental car: Sixt', start: '2026-12-04T09:00:00', timeZone: 'Europe/Berlin', duration: 'PT81H', freeBusyStatus: 'free',
|
||||||
|
locations: { pickup: { relativeTo: 'start', timeZone: 'Europe/Berlin' }, dropoff: { relativeTo: 'end', timeZone: 'Europe/Berlin' } },
|
||||||
|
});
|
||||||
|
expect(event.description).toContain('Car: VW Golf');
|
||||||
|
});
|
||||||
|
|
||||||
|
const concert = (start: string, end?: string, address: Record<string, string> = { addressLocality: 'Berlin', addressCountry: 'DE' }) => ({
|
||||||
|
'@context': ctx, '@type': 'EventReservation', reservationNumber: 'EV-30017', underName: 'Erika Mustermann',
|
||||||
|
reservedTicket: { '@type': 'Ticket', ticketNumber: 'T-9001' },
|
||||||
|
reservationFor: {
|
||||||
|
'@type': 'Event', name: 'Grafikdesign: Illustrator-Workshop', startDate: start, ...(end ? { endDate: end } : {}),
|
||||||
|
location: { '@type': 'Place', name: 'Werkstatt Mitte', address },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
it('gives an event without an end an explicit zero duration', async () => {
|
||||||
|
const card = await booking(concert('2026-07-18T14:00:00+02:00'), '2026-06-01T10:00:00Z');
|
||||||
|
const [event] = bookingToEvents(card, { senderDomain: 'workshops.test' });
|
||||||
|
expect(event).toMatchObject({ start: '2026-07-18T14:00:00', timeZone: 'Europe/Berlin', duration: 'PT0S', locations: { venue: { name: 'Werkstatt Mitte, Berlin' } } });
|
||||||
|
expect(event.description).toContain('Tickets: T-9001');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('runs an event to its end, to the end of the day for a date-only end, and keeps floating times floating', async () => {
|
||||||
|
const timed = await booking(concert('2026-07-18T14:00:00+02:00', '2026-07-18T19:00:00+02:00'), '2026-06-01T10:00:00Z');
|
||||||
|
expect(bookingToEvents(timed, { senderDomain: 'workshops.test' })[0]).toMatchObject({ duration: 'PT5H', timeZone: 'Europe/Berlin' });
|
||||||
|
const toDayEnd = await booking(concert('2026-07-18T14:00:00+02:00', '2026-07-18'), '2026-06-01T10:00:00Z');
|
||||||
|
expect(bookingToEvents(toDayEnd, { senderDomain: 'workshops.test' })[0]).toMatchObject({ duration: 'PT9H59M59S' });
|
||||||
|
const floating = await booking(concert('2026-07-18T14:00:00', '2026-07-18T16:30:00', { addressLocality: 'Somewhere' }), '2026-06-01T10:00:00Z');
|
||||||
|
expect(bookingToEvents(floating, { senderDomain: 'workshops.test' })[0]).toMatchObject({ start: '2026-07-18T14:00:00', timeZone: null, duration: 'PT2H30M' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('makes a date-only event all-day over its days', async () => {
|
||||||
|
const card = await booking(concert('2026-07-18', '2026-07-19'), '2026-06-01T10:00:00Z');
|
||||||
|
expect(bookingToEvents(card, { senderDomain: 'workshops.test' })[0]).toMatchObject({
|
||||||
|
start: '2026-07-18T00:00:00', showWithoutTime: true, timeZone: null, duration: 'P2D',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('books a table as a zero-length event with the party size', async () => {
|
||||||
|
const card = await booking({
|
||||||
|
'@context': ctx, '@type': 'FoodEstablishmentReservation', reservationNumber: 'TR-2207', underName: 'Erika Mustermann', partySize: 2,
|
||||||
|
reservationFor: { '@type': 'Restaurant', name: 'Restaurant Tim Raue', address: { streetAddress: 'Rudi-Dutschke-Straße 26', postalCode: '10969', addressLocality: 'Berlin', addressCountry: 'DE' } },
|
||||||
|
startTime: '2026-12-05T19:30:00+01:00',
|
||||||
|
});
|
||||||
|
const [event] = bookingToEvents(card, { senderDomain: 'tim-raue.de' });
|
||||||
|
expect(event).toMatchObject({
|
||||||
|
title: 'Restaurant Tim Raue', start: '2026-12-05T19:30:00', timeZone: 'Europe/Berlin', duration: 'PT0S',
|
||||||
|
locations: { restaurant: { name: 'Restaurant Tim Raue, Rudi-Dutschke-Straße 26, 10969 Berlin', timeZone: 'Europe/Berlin' } },
|
||||||
|
});
|
||||||
|
expect(event.description).toBe('Booking reference: TR-2207\nParty size: 2\nAdded from a mail from tim-raue.de');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses the labels it is given and names the mail subject', async () => {
|
||||||
|
const card = await booking(SFO_JFK);
|
||||||
|
const [event] = bookingToEvents(card, {
|
||||||
|
senderDomain: 'mail.united.com',
|
||||||
|
mailSubjectOrProvider: 'Your trip to New York',
|
||||||
|
labels: { reference: (r) => `Buchungscode: ${r}` },
|
||||||
|
});
|
||||||
|
expect(event.description).toContain('Buchungscode: KT4M9Z');
|
||||||
|
expect(event.description).toContain('Added from the mail "Your trip to New York" from united.com');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('bookingEventUid', () => {
|
||||||
|
const lh = (time: [string, string], extra: Partial<FlightSpec> = {}) => flight({
|
||||||
|
airline: { name: 'Lufthansa', iataCode: 'LH' }, number: '400', ref: 'QX7K2M',
|
||||||
|
dep: ['Frankfurt', 'FRA', time[0]], arr: ['New York JFK', 'JFK', time[1]], ...extra,
|
||||||
|
});
|
||||||
|
|
||||||
|
it('stays the same when the schedule changes, and the event then needs an update', async () => {
|
||||||
|
const before = await booking(lh(['2026-12-01T10:15:00+01:00', '2026-12-01T13:05:00-05:00']));
|
||||||
|
const after = await booking(lh(['2026-12-01T12:40:00+01:00', '2026-12-01T15:35:00-05:00']));
|
||||||
|
const [old] = bookingToEvents(before, { senderDomain: 'lufthansa.com' });
|
||||||
|
const [now] = bookingToEvents(after, { senderDomain: 'lufthansa.com' });
|
||||||
|
expect(now.uid).toBe(old.uid);
|
||||||
|
expect(eventNeedsUpdate(old as CalendarEvent, now)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('differs across sender domains and is shared by the subdomains of one', async () => {
|
||||||
|
const card = await booking(lh(['2026-12-01T10:15:00+01:00', '2026-12-01T13:05:00-05:00']));
|
||||||
|
expect(bookingEventUid(card, 'lufthansa.com')).not.toBe(bookingEventUid(card, 'lufthansa-deals.com'));
|
||||||
|
expect(bookingEventUid(card, 'booking.flixbus.com')).toBe(bookingEventUid(card, 'flixbus.com'));
|
||||||
|
expect(bookingEventUid(card, 'noreply@mail.lufthansa.com')).toBe(bookingEventUid(card, 'lufthansa.com'));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keys on the IATA code, and on the airline name only when there is no code', async () => {
|
||||||
|
const times: [string, string] = ['2026-12-01T10:15:00+01:00', '2026-12-01T13:05:00-05:00'];
|
||||||
|
const short = await booking(lh(times));
|
||||||
|
const long = await booking(lh(times, { airline: { name: 'Deutsche Lufthansa AG', iataCode: 'LH' } }));
|
||||||
|
expect(bookingEventUid(long, 'lufthansa.com')).toBe(bookingEventUid(short, 'lufthansa.com'));
|
||||||
|
const nameOnly = await booking(lh(times, { airline: { name: 'Lufthansa' } }));
|
||||||
|
const otherName = await booking(lh(times, { airline: { name: 'Lufthansa Group' } }));
|
||||||
|
expect((nameOnly as TransportBooking).legs[0].carrierCode).toBeUndefined();
|
||||||
|
expect(bookingEventUid(nameOnly, 'lufthansa.com')).not.toBe(bookingEventUid(otherName, 'lufthansa.com'));
|
||||||
|
expect(bookingEventUid(nameOnly, 'lufthansa.com')).not.toBe(bookingEventUid(short, 'lufthansa.com'));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ignores leading zeros of the flight number, so a boarding pass finds the markup event', async () => {
|
||||||
|
const times: [string, string] = ['2026-12-01T10:15:00+01:00', '2026-12-01T13:05:00-05:00'];
|
||||||
|
const padded = (await booking(lh(times, { number: '0072' }))) as TransportBooking;
|
||||||
|
const plain = (await booking(lh(times, { number: '72' }))) as TransportBooking;
|
||||||
|
expect(padded.legs[0].number).toBe('0072');
|
||||||
|
expect(bookingEventUid(padded, 'lufthansa.com')).toBe(bookingEventUid(plain, 'lufthansa.com'));
|
||||||
|
const other = await booking(lh(times, { number: '720' }));
|
||||||
|
expect(bookingEventUid(other, 'lufthansa.com')).not.toBe(bookingEventUid(plain, 'lufthansa.com'));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('adds the departure date only when there is no reference', async () => {
|
||||||
|
const noRef = (time: [string, string]) => ({ ...lh(time), reservationNumber: undefined });
|
||||||
|
const day1 = await booking(noRef(['2026-12-01T10:15:00+01:00', '2026-12-01T13:05:00-05:00']));
|
||||||
|
const day1Later = await booking(noRef(['2026-12-01T14:15:00+01:00', '2026-12-01T17:05:00-05:00']));
|
||||||
|
const day2 = await booking(noRef(['2026-12-02T10:15:00+01:00', '2026-12-02T13:05:00-05:00']));
|
||||||
|
expect(bookingEventUid(day1Later, 'lufthansa.com')).toBe(bookingEventUid(day1, 'lufthansa.com'));
|
||||||
|
expect(bookingEventUid(day2, 'lufthansa.com')).not.toBe(bookingEventUid(day1, 'lufthansa.com'));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('gives a leg the same uid whatever other legs the mail lists, and offers no event for legs that would share one', async () => {
|
||||||
|
const day1 = lh(['2026-12-01T10:15:00+01:00', '2026-12-01T13:05:00-05:00']);
|
||||||
|
const day8 = lh(['2026-12-08T10:15:00+01:00', '2026-12-08T13:05:00-05:00']);
|
||||||
|
const back = flight({ airline: { name: 'Lufthansa', iataCode: 'LH' }, number: '401', ref: 'QX7K2M', dep: ['New York JFK', 'JFK', '2026-12-08T17:30:00-05:00'], arr: ['Frankfurt', 'FRA', '2026-12-09T07:20:00+01:00'] });
|
||||||
|
const [both] = (await cards([day1, day8, back])) as TransportBooking[];
|
||||||
|
const [one] = (await cards([day1, back])) as TransportBooking[];
|
||||||
|
expect(both.legs).toHaveLength(3);
|
||||||
|
expect(bookingEventUid(both, 'lufthansa.com', both.legs[0])).toBe(bookingEventUid(one, 'lufthansa.com', one.legs[0]));
|
||||||
|
expect(bookingEventUid(both, 'lufthansa.com', both.legs[2])).toBe(bookingEventUid(one, 'lufthansa.com', one.legs[1]));
|
||||||
|
// LH 400 FRA twice under one reference: one uid can't hold both, so neither is offered (nor removed); LH 401 still is.
|
||||||
|
const slots = bookingEventSlots(both, { senderDomain: 'lufthansa.com' });
|
||||||
|
expect(slots.map((slot) => [slot.event !== null, slot.cancelled])).toEqual([[false, false], [false, false], [true, false]]);
|
||||||
|
expect(bookingToEvents(both, { senderDomain: 'lufthansa.com' }).map((e) => e.title)).toEqual(['LH 401 JFK → FRA']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ignores spaces in train numbers', async () => {
|
||||||
|
const train = (number: string) => ({
|
||||||
|
'@context': ctx, '@type': 'TrainReservation', reservationNumber: 'ZUG577',
|
||||||
|
reservationFor: {
|
||||||
|
'@type': 'TrainTrip', trainNumber: number, provider: { name: 'DB' },
|
||||||
|
departureStation: { name: 'Hannover Hbf', address: { addressCountry: 'DE' } }, departureTime: '2026-12-01T10:00:00+01:00',
|
||||||
|
arrivalStation: { name: 'Berlin Hbf', address: { addressCountry: 'DE' } }, arrivalTime: '2026-12-01T11:40:00+01:00',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const spaced = await booking(train('ICE 577'));
|
||||||
|
expect(bookingEventUid(spaced, 'bahn.de')).toBe(bookingEventUid(await booking(train('ICE577')), 'bahn.de'));
|
||||||
|
expect(bookingEventUid(spaced, 'bahn.de')).not.toBe(bookingEventUid(await booking(train('ICE 578')), 'bahn.de'));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps the uid of a rescheduled car, event or table that has a reference, and keys on the date without one', async () => {
|
||||||
|
const table = (ref: string | undefined, start: string) => ({
|
||||||
|
'@context': ctx, '@type': 'FoodEstablishmentReservation', ...(ref ? { reservationNumber: ref } : {}), partySize: 2,
|
||||||
|
reservationFor: { '@type': 'Restaurant', name: 'Restaurant Tim Raue', address: { addressCountry: 'DE' } }, startTime: start,
|
||||||
|
});
|
||||||
|
const car = (ref: string | undefined, pickup: string, dropoff: string) => ({
|
||||||
|
'@context': ctx, '@type': 'RentalCarReservation', ...(ref ? { reservationNumber: ref } : {}),
|
||||||
|
reservationFor: { '@type': 'RentalCar', name: 'Golf', rentalCompany: { name: 'Sixt' } },
|
||||||
|
pickupLocation: { name: 'Sixt Berlin', address: { addressCountry: 'DE' } }, pickupTime: pickup,
|
||||||
|
dropoffLocation: { name: 'Sixt Berlin', address: { addressCountry: 'DE' } }, dropoffTime: dropoff,
|
||||||
|
});
|
||||||
|
const show = (ref: string | undefined, name: string, start: string) => ({
|
||||||
|
'@context': ctx, '@type': 'EventReservation', ...(ref ? { reservationNumber: ref } : {}),
|
||||||
|
reservationFor: { '@type': 'Event', name, startDate: start, location: { name: 'Werkstatt', address: { addressCountry: 'DE' } } },
|
||||||
|
});
|
||||||
|
const uid = async (item: unknown) => bookingEventUid(await booking(item), 'example-tickets.de');
|
||||||
|
expect(await uid(table('TR-1', '2026-12-05T19:30:00+01:00'))).toBe(await uid(table('TR-1', '2026-12-06T20:00:00+01:00')));
|
||||||
|
expect(await uid(table(undefined, '2026-12-05T19:30:00+01:00'))).not.toBe(await uid(table(undefined, '2026-12-06T19:30:00+01:00')));
|
||||||
|
expect(await uid(car('RC-1', '2026-12-04T09:00:00+01:00', '2026-12-07T18:00:00+01:00'))).toBe(await uid(car('RC-1', '2026-12-05T09:00:00+01:00', '2026-12-08T18:00:00+01:00')));
|
||||||
|
expect(await uid(car(undefined, '2026-12-04T09:00:00+01:00', '2026-12-07T18:00:00+01:00'))).not.toBe(await uid(car(undefined, '2026-12-05T09:00:00+01:00', '2026-12-08T18:00:00+01:00')));
|
||||||
|
expect(await uid(show('EV-1', 'Workshop', '2026-12-05T14:00:00+01:00'))).toBe(await uid(show('EV-1', 'Workshop', '2026-12-12T14:00:00+01:00')));
|
||||||
|
// One order, two events: the name keeps them apart.
|
||||||
|
expect(await uid(show('EV-1', 'Workshop', '2026-12-05T14:00:00+01:00'))).not.toBe(await uid(show('EV-1', 'Concert', '2026-12-05T14:00:00+01:00')));
|
||||||
|
expect(await uid(show(undefined, 'Workshop', '2026-12-05T14:00:00+01:00'))).not.toBe(await uid(show(undefined, 'Workshop', '2026-12-12T14:00:00+01:00')));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keys stays on reference and hotel, adding the check-in date only without reference', async () => {
|
||||||
|
const stay = (ref: string | undefined, checkin: string) => ({
|
||||||
|
'@context': ctx, '@type': 'LodgingReservation', ...(ref ? { reservationNumber: ref } : {}),
|
||||||
|
reservationFor: { '@type': 'Hotel', name: 'Hotel Adlon Kempinski' }, checkinDate: checkin, checkoutDate: '2026-12-09',
|
||||||
|
});
|
||||||
|
const a = await booking(stay('HB-1', '2026-12-04'));
|
||||||
|
const moved = await booking(stay('HB-1', '2026-12-05'));
|
||||||
|
expect(bookingEventUid(moved, 'kempinski.com')).toBe(bookingEventUid(a, 'kempinski.com'));
|
||||||
|
const b = await booking(stay(undefined, '2026-12-04'));
|
||||||
|
const c = await booking(stay(undefined, '2026-12-05'));
|
||||||
|
expect(bookingEventUid(b, 'kempinski.com')).not.toBe(bookingEventUid(c, 'kempinski.com'));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('eventNeedsUpdate', () => {
|
||||||
|
/** What CalendarEvent/get returns for an event created from `wanted`. */
|
||||||
|
function serverShaped(wanted: Partial<CalendarEvent>): CalendarEvent {
|
||||||
|
const locations = Object.fromEntries(Object.entries(wanted.locations ?? {}).map(([id, loc]) => [id, {
|
||||||
|
...loc, description: null, locationTypes: null, coordinates: null, links: null,
|
||||||
|
timeZone: loc.timeZone ?? null, relativeTo: loc.relativeTo ?? null, iCalendar: { name: 'vlocation' },
|
||||||
|
}]));
|
||||||
|
return {
|
||||||
|
id: 'b', calendarIds: { a: true }, isDraft: false, isOrigin: true, utcStart: '2026-12-05T04:15:00Z', utcEnd: '2026-12-05T11:30:00Z',
|
||||||
|
sequence: 0, created: '2026-10-30T10:00:00Z', updated: '2026-10-30T10:00:00Z', status: 'confirmed', freeBusyStatus: 'busy',
|
||||||
|
privacy: 'public', color: null, keywords: null, categories: null, locale: null, replyTo: null, organizerCalendarAddress: null,
|
||||||
|
participants: null, recurrenceRules: null, recurrenceOverrides: null, alerts: null, useDefaultAlerts: false, virtualLocations: null, links: null,
|
||||||
|
...wanted,
|
||||||
|
locations,
|
||||||
|
} as unknown as CalendarEvent;
|
||||||
|
}
|
||||||
|
|
||||||
|
it('sees no change in the event the server gives back for the same booking', async () => {
|
||||||
|
const [wanted] = bookingToEvents(await booking(SFO_JFK), SENDER);
|
||||||
|
expect(eventNeedsUpdate(serverShaped(wanted), wanted)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('sees a changed start, duration, zone or location', async () => {
|
||||||
|
const [wanted] = bookingToEvents(await booking(SFO_JFK), SENDER);
|
||||||
|
const stored = serverShaped(wanted);
|
||||||
|
expect(eventNeedsUpdate({ ...stored, start: '2026-12-04T21:15:00' }, wanted)).toBe(true);
|
||||||
|
expect(eventNeedsUpdate({ ...stored, duration: 'PT7H' }, wanted)).toBe(true);
|
||||||
|
expect(eventNeedsUpdate({ ...stored, timeZone: 'America/Denver' }, wanted)).toBe(true);
|
||||||
|
expect(eventNeedsUpdate({ ...stored, locations: { ...stored.locations, arr: { ...stored.locations!.arr, timeZone: 'America/Chicago' } } }, wanted)).toBe(true);
|
||||||
|
expect(eventNeedsUpdate({ ...stored, locations: { dep: stored.locations!.dep } }, wanted)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not compare titles or descriptions: the user may rename the event or write notes', async () => {
|
||||||
|
const [wanted] = bookingToEvents(await booking(SFO_JFK), SENDER);
|
||||||
|
expect(eventNeedsUpdate({ ...serverShaped(wanted), title: 'Flight to New York', description: 'my notes' }, wanted)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ignores the zone of two all-day events', async () => {
|
||||||
|
const stay = await booking({
|
||||||
|
'@context': ctx, '@type': 'LodgingReservation', reservationNumber: 'HB-3', reservationFor: { '@type': 'Hotel', name: 'Hotel' },
|
||||||
|
checkinDate: '2026-12-04', checkoutDate: '2026-12-06',
|
||||||
|
});
|
||||||
|
const [wanted] = bookingToEvents(stay, { senderDomain: 'hotel.test' });
|
||||||
|
expect(eventNeedsUpdate({ ...serverShaped(wanted), timeZone: 'Europe/Berlin' }, wanted)).toBe(false);
|
||||||
|
expect(eventNeedsUpdate({ ...serverShaped(wanted), duration: 'P2D' }, wanted)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('sees no change in a midnight-to-midnight span the store saved as all-day', async () => {
|
||||||
|
const card = await booking({
|
||||||
|
'@context': ctx, '@type': 'RentalCarReservation', reservationNumber: 'RC-0000',
|
||||||
|
reservationFor: { '@type': 'RentalCar', name: 'Golf', rentalCompany: { name: 'Sixt' } },
|
||||||
|
pickupLocation: { name: 'Sixt Berlin', address: { addressCountry: 'DE' } }, pickupTime: '2026-12-04T00:00:00+01:00',
|
||||||
|
dropoffLocation: { name: 'Sixt Berlin', address: { addressCountry: 'DE' } }, dropoffTime: '2026-12-07T00:00:00+01:00',
|
||||||
|
});
|
||||||
|
const [wanted] = bookingToEvents(card, { senderDomain: 'sixt.de' });
|
||||||
|
expect(wanted).toMatchObject({ start: '2026-12-04T00:00:00', duration: 'PT72H', timeZone: 'Europe/Berlin' });
|
||||||
|
// What the store's createEvent sends, and what its lookup turns the server's answer into.
|
||||||
|
const sent = sanitizeOutgoingCalendarEventData({ ...wanted });
|
||||||
|
expect(sent).toMatchObject({ showWithoutTime: true, duration: 'P3D', timeZone: null });
|
||||||
|
const readBack = normalizeCalendarEventLike(serverShaped(sent));
|
||||||
|
expect(eventNeedsUpdate(readBack, wanted)).toBe(false);
|
||||||
|
expect(bookingEventPatch(readBack, wanted, 'sixt.de')).toEqual({});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('patches only what moved, and keeps the user\'s title, notes and own locations', async () => {
|
||||||
|
const before = await booking(SFO_JFK);
|
||||||
|
const after = await booking({ ...SFO_JFK, reservationFor: { ...SFO_JFK.reservationFor, departureTime: '2026-12-04T21:00:00-08:00', arrivalTime: '2026-12-05T07:15:00-05:00' } });
|
||||||
|
const [old] = bookingToEvents(before, SENDER);
|
||||||
|
const [now] = bookingToEvents(after, SENDER);
|
||||||
|
const mine = { '@type': 'Location', name: 'Gate B12' } as CalendarEvent['locations'] extends Record<string, infer L> | null ? L : never;
|
||||||
|
const edited = { ...serverShaped(old), title: 'Flight to New York', description: `${old.description}\nmy notes`, locations: { ...serverShaped(old).locations, mine } };
|
||||||
|
const patch = bookingEventPatch(edited, now, 'united.com');
|
||||||
|
expect(patch).toEqual({ start: '2026-12-04T21:00:00', duration: 'PT7H15M', timeZone: 'America/Los_Angeles', showWithoutTime: false });
|
||||||
|
expect(patch).not.toHaveProperty('title');
|
||||||
|
expect(patch).not.toHaveProperty('description');
|
||||||
|
expect(patch).not.toHaveProperty('locations');
|
||||||
|
|
||||||
|
const diverted = await booking({ ...SFO_JFK, reservationFor: { ...SFO_JFK.reservationFor, arrivalAirport: { '@type': 'Airport', name: 'Newark Liberty International Airport', iataCode: 'EWR' } } });
|
||||||
|
const [toEwr] = bookingToEvents(diverted, SENDER);
|
||||||
|
const locationPatch = bookingEventPatch(edited, toEwr, 'united.com');
|
||||||
|
expect(locationPatch.locations).toMatchObject({ mine: { name: 'Gate B12' }, arr: { name: 'Newark Liberty International Airport (EWR)' } });
|
||||||
|
expect(locationPatch).not.toHaveProperty('title');
|
||||||
|
expect(locationPatch).not.toHaveProperty('start');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('updates title and description while the description is still the generated one', async () => {
|
||||||
|
const [old] = bookingToEvents(await booking(SFO_JFK), SENDER);
|
||||||
|
const diverted = await booking({ ...SFO_JFK, reservationFor: { ...SFO_JFK.reservationFor, arrivalAirport: { '@type': 'Airport', name: 'Newark Liberty International Airport', iataCode: 'EWR' } } });
|
||||||
|
const [toEwr] = bookingToEvents(diverted, { ...SENDER, mailSubjectOrProvider: 'Change to your flight' });
|
||||||
|
const patch = bookingEventPatch(serverShaped(old), toEwr, 'united.com');
|
||||||
|
expect(patch).toMatchObject({ title: 'UA 110 SFO → EWR', description: toEwr.description });
|
||||||
|
expect(bookingEventPatch(serverShaped(old), toEwr)).not.toHaveProperty('title');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('bookingEndsAt', () => {
|
||||||
|
it('ends a trip at its last arrival and a stay at the end of the check-out day', async () => {
|
||||||
|
expect(bookingEndsAt(await booking(SFO_JFK))).toBe(Date.parse('2026-12-05T11:30:00Z'));
|
||||||
|
const stay = await booking({
|
||||||
|
'@context': ctx, '@type': 'LodgingReservation', reservationNumber: 'HB-2', reservationFor: { '@type': 'Hotel', name: 'Hotel' },
|
||||||
|
checkinDate: '2026-12-04', checkoutDate: '2026-12-06',
|
||||||
|
});
|
||||||
|
expect(bookingEndsAt(stay)).toBe(new Date(2026, 11, 7).getTime());
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
// extract.ts on the app's parser (html-parser.ts), which stands in for the
|
||||||
|
// webmail's DOMParser: what the ported extract tests don't cover.
|
||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { extractStructuredData } from '../extract';
|
||||||
|
|
||||||
|
describe('extractStructuredData on the app parser', () => {
|
||||||
|
it('reads microdata with entities and nested items from the default parser', () => {
|
||||||
|
const html = `<div itemscope itemtype="http://schema.org/ParcelDelivery">
|
||||||
|
<span itemprop="carrier">Deutsche Post & DHL</span>
|
||||||
|
<div itemprop="partOfOrder" itemscope itemtype="http://schema.org/Order"><meta itemprop="orderNumber" content="A-1"></div>
|
||||||
|
</div>`;
|
||||||
|
expect(extractStructuredData(html)).toEqual([{
|
||||||
|
source: 'microdata',
|
||||||
|
item: {
|
||||||
|
'@context': 'https://schema.org', '@type': 'ParcelDelivery', carrier: 'Deutsche Post & DHL',
|
||||||
|
partOfOrder: { '@type': 'Order', orderNumber: 'A-1' },
|
||||||
|
},
|
||||||
|
}]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('skips JSON-LD after the Outlook forward separator', () => {
|
||||||
|
const ld = '<script type="application/ld+json">{"@context":"http://schema.org","@type":"Order","orderNumber":"1"}</script>';
|
||||||
|
expect(extractStructuredData(`<div>${ld}</div>`)).toHaveLength(1);
|
||||||
|
expect(extractStructuredData(`<p>FYI</p><div id="divRplyFwdMsg">From: x</div><div>${ld}</div>`)).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
// Mirrors webmail lib/structured-mail/__tests__/extract.test.ts at 76b0c2fba; keep the two in step.
|
||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { extractStructuredData, mayContainStructuredData } from '../extract';
|
||||||
|
|
||||||
|
const page = (head: string, body = '') => `<!DOCTYPE html><html><head>${head}</head><body>${body}</body></html>`;
|
||||||
|
const ld = (value: unknown, type = 'application/ld+json') => `<script type="${type}">${JSON.stringify(value)}</script>`;
|
||||||
|
const order = { '@context': 'http://schema.org', '@type': 'Order', orderNumber: '42' };
|
||||||
|
|
||||||
|
describe('extractStructuredData: JSON-LD', () => {
|
||||||
|
it('reads blocks in head and body, type compared loosely', () => {
|
||||||
|
const html = page(ld(order), ld({ ...order, orderNumber: '43' }, ' Application/LD+JSON '));
|
||||||
|
expect(extractStructuredData(html).map((f) => f.item)).toEqual([order, { ...order, orderNumber: '43' }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reads a block nested inside a microdata tree', () => {
|
||||||
|
const html = page('', `<div itemscope itemtype="http://schema.org/EmailMessage">${ld(order)}</div>`);
|
||||||
|
expect(extractStructuredData(html).filter((f) => f.source === 'json-ld').map((f) => f.item)).toEqual([order]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('skips mail without any type it reads, without parsing', () => {
|
||||||
|
const html = page(ld({ '@context': 'http://schema.org', '@type': 'PromotionCard' }));
|
||||||
|
expect(mayContainStructuredData(html)).toBe(false);
|
||||||
|
expect(extractStructuredData(html)).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ignores markup inside quoted or forwarded parts', () => {
|
||||||
|
const quoted = `<blockquote>${ld(order)}</blockquote><div class="gmail_quote">${ld(order)}</div>`;
|
||||||
|
const outlook = `<p>FYI</p><div id="divRplyFwdMsg">From: someone</div><div>${ld(order)}</div>`;
|
||||||
|
expect(extractStructuredData(page('', quoted))).toEqual([]);
|
||||||
|
expect(extractStructuredData(page('', outlook))).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('caps the number of blocks it reads', () => {
|
||||||
|
const html = page(Array.from({ length: 60 }, () => ld(order)).join(''));
|
||||||
|
expect(extractStructuredData(html)).toHaveLength(50);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('runs no script and loads nothing while parsing', () => {
|
||||||
|
const html = page('<script>globalThis.__structuredMailRan = true</script>', `<img src="https://tracker.invalid/x.gif">${ld(order)}`);
|
||||||
|
extractStructuredData(html);
|
||||||
|
expect((globalThis as unknown as Record<string, unknown>).__structuredMailRan).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('extractStructuredData: microdata', () => {
|
||||||
|
it('reads values from content, href, src, datetime and text', () => {
|
||||||
|
const html = page('', `
|
||||||
|
<div itemscope itemtype="http://schema.org/ParcelDelivery">
|
||||||
|
<meta itemprop="trackingNumber" content=" 123 ">
|
||||||
|
<a itemprop="trackingUrl" href="https://track.example.com/123">track</a>
|
||||||
|
<img itemprop="image" src="https://img.example.com/a.png">
|
||||||
|
<time itemprop="expectedArrivalUntil" datetime="2026-11-02">2 Nov</time>
|
||||||
|
<span itemprop="carrier"> DHL
|
||||||
|
Express </span>
|
||||||
|
</div>`);
|
||||||
|
expect(extractStructuredData(html)).toEqual([{
|
||||||
|
source: 'microdata',
|
||||||
|
item: {
|
||||||
|
'@context': 'https://schema.org', '@type': 'ParcelDelivery', trackingNumber: '123',
|
||||||
|
trackingUrl: 'https://track.example.com/123', image: 'https://img.example.com/a.png',
|
||||||
|
expectedArrivalUntil: '2026-11-02', carrier: 'DHL Express',
|
||||||
|
},
|
||||||
|
}]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('nests items, splits itemprop lists and turns repeats into arrays', () => {
|
||||||
|
const html = page('', `
|
||||||
|
<div itemscope itemtype="https://schema.org/Order">
|
||||||
|
<meta itemprop="orderNumber" content="7">
|
||||||
|
<div itemprop="merchant seller" itemscope itemtype="http://schema.org/Organization"><meta itemprop="name" content="Shop"></div>
|
||||||
|
<meta itemprop="orderStatus" content="">
|
||||||
|
<link itemprop="orderStatus" href="http://schema.org/OrderProcessing">
|
||||||
|
<div itemprop="acceptedOffer" itemscope itemtype="http://schema.org/Offer">
|
||||||
|
<div itemprop="itemOffered" itemscope itemtype="http://schema.org/Product"><meta itemprop="name" content="One"></div>
|
||||||
|
</div>
|
||||||
|
<div itemprop="acceptedOffer" itemscope itemtype="http://schema.org/Offer">
|
||||||
|
<div itemprop="itemOffered" itemscope itemtype="http://schema.org/Product"><meta itemprop="name" content="Two"></div>
|
||||||
|
</div>
|
||||||
|
</div>`);
|
||||||
|
const [{ item }] = extractStructuredData(html);
|
||||||
|
const shop = { '@type': 'Organization', name: 'Shop' };
|
||||||
|
expect(item).toEqual({
|
||||||
|
'@context': 'https://schema.org', '@type': 'Order', orderNumber: '7', merchant: shop, seller: shop,
|
||||||
|
orderStatus: 'http://schema.org/OrderProcessing',
|
||||||
|
acceptedOffer: [
|
||||||
|
{ '@type': 'Offer', itemOffered: { '@type': 'Product', name: 'One' } },
|
||||||
|
{ '@type': 'Offer', itemOffered: { '@type': 'Product', name: 'Two' } },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ignores items not typed on schema.org', () => {
|
||||||
|
const html = page('', `
|
||||||
|
<div itemscope itemtype="http://example.com/Order"><meta itemprop="orderNumber" content="1"></div>
|
||||||
|
<div itemscope><meta itemprop="orderNumber" content="2"></div>`);
|
||||||
|
expect(extractStructuredData(html)).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('accepts several itemtypes and keeps the schema.org ones', () => {
|
||||||
|
const html = page('', '<div itemscope itemtype="http://example.com/X http://schema.org/Order"><meta itemprop="orderNumber" content="1"></div>');
|
||||||
|
expect(extractStructuredData(html)[0].item).toMatchObject({ '@type': 'Order', orderNumber: '1' });
|
||||||
|
});
|
||||||
|
});
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,79 @@
|
|||||||
|
[
|
||||||
|
{
|
||||||
|
"id": "flixbus-booking",
|
||||||
|
"sender": "booking.flixbus.com",
|
||||||
|
"source": "real",
|
||||||
|
"mailDate": "2024-05-28T15:18:21Z",
|
||||||
|
"html": "<!DOCTYPE html><html><head><meta charset=\"utf-8\"><script type=\"application/ld+json\">[{\"@context\":\"http://schema.org\",\"@type\":\"BusReservation\",\"reservationNumber\":\"3165000001\",\"underName\":{\"@type\":\"Person\",\"name\":\"Erika Mustermann\",\"email\":\"kunde@example.org\"},\"reservationStatus\":\"http://schema.org/ReservationConfirmed\",\"reservationFor\":{\"@type\":\"BusTrip\",\"busNumber\":\"FLX30\",\"busName\":\"Berlin Ostbahnhof\",\"busCompany\":{\"@type\":\"Organization\",\"name\":\"Flix\"},\"departureBusStop\":{\"@type\":\"BusStop\",\"name\":\"Hannover Hauptbahnhof (FlixTrain)\",\"address\":{\"@type\":\"PostalAddress\",\"streetAddress\":\"Ernst-August-Platz 1\",\"addressLocality\":\"Hannover\",\"postalCode\":\"30159\",\"addressCountry\":\"DE\"}},\"departureTime\":\"2024-06-04T11:44:00+0200\",\"arrivalBusStop\":{\"@type\":\"BusStop\",\"name\":\"Berlin Ostbahnhof (FlixTrain)\",\"address\":{\"@type\":\"PostalAddress\",\"streetAddress\":\"Koppenstr. 3\",\"addressLocality\":\"Berlin\",\"postalCode\":\"10243\",\"addressCountry\":\"DE\"}},\"arrivalTime\":\"2024-06-04T13:44:00+0200\"},\"reservedTicket\":{\"@type\":\"Ticket\",\"ticketNumber\":\"3165000001\",\"ticketToken\":\"https://shop.global.flixbus.com/pdfqr/3165000001/00000000000000000000000000000000000000000000000000?mfb_source=email\",\"underName\":{\"@type\":\"Person\",\"name\":\"Erika Mustermann\"},\"ticketedSeat\":{\"@type\":\"Seat\",\"seatingType\":\"Coach\",\"seatNumber\":\"6D\"}}},{\"@context\":\"http://schema.org\",\"@type\":\"BusReservation\",\"reservationNumber\":\"3165000001\",\"underName\":{\"@type\":\"Person\",\"name\":\"Max Mustermann\",\"email\":\"kunde@example.org\"},\"reservationStatus\":\"http://schema.org/ReservationConfirmed\",\"reservationFor\":{\"@type\":\"BusTrip\",\"busNumber\":\"FLX30\",\"busName\":\"Berlin Ostbahnhof\",\"busCompany\":{\"@type\":\"Organization\",\"name\":\"Flix\"},\"departureBusStop\":{\"@type\":\"BusStop\",\"name\":\"Hannover Hauptbahnhof (FlixTrain)\",\"address\":{\"@type\":\"PostalAddress\",\"streetAddress\":\"Ernst-August-Platz 1\",\"addressLocality\":\"Hannover\",\"postalCode\":\"30159\",\"addressCountry\":\"DE\"}},\"departureTime\":\"2024-06-04T11:44:00+0200\",\"arrivalBusStop\":{\"@type\":\"BusStop\",\"name\":\"Berlin Ostbahnhof (FlixTrain)\",\"address\":{\"@type\":\"PostalAddress\",\"streetAddress\":\"Koppenstr. 3\",\"addressLocality\":\"Berlin\",\"postalCode\":\"10243\",\"addressCountry\":\"DE\"}},\"arrivalTime\":\"2024-06-04T13:44:00+0200\"},\"reservedTicket\":{\"@type\":\"Ticket\",\"ticketNumber\":\"3165000001\",\"ticketToken\":\"https://shop.global.flixbus.com/pdfqr/3165000001/00000000000000000000000000000000000000000000000000?mfb_source=email\",\"underName\":{\"@type\":\"Person\",\"name\":\"Max Mustermann\"},\"ticketedSeat\":{\"@type\":\"Seat\",\"seatingType\":\"Coach\",\"seatNumber\":\"6C\"}}}]</script></head><body></body></html>"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "flixbus-invoice",
|
||||||
|
"sender": "flixbus.com",
|
||||||
|
"source": "real",
|
||||||
|
"mailDate": "2024-05-28T15:18:20Z",
|
||||||
|
"html": "<!DOCTYPE html><html><head><meta charset=\"utf-8\"><script type=\"application/ld+json\">[{\"@context\":\"http:\\/\\/schema.org\",\"@type\":\"BusReservation\",\"reservationNumber\":3165000001,\"underName\":{\"@type\":\"Person\",\"name\":\"Max Mustermann\",\"email\":\"kunde@example.org\"},\"bookingAgent\":{\"@type\":\"Organization\",\"name\":\"Flix SE\",\"url\":\"http:\\/\\/shop.flixbus.de\"},\"bookingTime\":\"2024-05-28T17:18:16+0200\",\"modifiedTime\":\"2024-05-28T17:18:17+0200\",\"reservationStatus\":\"http:\\/\\/schema.org\\/ReservationConfirmed\",\"reservationFor\":{\"@type\":\"BusTrip\",\"busNumber\":\"FLX30\",\"busName\":\"Berlin Ostbahnhof\",\"busCompany\":{\"@type\":\"Organization\",\"name\":\"Flix SE\"},\"departureBusStop\":{\"@type\":\"BusStop\",\"name\":\"Hannover Hauptbahnhof (FlixTrain)\",\"address\":{\"@type\":\"PostalAddress\",\"streetAddress\":\"Ernst-August-Platz 1\",\"addressLocality\":\"Hannover\",\"addressRegion\":\"\",\"postalCode\":\"30159\",\"addressCountry\":\"DE\"},\"directions\":\"Wagennummerierung beginnt an der Zugspitze. Wagen 100 befindet sich in der Zugmitte.\"},\"departureTime\":\"2024-06-04T11:44:00+0200\",\"arrivalBusStop\":{\"@type\":\"BusStop\",\"name\":\"Berlin Ostbahnhof (FlixTrain)\",\"address\":{\"@type\":\"PostalAddress\",\"streetAddress\":\"Koppenstr. 3\",\"addressLocality\":\"Berlin\",\"addressRegion\":\"\",\"postalCode\":\"10243\",\"addressCountry\":\"DE\"},\"directions\":\"\"},\"arrivalTime\":\"2024-06-04T13:44:00+0200\"},\"reservedTicket\":{\"@type\":\"Ticket\",\"ticketNumber\":3165000001,\"ticketToken\":\"https:\\/\\/api.flixbus.com\\/qrcode\\/de\\/3165000001\\/00000000000000000000000000000000000000000000000000\",\"price\":\"14.9700\",\"priceCurrency\":\"EUR\",\"underName\":{\"@type\":\"Person\",\"name\":\"Max Mustermann\"}}},{\"@context\":\"http:\\/\\/schema.org\",\"@type\":\"BusReservation\",\"reservationNumber\":3165000001,\"underName\":{\"@type\":\"Person\",\"name\":\"Erika Mustermann\",\"email\":\"kunde@example.org\"},\"bookingAgent\":{\"@type\":\"Organization\",\"name\":\"Flix SE\",\"url\":\"http:\\/\\/shop.flixbus.de\"},\"bookingTime\":\"2024-05-28T17:18:16+0200\",\"modifiedTime\":\"2024-05-28T17:18:17+0200\",\"reservationStatus\":\"http:\\/\\/schema.org\\/ReservationConfirmed\",\"reservationFor\":{\"@type\":\"BusTrip\",\"busNumber\":\"FLX30\",\"busName\":\"Berlin Ostbahnhof\",\"busCompany\":{\"@type\":\"Organization\",\"name\":\"Flix SE\"},\"departureBusStop\":{\"@type\":\"BusStop\",\"name\":\"Hannover Hauptbahnhof (FlixTrain)\",\"address\":{\"@type\":\"PostalAddress\",\"streetAddress\":\"Ernst-August-Platz 1\",\"addressLocality\":\"Hannover\",\"addressRegion\":\"\",\"postalCode\":\"30159\",\"addressCountry\":\"DE\"},\"directions\":\"Wagennummerierung beginnt an der Zugspitze. Wagen 100 befindet sich in der Zugmitte.\"},\"departureTime\":\"2024-06-04T11:44:00+0200\",\"arrivalBusStop\":{\"@type\":\"BusStop\",\"name\":\"Berlin Ostbahnhof (FlixTrain)\",\"address\":{\"@type\":\"PostalAddress\",\"streetAddress\":\"Koppenstr. 3\",\"addressLocality\":\"Berlin\",\"addressRegion\":\"\",\"postalCode\":\"10243\",\"addressCountry\":\"DE\"},\"directions\":\"\"},\"arrivalTime\":\"2024-06-04T13:44:00+0200\"},\"reservedTicket\":{\"@type\":\"Ticket\",\"ticketNumber\":3165000001,\"ticketToken\":\"https:\\/\\/api.flixbus.com\\/qrcode\\/de\\/3165000001\\/00000000000000000000000000000000000000000000000000\",\"price\":\"14.9700\",\"priceCurrency\":\"EUR\",\"underName\":{\"@type\":\"Person\",\"name\":\"Erika Mustermann\"}}}]</script></head><body></body></html>"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "kaufland-order-1",
|
||||||
|
"sender": "kaufland-marktplatz.de",
|
||||||
|
"source": "real",
|
||||||
|
"mailDate": "2024-10-07T20:20:57Z",
|
||||||
|
"html": "<!DOCTYPE html><html><head><meta charset=\"utf-8\"></head><body><span itemscope itemtype=\"http://schema.org/Order\"> <span itemprop=\"merchant\" itemscope itemtype=\"http://schema.org/Organization\"> <meta itemprop=\"name\" content=\"Kaufland.de\"> <link itemprop=\"url\" href=\"https://www.kaufland.de\"> </span> <span itemprop=\"potentialAction\" itemscope itemtype=\"http://schema.org/ViewAction\"> <link itemprop=\"target\" href=\"https://www.kaufland.de/account/orders?utm_medium=email&utm_content=purchase_confirmation_gmu_de-de&utm_source=real&utm_campaign=customer#100000000000001\"> <meta itemprop=\"name\" content=\"Bestellung ansehen\"> <meta itemprop=\"url\" content=\"https://www.kaufland.de/account/orders?utm_medium=email&utm_content=purchase_confirmation_gmu_de-de&utm_source=real&utm_campaign=customer#100000000000001\"> </span> <meta itemprop=\"orderNumber\" content=\"M100AA1\"> <meta itemprop=\"priceCurrency\" content=\"EUR\"> <meta itemprop=\"price\" content=\"399,00 €\"> <span itemprop=\"acceptedOffer\" itemscope itemtype=\"http://schema.org/Offer\"> <span itemprop=\"itemOffered\" itemscope itemtype=\"http://schema.org/Product\"> <meta itemprop=\"name\" content=\"Creality K1 3D-Drucker\"> <meta itemprop=\"image\" content=\"https://media.cdn.kaufland.de/product-images/100x100/4af2c802b184554243cd708e9d83a180.jpg\"> <meta itemprop=\"sku\" content=\"464541313\"> <meta itemprop=\"url\" content=\"https://www.kaufland.de/product/464541313/?utm_medium=email&utm_content=purchase_confirmation_gmu_de-de&utm_source=real&utm_campaign=customer\"> </span> <meta itemprop=\"price\" content=\"399,00 €\"> <meta itemprop=\"priceCurrency\" content=\"EUR\"> <span itemprop=\"eligibleQuantity\" itemscope itemtype=\"http://schema.org/QuantitativeValue\"> <meta itemprop=\"value\" content=\"1\"> </span> </span> <meta itemprop=\"orderStatus\" content=\"\"> <meta itemprop=\"url\" content=\"https://www.kaufland.de/account/orders?utm_medium=email&utm_content=purchase_confirmation_gmu_de-de&utm_source=real&utm_campaign=customer#100000000000001\"> <meta itemprop=\"orderStatus\" content=\"http://schema.org/OrderProcessing\"> <span itemprop=\"priceSpecification\" itemscope itemtype=\"http://schema.org/PriceSpecification\"> <meta itemprop=\"price\" content=\"0,00 €\"> <meta itemprop=\"priceCurrency\" content=\"EUR\"> </span> </span></body></html>"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "kaufland-parcel-1",
|
||||||
|
"sender": "kaufland-marktplatz.de",
|
||||||
|
"source": "real",
|
||||||
|
"mailDate": "2024-10-08T13:50:25Z",
|
||||||
|
"html": "<!DOCTYPE html><html><head><meta charset=\"utf-8\"></head><body><span itemscope=\"\" itemtype=\"http://schema.org/ParcelDelivery\"> <span itemprop=\"deliveryAddress\" itemscope=\"\" itemtype=\"http://schema.org/PostalAddress\"> <meta itemprop=\"name\" content=\"Erika Mustermann\"> <meta itemprop=\"streetAddress\" content=\"Musterweg 1\"> <meta itemprop=\"addressLocality\" content=\"Musterstadt\"> <meta itemprop=\"addressRegion\" content=\"Musterstadt\"> <meta itemprop=\"addressCountry\" content=\"DE\"> <meta itemprop=\"postalCode\" content=\"12345\"> </span> <meta itemprop=\"expectedArrivalUntil\" content=\"2024-10-13CEST03:10:23\"> <span itemprop=\"itemShipped\" itemscope=\"\" itemtype=\"http://schema.org/Product\"> <meta itemprop=\"name\" content=\"Creality K1 3D-Drucker\"> <link itemprop=\"url\" href=\"https://www.kaufland.de/product/464541313/?utm_medium=email&utm_content=purchase_sent_confirmation&utm_source=real&utm_campaign=customer\"> <link itemprop=\"image\" href=\"https://media.cdn.kaufland.de/product-images/100x100/4af2c802b184554243cd708e9d83a180.jpg\"> <meta itemprop=\"sku\" content=\"464541313\"> </span> <span itemprop=\"partOfOrder\" itemscope=\"\" itemtype=\"http://schema.org/Order\"> <meta itemprop=\"orderNumber\" content=\"M100AA1\"> <span itemprop=\"merchant\" itemscope=\"\" itemtype=\"http://schema.org/Organization\"> <meta itemprop=\"name\" content=\"Kaufland.de\"> <link itemprop=\"sameAs\" href=\"https://www.kaufland.de\"> </span> <link itemprop=\"orderStatus\" href=\"http://schema.org/OrderInTransit\"> </span> </span></body></html>"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "kaufland-order-2",
|
||||||
|
"sender": "kaufland-marktplatz.de",
|
||||||
|
"source": "real",
|
||||||
|
"mailDate": "2024-11-11T14:33:42Z",
|
||||||
|
"html": "<!DOCTYPE html><html><head><meta charset=\"utf-8\"></head><body><span itemscope itemtype=\"http://schema.org/Order\"> <span itemprop=\"merchant\" itemscope itemtype=\"http://schema.org/Organization\"> <meta itemprop=\"name\" content=\"Kaufland.de\"> <link itemprop=\"url\" href=\"https://www.kaufland.de\"> </span> <span itemprop=\"potentialAction\" itemscope itemtype=\"http://schema.org/ViewAction\"> <link itemprop=\"target\" href=\"https://www.kaufland.de/account/orders?utm_medium=email&utm_content=purchase_confirmation_gmu_de-de&utm_source=real&utm_campaign=customer#100000000000002\"> <meta itemprop=\"name\" content=\"Bestellung ansehen\"> <meta itemprop=\"url\" content=\"https://www.kaufland.de/account/orders?utm_medium=email&utm_content=purchase_confirmation_gmu_de-de&utm_source=real&utm_campaign=customer#100000000000002\"> </span> <meta itemprop=\"orderNumber\" content=\"M200BB2\"> <meta itemprop=\"priceCurrency\" content=\"EUR\"> <meta itemprop=\"price\" content=\"29,99 €\"> <span itemprop=\"acceptedOffer\" itemscope itemtype=\"http://schema.org/Offer\"> <span itemprop=\"itemOffered\" itemscope itemtype=\"http://schema.org/Product\"> <meta itemprop=\"name\" content=\"Atomstack Maker AC1 Kamera 5 Megapixel 400x400mm Fotografiebereich für Atomstack NEJE Tool Graviermaschine\"> <meta itemprop=\"image\" content=\"https://media.cdn.kaufland.de/product-images/100x100/29e4eac9c493c96fbe5d122f305ea21b.jpg\"> <meta itemprop=\"sku\" content=\"460132763\"> <meta itemprop=\"url\" content=\"https://www.kaufland.de/product/460132763/?utm_medium=email&utm_content=purchase_confirmation_gmu_de-de&utm_source=real&utm_campaign=customer\"> </span> <meta itemprop=\"price\" content=\"29,99 €\"> <meta itemprop=\"priceCurrency\" content=\"EUR\"> <span itemprop=\"eligibleQuantity\" itemscope itemtype=\"http://schema.org/QuantitativeValue\"> <meta itemprop=\"value\" content=\"1\"> </span> </span> <meta itemprop=\"orderStatus\" content=\"\"> <meta itemprop=\"url\" content=\"https://www.kaufland.de/account/orders?utm_medium=email&utm_content=purchase_confirmation_gmu_de-de&utm_source=real&utm_campaign=customer#100000000000002\"> <meta itemprop=\"orderStatus\" content=\"http://schema.org/OrderProcessing\"> <span itemprop=\"priceSpecification\" itemscope itemtype=\"http://schema.org/PriceSpecification\"> <meta itemprop=\"price\" content=\"0,00 €\"> <meta itemprop=\"priceCurrency\" content=\"EUR\"> </span> </span></body></html>"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "kaufland-parcel-2",
|
||||||
|
"sender": "kaufland-marktplatz.de",
|
||||||
|
"source": "real",
|
||||||
|
"mailDate": "2024-11-12T10:20:17Z",
|
||||||
|
"html": "<!DOCTYPE html><html><head><meta charset=\"utf-8\"></head><body><span itemscope=\"\" itemtype=\"http://schema.org/ParcelDelivery\"> <span itemprop=\"deliveryAddress\" itemscope=\"\" itemtype=\"http://schema.org/PostalAddress\"> <meta itemprop=\"name\" content=\"Erika Mustermann\"> <meta itemprop=\"streetAddress\" content=\"Musterweg 1\"> <meta itemprop=\"addressLocality\" content=\"Musterstadt\"> <meta itemprop=\"addressRegion\" content=\"Musterstadt\"> <meta itemprop=\"addressCountry\" content=\"DE\"> <meta itemprop=\"postalCode\" content=\"12345\"> </span> <meta itemprop=\"expectedArrivalUntil\" content=\"2024-11-17CET11:11:17\"> <span itemprop=\"itemShipped\" itemscope=\"\" itemtype=\"http://schema.org/Product\"> <meta itemprop=\"name\" content=\"Atomstack Maker AC1 Kamera 5 Megapixel 400x400mm Fotografiebereich für Atomstack NEJE Tool Graviermaschine\"> <link itemprop=\"url\" href=\"https://www.kaufland.de/product/460132763/?utm_medium=email&utm_content=purchase_sent_confirmation&utm_source=real&utm_campaign=customer\"> <link itemprop=\"image\" href=\"https://media.cdn.kaufland.de/product-images/100x100/29e4eac9c493c96fbe5d122f305ea21b.jpg\"> <meta itemprop=\"sku\" content=\"460132763\"> </span> <span itemprop=\"partOfOrder\" itemscope=\"\" itemtype=\"http://schema.org/Order\"> <meta itemprop=\"orderNumber\" content=\"M200BB2\"> <span itemprop=\"merchant\" itemscope=\"\" itemtype=\"http://schema.org/Organization\"> <meta itemprop=\"name\" content=\"Kaufland.de\"> <link itemprop=\"sameAs\" href=\"https://www.kaufland.de\"> </span> <link itemprop=\"orderStatus\" href=\"http://schema.org/OrderInTransit\"> </span> </span></body></html>"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "ahoi-event",
|
||||||
|
"sender": "ahoi.cloud",
|
||||||
|
"source": "real",
|
||||||
|
"mailDate": "2021-05-28T13:30:10Z",
|
||||||
|
"html": "<!DOCTYPE html><html><head><meta charset=\"utf-8\"><script type=\"application/ld+json\">[{\"@context\":\"https:\\/\\/schema.org\",\"@type\":\"EventReservation\",\"underName\":{\"@type\":\"Person\",\"name\":\"Erika Mustermann\"},\"reservationNumber\":\"AB12CD\",\"reservationStatus\":\"http:\\/\\/schema.org\\/Confirmed\",\"modifiedTime\":\"2021-05-28T15:30:04+02:00\",\"modifyReservationUrl\":\"http:\\/\\/default\\/buchung\\/AB12CD\\/Mustermann\",\"ticketDownloadUrl\":\"http:\\/\\/default\\/ticket\\/00000000000000000000000000000000.pdf\",\"ticketToken\":\"http:\\/\\/default\\/ticket\\/00000000000000000000000000000000.pdf\",\"ticketNumber\":\"AB12CD-1\",\"reservationFor\":{\"@type\":\"Event\",\"name\":\"Grafikdesign: Illustrator-Workshop\",\"startDate\":\"2021-07-18T14:00:00+02:00\",\"endDate\":\"2021-07-18T19:00:00+02:00\"}}]</script></head><body></body></html>"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "k4g-order",
|
||||||
|
"sender": "k4g.com",
|
||||||
|
"source": "real",
|
||||||
|
"mailDate": "2021-12-04T22:05:29Z",
|
||||||
|
"html": "<!DOCTYPE html><html><head><meta charset=\"utf-8\"><script type=\"application/ld+json\">{\"@context\":\"http:\\/\\/schema.org\",\"@type\":\"Order\",\"merchant\":{\"@type\":\"Organization\",\"name\":\"K4G\"},\"orderNumber\":\"A1B2C3D4\",\"priceCurrency\":\"EUR\",\"price\":25.99,\"acceptedOffer\":[{\"@type\":\"Offer\",\"itemOffered\":{\"@type\":\"Product\",\"name\":\"Farming Simulator 22 Steam CD Key\",\"sku\":\"892E8BE9\",\"url\":\"https:\\/\\/k4g.com\\/product\\/farming-simulator-22-steam-global-cd-key-6C745D16\",\"image\":\"https:\\/\\/cdn.k4g.com\\/files\\/cover\\/3b1c373f87c917c637c21b681a9b5464.jpeg\"},\"price\":25.99,\"priceCurrency\":\"EUR\",\"eligibleQuantity\":{\"@type\":\"QuantitativeValue\",\"value\":1},\"seller\":{\"@type\":\"Organization\",\"name\":\"Fenyx_Entertainment\"}}],\"url\":\"https:\\/\\/k4g.com\\/order?id=A1B2C3D4&token=0000000000000000000000000000000000000000000000000000000000000000\",\"priceSpecification\":{\"@type\":\"PriceSpecification\",\"price\":25.99,\"priceCurrency\":\"EUR\",\"valueAddedTaxIncluded\":true},\"orderStatus\":\"OrderProcessing\"}</script></head><body></body></html>"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "uber-order-1",
|
||||||
|
"sender": "uber.com",
|
||||||
|
"source": "real",
|
||||||
|
"mailDate": "2025-01-22T22:39:53Z",
|
||||||
|
"html": "<!DOCTYPE html><html><head><meta charset=\"utf-8\"></head><body><div itemscope itemtype=\"http://schema.org/Order\" style=\"display:none!important\"><div itemprop=\"merchant\" itemscope itemtype=\"http://schema.org/Organization\"><meta itemprop=\"name\" content=\"UberEATS\"></div></div></body></html>"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "uber-order-2",
|
||||||
|
"sender": "uber.com",
|
||||||
|
"source": "real",
|
||||||
|
"mailDate": "2025-02-06T00:08:10Z",
|
||||||
|
"html": "<!DOCTYPE html><html><head><meta charset=\"utf-8\"></head><body><div itemscope itemtype=\"http://schema.org/Order\" style=\"display:none!important\"><div itemprop=\"merchant\" itemscope itemtype=\"http://schema.org/Organization\"><meta itemprop=\"name\" content=\"UberEATS\"></div></div></body></html>"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "zave-newsletter",
|
||||||
|
"sender": "p.zave.it",
|
||||||
|
"source": "real",
|
||||||
|
"mailDate": "2024-11-12T00:27:23Z",
|
||||||
|
"html": "<!DOCTYPE html><html><head><meta charset=\"utf-8\"><script type=\"application/ld+json\">\r\n{\r\n \"@context\": \"http://schema.org\",\r\n \"@type\": \"FlightReservation\",\r\n \"reservationNumber\": \"RXJ34P\",\r\n \"reservationStatus\": \"http://schema.org/Confirmed\",\r\n \"underName\": {\r\n \"@type\": \"Person\",\r\n \"name\": \"Eva Green\"\r\n },\r\n \"reservationFor\": {\r\n \"@type\": \"Flight\",\r\n \"flightNumber\": \"110\",\r\n \"airline\": {\r\n \"@type\": \"Airline\",\r\n \"name\": \"United\",\r\n \"iataCode\": \"UA\"\r\n },\r\n \"departureAirport\": {\r\n \"@type\": \"Airport\",\r\n \"name\": \"San Francisco Airport\",\r\n \"iataCode\": \"SFO\"\r\n },\r\n \"departureTime\": \"2027-03-04T20:15:00-08:00\",\r\n \"arrivalAirport\": {\r\n \"@type\": \"Airport\",\r\n \"name\": \"John F. Kennedy International Airport\",\r\n \"iataCode\": \"JFK\"\r\n },\r\n \"arrivalTime\": \"2027-03-05T06:30:00-05:00\"\r\n }\r\n}\r\n</script></head><body></body></html>"
|
||||||
|
}
|
||||||
|
]
|
||||||
@@ -0,0 +1,336 @@
|
|||||||
|
// Mirrors webmail lib/structured-mail/__tests__/fixtures/synthetic-mails.ts at fa324170a; keep the two in step.
|
||||||
|
/**
|
||||||
|
* Self-written fixtures, modelled on Google's markup reference and on the
|
||||||
|
* kinds of broken input KItinerary's tests cover (written from scratch, not
|
||||||
|
* copied). Booking references and names are made up. `expected` lives in
|
||||||
|
* structured-mail-fixtures.test.ts.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface MailFixture {
|
||||||
|
id: string;
|
||||||
|
sender: string;
|
||||||
|
source: 'real' | 'synthetic';
|
||||||
|
mailDate: string;
|
||||||
|
html: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const MAIL_DATE = '2026-10-30T09:00:00Z';
|
||||||
|
const page = (head: string, body = '') => `<!DOCTYPE html><html><head><meta charset="utf-8">${head}</head><body><p>Thank you for your booking.</p>${body}</body></html>`;
|
||||||
|
const script = (text: string) => `<script type="application/ld+json">${text}</script>`;
|
||||||
|
const ld = (value: unknown) => script(JSON.stringify(value));
|
||||||
|
const C = 'http://schema.org';
|
||||||
|
|
||||||
|
const person = (name: string) => ({ '@type': 'Person', name });
|
||||||
|
const airport = (name: string, iataCode: string) => ({ '@type': 'Airport', name, iataCode });
|
||||||
|
|
||||||
|
function flightRes(o: {
|
||||||
|
ref: string; name: string; seat?: string; airline?: [string, string]; number: string;
|
||||||
|
from: [string, string]; dep: string; to: [string, string]; arr: string; status?: string; checkin?: string;
|
||||||
|
}) {
|
||||||
|
return {
|
||||||
|
'@context': C, '@type': 'FlightReservation', reservationNumber: o.ref,
|
||||||
|
reservationStatus: `http://schema.org/${o.status ?? 'ReservationConfirmed'}`,
|
||||||
|
underName: person(o.name),
|
||||||
|
...(o.seat ? { airplaneSeat: o.seat } : {}),
|
||||||
|
...(o.checkin ? { checkinUrl: o.checkin } : {}),
|
||||||
|
reservationFor: {
|
||||||
|
'@type': 'Flight', flightNumber: o.number,
|
||||||
|
airline: { '@type': 'Airline', name: (o.airline ?? ['Lufthansa', 'LH'])[0], iataCode: (o.airline ?? ['Lufthansa', 'LH'])[1] },
|
||||||
|
departureAirport: airport(...o.from), departureTime: o.dep,
|
||||||
|
arrivalAirport: airport(...o.to), arrivalTime: o.arr,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const fra: [string, string] = ['Frankfurt Airport', 'FRA'];
|
||||||
|
const jfk: [string, string] = ['John F. Kennedy International Airport', 'JFK'];
|
||||||
|
const out1 = { number: '400', from: fra, dep: '2026-12-01T10:15:00+01:00', to: jfk, arr: '2026-12-01T13:05:00-05:00' };
|
||||||
|
const back1 = { number: '401', from: jfk, dep: '2026-12-08T17:30:00-05:00', to: fra, arr: '2026-12-09T07:20:00+01:00' };
|
||||||
|
|
||||||
|
const eurowingsLegs = [
|
||||||
|
flightRes({ ref: 'EW7Q2K', name: 'Erika Mustermann', airline: ['Eurowings', 'EW'], number: 'EW582', from: ['Köln/Bonn', 'CGN'], dep: '2026-11-20T06:40:00+01:00', to: ['Palma de Mallorca', 'PMI'], arr: '2026-11-20T09:05:00+01:00', seat: '3A' }),
|
||||||
|
flightRes({ ref: 'EW7Q2K', name: 'Erika Mustermann', airline: ['Eurowings', 'EW'], number: 'EW583', from: ['Palma de Mallorca', 'PMI'], dep: '2026-11-27T10:00:00+01:00', to: ['Köln/Bonn', 'CGN'], arr: '2026-11-27T12:25:00+01:00', seat: '14F' }),
|
||||||
|
];
|
||||||
|
|
||||||
|
const voloteaPax = ['Erika Mustermann', 'Max Mustermann'].map((name, i) => flightRes({
|
||||||
|
ref: 'V0L8TA', name, airline: ['Volotea', 'V7'], number: '1520', seat: `1${i}C`,
|
||||||
|
from: ['Venezia', 'VCE'], dep: '2026-12-12T14:10:00+01:00', to: ['Nantes', 'NTE'], arr: '2026-12-12T16:15:00+01:00',
|
||||||
|
}));
|
||||||
|
|
||||||
|
const hotel = {
|
||||||
|
'@context': C, '@type': 'LodgingReservation', reservationNumber: 'HB-58213',
|
||||||
|
reservationStatus: 'http://schema.org/Confirmed', underName: person('Erika Mustermann'),
|
||||||
|
reservationFor: {
|
||||||
|
'@type': 'Hotel', name: 'Hotel Am Markt', telephone: '+49 30 1234567',
|
||||||
|
address: { '@type': 'PostalAddress', streetAddress: 'Marktplatz 1', addressLocality: 'Berlin', postalCode: '10178', addressCountry: 'DE' },
|
||||||
|
},
|
||||||
|
checkinDate: '2026-12-04T15:00:00+01:00', checkoutDate: '2026-12-06T11:00:00+01:00',
|
||||||
|
modifyReservationUrl: 'https://www.hotel-booking.example.com/manage/HB-58213',
|
||||||
|
};
|
||||||
|
|
||||||
|
const microdataFlight = (o: { ref: string; name: string; number: string; code: string; airline: string; from: [string, string]; dep: string; to: [string, string]; arr?: string }) => `
|
||||||
|
<div itemscope itemtype="http://schema.org/FlightReservation">
|
||||||
|
<meta itemprop="reservationNumber" content="${o.ref}">
|
||||||
|
<link itemprop="reservationStatus" href="http://schema.org/Confirmed">
|
||||||
|
<div itemprop="underName" itemscope itemtype="http://schema.org/Person"><meta itemprop="name" content="${o.name}"></div>
|
||||||
|
<div itemprop="reservationFor" itemscope itemtype="http://schema.org/Flight">
|
||||||
|
<meta itemprop="flightNumber" content="${o.number}">
|
||||||
|
<div itemprop="airline" itemscope itemtype="http://schema.org/Airline"><meta itemprop="name" content="${o.airline}"><meta itemprop="iataCode" content="${o.code}"></div>
|
||||||
|
<div itemprop="departureAirport" itemscope itemtype="http://schema.org/Airport"><meta itemprop="name" content="${o.from[0]}"><meta itemprop="iataCode" content="${o.from[1]}"></div>
|
||||||
|
<meta itemprop="departureTime" content="${o.dep}">
|
||||||
|
<div itemprop="arrivalAirport" itemscope itemtype="http://schema.org/Airport"><meta itemprop="name" content="${o.to[0]}"><meta itemprop="iataCode" content="${o.to[1]}"></div>
|
||||||
|
${o.arr ? `<meta itemprop="arrivalTime" content="${o.arr}">` : ''}
|
||||||
|
</div>
|
||||||
|
</div>`;
|
||||||
|
|
||||||
|
const parcel = {
|
||||||
|
'@context': C, '@type': 'ParcelDelivery',
|
||||||
|
carrier: { '@type': 'Organization', name: 'DHL' }, trackingNumber: '00340434161234567890',
|
||||||
|
trackingUrl: 'https://www.dhl.de/de/privatkunden/pakete-empfangen/verfolgen.html?piececode=00340434161234567890',
|
||||||
|
expectedArrivalUntil: '2026-11-03T18:00:00+01:00',
|
||||||
|
itemShipped: [{ '@type': 'Product', name: 'Wanderschuhe', image: 'https://img.shop.example.com/p/1.jpg' }, { '@type': 'Product', name: 'Socken' }],
|
||||||
|
partOfOrder: { '@type': 'Order', orderNumber: 'SO-449120', merchant: { '@type': 'Organization', name: 'Outdoor Shop' }, orderStatus: 'http://schema.org/OrderInTransit' },
|
||||||
|
};
|
||||||
|
|
||||||
|
const orderObj = {
|
||||||
|
'@context': C, '@type': 'Order', merchant: { '@type': 'Organization', name: 'Outdoor Shop' }, orderNumber: 'SO-449120',
|
||||||
|
orderStatus: 'http://schema.org/OrderProcessing', priceCurrency: 'EUR', price: '129.90',
|
||||||
|
acceptedOffer: [
|
||||||
|
{ '@type': 'Offer', itemOffered: { '@type': 'Product', name: 'Wanderschuhe' }, price: '119.90', priceCurrency: 'EUR', eligibleQuantity: { '@type': 'QuantitativeValue', value: '1' } },
|
||||||
|
{ '@type': 'Offer', itemOffered: { '@type': 'Product', name: 'Socken' }, price: '5.00', priceCurrency: 'EUR', eligibleQuantity: { '@type': 'QuantitativeValue', value: '2' } },
|
||||||
|
],
|
||||||
|
url: 'https://www.outdoor-shop.example.com/account/orders/SO-449120',
|
||||||
|
};
|
||||||
|
|
||||||
|
export const syntheticMails: MailFixture[] = [
|
||||||
|
// Two passengers, two legs: four objects, one booking.
|
||||||
|
{
|
||||||
|
id: 'flight-two-passengers-two-legs', sender: 'airline-a.example', source: 'synthetic', mailDate: MAIL_DATE,
|
||||||
|
html: page(ld([
|
||||||
|
flightRes({ ref: 'QX7K2M', name: 'Erika Mustermann', seat: '12A', ...out1, checkin: 'https://www.airline-a.example.com/checkin?pnr=QX7K2M' }),
|
||||||
|
flightRes({ ref: 'QX7K2M', name: 'Max Mustermann', seat: '12B', ...out1, checkin: 'https://www.airline-a.example.com/checkin?pnr=QX7K2M' }),
|
||||||
|
flightRes({ ref: 'QX7K2M', name: 'Erika Mustermann', seat: '31K', ...back1 }),
|
||||||
|
flightRes({ ref: 'QX7K2M', name: 'Max Mustermann', seat: '31J', ...back1 }),
|
||||||
|
])),
|
||||||
|
},
|
||||||
|
// Eurowings-style: objects in a top-level array without commas between them.
|
||||||
|
{
|
||||||
|
id: 'eurowings-missing-commas', sender: 'airline-b.example', source: 'synthetic', mailDate: MAIL_DATE,
|
||||||
|
html: page(script(`[${eurowingsLegs.map((l) => JSON.stringify(l, null, 1)).join('\n')}]`)),
|
||||||
|
},
|
||||||
|
// Volotea-style: several objects without the array brackets.
|
||||||
|
{
|
||||||
|
id: 'volotea-missing-brackets', sender: 'airline-c.example', source: 'synthetic', mailDate: MAIL_DATE,
|
||||||
|
html: page(script(voloteaPax.map((p) => JSON.stringify(p)).join(',\n'))),
|
||||||
|
},
|
||||||
|
// Eventbrite-style trailing commas.
|
||||||
|
{
|
||||||
|
id: 'eventbrite-trailing-commas', sender: 'tickets.example', source: 'synthetic', mailDate: MAIL_DATE,
|
||||||
|
html: page(script(`{
|
||||||
|
"@context": "http://schema.org", "@type": "EventReservation", "reservationNumber": "EB-1029384756",
|
||||||
|
"underName": {"@type": "Person", "name": "Erika Mustermann",},
|
||||||
|
"reservationFor": {"@type": "Event", "name": "Jazz im Park", "startDate": "2026-11-14T19:00:00+01:00", "endDate": "2026-11-14T22:30:00+01:00",
|
||||||
|
"location": {"@type": "Place", "name": "Stadtpark", "address": {"@type": "PostalAddress", "streetAddress": "Parkweg 2", "addressLocality": "Hamburg", "addressCountry": "DE",},},},
|
||||||
|
"ticketDownloadUrl": "https://tickets.example.com/t/EB-1029384756",
|
||||||
|
}`)),
|
||||||
|
},
|
||||||
|
// Airbnb-style XML-entity-encoded JSON.
|
||||||
|
{
|
||||||
|
id: 'airbnb-entities', sender: 'stays.example', source: 'synthetic', mailDate: MAIL_DATE,
|
||||||
|
html: page(script(JSON.stringify({
|
||||||
|
'@context': C, '@type': 'LodgingReservation', reservationNumber: 'HMXK29Q4ZP',
|
||||||
|
reservationFor: { '@type': 'LodgingBusiness', name: 'Altbauwohnung am Kanal', address: { '@type': 'PostalAddress', addressLocality: 'Amsterdam', addressCountry: 'NL' } },
|
||||||
|
checkinDate: '2027-01-15', checkoutDate: '2027-01-19', underName: person('Erika Mustermann'),
|
||||||
|
}).replace(/"/g, '"'))),
|
||||||
|
},
|
||||||
|
// @graph wrapper around a hotel reservation and its organisation.
|
||||||
|
{
|
||||||
|
id: 'graph-hotel', sender: 'hotel-booking.example.com', source: 'synthetic', mailDate: MAIL_DATE,
|
||||||
|
html: page(ld({ '@context': C, '@graph': [{ ...hotel, '@context': undefined }, { '@type': 'Organization', name: 'Hotel Booking' }] })),
|
||||||
|
},
|
||||||
|
// Several @types; train seat with coach.
|
||||||
|
{
|
||||||
|
id: 'train-multiple-types', sender: 'rail.example', source: 'synthetic', mailDate: MAIL_DATE,
|
||||||
|
html: page(ld({
|
||||||
|
'@context': C, '@type': ['TrainReservation', 'Thing'], reservationNumber: 'RX4471', underName: 'Erika Mustermann',
|
||||||
|
reservationStatus: 'ReservationConfirmed',
|
||||||
|
reservationFor: {
|
||||||
|
'@type': 'TrainTrip', trainNumber: 'ICE 577', provider: { '@type': 'Organization', name: 'Rail Co' },
|
||||||
|
departureStation: { '@type': 'TrainStation', name: 'Hamburg Hbf', address: { '@type': 'PostalAddress', addressLocality: 'Hamburg', addressCountry: 'DE' } },
|
||||||
|
departureTime: '2026-11-18T08:01:00+01:00',
|
||||||
|
arrivalStation: { '@type': 'TrainStation', name: 'München Hbf', address: { '@type': 'PostalAddress', addressLocality: 'München', addressCountry: 'DE' } },
|
||||||
|
arrivalTime: '2026-11-18T13:58:00+01:00',
|
||||||
|
},
|
||||||
|
reservedTicket: { '@type': 'Ticket', ticketedSeat: { '@type': 'Seat', seatSection: '12', seatNumber: '45' } },
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
// reservationFor as an array: one reservation per event.
|
||||||
|
{
|
||||||
|
id: 'event-reservationfor-array', sender: 'tickets.example', source: 'synthetic', mailDate: MAIL_DATE,
|
||||||
|
html: page(ld({
|
||||||
|
'@context': C, '@type': 'EventReservation', reservationNumber: 'KT-7781', underName: person('Erika Mustermann'),
|
||||||
|
reservationFor: [
|
||||||
|
{ '@type': 'TheaterEvent', name: 'Faust I', startDate: '2026-11-21T19:30:00+01:00', location: { '@type': 'Place', name: 'Schauspielhaus', address: { addressLocality: 'Bochum', addressCountry: 'DE' } } },
|
||||||
|
{ '@type': 'TheaterEvent', name: 'Faust II', startDate: '2026-11-22T18:00:00+01:00', location: { '@type': 'Place', name: 'Schauspielhaus', address: { addressLocality: 'Bochum', addressCountry: 'DE' } } },
|
||||||
|
],
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
// Legacy property names: reservationId, checkinTime/checkoutTime, Url; string address.
|
||||||
|
{
|
||||||
|
id: 'hotel-legacy-names', sender: 'hotel-chain.example', source: 'synthetic', mailDate: MAIL_DATE,
|
||||||
|
html: page(ld({
|
||||||
|
'@context': 'https://schema.org/', '@type': 'LodgingReservation', reservationId: '84123376',
|
||||||
|
reservationFor: { '@type': 'Resort', name: 'Seehotel Weiß', address: 'Seestraße 3, 83727 Schliersee, Deutschland' },
|
||||||
|
checkinTime: '2026-12-27', checkoutTime: '2027-01-02', Url: 'https://hotel-chain.example.com/r/84123376',
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
// Restaurant subtype; string address.
|
||||||
|
{
|
||||||
|
id: 'restaurant-cafe', sender: 'tables.example', source: 'synthetic', mailDate: MAIL_DATE,
|
||||||
|
html: page(ld({
|
||||||
|
'@context': C, '@type': 'FoodEstablishmentReservation', reservationNumber: 'T-2231', reservationStatus: 'http://schema.org/Confirmed',
|
||||||
|
underName: person('Erika Mustermann'), partySize: 4, startTime: '2026-11-07T19:30:00+01:00',
|
||||||
|
reservationFor: { '@type': 'CafeOrCoffeeShop', name: 'Café Kranzler', address: 'Kurfürstendamm 18, 10719 Berlin' },
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
// Event with a date-only end (end of that day) and a country object.
|
||||||
|
{
|
||||||
|
id: 'event-date-only-end', sender: 'festival.example', source: 'synthetic', mailDate: MAIL_DATE,
|
||||||
|
html: page(ld({
|
||||||
|
'@context': C, '@type': 'EventReservation', reservationNumber: 'FS26-0042', underName: 'Erika Mustermann',
|
||||||
|
reservationFor: { '@type': 'Festival', name: 'Winterfest', startDate: '2026-12-11', endDate: '2026-12-13',
|
||||||
|
location: { '@type': 'Place', name: 'Messegelände', address: { '@type': 'PostalAddress', addressLocality: 'Wien', addressCountry: { '@type': 'Country', name: 'Austria' } } } },
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
// Lufthansa-style microdata with the arrival time missing: nothing.
|
||||||
|
{
|
||||||
|
id: 'microdata-flight-missing-arrival', sender: 'airline-d.example', source: 'synthetic', mailDate: MAIL_DATE,
|
||||||
|
html: page('', microdataFlight({ ref: 'MD9X1Z', name: 'Erika Mustermann', number: '1234', code: 'LH', airline: 'Lufthansa', from: ['München', 'MUC'], dep: '2026-11-10T07:00:00+01:00', to: ['Hamburg', 'HAM'] })),
|
||||||
|
},
|
||||||
|
// Austrian-style microdata, two legs as two items.
|
||||||
|
{
|
||||||
|
id: 'microdata-two-legs', sender: 'airline-e.example', source: 'synthetic', mailDate: MAIL_DATE,
|
||||||
|
html: page('',
|
||||||
|
microdataFlight({ ref: 'OS4K7P', name: 'Erika Mustermann', number: 'OS122', code: 'OS', airline: 'Austrian', from: ['Wien', 'VIE'], dep: '2026-11-25T06:50:00+01:00', to: ['Frankfurt', 'FRA'], arr: '2026-11-25T08:15:00+01:00' })
|
||||||
|
+ microdataFlight({ ref: 'OS4K7P', name: 'Erika Mustermann', number: 'OS125', code: 'OS', airline: 'Austrian', from: ['Frankfurt', 'FRA'], dep: '2026-11-27T19:40:00+01:00', to: ['Wien', 'VIE'], arr: '2026-11-27T21:00:00+01:00' })),
|
||||||
|
},
|
||||||
|
// Iberia-style microdata: itemprop lists and values from text and datetime.
|
||||||
|
{
|
||||||
|
id: 'microdata-itemprop-lists', sender: 'airline-f.example', source: 'synthetic', mailDate: MAIL_DATE,
|
||||||
|
html: page('', `
|
||||||
|
<table itemscope itemtype="http://schema.org/FlightReservation"><tr>
|
||||||
|
<td>Booking <span itemprop="reservationNumber">IB55XQ</span></td>
|
||||||
|
<td itemprop="underName" itemscope itemtype="http://schema.org/Person"><span itemprop="name">Erika Mustermann</span></td>
|
||||||
|
<td itemprop="reservationFor" itemscope itemtype="http://schema.org/Flight">
|
||||||
|
<span itemprop="airline provider" itemscope itemtype="http://schema.org/Airline"><span itemprop="name">Iberia</span> (<span itemprop="iataCode">IB</span>)</span>
|
||||||
|
<span itemprop="flightNumber">IB 3107</span>
|
||||||
|
<span itemprop="departureAirport" itemscope itemtype="http://schema.org/Airport"><span itemprop="name">Madrid</span> <span itemprop="iataCode">MAD</span></span>
|
||||||
|
<time itemprop="departureTime" datetime="2026-11-16T09:30:00+01:00">16 Nov 09:30</time>
|
||||||
|
<span itemprop="arrivalAirport" itemscope itemtype="http://schema.org/Airport"><span itemprop="name">Barcelona</span> <span itemprop="iataCode">BCN</span></span>
|
||||||
|
<time itemprop="arrivalTime" datetime="2026-11-16T10:50:00+01:00">10:50</time>
|
||||||
|
</td></tr></table>`),
|
||||||
|
},
|
||||||
|
// Dates-only stay at a Resort, https context.
|
||||||
|
{
|
||||||
|
id: 'hotel-dates-only', sender: 'stays.example', source: 'synthetic', mailDate: MAIL_DATE,
|
||||||
|
html: page(ld({
|
||||||
|
'@context': 'https://schema.org', '@type': 'LodgingReservation', reservationNumber: 'RS-1188',
|
||||||
|
reservationFor: { '@type': 'Resort', name: 'Strandresort Usedom', address: { '@type': 'PostalAddress', addressLocality: 'Heringsdorf', addressCountry: 'DE' } },
|
||||||
|
checkinDate: '2027-06-01', checkoutDate: '2027-06-08',
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
// Rental car.
|
||||||
|
{
|
||||||
|
id: 'rental-car', sender: 'cars.example', source: 'synthetic', mailDate: MAIL_DATE,
|
||||||
|
html: page(ld({
|
||||||
|
'@context': C, '@type': 'RentalCarReservation', reservationNumber: 'RC-99123', underName: person('Erika Mustermann'),
|
||||||
|
reservationFor: { '@type': 'Car', name: 'Golf', brand: { '@type': 'Brand', name: 'VW' }, rentalCompany: { '@type': 'Organization', name: 'Car Rental Co' } },
|
||||||
|
pickupLocation: { '@type': 'Place', name: 'Flughafen BER', address: { '@type': 'PostalAddress', addressLocality: 'Schönefeld', addressCountry: 'DE' } },
|
||||||
|
pickupTime: '2026-12-04T09:00:00+01:00',
|
||||||
|
dropoffLocation: { '@type': 'Place', name: 'Flughafen BER', address: { '@type': 'PostalAddress', addressLocality: 'Schönefeld', addressCountry: 'DE' } },
|
||||||
|
dropoffTime: '2026-12-07T18:00:00+01:00',
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
// A parcel on its own.
|
||||||
|
{
|
||||||
|
id: 'parcel', sender: 'outdoor-shop.example.com', source: 'synthetic', mailDate: MAIL_DATE,
|
||||||
|
html: page(ld(parcel)),
|
||||||
|
},
|
||||||
|
// Order and parcel for the same order number in one mail: one card.
|
||||||
|
{
|
||||||
|
id: 'order-and-parcel', sender: 'outdoor-shop.example.com', source: 'synthetic', mailDate: MAIL_DATE,
|
||||||
|
html: page(ld([orderObj, parcel])),
|
||||||
|
},
|
||||||
|
// Cancelled flight.
|
||||||
|
{
|
||||||
|
id: 'flight-cancelled', sender: 'airline-a.example', source: 'synthetic', mailDate: MAIL_DATE,
|
||||||
|
html: page(ld(flightRes({ ref: 'QX7K2M', name: 'Erika Mustermann', ...out1, status: 'ReservationCancelled' }))),
|
||||||
|
},
|
||||||
|
// Eastbound over the date line: lands earlier on the wall clock.
|
||||||
|
{
|
||||||
|
id: 'flight-date-line-eastbound', sender: 'airline-g.example', source: 'synthetic', mailDate: MAIL_DATE,
|
||||||
|
html: page(ld(flightRes({ ref: 'QF8H2L', name: 'Erika Mustermann', airline: ['Qantas', 'QF'], number: '11', from: ['Sydney', 'SYD'], dep: '2026-12-04T09:50:00+11:00', to: ['Los Angeles', 'LAX'], arr: '2026-12-04T06:20:00-08:00' }))),
|
||||||
|
},
|
||||||
|
|
||||||
|
// ---- doubtful: must give nothing
|
||||||
|
{
|
||||||
|
id: 'doubtful-arrival-before-departure', sender: 'airline-a.example', source: 'synthetic', mailDate: MAIL_DATE,
|
||||||
|
html: page(ld(flightRes({ ref: 'BAD001', name: 'Erika Mustermann', ...out1, arr: '2026-12-01T03:05:00-05:00' }))),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'doubtful-25-hour-flight', sender: 'airline-a.example', source: 'synthetic', mailDate: MAIL_DATE,
|
||||||
|
html: page(ld(flightRes({ ref: 'BAD002', name: 'Erika Mustermann', ...out1, arr: '2026-12-02T15:15:00-05:00' }))),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'doubtful-years-away', sender: 'stays.example', source: 'synthetic', mailDate: MAIL_DATE,
|
||||||
|
html: page(ld({ ...hotel, reservationNumber: 'BAD003', checkinDate: '2029-05-01', checkoutDate: '2029-05-03' })),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'doubtful-checkout-before-checkin', sender: 'stays.example', source: 'synthetic', mailDate: MAIL_DATE,
|
||||||
|
html: page(ld({ ...hotel, reservationNumber: 'BAD004', checkinDate: '2026-12-06', checkoutDate: '2026-12-04' })),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'doubtful-rental-missing-times', sender: 'cars.example', source: 'synthetic', mailDate: MAIL_DATE,
|
||||||
|
html: page(ld({ '@context': C, '@type': 'RentalCarReservation', reservationNumber: 'BAD005', reservationFor: { '@type': 'Car', name: 'Golf' }, pickupTime: '2026-12-04', dropoffTime: '2026-12-07' })),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'doubtful-bare-date-departure', sender: 'airline-a.example', source: 'synthetic', mailDate: MAIL_DATE,
|
||||||
|
html: page(ld(flightRes({ ref: 'BAD006', name: 'Erika Mustermann', ...out1, dep: '2026-12-01' }))),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'doubtful-google-example', sender: 'newsletter.example', source: 'synthetic', mailDate: '2026-12-20T09:00:00Z',
|
||||||
|
html: page(ld(flightRes({ ref: 'RXJ34P', name: 'Eva Green', airline: ['United', 'UA'], number: '110', seat: '9A', from: ['San Francisco Airport', 'SFO'], dep: '2027-03-04T20:15:00-08:00', to: ['John F. Kennedy International Airport', 'JFK'], arr: '2027-03-05T06:30:00-05:00' }))),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'doubtful-unknown-types-only', sender: 'newsletter.example', source: 'synthetic', mailDate: MAIL_DATE,
|
||||||
|
html: page(ld([{ '@context': C, '@type': 'PromotionCard', image: 'https://cdn.example.com/a.png' }, { '@context': C, '@type': 'DiscountOffer', description: '20% off' }])),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'doubtful-json-beyond-repair', sender: 'airline-a.example', source: 'synthetic', mailDate: MAIL_DATE,
|
||||||
|
html: page(script('{"@context": "http://schema.org", "@type": "FlightReservation", "reservationNumber": "BAD007", "reservationFor": {"@type": "Flight"')),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'doubtful-foreign-context', sender: 'airline-a.example', source: 'synthetic', mailDate: MAIL_DATE,
|
||||||
|
html: page(ld({ ...flightRes({ ref: 'BAD008', name: 'Erika Mustermann', ...out1 }), '@context': 'http://vocab.example.com/' })),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'doubtful-zone-mismatch', sender: 'airline-a.example', source: 'synthetic', mailDate: MAIL_DATE,
|
||||||
|
html: page(ld(flightRes({ ref: 'BAD009', name: 'Erika Mustermann', ...out1, dep: '2026-12-01T10:15:00+00:00', arr: '2026-12-01T13:05:00-05:00' }))),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
// One leg of a two-leg booking lacks its arrival: the whole booking goes.
|
||||||
|
id: 'doubtful-one-bad-leg', sender: 'airline-a.example', source: 'synthetic', mailDate: MAIL_DATE,
|
||||||
|
html: page(ld([
|
||||||
|
flightRes({ ref: 'BAD010', name: 'Erika Mustermann', ...out1 }),
|
||||||
|
flightRes({ ref: 'BAD010', name: 'Erika Mustermann', ...back1, arr: '' }),
|
||||||
|
])),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
// The same leg twice with different times: the parts disagree.
|
||||||
|
id: 'doubtful-conflicting-parts', sender: 'airline-a.example', source: 'synthetic', mailDate: MAIL_DATE,
|
||||||
|
html: page(ld([
|
||||||
|
flightRes({ ref: 'BAD011', name: 'Erika Mustermann', ...out1 }),
|
||||||
|
flightRes({ ref: 'BAD011', name: 'Max Mustermann', ...out1, arr: '2026-12-01T14:05:00-05:00' }),
|
||||||
|
])),
|
||||||
|
},
|
||||||
|
];
|
||||||
@@ -0,0 +1,189 @@
|
|||||||
|
// Mirrors webmail lib/structured-mail/__tests__/format.test.ts at 972e5f621; keep the two in step.
|
||||||
|
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
||||||
|
import {
|
||||||
|
formatAddress,
|
||||||
|
formatMoment,
|
||||||
|
formatMomentDate,
|
||||||
|
formatMomentTime,
|
||||||
|
formatMoney,
|
||||||
|
isOtherZone,
|
||||||
|
isWebLink,
|
||||||
|
legPlaceLabel,
|
||||||
|
legService,
|
||||||
|
momentOffsetMinutes,
|
||||||
|
nightsBetween,
|
||||||
|
routeTitle,
|
||||||
|
shipmentItems,
|
||||||
|
summarizeItems,
|
||||||
|
} from '../format';
|
||||||
|
import type { Moment, Shipment, TransportBooking, TransportLeg } from '../types';
|
||||||
|
|
||||||
|
const NOW = new Date('2026-10-30T12:00:00Z');
|
||||||
|
const en = { locale: 'en-US', hour12: false, now: NOW } as const;
|
||||||
|
|
||||||
|
// SFO 20:15 −08:00 = 04:15Z the next day; JFK 06:30 −05:00.
|
||||||
|
const SFO_DEP: Moment = { date: '2026-12-03', time: '20:15:00', offsetMinutes: -480, utc: '2026-12-04T04:15:00Z', zone: 'America/Los_Angeles' };
|
||||||
|
const BERLIN: Moment = { date: '2026-12-01', time: '10:15:00', offsetMinutes: 60, utc: '2026-12-01T09:15:00Z', zone: 'Europe/Berlin' };
|
||||||
|
// A floating time the airport table resolved: no offset in the mail, utc from the zone.
|
||||||
|
const NRT_RESOLVED: Moment = { date: '2026-12-02', time: '17:40:00', utc: '2026-12-02T08:40:00Z', zone: 'Asia/Tokyo' };
|
||||||
|
|
||||||
|
const originalTz = process.env.TZ;
|
||||||
|
afterEach(() => {
|
||||||
|
process.env.TZ = originalTz;
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('wall-clock formatting', () => {
|
||||||
|
for (const tz of ['UTC', 'America/Los_Angeles', 'Europe/Berlin', 'Asia/Tokyo', 'Pacific/Kiritimati']) {
|
||||||
|
it(`keeps 20:15 −08:00 at 20:15 on 3 Dec with TZ=${tz}`, () => {
|
||||||
|
process.env.TZ = tz;
|
||||||
|
expect(formatMomentTime(SFO_DEP, en)).toBe('20:15');
|
||||||
|
expect(formatMomentDate(SFO_DEP, en)).toBe('Thu, Dec 3');
|
||||||
|
expect(formatMoment(SFO_DEP, en)).toBe('Thu, Dec 3, 20:15');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
it('shows the clock the mail gave whatever the offset', () => {
|
||||||
|
const tokyo: Moment = { date: '2026-12-02', time: '07:05:00', offsetMinutes: 540, utc: '2026-12-01T22:05:00Z' };
|
||||||
|
const kolkata: Moment = { date: '2026-12-02', time: '23:50:00', offsetMinutes: 330, utc: '2026-12-02T18:20:00Z' };
|
||||||
|
expect(formatMoment(tokyo, en)).toBe('Wed, Dec 2, 07:05');
|
||||||
|
expect(formatMoment(kolkata, en)).toBe('Wed, Dec 2, 23:50');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('formats a date-only moment without a time', () => {
|
||||||
|
const d: Moment = { date: '2027-06-01' };
|
||||||
|
expect(formatMomentTime(d, en)).toBeUndefined();
|
||||||
|
// Another year than "now": the year is shown.
|
||||||
|
expect(formatMoment(d, en)).toBe('Tue, Jun 1, 2027');
|
||||||
|
expect(formatMomentDate({ date: '2026-11-03' }, en)).toBe('Tue, Nov 3');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('follows the UI locale and the 12-hour setting', () => {
|
||||||
|
expect(formatMoment(BERLIN, { locale: 'de', hour12: false, now: NOW })).toBe('Di., 1. Dez., 10:15');
|
||||||
|
expect(formatMomentTime(SFO_DEP, { locale: 'en-US', hour12: true, now: NOW })).toBe('8:15 PM');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('accepts HH:mm and falls back to the raw value for a malformed moment', () => {
|
||||||
|
expect(formatMomentTime({ date: '2026-12-01', time: '09:05' }, en)).toBe('09:05');
|
||||||
|
expect(formatMomentDate({ date: 'soon' }, en)).toBe('soon');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('local-time hint', () => {
|
||||||
|
it('knows the offset of a moment from the mail or from its resolved zone', () => {
|
||||||
|
expect(momentOffsetMinutes(SFO_DEP)).toBe(-480);
|
||||||
|
expect(momentOffsetMinutes(NRT_RESOLVED)).toBe(540);
|
||||||
|
expect(momentOffsetMinutes({ date: '2026-12-01', time: '10:00:00' })).toBeUndefined();
|
||||||
|
expect(momentOffsetMinutes({ date: '2026-12-01' })).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('marks a time whose place keeps another clock than the reader', () => {
|
||||||
|
expect(isOtherZone(SFO_DEP, 'Europe/Berlin')).toBe(true);
|
||||||
|
expect(isOtherZone(SFO_DEP, 'America/Los_Angeles')).toBe(false);
|
||||||
|
expect(isOtherZone(NRT_RESOLVED, 'Asia/Tokyo')).toBe(false);
|
||||||
|
expect(isOtherZone(NRT_RESOLVED, 'Europe/Berlin')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('compares at the instant, so DST on the reader side counts', () => {
|
||||||
|
// 10:15 +01:00 in December: Berlin is +01:00 then, London +00:00.
|
||||||
|
expect(isOtherZone(BERLIN, 'Europe/Berlin')).toBe(false);
|
||||||
|
expect(isOtherZone(BERLIN, 'Europe/London')).toBe(true);
|
||||||
|
// A +02:00 time in July matches Berlin's summer time.
|
||||||
|
expect(isOtherZone({ date: '2026-07-18', time: '14:00:00', offsetMinutes: 120, utc: '2026-07-18T12:00:00Z' }, 'Europe/Berlin')).toBe(false);
|
||||||
|
expect(isOtherZone(BERLIN, 'x', () => 60)).toBe(false);
|
||||||
|
expect(isOtherZone(BERLIN, 'x', () => 0)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('never marks a floating or date-only moment', () => {
|
||||||
|
expect(isOtherZone({ date: '2026-12-01', time: '10:00:00', zone: null }, 'Asia/Tokyo')).toBe(false);
|
||||||
|
expect(isOtherZone({ date: '2026-12-01' }, 'Asia/Tokyo')).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('card text helpers', () => {
|
||||||
|
const leg = (from: string, to: string, extra: Partial<TransportLeg> = {}): TransportLeg => ({
|
||||||
|
carrier: 'Lufthansa', carrierCode: 'LH', number: '400',
|
||||||
|
departure: { place: { code: from, name: `${from} Airport` }, at: BERLIN },
|
||||||
|
arrival: { place: { code: to }, at: SFO_DEP },
|
||||||
|
seats: [], ...extra,
|
||||||
|
});
|
||||||
|
const flight = (legs: TransportLeg[]): TransportBooking => ({ key: 'k', kind: 'flight', passengers: [], links: {}, source: 'json-ld', legs });
|
||||||
|
|
||||||
|
it('titles a flight with airport codes, chaining connecting legs', () => {
|
||||||
|
expect(routeTitle(flight([leg('FRA', 'JFK')]))).toBe('FRA → JFK');
|
||||||
|
expect(routeTitle(flight([leg('FRA', 'JFK'), leg('JFK', 'FRA')]))).toBe('FRA → JFK → FRA');
|
||||||
|
expect(routeTitle(flight([leg('FRA', 'JFK'), leg('BOS', 'FRA')]))).toBe('FRA → JFK, BOS → FRA');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('titles a train with towns when every stop has one, else station names', () => {
|
||||||
|
const station = (name: string, locality?: string) => ({ name, ...(locality ? { address: { locality } } : {}) });
|
||||||
|
const train = (a: ReturnType<typeof station>, b: ReturnType<typeof station>): TransportBooking => ({
|
||||||
|
key: 'k', kind: 'train', passengers: [], links: {}, source: 'json-ld',
|
||||||
|
legs: [{ departure: { place: a, at: BERLIN }, arrival: { place: b, at: BERLIN }, seats: [] }],
|
||||||
|
});
|
||||||
|
expect(routeTitle(train(station('Hamburg Hbf', 'Hamburg'), station('München Hbf', 'München')))).toBe('Hamburg → München');
|
||||||
|
expect(routeTitle(train(station('Hamburg Hbf', 'Hamburg'), station('München Hbf')))).toBe('Hamburg Hbf → München Hbf');
|
||||||
|
expect(routeTitle(train(station('Berlin Hbf', 'Berlin'), station('Berlin Südkreuz', 'Berlin')))).toBe('Berlin Hbf → Berlin Südkreuz');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('labels airports and services', () => {
|
||||||
|
expect(legPlaceLabel({ code: 'FRA', name: 'Frankfurt Airport' }, 'flight')).toBe('Frankfurt Airport (FRA)');
|
||||||
|
expect(legPlaceLabel({ code: 'FRA' }, 'flight')).toBe('FRA');
|
||||||
|
expect(legPlaceLabel({ code: 'JFK', name: 'New York JFK' }, 'flight')).toBe('New York JFK');
|
||||||
|
expect(legPlaceLabel({ name: 'Hamburg Hbf', code: 'X' }, 'train')).toBe('Hamburg Hbf');
|
||||||
|
expect(legService(leg('FRA', 'JFK'))).toBe('Lufthansa LH 400');
|
||||||
|
expect(legService({ ...leg('FRA', 'JFK'), carrierCode: undefined, carrier: 'Flix', number: 'FLX30' })).toBe('Flix FLX30');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('counts nights by date', () => {
|
||||||
|
expect(nightsBetween({ date: '2026-12-04', time: '15:00:00' }, { date: '2026-12-06', time: '11:00:00' })).toBe(2);
|
||||||
|
expect(nightsBetween({ date: '2027-03-27' }, { date: '2027-03-29' })).toBe(2);
|
||||||
|
expect(nightsBetween({ date: '2027-03-29' }, { date: '2027-03-29' })).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('formats money in the UI locale with the card currency', () => {
|
||||||
|
expect(formatMoney({ amount: '129.90', currency: 'EUR' }, 'en-US')).toBe('€129.90');
|
||||||
|
expect(formatMoney({ amount: '1299.00', currency: 'EUR' }, 'de').replace(/\s/g, ' ')).toBe('1.299,00 €');
|
||||||
|
expect(formatMoney({ amount: '25.99', currency: 'USD' }, 'en-US')).toBe('$25.99');
|
||||||
|
expect(formatMoney({ amount: '5.00', currency: 'XX1' }, 'en-US')).toBe('5.00 XX1');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('writes an address on one line without repeating a combined street', () => {
|
||||||
|
expect(formatAddress({ street: 'Marktplatz 1', postalCode: '10178', locality: 'Berlin', country: 'DE' }, 'en')).toBe('Marktplatz 1, 10178 Berlin, Germany');
|
||||||
|
expect(formatAddress({ street: 'Kurfürstendamm 18, 10719 Berlin' }, 'en')).toBe('Kurfürstendamm 18, 10719 Berlin');
|
||||||
|
expect(formatAddress({}, 'en')).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('lists the first three items and counts pieces', () => {
|
||||||
|
const items = [{ name: 'A', quantity: 2 }, { name: 'B' }, { name: 'C' }, { name: 'D' }, { name: 'E', quantity: 3 }];
|
||||||
|
const s = summarizeItems(items);
|
||||||
|
expect(s.shown.map((i) => i.name)).toEqual(['A', 'B', 'C']);
|
||||||
|
expect(s.more).toBe(2);
|
||||||
|
expect(s.count).toBe(8);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('takes item images from the parcel when the order has none', () => {
|
||||||
|
const shipment: Shipment = {
|
||||||
|
kind: 'shipment', key: 'k', items: [{ name: 'Shoes', quantity: 1 }], source: 'json-ld',
|
||||||
|
parcels: [{ items: [{ name: 'Shoes', image: { url: 'https://img.example.com/1.jpg', host: 'img.example.com' } }] }],
|
||||||
|
};
|
||||||
|
expect(shipmentItems(shipment)[0].image?.host).toBe('img.example.com');
|
||||||
|
expect(shipmentItems({ ...shipment, items: [] }).map((i) => i.name)).toEqual(['Shoes']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('accepts only http(s) links', () => {
|
||||||
|
expect(isWebLink({ url: 'https://dhl.de/x' })).toBe(true);
|
||||||
|
expect(isWebLink({ url: 'http://dhl.de/x' })).toBe(true);
|
||||||
|
expect(isWebLink({ url: 'javascript:alert(1)' })).toBe(false);
|
||||||
|
expect(isWebLink({ url: 'data:text/html,x' })).toBe(false);
|
||||||
|
expect(isWebLink({ url: 'not a url' })).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('formatting does not depend on the process zone', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
process.env.TZ = 'Pacific/Honolulu';
|
||||||
|
});
|
||||||
|
it('keeps a Berlin morning a Berlin morning in Honolulu', () => {
|
||||||
|
expect(formatMoment(BERLIN, en)).toBe('Tue, Dec 1, 10:15');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
// The app's stand-in for DOMParser (html-parser.ts): the tree it builds must
|
||||||
|
// answer extract.ts's DOM calls the way a browser document does.
|
||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { decodeCharacterReferences, parseHtmlDocument } from '../html-parser';
|
||||||
|
|
||||||
|
const parse = (html: string) => parseHtmlDocument(html);
|
||||||
|
const tags = (nodes: ArrayLike<Element>) => Array.from(nodes).map((el) => el.tagName.toLowerCase());
|
||||||
|
|
||||||
|
describe('parseHtmlDocument', () => {
|
||||||
|
it('nests elements, closes void elements and ignores stray end tags', () => {
|
||||||
|
const doc = parse('<div id="a"><span>x<br>y</span></em><img src="i"><p>z</div><i>after</i>');
|
||||||
|
const div = doc.getElementById('a')!;
|
||||||
|
expect(tags(div.querySelectorAll('*' as string))).toEqual(['span', 'br', 'img', 'p']);
|
||||||
|
expect(doc.querySelector('i')!.parentElement).toBeNull();
|
||||||
|
expect(div.textContent).toBe('xyz');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('closes the elements HTML closes for you', () => {
|
||||||
|
const doc = parse('<ul><li id="one">1<li id="two">2</ul><p id="p">para<div id="d">block</div><table><tr><td id="c1">a<td id="c2">b<tr><td id="c3">c</table>');
|
||||||
|
expect(doc.getElementById('two')!.parentElement!.tagName).toBe('UL');
|
||||||
|
expect(doc.getElementById('one')!.textContent).toBe('1');
|
||||||
|
// A block start tag ends the open paragraph.
|
||||||
|
expect(doc.getElementById('d')!.parentElement).toBeNull();
|
||||||
|
expect(doc.getElementById('c2')!.parentElement).toBe(doc.getElementById('c1')!.parentElement);
|
||||||
|
expect(doc.getElementById('c3')!.parentElement).not.toBe(doc.getElementById('c1')!.parentElement);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reads a script verbatim up to its end tag, markup and entities included', () => {
|
||||||
|
const json = '{"name":"</div><b>&</b>","x":1}';
|
||||||
|
const doc = parse(`<div><script type="application/ld+json">${json}</script><p id="after">ok</p></div>`);
|
||||||
|
const script = doc.querySelector('script')!;
|
||||||
|
expect(script.textContent).toBe(json);
|
||||||
|
expect(doc.querySelectorAll('b')).toHaveLength(0);
|
||||||
|
expect(doc.getElementById('after')!.parentElement!.tagName).toBe('DIV');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('decodes character references in text and attribute values, as the browser does', () => {
|
||||||
|
const doc = parse('<a id="l" href="https://x.example/?a=1&b=2©=3" title=""Müller"">Café €5 😀 ©2026 &unknown;</a>');
|
||||||
|
const link = doc.getElementById('l')!;
|
||||||
|
expect(link.getAttribute('href')).toBe('https://x.example/?a=1&b=2©=3');
|
||||||
|
expect(link.getAttribute('title')).toBe('"Müller"');
|
||||||
|
expect(link.textContent).toBe('Café €5 \u{1F600} ©2026 &unknown;');
|
||||||
|
expect(decodeCharacterReferences('€�')).toBe('€�');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps the first of repeated attributes and matches names case-insensitively', () => {
|
||||||
|
const el = parse('<div ItemScope itemtype="https://schema.org/Order" itemtype="x"></div>').querySelector('div')!;
|
||||||
|
expect(el.hasAttribute('itemscope')).toBe(true);
|
||||||
|
expect(el.getAttribute('ITEMTYPE')).toBe('https://schema.org/Order');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('answers closest and querySelectorAll for the selectors extract.ts uses', () => {
|
||||||
|
const doc = parse('<!DOCTYPE html><div class="x gmail_quote"><section><span id="in">a</span></section></div><span id="out">b</span>');
|
||||||
|
const quoted = 'blockquote, .gmail_quote, .moz-forward-container, #appendonsend, #divRplyFwdMsg';
|
||||||
|
expect(doc.getElementById('in')!.closest(quoted)!.tagName).toBe('DIV');
|
||||||
|
expect(doc.getElementById('out')!.closest(quoted)).toBeNull();
|
||||||
|
expect(tags(doc.querySelectorAll('[id]'))).toEqual(['span', 'span']);
|
||||||
|
expect(() => doc.querySelectorAll('div span')).toThrow(/unsupported selector/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('matches classes case-insensitively only in a document without a doctype (quirks mode)', () => {
|
||||||
|
expect(parse('<div class="Gmail_Quote"><i>q</i></div>').querySelector('i')!.closest('.gmail_quote')).not.toBeNull();
|
||||||
|
expect(parse('<!doctype html><div class="Gmail_Quote"><i>q</i></div>').querySelector('i')!.closest('.gmail_quote')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('orders elements like compareDocumentPosition', () => {
|
||||||
|
const doc = parse('<div id="sep"><b id="inside">x</b></div><p id="after">y</p><p id="before-not">z</p>');
|
||||||
|
const sep = doc.getElementById('sep')!;
|
||||||
|
const FOLLOWING = 4;
|
||||||
|
expect(sep.compareDocumentPosition(doc.getElementById('after')!) & FOLLOWING).toBeTruthy();
|
||||||
|
expect(sep.compareDocumentPosition(doc.getElementById('inside')!) & FOLLOWING).toBeTruthy();
|
||||||
|
expect(doc.getElementById('after')!.compareDocumentPosition(sep) & FOLLOWING).toBeFalsy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('drops comments, so markup inside one is not read', () => {
|
||||||
|
const doc = parse('<!-- <script type="application/ld+json">{}</script> --><p>x</p>');
|
||||||
|
expect(doc.querySelectorAll('script')).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not enter template content', () => {
|
||||||
|
expect(parse('<template><div itemscope></div></template>').querySelectorAll('[itemscope]')).toHaveLength(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
// Mirrors webmail lib/structured-mail/__tests__/json-repair.test.ts at 76b0c2fba; keep the two in step.
|
||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { parseLenientJson } from '../json-repair';
|
||||||
|
|
||||||
|
const A = { '@type': 'FlightReservation', reservationNumber: 'A1' };
|
||||||
|
const B = { '@type': 'FlightReservation', reservationNumber: 'B2' };
|
||||||
|
|
||||||
|
describe('parseLenientJson', () => {
|
||||||
|
it('parses valid JSON unchanged', () => {
|
||||||
|
expect(parseLenientJson(JSON.stringify([A, B]))).toEqual([A, B]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('adds the commas Eurowings leaves out between array objects', () => {
|
||||||
|
expect(parseLenientJson(`[${JSON.stringify(A)}\n${JSON.stringify(B)}]`)).toEqual([A, B]);
|
||||||
|
expect(parseLenientJson(`[${JSON.stringify(A)}${JSON.stringify(B)}]`)).toEqual([A, B]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('adds the brackets Volotea leaves out, with or without commas', () => {
|
||||||
|
expect(parseLenientJson(`${JSON.stringify(A)},\n${JSON.stringify(B)}`)).toEqual([A, B]);
|
||||||
|
expect(parseLenientJson(`${JSON.stringify(A)}\n${JSON.stringify(B)}`)).toEqual([A, B]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('drops the trailing commas Eventbrite writes', () => {
|
||||||
|
expect(parseLenientJson('{"@type": "EventReservation", "reservationNumber": "E1", }')).toEqual({ '@type': 'EventReservation', reservationNumber: 'E1' });
|
||||||
|
expect(parseLenientJson('[{"a": [1, 2, ], }, ]')).toEqual([{ a: [1, 2] }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('decodes the XML entities Airbnb encodes its JSON with', () => {
|
||||||
|
const encoded = JSON.stringify(A).replace(/"/g, '"');
|
||||||
|
expect(parseLenientJson(encoded)).toEqual(A);
|
||||||
|
expect(parseLenientJson('{"name": "Café & Bar"}')).toEqual({ name: 'Café & Bar' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('strips comment and CDATA wrappers and escapes raw newlines in strings', () => {
|
||||||
|
expect(parseLenientJson(`<!--\n${JSON.stringify(A)}\n-->`)).toEqual(A);
|
||||||
|
expect(parseLenientJson(`<![CDATA[${JSON.stringify(A)}]]>`)).toEqual(A);
|
||||||
|
expect(parseLenientJson('{"name": "two\nlines"}')).toEqual({ name: 'two\nlines' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('leaves commas and braces inside strings alone', () => {
|
||||||
|
expect(parseLenientJson('{"name": "a, } { b", }')).toEqual({ name: 'a, } { b' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('gives up on input it cannot repair', () => {
|
||||||
|
expect(parseLenientJson('{"a": ')).toBeUndefined();
|
||||||
|
expect(parseLenientJson('not json at all')).toBeUndefined();
|
||||||
|
expect(parseLenientJson('')).toBeUndefined();
|
||||||
|
expect(parseLenientJson(`[${'x'.repeat(200_001)}]`)).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
// Mirrors webmail lib/structured-mail/__tests__/merge.test.ts at fa324170a; keep the two in step.
|
||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { readStructuredMail } from '..';
|
||||||
|
import type { Shipment, TransportBooking } from '../types';
|
||||||
|
|
||||||
|
const MAIL = { mailDate: new Date('2026-10-30T09:00:00Z') };
|
||||||
|
const C = 'http://schema.org';
|
||||||
|
const page = (...blocks: unknown[]) => `<html><head>${blocks.map((b) => `<script type="application/ld+json">${JSON.stringify(b)}</script>`).join('')}</head><body></body></html>`;
|
||||||
|
|
||||||
|
function leg(name: string, number: string, dep: string, arr: string, status = 'ReservationConfirmed', seat?: string) {
|
||||||
|
return {
|
||||||
|
'@context': C, '@type': 'FlightReservation', reservationNumber: 'QX7K2M', underName: { name }, reservationStatus: status,
|
||||||
|
...(seat ? { airplaneSeat: seat } : {}),
|
||||||
|
reservationFor: {
|
||||||
|
'@type': 'Flight', flightNumber: number, airline: { name: 'Lufthansa', iataCode: 'LH' },
|
||||||
|
departureAirport: { iataCode: number === '400' ? 'FRA' : 'JFK' }, departureTime: dep,
|
||||||
|
arrivalAirport: { iataCode: number === '400' ? 'JFK' : 'FRA' }, arrivalTime: arr,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const OUT: [string, string] = ['2026-12-01T10:15:00+01:00', '2026-12-01T13:05:00-05:00'];
|
||||||
|
const BACK: [string, string] = ['2026-12-08T17:30:00-05:00', '2026-12-09T07:20:00+01:00'];
|
||||||
|
|
||||||
|
describe('merging', () => {
|
||||||
|
it('folds two passengers on two legs into one booking with two legs and two names', () => {
|
||||||
|
const cards = readStructuredMail(page([
|
||||||
|
leg('Erika Mustermann', '400', ...OUT, undefined, '12A'), leg('Max Mustermann', '400', ...OUT, undefined, '12B'),
|
||||||
|
leg('Erika Mustermann', '401', ...BACK), leg('Max Mustermann', '401', ...BACK),
|
||||||
|
]), MAIL) as TransportBooking[];
|
||||||
|
expect(cards).toHaveLength(1);
|
||||||
|
expect(cards[0].passengers).toEqual(['Erika Mustermann', 'Max Mustermann']);
|
||||||
|
expect(cards[0].legs.map((l) => l.number)).toEqual(['400', '401']);
|
||||||
|
expect(cards[0].legs[0].seats).toEqual([{ passenger: 'Erika Mustermann', seat: '12A' }, { passenger: 'Max Mustermann', seat: '12B' }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('sorts legs by departure even when the mail lists them backwards', () => {
|
||||||
|
const [card] = readStructuredMail(page([leg('Erika Mustermann', '401', ...BACK), leg('Erika Mustermann', '400', ...OUT)]), MAIL) as TransportBooking[];
|
||||||
|
expect(card.legs.map((l) => l.number)).toEqual(['400', '401']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps a leg live when only one of its passengers is cancelled', () => {
|
||||||
|
const [card] = readStructuredMail(page([
|
||||||
|
leg('Erika Mustermann', '400', ...OUT), leg('Max Mustermann', '400', ...OUT, 'ReservationCancelled'),
|
||||||
|
]), MAIL) as TransportBooking[];
|
||||||
|
expect(card.legs[0].status).toBe('confirmed');
|
||||||
|
expect(card.status).toBe('confirmed');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('cancels a leg, and the booking, only when every part is cancelled', () => {
|
||||||
|
const [partly] = readStructuredMail(page([
|
||||||
|
leg('Erika Mustermann', '400', ...OUT, 'ReservationCancelled'), leg('Erika Mustermann', '401', ...BACK),
|
||||||
|
]), MAIL) as TransportBooking[];
|
||||||
|
expect(partly.legs.map((l) => l.status)).toEqual(['cancelled', 'confirmed']);
|
||||||
|
expect(partly.status).toBe('confirmed');
|
||||||
|
const [all] = readStructuredMail(page([
|
||||||
|
leg('Erika Mustermann', '400', ...OUT, 'ReservationCancelled'), leg('Erika Mustermann', '401', ...BACK, 'Cancelled'),
|
||||||
|
]), MAIL) as TransportBooking[];
|
||||||
|
expect(all.status).toBe('cancelled');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('drops the booking when two parts disagree about one leg, or one part is invalid', () => {
|
||||||
|
expect(readStructuredMail(page([leg('Erika Mustermann', '400', ...OUT), leg('Max Mustermann', '400', OUT[0], '2026-12-01T14:05:00-05:00')]), MAIL)).toEqual([]);
|
||||||
|
expect(readStructuredMail(page([leg('Erika Mustermann', '400', ...OUT), leg('Erika Mustermann', '401', BACK[0], '')]), MAIL)).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('collapses the same booking given as JSON-LD and as microdata', () => {
|
||||||
|
const microdata = `<div itemscope itemtype="http://schema.org/Order"><meta itemprop="orderNumber" content="SO-1"><div itemprop="merchant" itemscope itemtype="http://schema.org/Organization"><meta itemprop="name" content="Shop"></div></div>`;
|
||||||
|
const html = page({ '@context': C, '@type': 'Order', orderNumber: 'SO-1', merchant: 'Shop', price: 10, priceCurrency: 'EUR' }).replace('<body></body>', `<body>${microdata}</body>`);
|
||||||
|
const cards = readStructuredMail(html, MAIL) as Shipment[];
|
||||||
|
expect(cards).toHaveLength(1);
|
||||||
|
expect(cards[0].total).toEqual({ amount: '10.00', currency: 'EUR' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('makes one card of an Order and a ParcelDelivery for the same order number', () => {
|
||||||
|
const cards = readStructuredMail(page(
|
||||||
|
{ '@context': C, '@type': 'Order', orderNumber: 'SO-1', merchant: 'Shop', orderStatus: 'OrderProcessing', acceptedOffer: { itemOffered: { name: 'Schuhe' } } },
|
||||||
|
{ '@context': C, '@type': 'ParcelDelivery', trackingNumber: 'T1', carrier: 'DHL', partOfOrder: { '@type': 'Order', orderNumber: 'SO-1', orderStatus: 'OrderInTransit' } },
|
||||||
|
{ '@context': C, '@type': 'ParcelDelivery', trackingNumber: 'T2', carrier: 'DHL', partOfOrder: { '@type': 'Order', orderNumber: 'SO-1' } },
|
||||||
|
), MAIL) as Shipment[];
|
||||||
|
expect(cards).toHaveLength(1);
|
||||||
|
expect(cards[0]).toMatchObject({ orderNumber: 'SO-1', merchant: 'Shop', status: 'in_transit', items: [{ name: 'Schuhe' }] });
|
||||||
|
expect(cards[0].parcels.map((p) => p.trackingNumber)).toEqual(['T1', 'T2']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps orders with different numbers apart', () => {
|
||||||
|
const cards = readStructuredMail(page(
|
||||||
|
{ '@context': C, '@type': 'Order', orderNumber: 'SO-1', merchant: 'Shop' },
|
||||||
|
{ '@context': C, '@type': 'Order', orderNumber: 'SO-2', merchant: 'Shop' },
|
||||||
|
), MAIL);
|
||||||
|
expect(cards).toHaveLength(2);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,203 @@
|
|||||||
|
// Mirrors webmail lib/structured-mail/__tests__/normalize.test.ts at 916434924; keep the two in step.
|
||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { normalizeItems } from '../normalize';
|
||||||
|
import type { LodgingBooking, Shipment, TransportBooking } from '../types';
|
||||||
|
import { address, amount, lenientDate, link, moment, orderStatus, reservationStatus } from '../values';
|
||||||
|
|
||||||
|
const ctx = 'http://schema.org';
|
||||||
|
const norm = (...items: unknown[]) => normalizeItems(items.map((item) => ({ item, source: 'json-ld' as const })));
|
||||||
|
|
||||||
|
describe('value readers', () => {
|
||||||
|
it('reads ISO times with every offset form and computes the instant', () => {
|
||||||
|
expect(moment('2027-03-04T20:15:00-08:00')).toEqual({ date: '2027-03-04', time: '20:15:00', offsetMinutes: -480, utc: '2027-03-05T04:15:00Z' });
|
||||||
|
expect(moment('2024-06-04T11:44:00+0200')).toEqual({ date: '2024-06-04', time: '11:44:00', offsetMinutes: 120, utc: '2024-06-04T09:44:00Z' });
|
||||||
|
expect(moment('2026-01-01T10:00+05')).toMatchObject({ offsetMinutes: 300, utc: '2026-01-01T05:00:00Z' });
|
||||||
|
expect(moment('2026-01-01T10:00:00.123Z')).toMatchObject({ time: '10:00:00', offsetMinutes: 0, utc: '2026-01-01T10:00:00Z' });
|
||||||
|
expect(moment('2026-01-01T10:00:00')).toEqual({ date: '2026-01-01', time: '10:00:00' });
|
||||||
|
expect(moment('2026-01-01')).toEqual({ date: '2026-01-01' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('refuses broken times instead of guessing', () => {
|
||||||
|
expect(moment('2024-10-13CEST03:10:23')).toBeUndefined();
|
||||||
|
expect(moment('2026-02-30T10:00:00Z')).toBeUndefined();
|
||||||
|
expect(moment('2026-01-01T25:00:00Z')).toBeUndefined();
|
||||||
|
expect(moment('2026-01-01T10:00:00+15:00')).toBeUndefined();
|
||||||
|
expect(moment('next Friday')).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps only the date of a broken value where a date is enough', () => {
|
||||||
|
expect(lenientDate('2024-10-13CEST03:10:23')).toEqual({ date: '2024-10-13' });
|
||||||
|
expect(lenientDate('2026-03-12T12:00:00-08:00')).toEqual({ date: '2026-03-12' });
|
||||||
|
expect(lenientDate('20241013')).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reads both forms of the status enums over http and https', () => {
|
||||||
|
expect(reservationStatus('http://schema.org/ReservationConfirmed')).toBe('confirmed');
|
||||||
|
expect(reservationStatus('https://schema.org/Confirmed')).toBe('confirmed');
|
||||||
|
expect(reservationStatus('ReservationCancelled')).toBe('cancelled');
|
||||||
|
expect(reservationStatus(['', 'http://schema.org/Cancelled'])).toBe('cancelled');
|
||||||
|
expect(reservationStatus('http://schema.org/Bogus')).toBeUndefined();
|
||||||
|
expect(orderStatus(['', 'http://schema.org/OrderProcessing'])).toBe('processing');
|
||||||
|
expect(orderStatus('http://schema.org/OrderStatus/InTransit')).toBe('in_transit');
|
||||||
|
expect(orderStatus('OrderDelivered')).toBe('delivered');
|
||||||
|
expect(orderStatus('Processing')).toBe('processing');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reads prices in dot and German notation and refuses ambiguous ones', () => {
|
||||||
|
expect(amount(25.99)).toBe('25.99');
|
||||||
|
expect(amount('14.9700')).toBe('14.97');
|
||||||
|
expect(amount('399,00 €')).toBe('399.00');
|
||||||
|
expect(amount('1.299,00 EUR')).toBe('1299.00');
|
||||||
|
expect(amount('$1,299.50')).toBe('1299.50');
|
||||||
|
expect(amount('1,299')).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps only http(s) links with a real host', () => {
|
||||||
|
expect(link('https://www.fedex.com/track/1')).toEqual({ url: 'https://www.fedex.com/track/1', host: 'fedex.com' });
|
||||||
|
expect(link({ '@type': 'ViewAction', target: 'http://shop.example.com/o/1' })?.host).toBe('shop.example.com');
|
||||||
|
expect(link('http://default/buchung/VK4XKD')).toBeUndefined();
|
||||||
|
expect(link('javascript:alert(1)')).toBeUndefined();
|
||||||
|
expect(link('https://user:pw@example.com/')).toBeUndefined();
|
||||||
|
expect(link('http://192.168.1.1/x')).toBeUndefined();
|
||||||
|
expect(link('http://localhost:3000/x')).toBeUndefined();
|
||||||
|
expect(link('/relative')).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reads addresses given as objects, strings and country objects', () => {
|
||||||
|
expect(address('Unter den Linden 77, Berlin')).toEqual({ street: 'Unter den Linden 77, Berlin' });
|
||||||
|
expect(address({ streetAddress: 'Koppenstr. 3', addressLocality: 'Berlin', postalCode: '10243', addressCountry: 'de', addressRegion: '' }))
|
||||||
|
.toEqual({ street: 'Koppenstr. 3', locality: 'Berlin', postalCode: '10243', country: 'DE' });
|
||||||
|
expect(address({ addressLocality: 'Paris', addressCountry: { '@type': 'Country', name: 'France' } }))
|
||||||
|
.toEqual({ locality: 'Paris', countryName: 'France' });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('normalizeItems', () => {
|
||||||
|
const flight = {
|
||||||
|
'@context': ctx, '@type': 'FlightReservation', reservationNumber: 'QX7K2M',
|
||||||
|
reservationStatus: 'http://schema.org/ReservationConfirmed',
|
||||||
|
underName: { '@type': 'Person', name: 'Erika Mustermann' }, airplaneSeat: '12C',
|
||||||
|
checkinUrl: 'https://www.lufthansa.example.com/checkin',
|
||||||
|
reservationFor: {
|
||||||
|
'@type': 'Flight', flightNumber: 'LH400',
|
||||||
|
airline: { '@type': 'Airline', name: 'Lufthansa', iataCode: 'LH' },
|
||||||
|
departureAirport: { '@type': 'Airport', name: 'Frankfurt', iataCode: 'fra' }, departureTime: '2026-12-01T10:15:00+01:00',
|
||||||
|
arrivalAirport: { '@type': 'Airport', name: 'New York JFK', iataCode: 'JFK' }, arrivalTime: '2026-12-01T13:05:00-05:00',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
it('reads a flight reservation, splitting the airline code off the flight number', () => {
|
||||||
|
const [card] = norm(flight) as TransportBooking[];
|
||||||
|
expect(card).toEqual({
|
||||||
|
key: 'flight|ref:qx7k2m', kind: 'flight', reference: 'QX7K2M', status: 'confirmed', passengers: ['Erika Mustermann'],
|
||||||
|
provider: 'Lufthansa', links: { checkin: { url: 'https://www.lufthansa.example.com/checkin', host: 'lufthansa.example.com' } },
|
||||||
|
source: 'json-ld',
|
||||||
|
legs: [{
|
||||||
|
carrier: 'Lufthansa', carrierCode: 'LH', number: '400',
|
||||||
|
departure: { place: { name: 'Frankfurt', code: 'FRA' }, at: { date: '2026-12-01', time: '10:15:00', offsetMinutes: 60, utc: '2026-12-01T09:15:00Z' } },
|
||||||
|
arrival: { place: { name: 'New York JFK', code: 'JFK' }, at: { date: '2026-12-01', time: '13:05:00', offsetMinutes: -300, utc: '2026-12-01T18:05:00Z' } },
|
||||||
|
seats: [{ passenger: 'Erika Mustermann', seat: '12C' }], status: 'confirmed',
|
||||||
|
}],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('flattens arrays, @graph and reservationFor arrays', () => {
|
||||||
|
const two = { ...flight, reservationFor: [flight.reservationFor, { ...flight.reservationFor, flightNumber: '401' }] };
|
||||||
|
expect(norm([flight, flight]).length).toBe(2);
|
||||||
|
expect(norm({ '@context': ctx, '@graph': [flight, flight] }).length).toBe(2);
|
||||||
|
const legs = norm(two) as TransportBooking[];
|
||||||
|
expect(legs.map((c) => c.legs[0].number)).toEqual(['400', '401']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('accepts https contexts, @vocab and several @types, and drops foreign or missing contexts', () => {
|
||||||
|
expect(norm({ ...flight, '@context': 'https://schema.org/' })).toHaveLength(1);
|
||||||
|
expect(norm({ ...flight, '@context': { '@vocab': 'http://schema.org/' } })).toHaveLength(1);
|
||||||
|
expect(norm({ ...flight, '@type': ['Thing', 'FlightReservation'] })).toHaveLength(1);
|
||||||
|
expect(norm({ ...flight, '@context': 'http://example.com' })).toHaveLength(0);
|
||||||
|
const { '@context': _dropped, ...noContext } = flight;
|
||||||
|
expect(norm(noContext)).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reads bus trips the way FlixBus writes them', () => {
|
||||||
|
const [card] = norm({
|
||||||
|
'@context': ctx, '@type': 'BusReservation', reservationNumber: 3165152579,
|
||||||
|
underName: { '@type': 'Person', name: 'Max Mustermann' },
|
||||||
|
reservationFor: {
|
||||||
|
'@type': 'BusTrip', busNumber: 'FLX30', busName: 'Berlin Ostbahnhof', busCompany: { '@type': 'Organization', name: 'Flix' },
|
||||||
|
departureBusStop: { '@type': 'BusStop', name: 'Hannover Hauptbahnhof', address: { '@type': 'PostalAddress', addressLocality: 'Hannover', addressCountry: 'DE' } },
|
||||||
|
departureTime: '2024-06-04T11:44:00+0200',
|
||||||
|
arrivalBusStop: { '@type': 'BusStop', name: 'Berlin Ostbahnhof' }, arrivalTime: '2024-06-04T13:44:00+0200',
|
||||||
|
},
|
||||||
|
reservedTicket: { '@type': 'Ticket', ticketedSeat: { '@type': 'Seat', seatingType: 'Coach', seatNumber: '6D' } },
|
||||||
|
}) as TransportBooking[];
|
||||||
|
expect(card.reference).toBe('3165152579');
|
||||||
|
expect(card.legs[0]).toMatchObject({ carrier: 'Flix', number: 'FLX30', seats: [{ passenger: 'Max Mustermann', seat: '6D' }] });
|
||||||
|
expect(card.legs[0].departure.place).toEqual({ name: 'Hannover Hauptbahnhof', address: { locality: 'Hannover', country: 'DE' } });
|
||||||
|
expect(JSON.stringify(card)).not.toContain('busName');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reads hotels from Hotel subtypes with legacy names', () => {
|
||||||
|
const [card] = norm({
|
||||||
|
'@context': ctx, '@type': 'LodgingReservation', reservationId: 'h-1',
|
||||||
|
reservationFor: { '@type': 'Hotel', name: 'Hotel Adlon', address: 'Unter den Linden 77, 10117 Berlin', telephone: '+49 30 1' },
|
||||||
|
checkinTime: '2026-12-04T15:00:00+01:00', checkoutTime: '2026-12-06T12:00:00+01:00', Url: 'https://hotel.example.com/b/h-1',
|
||||||
|
}) as LodgingBooking[];
|
||||||
|
expect(card).toMatchObject({
|
||||||
|
kind: 'lodging', reference: 'h-1', name: 'Hotel Adlon', phone: '+49 30 1', address: { street: 'Unter den Linden 77, 10117 Berlin' },
|
||||||
|
checkin: { date: '2026-12-04', time: '15:00:00' }, checkout: { date: '2026-12-06', time: '12:00:00' },
|
||||||
|
links: { manage: { host: 'hotel.example.com' } },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reads restaurants, events with a date-only end, and rental cars', () => {
|
||||||
|
const [restaurant, event, car] = norm(
|
||||||
|
{ '@context': ctx, '@type': 'FoodEstablishmentReservation', reservationNumber: 'R9', partySize: '2', startTime: '2026-12-05T19:30:00+01:00', reservationFor: { '@type': 'Restaurant', name: 'Tim Raue' } },
|
||||||
|
{ '@context': ctx, '@type': 'EventReservation', reservationNumber: 'E9', reservationFor: { '@type': 'MusicEvent', name: 'Konzert', startDate: '2026-12-05T20:00:00+01:00', endDate: '2026-12-05', location: { '@type': 'Place', name: 'Halle', address: { addressCountry: 'DE' } } } },
|
||||||
|
{ '@context': ctx, '@type': 'RentalCarReservation', reservationNumber: 'C9', reservationFor: { '@type': 'Car', brand: { name: 'VW' }, model: 'Golf', rentalCompany: { name: 'Sixt' } }, pickupTime: '2026-12-04T09:00:00+01:00', dropoffTime: '2026-12-07T18:00:00+01:00', pickupLocation: { name: 'BER' }, dropoffLocation: 'BER' },
|
||||||
|
);
|
||||||
|
expect(restaurant).toMatchObject({ kind: 'restaurant', name: 'Tim Raue', partySize: 2, start: { time: '19:30:00' } });
|
||||||
|
expect(event).toMatchObject({ kind: 'event', name: 'Konzert', end: { date: '2026-12-05', time: '23:59:59' }, venue: { name: 'Halle' } });
|
||||||
|
expect(car).toMatchObject({ kind: 'rentalcar', company: 'Sixt', car: 'VW Golf', pickup: { place: { name: 'BER' } }, dropoff: { place: { name: 'BER' } } });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reads orders with German prices and parcels with their order', () => {
|
||||||
|
const [orderCard, parcelCard] = norm(
|
||||||
|
{
|
||||||
|
'@context': ctx, '@type': 'Order', merchant: { '@type': 'Organization', name: 'Kaufland.de' }, orderNumber: 'M1234AB',
|
||||||
|
price: '399,00 €', priceCurrency: 'EUR', orderStatus: ['', 'http://schema.org/OrderProcessing'],
|
||||||
|
acceptedOffer: { '@type': 'Offer', itemOffered: { '@type': 'Product', name: '3D-Drucker', image: 'https://media.example.com/p.jpg' }, eligibleQuantity: { value: '1' } },
|
||||||
|
url: 'https://www.kaufland.de/account/orders',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'@context': ctx, '@type': 'ParcelDelivery', carrier: 'DHL', trackingNumber: '0034', trackingUrl: 'https://www.dhl.de/t?id=0034',
|
||||||
|
expectedArrivalUntil: '2024-10-13CEST03:10:23', itemShipped: [{ '@type': 'Product', name: 'A' }, 'B'],
|
||||||
|
partOfOrder: { '@type': 'Order', orderNumber: 'M1234AB', merchant: 'Kaufland.de', orderStatus: 'http://schema.org/OrderInTransit' },
|
||||||
|
},
|
||||||
|
) as Shipment[];
|
||||||
|
expect(orderCard).toEqual({
|
||||||
|
kind: 'shipment', key: 'shipment|order:m1234ab', merchant: 'Kaufland.de', orderNumber: 'M1234AB', status: 'processing',
|
||||||
|
items: [{ name: '3D-Drucker', quantity: 1, image: { url: 'https://media.example.com/p.jpg', host: 'media.example.com' } }],
|
||||||
|
total: { amount: '399.00', currency: 'EUR' }, orderUrl: { url: 'https://www.kaufland.de/account/orders', host: 'kaufland.de' },
|
||||||
|
parcels: [], source: 'json-ld',
|
||||||
|
});
|
||||||
|
expect(parcelCard).toMatchObject({
|
||||||
|
key: 'shipment|order:m1234ab', merchant: 'Kaufland.de', status: 'in_transit',
|
||||||
|
parcels: [{ carrier: 'DHL', trackingNumber: '0034', trackingUrl: { host: 'dhl.de' }, expectedBy: { date: '2024-10-13' }, items: [{ name: 'A' }, { name: 'B' }] }],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('takes the expected arrival only from its latest bound', () => {
|
||||||
|
const parcel = (extra: Record<string, unknown>) => norm({ '@context': ctx, '@type': 'ParcelDelivery', trackingNumber: '1', carrier: 'DHL', ...extra })[0] as Shipment;
|
||||||
|
// The earliest possible delivery day is not an "arrives by" date.
|
||||||
|
expect(parcel({ expectedArrivalFrom: '2026-11-03' }).parcels[0].expectedBy).toBeUndefined();
|
||||||
|
expect(parcel({ expectedArrivalFrom: '2026-11-03', expectedArrivalUntil: '2026-11-05' }).parcels[0].expectedBy).toEqual({ date: '2026-11-05' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('drops types it does not read', () => {
|
||||||
|
expect(norm(
|
||||||
|
{ '@context': ctx, '@type': 'PromotionCard', image: 'https://x.example.com/a.png' },
|
||||||
|
{ '@context': ctx, '@type': 'EmailMessage', potentialAction: { '@type': 'ViewAction' } },
|
||||||
|
{ '@context': ctx, '@type': 'Invoice', totalPaymentDue: 3 },
|
||||||
|
)).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
// Mirrors webmail lib/structured-mail/__tests__/pkpass-fixtures.ts at 63830d403; keep the two in step.
|
||||||
|
/**
|
||||||
|
* Self-written Wallet passes for the .pkpass tests (made-up values, not
|
||||||
|
* Apple's or Google's documentation examples), and a jszip builder that
|
||||||
|
* packs them the way a real .pkpass is packed.
|
||||||
|
*/
|
||||||
|
import JSZip from 'jszip';
|
||||||
|
|
||||||
|
export const MAIL_DATE = new Date('2026-11-20T08:00:00Z');
|
||||||
|
|
||||||
|
type Json = Record<string, unknown>;
|
||||||
|
|
||||||
|
function pass(style: string, styleBody: Json, semantics: Json | null, extra: Json = {}): Json {
|
||||||
|
return {
|
||||||
|
formatVersion: 1,
|
||||||
|
passTypeIdentifier: 'pass.org.example.boarding',
|
||||||
|
serialNumber: 'SER-55120',
|
||||||
|
teamIdentifier: 'ABCDE12345',
|
||||||
|
organizationName: 'Swiss',
|
||||||
|
description: 'Boarding pass',
|
||||||
|
relevantDate: '2026-12-03T07:05+01:00',
|
||||||
|
webServiceURL: 'https://passes.example.org/v1',
|
||||||
|
authenticationToken: 'never-used-token-0000',
|
||||||
|
barcodes: [{ format: 'PKBarcodeFormatQR', message: 'M1BEISPIEL/JANA', messageEncoding: 'iso-8859-1' }],
|
||||||
|
[style]: { primaryFields: [{ key: 'origin', label: 'ZRH', value: 'Zurich' }], ...styleBody },
|
||||||
|
...(semantics ? { semantics } : {}),
|
||||||
|
...extra,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export const AIR_SEMANTICS: Json = {
|
||||||
|
airlineCode: 'LX',
|
||||||
|
flightNumber: 1072,
|
||||||
|
flightCode: 'LX1072',
|
||||||
|
confirmationNumber: 'PK7Q2Z',
|
||||||
|
departureAirportCode: 'ZRH',
|
||||||
|
departureAirportName: 'Zurich Airport',
|
||||||
|
destinationAirportCode: 'BER',
|
||||||
|
destinationAirportName: 'Berlin Brandenburg',
|
||||||
|
originalDepartureDate: '2026-12-03T07:40+01:00',
|
||||||
|
originalArrivalDate: '2026-12-03T09:05+01:00',
|
||||||
|
departureLocationTimeZone: 'Europe/Zurich',
|
||||||
|
destinationLocationTimeZone: 'Europe/Berlin',
|
||||||
|
passengerName: { givenName: 'Jana', familyName: 'Beispiel' },
|
||||||
|
seats: [{ seatRow: '14', seatNumber: 'C' }],
|
||||||
|
transitProvider: 'Swiss International Air Lines',
|
||||||
|
};
|
||||||
|
|
||||||
|
export const boardingPass = (semantics: Json | null = AIR_SEMANTICS, transitType = 'PKTransitTypeAir', extra: Json = {}) =>
|
||||||
|
pass('boardingPass', { transitType }, semantics, extra);
|
||||||
|
|
||||||
|
export const TRAIN_SEMANTICS: Json = {
|
||||||
|
confirmationNumber: 'ZUG4471',
|
||||||
|
transitProvider: 'Deutsche Bahn',
|
||||||
|
vehicleNumber: 'ICE 597',
|
||||||
|
departureStationName: 'Hamburg Hbf',
|
||||||
|
destinationStationName: 'Köln Hbf',
|
||||||
|
originalDepartureDate: '2026-12-05T08:46:00+01:00',
|
||||||
|
originalArrivalDate: '2026-12-05T12:50:00+01:00',
|
||||||
|
carNumber: '7',
|
||||||
|
seats: [{ seatNumber: '45' }],
|
||||||
|
passengerName: { givenName: 'Jana', familyName: 'Beispiel' },
|
||||||
|
};
|
||||||
|
|
||||||
|
export const BUS_SEMANTICS: Json = {
|
||||||
|
confirmationNumber: 'BUS-80411',
|
||||||
|
transitProvider: 'Fernbus Nord',
|
||||||
|
departureStationName: 'Bremen ZOB',
|
||||||
|
destinationStationName: 'Hannover ZOB',
|
||||||
|
currentDepartureDate: '2026-12-06T10:15:00+01:00',
|
||||||
|
passengerName: { givenName: 'Ole', familyName: 'Probe' },
|
||||||
|
};
|
||||||
|
|
||||||
|
export const EVENT_SEMANTICS: Json = {
|
||||||
|
eventName: 'Kammerkonzert im Turmsaal',
|
||||||
|
eventStartDate: '2026-12-12T19:30:00+01:00',
|
||||||
|
eventEndDate: '2026-12-12T21:45:00+01:00',
|
||||||
|
venueName: 'Turmsaal Hildesheim',
|
||||||
|
venueRegionName: 'Hildesheim',
|
||||||
|
confirmationNumber: 'TKT-30918',
|
||||||
|
attendeeName: 'Jana Beispiel',
|
||||||
|
};
|
||||||
|
|
||||||
|
export const eventTicket = (semantics: Json | null = EVENT_SEMANTICS) => pass('eventTicket', {}, semantics);
|
||||||
|
export const otherPass = (style: 'storeCard' | 'coupon' | 'generic') => pass(style, {}, { ...EVENT_SEMANTICS, ...AIR_SEMANTICS });
|
||||||
|
|
||||||
|
/** A .pkpass archive: pass.json plus the files a real one carries. */
|
||||||
|
export async function zipPass(passJson: unknown, options: { raw?: string; files?: Record<string, string> } = {}): Promise<Uint8Array> {
|
||||||
|
const zip = new JSZip();
|
||||||
|
zip.file('pass.json', options.raw ?? JSON.stringify(passJson));
|
||||||
|
zip.file('manifest.json', JSON.stringify({ 'pass.json': '0000000000000000000000000000000000000000' }));
|
||||||
|
zip.file('signature', 'not a real signature');
|
||||||
|
zip.file('icon.png', 'png bytes');
|
||||||
|
for (const [name, content] of Object.entries(options.files ?? {})) zip.file(name, content);
|
||||||
|
return zip.generateAsync({ type: 'uint8array', compression: 'DEFLATE' });
|
||||||
|
}
|
||||||
@@ -0,0 +1,293 @@
|
|||||||
|
// Mirrors webmail lib/structured-mail/__tests__/pkpass.test.ts at 1d4cae961; keep the two in step.
|
||||||
|
import JSZip from 'jszip';
|
||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { loadAirportZones, mergeCandidates, readStructuredMail, resolveCards, type EventBooking, type TransportBooking } from '..';
|
||||||
|
import { isPkpassAttachment, passToCards, readPassJson, readPkpass } from '../pkpass';
|
||||||
|
import {
|
||||||
|
AIR_SEMANTICS,
|
||||||
|
BUS_SEMANTICS,
|
||||||
|
EVENT_SEMANTICS,
|
||||||
|
MAIL_DATE,
|
||||||
|
TRAIN_SEMANTICS,
|
||||||
|
boardingPass,
|
||||||
|
eventTicket,
|
||||||
|
otherPass,
|
||||||
|
zipPass,
|
||||||
|
} from './pkpass-fixtures';
|
||||||
|
|
||||||
|
const CTX = { mailDate: MAIL_DATE };
|
||||||
|
|
||||||
|
/** The same flight as AIR_SEMANTICS, as the booking mail's JSON-LD would give it. */
|
||||||
|
const MARKUP = `<html><head><script type="application/ld+json">${JSON.stringify({
|
||||||
|
'@context': 'http://schema.org', '@type': 'FlightReservation', reservationNumber: 'PK7Q2Z',
|
||||||
|
underName: { '@type': 'Person', name: 'Jana Beispiel' },
|
||||||
|
reservationFor: {
|
||||||
|
'@type': 'Flight', flightNumber: 'LX1072', airline: { '@type': 'Airline', name: 'Swiss', iataCode: 'LX' },
|
||||||
|
departureAirport: { '@type': 'Airport', name: 'Zurich Airport', iataCode: 'ZRH' }, departureTime: '2026-12-03T07:40:00+01:00',
|
||||||
|
arrivalAirport: { '@type': 'Airport', name: 'Berlin Brandenburg', iataCode: 'BER' }, arrivalTime: '2026-12-03T09:05:00+01:00',
|
||||||
|
},
|
||||||
|
})}</script></head><body></body></html>`;
|
||||||
|
|
||||||
|
async function cardsFrom(pass: unknown) {
|
||||||
|
return readPkpass(await zipPass(pass), CTX);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('isPkpassAttachment', () => {
|
||||||
|
it('goes by file name or type, up to 5 MB', () => {
|
||||||
|
expect(isPkpassAttachment({ name: 'BoardingPass.PKPASS', type: 'application/octet-stream', size: 4000 })).toBe(true);
|
||||||
|
expect(isPkpassAttachment({ name: 'ticket', type: 'application/vnd.apple.pkpass; name=x', size: 4000 })).toBe(true);
|
||||||
|
expect(isPkpassAttachment({ name: 'ticket.pdf', type: 'application/pdf', size: 4000 })).toBe(false);
|
||||||
|
expect(isPkpassAttachment({ name: 'big.pkpass', type: 'application/vnd.apple.pkpass', size: 5 * 1024 * 1024 + 1 })).toBe(false);
|
||||||
|
expect(isPkpassAttachment({ name: 'empty.pkpass', type: 'application/vnd.apple.pkpass', size: 0 })).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('passToCards', () => {
|
||||||
|
it('turns an air boarding pass into the flight booking the markup describes, with the same key', async () => {
|
||||||
|
const [card] = await cardsFrom(boardingPass()) as TransportBooking[];
|
||||||
|
expect(card).toMatchObject({
|
||||||
|
kind: 'flight', source: 'pkpass', reference: 'PK7Q2Z', passengers: ['Jana Beispiel'],
|
||||||
|
provider: 'Swiss International Air Lines', links: {},
|
||||||
|
});
|
||||||
|
expect(card.legs).toEqual([{
|
||||||
|
carrier: 'Swiss International Air Lines', carrierCode: 'LX', number: '1072',
|
||||||
|
departure: { place: { code: 'ZRH', name: 'Zurich Airport' }, at: { date: '2026-12-03', time: '07:40:00', offsetMinutes: 60, utc: '2026-12-03T06:40:00Z' } },
|
||||||
|
arrival: { place: { code: 'BER', name: 'Berlin Brandenburg' }, at: { date: '2026-12-03', time: '09:05:00', offsetMinutes: 60, utc: '2026-12-03T08:05:00Z' } },
|
||||||
|
seats: [{ passenger: 'Jana Beispiel', seat: '14C' }],
|
||||||
|
}]);
|
||||||
|
const [markup] = readStructuredMail(MARKUP, CTX);
|
||||||
|
expect(markup.key).toBe('flight|ref:pk7q2z');
|
||||||
|
expect(card.key).toBe(markup.key);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('merges with the markup booking into one card with one leg', async () => {
|
||||||
|
const pass = await cardsFrom(boardingPass());
|
||||||
|
const markup = readStructuredMail(MARKUP, CTX);
|
||||||
|
const merged = mergeCandidates([...markup, ...pass].map((card) => ({ card, problems: [] }))) as TransportBooking[];
|
||||||
|
expect(merged).toHaveLength(1);
|
||||||
|
expect(merged[0].legs).toHaveLength(1);
|
||||||
|
expect(merged[0].legs[0].seats).toEqual([{ passenger: 'Jana Beispiel', seat: '14C' }]);
|
||||||
|
expect(merged[0].passengers).toEqual(['Jana Beispiel']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('merges with markup that pads the flight number with zeros (LX0072 and flightNumber 72)', async () => {
|
||||||
|
const pass = await cardsFrom(boardingPass({ ...AIR_SEMANTICS, flightNumber: 72, flightCode: 'LX72' }));
|
||||||
|
const markup = readStructuredMail(MARKUP.replace('"flightNumber":"LX1072"', '"flightNumber":"LX0072"'), CTX);
|
||||||
|
const merged = mergeCandidates([...markup, ...pass].map((card) => ({ card, problems: [] }))) as TransportBooking[];
|
||||||
|
expect(merged).toHaveLength(1);
|
||||||
|
expect(merged[0].legs).toHaveLength(1);
|
||||||
|
const { confirmationNumber: _ref, ...noRef } = AIR_SEMANTICS;
|
||||||
|
const [unreferenced] = await cardsFrom(boardingPass({ ...noRef, flightNumber: 72, flightCode: 'LX72' }));
|
||||||
|
const [markupLeg] = readStructuredMail(MARKUP.replace('"reservationNumber":"PK7Q2Z",', '').replace('"flightNumber":"LX1072"', '"flightNumber":"LX0072"'), CTX);
|
||||||
|
expect(unreferenced.key).toBe(markupLeg.key);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keys a pass without confirmation number by its leg, like markup without one', async () => {
|
||||||
|
const { confirmationNumber: _ref, ...semantics } = AIR_SEMANTICS;
|
||||||
|
const [card] = await cardsFrom(boardingPass(semantics));
|
||||||
|
expect(card.key).toBe('flight|leg:lx|1072|2026-12-03|zrh');
|
||||||
|
const [markup] = readStructuredMail(MARKUP.replace('"reservationNumber":"PK7Q2Z",', ''), CTX);
|
||||||
|
expect(card.key).toBe(markup.key);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('gives nothing for a flight whose departure airport has a name but no code, as its leg could not be matched', async () => {
|
||||||
|
const { departureAirportCode: _c, ...nameOnly } = AIR_SEMANTICS;
|
||||||
|
expect(await cardsFrom(boardingPass(nameOnly))).toEqual([]);
|
||||||
|
expect(await cardsFrom(boardingPass({ ...AIR_SEMANTICS, departureAirportCode: 'LSZH' }))).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps a leg without arrival time and resolves its zone like any flight', async () => {
|
||||||
|
const { originalArrivalDate: _a, ...semantics } = AIR_SEMANTICS;
|
||||||
|
const cards = await cardsFrom(boardingPass(semantics));
|
||||||
|
const [card] = resolveCards(cards, await loadAirportZones()) as TransportBooking[];
|
||||||
|
expect(card.legs[0].arrival).toEqual({ place: { code: 'BER', name: 'Berlin Brandenburg' } });
|
||||||
|
expect(card.legs[0].departure.at.zone).toBe('Europe/Zurich');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reads flightCode alone and refuses a flightCode that contradicts airlineCode and flightNumber', async () => {
|
||||||
|
const { airlineCode: _c, flightNumber: _n, ...codeOnly } = AIR_SEMANTICS;
|
||||||
|
const [card] = await cardsFrom(boardingPass(codeOnly)) as TransportBooking[];
|
||||||
|
expect(card.legs[0]).toMatchObject({ carrierCode: 'LX', number: '1072' });
|
||||||
|
expect(await cardsFrom(boardingPass({ ...AIR_SEMANTICS, flightCode: 'LX1073' }))).toEqual([]);
|
||||||
|
expect(await cardsFrom(boardingPass({ ...AIR_SEMANTICS, airlineCode: 'SWR' }))).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses the updated departure, and only the updated arrival with it', async () => {
|
||||||
|
const [card] = await cardsFrom(boardingPass({ ...AIR_SEMANTICS, currentDepartureDate: '2026-12-03T08:25+01:00' })) as TransportBooking[];
|
||||||
|
expect(card.legs[0].departure.at.time).toBe('08:25:00');
|
||||||
|
expect(card.legs[0].arrival?.at).toBeUndefined();
|
||||||
|
expect(await cardsFrom(boardingPass({ ...AIR_SEMANTICS, currentDepartureDate: '2026-12-04T00:30+01:00' }))).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('pairs the scheduled arrival only with the scheduled departure', async () => {
|
||||||
|
const { originalDepartureDate: _d, ...noOriginal } = AIR_SEMANTICS;
|
||||||
|
const [delayed] = await cardsFrom(boardingPass({ ...noOriginal, currentDepartureDate: '2026-12-03T08:25+01:00' })) as TransportBooking[];
|
||||||
|
expect(delayed.legs[0].departure.at.time).toBe('08:25:00');
|
||||||
|
expect(delayed.legs[0].arrival).toEqual({ place: { code: 'BER', name: 'Berlin Brandenburg' } });
|
||||||
|
const [updated] = await cardsFrom(boardingPass({ ...noOriginal, currentDepartureDate: '2026-12-03T08:25+01:00', currentArrivalDate: '2026-12-03T09:50+01:00' })) as TransportBooking[];
|
||||||
|
expect(updated.legs[0].arrival?.at?.time).toBe('09:50:00');
|
||||||
|
const [same] = await cardsFrom(boardingPass({ ...AIR_SEMANTICS, currentDepartureDate: '2026-12-03T07:40:00+01:00' })) as TransportBooking[];
|
||||||
|
expect(same.legs[0].arrival?.at?.time).toBe('09:05:00');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('places a UTC flight time in the airport zone once resolved, with or without a zone tag', async () => {
|
||||||
|
const zones = await loadAirportZones();
|
||||||
|
const { departureLocationTimeZone: _z, destinationLocationTimeZone: _y, ...noTags } = AIR_SEMANTICS;
|
||||||
|
const utc = { ...noTags, originalDepartureDate: '2026-12-03T06:40:00Z', originalArrivalDate: '2026-12-03T08:05:00Z' };
|
||||||
|
const raw = await cardsFrom(boardingPass(utc)) as TransportBooking[];
|
||||||
|
expect(raw[0].legs[0].departure.at.instantOnly).toBe(true);
|
||||||
|
const [card] = resolveCards(raw, zones) as TransportBooking[];
|
||||||
|
expect(card.legs[0].departure.at).toEqual({ date: '2026-12-03', time: '07:40:00', offsetMinutes: 60, utc: '2026-12-03T06:40:00Z', zone: 'Europe/Zurich' });
|
||||||
|
expect(card.legs[0].arrival?.at).toMatchObject({ time: '09:05:00', zone: 'Europe/Berlin' });
|
||||||
|
expect(resolveCards(raw)).toEqual([]);
|
||||||
|
|
||||||
|
const [tagged] = await cardsFrom(boardingPass({ ...AIR_SEMANTICS, originalDepartureDate: '2026-12-03T06:40:00+00:00' })) as TransportBooking[];
|
||||||
|
expect(tagged.legs[0].departure.at).toEqual({ date: '2026-12-03', time: '07:40:00', offsetMinutes: 60, utc: '2026-12-03T06:40:00Z' });
|
||||||
|
|
||||||
|
const london = { ...noTags, airlineCode: 'BA', flightNumber: 710, flightCode: 'BA710', departureAirportCode: 'LHR', departureAirportName: 'Heathrow', originalDepartureDate: '2026-12-03T06:40:00+00:00', originalArrivalDate: '2026-12-03T09:10:00+01:00' };
|
||||||
|
const [lhr] = resolveCards(await cardsFrom(boardingPass(london)), zones) as TransportBooking[];
|
||||||
|
expect(lhr.legs[0].departure.at).toMatchObject({ date: '2026-12-03', time: '06:40:00', offsetMinutes: 0, zone: 'Europe/London' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('places a UTC train time only with a zone tag, and never a UTC event time', async () => {
|
||||||
|
const utcTrain = { ...TRAIN_SEMANTICS, originalDepartureDate: '2026-12-05T07:46:00Z', originalArrivalDate: '2026-12-05T11:50:00Z' };
|
||||||
|
expect(await cardsFrom(boardingPass(utcTrain, 'PKTransitTypeTrain'))).toEqual([]);
|
||||||
|
const tagged = { ...utcTrain, departureLocationTimeZone: 'Europe/Berlin', destinationLocationTimeZone: 'Europe/Berlin' };
|
||||||
|
const [train] = await cardsFrom(boardingPass(tagged, 'PKTransitTypeTrain')) as TransportBooking[];
|
||||||
|
expect(train.legs[0].departure.at).toEqual({ date: '2026-12-05', time: '08:46:00', offsetMinutes: 60, utc: '2026-12-05T07:46:00Z' });
|
||||||
|
expect(train.legs[0].arrival?.at?.time).toBe('12:50:00');
|
||||||
|
expect(await cardsFrom(boardingPass({ ...BUS_SEMANTICS, currentDepartureDate: '2026-12-06T09:15:00Z' }, 'PKTransitTypeBus'))).toEqual([]);
|
||||||
|
expect(await cardsFrom(eventTicket({ ...EVENT_SEMANTICS, eventStartDate: '2026-12-12T18:30:00Z' }))).toEqual([]);
|
||||||
|
expect(await cardsFrom(eventTicket({ ...EVENT_SEMANTICS, eventEndDate: '2026-12-12T20:45:00Z' }))).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps the card only for a transit status that says the trip runs as planned', async () => {
|
||||||
|
for (const status of ['On Time', 'pünktlich', 'Boarding', 'SCHEDULED', 'planmäßig', 'on-time']) {
|
||||||
|
expect(await cardsFrom(boardingPass({ ...AIR_SEMANTICS, transitStatus: status })), status).toHaveLength(1);
|
||||||
|
}
|
||||||
|
for (const status of ['Delayed', 'Verspätet 40 Min.', 'Diverted', 'fällt aus', 'gestrichen', 'Gate closed', 'Cancelled']) {
|
||||||
|
expect(await cardsFrom(boardingPass({ ...AIR_SEMANTICS, transitStatus: status })), status).toEqual([]);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('gives nothing when the pass zone disagrees with the time, or the status says cancelled', async () => {
|
||||||
|
expect(await cardsFrom(boardingPass({ ...AIR_SEMANTICS, departureLocationTimeZone: 'America/New_York' }))).toEqual([]);
|
||||||
|
expect(await cardsFrom(boardingPass({ ...AIR_SEMANTICS, departureLocationTimeZone: 'Not/AZone' }))).toEqual([]);
|
||||||
|
expect(await cardsFrom(boardingPass({ ...AIR_SEMANTICS, transitStatus: 'Cancelled' }))).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('turns a train boarding pass into a train booking', async () => {
|
||||||
|
const [card] = await cardsFrom(boardingPass(TRAIN_SEMANTICS, 'PKTransitTypeTrain')) as TransportBooking[];
|
||||||
|
expect(card).toMatchObject({ kind: 'train', reference: 'ZUG4471', key: 'train|ref:zug4471', provider: 'Deutsche Bahn', passengers: ['Jana Beispiel'] });
|
||||||
|
expect(card.legs).toEqual([{
|
||||||
|
carrier: 'Deutsche Bahn', number: 'ICE 597',
|
||||||
|
departure: { place: { name: 'Hamburg Hbf' }, at: { date: '2026-12-05', time: '08:46:00', offsetMinutes: 60, utc: '2026-12-05T07:46:00Z' } },
|
||||||
|
arrival: { place: { name: 'Köln Hbf' }, at: { date: '2026-12-05', time: '12:50:00', offsetMinutes: 60, utc: '2026-12-05T11:50:00Z' } },
|
||||||
|
seats: [{ passenger: 'Jana Beispiel', seat: '7/45' }],
|
||||||
|
}]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('turns a bus boarding pass into a bus booking', async () => {
|
||||||
|
const [card] = await cardsFrom(boardingPass(BUS_SEMANTICS, 'PKTransitTypeBus')) as TransportBooking[];
|
||||||
|
expect(card).toMatchObject({ kind: 'bus', reference: 'BUS-80411', passengers: ['Ole Probe'] });
|
||||||
|
expect(card.legs[0]).toMatchObject({ departure: { place: { name: 'Bremen ZOB' }, at: { time: '10:15:00' } }, arrival: { place: { name: 'Hannover ZOB' } } });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('turns an event ticket into an event booking', async () => {
|
||||||
|
const [card] = await cardsFrom(eventTicket()) as EventBooking[];
|
||||||
|
expect(card).toEqual({
|
||||||
|
key: 'event|tkt-30918|kammerkonzert im turmsaal|2026-12-12', kind: 'event', reference: 'TKT-30918',
|
||||||
|
passengers: ['Jana Beispiel'], provider: 'Swiss', links: {}, source: 'pkpass', name: 'Kammerkonzert im Turmsaal',
|
||||||
|
venue: { name: 'Turmsaal Hildesheim', address: { locality: 'Hildesheim' } },
|
||||||
|
start: { date: '2026-12-12', time: '19:30:00', offsetMinutes: 60, utc: '2026-12-12T18:30:00Z' },
|
||||||
|
end: { date: '2026-12-12', time: '21:45:00', offsetMinutes: 60, utc: '2026-12-12T20:45:00Z' },
|
||||||
|
tickets: [],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ignores store cards, coupons and generic passes', async () => {
|
||||||
|
for (const style of ['storeCard', 'coupon', 'generic'] as const) expect(await cardsFrom(otherPass(style))).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('gives nothing for a pass without semantics, even with relevantDate and organizationName', async () => {
|
||||||
|
expect(await cardsFrom(boardingPass(null))).toEqual([]);
|
||||||
|
expect(await cardsFrom(eventTicket(null))).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('gives nothing for boat, generic or unknown transit types', async () => {
|
||||||
|
for (const type of ['PKTransitTypeBoat', 'PKTransitTypeGeneric', 'PKTransitTypeRocket', '']) {
|
||||||
|
expect(await cardsFrom(boardingPass(AIR_SEMANTICS, type))).toEqual([]);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('gives nothing when semantics lack what validation needs', async () => {
|
||||||
|
const { originalDepartureDate: _d, ...noDeparture } = AIR_SEMANTICS;
|
||||||
|
expect(await cardsFrom(boardingPass(noDeparture))).toEqual([]);
|
||||||
|
const { destinationAirportCode: _c, destinationAirportName: _n, ...noArrival } = AIR_SEMANTICS;
|
||||||
|
expect(await cardsFrom(boardingPass(noArrival))).toEqual([]);
|
||||||
|
const { departureStationName: _s, ...noStation } = TRAIN_SEMANTICS;
|
||||||
|
expect(await cardsFrom(boardingPass(noStation, 'PKTransitTypeTrain'))).toEqual([]);
|
||||||
|
const { eventStartDate: _e, ...noStart } = EVENT_SEMANTICS;
|
||||||
|
expect(await cardsFrom(eventTicket(noStart))).toEqual([]);
|
||||||
|
expect(await cardsFrom(boardingPass({ ...AIR_SEMANTICS, originalDepartureDate: '3 Dec 2026 07:40' }))).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('gives nothing for a voided pass, a wrong formatVersion or two pass styles', async () => {
|
||||||
|
expect(await cardsFrom(boardingPass(AIR_SEMANTICS, 'PKTransitTypeAir', { voided: true }))).toEqual([]);
|
||||||
|
expect(await cardsFrom(boardingPass(AIR_SEMANTICS, 'PKTransitTypeAir', { formatVersion: 2 }))).toEqual([]);
|
||||||
|
expect(await cardsFrom(boardingPass(AIR_SEMANTICS, 'PKTransitTypeAir', { eventTicket: {} }))).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('gives nothing for dates far from the mail date', async () => {
|
||||||
|
const far = { ...AIR_SEMANTICS, originalDepartureDate: '2028-06-03T07:40+02:00', originalArrivalDate: '2028-06-03T09:05+02:00' };
|
||||||
|
expect(passToCards(boardingPass(far), CTX)).toEqual([]);
|
||||||
|
expect(passToCards(eventTicket({ ...EVENT_SEMANTICS, eventStartDate: '2024-01-12T19:30:00+01:00', eventEndDate: undefined }), CTX)).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('readPassJson', () => {
|
||||||
|
it('reads pass.json, with or without a byte order mark', async () => {
|
||||||
|
expect(await readPassJson(await zipPass(boardingPass()))).toMatchObject({ formatVersion: 1, semantics: { airlineCode: 'LX' } });
|
||||||
|
expect(await readPassJson(await zipPass(null, { raw: String.fromCharCode(0xfeff) + JSON.stringify(eventTicket()) }))).toMatchObject({ eventTicket: {} });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('gives undefined without throwing for anything doubtful', async () => {
|
||||||
|
const valid = await zipPass(boardingPass());
|
||||||
|
const corrupt = valid.slice(0, valid.length / 2);
|
||||||
|
const noPass = await (() => { const zip = new JSZip(); zip.file('manifest.json', '{}'); return zip.generateAsync({ type: 'uint8array' }); })();
|
||||||
|
const huge = await zipPass({ ...boardingPass(), padding: 'x'.repeat(300 * 1024) });
|
||||||
|
const manyFiles = await zipPass(boardingPass(), { files: Object.fromEntries(Array.from({ length: 100 }, (_, i) => [`en.lproj/f${i}.txt`, 'x'])) });
|
||||||
|
const notJson = await zipPass(null, { raw: '{"formatVersion": 1, "boardingPass": ' });
|
||||||
|
const notObject = await zipPass(null, { raw: '[1, 2]' });
|
||||||
|
for (const bytes of [new Uint8Array(), new Uint8Array([1, 2, 3, 4]), corrupt, noPass, huge, manyFiles, notJson, notObject]) {
|
||||||
|
await expect(readPassJson(bytes)).resolves.toBeUndefined();
|
||||||
|
}
|
||||||
|
expect(await readPkpass(huge, CTX)).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('never inflates anything but pass.json', async () => {
|
||||||
|
const zip = new JSZip();
|
||||||
|
zip.file('pass.json', JSON.stringify(boardingPass()));
|
||||||
|
zip.file('strip.png', 'y'.repeat(4 * 1024 * 1024));
|
||||||
|
zip.file('en.lproj/pass.strings', '"a" = "b";');
|
||||||
|
const bytes = await zip.generateAsync({ type: 'uint8array', compression: 'DEFLATE' });
|
||||||
|
const real = await JSZip.loadAsync(bytes);
|
||||||
|
// Every way to inflate an entry, on every entry, however it is reached
|
||||||
|
// (zip.file(), zip.files[...], forEach): record which entries are read.
|
||||||
|
const inflated: string[] = [];
|
||||||
|
for (const entry of Object.values(real.files)) {
|
||||||
|
const target = entry as unknown as Record<string, (...args: unknown[]) => unknown>;
|
||||||
|
for (const method of ['async', 'internalStream', 'nodeStream']) {
|
||||||
|
const original = target[method];
|
||||||
|
if (typeof original !== 'function') continue;
|
||||||
|
target[method] = function (this: unknown, ...args: unknown[]) {
|
||||||
|
inflated.push(entry.name);
|
||||||
|
return original.apply(this, args);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const loadZip = async () => ({ loadAsync: async () => real }) as unknown as typeof JSZip;
|
||||||
|
expect(await readPkpass(bytes, CTX, { loadZip })).toHaveLength(1);
|
||||||
|
expect(inflated).toEqual(['pass.json']);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
// Mirrors webmail lib/structured-mail/__tests__/structured-mail-fixtures.test.ts at fa324170a; keep the two in step.
|
||||||
|
/**
|
||||||
|
* Every fixture through the whole pipeline (extract, normalise, validate,
|
||||||
|
* merge, resolve zones), compared field by field with the reviewed cards in
|
||||||
|
* expected-cards.json. Real samples are the 11 card-relevant mails from
|
||||||
|
* local-data/schema examples, redacted; synthetic ones are self-written.
|
||||||
|
* Doubtful fixtures must give no card at all.
|
||||||
|
*/
|
||||||
|
import { afterAll, describe, expect, it } from 'vitest';
|
||||||
|
import { loadAirportZones, needsAirportZones, readStructuredMail, resolveCards, type StructuredCard } from '..';
|
||||||
|
import expected from './fixtures/expected-cards.json';
|
||||||
|
import real from './fixtures/real-mails.json';
|
||||||
|
import { syntheticMails, type MailFixture } from './fixtures/synthetic-mails';
|
||||||
|
|
||||||
|
const fixtures: MailFixture[] = [...(real as MailFixture[]), ...syntheticMails];
|
||||||
|
const expectedById = expected as Record<string, StructuredCard[]>;
|
||||||
|
|
||||||
|
async function cardsOf(fixture: MailFixture): Promise<StructuredCard[]> {
|
||||||
|
const cards = readStructuredMail(fixture.html, { mailDate: new Date(fixture.mailDate) });
|
||||||
|
return needsAirportZones(cards) ? resolveCards(cards, await loadAirportZones()) : resolveCards(cards);
|
||||||
|
}
|
||||||
|
|
||||||
|
type Score = { mails: number; found: number; correct: number; wrong: number; none: number };
|
||||||
|
const scores = new Map<string, Score>();
|
||||||
|
|
||||||
|
describe('structured mail fixtures', () => {
|
||||||
|
it('has an expectation for every fixture and no stray ones', () => {
|
||||||
|
expect(Object.keys(expectedById).sort()).toEqual(fixtures.map((f) => f.id).sort());
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each(fixtures.map((f) => [f.id, f] as const))('%s', async (_id, fixture) => {
|
||||||
|
const cards = await cardsOf(fixture);
|
||||||
|
const want = expectedById[fixture.id];
|
||||||
|
const score = scores.get(fixture.sender) ?? { mails: 0, found: 0, correct: 0, wrong: 0, none: 0 };
|
||||||
|
score.mails++;
|
||||||
|
const equal = JSON.stringify(cards) === JSON.stringify(want);
|
||||||
|
if (cards.length === 0) score.none++;
|
||||||
|
else {
|
||||||
|
score.found++;
|
||||||
|
if (equal) score.correct++;
|
||||||
|
else score.wrong++;
|
||||||
|
}
|
||||||
|
scores.set(fixture.sender, score);
|
||||||
|
expect(cards).toEqual(want);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('gives nothing for every doubtful fixture', async () => {
|
||||||
|
for (const fixture of fixtures.filter((f) => f.id.startsWith('doubtful-'))) {
|
||||||
|
expect(await cardsOf(fixture), fixture.id).toEqual([]);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(() => {
|
||||||
|
const rows = [...scores.entries()].sort(([a], [b]) => a.localeCompare(b));
|
||||||
|
const lines = rows.map(([sender, s]) => `${sender.padEnd(28)} mails ${s.mails} found ${s.found} correct ${s.correct} wrong ${s.wrong} none ${s.none}`);
|
||||||
|
const total = rows.reduce((t, [, s]) => ({ found: t.found + s.found, correct: t.correct + s.correct, wrong: t.wrong + s.wrong }), { found: 0, correct: 0, wrong: 0 });
|
||||||
|
console.log(`structured mail score per sender\n${lines.join('\n')}\ntotal found ${total.found}, correct ${total.correct}, wrong ${total.wrong}`);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,128 @@
|
|||||||
|
// Mirrors webmail lib/structured-mail/__tests__/time-zones.test.ts at a529e6e57; keep the two in step.
|
||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import table from '../data/airport-timezones.json';
|
||||||
|
import {
|
||||||
|
countryZone,
|
||||||
|
fixedOffsetZone,
|
||||||
|
isValidTimeZone,
|
||||||
|
loadAirportZones,
|
||||||
|
resolveZone,
|
||||||
|
wallClockAt,
|
||||||
|
zonedTimeToUtc,
|
||||||
|
zoneOffsetAt,
|
||||||
|
} from '../time-zones';
|
||||||
|
|
||||||
|
const utc = (iso: string) => Date.parse(iso);
|
||||||
|
|
||||||
|
describe('airport time zone table', () => {
|
||||||
|
it('maps IATA codes to their IANA zones', async () => {
|
||||||
|
const zoneOf = await loadAirportZones();
|
||||||
|
expect(zoneOf('FRA')).toBe('Europe/Berlin');
|
||||||
|
expect(zoneOf('JFK')).toBe('America/New_York');
|
||||||
|
expect(zoneOf('SFO')).toBe('America/Los_Angeles');
|
||||||
|
expect(zoneOf('NRT')).toBe('Asia/Tokyo');
|
||||||
|
expect(zoneOf('HNL')).toBe('Pacific/Honolulu');
|
||||||
|
expect(zoneOf('syd')).toBe('Australia/Sydney');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('knows nothing about codes it does not have', async () => {
|
||||||
|
const zoneOf = await loadAirportZones();
|
||||||
|
expect(zoneOf('QQQ')).toBeUndefined();
|
||||||
|
expect(zoneOf('FRAX')).toBeUndefined();
|
||||||
|
expect(zoneOf(undefined)).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('holds only valid zone names and indexes inside the zone list', () => {
|
||||||
|
expect(Object.keys(table.airports).length).toBeGreaterThan(7000);
|
||||||
|
for (const zone of table.zones) expect(isValidTimeZone(zone), zone).toBe(true);
|
||||||
|
for (const index of Object.values(table.airports)) {
|
||||||
|
expect(index).toBeGreaterThanOrEqual(0);
|
||||||
|
expect(index).toBeLessThan(table.zones.length);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('fixedOffsetZone', () => {
|
||||||
|
it('inverts the sign for whole hours', () => {
|
||||||
|
expect(fixedOffsetZone(-8 * 60)).toBe('Etc/GMT+8');
|
||||||
|
expect(fixedOffsetZone(5 * 60)).toBe('Etc/GMT-5');
|
||||||
|
expect(fixedOffsetZone(14 * 60)).toBe('Etc/GMT-14');
|
||||||
|
expect(fixedOffsetZone(-12 * 60)).toBe('Etc/GMT+12');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps the UTC instant for half hours and offsets without an Etc zone', () => {
|
||||||
|
expect(fixedOffsetZone(0)).toBe('Etc/UTC');
|
||||||
|
expect(fixedOffsetZone(330)).toBe('Etc/UTC');
|
||||||
|
expect(fixedOffsetZone(345)).toBe('Etc/UTC');
|
||||||
|
expect(fixedOffsetZone(-13 * 60)).toBe('Etc/UTC');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('produces zones whose offset is the one asked for', () => {
|
||||||
|
for (const hours of [-12, -8, -1, 1, 5, 9, 14]) {
|
||||||
|
const zone = fixedOffsetZone(hours * 60);
|
||||||
|
expect(zoneOffsetAt(zone, utc('2026-07-01T00:00:00Z'))).toBe(hours * 60);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('zoneOffsetAt and wall clocks', () => {
|
||||||
|
it('follows DST', () => {
|
||||||
|
expect(zoneOffsetAt('Europe/Berlin', utc('2026-01-15T12:00:00Z'))).toBe(60);
|
||||||
|
expect(zoneOffsetAt('Europe/Berlin', utc('2026-07-15T12:00:00Z'))).toBe(120);
|
||||||
|
expect(zoneOffsetAt('America/Los_Angeles', utc('2027-03-05T04:15:00Z'))).toBe(-480);
|
||||||
|
expect(zoneOffsetAt('Asia/Kathmandu', utc('2026-07-15T12:00:00Z'))).toBe(345);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('turns an instant into the local date and time across the date line', () => {
|
||||||
|
expect(wallClockAt(utc('2027-03-05T04:15:00Z'), 'America/Los_Angeles')).toEqual({ date: '2027-03-04', time: '20:15:00' });
|
||||||
|
expect(wallClockAt(utc('2026-12-01T00:30:00Z'), 'Asia/Tokyo')).toEqual({ date: '2026-12-01', time: '09:30:00' });
|
||||||
|
expect(wallClockAt(utc('2026-12-01T00:30:00Z'), 'Pacific/Honolulu')).toEqual({ date: '2026-11-30', time: '14:30:00' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('turns a local time back into the instant, and refuses DST gaps and overlaps', () => {
|
||||||
|
expect(zonedTimeToUtc('2027-03-04', '20:15', 'America/Los_Angeles')).toBe(utc('2027-03-05T04:15:00Z'));
|
||||||
|
expect(zonedTimeToUtc('2026-12-01', '09:30:00', 'Asia/Tokyo')).toBe(utc('2026-12-01T00:30:00Z'));
|
||||||
|
// 02:30 on 29 March 2026 doesn't exist in Berlin; 02:30 on 25 October happens twice.
|
||||||
|
expect(zonedTimeToUtc('2026-03-29', '02:30', 'Europe/Berlin')).toBeNull();
|
||||||
|
expect(zonedTimeToUtc('2026-10-25', '02:30', 'Europe/Berlin')).toBeNull();
|
||||||
|
expect(zonedTimeToUtc('2026-13-01', '10:00', 'Europe/Berlin')).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('countryZone', () => {
|
||||||
|
it('names the zone of single-zone countries only', () => {
|
||||||
|
expect(countryZone('DE')).toBe('Europe/Berlin');
|
||||||
|
expect(countryZone(' de ')).toBe('Europe/Berlin');
|
||||||
|
expect(countryZone('US')).toBeUndefined();
|
||||||
|
expect(countryZone('ES')).toBeUndefined();
|
||||||
|
expect(countryZone('Germany')).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not trust France for a time without an offset', () => {
|
||||||
|
expect(countryZone('FR')).toBe('Europe/Paris');
|
||||||
|
expect(countryZone('FR', { floating: true })).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('resolveZone', () => {
|
||||||
|
const at = utc('2026-06-04T09:44:00Z');
|
||||||
|
|
||||||
|
it('accepts a named zone that agrees with the offset', () => {
|
||||||
|
expect(resolveZone({ named: { zone: 'Europe/Berlin', source: 'country' }, offsetMinutes: 120, utcMs: at }))
|
||||||
|
.toEqual({ zone: 'Europe/Berlin', source: 'country' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reports a mismatch when table and mail disagree', () => {
|
||||||
|
expect(resolveZone({ named: { zone: 'Europe/Berlin', source: 'airport' }, offsetMinutes: 60, utcMs: at })).toBe('mismatch');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('falls back to a fixed offset, then to UTC, without a named zone', () => {
|
||||||
|
expect(resolveZone({ offsetMinutes: -480, utcMs: at })).toEqual({ zone: 'Etc/GMT+8', source: 'offset' });
|
||||||
|
expect(resolveZone({ offsetMinutes: 330, utcMs: at })).toEqual({ zone: 'Etc/UTC', source: 'utc' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reads a time without offset in the named zone, or leaves it floating', () => {
|
||||||
|
expect(resolveZone({ named: { zone: 'Asia/Tokyo', source: 'airport' } })).toEqual({ zone: 'Asia/Tokyo', source: 'airport' });
|
||||||
|
expect(resolveZone({})).toEqual({ zone: null, source: 'floating' });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
// The trust gate on the app's Email shape: only the raw `headers` list from
|
||||||
|
// Email/get, no pre-parsed Authentication-Results (the webmail's full fetch
|
||||||
|
// adds those; the ported trust tests pass them in).
|
||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import type { Email } from '../../../api/types';
|
||||||
|
import { getStructuredMailTrust } from '../trust';
|
||||||
|
import { isStructuredMailEnabled } from '../gate';
|
||||||
|
|
||||||
|
const HTML = '<script type="application/ld+json">{"@context":"http://schema.org","@type":"Order","orderNumber":"SO-1"}</script>';
|
||||||
|
const RECEIVED = { name: 'Received', value: 'from mail.shop.example (mail.shop.example [192.0.2.1]) by mx.example.org (Stalwart SMTP) with ESMTPS id 1' };
|
||||||
|
const PASS = 'mx.example.org; dkim=pass header.d=shop.example header.s=s1; spf=pass smtp.mailfrom=bounce@shop.example; dmarc=pass header.from=shop.example';
|
||||||
|
|
||||||
|
function mail(headers: { name: string; value: string }[], from = 'orders@shop.example'): Email {
|
||||||
|
return {
|
||||||
|
id: 'm1', threadId: 't1', mailboxIds: {}, keywords: {}, size: 1, hasAttachment: false,
|
||||||
|
receivedAt: '2026-10-30T09:00:00Z', subject: 'Your order SO-1', from: [{ email: from }], headers,
|
||||||
|
htmlBody: [{ partId: '2', type: 'text/html' }], textBody: [],
|
||||||
|
bodyValues: { '2': { value: HTML } },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const reason = (email: Email) => {
|
||||||
|
const trust = getStructuredMailTrust(email);
|
||||||
|
return trust.trusted ? 'trusted' : trust.reason;
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('getStructuredMailTrust from raw headers', () => {
|
||||||
|
it('trusts a delivered mail whose topmost Authentication-Results pass for the From domain', () => {
|
||||||
|
expect(reason(mail([RECEIVED, { name: 'Authentication-Results', value: PASS }]))).toBe('trusted');
|
||||||
|
// Header names come in any case.
|
||||||
|
expect(reason(mail([{ name: 'received', value: RECEIVED.value }, { name: 'authentication-results', value: PASS }]))).toBe('trusted');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('refuses without results, with failing ones, or when only a lower header passes', () => {
|
||||||
|
expect(reason(mail([RECEIVED]))).toBe('no_authentication_results');
|
||||||
|
expect(reason(mail([RECEIVED, { name: 'Authentication-Results', value: 'mx.example.org; spf=fail smtp.mailfrom=shop.example; dmarc=fail header.from=shop.example' }])))
|
||||||
|
.toBe('sender_not_verified');
|
||||||
|
// The receiving server's header is the topmost; one below it may be the sender's own.
|
||||||
|
expect(reason(mail([
|
||||||
|
RECEIVED,
|
||||||
|
{ name: 'Authentication-Results', value: 'mx.example.org; spf=none smtp.mailfrom=shop.example; dmarc=none' },
|
||||||
|
{ name: 'Authentication-Results', value: PASS },
|
||||||
|
]))).toBe('sender_not_verified');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('refuses results the sender wrote above every Received line', () => {
|
||||||
|
expect(reason(mail([{ name: 'Authentication-Results', value: PASS }, RECEIVED]))).toBe('not_written_on_receipt');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is on while the app has no admin policy', () => {
|
||||||
|
expect(isStructuredMailEnabled()).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,183 @@
|
|||||||
|
// Mirrors webmail lib/structured-mail/__tests__/trust.test.ts at 8098039d6; keep the two in step.
|
||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { parseAuthenticationResults } from '../../email-headers';
|
||||||
|
import { authenticationResultsOnReceipt } from '../receipt';
|
||||||
|
import type { Email } from '../../../api/types';
|
||||||
|
import type { EmailBodyPart } from '../trust';
|
||||||
|
import { hasForwardPrefix } from '../forward-prefix';
|
||||||
|
import { readStructuredMail } from '..';
|
||||||
|
import { getStructuredMailTrust, isPersonalMailboxDomain } from '../trust';
|
||||||
|
|
||||||
|
const HTML = `<html><head><script type="application/ld+json">${JSON.stringify({
|
||||||
|
'@context': 'http://schema.org', '@type': 'Order', orderNumber: 'SO-1', merchant: 'Shop', orderStatus: 'OrderProcessing',
|
||||||
|
})}</script></head><body><p>Thanks for your order.</p></body></html>`;
|
||||||
|
|
||||||
|
const AR = {
|
||||||
|
pass: 'mx.example.org; dkim=pass header.d=shop.example header.s=s1; spf=pass smtp.mailfrom=bounce@shop.example; dmarc=pass header.from=shop.example',
|
||||||
|
fail: 'mx.example.org; dkim=fail header.d=shop.example; spf=fail smtp.mailfrom=shop.example; dmarc=fail header.from=shop.example',
|
||||||
|
unverified: 'mx.example.org; dkim=pass header.d=bulk-sender.example; spf=pass smtp.mailfrom=bounce@bulk-sender.example; dmarc=none',
|
||||||
|
};
|
||||||
|
|
||||||
|
type Headers = { name: string; value: string }[];
|
||||||
|
|
||||||
|
function mail(o: {
|
||||||
|
from?: string; ar?: string; headers?: Headers; subject?: string; html?: string; text?: string;
|
||||||
|
truncated?: boolean; bodyStructure?: EmailBodyPart; onReceipt?: boolean;
|
||||||
|
} = {}): Email {
|
||||||
|
const from = o.from ?? 'orders@shop.example';
|
||||||
|
const headers: Headers = o.headers ?? [
|
||||||
|
{ name: 'Received', value: 'from mail.shop.example (mail.shop.example [192.0.2.1]) by mx.example.org (Stalwart SMTP) with ESMTPS id 1' },
|
||||||
|
...(o.ar ? [{ name: 'Authentication-Results', value: o.ar }] : []),
|
||||||
|
{ name: 'From', value: `<${from}>` },
|
||||||
|
];
|
||||||
|
const arValues = headers.filter((h) => h.name.toLowerCase() === 'authentication-results').map((h) => h.value);
|
||||||
|
return {
|
||||||
|
id: 'm1', threadId: 't1', mailboxIds: {}, keywords: {}, size: 1, receivedAt: '2026-10-30T09:00:00Z',
|
||||||
|
from: [{ email: from }], subject: o.subject ?? 'Your order SO-1',
|
||||||
|
headers: headers as unknown as Email['headers'],
|
||||||
|
...(arValues.length > 0 ? { authenticationResults: parseAuthenticationResults(arValues) } : {}),
|
||||||
|
...(o.onReceipt !== undefined ? { authenticationResultsOnReceipt: o.onReceipt } : {}),
|
||||||
|
htmlBody: [{ partId: '2', type: 'text/html' } as EmailBodyPart],
|
||||||
|
textBody: o.text !== undefined ? [{ partId: '1', type: 'text/plain' } as EmailBodyPart] : [],
|
||||||
|
bodyValues: {
|
||||||
|
'2': { value: o.html ?? HTML, isTruncated: !!o.truncated, isEncodingProblem: false },
|
||||||
|
...(o.text !== undefined ? { '1': { value: o.text, isTruncated: false, isEncodingProblem: false } } : {}),
|
||||||
|
},
|
||||||
|
...(o.bodyStructure ? { bodyStructure: o.bodyStructure } : {}),
|
||||||
|
} as unknown as Email;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** What the reader does: the trust gate, then the parser. */
|
||||||
|
function cardsFor(email: Email) {
|
||||||
|
if (!getStructuredMailTrust(email).trusted) return [];
|
||||||
|
const html = email.bodyValues!['2'].value;
|
||||||
|
return readStructuredMail(html, { mailDate: new Date(email.receivedAt) });
|
||||||
|
}
|
||||||
|
|
||||||
|
const reason = (email: Email) => {
|
||||||
|
const trust = getStructuredMailTrust(email);
|
||||||
|
return trust.trusted ? 'trusted' : trust.reason;
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('the same markup under each Authentication-Results verdict', () => {
|
||||||
|
it('gives a card only when SPF or DKIM passes for the From domain', () => {
|
||||||
|
expect(cardsFor(mail({ ar: AR.pass }))).toHaveLength(1);
|
||||||
|
expect(cardsFor(mail({ ar: AR.fail }))).toEqual([]);
|
||||||
|
expect(cardsFor(mail({ ar: AR.unverified }))).toEqual([]);
|
||||||
|
expect(cardsFor(mail({}))).toEqual([]);
|
||||||
|
// Internal: a colleague on the reader's own domain, handed in without any header.
|
||||||
|
expect(cardsFor(mail({ from: 'travel@example.org' }))).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('names the reason', () => {
|
||||||
|
expect(reason(mail({ ar: AR.pass }))).toBe('trusted');
|
||||||
|
expect(reason(mail({ ar: AR.fail }))).toBe('sender_not_verified');
|
||||||
|
expect(reason(mail({ ar: AR.unverified }))).toBe('sender_not_verified');
|
||||||
|
expect(reason(mail({}))).toBe('no_authentication_results');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('accepts a DKIM pass from a subdomain of the From domain (Uber signs as ibt.uber.com)', () => {
|
||||||
|
const ar = 'mx.google.com; dkim=pass header.i=@ibt.shop.example header.s=smtp3; spf=pass smtp.mailfrom=bounces@ibt.shop.example; dmarc=pass header.from=shop.example';
|
||||||
|
expect(reason(mail({ ar }))).toBe('trusted');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Authentication-Results written by the sender', () => {
|
||||||
|
it('refuses a passing header with no Received above it (submitted over JMAP)', () => {
|
||||||
|
const headers = [{ name: 'Authentication-Results', value: AR.pass }, { name: 'From', value: '<orders@shop.example>' }];
|
||||||
|
expect(reason(mail({ headers }))).toBe('not_written_on_receipt');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('refuses a passing header right below an authenticated submission hop', () => {
|
||||||
|
const headers = [
|
||||||
|
{ name: 'Received', value: 'from [10.0.0.5] (user@example.org) by mx.example.org with ESMTPSA id 7' },
|
||||||
|
{ name: 'Authentication-Results', value: AR.pass },
|
||||||
|
];
|
||||||
|
expect(reason(mail({ headers }))).toBe('not_written_on_receipt');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('trusts the flag the full message fetch recorded', () => {
|
||||||
|
expect(reason(mail({ ar: AR.pass, onReceipt: false }))).toBe('not_written_on_receipt');
|
||||||
|
expect(reason(mail({ ar: AR.pass, headers: [{ name: 'Authentication-Results', value: AR.pass }], onReceipt: true }))).toBe('trusted');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('authenticationResultsOnReceipt', () => {
|
||||||
|
it('accepts the header order Stalwart writes on delivery', () => {
|
||||||
|
expect(authenticationResultsOnReceipt([
|
||||||
|
{ name: 'Delivered-To', value: 'info@example.org' },
|
||||||
|
{ name: 'X-Spam-Status', value: 'No' },
|
||||||
|
{ name: 'Received', value: 'from smh-01.example.net (smh-01.example.net [178.254.0.201]) (using TLSv1.3 with cipher TLS13_AES_256_GCM_SHA384) by mail.example.org (Stalwart SMTP) with ESMTPS id 1' },
|
||||||
|
{ name: 'Authentication-Results', value: 'mail.example.org; dkim=pass header.d=shop.example' },
|
||||||
|
{ name: 'Received-SPF', value: 'pass' },
|
||||||
|
])).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('accepts the header order Gmail writes', () => {
|
||||||
|
expect(authenticationResultsOnReceipt([
|
||||||
|
{ name: 'Delivered-To', value: 'someone@gmail.com' },
|
||||||
|
{ name: 'Received', value: 'by 2002:a05:6a10:1b4a:b0:5b1:2::1 with SMTP id 10csp1;' },
|
||||||
|
{ name: 'X-Received', value: 'by 2002:a17:906 with SMTP id 1' },
|
||||||
|
{ name: 'ARC-Authentication-Results', value: 'i=1; mx.google.com; dkim=pass' },
|
||||||
|
{ name: 'Return-Path', value: '<bounce@shop.example>' },
|
||||||
|
{ 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('<p>Thanks', '<p>---------- Forwarded message ----------</p><p>Thanks');
|
||||||
|
expect(reason(mail({ ar: AR.pass, html: separator }))).toBe('forwarded');
|
||||||
|
const outlook = 'FYI\n\nVon: Shop <orders@shop.example>\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');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
// utf8.ts: pass.json decoding where Hermes lacks a fatal TextDecoder.
|
||||||
|
import { afterEach, describe, expect, it } from 'vitest';
|
||||||
|
import { readPassJson } from '../pkpass';
|
||||||
|
import { decodeByHand, decodeUtf8Strict } from '../utf8';
|
||||||
|
import { boardingPass, zipPass } from './pkpass-fixtures';
|
||||||
|
|
||||||
|
const encode = (text: string) => new TextEncoder().encode(text);
|
||||||
|
const original = globalThis.TextDecoder;
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
globalThis.TextDecoder = original;
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('decodeByHand', () => {
|
||||||
|
it('decodes what TextEncoder wrote, from ASCII to astral characters', () => {
|
||||||
|
const text = 'Zürich → 東京 \u{1F6EB} ok';
|
||||||
|
expect(decodeByHand(encode(text))).toBe(text);
|
||||||
|
const long = 'é'.repeat(10_000);
|
||||||
|
expect(decodeByHand(encode(long))).toBe(long);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws on malformed input as a fatal decoder does', () => {
|
||||||
|
for (const bad of [[0xc3], [0xff], [0xc0, 0x80], [0xed, 0xa0, 0x80], [0xf4, 0x90, 0x80, 0x80], [0xe2, 0x28, 0xa1]]) {
|
||||||
|
expect(() => decodeByHand(new Uint8Array(bad))).toThrow();
|
||||||
|
expect(() => new TextDecoder('utf-8', { fatal: true }).decode(new Uint8Array(bad))).toThrow();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('decodeUtf8Strict without TextDecoder', () => {
|
||||||
|
it('still reads a Wallet pass', async () => {
|
||||||
|
const bytes = await zipPass(boardingPass());
|
||||||
|
(globalThis as { TextDecoder?: unknown }).TextDecoder = undefined;
|
||||||
|
expect(decodeUtf8Strict(encode('Köln'))).toBe('Köln');
|
||||||
|
expect(await readPassJson(bytes)).toMatchObject({ formatVersion: 1 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('falls back when the decoder refuses the fatal option', () => {
|
||||||
|
globalThis.TextDecoder = class {
|
||||||
|
constructor(_label?: string, options?: { fatal?: boolean }) {
|
||||||
|
if (options?.fatal) throw new RangeError('fatal is not supported');
|
||||||
|
}
|
||||||
|
decode(): string { return 'wrong'; }
|
||||||
|
} as unknown as typeof TextDecoder;
|
||||||
|
expect(decodeUtf8Strict(encode('Köln'))).toBe('Köln');
|
||||||
|
expect(() => decodeUtf8Strict(new Uint8Array([0xc3]))).toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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<string, unknown> = {}) {
|
||||||
|
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<StructuredCard[]> => {
|
||||||
|
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');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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-<kind>-<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<BookingEventLabels>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 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<CalendarEvent> | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
type Locations = Record<string, CalendarLocation>;
|
||||||
|
|
||||||
|
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<CalendarEvent> = {},
|
||||||
|
): Partial<CalendarEvent> {
|
||||||
|
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<CalendarEvent> | 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<CalendarEvent> | 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<CalendarEvent>[] {
|
||||||
|
return slotsOf(booking, options)
|
||||||
|
.filter((slot) => !slot.cancelled && slot.event)
|
||||||
|
.map((slot) => slot.event as Partial<CalendarEvent>);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// 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<CalendarEvent>): Partial<CalendarEvent> {
|
||||||
|
return normalizeCalendarEventLike(sanitizeOutgoingCalendarEventData({ ...event }));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Start, duration, all-day flag or zone differ (the zone only matters for timed events). */
|
||||||
|
function whenDiffers(have: Partial<CalendarEvent>, want: Partial<CalendarEvent>): 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<CalendarEvent>, want: Partial<CalendarEvent>): 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<CalendarEvent>, wanted: Partial<CalendarEvent>): 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<CalendarEvent>, wanted: Partial<CalendarEvent>, senderDomain?: string): Partial<CalendarEvent> {
|
||||||
|
const have = normalizeCalendarEventLike({ ...existing });
|
||||||
|
const want = asStoredEvent(wanted);
|
||||||
|
const patch: Partial<CalendarEvent> = {};
|
||||||
|
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;
|
||||||
|
}
|
||||||
File diff suppressed because one or more lines are too long
@@ -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<Partial<CalendarEvent>, '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<T extends Partial<CalendarEvent>>(event: T): T {
|
||||||
|
const raw = event as Record<string, unknown>;
|
||||||
|
let patched = false;
|
||||||
|
const updates: Partial<CalendarEvent> = {};
|
||||||
|
|
||||||
|
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<string, unknown>).recurrenceRule;
|
||||||
|
delete (result as Record<string, unknown>).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<T extends Partial<CalendarEvent>>(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<T extends Partial<CalendarEvent>>(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<T extends Partial<CalendarEvent>>(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;
|
||||||
|
}
|
||||||
@@ -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<string, unknown>;
|
||||||
|
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;
|
||||||
|
}
|
||||||
@@ -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<string>();
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
@@ -0,0 +1,536 @@
|
|||||||
|
/**
|
||||||
|
* App only (the webmail uses the browser's DOMParser): the HTML parser
|
||||||
|
* extract.ts reads booking markup with. React Native has no DOM, so this
|
||||||
|
* builds a small detached tree that answers the few DOM calls extract.ts
|
||||||
|
* makes (querySelectorAll, closest, getAttribute, hasAttribute, textContent,
|
||||||
|
* parentElement, tagName, getElementById, compareDocumentPosition) and
|
||||||
|
* nothing else. It runs no script and loads nothing: it only reads text.
|
||||||
|
*
|
||||||
|
* The tokenizer is the one src/lib/email-html.ts sanitises mail with
|
||||||
|
* (readTag and the loop in rewriteTags, at 440a2e7a), copied because
|
||||||
|
* email-html.ts keeps it private. Tree building follows the browser where it
|
||||||
|
* matters for markup in mail: void elements, raw-text elements (a JSON-LD
|
||||||
|
* script is read verbatim up to its end tag), the end tags HTML implies
|
||||||
|
* (a <p> closed by a block, an <li> by the next <li>, table cells and rows),
|
||||||
|
* stray end tags ignored, character references decoded in text and
|
||||||
|
* attribute values. Misnested formatting tags are not re-parented the way the
|
||||||
|
* browser's adoption agency does; that changes nothing extract.ts asks.
|
||||||
|
*/
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// tokenizer (src/lib/email-html.ts)
|
||||||
|
|
||||||
|
interface ParsedAttr {
|
||||||
|
name: string;
|
||||||
|
/** Raw value (character references intact), or null for a boolean attribute. */
|
||||||
|
value: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ParsedTag {
|
||||||
|
name: string;
|
||||||
|
attrs: ParsedAttr[];
|
||||||
|
selfClosing: boolean;
|
||||||
|
/** Index just past the closing `>`. */
|
||||||
|
end: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isHtmlSpace(ch: string | undefined): boolean {
|
||||||
|
return ch === ' ' || ch === '\t' || ch === '\n' || ch === '\f' || ch === '\r';
|
||||||
|
}
|
||||||
|
|
||||||
|
function isAsciiAlpha(ch: string | undefined): boolean {
|
||||||
|
if (!ch) return false;
|
||||||
|
const code = ch.charCodeAt(0) | 0x20;
|
||||||
|
return code >= 97 && code <= 122;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The browser lowercases tag and attribute names in ASCII only. */
|
||||||
|
function asciiLower(s: string): string {
|
||||||
|
return s.replace(/[A-Z]+/g, (m) => m.toLowerCase());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read a start or end tag whose name begins at `i`. Returns null when the
|
||||||
|
* input ends inside the tag: the browser drops such a tag and everything
|
||||||
|
* after it.
|
||||||
|
*/
|
||||||
|
function readTag(html: string, i: number): ParsedTag | null {
|
||||||
|
const n = html.length;
|
||||||
|
let j = i;
|
||||||
|
while (j < n && !isHtmlSpace(html[j]) && html[j] !== '/' && html[j] !== '>') j++;
|
||||||
|
const name = html.slice(i, j);
|
||||||
|
const attrs: ParsedAttr[] = [];
|
||||||
|
while (j < n) {
|
||||||
|
const ch = html[j];
|
||||||
|
if (isHtmlSpace(ch)) { j++; continue; }
|
||||||
|
if (ch === '>') return { name, attrs, selfClosing: false, end: j + 1 };
|
||||||
|
if (ch === '/') {
|
||||||
|
j++;
|
||||||
|
if (html[j] === '>') return { name, attrs, selfClosing: true, end: j + 1 };
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const start = j;
|
||||||
|
j++;
|
||||||
|
while (j < n && !isHtmlSpace(html[j]) && html[j] !== '/' && html[j] !== '>' && html[j] !== '=') j++;
|
||||||
|
const attrName = html.slice(start, j);
|
||||||
|
while (j < n && isHtmlSpace(html[j])) j++;
|
||||||
|
if (html[j] !== '=') {
|
||||||
|
attrs.push({ name: attrName, value: null });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
j++;
|
||||||
|
while (j < n && isHtmlSpace(html[j])) j++;
|
||||||
|
if (j >= n) return null;
|
||||||
|
const q = html[j];
|
||||||
|
if (q === '"' || q === "'") {
|
||||||
|
const close = html.indexOf(q, j + 1);
|
||||||
|
if (close < 0) return null;
|
||||||
|
attrs.push({ name: attrName, value: html.slice(j + 1, close) });
|
||||||
|
j = close + 1;
|
||||||
|
} else if (q === '>') {
|
||||||
|
attrs.push({ name: attrName, value: '' });
|
||||||
|
} else {
|
||||||
|
const s = j;
|
||||||
|
while (j < n && !isHtmlSpace(html[j]) && html[j] !== '>') j++;
|
||||||
|
attrs.push({ name: attrName, value: html.slice(s, j) });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// character references
|
||||||
|
|
||||||
|
const LATIN1_NAMES = (
|
||||||
|
'nbsp iexcl cent pound curren yen brvbar sect uml copy ordf laquo not shy reg macr deg plusmn sup2 sup3 '
|
||||||
|
+ 'acute micro para middot cedil sup1 ordm raquo frac14 frac12 frac34 iquest Agrave Aacute Acirc Atilde Auml '
|
||||||
|
+ 'Aring AElig Ccedil Egrave Eacute Ecirc Euml Igrave Iacute Icirc Iuml ETH Ntilde Ograve Oacute Ocirc Otilde '
|
||||||
|
+ 'Ouml times Oslash Ugrave Uacute Ucirc Uuml Yacute THORN szlig agrave aacute acirc atilde auml aring aelig '
|
||||||
|
+ 'ccedil egrave eacute ecirc euml igrave iacute icirc iuml eth ntilde ograve oacute ocirc otilde ouml divide '
|
||||||
|
+ 'oslash ugrave uacute ucirc uuml yacute thorn yuml'
|
||||||
|
).split(' ');
|
||||||
|
|
||||||
|
/** Named references the browser also reads without a `;` (the legacy set). */
|
||||||
|
const LEGACY_ENTITIES: Record<string, number> = { amp: 38, lt: 60, gt: 62, quot: 34, AMP: 38, LT: 60, GT: 62, QUOT: 34 };
|
||||||
|
LATIN1_NAMES.forEach((name, i) => { LEGACY_ENTITIES[name] = 160 + i; });
|
||||||
|
|
||||||
|
/** Named references that need their `;`: the ones mail commonly carries beyond Latin-1. */
|
||||||
|
const NAMED_ENTITIES: Record<string, number> = {
|
||||||
|
...LEGACY_ENTITIES,
|
||||||
|
apos: 39, euro: 0x20ac, ndash: 0x2013, mdash: 0x2014, lsquo: 0x2018, rsquo: 0x2019, sbquo: 0x201a,
|
||||||
|
ldquo: 0x201c, rdquo: 0x201d, bdquo: 0x201e, dagger: 0x2020, Dagger: 0x2021, bull: 0x2022, hellip: 0x2026,
|
||||||
|
permil: 0x2030, prime: 0x2032, Prime: 0x2033, lsaquo: 0x2039, rsaquo: 0x203a, trade: 0x2122,
|
||||||
|
larr: 0x2190, uarr: 0x2191, rarr: 0x2192, darr: 0x2193, harr: 0x2194, rArr: 0x21d2, lArr: 0x21d0,
|
||||||
|
minus: 0x2212, ensp: 0x2002, emsp: 0x2003, thinsp: 0x2009, zwnj: 0x200c, zwj: 0x200d, lrm: 0x200e,
|
||||||
|
rlm: 0x200f, OElig: 0x152, oelig: 0x153, Scaron: 0x160, scaron: 0x161, Yuml: 0x178, fnof: 0x192,
|
||||||
|
circ: 0x2c6, tilde: 0x2dc, Alpha: 0x391, alpha: 0x3b1, Beta: 0x392, beta: 0x3b2, mu: 0x3bc, pi: 0x3c0,
|
||||||
|
Omega: 0x3a9, omega: 0x3c9, infin: 0x221e, ne: 0x2260, le: 0x2264, ge: 0x2265, asymp: 0x2248,
|
||||||
|
check: 0x2713, star: 0x2606, starf: 0x2605, hearts: 0x2665, loz: 0x25ca, NewLine: 10, Tab: 9,
|
||||||
|
colon: 58, comma: 44, period: 46, sol: 47, bsol: 92, num: 35, dollar: 36, percnt: 37, lpar: 40,
|
||||||
|
rpar: 41, ast: 42, plus: 43, equals: 61, quest: 63, commat: 64, lsqb: 91, rsqb: 93, lowbar: 95,
|
||||||
|
grave: 96, lcub: 123, rcub: 125, verbar: 124, excl: 33,
|
||||||
|
};
|
||||||
|
|
||||||
|
/** C1 numeric references the browser reads as windows-1252. */
|
||||||
|
const WINDOWS_1252: Record<number, number> = {
|
||||||
|
0x80: 0x20ac, 0x82: 0x201a, 0x83: 0x192, 0x84: 0x201e, 0x85: 0x2026, 0x86: 0x2020, 0x87: 0x2021,
|
||||||
|
0x88: 0x2c6, 0x89: 0x2030, 0x8a: 0x160, 0x8b: 0x2039, 0x8c: 0x152, 0x8e: 0x17d, 0x91: 0x2018,
|
||||||
|
0x92: 0x2019, 0x93: 0x201c, 0x94: 0x201d, 0x95: 0x2022, 0x96: 0x2013, 0x97: 0x2014, 0x98: 0x2dc,
|
||||||
|
0x99: 0x2122, 0x9a: 0x161, 0x9b: 0x203a, 0x9c: 0x153, 0x9e: 0x17e, 0x9f: 0x178,
|
||||||
|
};
|
||||||
|
|
||||||
|
function codePointText(code: number): string {
|
||||||
|
if (!Number.isFinite(code) || code === 0 || code > 0x10ffff || (code >= 0xd800 && code <= 0xdfff)) return String.fromCharCode(0xfffd);
|
||||||
|
return String.fromCodePoint(WINDOWS_1252[code] ?? code);
|
||||||
|
}
|
||||||
|
|
||||||
|
const REFERENCE = /&(?:#[xX]([0-9a-fA-F]+);?|#([0-9]+);?|([A-Za-z][A-Za-z0-9]*);?)/g;
|
||||||
|
|
||||||
|
/** Decode character references the way the browser does in text (or, with `attribute`, in a value). */
|
||||||
|
export function decodeCharacterReferences(text: string, attribute = false): string {
|
||||||
|
if (!text.includes('&')) return text;
|
||||||
|
return text.replace(REFERENCE, (match: string, hex: string | undefined, dec: string | undefined, name: string | undefined, offset: number) => {
|
||||||
|
if (hex !== undefined) return codePointText(parseInt(hex, 16));
|
||||||
|
if (dec !== undefined) return codePointText(parseInt(dec, 10));
|
||||||
|
const withSemicolon = match.endsWith(';');
|
||||||
|
if (withSemicolon && name && NAMED_ENTITIES[name] !== undefined) return String.fromCodePoint(NAMED_ENTITIES[name]);
|
||||||
|
// Without `;` only a legacy name counts, read as the longest one that
|
||||||
|
// prefixes the run ("©2026" is "©2026"); in a value not when an
|
||||||
|
// alphanumeric or `=` follows it ("?a=1©=2" stays a query string).
|
||||||
|
for (let len = Math.min(name?.length ?? 0, 6); len >= 2; len--) {
|
||||||
|
const candidate = name!.slice(0, len);
|
||||||
|
const code = LEGACY_ENTITIES[candidate];
|
||||||
|
if (code === undefined) continue;
|
||||||
|
const rest = match.slice(1 + len);
|
||||||
|
if (attribute) {
|
||||||
|
const next = rest[0] ?? text[offset + match.length];
|
||||||
|
if (next !== undefined && /[A-Za-z0-9=]/.test(next)) return match;
|
||||||
|
}
|
||||||
|
return String.fromCodePoint(code) + rest;
|
||||||
|
}
|
||||||
|
return match;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// tree
|
||||||
|
|
||||||
|
const VOID_ELEMENTS = new Set([
|
||||||
|
'area', 'base', 'basefont', 'bgsound', 'br', 'col', 'embed', 'frame', 'hr', 'img', 'input', 'keygen',
|
||||||
|
'link', 'meta', 'param', 'source', 'track', 'wbr',
|
||||||
|
]);
|
||||||
|
|
||||||
|
/** Read as text up to their end tag. A DOMParser document runs no script, so <noscript> is markup. */
|
||||||
|
const RAW_TEXT_ELEMENTS = new Set(['script', 'style', 'xmp', 'iframe', 'noembed', 'noframes', 'plaintext']);
|
||||||
|
/** Read as text with character references decoded. */
|
||||||
|
const RCDATA_ELEMENTS = new Set(['title', 'textarea']);
|
||||||
|
|
||||||
|
/** Start tags that close an open <p> (HTML "in body" insertion mode). */
|
||||||
|
const CLOSES_P = new Set([
|
||||||
|
'address', 'article', 'aside', 'blockquote', 'center', 'details', 'dialog', 'dir', 'div', 'dl', 'fieldset',
|
||||||
|
'figcaption', 'figure', 'footer', 'form', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'header', 'hgroup', 'hr',
|
||||||
|
'listing', 'main', 'menu', 'nav', 'ol', 'p', 'pre', 'search', 'section', 'summary', 'table', 'ul', 'li',
|
||||||
|
'dd', 'dt', 'plaintext', 'xmp',
|
||||||
|
]);
|
||||||
|
const HEADINGS = new Set(['h1', 'h2', 'h3', 'h4', 'h5', 'h6']);
|
||||||
|
/** Elements an implied end tag never reaches past. */
|
||||||
|
const SCOPE_BOUNDARIES = new Set(['html', 'table', 'td', 'th', 'caption', 'template', 'object', 'applet', 'marquee', 'button']);
|
||||||
|
|
||||||
|
let nextOrder = 0;
|
||||||
|
|
||||||
|
class MiniText {
|
||||||
|
readonly nodeType = 3;
|
||||||
|
constructor(public data: string) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
type MiniChild = MiniElement | MiniText;
|
||||||
|
|
||||||
|
abstract class MiniParent {
|
||||||
|
children: MiniChild[] = [];
|
||||||
|
|
||||||
|
get textContent(): string {
|
||||||
|
let out = '';
|
||||||
|
const walk = (node: MiniParent) => {
|
||||||
|
for (const child of node.children) {
|
||||||
|
if (child instanceof MiniText) out += child.data;
|
||||||
|
else walk(child);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
walk(this);
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Elements below this node in document order, not entering <template> content. */
|
||||||
|
protected descendants(): MiniElement[] {
|
||||||
|
const out: MiniElement[] = [];
|
||||||
|
const walk = (node: MiniParent) => {
|
||||||
|
for (const child of node.children) {
|
||||||
|
if (!(child instanceof MiniElement)) continue;
|
||||||
|
out.push(child);
|
||||||
|
if (child.localName !== 'template') walk(child);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
walk(this);
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
querySelectorAll(selector: string): MiniElement[] {
|
||||||
|
const match = compileSelector(selector);
|
||||||
|
return this.descendants().filter((element) => match(element));
|
||||||
|
}
|
||||||
|
|
||||||
|
querySelector(selector: string): MiniElement | null {
|
||||||
|
return this.querySelectorAll(selector)[0] ?? null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class MiniElement extends MiniParent {
|
||||||
|
readonly nodeType = 1;
|
||||||
|
readonly order = nextOrder++;
|
||||||
|
/** Order of the last element inside this one; set when the element closes. */
|
||||||
|
lastInside = this.order;
|
||||||
|
parent: MiniParent | null = null;
|
||||||
|
private readonly attributes: Map<string, string>;
|
||||||
|
|
||||||
|
constructor(readonly localName: string, attrs: ParsedAttr[], readonly quirks: boolean) {
|
||||||
|
super();
|
||||||
|
this.attributes = new Map();
|
||||||
|
for (const attr of attrs) {
|
||||||
|
const key = asciiLower(attr.name);
|
||||||
|
// The browser keeps the first of each name.
|
||||||
|
if (!this.attributes.has(key)) this.attributes.set(key, attr.value === null ? '' : decodeCharacterReferences(attr.value, true));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
get tagName(): string {
|
||||||
|
return this.localName.toUpperCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
get parentElement(): MiniElement | null {
|
||||||
|
return this.parent instanceof MiniElement ? this.parent : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
getAttribute(name: string): string | null {
|
||||||
|
return this.attributes.get(asciiLower(name)) ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
hasAttribute(name: string): boolean {
|
||||||
|
return this.attributes.has(asciiLower(name));
|
||||||
|
}
|
||||||
|
|
||||||
|
closest(selector: string): MiniElement | null {
|
||||||
|
const match = compileSelector(selector);
|
||||||
|
for (let node: MiniElement | null = this; node; node = node.parentElement) {
|
||||||
|
if (match(node)) return node;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** DOCUMENT_POSITION_* bits of `other` relative to this element (elements only). */
|
||||||
|
compareDocumentPosition(other: MiniElement): number {
|
||||||
|
if (other === this) return 0;
|
||||||
|
if (other.order > this.order) return other.order <= this.lastInside ? 4 | 16 : 4;
|
||||||
|
return this.order <= other.lastInside ? 2 | 8 : 2;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class MiniDocument extends MiniParent {
|
||||||
|
readonly nodeType = 9;
|
||||||
|
|
||||||
|
getElementById(id: string): MiniElement | null {
|
||||||
|
if (!id) return null;
|
||||||
|
return this.descendants().find((element) => element.getAttribute('id') === id) ?? null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// selectors: comma lists of compounds made of a tag, #id, .class and [attr] or [attr=value]
|
||||||
|
|
||||||
|
type Matcher = (element: MiniElement) => boolean;
|
||||||
|
|
||||||
|
const selectorCache = new Map<string, Matcher>();
|
||||||
|
const COMPOUND_PART = /^(?:#([-\w]+)|\.([-\w]+)|\[\s*([-\w:]+)\s*(?:=\s*(?:"([^"]*)"|'([^']*)'|([^\]\s]*))\s*)?\])/;
|
||||||
|
|
||||||
|
function compileCompound(source: string): Matcher {
|
||||||
|
let rest = source;
|
||||||
|
const tag = /^([A-Za-z][-\w]*|\*)/.exec(rest);
|
||||||
|
const tests: Matcher[] = [];
|
||||||
|
if (tag) {
|
||||||
|
const name = asciiLower(tag[1]);
|
||||||
|
if (name !== '*') tests.push((element) => element.localName === name);
|
||||||
|
rest = rest.slice(tag[0].length);
|
||||||
|
}
|
||||||
|
while (rest.length > 0) {
|
||||||
|
const part = COMPOUND_PART.exec(rest);
|
||||||
|
if (!part) throw new Error(`html-parser: unsupported selector "${source}"`);
|
||||||
|
const [, id, cls, attr] = part;
|
||||||
|
const value = part[4] ?? part[5] ?? part[6];
|
||||||
|
if (id !== undefined) {
|
||||||
|
tests.push((element) => {
|
||||||
|
const own = element.getAttribute('id');
|
||||||
|
// Quirks mode (no doctype) matches ids and classes ASCII case-insensitively.
|
||||||
|
return own !== null && (element.quirks ? asciiLower(own) === asciiLower(id) : own === id);
|
||||||
|
});
|
||||||
|
} else if (cls !== undefined) {
|
||||||
|
tests.push((element) => {
|
||||||
|
const classes = (element.getAttribute('class') ?? '').split(/[\t\n\f\r ]+/);
|
||||||
|
return element.quirks ? classes.some((c) => asciiLower(c) === asciiLower(cls)) : classes.includes(cls);
|
||||||
|
});
|
||||||
|
} else if (attr !== undefined) {
|
||||||
|
tests.push(value === undefined
|
||||||
|
? (element) => element.hasAttribute(attr)
|
||||||
|
: (element) => element.getAttribute(attr) === value);
|
||||||
|
}
|
||||||
|
rest = rest.slice(part[0].length);
|
||||||
|
}
|
||||||
|
if (tests.length === 0) return () => true;
|
||||||
|
return (element) => tests.every((test) => test(element));
|
||||||
|
}
|
||||||
|
|
||||||
|
function compileSelector(selector: string): Matcher {
|
||||||
|
const cached = selectorCache.get(selector);
|
||||||
|
if (cached) return cached;
|
||||||
|
const groups = selector.split(',').map((group) => group.trim());
|
||||||
|
if (groups.some((group) => group === '' || /[\s>+~]/.test(group))) {
|
||||||
|
throw new Error(`html-parser: unsupported selector "${selector}"`);
|
||||||
|
}
|
||||||
|
const compiled = groups.map(compileCompound);
|
||||||
|
const matcher: Matcher = (element) => compiled.some((match) => match(element));
|
||||||
|
selectorCache.set(selector, matcher);
|
||||||
|
return matcher;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// tree building
|
||||||
|
|
||||||
|
class TreeBuilder {
|
||||||
|
readonly doc = new MiniDocument();
|
||||||
|
private readonly stack: MiniParent[] = [this.doc];
|
||||||
|
|
||||||
|
constructor(private readonly quirks: boolean) {}
|
||||||
|
|
||||||
|
private get current(): MiniParent {
|
||||||
|
return this.stack[this.stack.length - 1];
|
||||||
|
}
|
||||||
|
|
||||||
|
text(data: string): void {
|
||||||
|
if (!data) return;
|
||||||
|
const children = this.current.children;
|
||||||
|
const last = children[children.length - 1];
|
||||||
|
if (last instanceof MiniText) last.data += data;
|
||||||
|
else children.push(new MiniText(data));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Index in the stack of the nearest open element named one of `names`, not past a scope boundary. */
|
||||||
|
private findOpen(names: Set<string> | string, boundaries: Set<string> = SCOPE_BOUNDARIES): number {
|
||||||
|
const wanted = typeof names === 'string' ? new Set([names]) : names;
|
||||||
|
for (let i = this.stack.length - 1; i > 0; i--) {
|
||||||
|
const element = this.stack[i] as MiniElement;
|
||||||
|
if (wanted.has(element.localName)) return i;
|
||||||
|
if (boundaries.has(element.localName)) return -1;
|
||||||
|
}
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
private popTo(index: number): void {
|
||||||
|
while (this.stack.length > index) {
|
||||||
|
const element = this.stack.pop() as MiniElement;
|
||||||
|
element.lastInside = nextOrder - 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private closeIfOpen(names: Set<string> | string, boundaries?: Set<string>): void {
|
||||||
|
const index = this.findOpen(names, boundaries);
|
||||||
|
if (index > 0) this.popTo(index);
|
||||||
|
}
|
||||||
|
|
||||||
|
start(name: string, attrs: ParsedAttr[]): MiniElement {
|
||||||
|
if (CLOSES_P.has(name)) this.closeIfOpen('p', new Set([...SCOPE_BOUNDARIES]));
|
||||||
|
if (HEADINGS.has(name) && this.current instanceof MiniElement && HEADINGS.has(this.current.localName)) {
|
||||||
|
this.popTo(this.stack.length - 1);
|
||||||
|
}
|
||||||
|
switch (name) {
|
||||||
|
case 'li': this.closeIfOpen('li', new Set([...SCOPE_BOUNDARIES, 'ul', 'ol'])); break;
|
||||||
|
case 'dd': case 'dt': this.closeIfOpen(new Set(['dd', 'dt']), new Set([...SCOPE_BOUNDARIES, 'dl'])); break;
|
||||||
|
case 'td': case 'th': this.closeIfOpen(new Set(['td', 'th']), new Set(['table', 'tr', 'html', 'template'])); break;
|
||||||
|
case 'tr': this.closeIfOpen('tr', new Set(['table', 'html', 'template'])); break;
|
||||||
|
case 'thead': case 'tbody': case 'tfoot':
|
||||||
|
this.closeIfOpen(new Set(['thead', 'tbody', 'tfoot']), new Set(['table', 'html', 'template'])); break;
|
||||||
|
case 'option': this.closeIfOpen('option', new Set(['select', 'html', 'template'])); break;
|
||||||
|
case 'a': this.closeIfOpen('a'); break;
|
||||||
|
default: break;
|
||||||
|
}
|
||||||
|
const element = new MiniElement(name, attrs, this.quirks);
|
||||||
|
element.parent = this.current;
|
||||||
|
this.current.children.push(element);
|
||||||
|
if (!VOID_ELEMENTS.has(name)) this.stack.push(element);
|
||||||
|
return element;
|
||||||
|
}
|
||||||
|
|
||||||
|
end(name: string): void {
|
||||||
|
if (name === 'br') {
|
||||||
|
this.start('br', []);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (name === 'p' && this.findOpen('p') < 0) {
|
||||||
|
// A stray </p> makes an empty paragraph.
|
||||||
|
this.start('p', []);
|
||||||
|
}
|
||||||
|
for (let i = this.stack.length - 1; i > 0; i--) {
|
||||||
|
if ((this.stack[i] as MiniElement).localName === name) {
|
||||||
|
this.popTo(i);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// An end tag with nothing open by that name is ignored.
|
||||||
|
}
|
||||||
|
|
||||||
|
finish(): MiniDocument {
|
||||||
|
this.popTo(1);
|
||||||
|
return this.doc;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Parse `html` into a detached tree that answers the DOM calls extract.ts makes. */
|
||||||
|
export function parseHtmlDocument(html: string): Document {
|
||||||
|
const n = html.length;
|
||||||
|
const quirks = !/^\s*(?:<!--[\s\S]*?-->\s*)*<!doctype/i.test(html.slice(0, 4096));
|
||||||
|
const builder = new TreeBuilder(quirks);
|
||||||
|
let i = 0;
|
||||||
|
while (i < n) {
|
||||||
|
const lt = html.indexOf('<', i);
|
||||||
|
if (lt < 0) {
|
||||||
|
builder.text(decodeCharacterReferences(html.slice(i)));
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
builder.text(decodeCharacterReferences(html.slice(i, lt)));
|
||||||
|
const next = html[lt + 1];
|
||||||
|
|
||||||
|
if (isAsciiAlpha(next)) {
|
||||||
|
const tag = readTag(html, lt + 1);
|
||||||
|
if (!tag) break;
|
||||||
|
const lower = asciiLower(tag.name);
|
||||||
|
builder.start(lower, tag.attrs);
|
||||||
|
i = tag.end;
|
||||||
|
if (RAW_TEXT_ELEMENTS.has(lower) || RCDATA_ELEMENTS.has(lower)) {
|
||||||
|
const close = new RegExp(`</${lower}[\\t\\n\\f\\r />]`, 'gi');
|
||||||
|
close.lastIndex = i;
|
||||||
|
const m = lower === 'plaintext' ? null : close.exec(html);
|
||||||
|
const textEnd = m ? m.index : n;
|
||||||
|
const text = html.slice(i, textEnd);
|
||||||
|
builder.text(RCDATA_ELEMENTS.has(lower) ? decodeCharacterReferences(text) : text);
|
||||||
|
i = textEnd;
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (next === '/') {
|
||||||
|
const after = html[lt + 2];
|
||||||
|
if (isAsciiAlpha(after)) {
|
||||||
|
const tag = readTag(html, lt + 2);
|
||||||
|
if (!tag) break;
|
||||||
|
builder.end(asciiLower(tag.name));
|
||||||
|
i = tag.end;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (after === '>') {
|
||||||
|
i = lt + 3;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (after === undefined) {
|
||||||
|
builder.text('</');
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
const gt = html.indexOf('>', lt + 2);
|
||||||
|
i = gt < 0 ? n : gt + 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (next === '!') {
|
||||||
|
if (html.startsWith('<!--', lt)) {
|
||||||
|
const k = lt + 4;
|
||||||
|
if (html[k] === '>') { i = k + 1; continue; }
|
||||||
|
if (html.startsWith('->', k)) { i = k + 2; continue; }
|
||||||
|
const a = html.indexOf('-->', k);
|
||||||
|
const b = html.indexOf('--!>', k);
|
||||||
|
if (a < 0 && b < 0) { i = n; continue; }
|
||||||
|
i = a >= 0 && (b < 0 || a < b) ? a + 3 : b + 4;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const gt = html.indexOf('>', lt + 2);
|
||||||
|
i = gt < 0 ? n : gt + 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (next === '?') {
|
||||||
|
const gt = html.indexOf('>', lt + 2);
|
||||||
|
i = gt < 0 ? n : gt + 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
builder.text('<');
|
||||||
|
i = lt + 1;
|
||||||
|
}
|
||||||
|
// Only what extract.ts calls is there; the cast says so to the type checker.
|
||||||
|
return builder.finish() as unknown as Document;
|
||||||
|
}
|
||||||
@@ -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';
|
||||||
@@ -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(/-->$/, '')
|
||||||
|
.replace(/^<!\[CDATA\[/, '')
|
||||||
|
.replace(/\]\]>$/, '')
|
||||||
|
.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
const NAMED_ENTITIES: Record<string, string> = { 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;
|
||||||
|
}
|
||||||
@@ -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<T>(values: (T | undefined)[]): T[] {
|
||||||
|
return [...new Set(values.filter((v): v is T => v !== undefined))];
|
||||||
|
}
|
||||||
|
|
||||||
|
function latest(values: (string | undefined)[]): string | undefined {
|
||||||
|
return unique(values).sort().pop();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Status of a leg or booking several reservations contributed to: cancelled
|
||||||
|
* only when every one of them says so. One cancelled passenger on a leg the
|
||||||
|
* others still hold leaves the leg live.
|
||||||
|
*/
|
||||||
|
function combinedStatus(statuses: (ReservationStatus | undefined)[]): ReservationStatus | undefined {
|
||||||
|
if (statuses.length > 0 && statuses.every((s) => s === 'cancelled')) return 'cancelled';
|
||||||
|
return statuses.find((s) => s && s !== 'cancelled');
|
||||||
|
}
|
||||||
|
|
||||||
|
function mergeTransport(group: TransportBooking[]): TransportBooking | null {
|
||||||
|
const legs = new Map<string, { legs: TransportLeg[] }>();
|
||||||
|
for (const booking of group) {
|
||||||
|
for (const leg of booking.legs) {
|
||||||
|
const key = legKey(leg);
|
||||||
|
const entry = legs.get(key) ?? { legs: [] };
|
||||||
|
entry.legs.push(leg);
|
||||||
|
legs.set(key, entry);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const merged: TransportLeg[] = [];
|
||||||
|
for (const { legs: parts } of legs.values()) {
|
||||||
|
const [first] = parts;
|
||||||
|
for (const other of parts.slice(1)) {
|
||||||
|
if (!sameMoment(first.departure.at, other.departure.at) || !sameMoment(first.arrival?.at, other.arrival?.at)) return null;
|
||||||
|
}
|
||||||
|
const seats: Seat[] = [];
|
||||||
|
const seen = new Set<string>();
|
||||||
|
for (const seat of parts.flatMap((p) => p.seats)) {
|
||||||
|
const id = `${seat.passenger ?? ''}|${seat.seat}`;
|
||||||
|
if (!seen.has(id)) {
|
||||||
|
seen.add(id);
|
||||||
|
seats.push(seat);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const status = combinedStatus(parts.map((p) => p.status));
|
||||||
|
const withArrival = parts.find((p) => p.arrival?.at) ?? first;
|
||||||
|
const { status: _ignored, ...rest } = first;
|
||||||
|
merged.push({ ...rest, ...(withArrival.arrival ? { arrival: withArrival.arrival } : {}), seats, ...(status ? { status } : {}) });
|
||||||
|
}
|
||||||
|
merged.sort((a, b) => (a.departure.at.utc ?? `${a.departure.at.date}T${a.departure.at.time}`).localeCompare(b.departure.at.utc ?? `${b.departure.at.date}T${b.departure.at.time}`));
|
||||||
|
const [head] = group;
|
||||||
|
const allCancelled = merged.length > 0 && merged.every((leg) => leg.status === 'cancelled');
|
||||||
|
const bookingStatus = allCancelled ? 'cancelled' : combinedStatus(group.map((b) => b.status).filter((s) => s !== 'cancelled'));
|
||||||
|
const { status: _s, modified: _m, provider: _p, ...base } = head;
|
||||||
|
const modified = latest(group.map((b) => b.modified));
|
||||||
|
const provider = group.find((b) => b.provider)?.provider;
|
||||||
|
return {
|
||||||
|
...base,
|
||||||
|
...(bookingStatus ? { status: bookingStatus } : {}),
|
||||||
|
passengers: unique(group.flatMap((b) => b.passengers)),
|
||||||
|
...(provider ? { provider } : {}),
|
||||||
|
links: mergeLinks(group.map((b) => b.links)),
|
||||||
|
...(modified ? { modified } : {}),
|
||||||
|
legs: merged,
|
||||||
|
} as TransportBooking;
|
||||||
|
}
|
||||||
|
|
||||||
|
function mergeOther(group: Booking[]): Booking {
|
||||||
|
const [head] = group;
|
||||||
|
const status = combinedStatus(group.map((b) => b.status));
|
||||||
|
const modified = latest(group.map((b) => b.modified));
|
||||||
|
const { status: _s, modified: _m, ...base } = head;
|
||||||
|
const merged = {
|
||||||
|
...base,
|
||||||
|
...(status ? { status } : {}),
|
||||||
|
passengers: unique(group.flatMap((b) => b.passengers)),
|
||||||
|
links: mergeLinks(group.map((b) => b.links)),
|
||||||
|
...(modified ? { modified } : {}),
|
||||||
|
} as Booking;
|
||||||
|
if (merged.kind === 'event') merged.tickets = unique(group.flatMap((b) => (b.kind === 'event' ? b.tickets : [])));
|
||||||
|
return merged;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Later shipping states win over earlier ones; a problem state over all. */
|
||||||
|
const ORDER_PROGRESS: OrderStatus[] = ['payment_due', 'processing', 'in_transit', 'pickup_available', 'delivered', 'returned', 'problem', 'cancelled'];
|
||||||
|
|
||||||
|
function mergeItems(lists: Item[][]): Item[] {
|
||||||
|
const byName = new Map<string, Item>();
|
||||||
|
for (const item of lists.flat()) {
|
||||||
|
const key = item.name.toLowerCase();
|
||||||
|
const known = byName.get(key);
|
||||||
|
byName.set(key, known ? { ...item, ...known, image: known.image ?? item.image } : item);
|
||||||
|
}
|
||||||
|
return [...byName.values()].map((item) => {
|
||||||
|
if (!item.image) delete item.image;
|
||||||
|
return item;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function mergeShipments(group: Shipment[]): Shipment {
|
||||||
|
const parcels: Parcel[] = [];
|
||||||
|
for (const parcel of group.flatMap((s) => s.parcels)) {
|
||||||
|
const same = parcel.trackingNumber ? parcels.find((p) => p.trackingNumber === parcel.trackingNumber) : undefined;
|
||||||
|
if (same) {
|
||||||
|
same.carrier ??= parcel.carrier;
|
||||||
|
same.trackingUrl ??= parcel.trackingUrl;
|
||||||
|
same.expectedBy ??= parcel.expectedBy;
|
||||||
|
same.items = mergeItems([same.items, parcel.items]);
|
||||||
|
} else {
|
||||||
|
parcels.push({ ...parcel, items: [...parcel.items] });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const statuses = group.map((s) => s.status).filter((s): s is OrderStatus => !!s);
|
||||||
|
const status = statuses.sort((a, b) => ORDER_PROGRESS.indexOf(a) - ORDER_PROGRESS.indexOf(b)).pop();
|
||||||
|
const withOrder = group.find((s) => s.items.length > 0 || s.total) ?? group[0];
|
||||||
|
const merged: Shipment = {
|
||||||
|
kind: 'shipment',
|
||||||
|
key: group[0].key,
|
||||||
|
...(group.find((s) => s.merchant) ? { merchant: group.find((s) => s.merchant)!.merchant } : {}),
|
||||||
|
...(group.find((s) => s.orderNumber) ? { orderNumber: group.find((s) => s.orderNumber)!.orderNumber } : {}),
|
||||||
|
...(status ? { status } : {}),
|
||||||
|
items: mergeItems(group.map((s) => s.items)),
|
||||||
|
...(withOrder.total ? { total: withOrder.total } : {}),
|
||||||
|
...(group.find((s) => s.orderUrl) ? { orderUrl: group.find((s) => s.orderUrl)!.orderUrl } : {}),
|
||||||
|
parcels,
|
||||||
|
source: group[0].source,
|
||||||
|
};
|
||||||
|
return merged;
|
||||||
|
}
|
||||||
|
|
||||||
|
function startOf(card: StructuredCard): string {
|
||||||
|
switch (card.kind) {
|
||||||
|
case 'flight': case 'train': case 'bus': return card.legs[0]?.departure.at.utc ?? card.legs[0]?.departure.at.date ?? '';
|
||||||
|
case 'lodging': return card.checkin.date;
|
||||||
|
case 'rentalcar': return card.pickup.at.utc ?? card.pickup.at.date;
|
||||||
|
case 'event': case 'restaurant': return card.start.utc ?? card.start.date;
|
||||||
|
case 'shipment': return '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Fold candidates with equal keys into one card each; drop doubtful groups. */
|
||||||
|
export function mergeCandidates(candidates: Candidate[]): StructuredCard[] {
|
||||||
|
const groups = new Map<string, Candidate[]>();
|
||||||
|
for (const candidate of candidates) {
|
||||||
|
const group = groups.get(candidate.card.key) ?? [];
|
||||||
|
group.push(candidate);
|
||||||
|
groups.set(candidate.card.key, group);
|
||||||
|
}
|
||||||
|
const out: StructuredCard[] = [];
|
||||||
|
for (const group of groups.values()) {
|
||||||
|
if (group.some((c) => c.problems.length > 0)) continue;
|
||||||
|
const cards = group.map((c) => c.card);
|
||||||
|
const [head] = cards;
|
||||||
|
if (head.kind === 'shipment') out.push(mergeShipments(cards as Shipment[]));
|
||||||
|
else if (head.kind === 'flight' || head.kind === 'train' || head.kind === 'bus') {
|
||||||
|
const merged = mergeTransport(cards as TransportBooking[]);
|
||||||
|
if (merged) out.push(merged);
|
||||||
|
} else out.push(mergeOther(cards as Booking[]));
|
||||||
|
}
|
||||||
|
return out.sort((a, b) => KIND_ORDER.indexOf(a.kind) - KIND_ORDER.indexOf(b.kind) || startOf(a).localeCompare(startOf(b)));
|
||||||
|
}
|
||||||
@@ -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<string, BookingKind> = {
|
||||||
|
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('|');
|
||||||
|
}
|
||||||
@@ -0,0 +1,422 @@
|
|||||||
|
// Mirrors webmail lib/structured-mail/pkpass.ts at 1d4cae961; keep the two in step.
|
||||||
|
/**
|
||||||
|
* Apple Wallet passes (.pkpass) attached to booking mail: the same cards as
|
||||||
|
* schema.org markup would give. A boarding pass becomes a flight, train or
|
||||||
|
* bus booking and an event ticket an event booking; every other pass is
|
||||||
|
* ignored. Only the pass's `semantics` dictionary counts, as that is the
|
||||||
|
* machine-readable part (Apple's "SemanticTags"); the display fields are
|
||||||
|
* free text for people. A pass that leaves anything in doubt gives no card.
|
||||||
|
*
|
||||||
|
* The signature is not checked (that needs Apple's certificate chain); the
|
||||||
|
* mail-level trust gate stands in for it. Nothing in the pass is fetched:
|
||||||
|
* webServiceURL, images and links are never read.
|
||||||
|
*
|
||||||
|
* Plain TypeScript, no React; jszip is imported only when a pass is opened.
|
||||||
|
*/
|
||||||
|
import type JSZip from 'jszip';
|
||||||
|
import { cardKey } from './normalize';
|
||||||
|
import { isValidTimeZone, wallClockAt, zoneOffsetAt } from './time-zones';
|
||||||
|
import type { EventBooking, Moment, Place, Seat, StructuredCard, TransportBooking, TransportKind, TransportLeg } from './types';
|
||||||
|
import { validateCard } from './validate';
|
||||||
|
import { decodeUtf8Strict } from './utf8';
|
||||||
|
import { isObj, moment, str, type Obj } from './values';
|
||||||
|
|
||||||
|
/** Largest attachment that is downloaded and opened. */
|
||||||
|
export const PKPASS_MAX_BYTES = 5 * 1024 * 1024;
|
||||||
|
/** At most this many passes are read from one mail. */
|
||||||
|
export const PKPASS_MAX_PER_MAIL = 3;
|
||||||
|
/** Largest pass.json read, once inflated. */
|
||||||
|
export const PASS_JSON_MAX_BYTES = 256 * 1024;
|
||||||
|
/** Archives with more entries are refused unread. */
|
||||||
|
export const PKPASS_MAX_ENTRIES = 100;
|
||||||
|
|
||||||
|
const PKPASS_TYPE = 'application/vnd.apple.pkpass';
|
||||||
|
|
||||||
|
export interface PkpassAttachmentLike {
|
||||||
|
name?: string | null;
|
||||||
|
type?: string | null;
|
||||||
|
size?: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** An attachment that may be a Wallet pass: `*.pkpass` or typed as one, at most 5 MB. */
|
||||||
|
export function isPkpassAttachment(att: PkpassAttachmentLike | null | undefined): boolean {
|
||||||
|
if (!att) return false;
|
||||||
|
const size = att.size;
|
||||||
|
if (typeof size !== 'number' || !Number.isFinite(size) || size <= 0 || size > PKPASS_MAX_BYTES) return false;
|
||||||
|
const byName = typeof att.name === 'string' && /\.pkpass$/i.test(att.name.trim());
|
||||||
|
const byType = typeof att.type === 'string' && att.type.split(';')[0].trim().toLowerCase() === PKPASS_TYPE;
|
||||||
|
return byName || byType;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ReadPassOptions {
|
||||||
|
/** jszip's constructor; defaults to a dynamic import of jszip. */
|
||||||
|
loadZip?: () => Promise<typeof JSZip>;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function defaultLoadZip(): Promise<typeof JSZip> {
|
||||||
|
const { default: JSZipClass } = await import('jszip');
|
||||||
|
return JSZipClass;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ZipStream {
|
||||||
|
on(event: 'data', cb: (chunk: Uint8Array) => void): ZipStream;
|
||||||
|
on(event: 'error', cb: (err: Error) => void): ZipStream;
|
||||||
|
on(event: 'end', cb: () => void): ZipStream;
|
||||||
|
pause(): ZipStream;
|
||||||
|
resume(): ZipStream;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Inflate one entry, giving up as soon as it passes `maxBytes`. The sizes in
|
||||||
|
* the ZIP headers are whatever the sender wrote; counting inflated bytes is
|
||||||
|
* what stops a ZIP bomb.
|
||||||
|
*/
|
||||||
|
function readCapped(entry: JSZip.JSZipObject, maxBytes: number): Promise<Uint8Array | undefined> {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
const chunks: Uint8Array[] = [];
|
||||||
|
let total = 0;
|
||||||
|
let done = false;
|
||||||
|
const finish = (value: Uint8Array | undefined) => {
|
||||||
|
if (done) return;
|
||||||
|
done = true;
|
||||||
|
resolve(value);
|
||||||
|
};
|
||||||
|
let stream: ZipStream;
|
||||||
|
try {
|
||||||
|
// internalStream is part of jszip's API but missing from its typings.
|
||||||
|
stream = (entry as unknown as { internalStream(type: 'uint8array'): ZipStream }).internalStream('uint8array');
|
||||||
|
} catch {
|
||||||
|
finish(undefined);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
stream
|
||||||
|
.on('data', (chunk) => {
|
||||||
|
if (done) return;
|
||||||
|
total += chunk.byteLength;
|
||||||
|
if (total > maxBytes) {
|
||||||
|
stream.pause();
|
||||||
|
finish(undefined);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
chunks.push(chunk);
|
||||||
|
})
|
||||||
|
.on('error', () => finish(undefined))
|
||||||
|
.on('end', () => {
|
||||||
|
const out = new Uint8Array(total);
|
||||||
|
let offset = 0;
|
||||||
|
for (const chunk of chunks) {
|
||||||
|
out.set(chunk, offset);
|
||||||
|
offset += chunk.byteLength;
|
||||||
|
}
|
||||||
|
finish(out);
|
||||||
|
})
|
||||||
|
.resume();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The parsed pass.json of a .pkpass archive, or undefined for anything
|
||||||
|
* doubtful: not a ZIP, more than 100 entries, no pass.json, pass.json over
|
||||||
|
* 256 KB once inflated, not JSON, not an object. Nothing else in the archive
|
||||||
|
* is inflated. Never throws.
|
||||||
|
*/
|
||||||
|
export async function readPassJson(bytes: ArrayBuffer | Uint8Array, options: ReadPassOptions = {}): Promise<Obj | undefined> {
|
||||||
|
try {
|
||||||
|
if (bytes.byteLength === 0 || bytes.byteLength > PKPASS_MAX_BYTES) return undefined;
|
||||||
|
const JSZipClass = await (options.loadZip ?? defaultLoadZip)();
|
||||||
|
const zip = await JSZipClass.loadAsync(bytes);
|
||||||
|
if (Object.keys(zip.files).length > PKPASS_MAX_ENTRIES) return undefined;
|
||||||
|
const entry = zip.file('pass.json');
|
||||||
|
if (!entry || entry.dir) return undefined;
|
||||||
|
const raw = await readCapped(entry, PASS_JSON_MAX_BYTES);
|
||||||
|
if (!raw) return undefined;
|
||||||
|
// App: Hermes may lack TextDecoder's fatal mode (utf8.ts).
|
||||||
|
let text = decodeUtf8Strict(raw);
|
||||||
|
if (text.charCodeAt(0) === 0xfeff) text = text.slice(1);
|
||||||
|
const parsed: unknown = JSON.parse(text);
|
||||||
|
return isObj(parsed) ? parsed : undefined;
|
||||||
|
} catch {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const PASS_STYLES = ['boardingPass', 'coupon', 'eventTicket', 'generic', 'storeCard'] as const;
|
||||||
|
|
||||||
|
const TRANSIT_KINDS: Record<string, TransportKind> = {
|
||||||
|
PKTransitTypeAir: 'flight',
|
||||||
|
PKTransitTypeTrain: 'train',
|
||||||
|
PKTransitTypeBus: 'bus',
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Thrown inside the mapping when a value is present but can't be trusted. */
|
||||||
|
class Doubtful extends Error {}
|
||||||
|
|
||||||
|
function doubt(): never {
|
||||||
|
throw new Doubtful();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A string tag: absent → undefined, present but not text → doubtful. */
|
||||||
|
function text(semantics: Obj, key: string): string | undefined {
|
||||||
|
const value = semantics[key];
|
||||||
|
if (value === undefined || value === null) return undefined;
|
||||||
|
if (typeof value !== 'string' && typeof value !== 'number') doubt();
|
||||||
|
return str(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A date tag: absent → undefined, present but not ISO 8601 → doubtful. */
|
||||||
|
function date(semantics: Obj, key: string): Moment | undefined {
|
||||||
|
const value = semantics[key];
|
||||||
|
if (value === undefined || value === null || value === '') return undefined;
|
||||||
|
return moment(value) ?? doubt();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A time as local to its place. Passes often give times in UTC (`Z`,
|
||||||
|
* `+00:00`), which fixes the instant but not the wall clock. With the pass's
|
||||||
|
* IANA zone for the place (departureLocationTimeZone,
|
||||||
|
* destinationLocationTimeZone) the instant is turned into that zone's wall
|
||||||
|
* clock; for a flight without one, the moment is marked `instantOnly` and the
|
||||||
|
* airport's zone supplies the wall clock later (resolve.ts); for anything
|
||||||
|
* else it is doubtful. A zone given next to a non-UTC offset has to agree
|
||||||
|
* with it, otherwise one of the two is wrong.
|
||||||
|
*/
|
||||||
|
function placeTime(semantics: Obj, zoneKey: string | undefined, at: Moment | undefined, airport: boolean): Moment | undefined {
|
||||||
|
const tag = zoneKey ? semantics[zoneKey] : undefined;
|
||||||
|
let zone: string | undefined;
|
||||||
|
if (tag !== undefined && tag !== null) {
|
||||||
|
if (typeof tag !== 'string' || !isValidTimeZone(tag)) doubt();
|
||||||
|
zone = tag;
|
||||||
|
}
|
||||||
|
if (!at?.utc || at.offsetMinutes === undefined) return at;
|
||||||
|
const ms = Date.parse(at.utc);
|
||||||
|
if (at.offsetMinutes === 0) {
|
||||||
|
if (zone) {
|
||||||
|
const local = wallClockAt(ms, zone);
|
||||||
|
return { date: local.date, time: local.time, offsetMinutes: zoneOffsetAt(zone, ms), utc: at.utc };
|
||||||
|
}
|
||||||
|
if (airport) return { ...at, instantOnly: true };
|
||||||
|
doubt();
|
||||||
|
}
|
||||||
|
if (zone && zoneOffsetAt(zone, ms) !== at.offsetMinutes) doubt();
|
||||||
|
return at;
|
||||||
|
}
|
||||||
|
|
||||||
|
function sameTime(a: Moment, b: Moment): boolean {
|
||||||
|
if (a.utc && b.utc) return a.utc === b.utc;
|
||||||
|
return a.date === b.date && (a.time ?? '') === (b.time ?? '');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* transitStatus is free text. Only a status that clearly says the trip runs
|
||||||
|
* as planned keeps the card; "Delayed 40 min", "Diverted", "fällt aus" or
|
||||||
|
* anything unknown leaves the times in doubt.
|
||||||
|
*/
|
||||||
|
const PLANNED_STATUS = new Set(['on time', 'pünktlich', 'boarding', 'scheduled', 'planmäßig']);
|
||||||
|
|
||||||
|
function plannedStatus(status: string | undefined): boolean {
|
||||||
|
if (status === undefined) return true;
|
||||||
|
const normal = status.normalize('NFC').toLowerCase().replace(/[-_]+/g, ' ').replace(/[.!]+$/, '').replace(/\s+/g, ' ').trim();
|
||||||
|
return normal === '' || PLANNED_STATUS.has(normal);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** PersonNameComponents → "Given Family". */
|
||||||
|
function personName(value: unknown): string | undefined {
|
||||||
|
if (value === undefined || value === null) return undefined;
|
||||||
|
if (!isObj(value)) doubt();
|
||||||
|
const parts = [str(value.givenName), str(value.middleName), str(value.familyName)].filter((p): p is string => !!p);
|
||||||
|
return parts.length > 0 ? parts.join(' ') : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Split the IATA flight: airlineCode + flightNumber, flightCode ("LH400"), or both when they agree. */
|
||||||
|
function flightIdentity(semantics: Obj): { carrierCode: string; number: string } {
|
||||||
|
const airline = text(semantics, 'airlineCode')?.toUpperCase();
|
||||||
|
if (airline !== undefined && !/^[A-Z0-9]{2}$/.test(airline)) doubt();
|
||||||
|
const numberRaw = text(semantics, 'flightNumber');
|
||||||
|
if (numberRaw !== undefined && !/^\d{1,4}$/.test(numberRaw)) doubt();
|
||||||
|
const number = numberRaw !== undefined ? String(Number(numberRaw)) : undefined;
|
||||||
|
const codeRaw = text(semantics, 'flightCode')?.replace(/\s+/g, '').toUpperCase();
|
||||||
|
let fromCode: { carrierCode: string; number: string } | undefined;
|
||||||
|
if (codeRaw !== undefined) {
|
||||||
|
const match = /^([A-Z0-9]{2})(\d{1,4})$/.exec(codeRaw);
|
||||||
|
if (!match || !/[A-Z]/.test(match[1])) doubt();
|
||||||
|
fromCode = { carrierCode: match[1], number: String(Number(match[2])) };
|
||||||
|
}
|
||||||
|
const carrierCode = airline ?? fromCode?.carrierCode;
|
||||||
|
const flight = number ?? fromCode?.number;
|
||||||
|
if (!carrierCode || !flight) doubt();
|
||||||
|
if (fromCode && (fromCode.carrierCode !== carrierCode || fromCode.number !== flight)) doubt();
|
||||||
|
return { carrierCode, number: flight };
|
||||||
|
}
|
||||||
|
|
||||||
|
function airport(semantics: Obj, prefix: 'departure' | 'destination', codeRequired: boolean): Place {
|
||||||
|
const code = text(semantics, `${prefix}AirportCode`)?.toUpperCase();
|
||||||
|
if (code !== undefined && !/^[A-Z]{3}$/.test(code)) doubt();
|
||||||
|
if (codeRequired && !code) doubt();
|
||||||
|
const name = text(semantics, `${prefix}AirportName`);
|
||||||
|
return { ...(name ? { name } : {}), ...(code ? { code } : {}) };
|
||||||
|
}
|
||||||
|
|
||||||
|
function station(semantics: Obj, prefix: 'departure' | 'destination'): Place {
|
||||||
|
const name = text(semantics, `${prefix}StationName`) ?? text(semantics, `${prefix}LocationDescription`);
|
||||||
|
return name ? { name } : {};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Seats as the markup writes them: "12A" for flights, "section/number" otherwise. */
|
||||||
|
function seats(semantics: Obj, kind: TransportKind, passenger: string | undefined): Seat[] {
|
||||||
|
const value = semantics.seats;
|
||||||
|
if (value === undefined || value === null) return [];
|
||||||
|
if (!Array.isArray(value)) doubt();
|
||||||
|
const car = kind === 'train' ? text(semantics, 'carNumber') : undefined;
|
||||||
|
const out: Seat[] = [];
|
||||||
|
for (const entry of value) {
|
||||||
|
if (!isObj(entry)) doubt();
|
||||||
|
const number = str(entry.seatNumber);
|
||||||
|
const row = str(entry.seatRow);
|
||||||
|
const identifier = str(entry.seatIdentifier);
|
||||||
|
let seat: string | undefined;
|
||||||
|
if (kind === 'flight') {
|
||||||
|
if (number && row) {
|
||||||
|
if (number.toUpperCase().startsWith(row.toUpperCase())) seat = number.toUpperCase();
|
||||||
|
else if (/^\d{1,3}$/.test(row) && /^[A-Z]$/i.test(number)) seat = `${row}${number.toUpperCase()}`;
|
||||||
|
// Any other combination is left out rather than guessed.
|
||||||
|
} else {
|
||||||
|
seat = (number ?? identifier)?.toUpperCase();
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const base = number ?? identifier;
|
||||||
|
const section = str(entry.seatSection) ?? car;
|
||||||
|
seat = base && section ? `${section}/${base}` : base;
|
||||||
|
}
|
||||||
|
if (seat) out.push({ ...(passenger ? { passenger } : {}), seat });
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
function transportCard(kind: TransportKind, pass: Obj, semantics: Obj): TransportBooking {
|
||||||
|
if (!plannedStatus(text(semantics, 'transitStatus'))) doubt();
|
||||||
|
|
||||||
|
const flight = kind === 'flight';
|
||||||
|
const departureTime = (key: string) => placeTime(semantics, 'departureLocationTimeZone', date(semantics, key), flight);
|
||||||
|
const arrivalTime = (key: string) => placeTime(semantics, 'destinationLocationTimeZone', date(semantics, key), flight);
|
||||||
|
const original = departureTime('originalDepartureDate');
|
||||||
|
const current = departureTime('currentDepartureDate');
|
||||||
|
// A departure moved to another day would make the pass describe another leg.
|
||||||
|
if (original && current && original.date !== current.date) doubt();
|
||||||
|
const departureAt = current ?? original;
|
||||||
|
if (!departureAt) doubt();
|
||||||
|
// The scheduled arrival belongs to the scheduled departure only; after a
|
||||||
|
// change, only the updated arrival (if any) goes with the updated departure.
|
||||||
|
const onSchedule = !current || (!!original && sameTime(current, original));
|
||||||
|
const arrivalAt = arrivalTime('currentArrivalDate') ?? (onSchedule ? arrivalTime('originalArrivalDate') : undefined);
|
||||||
|
|
||||||
|
const passenger = personName(semantics.passengerName);
|
||||||
|
const provider = text(semantics, 'transitProvider');
|
||||||
|
const organization = str(pass.organizationName);
|
||||||
|
const reference = text(semantics, 'confirmationNumber');
|
||||||
|
|
||||||
|
let leg: TransportLeg;
|
||||||
|
if (kind === 'flight') {
|
||||||
|
const { carrierCode, number } = flightIdentity(semantics);
|
||||||
|
leg = {
|
||||||
|
...(provider ? { carrier: provider } : {}),
|
||||||
|
carrierCode,
|
||||||
|
number,
|
||||||
|
departure: { place: airport(semantics, 'departure', true), at: departureAt },
|
||||||
|
arrival: { place: airport(semantics, 'destination', false), ...(arrivalAt ? { at: arrivalAt } : {}) },
|
||||||
|
seats: seats(semantics, kind, passenger),
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
const number = text(semantics, 'vehicleNumber');
|
||||||
|
leg = {
|
||||||
|
...(provider ? { carrier: provider } : {}),
|
||||||
|
...(number ? { number } : {}),
|
||||||
|
departure: { place: station(semantics, 'departure'), at: departureAt },
|
||||||
|
arrival: { place: station(semantics, 'destination'), ...(arrivalAt ? { at: arrivalAt } : {}) },
|
||||||
|
seats: seats(semantics, kind, passenger),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const shownProvider = provider ?? organization;
|
||||||
|
return {
|
||||||
|
key: '',
|
||||||
|
kind,
|
||||||
|
...(reference ? { reference } : {}),
|
||||||
|
passengers: passenger ? [passenger] : [],
|
||||||
|
...(shownProvider ? { provider: shownProvider } : {}),
|
||||||
|
links: {},
|
||||||
|
source: 'pkpass',
|
||||||
|
legs: [leg],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function eventCard(pass: Obj, semantics: Obj): EventBooking {
|
||||||
|
const name = text(semantics, 'eventName');
|
||||||
|
// Event tickets have no zone tag, so a UTC time can't be placed: doubtful.
|
||||||
|
const start = placeTime(semantics, undefined, date(semantics, 'eventStartDate'), false);
|
||||||
|
if (!name || !start) doubt();
|
||||||
|
let end = placeTime(semantics, undefined, date(semantics, 'eventEndDate'), false);
|
||||||
|
// A date-only end next to a timed start says nothing reliable about when it ends.
|
||||||
|
if (end && !end.time && start.time) end = undefined;
|
||||||
|
const venueName = text(semantics, 'venueName');
|
||||||
|
const region = text(semantics, 'venueRegionName');
|
||||||
|
const venue: Place = {
|
||||||
|
...(venueName ? { name: venueName } : {}),
|
||||||
|
...(region ? { address: { locality: region } } : {}),
|
||||||
|
};
|
||||||
|
const attendee = text(semantics, 'attendeeName');
|
||||||
|
const reference = text(semantics, 'confirmationNumber');
|
||||||
|
const organization = str(pass.organizationName);
|
||||||
|
return {
|
||||||
|
key: '',
|
||||||
|
kind: 'event',
|
||||||
|
...(reference ? { reference } : {}),
|
||||||
|
passengers: attendee ? [attendee] : [],
|
||||||
|
...(organization ? { provider: organization } : {}),
|
||||||
|
links: {},
|
||||||
|
source: 'pkpass',
|
||||||
|
name,
|
||||||
|
...(venue.name || venue.address ? { venue } : {}),
|
||||||
|
start,
|
||||||
|
...(end ? { end } : {}),
|
||||||
|
tickets: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The cards a parsed pass.json describes: at most one, validated against
|
||||||
|
* the mail's date (validateCard), with its merge key, zones not yet resolved.
|
||||||
|
* Empty for any pass that isn't a boarding pass for air, train or bus or an
|
||||||
|
* event ticket, for a voided pass, for a pass without `semantics`, and for
|
||||||
|
* anything doubtful. Never throws.
|
||||||
|
*/
|
||||||
|
export function passToCards(pass: unknown, ctx: { mailDate: Date }): StructuredCard[] {
|
||||||
|
try {
|
||||||
|
if (!isObj(pass) || pass.formatVersion !== 1 || pass.voided === true) return [];
|
||||||
|
const styles = PASS_STYLES.filter((style) => pass[style] !== undefined);
|
||||||
|
if (styles.length !== 1) return [];
|
||||||
|
const semantics = pass.semantics;
|
||||||
|
if (!isObj(semantics)) return [];
|
||||||
|
let card: StructuredCard;
|
||||||
|
if (styles[0] === 'boardingPass') {
|
||||||
|
const boarding = pass.boardingPass;
|
||||||
|
if (!isObj(boarding) || typeof boarding.transitType !== 'string') return [];
|
||||||
|
const kind = TRANSIT_KINDS[boarding.transitType];
|
||||||
|
if (!kind) return [];
|
||||||
|
card = transportCard(kind, pass, semantics);
|
||||||
|
} else if (styles[0] === 'eventTicket') {
|
||||||
|
if (!isObj(pass.eventTicket)) return [];
|
||||||
|
card = eventCard(pass, semantics);
|
||||||
|
} else {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
const keyed = { ...card, key: cardKey(card) } as StructuredCard;
|
||||||
|
return validateCard(keyed, ctx.mailDate).problems.length === 0 ? [keyed] : [];
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** readPassJson + passToCards. */
|
||||||
|
export async function readPkpass(bytes: ArrayBuffer | Uint8Array, ctx: { mailDate: Date }, options?: ReadPassOptions): Promise<StructuredCard[]> {
|
||||||
|
const pass = await readPassJson(bytes, options);
|
||||||
|
return pass ? passToCards(pass, ctx) : [];
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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<string, string> = {
|
||||||
|
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<string, Intl.DateTimeFormat>();
|
||||||
|
|
||||||
|
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<string, number> = {};
|
||||||
|
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<number>();
|
||||||
|
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<string, number> };
|
||||||
|
let airportTable: Promise<AirportTable> | 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' };
|
||||||
|
}
|
||||||
@@ -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<Email, 'from' | 'subject' | 'headers' | 'htmlBody' | 'textBody' | 'bodyValues'> & {
|
||||||
|
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<Email, 'htmlBody' | 'bodyValues'>): { 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 };
|
||||||
|
}
|
||||||
@@ -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';
|
||||||
|
}
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
/**
|
||||||
|
* App only: strict UTF-8 decoding for pkpass.ts, which the webmail does with
|
||||||
|
* `new TextDecoder('utf-8', { fatal: true })`. Hermes may lack TextDecoder or
|
||||||
|
* its `fatal` option; then the bytes are decoded here, and a malformed
|
||||||
|
* sequence throws as the fatal decoder would.
|
||||||
|
*/
|
||||||
|
export function decodeUtf8Strict(bytes: Uint8Array): string {
|
||||||
|
const Decoder = (globalThis as { TextDecoder?: typeof TextDecoder }).TextDecoder;
|
||||||
|
if (Decoder) {
|
||||||
|
let decoder: TextDecoder | null = null;
|
||||||
|
try {
|
||||||
|
decoder = new Decoder('utf-8', { fatal: true });
|
||||||
|
} catch {
|
||||||
|
decoder = null;
|
||||||
|
}
|
||||||
|
if (decoder) return decoder.decode(bytes);
|
||||||
|
}
|
||||||
|
return decodeByHand(bytes);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** UTF-8 to a string, throwing on overlong, surrogate, out-of-range or cut-off sequences. */
|
||||||
|
export function decodeByHand(bytes: Uint8Array): string {
|
||||||
|
const parts: string[] = [];
|
||||||
|
let chunk: number[] = [];
|
||||||
|
const flush = () => {
|
||||||
|
if (chunk.length > 0) parts.push(String.fromCodePoint(...chunk));
|
||||||
|
chunk = [];
|
||||||
|
};
|
||||||
|
let i = 0;
|
||||||
|
while (i < bytes.length) {
|
||||||
|
const b0 = bytes[i];
|
||||||
|
let cp: number;
|
||||||
|
let need: number;
|
||||||
|
let min: number;
|
||||||
|
if (b0 < 0x80) { cp = b0; need = 0; min = 0; }
|
||||||
|
else if (b0 >= 0xc2 && b0 <= 0xdf) { cp = b0 & 0x1f; need = 1; min = 0x80; }
|
||||||
|
else if (b0 >= 0xe0 && b0 <= 0xef) { cp = b0 & 0x0f; need = 2; min = 0x800; }
|
||||||
|
else if (b0 >= 0xf0 && b0 <= 0xf4) { cp = b0 & 0x07; need = 3; min = 0x10000; }
|
||||||
|
else throw new TypeError('invalid UTF-8');
|
||||||
|
// A missing continuation byte (the input ends mid-sequence) is undefined here.
|
||||||
|
for (let k = 1; k <= need; k++) {
|
||||||
|
const b = bytes[i + k];
|
||||||
|
if (b === undefined || (b & 0xc0) !== 0x80) throw new TypeError('invalid UTF-8');
|
||||||
|
cp = (cp << 6) | (b & 0x3f);
|
||||||
|
}
|
||||||
|
if (cp < min || cp > 0x10ffff || (cp >= 0xd800 && cp <= 0xdfff)) throw new TypeError('invalid UTF-8');
|
||||||
|
chunk.push(cp);
|
||||||
|
if (chunk.length >= 4096) flush();
|
||||||
|
i += need + 1;
|
||||||
|
}
|
||||||
|
flush();
|
||||||
|
return parts.join('');
|
||||||
|
}
|
||||||
@@ -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 };
|
||||||
|
}
|
||||||
@@ -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<string, unknown>;
|
||||||
|
|
||||||
|
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<string, ReservationStatus> = {
|
||||||
|
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<string, OrderStatus> = {
|
||||||
|
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)];
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user