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.
| Account | Store by default | What works |
|---|---|---|
| Google Workspace, or Gmail with OAuth2 | google | Everything on this page, through the Google Calendar API. |
| Gmail with an app password | none | Invites 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 Mailwright | caldav, set by hand with the server's url | Everything on this page, through CalDAV. |
| Any other account | none | Invites 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-collectionwhere the server has it, so each sync fetches only what changed. - The cache keeps events from
window_pastago towindow_futureahead 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 orpollwhen 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 hintthe 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.
| State | Meaning |
|---|---|
ready | The last sync covered every calendar. |
syncing | No sync has finished since the daemon started. |
auth_required | The 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. |
error | The 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=SECONDLYorFREQ=MINUTELYis never expanded, and the read setstruncatedtoo. - An event keeps at most 500 attendees. The rest are dropped, and the event has
attendees_truncatedset.
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.
--fromand--todefault to now and 7 days later,--dayschanges the length, and the range is at most 400 days. A date alone in--totakes 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
calendarslists each account's calendars with their ID, name, access and time zone. Access isreadorwrite. One calendar per account is the default for new events.agendalists the events and instances that overlap the range, from every account with a store, in time order.--accountand--calendarnarrow 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 theetagthat 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/calendarpart 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
viastore. - Otherwise chainmail emails an iCalendar REPLY to the organizer from the account, and the result says
viaemail. The reply is a send like any other, andsent_refis 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
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.
--titleis 1 to 1024 characters,--locationat most 1024, and--descriptionat most 64 KiB.--endor--durationgives the end, such as30mor1h30m. With--all-day,--startand--endare dates,--endis the last day, and--durationis whole days such as2d. An all-day event lasts one day by default.--tznames 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.--attendeerepeats, up to 500 attendees. Each one is invited by the calendar provider.--rruletakes an RRULE value of at most 1024 bytes, such asFREQ=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.
--startalone moves the event and keeps its length.--durationalone keeps the start.--attendeereplaces the whole attendee list, and--no-attendeesclears it.- An empty value, such as
--location '', clears that field. An empty--rruleremoves 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-daywith dates makes a timed event all day, and--timedwith times and--endor--durationmakes 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).calendarsshows 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.
| Operation | When held |
|---|---|
event create with attendees | The 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 cancel | Nothing 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 calendar | The answer is not made. |
rsvp by email | The 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.
| Kind | When | Payload |
|---|---|---|
invite | A 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_changed | The 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_cancelled | The organizer cancelled or deleted an event James attends. | ref, calendar_id, start |
invite_reply | An 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>orget_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_pastago 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
invitefrom the sync. An invite that arrives by mail raisesinviteonly on an account withstore = "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.eventshttps://www.googleapis.com/auth/calendar.calendarlist.readonlyhttps://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:
- In each Google Cloud project with a chainmail client, enable the Google Calendar API under APIs and Services.
- Under Google Auth Platform, then Data access, add the three scopes above.
- 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_accessamong 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"
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.