Repository navigation
Commit b235fa6
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
File tree
- .changeset
- connectors
- apple-calendar/src
- apple
- src
- calendar
- mail
- reminders
- google
- twister
- docs
- src
- tools
Some content is hidden
Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
0 commit comments