Skip to content

Commit b235fa6

Browse files
KrisBraunclaude
andauthored
iCloud Mail & Calendar connector (#331)
* refactor(apple): repackage apple-calendar as the apple connector * feat(apple): add product-channel id namespacing * feat(apple): per-product channel lists (calendar real, mail stub) * feat(apple): product registry + channel compose (no-OAuth, creds-gated) * feat(apple): composite dispatch + namespaced calendar channels * feat(apple): html-vs-text heuristic for single-part mail bodies * feat(apple): transform IMAP messages into threaded mail links * fix(apple): attribute From-less and owner-originated mail explicitly, never to the connector * feat(apple): mail sync host interface + IMAP fetch helpers * feat(apple): mail channel enumeration + IMAP sync driver getMailChannels() now connects over IMAP and enumerates a real INBOX channel (instead of the always-empty stub), and getChannels() wires it through a small per-connector host adapter so the built-in imap/ integrations tools and appleId/app-password reach the pure mail/* helpers. Adds mailInitialSync/mailIncrementalSync: full INBOX + Sent backfill since a history floor, persisting a uidValidity/lastUid cursor; and an incremental pass that fetches new mail plus a recent-window rescan (for read-state changes and new Sent replies), re-baselining automatically if UIDVALIDITY changes. Both delegate all message/thread shaping to the existing transformMessages helper. * fix(apple): keep mail sync to a single IMAP session on re-baseline * feat(apple): wire mail read into the composite (enable, sync, poll) * fix(apple): merge INBOX+Sent into one thread upsert and bound incremental mail search INBOX and Sent were transformed in two separate calls, producing two NewLinkWithNotes for the same thread with conflicting unread/author state; whichever was saved last won, so an owner's own reply could silently clear unread on a thread with an unseen inbound message. Detect the owner by address per-message (iCloud Sent carries the owner's address in From) so INBOX and Sent can be merged into a single transformMessages call per sync pass. Also floor the incremental new-mail search by the plan window instead of searching an unbounded UID range: a dormant account with a stored cursor of 0 was fetching the entire mailbox on its first poll. * fix(apple): parse iCloud's default-namespace CalDAV responses and list only event calendars iCloud returns the DAV namespace as the default (unprefixed) namespace, e.g. `<response xmlns="DAV:">`, but the multistatus response-splitter required a namespace prefix, so it parsed zero entries and CalDAV discovery always failed with "Could not discover CalDAV principal URL". Match an optional prefix. Also filter the calendar list to real event calendars: require an actual `<calendar>` resourcetype element (a substring test for "calendar" also matched the calendarserver.org namespace URL on system collections) and a VEVENT supported-calendar-component-set — so the scheduling inbox/outbox, the notification collection, and Reminders (VTODO) lists are excluded. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix(apple): preserve read-state across incremental mail polls The recent-window rescan re-fetches already-synced messages to pick up reads done in the mail client, and it set unread directly from the IMAP \Seen flag — so a message read in the app but still unseen on the server flipped back to unread on every poll. On incremental sync, only a newly-arrived message may re-mark a thread unread; re-fetched messages propagate reads (seen becomes read) but never re-assert unread. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * feat(apple): nested Email/Calendar setup, branded type labels, crisp logos Declare products so the connection setup renders top-level Email and Calendar sections — each expanding to its own channels — instead of one flat list that mixed the mail inbox in with the calendars. Brand each link type ("iCloud email thread" / "iCloud Calendar event") via sourceName plus label, give email threads a mail mark instead of falling back to the calendar logo, and use higher-contrast marks that don't wash out at logo size. Broaden the description so search matches "Apple Mail"/"Apple Calendar" and the icloud.com/me.com/mac.com domains. Also correct the stale workspace importer key and drop an unused import. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * feat(apple): SMTP send wrapper with error classifier and bounded retry * feat(twister): add Imap.watch/unwatch for IMAP IDLE push notifications Connectors using the Imap tool can now register a per-mailbox push watch: the platform holds an IMAP IDLE connection open and invokes the registered callback within seconds of new mail or flag changes, instead of the connector waiting for its next poll. Watches are keyed upserts (re-arm from the recurring poll to self-heal and pick up rotated credentials) and are removed with unwatch() on channel disable. * feat(apple): instant mail arrival via IMAP IDLE push The Inbox channel now registers an IMAP IDLE watch when it enables, and re-arms it on every recurring poll (a keyed upsert — this self-heals a dropped watch and refreshes rotated credentials). Push notifications are coalesced through a short-delay drain into one incremental sync pass, so new mail and read-state changes land in seconds; the 15-minute poll remains as the safety net. The watch, its callback, and the drain are torn down on channel disable. * feat(apple): recipient role-split, reply-all, and subject helpers for mail write * feat(apple): resolve a mail thread's INBOX messages by subject + root id * feat(apple): expose the Smtp tool on the mail host Adds smtp to the MailHost interface and populates it in buildMailHost() so mail-write code (Task 5) can send via SMTP. Also updates the sync.test.ts fake host with a stub smtp field to satisfy the now-required MailHost property (unused by sync-in tests). * feat(apple): reply write-back over SMTP with root-id threading * feat(apple): compose new mail from Plot via onCreateLink * feat(apple): write read-state (\Seen) and to-do (\Flagged) back over IMAP * feat(apple): wire mail write-back (reply, compose, read-state, to-do) into the composite * fix(apple): keep mail write-back best-effort when IMAP resolve fails * fix(apple): sweep compose-dedup keys on disable; distinguish connection errors from rate limits Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TxfFYB3xnHGqaNPW5QugRa * feat(connectors): brand logos with product app icons Use the iOS Mail and iOS Calendar app icons for the iCloud Mail & Calendar connection and its email/event threads, and the Gmail icon for the Gmail & Calendar connection, instead of a single generic mark. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TxfFYB3xnHGqaNPW5QugRa * feat(twister): add attachment fields and getAccountIdentity hook Add optional `attachments` fields to `SmtpMessage` and `ImapMessage` so connectors can send and discover email attachments through the SMTP/IMAP tools, and add an optional `Connector.getAccountIdentity()` hook for non-OAuth connectors to declare a matchable identity email for the connected account. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JXQs7rh8EozqGZi974TJCs * feat(twister): add Imap.fetchAttachment() to download attachment bytes Connectors can now retrieve the raw content of an attachment part discovered via fetchMessages()'s attachments field: fetchAttachment(session, uid, partNumber) issues a separate FETCH for just that MIME part and decodes it (base64 or quoted-printable) to raw bytes. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JXQs7rh8EozqGZi974TJCs * feat(apple): declare account identity via getAccountIdentity so the owner is recognized as the signed-in user Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JXQs7rh8EozqGZi974TJCs * feat(apple): send and receive mail attachments Inbound sync now maps each message's discovered attachment parts to fileRef actions on its note, so attachments show up and can be downloaded from Plot. The connector implements downloadAttachment to re-fetch a part's bytes over IMAP on demand. Outbound replies and composed emails now carry along any files the user attached in Plot, read via the Files tool and sent as SMTP attachments. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JXQs7rh8EozqGZi974TJCs * feat(apple): native email forward with original body + attachments The iCloud Mail connector now reconstructs a real forward instead of falling back to the generic blockquote forward: the outgoing message starts a new thread (no In-Reply-To/References), carries the forwarder's own note above a quoted "---------- Forwarded message ----------" attribution block (From/Date/Subject/To of the source message), and re-attaches the original message's attachments. Since IMAP has no way to search by Message-ID, the source message is located the same way thread replies already are: a bounded subject+date window search, headers fetched, then filtered locally by exact Message-ID match, checking INBOX first and falling back to the Sent mailbox. A short content-hash dedup window guards a retried dispatch against forwarding the same message twice, and a source that can no longer be found (e.g. deleted upstream) surfaces as a user-visible delivery error rather than an error-tracking page. * fix(apple): preserve clean forward note via sync baseline The forward's sent SMTP body (the forwarder's note plus a quoted "---------- Forwarded message ----------" block and the original message) differs from what's stored as the opening note in Plot (just the forwarder's own note). Without a sync baseline, the next Sent- mailbox re-ingest of that same sent message would overwrite the clean note with the full quoted body. onCreateLink's forward path now sets `originatingNote.externalContent` to the exact sent text (on both the fresh-send and the dedup-hit paths), so the baseline hash matches and the clean note is preserved. * feat(apple): durable retry for read/to-do flag write-backs Marking a mail thread read/unread or toggling its to-do state writes back an IMAP flag change. Previously a transient IMAP hiccup (a dropped connection, a network blip) silently dropped that write with no retry. Flag write-backs now defer to a durable drain on failure: the desired flag state is persisted and handed to a coalesced retry pass that re-applies it once IMAP is reachable again, with bounded retries so a persistently failing thread can't wedge the queue. A fresh toggle before the retry lands simply supersedes the pending one. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QDW5yodGHqeRx4WSmVVg9m * feat(apple): sync Apple Mail flag → Plot to-do (read direction) Completes the to-do ↔ \Flagged loop for the iCloud Mail connector. The write direction (marking a thread to-do in Plot flags the message in Apple Mail) already existed; this adds the read direction, so flagging or unflagging a message directly in Apple Mail now updates the Plot thread's to-do state too — mirroring how other mail connectors map a provider-native flag to Plot's to-do. - Added an `activate()` override that records the connecting user's actor id, needed to attribute a to-do change made in Apple Mail to the right person. - Added a small per-thread reconciliation pass that runs during sync: it diffs each thread's current \Flagged state against the last-known state and propagates a change to Plot exactly once. A historical \Flagged message from before the connection existed is recorded but not propagated, so a first sync doesn't spam new to-dos. - The write path now records the to-do state before writing the IMAP flag, so the connector's own write doesn't get read back and re-applied as if it were a new external change. * feat(apple): label mail notes 'Reply' and enable file attachments Adds noteLabel:"Reply" so the in-thread composer copy reads "Add a reply" (matching Gmail) rather than "Add a note", and declares supportsFileAttachments:true now that the connector round-trips attachments in both directions (SMTP send + IMAP part fetch), so the composer/reply surfaces the Attach file button. * fix(apple): supersede stale flag write-backs and degrade forward fetch gracefully The read/to-do flag write-back queues a durable retry when a direct IMAP write fails, but nothing cleared that retry payload once a later toggle succeeded directly — so an opposite toggle made right after a transient failure could still have the old, stale operation replayed by the retry queue. A successful (or no-op) direct write now supersedes any pending retry for that thread. Also: forwarding a message now returns a retryable delivery error instead of throwing when iCloud is transiently unreachable while fetching the original message, matching how the reply and flag-write paths already degrade. And on mail channel disable, the to-do echo-dedup markers are now swept along with the other per-channel bookkeeping. * feat(apple): serialize mail sync passes with a per-channel lock The mail sync path could open several independent IMAP sessions to the same iCloud account at once — an initial backfill, a scheduled poll, and a push-triggered incremental sync could all race each other, and iCloud enforces a per-account IMAP connection cap. Add a per-channel sync lock (mirroring the pattern already used for calendar sync) so these passes coalesce into at most one in-flight IMAP session: - The initial backfill skips its (idempotent, upsert-by-source) work when another pass already holds the lock, but still arms the recurring poll and the IMAP push watch so a lost race never leaves the channel without scheduled work. - The scheduled poll re-arms the push watch before checking the lock (so a dropped watch always self-heals) and skips its own sync pass when another is in flight, relying on the next interval to retry. - The push-triggered drain reschedules itself instead of dropping the notification when the lock is held, since the change that triggered it may not be covered by the in-flight pass. - The lock is released on channel disable so a re-enable can acquire it immediately instead of waiting out the lease. User-facing write-backs (replies, read/to-do flag sync, attachment downloads) intentionally stay outside this lock — they're latency sensitive and open their own short-lived sessions, and lock acquisition is non-blocking so there's no deadlock risk in leaving them unlocked. * feat(twister): expose IMAP mailbox HIGHESTMODSEQ on ImapMailboxStatus Add an optional `highestModSeq` field to `ImapMailboxStatus`, populated from a mailbox's CONDSTORE (RFC 7162) high-water mark when the IMAP server advertises it. This lets connectors persist the value as a since-last-poll cursor and skip re-scanning a mailbox whose mod-sequence has not advanced, rather than refetching a recent-message window on every poll. The field is absent when the server or mailbox does not support mod-sequences, so callers must treat its absence as "unknown" and fall back to their existing scan. * feat(apple): skip the mail rescan when a mailbox's CONDSTORE modseq is unchanged Every incremental poll used to re-fetch the entire recent-window (up to 30 days) of INBOX and Sent just to catch read/flag changes made in Apple Mail, even when nothing had changed since the last poll. Now each mailbox's CONDSTORE HIGHESTMODSEQ (RFC 7162) is persisted as a cursor. When a mailbox's mod-sequence hasn't advanced since the last successful poll, the rescan for that mailbox is skipped entirely — any new message, flag change, or expunge always advances the mod-sequence, so an unchanged value guarantees there's nothing new to fetch. INBOX and Sent are gated independently. Falls back to today's full rescan when the server doesn't advertise CONDSTORE, or when no baseline cursor exists yet (older stored sync state, or the very first poll). * fix(apple): rescan both mailboxes together so a Sent-only change can't rebuild a thread from a partial message set The incremental mail sync gated the INBOX and Sent rescans on each mailbox's own CONDSTORE modseq independently. When only Sent advanced (e.g. replying to an already-read INBOX thread), the sync fetched Sent alone and rebuilt that thread's title/author/read-state from just the reply, discarding the original message's subject and sender. The two mailboxes are now gated together: if either one's modseq changed, both are rescanned this pass, so a thread touched by the change is always rebuilt from its complete message set. Both are only skipped when neither mailbox changed. * docs(apple): clarify the Sent modseq cursor is gated jointly, not independently * feat(apple): add RFC 6578 WebDAV-Sync support to the CalDAV client Adds `CalDAVClient.getCollectionChanges()`, which issues a `sync-collection` REPORT so the server returns only the calendar objects that changed since a stored sync token, instead of listing every event's etag on each poll. Three details the parser has to get right, all verified against a live CalDAV server: - The calendar collection's own href appears in the delta alongside the changed objects (a child changed, so the collection changed too). It is filtered out by comparing against the requested collection href rather than by checking for an `.ics` suffix. - Deletions arrive as a bare 404 `<response>` carrying neither `getetag` nor `calendar-data`, so the existing event-response parser drops them silently. They are classified separately here and reported as `deletedHrefs`. - Responses may use the DAV: default namespace with no element prefix, so all new matching accepts both prefixed and unprefixed forms. An expired or unrecognised token fails the RFC 6578 valid-sync-token precondition, which is returned as a 403 whose body distinguishes it from an ordinary access-denied 403. That case now surfaces as `InvalidSyncTokenError` so callers can discard the token and fall back to a full resync instead of treating it as a hard failure. Adds the first test coverage for the CalDAV client. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CAK1qcVikqJ8Gfv7pEm9WX * feat(apple): bundle calendar invite emails onto the event's thread A meeting invitation arrives twice for a composite mail + calendar connection: once as an email carrying a text/calendar part, and once as the calendar event itself. Those now converge on a single thread instead of two. The calendar half already published an `icaluid:<uid>` alias in the link's `sources`; the mail half now publishes the same alias when an inbound message carries a calendar part whose iCalendar UID identifies an event, and the runtime bundles links whose `sources` overlap. Only messages that genuinely concern an existing event bundle: - METHOD:CANCEL — bundles - METHOD:REQUEST with SEQUENCE > 0 (an update to an existing event) — bundles - METHOD:REQUEST with SEQUENCE 0 (the bare initial invitation) — does not - METHOD:REPLY (someone's RSVP) — does not METHOD is a VCALENDAR-level property, so it is read from the raw iCalendar text rather than the VEVENT parser, which only sees event-scoped properties. A bundled link deliberately omits `title`. A thread's title is taken from the last link that supplies one, so a bundled email that carried its own subject would overwrite the event's title on every subsequent mail sync. Omitting the field preserves whatever title the event established. Calendar parts are only fetched when the message structure actually advertises one, so ordinary mail costs no extra round-trips. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CAK1qcVikqJ8Gfv7pEm9WX * feat(apple): sync calendars incrementally with WebDAV-Sync Each 15-minute calendar poll compared the collection ctag and, on any change, listed every event's etag to diff client-side. That cost scaled with the size of the calendar rather than the size of the change, so a calendar with thousands of events paid for a full listing every time one event moved. A calendar's changes are now fetched with a `sync-collection` REPORT, which returns only the objects that changed since the stored sync token. The ctag and etag-diff path is kept and still runs whenever there is no usable token — on the first pass after enabling a calendar, and for one pass after a token is rejected — so the new path is an optimisation in front of proven behaviour rather than a replacement for it. An expired token surfaces as a distinct error, clears the stored token, and falls back for that pass instead of failing the sync. The token is written only after every changed object has been saved and every deletion archived. If a pass dies partway, the previous token remains, so the next poll re-derives and re-applies the same delta; saves are idempotent upserts, so replaying is harmless. Writing the token earlier would silently skip those changes forever. Also in this change: - Changed objects are fetched in chunks with queued continuations, matching the initial sync's batching. Previously a single unbounded request covered every changed object, which a large catch-up could push past one execution's limits. - A failing poll now reports the error instead of only logging it. It still schedules the next poll, so a transient blip cannot break the chain, but a persistent failure such as a revoked app-specific password is no longer invisible. Deletion archiving stays serial: archiving matches a single metadata filter, so distinct events cannot be archived in one call without also matching unrelated events on the same calendar. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CAK1qcVikqJ8Gfv7pEm9WX * feat(apple): prefer the organizer's cancellation email, and guard RSVP writes Two unrelated fixes to the calendar half. When a cancelled event and its cancellation email land on the same thread, the connector no longer adds its own generic "This event was cancelled." note. The organizer's actual email is already there and says more. The structural part of the cancellation — the cancelled status and the archived schedule — is still applied unconditionally; only the redundant note is suppressed. The marker that signals "a cancellation email arrived for this event" is consumed once it has been observed, and any leftovers are swept on teardown, so the key space stays bounded. RSVP write-back now sends `If-Match`. It read an event, patched the attendee's participation status, and wrote the whole event back without any concurrency check, so an edit made on the server between the read and the write was silently overwritten. The write now carries the etag from the read; if the server reports a precondition failure, the event is re-read, the status re-applied to the fresh copy, and the write retried once. A race that resolves on retry is expected and stays quiet. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CAK1qcVikqJ8Gfv7pEm9WX * fix(apple): land cancelled occurrences already-read on an initial sync Adds coverage for the master/occurrence transform — the code that merges a recurring event's master and its overrides — and fixes a bug it exposed. A recurring event's master and all of its RECURRENCE-ID overrides share one calendar object resource, so the transform always receives a whole event at once and coalesces it into a single link. That is what makes it safe to fetch only changed objects during incremental sync, and it now has tests: one resource carrying a master plus two overrides produces exactly one link, the master's title survives, and an override never overwrites it. The bug: when buffering a cancelled occurrence during an initial sync, the occurrence was written without `unread: false`, unlike the non-cancelled branch beside it. Backfilling a calendar could therefore raise notifications for occurrences that were cancelled long ago. Cancelled occurrences now follow the same rule as every other initial-sync write. Incremental sync still omits the field, so a real cancellation continues to surface. Also covered: the initial-vs-incremental split for overrides (including that an occurrence link deliberately leaves its title undefined so it cannot clobber the master's), the cross-batch buffering and orphan flush, and event basics — schedule, attendees, organizer and the content-hashed description note. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CAK1qcVikqJ8Gfv7pEm9WX * fix(apple): keep a title on bundled invite mail when no event is synced Review fixes for the calendar work in this branch. A bundled invite email omitted its title so it could not overwrite the event's title on the shared thread. That is right when the event exists, but when it does not, the thread is created with the platform's placeholder title instead of anything meaningful — permanently, for a connection syncing mail but not calendar, and for a cancellation email whose event was already cancelled during the first calendar sync (that event is skipped, so no titled link is ever written). A bundled link now still always publishes its `icaluid:` alias, so threads converge exactly as before, but supplies its title unless the connector has actually saved a link for that event. If the event syncs later, the calendar sets the title; if it never does, the email's subject stands. Note "has saved a link" is tracked separately from the existing href-to-uid map, which records every event CalDAV returned including ones that produce no link at all — the cancelled-during-initial-sync case among them. Also: - Once a sync token is stored, the poll goes straight to the sync-collection REPORT. It already reports whether anything changed and returns a fresh token, so the preceding ctag request was redundant — and, because the incremental path never refreshed the stored ctag, it compared against a value frozen at the first sync and reported a change on every poll forever. The ctag comparison is kept for the no-token case, where it still gates the expensive full walk. - Events deleted on the sync-token path are now removed from the href-to-uid and etag maps. They were only pruned on the fallback path, so a long-lived calendar's maps grew by one entry per deleted event indefinitely. - A thread's calendar classification is cached after the first pass instead of re-downloading and re-classifying the calendar part on every sync. Besides the repeated fetches, the classification silently changed once the message aged out of the recent window, which moved the link's canonical identifier and left a second link on the thread. - A failing poll no longer reports authentication failures. A revoked app-specific password is something the user can see and fix, so reporting it every fifteen minutes per calendar is noise; every other failure still reports. - An inline calendar part is no longer offered as a downloadable file. It has no filename of its own, so it appeared as an unnamed attachment on invitation mail and was re-attached when forwarding. A genuinely named .ics attachment still behaves as a normal attachment. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CAK1qcVikqJ8Gfv7pEm9WX * chore(apple): serve the connector logos from plot.day The connection logo and the mail/calendar link-type logos pointed at image URLs on a third-party wiki. Those URLs are outside our control: the file can be renamed or re-derived, and hotlinking is subject to the host's rate limits — either of which silently breaks the icon on every email thread and in the connections list. Point all three at copies hosted on plot.day instead. No behaviour change beyond where the bytes come from. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LNgU6vPh66xWTCsnAan1Dv * feat(apple): address mail per message, and honour recipient changes Email addresses each message, not the conversation, but the mail link type declared the thread sharing model: every note inherited one thread-wide roster. Someone brought into a conversation halfway through therefore saw the entire exchange that preceded them. Declare `sharingModel: "message"` and give each ingested note its own recipient set — that message's own From/To/Cc — while the thread roster stays the union across every message. The connection owner is added to every note explicitly rather than left to identity resolution: plenty of mail names them in no header at all (mailing lists, aliases, Bcc), and under the message model a note that omits them is redacted from them. Bcc recipients are deliberately absent, since IMAP doesn't expose them on received mail and inferring them would leak a blind copy to the thread. The other half of the same behaviour is outbound. `supportsContactChanges` lets recipients be added or dropped on an existing thread, but the reply path derived its recipients purely from the latest message's headers, so a recipient the user had just added was silently dropped and one they removed was still addressed. Replies now resolve through the shared `resolveOutboundReplyRecipients` helper: the runtime's curated recipient set wins, otherwise the note's own access list narrows and extends the original participants, otherwise it's a plain reply-all. Display names are re-attached afterwards, so a reply still reads "Jane Doe <jane@…>". A note the user kept private (addressed to nobody but themselves) now sends nothing and reports no failure, instead of surfacing "Failed to send". The two flags ship together on purpose: the flag without the model silently drops mid-thread additions, and the model without the flag hides the affordance that produces them. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LNgU6vPh66xWTCsnAan1Dv * feat(apple): declare the event statuses and attendee roles Calendar sync already sets Confirmed, Tentative or Cancelled on every event link, and derives required/optional attendee roles from each ATTENDEE's ROLE parameter — but the event link type declared neither. The declaration is what gives a synced value its label, icon and filter entry, so those statuses reached the app as bare values with none of them, and optional attendees couldn't be labelled as optional. Nothing failed at build or sync time, which is why it went unnoticed. Declare all three statuses (Confirmed marked `hiddenDefault`, since nearly every event carries it and it would otherwise put a chip on all of them) and both attendee roles, and add a test pinning the declaration to the values the sync actually emits. `supportsContactChanges` stays off for events, unlike the mail link type: the CalDAV write-back path only updates the owner's own ATTENDEE PARTSTAT (their RSVP) and can't add or remove attendees, so offering roster editing would accept changes that never reach the calendar. Recorded as a comment so the omission reads as a decision rather than an oversight. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LNgU6vPh66xWTCsnAan1Dv * feat(apple): expose every selectable mailbox as a mail channel Previously the iCloud connector exposed only a single hardcoded INBOX channel, and an account without an INBOX synced no mail at all. Now every selectable IMAP mailbox is enumerated as its own channel (Archive, custom folders, nested folders, etc.), with INBOX pre-selected and the rest opt-in. Non-selectable containers (\Noselect) and the Sent/Drafts/Trash/ Junk mailboxes are excluded — Sent because it's still read implicitly during INBOX's sync pass rather than as its own channel, and the others because they have no sync value. A nested mailbox's title now renders its full hierarchical path (e.g. "Archive / 2024") using the mailbox's own delimiter, so folders with the same leaf name under different parents stay distinguishable. Behaviour change: an account missing INBOX now still surfaces its other folders as channels, instead of the connector offering nothing. * apple: sync each mail channel's own IMAP mailbox Every selectable IMAP mailbox is offered as its own channel, but the mail read-sync path still selected the literal "INBOX" at every step: a channel for Archive (or any other folder) ran a second, redundant INBOX poll under its own cursor, and its folder's mail never synced. The sync now takes the channel's raw mailbox name (`parse(channelId).rawId`) and uses it for the SELECT, the SEARCH, the FETCH, the re-baseline paths, and the per-message mailbox tag that attachment refs are built from (IMAP UIDs are only unique within one mailbox, so that tag is load-bearing). The Sent mailbox is merged only on the INBOX channel's pass. Each channel polls independently, so without that guard every folder channel would also merge the same Sent messages and upsert them onto the same threads — a folder that doesn't hold a thread's root would rebuild it from the owner's reply alone, recomputing title, author and unread from a partial message set, and the folders' passes would overwrite each other on every poll. The combined CONDSTORE gate is unchanged: with Sent out of a folder's pass it reduces to that folder's own HIGHESTMODSEQ, and each channel keeps its own cursors. Accepted limitation, recorded at the guard: a thread rooted in a non-INBOX folder that has a recent owner reply in Sent can still be rebuilt from that reply during INBOX's own pass, because that pass cannot tell which folder holds the root — IMAP offers no search-by-Message-ID. * apple: make mail transform express a merged multi-mailbox batch `transformMessages` rebuilt each thread from a single mailbox's messages, with one channel, one initial-sync flag and a flat UID set for the whole call. That shape cannot describe a pass that reads several IMAP folders at once, and it carried two defects a merged pass would amplify. IMAP UIDs are unique only WITHIN a mailbox, but "which messages are new this pass" was a flat list of numbers. An old unseen message in one folder therefore inherited the new-mail status of an unrelated message that happened to share its uid in another folder, re-marking the thread unread on every poll. New messages are now identified by the exported `messageKey(m)` — mailbox plus uid. A message kept in two folders produced two notes with the same key in one batch, so which copy won (and which `\Seen`/`\Flagged` flags the thread was rebuilt from) depended on fetch order. Copies sharing a Message-ID are now collapsed deterministically, preferring the copy in the thread's home mailbox and otherwise the first by mailbox and uid. The context type changes accordingly: - `channelId` -> `channelByRoot`, so each thread carries its own home channel. It still feeds both `link.channelId` and `meta.syncableId`, which must stay equal for disable-time archiving to match. - `initialSync` -> `initialRoots`, so one pass can backfill a newly-enabled folder (`unread: false, archived: false`) while syncing an already-cursored one incrementally. Neither batch-wide value is correct for both: `true` clears genuine unread state and un-archives threads the user archived; `false` leaves backfilled threads with no `unread` key on insert, which notifies. - `newUids` -> `newMessages`, keyed as above. - new `sentMailbox`: when every message a thread contributed came from Sent, the batch knows neither the real subject nor the read state, so `title` and `unread` are omitted rather than recomputed from the owner's own reply. A root being ingested for the first time still gets `unread: false`, since omitting it on insert would notify about mail the user sent themselves. Message ordering also gained a tie-break on (INBOX-first, mailbox, uid) so note order, the originating message that sets title and author, and the participant union no longer depend on which folder was read first. The existing per-mailbox sync path is adapted to the new context shape with its behaviour unchanged; it is replaced by the merged pass next. * apple: fix Sent-only INSERT titling and duplicate-delivery dedupe order A Sent-only thread root being ingested for the first time (an INSERT, not an update) previously omitted `title` along with `unread`, which left the new thread permanently titled "Untitled" — there's no server-side default for title on insert the way there is for unread. `title` now follows the same precedence `unread` already had: a first-time root gets a title from the available subject, and only an already-known Sent-only root omits it to preserve what's already there. Also fixes `dedupeCopies` picking a non-deterministic winner when both copies of a duplicate-delivered message land in the same (home) mailbox — it now falls through to the same order-independent tie-break already used for copies split across mailboxes. Plus doc/comment accuracy fixes: two comments that overstated what the code guarantees, and a defensive change to compute the Sent-only check from the pre-dedupe message set. * apple: use one predicate to detect the Sent mailbox everywhere getMailChannels excluded the Sent mailbox from the channel list using only the IMAP SPECIAL-USE attribute, while the sync code that resolves which mailbox actually is Sent also falls back to matching the name "Sent ..." when the server doesn't advertise SPECIAL-USE. On a server that omits SPECIAL-USE, that mismatch let a plainly-named Sent folder show up as an ordinary enable-able channel while sync simultaneously read mail from it as Sent under the hood. Extract a single isSentMailbox() predicate and use it in both places, so channel enumeration and Sent-mailbox resolution can never disagree again. Also adds test coverage for case-insensitive INBOX detection. * apple: narrow Sent-mailbox name match from prefix to a known-name list isSentMailbox() decides both which mailbox sync treats as Sent and which mailboxes are offered as selectable channels. Its name fallback (for servers that don't advertise IMAP SPECIAL-USE) used a /^sent/i prefix match, which meant any user folder whose name merely started with "sent" - "Sentiment", "Sent by client", "Sentinel alerts" - was silently excluded from the channel list with no error or indication why. Narrow the fallback to a closed list of the names real IMAP servers actually use for Sent (case-insensitive, trimmed): "sent", "sent messages", "sent items", "sent mail". The specialUse === "\Sent" check remains the primary, higher-priority signal and is unchanged. * apple: sync every enabled mail folder in one merged pass A mail thread's link `source` is derived from its root Message-ID, which is mailbox-independent, so a conversation with messages in two enabled folders is ONE thread that both folders address. Running a sync pass per folder made each one rebuild that shared thread from only its own messages: `title`, `unread` and the home channel were each recomputed from a partial view and the passes overwrote one another on every poll. Disabling one folder could then archive threads whose live messages sat in the other. Replace the per-channel passes with a single connection-level `mailSync()` that reads every enabled mailbox plus Sent on one IMAP session and rebuilds every touched thread from its complete visible message set in exactly one `transformMessages()` call. - Cursors move from one document per channel to one per connection, keyed by raw mailbox name, so the CONDSTORE gate generalises from (this folder, Sent) to every mailbox in the pass. The gate stays all-or-nothing: if any mailbox advanced, all are re-read. A per-mailbox gate would reintroduce the defect. - Initial-ness is now per mailbox (one without a cursor is backfilled and contributes no new-message signal) and per thread root (one never seen before, arriving from history rather than as live mail, is ingested silently). A UIDVALIDITY reset therefore no longer clears the read state of threads that were already synced. - Each thread's home channel is resolved once and persisted alongside its calendar-bundle decision, rather than derived from whichever messages happen to fall inside the current window. It is always a currently-enabled channel, so disable-time archiving stays precisely scoped. - Sent is read on every pass instead of only when the inbox folder is enabled, so a user who syncs only other folders still sees their own replies. When a pass sees nothing of a known thread but the owner's outbound copy, `title` and `unread` are left untouched rather than recomputed from half a conversation. - New-message identity is mailbox-qualified, so an old unseen message in one folder can no longer inherit the "new" status of an unrelated message sharing its UID in another and re-notify forever. - `fetchUidRange` accepts `alreadySelected` to skip a redundant SELECT when the caller just selected the mailbox for its searches. Adds a soft warning when a pass grows past the per-execution memory budget, and records the cost model and the two-phase-fetch escalation alongside it. * apple: choose the mail sync window per pass, not per mailbox The merged mail pass reads every enabled folder plus Sent on one IMAP session and rebuilds each thread from its complete visible message set in a single transform. The phase decision (backfill vs incremental) is correctly per mailbox, but the SEARCH WINDOW was too: a backfilling folder searched from the account's history floor while an already-synced folder searched only the recent 30 days. On a connection whose history floor is a year wide those two windows are ~11 months apart, so enabling a new folder rebuilt any thread spanning both folders from just the new folder's half — retitled from the wrong originator, and marked read when that half was \Seen while the missing root was not. Neither message ever re-enters the 30-day window, so the wrong title and read state stuck. The same asymmetry dropped the owner's older Sent replies off every thread a newly-enabled folder backfilled. The window is now a property of the pass: if any mailbox is backfilling, a full rescan is pending, or the granted history window has widened, then every mailbox — Sent included — searches from the history floor; otherwise every mailbox searches the recent window. A wide pass also cannot be skipped by the CONDSTORE gate. That last condition also restores a behaviour lost when the per-mailbox cursors were merged: each mailbox's cursor now records how far back it has actually been read, so when a connection is granted a wider history window, the next pass re-reads every mailbox from the new floor instead of staying on the recent window forever. The cursor then advances, so the wide pass is paid once. Also in this change: - Per-thread metadata is written with a single batched store call instead of one write per thread root. A half-written loop left the remaining roots looking never-seen on the next pass, which re-emitted them as unarchived and read. - ThreadMeta.channelId is optional, so a thread's home folder can be cleared without discarding its calendar-bundling decision. - The connection-level cursor document is a read-modify-write, which is only safe while one pass can run per connection; that constraint is now stated at the write site. - The in-memory IMAP test double now honours a search's `since` bound, so a window that is too narrow actually misses messages, and fixture timestamps are anchored to the current time rather than fixed dates. - Docstring repairs around channel enable/disable. * apple: schedule one mail sync pass per connection, not per folder The merged mail pass already reads every enabled folder plus Sent in one go, but everything that scheduled it was still keyed per folder: the sync lock, the recurring poll and the IMAP push drain. With more than one folder enabled, two passes could run against the same account at once and each one read-modify-writes the single connection-level cursor document — the later writer restoring a sibling mailbox's pre-pass cursor (so already-ingested mail is re-classified as new and threads the user just read flip back to unread), or dropping a cursor the sibling had just created (forcing a redundant full backfill and a second sync-completed signal). The lock, the poll and the push drain are now connection-level, and each entry point enumerates the enabled mail channels and hands the whole list to one merged pass. A push arriving on any folder coalesces into that single drain. IMAP IDLE watches stay per mailbox, since that is how they are keyed. The poll re-arms every enabled folder's watch before it consults the lock, so a dropped watch still self-heals while another pass is running. Enabling a folder while a pass is in flight now retries instead of skipping: the running pass built its channel list before the new folder was marked enabled, so it cannot cover it, and skipping would leave that folder unsynced with its sync-completed signal never fired. The granted history window travels with the retry rather than being re-read, and is persisted by the pass itself under the lock so it can never be clobbered by a concurrent write. The trailing per-folder argument is kept (and ignored) on the poll, push and drain callbacks so tasks scheduled by earlier versions still resolve. * apple: persist the granted mail history floor independently of the sync cursor The connection-level "how far back should this pass search" floor was carried only as a queued-task argument, so it could be lost outright if the task exhausted its retries (e.g. repeated IMAP timeouts on a first backfill) — after which the recurring poll and push-driven sync would silently fall back to a rolling default window forever, instead of the window the plan actually granted. The floor now also persists to its own connection-level key, widening only (a later, narrower grant can never erase history already recorded as granted), kept deliberately separate from the sync cursor document so the write can't race an in-flight sync pass. Every entry point that can otherwise run with no floor of its own now falls back to this persisted value instead of a hardcoded default, so a lost enable task or a coalesced retry burst self-heals on the very next scheduled sync. Also: - the mail-sync retry path no longer re-arms the recurring poll on every lock-contention retry — that could push the poll's next run 15 minutes into the future on each attempt, effectively pausing it for as long as a stuck sync held the lock - the enable-path and pass-level default history window now share one constant instead of two independently maintained copies - stale comments referencing a removed per-channel lock/method were updated - test coverage was tightened around push notifications for a since-disabled mail folder and the default history window * apple: tear down mail sync per connection, not per folder Disabling one enabled mail folder used to run connection-wide teardown: it cancelled the shared write-back drain and swept the per-connection marker keys (compose dedup guards, write-back retry payloads, to-do flag echo-break markers, calendar-cancellation hints, bundle cache). With more than one folder enable-able that dropped live state for the folders still enabled — e.g. clearing a to-do on a thread in a folder that stays enabled could resurrect it on the next sync, because the echo-break marker was gone and the pass re-read the IMAP flag as a fresh change. Split channel-disable teardown into two halves: - Always, for the disabled folder: stop its IDLE watch, drop its push callback, prune only its cursor from the shared sync-state document, re-home the threads it owned (clearing only their home-channel field so the next pass re-derives it without re-classifying or treating the thread as new), and archive its links. - Only when it was the last enabled folder: cancel the connection-level poll, drains and lock, delete the whole sync-state document, and sweep the per-connection marker keys. The connection's owner identity and granted history floor survive, so a later re-enable resumes cleanly. Add upgrade() to migrate existing connections off the old per-folder state and scheduling: clear legacy per-folder cursors, history floors and bundle cache, fold the widest granted floor into the connection state, seed per-thread metadata for already-synced threads so the first merged pass doesn't mass mark-read or un-archive them, cancel legacy per-folder scheduling, and arm the single connection-level poll. * apple: make upgrade()'s thread-home seed genuinely one-shot upgrade() runs once per active instance on every connector version deploy, not only the first one. Its migration step that seeds a mail:thread:<root> document for every previously-flagged root was overwriting that document unconditionally, so any later deploy reset threads a real sync pass had already homed (channelId + bundle) back to an empty document — forcing an unnecessary re-resolution of each thread's folder and re-classification of its calendar attachments on every future deploy. Fixed by only seeding roots that don't already have a mail:thread: document, so the migration only ever touches state once. Also fixed onMailChannelDisabled to still schedule a full rescan on the next sync pass when a non-last folder is disabled before any sync has ever run (previously skipped because there was no existing state document to update). * apple: sweep stale mail-sync docstrings, note deferred write-back gap Docs-only follow-up to the merged mail sync pass: the attachment-ref comment in attachments.ts still described a UIDVALIDITY cursor scoped to a single "INBOX channel", which predates every enabled mailbox (and Sent) getting its own persisted cursor. Updated it to describe the current per-mailbox cursor storage and why attachment refs still don't validate against it. Also adds a TODO in imap-fetch.ts flagging that thread/message lookups used by write-back (flag toggles, forward source resolution) are still hardcoded to INBOX. That's a known, intentionally deferred gap for a separate follow-up — write-back doesn't yet search a thread's own home mailbox — not an oversight from the sync merge. No behavior change; comments and one TODO only. * apple: write back read/to-do/reply flags to a thread's own mailbox Mail write-back always selected INBOX when resolving a thread's messages, even after mail sync began merging every enabled folder into one pass. For a thread whose messages live in a non-INBOX folder (an Archive rule, a Focus filter), this meant: - Marking the thread read or to-do in Plot resolved zero INBOX messages, hit the "nothing to flag" branch, and returned successfully without ever writing \Seen/\Flagged over IMAP. The change was lost silently. - Replying resolved no latest message, so the reply degraded to root-id-only threading and lost reply-all recipients derived from the message headers. Resolve each write-back against the thread's own home mailbox instead. The mailbox is read from thread.meta.channelId (the namespaced mail channel id the sync path already persists on every link) via a new mailChannelRawId() helper; it splits on the first ':' so folder names containing '/' or ':' round-trip, and falls back to INBOX for threads synced before multi-folder support. resolveThreadMessages() takes an explicit mailbox argument, and its ResolvedThread fields are renamed inboxMessages/inboxUids -> messages/uids to stop asserting INBOX where it is no longer true. The deferred write-back retry persists the target mailbox alongside the flag so a retried flag lands in the right folder too (older payloads without it fall back to INBOX). The forward-source lookup (fetchOriginalMessage) intentionally stays INBOX+Sent-scoped: a forward draft carries no origin-thread channel, so there is no per-message hint of a third folder to widen to. Documented as an accepted limitation. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * imap, apple: add email classifier facets to the iCloud connector Gives the Apple Mail connector the same format/automation/reach classification and OTP/confirm-link CTA extraction Gmail and Outlook already have, by fetching List-Id, List-Unsubscribe, Precedence, Auto-Submitted, Return-Path, Importance, X-Priority, and Authentication-Results alongside the existing References header and running them through the shared email-classifier package. The DMARC trust check verifies against real inbound headers: iCloud stamps separate Authentication-Results headers per mechanism (SPF, DKIM, DMARC, BIMI) rather than one combined header per hop, so the selector specifically finds the DMARC-bearing entry instead of picking whichever trusted header happens to come first. * refactor(apple): extract shared ICS parsing helpers into ics-common.ts Prerequisite for the VTODO (Reminders) parser in the next task — moves the line/param/date-parsing helpers ics-parser.ts already had private into a module both the VEVENT and upcoming VTODO parsers can import, instead of duplicating them. No behavior change: ics-parser.ts re-exports the two names (ICSDateTimeProp, parseICSDateTime) it exposed publicly before. * fix(apple): correct unfoldLines RFC 5545 behavior and update tests to match Fixes regression from initial Task 1 implementation: unfoldLines should strip the fold marker (newline + space/tab) completely per RFC 5545 §3.1, not replace it with a space. The initial test case was unrealistic; this commit replaces it with RFC-5545-accurate test cases that verify the correct behavior: - Two spaces after fold marker (one marker + one original) → one space result - One space (the fold marker only) → zero spaces result Also removes cosmetic double-blank-line in ics-parser.ts leftover from refactor. * feat(apple): add VTODO parser for iCloud Reminders New reminders/ics-todo-parser.ts mirrors the existing VEVENT parser's structure, built on the shared ics-common.ts helpers. Parses the fields Reminders sync needs: SUMMARY/DESCRIPTION/DUE/COMPLETED/STATUS/RELATED-TO/ RRULE. No occurrence expansion — recurring reminders are synced as their master state only, per the design spec. * feat(apple): generalize CalDAVClient for VTODO (Reminders) collections listCalendars becomes a thin VEVENT-scoped wrapper over the new listCalendarsByComponent, which filters supported-calendar-component-set for either VEVENT or VTODO — no behavior change for the existing calendar path. Adds fetchTodos (unwindowed VTODO calendar-query) and a best-effort discoverDefaultTasksListHref probe that degrades to null (opt-in-only) when the server doesn't advertise a default reminders list. * feat(apple): declare the reminder link type and list reminders channels REMINDERS_LINK_TYPES mirrors Google Tasks' "task" type (sharingModel: none, open/done statuses, composable). getReminderChannels lists VTODO collections via the Task 3 CalDAVClient additions and marks the discovered default list enabledByDefault, degrading to opt-in-only when no default is discoverable. * feat(apple): transform VTODO reminders into Plot reminder links transformTodo mirrors google/tasks' transformTask: CANCELLED todos return null (caller archives instead of upserting), COMPLETED maps to done with no todo/todoDate, subtasks render as Todo/Done-tagged notes on the parent thread, and the description note + link are authored by the connection owner (same convention as Google Tasks' personal to-do list). * feat(apple): reminders sync — full backfill + WebDAV-Sync incremental RemindersHost mirrors google/tasks' TasksSyncHost host-adapter pattern. fullSyncFn/processSyncChunkFn do a one-shot VTODO fetch with chunked saving (50 at a time), splitting same-batch parent/subtask nesting like Google Tasks' saveTaskPageFn. pollFn mirrors Calendar's proven WebDAV-Sync-fast-path /ctag-fallback design, including the fix of never re-checking ctag once a sync token is trusted. CANCELLED todos are archived rather than upserted. * fix(apple): route reminders pollFn full rescans through queueFullSync pollFn's two rescan branches ("no cursor" and "ctag changed") called fullSyncFn(host, listId, ...) inline and discarded its SyncBatchResult. fullSyncFn/processSyncChunkFn only save REMINDERS_CHUNK_SIZE (50) items per call, returning {next: {remaining}} when more are pending — so any list with more than 50 items silently lost everything past the first 50, PERMANENTLY: fullSyncFn seeds a fresh sync token from a REPORT taken after the full fetch, so every subsequent incremental poll then correctly reports "nothing changed" against that truncated state, and the dropped items never resurface. Add RemindersHost.scheduler.queueFullSync(listId, initialSync) and route both rescan triggers through it instead of calling fullSyncFn directly. This queues a background task that reuses the same fullSyncFn/ processSyncChunkFn continuation chain the initial backfill already uses (wired to remindersInit/remindersSyncBatch in Task 7), so a large list's rescan chunks across executions instead of truncating inside one poll. The "no cursor" branch now passes initialSync:true (was false) — a lost cursor means every item is being saved for the first time from this connector's perspective, matching Calendar's "recovering" semantics, so unread/archived defaults are suppressed the same way a genuine first backfill would. The "ctag changed" branch stays initialSync:false — a routine rescan of an already-established list should preserve existing read/archived state. Neither branch calls host.scheduler.schedulePoll anymore; that becomes the queued task's responsibility once it reaches {done: true}. * feat(apple): wire the reminders product into the connector lifecycle products.ts registers REMINDERS_LINK_TYPES/getReminderChannels alongside mail/calendar. apple.ts gains buildRemindersHost (mirrors buildMailHost's namespacing), routes onChannelEnabled/onChannelDisabled by product key, and adds remindersInit/remindersSyncBatch/remindersPoll task methods that drive reminders/sync.ts's pure functions. activate() now also records the owner's actor id under the reminders namespace, for description-note/subtask authorship. Also fixes a type mismatch in reminders/sync.ts's RemindersHost surfaced by wiring the real Integrations tool in: archiveLinks' filter declared meta?: Record<string, unknown>, incompatible with the tool's actual Record<string, JSONValue>. Updates two apple.test.ts fixtures (compose stub, activate namespace test) for the new required getRemindersChannels param and buildRemindersHost dispatch. * feat(apple): reminders write-back — create + status toggle onCreateLinkFn builds a minimal VTODO and PUTs it to the target list's collection href, composing a new reminder from Plot. onLinkUpdatedFn writes a done/reopen toggle back to the VTODO's STATUS (+ COMPLETED timestamp), retrying once on a concurrent-edit 412 with a freshly re-fetched etag — mirrors the retry shape caldav.ts already documents for calendar event writes. Title/description edits are not written back in v1, matching Google Tasks parity. apple.ts's onCreateLink now tries mail then reminders by draft type; onLinkUpdated is a new override. * fix(apple): reminders write-back — surface failed PUTs, dedupe composed note onLinkUpdatedFn silently swallowed a failed status write-back: updateEventICS resolves false (rather than throwing) for any non-412 failure, and neither the initial attempt nor the 412 retry checked that return. Since the next regular poll unconditionally re-derives status from iCloud's live VTODO, a dropped write reverted the user's done/reopen action with no error anywhere. Now throws when the PUT resolves false on either attempt, matching Google Tasks' write-back (whose REST client throws on failure) and letting the runtime's normal unexpected-callback handling take over. onCreateLinkFn also omitted originatingNote: transformTodo always emits a key: "description" note when DESCRIPTION is non-empty, so the very next sync-in of a composed reminder appended a second, duplicate description note alongside the keyless one Plot already created. Binds the composed note via originatingNote (key + externalContent) so it's recognized as the same note on re-ingest instead. Adds 4 tests (13 total in write.test.ts): two covering a false PUT return on the initial attempt and on the post-conflict retry, two covering originatingNote's presence/absence based on composed note content. * fix(apple): reminders — persist real CalDAV href, chunk via store not callback Final whole-branch review across the reminders VTODO feature caught two issues that only became visible once every task was assembled together: 1. Write-back for a reminder synced in from Apple's own Reminders app reconstructed its CalDAV resource path as `<uid>.ics`, which is only guaranteed correct for reminders Plot itself created. Apple's own app assigns resource paths under no such guarantee, so completing/reopening an Apple-originated reminder from Plot could silently 404 and no-op, with the next sync invisibly reverting the user's action. Now the real href from the CalDAV multistatus response is persisted in `link.meta.todoHref` at sync time and preferred at write-back time, falling back to the UID reconstruction only for pre-existing links. 2. The chunked full-sync continuation threaded the entire remaining parsed VTODO list through a `this.callback()`/task argument on every batch boundary, with no cap on list size — risking the runtime's callback-payload limit on a large account. Continuation now stores the parsed list once under `pending:<listId>` and threads only a numeric offset through the callback chain, mirroring Calendar's `sync_state_<id>` + `batchNumber` shape. The store key is cleared once the list is fully drained. Full connector suite: 451/451 passing (448 + 3 new tests covering both fixes). tsc --noEmit and plot lint clean. --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
1 parent ba571a2 commit b235fa6

70 files changed

Lines changed: 18089 additions & 2354 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@plotday/twister": minor
3+
---
4+
5+
Added: `Connector.getAccountIdentity()` lets non-OAuth connectors declare a matchable identity email for the connected account.

‎.changeset/imap-attachments.md‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@plotday/twister": minor
3+
---
4+
5+
Added: `ImapMessage.attachments` surfaces attachment parts discovered from a fetched message's BODYSTRUCTURE.
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@plotday/twister": minor
3+
---
4+
5+
Added: `Imap.fetchAttachment()` downloads the raw decoded bytes of a message attachment by IMAP part number (as reported in `ImapMessage.attachments`).

‎.changeset/imap-header-fields.md‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@plotday/twister": minor
3+
---
4+
5+
Added: `ImapMessage` now carries `listId`, `listUnsubscribe`, `precedence`, `autoSubmitted`, `returnPath`, `importance`, `xPriority`, and `authenticationResults` (the raw values of any headers a connector's IMAP fetch picked up), so an IMAP-based mail connector can build classifier signals the same way the Gmail and Outlook connectors already do.

‎.changeset/imap-highest-modseq.md‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@plotday/twister": minor
3+
---
4+
5+
Added: `ImapMailboxStatus.highestModSeq` — surfaces a mailbox's CONDSTORE (RFC 7162) `HIGHESTMODSEQ` high-water mark when the IMAP server advertises it. Connectors can persist this value as a since-last-poll cursor and skip re-scanning a mailbox whose mod-sequence hasn't advanced, instead of refetching a recent-message window every poll. The field is absent when the server or mailbox does not support mod-sequences.

‎.changeset/imap-idle-push-watch.md‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
---
2+
"@plotday/twister": minor
3+
---
4+
5+
Added: `Imap.watch()` / `Imap.unwatch()` — server-maintained IMAP IDLE push
6+
watches. A connector can register a callback for a mailbox and the platform
7+
holds the IDLE connection open, invoking the callback within seconds of new
8+
mail or flag changes so incremental sync no longer waits for the next poll.
9+
Includes the new `ImapWatchOptions` type.

‎.changeset/smtp-attachments.md‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@plotday/twister": minor
3+
---
4+
5+
Added: `SmtpMessage.attachments` lets connectors include file attachments when sending email through the SMTP tool.

0 commit comments

Comments
 (0)