Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions connectors/apple/package.json
Original file line numberDiff line numberDiff line change
Expand Up@@ -29,6 +29,7 @@
"test": "vitest run"
},
"dependencies": {
"@plotday/rsvp-fold": "workspace:^",
"@plotday/twister": "workspace:^"
},
"devDependencies": {
Expand Down
52 changes: 52 additions & 0 deletions connectors/apple/src/mail/calendar-bundle.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -63,6 +63,22 @@ describe("classifyICS — the full classification matrix", () => {
expect(result).toBeNull();
});

it("skips a METHOD:REPLY with no SEQUENCE too — folding is not this function's job", () => {
// The null verdict says nothing about what becomes of the message: in a
// sync pass a recognised response is folded onto the event's own thread
// before it is ever offered here (see `sync.ts`).
const raw = [
"BEGIN:VCALENDAR",
"METHOD:REPLY",
"BEGIN:VEVENT",
"UID:evt-reply@example.test",
"END:VEVENT",
"END:VCALENDAR",
].join("\r\n");

expect(classifyICS(raw)).toBeNull();
});

it("returns null when the ICS has no UID at all", () => {
const result = classifyICS(ics({ method: "CANCEL" }));
expect(result).toBeNull();
Expand DownExpand Up@@ -109,3 +125,39 @@ describe("classifyICS — the full classification matrix", () => {
expect(classifyICS(folded)).toEqual({ uid: "evt-8-part1-part2", kind: "cancel" });
});
});

describe("classifyICS — property reading after the shared-icsProp swap", () => {
it("reads a folded UID line (RFC 5545 continuation) the same as before", () => {
// A 75-octet line wrapped with CRLF + single space. The UID must come
// back joined, not truncated at the fold.
const ics = [
"BEGIN:VCALENDAR",
"METHOD:CANCEL",
"BEGIN:VEVENT",
"UID:this-is-a-deliberately-long-identifier-that-the-generator-wrapped",
" -across-two-lines@example.test",
"SEQUENCE:1",
"END:VEVENT",
"END:VCALENDAR",
].join("\r\n");

expect(classifyICS(ics)).toEqual({
uid: "this-is-a-deliberately-long-identifier-that-the-generator-wrapped-across-two-lines@example.test",
kind: "cancel",
});
});

it("ignores parameters on the property it reads", () => {
const ics = [
"BEGIN:VCALENDAR",
"METHOD:REQUEST",
"BEGIN:VEVENT",
"UID;X-VENDOR-QUIRK=1:evt-params@example.test",
"SEQUENCE:3",
"END:VEVENT",
"END:VCALENDAR",
].join("\r\n");

expect(classifyICS(ics)).toEqual({ uid: "evt-params@example.test", kind: "update" });
});
});
61 changes: 31 additions & 30 deletions connectors/apple/src/mail/calendar-bundle.ts
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,25 @@
/**
* Mail-side half of mail↔calendar thread bundling (see `apple.ts`'s
* `buildEventSources()` for the calendar side, which already emits
* `["apple-calendar:<uid>", "icaluid:<uid>"]`). When an inbound email
* carries a `text/calendar`/`application/ics` MIME part, this classifies its
* Mail-side classification of a calendar MIME part, and the bundling half of
* how an email meets its event (see `apple.ts`'s `buildEventSources()` for the
* calendar side, which already emits
* `["apple-calendar:<uid>", "icaluid:<uid>"]`). When an inbound email carries
* a `text/calendar`/`application/ics` MIME part, this classifies its
* relationship to the referenced event so `sync.ts` can decide whether to
* bundle the mail thread onto the same Plot thread as the calendar event via
* the shared `icaluid:<uid>` alias.
*
* Bundling is not the only way a calendar part reaches the event's thread.
* `sync.ts` routes an attendee response (`METHOD:REPLY`) to a separate FOLD:
* the response is attached to the event's thread as a note of its own — or, if
* it is a bare acceptance saying nothing the guest list does not already show,
* dropped entirely — and its message is kept out of the mail thread either
* way. A response the fold RECOGNISES never reaches `classifyICS` in a sync
* pass, so the non-bundling verdict this file gives one says nothing about what
* becomes of the message. A `METHOD:REPLY` the fold does not recognise — no
* `ATTENDEE` line, or a `PARTSTAT` outside accepted/declined/tentative such as
* `NEEDS-ACTION` or `DELEGATED` — does fall through to here, and is classified
* as non-bundling like any other part.
*
* Ports the Google connector's `classifyCalendarThread` decision
* (`google/src/mail/gmail-api.ts`) — the product-approved rule for which ICS
* methods bundle vs. skip — adapted to a single already-fetched ICS blob
Expand All@@ -15,6 +28,8 @@
* and hands the decoded text to `classifyICS`).
*/

import { icsProp } from "@plotday/rsvp-fold";

/** Raw classification of one ICS blob, before the mail sync pass resolves
* whether the calendar product has already synced an event for that UID. */
export type ClassifiedICS = { uid: string; kind: "cancel" | "update" };
Expand All@@ -39,37 +54,23 @@ export function isCalendarAttachment(mimeType: string): boolean {
return CALENDAR_MIME_TYPES.has(mimeType.toLowerCase());
}

/**
* Unfold RFC 5545 continuation lines (CRLF/LF + leading space/tab is a
* continuation of the previous line's value) and read a property's value.
* Unscoped — matches the property anywhere in the ICS text, which is
* correct for `METHOD` (a VCALENDAR-level property that sits outside
* `BEGIN:VEVENT`/`END:VEVENT`; the existing `parseICSEvents`/`parseVEvent`
* in `../calendar/ics-parser` parses only VEVENT-scoped properties and has
* no `method` field at all) as well as for `UID`/`SEQUENCE` (VEVENT-scoped,
* but a calendar invite email carries exactly one VEVENT).
*/
function icsProp(ics: string, name: string): string | null {
const unfolded = ics.replace(/\r?\n[ \t]/g, "");
const re = new RegExp(`^${name}(?:;[^:\\r\\n]*)?:(.*)$`, "im");
const m = unfolded.match(re);
return m ? m[1].trim() : null;
}

/**
* Classify one ICS (VCALENDAR) text's relationship to its event, per the
* product-approved rule (see module doc):
*
* | ICS content | Action |
* |-------------------------------------------|---------|
* | `METHOD:CANCEL` | bundle |
* | `METHOD:REQUEST` with `SEQUENCE > 0` | bundle |
* | `METHOD:REQUEST` with `SEQUENCE == 0` | skip |
* | `METHOD:REPLY` (an RSVP) | skip |
* | ICS content | Action |
* |---------------------------------------|--------------------------------------------|
* | `METHOD:CANCEL` | bundle |
* | `METHOD:REQUEST` with `SEQUENCE > 0` | bundle |
* | `METHOD:REQUEST` with `SEQUENCE == 0` | skip |
* | `METHOD:REPLY` (an RSVP) | folded onto the event thread (see sync.ts) |
*
* Returns `null` for "skip" (including no parseable UID at all) so callers
* can uniformly treat every non-bundling case — RSVP, bare invite, or
* unparseable text — the same way.
* Returns `null` for everything that does not bundle (including no parseable
* UID at all) so callers can uniformly treat every non-bundling case — RSVP,
* bare invite, or unparseable text — the same way. A `METHOD:REPLY` still
* returns `null` here; in a sync pass it is normally folded before this
* function is offered the part, so that `null` is reached only by another
* caller or by a response the fold does not recognise (see the module doc).
*/
export function classifyICS(ics: string): ClassifiedICS | null {
const uid = icsProp(ics, "UID");
Expand Down
Loading
Loading