Skip to content

Commit 4ab37cb

Browse files
authored
Merge pull request #301 from plotday/fix/gmail-archive-marks-thread-read
fix(gmail): mark a thread read once it leaves every synced label
2 parents 4eac615 + d490165 commit 4ab37cb

2 files changed

Lines changed: 211 additions & 3 deletions

File tree

‎connectors/gmail/src/sync.test.ts‎

Lines changed: 147 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -535,3 +535,150 @@ describe("processEmailThreadsFn — calendar-thread bundling", () => {
535535
expect(store.get("cancel-email:uid-1")).toBeTruthy();
536536
});
537537
});
538+
539+
/** A single-message GmailThread carrying `labels`, with a plain-text body. */
540+
function labelledThread(threadId: string, labels: string[]): GmailThread {
541+
const message: GmailMessage = {
542+
id: `${threadId}-msg-1`,
543+
threadId,
544+
labelIds: labels,
545+
snippet: "Probably easier to show this with a real example.",
546+
historyId: "1",
547+
internalDate: "1700000000000",
548+
sizeEstimate: 100,
549+
payload: part("text/plain", {
550+
data: "Probably easier to show this with a real example.",
551+
headers: [
552+
["From", "Alice <alice@example.com>"],
553+
["To", "me@example.com"],
554+
["Subject", "New role?"],
555+
["Date", "Wed, 1 Jul 2026 10:00:00 -0700"],
556+
],
557+
}),
558+
};
559+
return { id: threadId, historyId: "1", messages: [message] };
560+
}
561+
562+
describe("processEmailThreadsFn — archived/trashed in Gmail", () => {
563+
const setThreadToDoOf = (host: GmailSyncHost) =>
564+
host.tools.integrations.setThreadToDo as ReturnType<typeof vi.fn>;
565+
const saveLinkOf = (host: GmailSyncHost) =>
566+
host.tools.integrations.saveLink as ReturnType<typeof vi.fn>;
567+
568+
it("marks a previously-synced thread read once it leaves every enabled channel", async () => {
569+
const { host, store } = makeHost();
570+
store.set("auth_actor_id", "actor-1");
571+
// We synced this thread while it was still in the inbox.
572+
store.set("unread:archived-thread", true);
573+
574+
// Archived in Gmail: INBOX is gone. IMPORTANT survives but isn't enabled.
575+
await processEmailThreadsFn(
576+
host,
577+
[labelledThread("archived-thread", ["IMPORTANT", "UNREAD"])],
578+
false
579+
);
580+
581+
expect(setThreadToDoOf(host)).toHaveBeenCalledWith(
582+
"https://mail.google.com/mail/u/0/#inbox/archived-thread",
583+
"actor-1",
584+
false
585+
);
586+
});
587+
588+
it("does not archive the thread in Plot when it is archived in Gmail", async () => {
589+
const { host, store } = makeHost();
590+
store.set("auth_actor_id", "actor-1");
591+
store.set("unread:archived-thread", true);
592+
593+
await processEmailThreadsFn(
594+
host,
595+
[labelledThread("archived-thread", ["IMPORTANT"])],
596+
false
597+
);
598+
599+
// Archiving in Gmail must never archive in Plot — the thread stays, it
600+
// just stops being unread.
601+
expect(saveLinkOf(host)).not.toHaveBeenCalled();
602+
});
603+
604+
it("marks a trashed thread read too", async () => {
605+
const { host, store } = makeHost();
606+
store.set("auth_actor_id", "actor-1");
607+
store.set("unread:trashed-thread", true);
608+
609+
await processEmailThreadsFn(
610+
host,
611+
[labelledThread("trashed-thread", ["TRASH", "UNREAD"])],
612+
false
613+
);
614+
615+
expect(setThreadToDoOf(host)).toHaveBeenCalledWith(
616+
"https://mail.google.com/mail/u/0/#inbox/trashed-thread",
617+
"actor-1",
618+
false
619+
);
620+
});
621+
622+
it("caches the thread as read so a later re-unread in Gmail still syncs", async () => {
623+
const { host, store } = makeHost();
624+
store.set("auth_actor_id", "actor-1");
625+
store.set("unread:archived-thread", true);
626+
627+
await processEmailThreadsFn(
628+
host,
629+
[labelledThread("archived-thread", ["IMPORTANT", "UNREAD"])],
630+
false
631+
);
632+
633+
expect(store.get("unread:archived-thread")).toBe(false);
634+
});
635+
636+
it("ignores a thread it has never synced", async () => {
637+
const { host, store } = makeHost();
638+
store.set("auth_actor_id", "actor-1");
639+
// No `unread:*` key: mailbox-wide history surfaces threads from labels the
640+
// user never chose to sync. Those were never ours to mark read.
641+
642+
await processEmailThreadsFn(
643+
host,
644+
[labelledThread("foreign-thread", ["IMPORTANT"])],
645+
false
646+
);
647+
648+
expect(setThreadToDoOf(host)).not.toHaveBeenCalled();
649+
expect(store.get("unread:foreign-thread")).toBeUndefined();
650+
});
651+
652+
it("leaves a thread still in an enabled channel alone", async () => {
653+
const { host, store } = makeHost();
654+
store.set("auth_actor_id", "actor-1");
655+
store.set("unread:inbox-thread", true);
656+
657+
await processEmailThreadsFn(
658+
host,
659+
[labelledThread("inbox-thread", ["INBOX", "UNREAD"])],
660+
false
661+
);
662+
663+
// Still in the inbox — normal sync path, no read stamp.
664+
expect(setThreadToDoOf(host)).not.toHaveBeenCalled();
665+
expect(saveLinkOf(host)).toHaveBeenCalled();
666+
});
667+
668+
it("does not mark threads read during an initial backfill", async () => {
669+
const { host, store } = makeHost();
670+
store.set("auth_actor_id", "actor-1");
671+
store.set("unread:archived-thread", true);
672+
673+
// forceChannelId is set during per-channel backfill; a thread that no
674+
// longer carries the label must not be read-stamped by a backfill pass.
675+
await processEmailThreadsFn(
676+
host,
677+
[labelledThread("archived-thread", ["IMPORTANT"])],
678+
true,
679+
"INBOX"
680+
);
681+
682+
expect(setThreadToDoOf(host)).not.toHaveBeenCalled();
683+
});
684+
});

‎connectors/gmail/src/sync.ts‎

Lines changed: 64 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -379,8 +379,11 @@ export function fnv1aHex(input: string): string {
379379
* 1. Custom (user-defined) labels — alphabetically.
380380
* 2. STARRED → IMPORTANT → INBOX → SENT → DRAFT (system labels).
381381
*
382-
* Returns null if no enabled channel matches the thread (the thread came in
383-
* via mailbox-wide history but doesn't belong to any channel the user wants).
382+
* Returns null if no enabled channel matches the thread. Note this answers
383+
* "does it belong to a channel *now*", and conflates two cases the caller must
384+
* separate: a thread that never belonged to one (mailbox-wide history reports
385+
* labels the user doesn't sync), and a thread that has just left one (archived
386+
* or trashed in Gmail). See {@link markDepartedThreadRead}.
384387
*
385388
* Custom labels in Gmail use IDs like `Label_14`. We treat anything not in
386389
* the system-label list above as "custom", which matches getChannels()'s
@@ -415,6 +418,48 @@ export function pickChannelForThread(
415418
return null;
416419
}
417420

421+
/**
422+
* Mark a thread read in Plot after it has left every enabled channel — the
423+
* user archived it, trashed it, or stripped its last synced label in Gmail.
424+
*
425+
* Deliberately does not archive the Plot thread: clearing an email out of
426+
* Gmail says "I'm done reading this", not "delete my Plot copy". Marking it
427+
* read is enough to move it out of Active and into Done, since Active holds
428+
* to-dos plus unread threads.
429+
*
430+
* No-ops for threads we have never synced. `unread:<id>` is written for every
431+
* thread we save and is never cleared, so its presence is our record that this
432+
* thread is one of ours — including for threads synced long before this code
433+
* shipped, which a dedicated marker key would have missed.
434+
*
435+
* Read state is applied via setThreadToDo(todo=false), whose clear path stamps
436+
* `read_at` for this connection's owner. It resolves the thread by source URL,
437+
* so no channel is needed — which matters here precisely because the thread no
438+
* longer belongs to one. The stamp only fires when `read_at` is NULL, so
439+
* repeat history events for the same thread are harmless.
440+
*/
441+
async function markDepartedThreadRead(
442+
host: GmailSyncHost,
443+
thread: GmailThread
444+
): Promise<void> {
445+
const synced = await host.get<boolean>(`unread:${thread.id}`);
446+
if (synced == null) return; // Never synced — not ours to touch.
447+
448+
const actorId = await host.get<ActorId>("auth_actor_id");
449+
if (!actorId) return;
450+
451+
await host.tools.integrations.setThreadToDo(
452+
`https://mail.google.com/mail/u/0/#inbox/${thread.id}`,
453+
actorId,
454+
false
455+
);
456+
457+
// Keep the unread cache honest: Plot now considers this read. If the thread
458+
// returns to the inbox unread later, saveTransformedThread sees the
459+
// false → true flip and syncs it back to unread.
460+
await host.set(`unread:${thread.id}`, false);
461+
}
462+
418463
/**
419464
* Merges thread fetches that must be retried into the prior pending set.
420465
*
@@ -1311,7 +1356,23 @@ export async function processEmailThreadsFn(
13111356

13121357
const chosen =
13131358
forceChannelId ?? pickChannelForThread(thread, enabledChannels);
1314-
if (!chosen) continue; // Thread doesn't match any enabled channel.
1359+
if (!chosen) {
1360+
// No enabled channel matches. Two very different situations land here,
1361+
// and telling them apart is the whole point:
1362+
//
1363+
// - We have never synced this thread. Mailbox-wide history reports
1364+
// every thread in the account, including ones living only in labels
1365+
// the user chose not to sync. Not ours — ignore it.
1366+
// - We HAVE synced it and it has since left every enabled channel,
1367+
// i.e. the user archived or trashed it in Gmail.
1368+
//
1369+
// For the latter we deliberately do NOT archive in Plot: the thread
1370+
// stays put. But an email the user has cleared out of Gmail shouldn't
1371+
// keep sitting in Active as unread, so mark it read and let it fall
1372+
// through to Done.
1373+
await markDepartedThreadRead(host, thread);
1374+
continue;
1375+
}
13151376

13161377
transformed.push({ thread, plot, channelId: chosen });
13171378
for (const c of plot.accessContacts ?? []) {

0 commit comments

Comments
 (0)