Files
pl_mail/docs/install/troubleshooting.md
T
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

15 KiB

Troubleshooting

What /healthz means, how to tell a stuck queue from an empty one, where the logs actually are, and the failures that have already happened to somebody here.

/healthz

GET /healthz
{
  "status": "ok",
  "checks": { "database": true, "queue": true, "workers": true }
}

Unauthenticated, because Docker healthchecks and uptime monitors hold no session — and therefore deliberately vague. It answers only what such a caller could infer by trying the app anyway: no counts, no addresses, no account names, no version. /admin is where the numbers live, behind ROLE_ADMIN, and a test asserts this shape so an addition that leaks something fails the suite.

Field Meaning
status ok with 200, or error with 503
database whether the database is reachable. The only thing that sets the status code
queue true while fewer than 5000 messages are pending across all queues, false past that, null when it could not be determined
workers true when every process that has ever reported a heartbeat is still beating, false when one is stale, null when there are no heartbeats at all

503 only when the database is down, because that is the one failure where serving is impossible. A backed-up queue stays 200 on purpose: mail is late, not gone, and restarting the container would not help — it would take a working instance out of rotation and lose the in-flight work as well. The 5000 threshold is generous for the same reason a first sync of a large mailbox legitimately queues thousands of jobs and must not read as an outage.

null is not false. A monitor should be able to tell "the queue is backed up" from "I could not look", and an install that has never run a worker has no heartbeats — nothing has failed, there is simply nothing to report yet.

The image's HEALTHCHECK points here, with a 60-second start period. It used to probe Caddy's metrics port, which answers as soon as the web server is listening — well before PHP can reach the database — so a stack with an unreachable database reported itself healthy and depends_on: service_healthy waited for nothing. The worker services have no HTTP server and disable the healthcheck entirely; their liveness reaches this same endpoint through heartbeats.

The failure mode is alerting on the status code alone. A dead scheduler and a stalled queue both answer 200. Watch the checks object, not just the HTTP status.

The queue

Eight transports on the Doctrine transport, consumed by seven processes in the worker container:

Queue Process What is on it
export worker-export Anything leaving plMail — sends, flag pushes, Gmail label changes, mail from the notifier. The only queue somebody is waiting on
ingest worker-ingest Fetching mail that is arriving now and nothing else: account and mailbox syncs, Gmail and Graph message batches, calendar syncs
ingest_backlog worker-backlog A first import: pages of a mailbox's history, newest first. Long while a large account is being brought in, and that is the design — nothing arriving now waits on it
enrich_live worker-live What follows mail that has just arrived on an account whose first import is over: sorting by the assistant, event extraction, insight cards, read receipts. Its own process, so new mail is never behind an import
release worker-release Ends the wait for mail held for the assistant. Nearly always empty; its own process so nothing is ever in front of it
enrich worker-enrich The same work for recent mail brought in by an import, and search indexing
enrich_backlog worker-enrich Old mail being sorted by the assistant after an import. Only touched when enrich is empty
maintenance worker-maintenance Backfills, rule runs over existing mail, admin "run now" buttons, calendar push registration
bulk worker-bulk Whole-view mark-read and archive runs. Somebody is watching an indicator for these, so they do not queue behind a backfill
async worker-maintenance Retired. Drained so envelopes queued before the split still have a consumer; nothing routes here any more

Four processes rather than four transports on one worker, because a worker already inside a long handler cannot pick up anything else however the queues are prioritised — which is why pressing Send used to wait for a Gmail batch to finish.

From a terminal:

docker compose exec php php bin/console messenger:stats
docker compose exec php php bin/console messenger:failed:show

The queue panel under Admin is the better view when mail stops arriving, because it distinguishes the two states a count cannot: it names the messages a worker is holding right now — the handler, its payload and how long it has been held — above the list of everything still waiting. A queue that is stuck therefore looks different from a queue that is merely deep. See Administration.

Retries are bounded and differ per queue: export gives five attempts over roughly a minute and a half, because its failures are a relay refusing a connection and somebody is watching; ingest and maintenance give five attempts over about eight and a half minutes, because theirs are third-party rate limits that take minutes to clear. What exhausts them lands in the failed transport, which the admin panel can retry or purge.

The failure mode is a queue with no consumer. Nothing errors — messages accumulate and the app looks fine. This has happened: a dev stack whose override still defined the pre-split messenger-worker ran a worker consuming only the retired async queue, so sends and syncs piled up with no error anywhere.

The scheduler

The recurring jobs live in App\Infrastructure\Scheduler\MaintenanceSchedule and are dispatched by one container consuming scheduler_default. Without that container, none of them fire.

Cadence Command
every minute app:mail:wake-snoozed
every minute app:calendar:alerts
*/15 app:mail:sync
7-59/15 app:calendar:sync --stale
11-59/15 app:contacts:sync --stale
hourly at :20 app:calendar:push
daily 03:50 app:calendar:materialise
daily 04:00 app:push:renew --repair
daily 04:30 app:monitoring:prune
hourly at :37 app:updates:check
Sundays 05:00 app:prune:blobs
docker compose exec php php bin/console debug:scheduler

The schedule is stateful, so a worker that was down over a scheduled run catches up when it comes back rather than skipping the day — and only the last missed run is replayed, because these are all idempotent sweeps and running yesterday's backlog five times over would be waste. Times are spread across the small hours rather than stacked on midnight because they share one worker.

The failure mode is the symptom list, not an error. No mail poll, no snooze ever waking, no calendar sync, no reminders, no log pruning, no blob cleanup — all at once, and all silent. This is the state the project was in before the scheduler existed, with logs and orphaned blobs growing without bound. If several unrelated things "stopped working", check for this container first.

Workers and heartbeats

Liveness is heartbeat-based rather than process-based, because the web container cannot see into the others. Each long-running process writes a row keyed by APP_CONTAINER_NAME, and staleness is per type:

Process type Stale after
imap-idle 2100s — just over the 29-minute IDLE reissue
imap-supervise 300s
messenger-worker 120s — the listener beats every 30s
anything else 600s

A row four times past its threshold is reaped entirely, which is wide enough that a briefly wedged process shows up red on the dashboard before it disappears.

Workers exit on --time-limit=3600 and are restarted by Compose, so a heartbeat gap of a second or two around the hour is normal. Admin → System → restart workers asks all of them to exit at the end of their current message; the app has no Docker socket and does not need one, because restart: unless-stopped means exiting is restarting.

The failure mode is APP_CONTAINER_NAME being unset. The key falls back to the hostname, which changes every time a container is recreated, so the dashboard fills with dead workers that were never really dead.

Where the logs are

Container output. In prod, Monolog writes JSON to php://stderr, so everything is in docker compose logs:

docker compose logs -f php
docker compose logs -f worker

Everything that is not the web server logs through the worker: the queue consumers, the scheduler, the IMAP supervisor and the hub. Each JSON record names the process it came from, so a filter on worker-ingest or scheduler finds one of them.

The main handler is fingers_crossed at error with a 50-message buffer, which means routine info lines are discarded until something errors — and then the 50 preceding messages are flushed with it. That is why an error in the log usually arrives with its own context attached, and why you will not find a quiet trace of an operation that succeeded. Deprecations go to stderr on their own channel. In dev the same output goes to var/log/dev.log.

The admin log browser. A second handler writes into the database, and Admin → Logs searches it: 100 entries a page, filterable by minimum level from info to critical, with the container name on each row. The minimum level kept is APP_DB_LOG_LEVEL, warning by default. Anything at warning or worse that nobody has read outlines the user menu — amber or red — on every page, for administrators only. Opening the browser is what marks them seen.

app:monitoring:prune keeps 14 days of log entries and 30 days of heartbeats by default, nightly. It also removes finished background jobs after 30 days (--job-days=N) and trusted devices once they have expired.

The failure mode is looking for an info-level trace in production. fingers_crossed means it was never written unless an error followed it. Lower APP_DB_LOG_LEVEL to info temporarily if you need the database side to keep more, and put it back — at debug the table grows quickly.

Diagnosing a specific account

docker compose exec php php bin/console app:mail:test-connection      # IMAP/SMTP probe
docker compose exec php php bin/console app:imap:test --account=ID    # connection and folder listing
docker compose exec php php bin/console app:graph:diagnose            # what Graph actually permits
docker compose exec php php bin/console app:mail:sync ACCOUNT_ID      # dispatch a sync by hand

For Gmail push specifically, the admin area's Gmail webhooks panel shows the topic, the exact endpoint Pub/Sub must call, and why any notification was refused.

The failure mode is testing from the wrong container. These commands are fine through docker compose exec php, which loads the generated secrets through composer's autoload files. A bare docker run against the image is not — it has no secrets volume, so it holds a different encryption key and cannot read a single stored credential.

Failures that have actually happened

Symptom Cause
Attachment downloads 404, blob data vanishes when a container is recreated The blob directories are not on a shared volume. See Docker Compose
Three of six containers never start, logs show column … already exists Concurrent boot migrations. Fixed by the advisory lock in app:db:migrate; if you see it now, something is running doctrine:migrations:migrate directly
A container refuses to start: "APP_ENCRYPTION_KEY cannot decrypt the credentials already stored" A service missing the app_secrets mount, or a key that changed under a running stack
The entrypoint aborts before starting anything, after a message about ACLs An old image: a setfacl failure on ZFS, NFS or a Docker Desktop share under set -e. The entrypoint has not set ACLs since 0.3.0
Every start takes a minute or more before the web server answers, and the log is silent after "Statement statistics are enabled" An image older than 0.3.0 running as root on slow disks: the entrypoint set ACLs over var/cache at every start, which copies every cached file into the container's layer. Update. A further thirty seconds went on waiting for the database's first health probe — if you keep your own compose file, give the database health check interval: 5s
A stack reports itself healthy while the database is unreachable The old healthcheck probed Caddy's metrics port. /healthz replaced it
Everything works but nothing ever updates by itself MERCURE_PUBLIC_URL pointing somewhere the browser cannot reach. See Behind a reverse proxy
Gmail push returns 403 for every notification GMAIL_PUBSUB_VERIFICATION_TOKEN unset or not matching the ?token= on the Pub/Sub subscription. It fails closed
Calendar push never registers, warning at registration time APP_PUBLIC_URL not HTTPS, or a loopback host, or a Google Cloud project whose domain verification is pending
No reminders at all, for anyone, once a minute One account whose display address was an IMAP username rather than an address threw while the alert mail was being built, ending the whole sweep and losing the alerts already claimed in that batch. Fixed at both ends — the message is built inside the channel's own try, and the deliverer survives a channel that throws anyway
Sends and syncs queue and nothing drains them, no error anywhere A worker consuming a queue nothing routes to. Check messenger:stats against the four transports above

The failure mode common to most of that table is silence. Almost none of these produce an error on a page; they produce an absence. That is why the admin panel reports queue depth and heartbeats rather than only errors.

Things that bite

A 200 from /healthz means "keep serving", not "everything works". Only the database decides the status code. Every worker can be dead behind a 200 — deliberately, because the app still serves mail that is already synced.

workers: null on a fresh install is correct. Nothing has beaten yet. It becomes meaningful only after the workers have run once.

The queue threshold is 5000 for a reason. Lowering it to catch problems earlier means a first sync of a large mailbox reports itself as an outage and an orchestrator restarts a healthy container.

A missing scheduler container is invisible in every panel that shows errors. Nothing fails; things merely never happen. debug:scheduler and the heartbeat list are where it shows.

app:reset --full is not a troubleshooting step. It goes back to first-run state: every table, every user, the stored files. It exists so an install whose unreadable data is genuinely expendable can start over, and the encryption-key probe deliberately allows it to run — see CONTRIBUTING.

Logs from docker compose logs php are only the web container's. The interesting line is usually in whichever worker process owns the queue the work was on, and those all log through docker compose logs worker.