mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-10-30 16:16:19 +00:00
134 lines
5.7 KiB
TypeScript
134 lines
5.7 KiB
TypeScript
/**
|
|
* 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 };
|
|
}
|