Files
13303b247e v0.3.9 milestone (#283)
* 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>
2026-10-23 11:18:21 +02:00

19 KiB

Google

Gmail and Google Calendar both reach plMail through one OAuth application that you register in the Google Cloud console. Google stopped accepting plain passwords for Gmail, so this registration is not optional if you want a Gmail account in plMail — and because it is your Cloud project rather than one operated by this software, everything on this page is something you do once, as the administrator of your own installation.

Three things come out of that project, and they are worth separating before you start, because people routinely set up the first and then wonder why the third does not work:

What Buys you Needed?
An OAuth client Signing in, reading and sending mail, and calendars Yes, for any Gmail account
A Cloud Pub/Sub topic and subscription Gmail arriving the instant it is sent Optional; without it Gmail is checked every fifteen minutes
A verified domain Calendar changes arriving as they happen Optional; without it calendars are read every fifteen minutes

Everything under Settings that this enables is described in Accounts and aliases and Connected calendars.

The Cloud project and the APIs on it

  1. Open console.cloud.google.com and create a project, or pick an existing one. The project is the unit everything else here belongs to: the OAuth client, the consent screen, the Pub/Sub topic and the domain verification all live inside it, and using two projects for two halves of this is the most common way to end up with a consent screen that grants a scope the API has not been enabled for.
  2. Under APIs & Services → Library, enable the Gmail API. Without it every call plMail makes after a successful sign-in is refused, which presents as an account that connects and then never syncs.
  3. Enable the Google Calendar API on the same project if you want calendars. This is the step the admin screen means when it says there is nothing to set up on the plMail side — calendar access is bought entirely in this console.
  4. Enable the Google People API too if you want contacts. It is the same kind of step for the address book: without it, mail and calendars work, and each user's Google address book in Settings → Contacts says that the administrator needs to enable the People API. Once it is enabled, contacts sync on their own; nobody has to sign in again.

Note your project ID while you are here. It is lower-case and may differ from the display name you typed; the Pub/Sub topic below is named with the ID, not the name.

Under OAuth consent screen, choose Internal if you have a Google Workspace organisation and everyone signing in belongs to it, otherwise External. Fill in the application name and support email. This is the screen your users read before they hand plMail access to their mailbox, so the name you type is the name they will see; on a single-user install it is still worth making it recognisable, because it is also what appears in the account's Google security settings afterwards.

Add the scopes listed in the next section. An External project starts in Testing status, where only the accounts you list as test users may consent at all — which is the normal and correct state for a self-hosted install serving a handful of people. Add yourself, and anyone else who will connect a mailbox, as a test user.

The OAuth client and its redirect URI

Under Credentials, create an OAuth client ID of type Web application, and add this to its Authorised redirect URIs:

https://your-domain/oauth/google/callback

That path is a route in plMail (/oauth/{provider}/callback with google as the provider), and Google matches it character for character against the address the browser was sent from — a trailing slash, http instead of https, or the wrong hostname all fail with redirect_uri_mismatch before plMail sees the request at all. You do not have to transcribe it: Admin → Integrations → Mail sign-in → Gmail sign-in renders the exact URI for your installation with a copy button beside it, built from your public address, and the setup wizard shows the same thing. The screen's own warning is worth repeating — the URI cannot be changed later without breaking every connection already authorised against it.

Copy the client ID and client secret. They can go in either of two places:

  • Admin → Integrations → Mail sign-in → Gmail sign-in, which stores them in the database.
  • GOOGLE_OAUTH_CLIENT_ID and GOOGLE_OAUTH_CLIENT_SECRET in the environment, which is what an older installation will already be doing. See the configuration reference.

A stored value wins over the environment, and only when it is actually set — a half-filled row cannot shadow a working environment variable. The form refuses a client ID with no secret behind it, because that combination looks configured and fails at the consent screen.

The scopes, and what each one is for

plMail asks for these five at consent time, for every Gmail account:

Scope What it is for
https://mail.google.com/ The mailbox itself: reading messages, sending, applying and removing labels, moving and deleting. This is full mailbox access, and it is what a mail client is.
https://www.googleapis.com/auth/calendar Calendars on the same account, read and write.
https://www.googleapis.com/auth/contacts Contacts on the same account, read and write, through the People API.
openid Identifies the account the token belongs to.
email Supplies the address, which is what plMail names the account after.

The calendar scope is read-write rather than read-only on purpose. Subscribing to a calendar is only half the feature: an event you edit in plMail is written back, which means creating, updating and deleting on Google's side, and none of that is possible with a read-only grant.

The authorisation request also carries prompt=consent and an offline access type. Those are not scopes; they are what makes Google return a refresh token. Without one, the connection works until the first access token expires and then stops, and the only repair is reconnecting the account.

Calendar is on the same sign-in as mail

There is no second connection, no second OAuth application and no second consent screen for calendars. The calendar scope is requested alongside the mail scopes on the one grant, so as the administrator you tick an extra box in this console and there is nothing else to configure — which is exactly what the admin screen tells you: "Nothing to set up here. Enable the Google Calendar API for this project and add the .../auth/calendar scope to the consent screen — users then get their calendars with the same sign-in they already use for mail. Without it, mail keeps working and calendars simply do not appear."

The cost of putting both on one grant is that the consent screen asks for more up front, and Google's consent screen lets a user untick an individual permission. A token can therefore come back with mail access and no calendar access. plMail does not try to detect that at grant time; it finds out when a calendar call is refused, and says so in the calendar settings — "Google's consent screen lets you untick calendar access while allowing mail. Reconnect the account and leave the calendar box ticked."

Contacts are on the same sign-in too

The contacts scope rides the same grant, for the same reasons. A Google account connected to plMail brings its contacts with it: its address book appears under Contacts beside plMail's own, with Google's contact groups as tags, and an edit made in plMail is written back — names, phonetic names, organisation, birthday and other dates, relations, websites, chat and SIP addresses, custom fields and the photo, as well as addresses and numbers — and from Google to the phones that sync with it. Only the fields plMail shows are sent, so what Google holds beyond them (occupations, interests, an organisation's symbol) is left as it is. See Contacts.

An account connected before plMail asked for contacts holds a grant without the scope. Nothing is wrong with it: mail and calendars carry on, and Settings → Account health shows a notice, Connect … again to sync its contacts, whose button signs in once more and adds the permission. Its address book appears as soon as that is done.

Google classes the contacts scope as sensitive. That is a lighter class than the mail scope's restricted, which an installation already has to live with (see below), so it changes nothing about verification.

What "restricted scope" verification means here

https://mail.google.com/ grants access to the whole mailbox, and Google treats scopes of that reach as restricted: an application that requests one and is published goes through Google's verification review, and the same is true of the full drive scope plMail's Google Drive integration uses. The app's own Drive setup notes say it plainly — Google requires app verification before users outside your test list can consent.

For a self-hosted install the usual advice is to leave the project in Testing, add the handful of addresses that will connect as test users, and click through the "unverified app" interstitial. That works, and it has one cost that catches everybody: Google expires refresh tokens issued by an app in Testing after about a week, so the account stops without warning every seven days and has to be reconnected. Forever.

Publishing status and verification status are two different things, and this is the distinction worth knowing, because conflating them is what makes people accept the weekly reconnect as the price of not being verified. Setting the consent screen's publishing status to In production does not submit anything for review. An app published without verification still works — users see a larger warning screen the first time they consent, and for scopes of Gmail's reach Google caps how many users may consent at all. For an install whose users are you and your household, that cap is not a constraint and the warning screen is a one-off.

So: if the weekly reconnect is annoying you, publish. Verification is the separate, heavier step, and it is only worth it for an install with real users who are not you.

plMail will tell you which of these you are in. When a sign-in dies it records how long it lasted, and a grant that lasted about a week gets a card that names this cause rather than the generic "sign in again" — and once it has seen the pattern once, it warns a day or two before the next one runs out. See Account health.

Instant Gmail delivery, via Cloud Pub/Sub

Gmail does not call a webhook directly. It publishes to a Cloud Pub/Sub topic, and Pub/Sub pushes from there to plMail, so instant delivery needs two cloud resources and a shared secret. This is one-time setup for the whole installation, not per account.

Create the topic:

gcloud pubsub topics create gmail-push --project=YOUR_PROJECT_ID

Let Google's mail service publish to it. This grant is what makes Gmail able to tell your topic anything at all:

gcloud pubsub topics add-iam-policy-binding gmail-push \
  --project=YOUR_PROJECT_ID \
  --member=serviceAccount:gmail-api-push@system.gserviceaccount.com \
  --role=roles/pubsub.publisher

Pick a secret of your own, then create the push subscription that delivers to plMail:

gcloud pubsub subscriptions create gmail-push-sub \
  --project=YOUR_PROJECT_ID \
  --topic=gmail-push \
  --push-endpoint="https://your-domain/gmail/push?token=YOUR_SECRET"

POST /gmail/push is the route that receives these. The token in the query string is the only thing authenticating the endpoint — anyone on the internet can POST to it — so plMail compares it in constant time and answers 403 to anything that does not carry it. It fails closed: with no token configured, every notification is rejected.

Finally, tell plMail the same two values, either in Admin → Integrations → Mail sign-in → Gmail sign-in (fields Pub/Sub topic and Push verification token) or in the environment:

GMAIL_PUBSUB_TOPIC=projects/YOUR_PROJECT_ID/topics/gmail-push
GMAIL_PUBSUB_VERIFICATION_TOKEN=YOUR_SECRET

The stored values win and fall back to the environment, exactly as the OAuth credentials do.

Then turn on Instant delivery for the account under Settings → Mail accounts. It is per account and opt-in: without the topic configured the account stays on the fifteen-minute schedule and says so rather than failing.

A Gmail watch lasts at most seven days. plMail renews any watch expiring within the next day, from a nightly sweep, so there is nothing to maintain by hand. Admin → Gmail webhooks is where to look when mail is not arriving instantly: it shows the topic, the exact endpoint the subscription must POST to — token included — the watch expiry, the last push received, and why any notification was refused.

Calendar push: watch channels, not Pub/Sub

Calendar push is a completely different mechanism from the one above, and none of the GMAIL_PUBSUB_* configuration applies to it. plMail opens a watch channel on the calendar's events, which is a plain webhook: Google POSTs to an address you own whenever anything in that calendar changes. An installation with no Pub/Sub at all can have calendar push, and an installation with Pub/Sub working perfectly can have no calendar push.

Gmail push Google Calendar push
Mechanism users.watch publishing to a Pub/Sub topic events.watch channel, a plain webhook
Endpoint POST /gmail/push?token=… POST /webhook/google/calendar
Proof it is genuine the token in the URL the channel token in X-Goog-Channel-Token
Cloud resources a topic, a publisher grant, a subscription none
Extra requirement — the callback domain must be verified
Lifetime seven days a week, whatever Google grants

Two things have to be true before a channel can be opened:

A publicly reachable HTTPS callback. The address is built from your configured public URL, never from the incoming request, because the process that registers channels is a scheduled command with no request to infer a hostname from. An address that is not https://, or that resolves to localhost, is refused locally before a single API call is made. See Behind a reverse proxy.

Domain verification in the Cloud project that owns the OAuth client. Verify the domain in Search Console, then add it under Domain verification in the Cloud console. Until you do, every events.watch is refused. This is the one thing about this feature you cannot discover from inside plMail, which is why the admin screen states it: "Google will not open a watch channel unless the callback domain is verified in the Cloud project that owns the OAuth client… Microsoft needs no equivalent step."

Neither of these breaks anything. A calendar that cannot register a channel stays on the fifteen-minute sweep, which is a working calendar that is at most fifteen minutes behind. The admin screen says the same: "Without a public HTTPS address, connected calendars simply stay on the fifteen-minute sweep." Registration failures are logged as warnings, not errors, and the scheduled app:calendar:push sweep retries every hour — so an install that fixes its address or completes its domain verification starts pushing within the hour, with nobody clicking anything.

You can also ask for it immediately:

docker compose exec php php bin/console app:calendar:push

Calendars Google will not let you watch

Some calendars Google serves are generated rather than stored — a country's public holidays, the birthdays drawn from Contacts, week numbers — and events.watch refuses every one of them with pushNotSupportedForRequestedResource. That is a permanent fact about the calendar, so plMail records it on the calendar, logs it once at info level, and stops asking. The calendar is polled from then on and reads as polled in the interface, because that is exactly what it is.

Things that bite

The project ID is not the project name. It is lower-case, may carry a numeric suffix, and is what belongs in GMAIL_PUBSUB_TOPIC. A topic named after the display name resolves to nothing and the watch registration fails with a message about a topic that does not exist.

The redirect URI must match exactly, and cannot be changed afterwards. Copy it from Admin → Integrations rather than typing it. Changing it later breaks every connection already authorised against it, which means every user reconnecting.

A consent screen without prompt=consent returns a refresh token only once. plMail always sends it, so this bites the other way round: if you build your own authorisation URL while debugging and omit it, the account connects, works for an hour, and then cannot refresh.

Calendar access can be missing on an account whose mail works perfectly, because Google's consent screen lets the user untick it. The symptom is an account that appears under Where calendars come from with no calendars on it. Reconnect and leave the box ticked.

Enabling the Calendar API is not the same as adding the calendar scope, and doing one without the other fails in two different ways: the scope without the API is a 403 on every calendar call, and the API without the scope is a token that was never granted calendar access. The same goes for the People API and the contacts scope.

Domain verification is per Cloud project, not per Google account. Verifying the domain in Search Console is only the first half; it also has to be added under Domain verification in the same Cloud project that owns the OAuth client, or events.watch keeps being refused with the domain apparently verified.

The first notification on a new channel is a handshake, not a change. Google sends X-Goog-Resource-State: sync the moment a channel opens, meaning only "the channel is open". plMail ignores it deliberately — acting on it would queue a full calendar read for every registration and every weekly renewal in the install.

An app left in Testing expires its refresh tokens after about a week. The account keeps working until then and stops afterwards; reconnecting fixes it until the next time. This is Google's policy for apps in Testing, not something plMail can work around — but plMail does recognise it, because the error Google returns (invalid_grant) is the same one it returns for a revoked consent and a changed password, and only the interval tells them apart. A sign-in that lasted about a week gets a card saying so, and after the first occurrence Account health warns a day or two before the next one. The fix is to publish the consent screen, which is not the same as verifying it.

The Pub/Sub token has to match character for character. plMail rejects notifications that do not carry it, and the rejection is logged rather than silent — Admin → Gmail webhooks will show refused notifications, which is the difference between "Pub/Sub is not reaching us" and "Pub/Sub is reaching us and being turned away".