@@ -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+ } ) ;
0 commit comments