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
10 changes: 10 additions & 0 deletions .changeset/new-note-unread-default.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
---
"@plotday/twister": patch
---

Fixed: corrected the documented default for `NewNote.unread`.

Omitting the flag was described as "leave read state alone". It is not — attaching
a note marks its thread unread for every recipient except the note's author, and
there is no outcome that leaves read state untouched. A note that should not raise
unread must pass an explicit `false`.
1 change: 1 addition & 0 deletions connectors/google/package.json
Original file line numberDiff line numberDiff line change
Expand Up@@ -31,6 +31,7 @@
},
"dependencies": {
"@plotday/google-contacts": "workspace:^",
"@plotday/rsvp-fold": "workspace:^",
"@plotday/twister": "workspace:^"
},
"devDependencies": {
Expand Down
140 changes: 20 additions & 120 deletions connectors/google/src/mail/gmail-api.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -10,6 +10,7 @@ import type {
import { markdownToPlainText } from "@plotday/twister/utils/markdown";
import { markdownToHtml } from "@plotday/twister/utils/markdown-html";
import { isNoReplySender } from "@plotday/twister/signals";
import { icsProp, parseIcsReply } from "@plotday/rsvp-fold";


export type GmailLabel = {
Expand DownExpand Up@@ -767,25 +768,6 @@ function normalizeMessageId(raw: string | null): string | null {
return match ? match[0] : raw.trim();
}

/**
* Unfold RFC 5545 lines (CRLF + leading space/tab is a continuation) and
* match one property line: group 1 is its parameter section (leading `;`
* included, or `""` when there are none), group 2 is its value. Shared by
* `icsProp` (value only) and `icsPropLine` (params + value), so the
* unfolding rule and line regex exist exactly once.
*/
function matchIcsLine(ics: string, name: string): RegExpMatchArray | null {
const unfolded = ics.replace(/\r?\n[ \t]/g, "");
const re = new RegExp(`^${name}((?:;[^:\\r\\n]*)?):(.*)$`, "im");
return unfolded.match(re);
}

/** Unfold RFC 5545 lines (CRLF + leading space/tab is a continuation) and read a property. */
function icsProp(ics: string, name: string): string | null {
const m = matchIcsLine(ics, name);
return m ? m[2].trim() : null;
}

/**
* MIME types an iCalendar part is delivered under. Google and Exchange use
* `text/calendar`; a sizeable minority of senders use `application/ics`.
Expand DownExpand Up@@ -927,60 +909,6 @@ export type CalendarReply = {
sourceCreatedAt: Date;
};

/**
* RFC 5545 text un-escaping: `\n`/`\N` → newline, `\,` `\;` `\\` → the
* literal character. Single-pass so an escaped backslash immediately
* followed by a literal `n` (`\\n`) isn't misread as a newline escape — a
* two-pass `\n`-then-`\\` replacement would consume the second backslash of
* `\\` as if it started its own `\n` escape.
*/
function unescapeIcsText(value: string): string {
return value.replace(/\\([nN,;\\])/g, (_, ch: string) =>
ch === "n" || ch === "N" ? "\n" : ch
);
}

/**
* Read a property's raw line (parameters included) from an ICS body. Shares
* `icsProp`'s unfolding and line regex via `matchIcsLine`, but returns
* everything after the property name so parameters can be parsed.
*/
function icsPropLine(ics: string, name: string): string | null {
const m = matchIcsLine(ics, name);
return m ? `${m[1]}:${m[2]}` : null;
}

/**
* Split an ICS property's parameter section into a map. Values may be quoted
* (`X-RESPONSE-COMMENT="a, b"`), and a quoted value may contain the `;` and
* `:` that otherwise delimit parameters — so scan rather than split.
*/
function parseIcsParams(paramSection: string): Record<string, string> {
const params: Record<string, string> = {};
const re = /;([A-Za-z0-9-]+)=("([^"]*)"|[^;:]*)/g;
let m: RegExpExecArray | null;
while ((m = re.exec(paramSection)) !== null) {
params[m[1].toUpperCase()] = m[3] !== undefined ? m[3] : m[2];
}
return params;
}

/**
* Parse an ICS date-time into a UTC instant. Handles `20260804T140000Z`
* (UTC), `20260804T100000` (floating or TZID-qualified — read as UTC, since
* resolving a TZID needs a tz database the worker doesn't carry), and
* `20260804` (VALUE=DATE).
*/
function parseIcsDate(value: string): Date | null {
const m = value
.trim()
.match(/^(\d{4})(\d{2})(\d{2})(?:T(\d{2})(\d{2})(\d{2})(Z)?)?$/);
if (!m) return null;
const [, y, mo, d, h = "00", mi = "00", s = "00"] = m;
const ms = Date.UTC(+y, +mo - 1, +d, +h, +mi, +s);
return Number.isNaN(ms) ? null : new Date(ms);
}

/**
* Google's response-notification body opens with "<name> has declined this
* invitation with a note:" followed by the quoted comment, before the Meet /
Expand DownExpand Up@@ -1008,9 +936,16 @@ function commentFromBody(message: GmailMessage): string | null {
* Extract every attendee response carried by a Gmail conversation.
*
* A descriptor is produced for each message whose calendar body — read ahead
* of time by {@link resolveIcsByMessage} — carries `METHOD:REPLY`, a `UID`, an
* `ATTENDEE` with a decided `PARTSTAT`, and a resolvable attendee address.
* `NEEDS-ACTION` yields nothing — there is no response to report.
* of time by {@link resolveIcsByMessage} — carries a `UID` and parses as a
* decided response via the shared {@link parseIcsReply}. `NEEDS-ACTION`
* yields nothing — there is no response to report.
*
* The per-ICS parse (`METHOD`, `PARTSTAT`, `RECURRENCE-ID`, `COMMENT` /
* `X-RESPONSE-COMMENT`, `ATTENDEE` CN/mailto) is shared with other calendar
* connectors via `@plotday/rsvp-fold`; only the Gmail-shaped bits stay here:
* looping over the conversation's messages, the `UID` used to address the
* event thread, and falling back to the notification body's quoted note when
* the ICS itself carried none.
*
* Every reply message is returned rather than only the first, so a
* conversation carrying a revised response stays correct.
Expand All@@ -1024,60 +959,25 @@ export function extractCalendarReplies(
for (const message of messages) {
const ics = icsByMessage.get(message.id);
if (!ics) continue;
if ((icsProp(ics, "METHOD") ?? "").toUpperCase() !== "REPLY") continue;

const uid = icsProp(ics, "UID");
if (!uid) continue;

const attendeeLine = icsPropLine(ics, "ATTENDEE");
if (!attendeeLine) continue;
const sep = attendeeLine.lastIndexOf(":");
const params = parseIcsParams(attendeeLine.slice(0, sep));
const attendeeEmail = attendeeLine
.slice(sep + 1)
.trim()
.replace(/^mailto:/i, "");
if (!attendeeEmail) continue;

const partstat = (params.PARTSTAT ?? "").toUpperCase();
if (
partstat !== "DECLINED" &&
partstat !== "ACCEPTED" &&
partstat !== "TENTATIVE"
) {
continue;
}

const recurrenceLine = icsPropLine(ics, "RECURRENCE-ID");
let occurrence: Date | null = null;
let allDay = false;
if (recurrenceLine) {
const rSep = recurrenceLine.lastIndexOf(":");
const rParams = parseIcsParams(recurrenceLine.slice(0, rSep));
allDay = (rParams.VALUE ?? "").toUpperCase() === "DATE";
occurrence = parseIcsDate(recurrenceLine.slice(rSep + 1));
}

const icsComment = icsProp(ics, "COMMENT");
const comment =
(icsComment ? unescapeIcsText(icsComment).trim() : "") ||
(params["X-RESPONSE-COMMENT"]
? unescapeIcsText(params["X-RESPONSE-COMMENT"]).trim()
: "") ||
commentFromBody(message) ||
null;

const fromName =
parseEmailAddress(getHeader(message, "From") ?? "")?.name ?? null;
const reply = parseIcsReply(ics, { name: fromName });
if (!reply) continue;

const comment = reply.comment ?? commentFromBody(message);

replies.push({
messageId: message.id,
uid,
partstat,
attendeeName: params.CN?.trim() || fromName || null,
attendeeEmail,
occurrence,
allDay,
partstat: reply.partstat,
attendeeName: reply.attendeeName,
attendeeEmail: reply.attendeeEmail,
occurrence: reply.occurrence,
allDay: reply.allDay,
comment,
sourceCreatedAt: new Date(Number(message.internalDate)),
});
Expand Down
104 changes: 0 additions & 104 deletions connectors/google/src/mail/rsvp-note.ts

This file was deleted.

67 changes: 64 additions & 3 deletions connectors/google/src/mail/sync.test.ts
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
import { afterEach, describe, expect, it, vi } from "vitest";

import type { CreateLinkDraft, NewLinkWithNotes, Uuid } from "@plotday/twister";
import { priorRsvpKey } from "@plotday/rsvp-fold";

import {
GmailApi,
Expand All@@ -20,7 +21,6 @@ import {
REACTION_SEND_DELAY_MS,
sendReactionEmailFn,
} from "./sync";
import { priorRsvpKey } from "./rsvp-note";

/** Decode the base64url raw message the Gmail send API would receive. */
function decodeRawMessage(b64url: string): string {
Expand DownExpand Up@@ -1132,8 +1132,69 @@ describe("processEmailThreadsFn — attendee responses fold onto the event", ()
// Two notes: the decline, then the reversal.
expect(notes).toHaveLength(2);
expect(notes[1]).toMatchObject({ content: "Beth Round accepted." });
// The outstanding non-acceptance is resolved, so the key is gone.
expect(store.has(key)).toBe(false);
// The key now records the last folded response for every emitted
// response, including an acceptance — not just outstanding
// non-acceptances — so a later repeat of this exact ACCEPTED is
// recognised as already folded instead of re-emitting.
expect(store.get(key)).toBe("ACCEPTED");

// A third pass re-delivers that same ACCEPTED response. This is the
// sequence the old store got wrong: it cleared its marker on every
// acceptance, so a repeated acceptance always looked unrecorded and
// would have re-emitted. The new store keeps the marker, so
// `alreadyFolded` recognises the repeat and no third note appears.
await processEmailThreadsFn(
host,
[rsvpThread("rsvp-accepted-again", replyIcs("ACCEPTED"))],
false,
"INBOX"
);
expect(notes).toHaveLength(2);
});

it("does not re-emit a note when the same conversation is processed again", async () => {
const { host } = makeHost();
const { notes, links } = captureSaves(host);
const thread = rsvpThread("rsvp-reprocess", replyIcs("DECLINED"));

await processEmailThreadsFn(host, [thread], false, "INBOX");
expect(notes).toHaveLength(1);

// Gmail's own history-based incremental sync can redeliver the same
// notification (a history replay, an at-least-once webhook) — this is
// the routine case, not a rare replay.
await processEmailThreadsFn(host, [thread], false, "INBOX");

// No second note: re-emitting one would re-apply its unread intent and
// drag the organiser's event thread back to unread for no new
// information. The message is still dropped from the mail side, though —
// no standalone email thread appears for it either time.
expect(notes).toHaveLength(1);
expect(links).toHaveLength(0);
});

it("does not re-emit a note when a commented acceptance is processed again", async () => {
// A bare (comment-less) repeat is suppressed by `shouldEmitRsvpNote`
// itself once there's no outstanding non-acceptance — `alreadyFolded`
// never even has to matter for that case. A COMMENTED acceptance is
// the one shape `shouldEmitRsvpNote` always says yes to on its own
// (its second rule: any comment earns a note), so `alreadyFolded` is
// the only thing standing between a redelivered commented acceptance
// and re-emitting on every redelivery.
const { host } = makeHost();
const { notes, links } = captureSaves(host);
const thread = rsvpThread(
"rsvp-comment-reprocess",
replyIcs("ACCEPTED", { comment: "Looking forward to it" })
);

await processEmailThreadsFn(host, [thread], false, "INBOX");
expect(notes).toHaveLength(1);

await processEmailThreadsFn(host, [thread], false, "INBOX");

expect(notes).toHaveLength(1);
expect(links).toHaveLength(0);
});

it("does not let a decline on one occurrence suppress an acceptance on another", async () => {
Expand Down
Loading
Loading