@bobfrankston/mailx-types 0.1.55 → 0.1.59

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/index.d.ts +56 -0
  2. package/index.js +212 -2
  3. package/package.json +1 -1
package/index.d.ts CHANGED
@@ -497,4 +497,60 @@ export declare function formatSender(addr: {
497
497
  text: string;
498
498
  claimed: string | null;
499
499
  };
500
+ export interface InviteAttendee {
501
+ name: string;
502
+ email: string;
503
+ /** NEEDS-ACTION | ACCEPTED | DECLINED | TENTATIVE | DELEGATED */
504
+ status: string;
505
+ rsvp: boolean;
506
+ }
507
+ export interface ParsedInvite {
508
+ /** REQUEST (an invitation), REPLY (someone's RSVP), CANCEL, PUBLISH… */
509
+ method: string;
510
+ uid: string;
511
+ sequence: number;
512
+ summary: string;
513
+ description: string;
514
+ location: string;
515
+ /** ISO 8601. All-day events carry a bare date (no time part). */
516
+ start: string;
517
+ end: string;
518
+ /** IANA zone from TZID, when the invite specified one. */
519
+ tzid: string;
520
+ allDay: boolean;
521
+ organizer: {
522
+ name: string;
523
+ email: string;
524
+ };
525
+ attendees: InviteAttendee[];
526
+ /** Raw RRULE, if the event repeats. Rendered verbatim — turning it into
527
+ * prose is a separate job and getting it subtly wrong is worse than
528
+ * showing the rule. */
529
+ rrule: string;
530
+ status: string;
531
+ /** True when the invite was produced by Google Calendar. Google's own
532
+ * HTML body carries working Yes/No/Maybe links, so the UI must NOT add a
533
+ * second set of RSVP controls for these — see inviteHasGoogleRsvp. */
534
+ fromGoogle: boolean;
535
+ }
536
+ /**
537
+ * Parse the VEVENT out of a text/calendar part.
538
+ *
539
+ * Deliberately hand-rolled rather than pulling in an ICS library: mailx-types
540
+ * is the zero-dependency package shared with Android, and an invite card needs
541
+ * a dozen properties, not full RFC 5545 coverage. Returns null when there is
542
+ * no VEVENT to show.
543
+ */
544
+ export declare function parseInvite(ics: string): ParsedInvite | null;
545
+ /**
546
+ * Does the message body already carry Google's own Yes/No/Maybe controls?
547
+ *
548
+ * Google Calendar invitations embed working RSVP links
549
+ * (`calendar.google.com/calendar/event?action=RESPOND&rst=1|2|3`). When they
550
+ * are present the UI must show the invite summary but NOT a second set of RSVP
551
+ * buttons — two responders racing to set PARTSTAT is worse than one
552
+ * (Bob 2026-08-22: "Google's own responder handles it so you might detect that
553
+ * case specially").
554
+ */
555
+ export declare function inviteHasGoogleRsvp(bodyHtml: string): boolean;
500
556
  //# sourceMappingURL=index.d.ts.map
package/index.js CHANGED
@@ -479,9 +479,32 @@ export function displayNameClaimsOtherAddress(name, address) {
479
479
  const m = n.match(/[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}/g);
480
480
  if (!m)
481
481
  return null;
482
+ const realDomain = real.split("@").pop() || "";
482
483
  for (const claimed of m) {
483
- if (claimed.toLowerCase() !== real)
484
- return claimed;
484
+ if (claimed.toLowerCase() === real)
485
+ continue;
486
+ // Same organisation is not impersonation. Plenty of legitimate senders
487
+ // put a contact address in the display name that differs from the
488
+ // envelope address at the same org — IEEE Xplore alerts arrive as
489
+ // `"Content-Alert@ieee.org" <no-reply@xplore.ieee.org>`, which the
490
+ // first cut flagged in danger red and read as a spam marker
491
+ // (Bob 2026-08-20: "marked as spam but is a valid message"; that
492
+ // message passes SPF, DKIM and DMARC and SpamAssassin scores it -2.0).
493
+ //
494
+ // Sub/parent-domain rather than "same registrable domain" on purpose:
495
+ // comparing the last two labels would treat paypal.co.uk and
496
+ // evil.co.uk as related, which is exactly the case that must stay
497
+ // flagged. A subdomain relationship cannot be forged across orgs.
498
+ const claimedDomain = claimed.toLowerCase().split("@").pop() || "";
499
+ if (claimedDomain && realDomain) {
500
+ if (claimedDomain === realDomain)
501
+ continue;
502
+ if (claimedDomain.endsWith("." + realDomain))
503
+ continue;
504
+ if (realDomain.endsWith("." + claimedDomain))
505
+ continue;
506
+ }
507
+ return claimed;
485
508
  }
486
509
  return null;
487
510
  }
@@ -506,4 +529,191 @@ export function formatSender(addr) {
506
529
  return { text: address, claimed: null };
507
530
  return { text: `${name} <${address}>`, claimed: displayNameClaimsOtherAddress(name, address) };
508
531
  }
532
+ /** Unfold RFC 5545 continuation lines: a line beginning with a space or tab
533
+ * continues the previous one. Google folds ATTENDEE lines mid-parameter, so
534
+ * skipping this loses attendees entirely. */
535
+ function unfoldIcs(text) {
536
+ const raw = (text || "").replace(/\r\n/g, "\n").split("\n");
537
+ const out = [];
538
+ for (const line of raw) {
539
+ if (/^[ \t]/.test(line) && out.length)
540
+ out[out.length - 1] += line.slice(1);
541
+ else
542
+ out.push(line);
543
+ }
544
+ return out;
545
+ }
546
+ /** RFC 5545 TEXT unescaping: \\n is a newline, and \\, \\; \\, are literals. */
547
+ function unescapeIcs(v) {
548
+ return (v || "")
549
+ .replace(/\\n/gi, "\n")
550
+ .replace(/\\([;,\\])/g, "$1");
551
+ }
552
+ /** Split "KEY;PARAM=x;PARAM2=y:value" into its three pieces. */
553
+ function splitIcsLine(line) {
554
+ // The first colon that is not inside a quoted parameter ends the name part.
555
+ let inQuote = false, colon = -1;
556
+ for (let i = 0; i < line.length; i++) {
557
+ const c = line[i];
558
+ if (c === '"')
559
+ inQuote = !inQuote;
560
+ else if (c === ":" && !inQuote) {
561
+ colon = i;
562
+ break;
563
+ }
564
+ }
565
+ if (colon < 0)
566
+ return null;
567
+ const namePart = line.slice(0, colon);
568
+ const value = line.slice(colon + 1);
569
+ const bits = namePart.split(";");
570
+ const key = (bits.shift() || "").toUpperCase();
571
+ const params = {};
572
+ for (const b of bits) {
573
+ const eq = b.indexOf("=");
574
+ if (eq < 0)
575
+ continue;
576
+ params[b.slice(0, eq).toUpperCase()] = b.slice(eq + 1).replace(/^"|"$/g, "");
577
+ }
578
+ return { key, params, value };
579
+ }
580
+ /** ICS timestamp → ISO 8601. Handles 20260823T080000 (local/zoned),
581
+ * 20260823T133939Z (UTC) and 20260823 (all-day). Zoned values keep their
582
+ * wall-clock reading; the caller pairs them with `tzid`. */
583
+ function icsDateToIso(v, isUtc) {
584
+ const m = (v || "").match(/^(\d{4})(\d{2})(\d{2})(?:T(\d{2})(\d{2})(\d{2})(Z)?)?$/);
585
+ if (!m)
586
+ return "";
587
+ const [, y, mo, d, hh, mm, ss, z] = m;
588
+ if (!hh)
589
+ return `${y}-${mo}-${d}`;
590
+ const base = `${y}-${mo}-${d}T${hh}:${mm}:${ss}`;
591
+ return (z || isUtc) ? base + "Z" : base;
592
+ }
593
+ function parseCalAddress(value, params) {
594
+ const email = (value || "").replace(/^mailto:/i, "").trim();
595
+ return { name: (params.CN || "").trim() || email, email };
596
+ }
597
+ /**
598
+ * Parse the VEVENT out of a text/calendar part.
599
+ *
600
+ * Deliberately hand-rolled rather than pulling in an ICS library: mailx-types
601
+ * is the zero-dependency package shared with Android, and an invite card needs
602
+ * a dozen properties, not full RFC 5545 coverage. Returns null when there is
603
+ * no VEVENT to show.
604
+ */
605
+ export function parseInvite(ics) {
606
+ if (!ics || !/BEGIN:VEVENT/i.test(ics))
607
+ return null;
608
+ const lines = unfoldIcs(ics);
609
+ const out = {
610
+ method: "", uid: "", sequence: 0, summary: "", description: "", location: "",
611
+ start: "", end: "", tzid: "", allDay: false,
612
+ organizer: { name: "", email: "" }, attendees: [], rrule: "", status: "",
613
+ fromGoogle: false,
614
+ };
615
+ let inEvent = false;
616
+ // VTIMEZONE blocks contain their own DTSTART/RRULE — those describe the
617
+ // zone's DST rules, not the meeting. Ignore everything inside them.
618
+ let depthNonEvent = 0;
619
+ for (const line of lines) {
620
+ const up = line.toUpperCase();
621
+ if (up.startsWith("BEGIN:VEVENT")) {
622
+ inEvent = true;
623
+ continue;
624
+ }
625
+ if (up.startsWith("END:VEVENT")) {
626
+ inEvent = false;
627
+ continue;
628
+ }
629
+ if (!inEvent && (up.startsWith("BEGIN:VTIMEZONE") || up.startsWith("BEGIN:VALARM"))) {
630
+ depthNonEvent++;
631
+ continue;
632
+ }
633
+ if (up.startsWith("END:VTIMEZONE") || up.startsWith("END:VALARM")) {
634
+ if (depthNonEvent)
635
+ depthNonEvent--;
636
+ continue;
637
+ }
638
+ if (inEvent && up.startsWith("BEGIN:VALARM")) {
639
+ depthNonEvent++;
640
+ continue;
641
+ }
642
+ const p = splitIcsLine(line);
643
+ if (!p)
644
+ continue;
645
+ if (!inEvent) {
646
+ if (p.key === "METHOD")
647
+ out.method = p.value.trim().toUpperCase();
648
+ else if (p.key === "PRODID" && /google/i.test(p.value))
649
+ out.fromGoogle = true;
650
+ continue;
651
+ }
652
+ if (depthNonEvent)
653
+ continue; // inside VALARM
654
+ switch (p.key) {
655
+ case "SUMMARY":
656
+ out.summary = unescapeIcs(p.value);
657
+ break;
658
+ case "DESCRIPTION":
659
+ out.description = unescapeIcs(p.value);
660
+ break;
661
+ case "LOCATION":
662
+ out.location = unescapeIcs(p.value);
663
+ break;
664
+ case "UID":
665
+ out.uid = p.value.trim();
666
+ break;
667
+ case "STATUS":
668
+ out.status = p.value.trim().toUpperCase();
669
+ break;
670
+ case "RRULE":
671
+ out.rrule = p.value.trim();
672
+ break;
673
+ case "SEQUENCE":
674
+ out.sequence = parseInt(p.value, 10) || 0;
675
+ break;
676
+ case "DTSTART":
677
+ out.start = icsDateToIso(p.value.trim(), false);
678
+ out.tzid = p.params.TZID || out.tzid;
679
+ out.allDay = (p.params.VALUE || "").toUpperCase() === "DATE" || !/T/.test(p.value);
680
+ break;
681
+ case "DTEND":
682
+ out.end = icsDateToIso(p.value.trim(), false);
683
+ out.tzid = out.tzid || p.params.TZID || "";
684
+ break;
685
+ case "ORGANIZER":
686
+ out.organizer = parseCalAddress(p.value, p.params);
687
+ break;
688
+ case "ATTENDEE": {
689
+ const a = parseCalAddress(p.value, p.params);
690
+ out.attendees.push({
691
+ name: a.name,
692
+ email: a.email,
693
+ status: (p.params.PARTSTAT || "NEEDS-ACTION").toUpperCase(),
694
+ rsvp: (p.params.RSVP || "").toUpperCase() === "TRUE",
695
+ });
696
+ break;
697
+ }
698
+ }
699
+ }
700
+ if (!out.summary && !out.start)
701
+ return null;
702
+ return out;
703
+ }
704
+ /**
705
+ * Does the message body already carry Google's own Yes/No/Maybe controls?
706
+ *
707
+ * Google Calendar invitations embed working RSVP links
708
+ * (`calendar.google.com/calendar/event?action=RESPOND&rst=1|2|3`). When they
709
+ * are present the UI must show the invite summary but NOT a second set of RSVP
710
+ * buttons — two responders racing to set PARTSTAT is worse than one
711
+ * (Bob 2026-08-22: "Google's own responder handles it so you might detect that
712
+ * case specially").
713
+ */
714
+ export function inviteHasGoogleRsvp(bodyHtml) {
715
+ if (!bodyHtml)
716
+ return false;
717
+ return /calendar\.google\.com\/calendar\/event\?action=RESPOND/i.test(bodyHtml);
718
+ }
509
719
  //# sourceMappingURL=index.js.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bobfrankston/mailx-types",
3
- "version": "0.1.55",
3
+ "version": "0.1.59",
4
4
  "type": "module",
5
5
  "main": "index.js",
6
6
  "types": "index.d.ts",