* Run the end-to-end suite on pull requests into milestone and wip branches
A release's work is now collected on a milestone branch (milestone/v0.4.0)
before it reaches main, and the suite only ran on pull requests into main,
so nothing proposed for the milestone was tested. `milestone/**` joins
`main`, and `wip/**` comes along from wip/28, where it already runs the
suite on the phases of #28 — the same block on both, so they merge
without a conflict.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
(cherry picked from commit d009ff72ad)
* linked-issue: read the description of a pull request into a milestone branch (#254)
GitHub links an issue from "Closes #N" only in a pull request into the
default branch. Into milestone/v0.3.8 or a wip/ branch the closing
references stay empty, so the check failed on every such pull request
however its description named the issue (#252 was the first). For a pull
request into another branch, the description is now read for the same
keywords GitHub accepts.
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
* The release pull request is the suite run behind a tag (#252)
Releasing asked for the E2E workflow to be started by hand on main before
a tag. Since main became protected, the Release vX.Y.Z pull request cannot
merge until that workflow is green on it, and its commit is what is tagged,
so a manual run only ran the same jobs twice. Releasing now says so, and to
update the branch if main moved after its checks ran, since the protection
does not ask for up-to-date branches.
The paragraph in CONTRIBUTING and the header of e2e.yml that said main is
not verified commit by commit describe main before it was protected; both
now describe it as it is.
Closes #251
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
* Calendar on a phone: a readable month, a toolbar and top bar that fit, a live now line (#256)
The month chip is its title first, on up to two lines, then its time range
and place; a narrower day drops the end and the place for a third line of
title, long words are hyphenated, and an unanswered invitation has a
dashed bar. Below 39rem, where a day is bars, the grid shrinks and the
chosen day is listed in full under it: today at first, any tapped day
after, with a + for that day that the toolbar's + follows too. On a wider
touch screen the first tap opens a day's card and the second its entry
(calendar--month-tap, and a day-open: variant that the hover and the tap
share).
The calendar toolbar fits below 37rem with icons, a short date label and
smaller buttons, and under 22rem today's date in a box for Today. English
writes a same-month week as "Oct 20-26" (range_short_month). The top bar
tightens below 640px and moves Sync now, What's new and Send feedback into
the user menu. Dialogs are the whole screen below 768px, as compose is.
calendar--now moves the now line every minute on the view's clock and
redraws a view that shows the old day or the new one when the date
changes, which also moves the month's circle on today.
And a + on a day that has gone opened the editor on today:
DefaultEventStart::onDay pulled any past day to the next hour. A past day
now keeps its date.
Closes #253
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
* Close an issue when its pull request is merged into a milestone branch (#260)
GitHub acts on "Closes #N" only for merges into the default branch, so
issues fixed on a milestone branch stayed open until the release reached
main. A workflow on merged pull requests into milestone/** now closes the
issues the description names, with a comment linking the pull request.
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
* Preview the reply, not the quote, and send replies with their quote visible (#258)
The list preview flattened the whole body and skipped short blocks to find
the first long one, so a one-line reply previewed as its attribution line or
the quoted mail. The quoted history (same seam as the reading view's "···")
and a "-- " signature are cut off first now, in HTML and plain text, and the
chrome skip only applies to bulk mail. The JMAP preview uses the same code.
The composer folded the quote with an inline display: none and its clean-up
removed only the toggle, so replies went out with an invisible quote. The
fold is removed in the editor and again on the server, and the reading view
reveals a folded quote in mail already received.
Closes #257
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
* Move a thread's unread count when JMAP marks a message read or unread (#267)
EmailPatchApplier wrote seenAt for $seen and left MessageThread::$unreadCount alone, so the web list (which draws bold from that counter) disagreed with the phone app. $seen now goes through ThreadStatusUpdater::markRead(), the web UI's own path, and markRead() counts the whole thread rather than the messages passed in.
Closes #266
Co-authored-by: Philippe Delodder <7259755+phdelodder@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
* Leave a message that is already in the destination folder where it is (#270)
Trashing a conversation with a message already in the bin queued that message as a move from Trash into Trash. webklex's move() then left the connection in EXAMINE and the server refused it ("NO Not permitted in EXAMINE state"), and the row had already lost its UID. ApplyImapFlagsHandler no longer sends a move into the folder a message is in (and gives the row its UID back), and ThreadStatusUpdater's trash(), archive() and restore() leave such messages out of the move.
Closes #269
Co-authored-by: Philippe Delodder <7259755+phdelodder@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
* Hide a conversation from the lists only when all of it is in the bin (#272)
A thread's labels are the union of its messages', so one deleted message put Trash on the thread and took the whole conversation out of the Inbox, its tab, label lists, All Mail, Unread, the badges and search, along with any reply threaded into it afterwards. The bin rule is now "carries Trash and has no message outside it", written once (binnedThreads()) for every list and badge and spelled out the same way in the search SQL. Option (b) of the issue.
Closes #268
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
* Let a long message scroll in the expanded compose window (#273)
expandedValueChanged() used closest('div') to reach the editor's wrapper, but closest() starts at the element itself and the editor is a div, so the wrapper never became a flex container and the editor stayed 170px tall, clipped by [contain:paint]. The wrapper is now the flex column and the editor grows (1 0 auto) to fill the window or its content; the plain-text field fills the expanded window too.
Closes #262
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
* Write each changelog entry as a file of its own, collected at release (#275)
Every pull request added its paragraph under ## Unreleased in CHANGELOG.md at the same place, so the second of any two conflicted with the first once it merged, and each conflict fix re-ran the whole E2E suite. A pull request now adds changelog.d/<issue>-<words>.md; bin/changelog-release.sh, run in the Release vX.Y.Z pull request, collects the fragments (highlights first) and anything still under ## Unreleased into the release's section and deletes them. CHANGELOG.md stays the one file What's new, bin/release-notes.sh and GitHub read.
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
* Say which step failed when notifications can't be turned on (#278)
Every failure in the enable path (registering the service worker, the
browser's push subscription, the server's answer) ended in the same
"Something went wrong" and the exception was dropped, so a report from a
Pixel 8 could not say which. The pane now names the step and the
DOMException name or HTTP status, and logs the exception to the console.
Closes #277
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
* Contacts: one address book, with Google, Outlook, Nextcloud and CardDAV contacts in it (#282)
A contacts page and pane for plMail's own contacts and the ones on a server:
Google and Outlook on the mail account's own sign-in, Nextcloud through the
Nextcloud connection, any CardDAV server through Settings -> Contacts. Every
field a phone keeps, the photo included, syncs both ways. Duplicates merge
with 90 days to undo; vCard and CSV import and export; contacts suggested
while composing; JMAP Contact/get, /query and /set. Nextcloud calendars come
with the Nextcloud connection too.
Replaces #237 and #109 by @jo2904 and #244 by @DanversKara.
Closes #157
Closes #236
Co-authored-by: jo2904 <jo2904@users.noreply.github.com>
Co-authored-by: DanversKara <DanversKara@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
* Install APCu and Xdebug with PIE instead of from PECL (#286)
PECL is deprecated in favour of PIE and was down for most of an evening on 2026-10-21, failing every image build. PIE 1.5.2 now installs APCu (and Xdebug in the dev image) from the extensions' own releases; it is mounted for that one step and not left in the image. Nothing changes at runtime.
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
* Keep contact photos where the web container can serve them (#287)
Contact photos were stored in var/contacts, outside the volumes the web and worker containers share, so synced photos answered 404. They now live under APP_STORAGE_DIR/uploads/contacts; a migration clears the lost paths and has synced books read again so the photos come back.
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
* Match a contact's email addresses in any case (#288)
Addresses are stored lower-cased but were matched as the provider spelled them, so a pull removed and re-inserted the same address and broke the unique key (prod sync failure). They are now matched in their stored form.
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
* Open a contact's reference email in the mail pane (#294)
A click on one of a contact's reference emails in the docked contacts
pane was a Turbo visit inside the contacts frame, which blanked the mail
pane. The click is now offered to the mail pane as a mail-pane:open
event; the pane opens the message beside the contact. Without a mail
pane (the full contacts page) it stays an ordinary visit.
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
* Open mail beside the full Contacts page without a reload (#297)
The full Contacts page was a page of its own, so a reference email
clicked there was a visit: the contact was gone, and on a notebook the
mailbox came back on the calendar it remembered.
The full page is now the mailbox with data-contacts-full on the shell:
the mail column put away and the contacts dock given the row, with the
page's own content in the dock's frame. Opening a message from it loads
the message into the existing mail pane, then contacts--pane#reveal
drops the state in a view transition: the mail slides in from the left
and the dock narrows to its width, the same DOM throughout. On a
notebook the calendar gives way; on a wide screen it stays.
The reference links lose data-turbo-frame="_top", which made ui--split
stash a calendar position for a visit that never came.
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
* Preview image and video attachments on click, like PDFs (#295) (#298)
Clicking an image or video attachment chip opens a viewer in the modal
instead of downloading. Previewable chips (PDF included) get a download
half on the right. Images load from the existing attachment route; videos
from a new /mail/attachment/{id}/video route that serves a fixed video
type inline under the sandbox CSP and answers Range requests.
Closes #295
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
* Erase a message's parts with it, and delete mail one way (#300)
MessageEraser loaded a message's parts to delete their files, then removed
only the message. After the delete Doctrine forgot the message but kept the
parts managed, so the next flush in the same unit of work failed with "a new
entity was found through the relationship MessagePart#message". Every sync
that removed server-deleted mail with an attachment failed that way.
The eraser now removes the parts, removes a thread it empties (announced to
JMAP as destroyed), and logs file-delete failures instead of keeping the row.
MessagePurger hands its local delete to the eraser, so "delete forever" gains
the thread-label re-sync it lacked. ImapAccountSyncer clears an entity manager
a failed folder left open, so one folder's refused flush no longer fails the
rest.
Closes #299
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
* Rework the contact editor: quick add, paste details and Improve with AI (#305) (#306)
New contacts start in quick add: paste a name, signature, address line or
vCard and the details it finds are listed, each with a one-click type. The
editor shows the name as one field whose parts fill themselves (titles and
degrees included), rows that appear as you type, Paste details for existing
contacts and a Save to picker. Contact help is a new AI feature, off by
default, that rereads pasted text and suggests updates from the signature
of a contact's newest mail. Phones are read with libphonenumber.
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
* Keep spam off the radar and out of search; add a Not spam button (#308)
Cards read out of mail in Spam are left off the Happening Soon panel and
the mailbox strip (read-time filter, so moving the mail out brings them
back), and a spam conversation no longer shows its cards above the
message. The bin keeps its cards. Search and the type-ahead leave Spam
out unless asked with in:spam, as they already did for the bin. A spam
conversation's toolbar gets a Not spam button that moves it to the inbox.
Closes #304
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
* Add to contacts asks new or existing; contacts button gets the calendar's three positions (#312)
Closes #309
Closes #311
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Show the pictures a review request hid, dropped or never asked for (#274)
* Show the pictures a review request hid, dropped or never asked for
A BAUHAUS review request, sent through Bazaarvoice, showed neither its
logo nor its product photos, with images allowed. The image proxy was
fine; three steps around it were not.
- The logo is linked as http://. The sanitizer keeps http sources and
leaves them to render time, the fetcher only connects over TLS, and
nothing in between upgraded the URL, so every http image became the
placeholder. RemoteContentBlocker now signs the https:// form. The
fetcher's own rules also say in the log which one refused a target;
until now that refusal was the one silent path.
- The first product card sits inside a <form>, and dropElement('form')
took the photo, the name and the star links with it. The form is
unwrapped now; textarea and select still go with their content.
- Each card is class="d" style="display:none", switched on with
.d {display:block !important}. CssToInlineStyles keeps an inline
property whatever the rule says, so the card stayed hidden next to its
Outlook twin. Html5CssInliner now lets a stylesheet !important beat a
plain inline style, and matches selectors against forms as the divs
the reader will see, so `form .a` rules take the sender's fallback
for clients without forms.
app:backfill safe-html-hidden-cards
re-sanitizes stored mail with a form or a stylesheet !important. One
task for both, because neither fix brings the card back alone.
Closes #271
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Mark the German mail page as translated from the current English one
Both pages gained the same two paragraphs in the previous commit, but
the translated-from hash still named the English page before them, so
DocumentationCoverageTest reported the translation as stale.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Credit the image proxy entry and keep it as a changelog fragment
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Paul Lützner <karatektus@gmail.com>
* Swipe a mail row, choose what each swipe does, and long-press for its actions on a phone (#314)
On a phone, below md and by touch only: a row swipes right for read/unread and left for archive, each direction set under Settings → Mail → Swipe actions (read/unread, archive, star, nothing; never trash), and holding a row opens an action sheet with every action its strip offers. Mouse, trackpad and wider windows are unchanged.
Takes over #117, #118 and #119, whose fork was archived. Also gives the Contacts settings' two cards their gap.
Co-authored-by: Flouss <15911342+Flouss@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
* Admin → Integrations lists the calendar services, so a pinned CalDAV address can be changed later (#279)
CalDAV and Calendar feed get their own group below the file services, with the shared provider row and Configure form; Nextcloud stays with the file services. Calendar services are offered to everyone, so their rows say so and the form has no switch. The server address accepts only http or https, up to 512 characters.
Once people have connected, the CalDAV form says how many connections a changed or cleared pin reaches. A calendar feed offers its address only while one is stored, so it can be cleared.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Offer an address book another connection already mirrors locked, not as new (#319)
Two connections to the same server, a Nextcloud one and a CardDAV one made by hand, reach the same address book under the same remote id. Settings → Contacts now offers such a book locked, saying which connection has it, and refuses a posted tick, as it does for calendars. Otherwise every contact in it showed twice.
Closes #317
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Gmailify: the IMAP folder places a sibling's mail, Gmail deletions detach, and a switch to turn it off (#204)
Closes #203
Closes #205
- A sibling's IMAP copy keeps the placement its folder gives it; Gmail's INBOX/SENT/DRAFT/TRASH/SPAM no longer move it (by @phdelodder).
- Gmail deleting its copy detaches the sibling's IMAP copy instead of erasing it.
- app:backfill gmailify-placement repairs copies the old merge had moved.
- Settings → Aliases: a Gmailify switch per Gmail account. Off detaches carried copies in a background job and declines mail Gmail still collects for another account (Bcc'd mail excepted, as documented).
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
* Gmail sync: look up only the listed ids, not every stored one (#321)
Part of #223: newGmailIds() asks only about the ids Gmail just listed, and nothing when none were.
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
* Store whether a conversation is in the bin, kept by triggers (#322)
Part of #223: message_thread.binned holds #268's rule, kept by triggers on message_label, thread_label and message; lists, badges and search read the column. Identical results on 41 compared queries, most 5-100x faster on a 67k-thread mailbox.
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
* Account sync: wait out a provider that can't be reached, quietly (#333)
When the worker couldn't connect to the Gmail or Graph API at all, the
account sync went up Messenger's retry ladder: a WARNING with three nested
stack traces per attempt, then CRITICAL and a parked job if the outage
lasted. That job resumes from a cursor and is asked for again every
quarter hour, so nothing was ever lost.
WaitOutConnectionFailures now puts a SyncAccountMessage that failed only
with an HttpClient TransportException back on its own queue, on the
ingest ladder's delays, with one info line per attempt. After five waits
it is dropped with a single warning. Message batches and every other
failure keep the normal path.
Closes #332
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
---------
Co-authored-by: Sven Joder <sj@pmdevelopment.de>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Philippe Delodder <7259755+phdelodder@users.noreply.github.com>
Co-authored-by: jo2904 <jo2904@users.noreply.github.com>
Co-authored-by: DanversKara <DanversKara@users.noreply.github.com>
Co-authored-by: Flouss <15911342+Flouss@users.noreply.github.com>
19 KiB
Connected calendars
plMail can mirror calendars you keep somewhere else — Google, Outlook, a CalDAV server, or anything
published as an .ics at an address — and, for the first three, send your changes back. It can also
move a calendar in and out as a file.
Everything on this page lives under Settings → Calendars, which has two halves: the list of your calendars, and Where calendars come from beneath it.
The provider-specific setup — which console, which client ID, which scopes — is on its own pages: Google, Microsoft, CalDAV, ICS feeds.
Where calendars come from
Two populations that look like one on screen and are nothing alike underneath.
Mail accounts that already carry calendars. A Gmail or Outlook account you added for mail can also see your calendars there, because plMail asks for calendar access on the same consent as mailbox access. There is no second sign-in, no second application to register, and no second thing to configure — the account shows a Find calendars on… button and that is the whole of it.
Connections made for calendars alone. A CalDAV server — Radicale, Baïkal, Fastmail,
iCloud, a box in a cupboard — or a calendar address, which is any published .ics.
Your Nextcloud connection. If you connected Nextcloud for files under Settings → Integrations, it is listed here too, with its own Find calendars on… button. The same app password reaches your Nextcloud calendars, so there is no address or password to type a second time. It is marked Set up in Integrations instead of offering a disconnect button: the connection holds your files as well, so it is disconnected there, and that confirmation says how many calendars go with it.
A calendar already mirrored through another connection — a CalDAV connection you made by hand to the same Nextcloud, say — is shown as Already mirrored through … and cannot be ticked a second time, so its events are not synced twice.
If the section says No account or connection here carries calendars yet, you have IMAP accounts and nothing else; connect a CalDAV server or subscribe to an address.
Picking which calendars to mirror
Find calendars on… lists everything the account or connection can see and asks you to tick the ones to keep in step. Ticking one creates a calendar here and fills it in within the minute; the one already marked Main is the provider's primary.
Unticking one deletes the copy held here. The screen says so, and adds the important half: anything on that calendar that came from your mail rather than from the calendar is moved to your default calendar first. That population is real — you can point a mail account's extracted events at a mirrored calendar, and from then on every booking read out of that account's mail lands there as the only copy in existence. Everything the remote actually gave you is deleted, because those are copies and the provider still holds the originals; re-tick the calendar and they come back within the minute.
Your default calendar cannot be unticked while it is the default: New events land here, so this one stays. Make another calendar the default first.
Nothing the browser posts describes a calendar. Only the ticked ids are read; the name, the colour, the time zone and — most importantly — whether the calendar accepts writes are re-read from the remote as the subscription is made.
Connecting a CalDAV server
Connect a CalDAV server asks for a name, a server address, a username and an app password.
The address can be the full CalDAV URL your server shows you, or just its domain — plMail asks the domain where its calendars live before giving up. The form opens prefilled with the domain of your own mailbox, which is a suggestion and nothing more.
Use an app password created on the server rather than your login password; iCloud and Fastmail accept nothing else. There is a checkbox offering to reuse the password from one of your mail accounts, which is off by default and worth leaving off — most servers want an app-specific password, and the address in the box above is one you typed and nothing has checked, so ticking it sends a password you gave plMail for your mailbox to whatever host is in that field.
Gmail and Outlook calendars do not come in this way. They arrive with the mail account.
Disconnect removes every calendar mirrored from that server, moving anything that came from your mail to your default calendar first, exactly as unticking does.
Subscribing to a published address
Subscribe to an address takes a single field: the address a calendar is published at, ending in
.ics. A webcal:// link works too — it is the same address under another name, and plMail rewrites
the scheme for you. webcals:// likewise.
This is how you follow public holidays, a fixture list, a colleague's published availability, or the "secret address in iCal format" that Google and Outlook hand out for a calendar they will not give API access to. Leave the name empty and the calendar names itself.
Two things the form says and means:
- A subscribed calendar is read-only. It is a copy of a published file; there is no method that would write to it and no server that would accept one.
- Anyone holding the address can read the calendar, so treat a secret one like a password. Nothing renders the address back once it is stored.
The feed is fetched exactly once while you wait, which is why a subscription that fails leaves nothing behind — there is no credential to correct and no second field to change, so a broken row you would have to notice and delete would only be in your way. The failure worth expecting: a Subscribe button copies a link to a web page about as often as it copies a link to a calendar file, and the two are indistinguishable until something tries to parse one.
What two-way sync carries
For Google, Microsoft and CalDAV, changes travel in both directions. What crosses is the meeting itself: title, start and end, time zone, all-day, location, description, status, the recurrence rule with every per-occurrence change filed against it, participants, and reminders. A booking taken through one of your booking pages, and an event you created on two calendars at once, both travel like any other event.
What does not cross:
- Anything about how plMail draws it. A calendar's colour, its visibility, its position in your list, whether it is your default — those are yours.
- Where an event came from. plMail records whether an event was typed, read out of a message, mirrored, or booked; a provider has no field for that and nothing carries it back.
- Attachments on an event.
Two more limits are provider-specific and are covered under
Things that bite: clearing every reminder does not clear Google's, and a
subscribed .ics feed is one-way by construction.
Which side wins
The rules are short and they are worth knowing, because "changes sync back" is only worth having if it is trustworthy.
- Your changes go out first, always. Every pending local change is pushed before anything is pulled. So the pull that follows asks "did the remote change too?" of a remote that has already been told, and the ordinary case — you edited here and nobody else touched it — is not a conflict at all.
- An unchanged remote copy is not applied. If the remote's version marker matches the one stored, nothing is written at all. This is what stops the pull straight after a push from re-applying the remote's echo of your own edit over something you typed in the meantime.
- A changed remote copy wins. Not last-write-wins — there is no clock the two sides share, so comparing timestamps is comparing two guesses. The remote wins because it is the copy other people can also see: losing an edit you made on your phone is recoverable by making it again, and diverging from what an organiser and four attendees are looking at is not.
- When both sides changed, the local change is discarded — loudly. Rule 1 means this only happens when the push for that event failed. The discarded version is written to the log in full before it is overwritten, so there is something to look at afterwards.
- A read-only calendar is never pushed to. Your edit stays local, and the run says so once.
A calendar's row in settings shows when it last synced, or Not synced yet, and carries the last error when there was one.
Push, and why a self-hosted install usually polls
Connected calendars are swept every fifteen minutes. Google and Microsoft can also push — tell plMail the moment something changes — and both notifications say only "something in this calendar changed", so a webhook does exactly one thing: ask for a sync of the calendar it names.
Push needs a publicly reachable HTTPS address, because that is where the provider calls back.
plMail checks this itself rather than letting the provider refuse: the configured public URL must
start with https:// and must not be localhost, 127.0.0.1 or ::1. A great many self-hosted
installs fail that on purpose, and that is fine — push is never load-bearing. A calendar that
cannot register a channel is a working calendar fifteen minutes behind, and there is no arrangement
in which refusing to sync because push could not be registered would be better.
Google additionally requires the callback host to be verified in the Cloud project that owns the OAuth client — verify it in Search Console, then add it under Domain verification in the Cloud console. Until that is done, every registration is refused. It is at least visible: a warning in the log at registration time, rather than a channel that silently never delivers. Microsoft has no equivalent step. See Google and Microsoft.
CalDAV servers and .ics feeds have no push at all and are always polled. Neither is a problem.
Channels are registered and renewed by an hourly sweep rather than at the moment you tick a calendar. That is deliberate: registration fails for reasons that have nothing to do with your click — no public address yet, a domain verification still pending — and tied to the subscribe screen alone, those calendars would never get push until somebody thought to unsubscribe and re-subscribe them. Driven from a sweep, the same install starts pushing within the hour of the underlying problem being fixed, with nobody touching anything. Ticking a calendar does ask once, immediately, and gives up quietly, which is what removes the first hour.
Some Google calendars can never be watched — a country's holidays, birthdays drawn from Contacts, week numbers. Google refuses those permanently, plMail remembers the refusal, and the calendar simply reads as polled.
Syncing by hand
Each calendar row has a Sync now button, which answers Syncing now — this calendar will update in a moment. From a terminal:
# every connected calendar
docker compose exec php php bin/console app:calendar:sync
# one of them
docker compose exec php php bin/console app:calendar:sync 12
# only those due — this is what the scheduler runs, every fifteen minutes
docker compose exec php php bin/console app:calendar:sync --stale
And for push channels:
# register and renew whatever needs it — the scheduler runs this hourly
docker compose exec php php bin/console app:calendar:push
# re-register everything regardless of expiry
docker compose exec php php bin/console app:calendar:push --force
# tear the channels down and go back to polling
docker compose exec php php bin/console app:calendar:push --stop
app:calendar:sync dispatches the work to a background worker rather than doing it in the console
process, so a sync you run by hand behaves exactly like a scheduled one — including its retries.
app:calendar:push reports "staying on polling" rather than failing, for the reason above.
Calendars as files
Export
Every calendar row has a Download as .ics button, and every event has Download .ics in its editor. An exported event is the whole series, not the one occurrence you clicked.
A whole-calendar download is streamed one meeting at a time, so a decade of a busy calendar costs one meeting of memory rather than ten years of it — with the visible consequence that the browser shows an indeterminate progress bar, because the size is not known until the last event has been written.
The file is a calendar object, not an invitation: no METHOD is written, deliberately, so
downloading your own calendar does not make some clients try to send four hundred meeting requests on
your behalf.
Import
Import a calendar file on the settings page reads an .ics — an export from another calendar, or
an invitation somebody sent you — onto a calendar you pick. Files up to 4 MB.
Only calendars that accept new events are offered. If you have nothing but mirrored calendars, the picker says so: None of your calendars accepts new events. A mirrored calendar is a read-only copy; make one of your own first.
Import has one rule that is not obvious, and the form states it:
An event already on this calendar is updated rather than added twice, and one already on another of your calendars — from an invitation, or from a calendar you mirror — is left where it is.
So three outcomes, and the middle one is the surprising one. On the target calendar under the same identity: updated, which is what makes export-then-import a round trip rather than a duplication. On a different calendar of yours: skipped and counted, because you picked this calendar and rewriting a row on another one is not what you asked for — least of all when the other one is a mirror, where the rewrite would be pushed out to the provider. Nowhere yet: created.
The result is reported as N added, N updated, N skipped, or, when there was nothing to do, Nothing to add — every event in that file is already on your calendars. A file plMail cannot read as a calendar says so rather than importing zero events silently.
Two things an import deliberately ignores: the revision number in the file, and anything you have dismissed. You chose this file and pressed a button — refusing half of it because the exporter wrote a lower revision than the original invitation, or leaving out the one event you were looking for because you once dismissed it, would both be the import quietly doing nothing where it matters most.
The same meeting arriving twice
One meeting can reach plMail by two honest routes at once: extracted from its invitation onto your default calendar, and mirrored from the provider onto a connected one. Both rows are correct, and plMail does not delete either. The duplication is answered on screen instead.
Two entries are treated as the same meeting when they share an identity and a start instant. Identity is the UID the organiser assigned — not the title and the time, because matching on those would collapse a weekly one-to-one held with two different people at the same hour into a single entry, and a meeting quietly disappearing from a calendar is the worst shape a calendar bug takes.
They are drawn as one entry only while they agree about the five things you can see: start, end, title, all-day, and whether it has been called off. Agreeing, you get one entry showing both calendars' colours, saying On Work, Personal, opening one editor with both ticked. Disagreeing, the group splits and you get an entry each — deliberately, because a merged entry that quietly picked a winner would hide a real disagreement (an update that reached one route and not the other) behind a tidier screen.
Recurrence is not one of the five. Two copies where one repeats and the other does not agree about the one occurrence they share and about nothing else, so that occurrence merges and the repeating copy draws its own entries on every later day — which is itself the visible signal that the two differ.
Things that bite
Unticking a calendar deletes the copy held here. It is not "stop syncing and keep what I have". Events the remote gave you go; events that came from your mail are moved to your default calendar first. Re-ticking pulls the remote's events back.
An unpushed local edit is lost when you unsubscribe. An event carrying both a remote id and a change that has not gone out yet is deleted with the rest, so that change never reaches the provider. The window is one sweep — fifteen minutes — and it is logged. Wait for a sync before unsubscribing if you have just edited something.
Google's consent screen lets you untick calendar access while granting mail. The account then syncs mail perfectly and answers every calendar call with a refusal. The Find calendars screen says so rather than showing an empty list: Google's consent screen lets you untick calendar access while allowing mail. Reconnect the account and leave the calendar box ticked.
On Microsoft, the calendar permission may be missing from the app registration. Mail keeps working without it; calendars do not. The same screen says so, and it is an administrator's fix — see Microsoft.
The first read of a Google calendar goes back one year, not forever. A calendar in use for a decade holds tens of thousands of events, and an unbounded first read would fetch every one of them to fill a local table with meetings from 2016 that no view will ever show. Forward, it is unbounded.
A Microsoft calendar is synced over a window, not in full. Graph offers change tracking on exactly one surface, and it is a bounded one: a year back and two years forward, matching the range plMail draws occurrences into. Events outside that window are not mirrored.
Removing every reminder does not clear the reminders at Google. See Reminders.
A .ics subscription re-downloads the whole file whenever it changes. There is no delta feed for a
published file, and a file states what exists rather than what was removed — so a feed that has
changed is read again from scratch, which is what stops cancelled fixtures accumulating forever.
Unchanged, it costs a conditional request and no body at all, which is why polling a holiday feed
every fifteen minutes is free. Feeds are read up to 8 MB.
An import onto the wrong calendar cannot be redirected afterwards. Events already on another of your calendars are skipped rather than moved, so importing the same file onto a second calendar adds nothing there.
Two entries for one meeting means the two copies disagree, not that deduplication failed. Look at the start, the end, the title, the all-day flag and whether one of them is cancelled — one of those differs. Open either and save it with both calendars ticked to bring them back together.
Related: Calendar · Invitations and events from mail · Reminders · Sharing and booking
Setting up a provider: Google · Microsoft · CalDAV · ICS feeds
How it works: The sync engine — the driver contract every provider implements, push channels, and how duplicates are resolved.