@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.
- package/index.d.ts +56 -0
- package/index.js +187 -0
- 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
|