@bobfrankston/mailx-types 0.1.57 → 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 +187 -0
  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
@@ -529,4 +529,191 @@ export function formatSender(addr) {
529
529
  return { text: address, claimed: null };
530
530
  return { text: `${name} <${address}>`, claimed: displayNameClaimsOtherAddress(name, address) };
531
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
+ }
532
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.57",
3
+ "version": "0.1.59",
4
4
  "type": "module",
5
5
  "main": "index.js",
6
6
  "types": "index.d.ts",