Skip to main content

Calendars

chainmail reads and changes calendars as well as mail. Google accounts use the Google Calendar API, and CalDAV servers such as Mailwright use CalDAV with server scheduling (RFC 6638). The daemon keeps a calendar cache, lists invites that wait for an answer, answers them, reports free/busy, and creates, changes and cancels events.

Calendar writes go through the same send policy as mail. An invitation, an update or a cancellation reaches people, so the daemon checks the people it would reach against the allowlist, as it checks a send's recipients. A new event whose attendees the policy does not allow is created as a draft event, and nobody is invited. See Held writes and draft events.

What each account gets​

Each account has a calendar store, set by store in its [accounts.<name>.calendar] table. The store decides what the account can do.

AccountStore by defaultWhat works
Google Workspace, or Gmail with OAuth2googleEverything on this page, through the Google Calendar API.
Gmail with an app passwordnoneInvites that arrive by mail, answered by email. The Calendar API takes no password, so store = "google" on such an account is a config error.
A CalDAV server, such as Mailwrightcaldav, set by hand with the server's urlEverything on this page, through CalDAV.
Any other accountnoneInvites that arrive by mail, answered by email.

An account with store = "none" has no calendar to read or write. agenda and calendars leave it out unless --account names it, and then refuse it with NO_CALENDAR_STORE (exit 2). So do the event writes, and free/busy for other people asked through it. invites, invite and rsvp still work for the invites that reach it by mail.

A caldav store works on any preset. It finds the calendars from url through /.well-known/caldav. Its requests carry the account's own credentials: the OAuth2 access token as Authorization: Bearer for an oauth2 account, or the username and password for a password account.

The calendar cache​

The daemon syncs every account with a store into calendar.db, beside cache.db in the cache directory. Calendar reads answer from that file. Writes go to the calendar server, and the daemon syncs the account again straight after.

  • A sync runs every [calendar] poll, 5 minutes by default, and at once when an invite arrives by mail for the account. One account's syncs never overlap.
  • Google syncs with sync tokens, and CalDAV with sync-collection where the server has it, so each sync fetches only what changed.
  • The cache keeps events from window_past ago to window_future ahead of now, 30 and 365 days by default. A read outside that window finds nothing.
  • A failed sync is tried again after poll, then twice as long each time, up to 30 minutes or poll when that is longer.
  • Before an account's first full sync finishes, a calendar read on it fails with CACHE_NOT_READY (exit 7) and the hint the calendar has not finished its first sync; run: chainmail cache status.

calendar.db holds nothing that the calendar servers do not. A file of another schema version, or one SQLite reports as corrupt, is deleted and filled again by a full sync, with the log line recreated calendar.db; calendars sync in full.

chainmail cache status shows one calendar line per account with a store. The MCP tool cache_status returns the same lines as calendars.

StateMeaning
readyThe last sync covered every calendar.
syncingNo sync has finished since the daemon started.
auth_requiredThe calendar server refused the account's credentials, or a Google token lacks the calendar scopes. Mail is not affected. The hint of a refused call says what to run, such as chainmail auth login <account>. See Google accounts.
errorThe last sync failed for another reason. ERROR holds its reason, never the server's text.

Recurring events​

The cache keeps a recurring series as its master event, its changed instances and its excluded dates. A read expands the series into instances in the series' own time zone, so an instance keeps its local time across a change of daylight saving time.

  • One read expands at most 1,000 instances of a series. A read that stops there sets truncated.
  • A rule with FREQ=SECONDLY or FREQ=MINUTELY is never expanded, and the read sets truncated too.
  • An event keeps at most 500 attendees. The rest are dropped, and the event has attendees_truncated set.

Refs​

An event's ref is ce_ followed by 26 lowercase letters and digits, such as ce_01jcb7m2k9r8q3v4w5x6y7z8a9. It stays the same across syncs. One instance of a series adds @ and its original start in UTC, such as ce_01jcb7m2k9r8q3v4w5x6y7z8a9@20261110T150000Z.

A ref with an instance part acts on that instance only. A plain ref acts on the whole series. Refs come from agenda, invites, event and calendar watch events. Pass them unchanged.

Times​

The CLI reads a time as RFC 3339, such as 2026-11-10T09:00:00Z, as a time without a zone, such as 2026-11-10 09:00, or as a date, such as 2026-11-10. It reads a time or date without a zone in the CLI machine's own zone, or in the zone --tz names. The CLI never reads the daemon's config, so [calendar] timezone does not apply to what you type.

  • A time the clocks skip when daylight saving time starts is refused.
  • A time the clocks show twice when it ends means the first of the two.
  • --from and --to default to now and 7 days later, --days changes the length, and the range is at most 400 days. A date alone in --to takes in that whole day.

The daemon uses [calendar] timezone as the zone of an event created without one. It defaults to the daemon host's zone.

An all-day event runs from 00:00 UTC of its first date to 00:00 UTC of the day after its last date. Every all-day time in requests and results has that form, whatever the zone. The CLI turns the dates you type into those UTC midnights, so an all-day event keeps the dates you gave.

Results give each time in UTC, with the event's own zone in time_zone. Tables show each event in local time, and also in the event's own zone when that zone has another offset.

Reading calendars​

chainmail calendars
chainmail agenda
chainmail agenda --days 14 --account work
chainmail agenda --from 2026-11-10 --to 2026-11-14 --tz Europe/London
chainmail event ce_01jcb7m2k9r8q3v4w5x6y7z8a9
  • calendars lists each account's calendars with their ID, name, access and time zone. Access is read or write. One calendar per account is the default for new events.
  • agenda lists the events and instances that overlap the range, from every account with a store, in time order. --account and --calendar narrow it, and both repeat. Events James declined are left out unless --include-declined.
  • event <ref> shows one event or instance with its organizer, its attendees and their answers, James's own answer, and the etag that a change needs.

Event titles, locations, descriptions, names and comments come from whoever wrote the event. They are untrusted, as mail is, and JSON output wraps them as {"untrusted": "..."}. See Untrusted content.

Invites​

chainmail invites lists the invites that wait for James's answer, from two sources:

  • events in his calendars where he is an attendee who has not answered, which are not cancelled and have not ended, and
  • invitations that arrived as a text/calendar part of a mail message, with no matching event in that account's calendars, which have not ended and whose latest version is not a cancellation.

Each invite appears once. When both sources know an invite, the calendar event wins, and the invite also carries message_ref, the ref of the message it came in. The message ref links the invite to its mail and to work items.

chainmail invites
chainmail invite ce_01jcb7m2k9r8q3v4w5x6y7z8a9

The daemon finds invites in mail where the cache runs: on accounts with watching and the cache on, as new mail arrives. It does not look through mail the cache backfills. It reads a calendar part of at most 1 MiB and skips larger ones.

Answering an invite​

chainmail rsvp ce_01jcb7m2k9r8q3v4w5x6y7z8a9 accept
chainmail rsvp ce_01jcb7m2k9r8q3v4w5x6y7z8a9@20261110T150000Z decline --comment 'Away that week'

The answer is accept, decline or tentative. --comment sends a note of up to 4096 bytes to the organizer.

  • When the invite is an event in a calendar of an account with a store, the answer goes through the calendar. Google records it and notifies the organizer. A CalDAV server records it and sends the organizer an iTIP REPLY by its own scheduling. The result says via store.
  • Otherwise chainmail emails an iCalendar REPLY to the organizer from the account, and the result says via email. The reply is a send like any other, and sent_ref is its copy in Sent when the server stored one.

The organizer is checked against the send policy as a recipient. An answer the policy does not allow is held, and the command exits 4. Through the calendar the answer is not made. By email the reply is saved to Drafts, as a held send is.

When the event changed since the cache read it, the daemon reads it again once and answers if James's answer is still what it was. Otherwise the answer fails with EVENT_CONFLICT (exit 7).

With --idempotency-key, a repeat with the same key does not answer twice. Without a key each rsvp is a new answer, so answering again later sends a new reply.

Free/busy​

chainmail freebusy
chainmail freebusy --account work --days 3 [email protected] [email protected]

With no addresses, freebusy shows James's busy time across every account's calendars, merged into blocks that name no event. Events he declined, transparent events and cancelled events do not count.

Each address asks the provider of the account --account names for that person's busy time: Google's free/busy query, or a free/busy request to the CalDAV server's schedule outbox. A person the provider cannot see shows as unknown, with known false, never as free. One call asks about at most 100 people.

Creating and changing events​

chainmail event create --account work --title 'Design review' --start '2026-11-12 14:00' --duration 1h --attendee [email protected]
chainmail event create --account work --title 'Offsite' --all-day --start 2026-11-19 --end 2026-11-20
chainmail event create --account work --title 'Standup' --start '2026-11-16 09:30' --duration 15m --rrule 'FREQ=WEEKLY;BYDAY=MO,WE,FR'
chainmail event edit ce_01jcb7m2k9r8q3v4w5x6y7z8a9 --start '2026-11-12 15:00'
chainmail event cancel ce_01jcb7m2k9r8q3v4w5x6y7z8a9

Create​

event create makes an event on the account's default calendar, or on the one --calendar names. --account may be left out when exactly one account has a calendar store. James is the organizer. When he invites others, he is also an attendee who has accepted.

  • --title is 1 to 1024 characters, --location at most 1024, and --description at most 64 KiB.
  • --end or --duration gives the end, such as 30m or 1h30m. With --all-day, --start and --end are dates, --end is the last day, and --duration is whole days such as 2d. An all-day event lasts one day by default.
  • --tz names a timed event's zone, as well as the zone the CLI reads times in. Without it the event takes the daemon's [calendar] timezone.
  • --attendee repeats, up to 500 attendees. Each one is invited by the calendar provider.
  • --rrule takes an RRULE value of at most 1024 bytes, such as FREQ=WEEKLY;BYDAY=MO. A rule chainmail cannot expand is refused, and so is one more frequent than hourly.

A create is safe to repeat. The daemon derives the event's UID from the account and the idempotency key, so a create retried after a crash finds the event the first one made instead of making another. Without --idempotency-key the CLI derives a key from the account, the calendar and every field, with the attendees sorted. The same create within 7 days returns the event already created. To create the same event again on purpose, pass a new key.

Change and cancel​

event edit <ref> changes the fields its flags name, and only those.

  • --start alone moves the event and keeps its length. --duration alone keeps the start.
  • --attendee replaces the whole attendee list, and --no-attendees clears it.
  • An empty value, such as --location '', clears that field. An empty --rrule removes the recurrence. One instance of a series has no rule of its own, so change the rule on the series.
  • An all-day event stays all day and takes dates. --all-day with dates makes a timed event all day, and --timed with times and --end or --duration makes an all-day event timed.

event cancel <ref> cancels an event James organizes and tells its attendees, or deletes an event with no other attendees. A ref with an instance part cancels that instance only.

Both need the event's etag. Without --etag the CLI reads the event first and uses the etag it has now. When the event changed since the etag was read, the change is refused with EVENT_CONFLICT (exit 7). Read the event again and decide again.

A change can be refused for two more reasons:

  • Only the organizer can change or cancel an event that has other attendees. Anyone else gets NOT_ORGANIZER (exit 3), with the hint to ask the organizer or answer the event. An event with no attendee but James counts as his.
  • A calendar the account can only read refuses a write with CALENDAR_READ_ONLY (exit 3). calendars shows each calendar's access.

Held writes and draft events​

Every attendee other than James hears of a new event, a change or a cancellation from the calendar provider. So the daemon checks those attendees against the policy, as the recipients of one send: send_allow for the account, plus the addresses of every configured account, and max_recipients. A write that notifies nobody but James is never held. A change counts both the old and the new attendees.

OperationWhen held
event create with attendeesThe event is created without notifying anyone. Google gets sendUpdates=none. A CalDAV server gets each attendee with SCHEDULE-AGENT=CLIENT, so the server sends nothing. The result is a draft event, with its ref in event_ref.
event edit, event cancelNothing is changed and nobody is notified. The result's summary names the changed fields, the new times and the number of attendees, never event text.
rsvp through the calendarThe answer is not made.
rsvp by emailThe reply is saved to the account's Drafts folder, as any held send is.

A held write exits 4 with HELD, after the result on stdout. The hint of a held create is held by policy: the event was created as a draft and no invitations were sent; James can send them from the calendar app.

A hold is final. Nothing in chainmail sends a draft event's invitations later. James opens the draft event in Google Calendar or a CalDAV client and sends the invitations from there, or deletes it. An agent that gets a hold must tell James and stop, and must not look for another way to reach the attendees. To let an attendee through, add the address to send_allow and restart the daemon. The send policy has the rules.

Calendar events in the watch log​

Four opt-in event kinds report calendar changes. A watcher gets them only when --kind or kinds names them.

KindWhenPayload
inviteA new invite waits for James's answer.ref, calendar_id, start, end, the organizer's address, and message_ref for an invite that came by mail
event_changedThe start, end, location, recurrence or attendee list changed on an event James attends or organizes.ref, calendar_id, fields with the changed field names, and the new start and end
event_cancelledThe organizer cancelled or deleted an event James attends.ref, calendar_id, start
invite_replyAn attendee answered an event James organizes.ref, calendar_id, the attendee's address and partstat, such as ACCEPTED
chainmail watch --group calendar --kind invite,event_changed,event_cancelled,invite_reply
  • The payload never holds event text. Read the event with chainmail event <ref> or get_event.
  • A calendar's first full sync raises no event.
  • Changes chainmail made itself raise events too, so every watcher hears of them.
  • An event that ended more than window_past ago raises none.
  • The syncer writes the events to the log before it commits the calendar's changes, so a crash between the two raises them again on the next sync. Calendar events are delivered at least once.
  • Calendar invites raise invite from the sync. An invite that arrives by mail raises invite only on an account with store = "none".

The events carry their account, so --account filters them. They have no folder, so they pass any --in, and --item leaves them out. Watching for mail covers groups and delivery.

Setting up accounts​

Google accounts​

A Google account whose calendar store is google asks for three more OAuth2 scopes than mail needs:

  • https://www.googleapis.com/auth/calendar.events
  • https://www.googleapis.com/auth/calendar.calendarlist.readonly
  • https://www.googleapis.com/auth/calendar.freebusy

An account logged in before calendars existed has a refresh token without them. Its mail keeps working. Its calendar line shows auth_required, and calendar calls fail with AUTH_REQUIRED (exit 5) and the hint run: chainmail auth login <account>. To give it the scopes:

  1. In each Google Cloud project with a chainmail client, enable the Google Calendar API under APIs and Services.
  2. Under Google Auth Platform, then Data access, add the three scopes above.
  3. Run chainmail auth login <account> for each Google account, and approve the new consent screen.
chainmail auth login work
chainmail cache status --output table

The calendar scopes are sensitive, so the consent screen of an unverified External app, such as the personal Gmail project in Accounts and providers, shows Google's warning again. Click Advanced, then continue to the app.

An account with store = "none" asks for no calendar scope, so it keeps the consent it has.

[accounts.personal.calendar]
store = "none" # mail only, with the consent it has

A CalDAV account with a Keycloak issuer client​

A CalDAV server such as Mailwright can take OAuth2 access tokens from an OpenID Connect issuer, such as a Keycloak realm. The account then logs in once with chainmail auth login. The daemon presents the access token with XOAUTH2 over IMAP and SMTP, and as Authorization: Bearer over CalDAV.

The issuer needs a client for chainmail:

  • a public client, with client authentication off, since every install would share one secret,
  • the standard flow with PKCE (S256), and no direct access grants,
  • the one valid redirect URI http://127.0.0.1:53682/, exactly, with its trailing slash,
  • offline_access among its scopes, so the token endpoint returns a refresh token that outlives the browser session, and
  • an audience mapper that adds the audience the mail server checks to the access token. Mailwright checks for mailwright.

The server must name the account from a claim of the token. Mailwright reads preferred_username, so the account's username is that name.

In chainmail's config, the client has an issuer and the scopes to ask for, and the account uses the custom preset with auth = "oauth2":

[oauth_clients.keycloak]
client_id = "chainmail"
issuer = "https://auth.example.net/realms/example" # OIDC discovery base
scopes = ["openid", "offline_access"]

[accounts.mail]
provider = "custom"
address = "[email protected]"
username = "ann" # the preferred_username claim
auth = "oauth2"
oauth_client = "keycloak"
imap = { host = "mail.example.net", port = 993 }
smtp = { host = "mail.example.net", port = 465 }

[accounts.mail.calendar]
store = "caldav"
url = "https://mail.example.net/" # discovered through /.well-known/caldav

The daemon reads <issuer>/.well-known/openid-configuration once per client for the login and token endpoints. A client with an issuer needs no secret, so there is no oauth_clients/keycloak to store. Restart the daemon, then log in:

chainmail auth login mail
chainmail accounts test mail --output table

auth login prints the issuer's consent URL. The flow is the paste-back flow of the Google accounts, described in Auth. When the token endpoint returns no refresh token, the login fails with a hint to give the client offline access, such as the offline_access scope.

Mail servers behind another name​

When IMAP or SMTP is reachable only through a host whose certificate names another, such as a load balancer on a private network, set tls_server_name on the server override. TLS then sends that name and checks the certificate against it, while the connection goes to host.

[accounts.mail.imap]
host = "mail-nlb.internal.example.net"
port = 993
tls_server_name = "mail.example.net"

tls_server_name applies to IMAP and SMTP. CalDAV connects to the host in url.

The calendar keys are in the configuration reference.

Over MCP​

The same calls are ten MCP tools: list_calendars, agenda, get_event, list_invites, get_invite, free_busy, rsvp, create_event, update_event and cancel_event. They take RFC 3339 times, and update_event takes the fields to change and a clear list. MCP tools lists their inputs, and Working with mail covers how an agent handles held calendar writes.

Every flag of the calendar commands is in the command reference, from chainmail agenda to chainmail event create.