plMail

Architecture

The layers, what lives where, and the rules that keep it that way. This page is about the shape of the codebase rather than about any one feature; the feature pages — Mail ingest, The calendar model, The sync engine, Event extraction, JMAP and the Security model — assume it.

CODESTYLE.md §5 states these rules as rules. What follows is why each one is worth the friction it costs, and where the codebase enforces it rather than merely asking.

The tree

src/
  Command/        Console commands, grouped by area (Mail/, Imap/, Push/, Calendar/, Maintenance/…)
  Controller/     HTTP actions, grouped by area (Mail/, Admin/, Settings/, Sharing/, Webhook/…)
  Domain/         The vocabulary: Enum/, DTO/, Interface/, Exception/, Model/, Trait/, Helper/, Filter/
  Entity/         Doctrine entities, grouped by area (Mail/, Label/, User/, Calendar/, Monitoring/)
  Form/           Symfony form types
  Infrastructure/ Framework-facing wiring: Doctrine/, Messaging/, Event/, Scheduler/, Setup/, Encryption/
  Jmap/           A protocol implementation, self-contained, with its own Method/, Mapper/, Protocol/
  Repository/     One per entity, mirroring Entity/'s grouping
  Security/       Authenticators, user provider, two-factor
  Service/        Everything that decides something. The largest layer, by design.
  Twig/           Extensions and runtime helpers (Vendor/ for lifted upstream code)

Two boundaries carry most of the weight.

Domain/ holds no framework types. It is the vocabulary the rest of the application argues in: the enums that own their per-case rules, the final readonly DTOs that cross layers, the interfaces for the axes that vary, and the exception hierarchies. Because it depends on nothing, everything can depend on it — a driver under Service/Calendar/Sync/Google/ and a JMAP method under Jmap/Method/Calendar/ both speak CalendarSource, RemoteEvent and EventStatus without either learning about the other.

Infrastructure/ holds the glue that only exists because Symfony and Doctrine exist: src/Infrastructure/Doctrine/Type/EncryptedStringType.php, the Messenger message/handler pairs, the event subscribers, src/Infrastructure/Scheduler/MaintenanceSchedule.php, and the boot-time probes under src/Infrastructure/Setup/. Nothing here decides what an operation means.

Service/ is deliberately the biggest directory. Grouping is by domain area first and kind second — src/Service/Mail/, src/Service/Gmail/, src/Service/Calendar/Sync/CalDav/ — which is what makes "add a provider" a new directory rather than eleven files dropped into a flat one.

Controllers resolve, authorise, delegate, render

A controller does not decide what an operation means. The commit that established this is titled "Leave controllers with their actions and little else", and the rule shows up as three habits: route attributes on the class for the shared prefix and on the method for the rest, #[IsGranted] at class level with per-action overrides only where they genuinely differ, and ownership assertion in a single private resolver so no action can forget it.

The rule has teeth because the alternative has bitten. Thread/set in JMAP and the web snooze button both go through App\Service\Mail\ThreadSnoozeService; the web endpoint used to write MessageThread::$snoozedUntil directly and nothing else, which left the conversation sitting in the Inbox — locally and at the provider — while the row vanished from the list, until the sweep "woke" a thread that had never left. Where two callers must genuinely differ, the difference is named at both ends: a form post gets an "in 1 day" fallback on an unparseable date where ThreadSetMethod::snoozeDate() refuses it, and each side's comment points at the other.

Every query lives in a repository

There is no createQueryBuilder outside src/Repository. A query used twice is a named repository method; a query used once but with a reason is a named repository method whose docblock gives the reason. App\Repository\Calendar\CalendarEventRepository is the clearest example of the payoff — findOneByRemoteId(), findOneByUid(), findPendingSync(), findRemoteRowsNotIn() and findRowsTheRemoteNeverGave() each carry the paragraph explaining what population it selects, and the sync engine reads as a sequence of decisions rather than as a sequence of queries.

Raw DBAL is allowed and says why it had to be raw. CalendarEventRepository::findOneByRemoteInstanceId() tests jsonb key existence, which has no DQL operator and no registered function; it is written jsonb_exists() rather than the ? operator that means the same thing, because DBAL reads a bare ? as a positional placeholder and refuses the query. CalendarAlertDeliveryRepository::claim() is raw because the whole point is one INSERT … ON CONFLICT DO NOTHING statement, which the ORM has no way to express.

Interfaces for the axis that varies

src/Domain/Interface/ holds one interface per pluggable axis, and the list is short on purpose:

Interface The axis
AccountSyncerInterface how mail is fetched for one account
MailSenderInterface how mail leaves
PushSubscriptionManagerInterface push registration for a mail account
CalendarPushSubscriptionManagerInterface push registration for one mirrored calendar
CalendarSyncDriverInterface one kind of remote calendar
IntegrationDriverInterface (+ VerifiableDriverInterface, SearchableDriverInterface, TimelineDriverInterface) one external file or photo service
EventExtractorInterface one way of finding events in a message
ProposalDetectorInterface one way of reading a date out of prose
PostIngestStepInterface something that wants to react to newly ingested mail
AlertChannelInterface one way an alert reaches a person

Implementations live under the provider's own directory and are resolved by a registry — MailSenderRegistry, IntegrationDriverRegistry, CalendarSyncDriverRegistry, CalendarPushRegistry — each of which takes a tagged iterator and returns the first implementation that claims the subject. Adding a provider is a directory, never an edit to a switch.

Two of the splits above are worth reading as arguments rather than as a table.

Push is two interfaces, not one widened one. CalendarPushSubscriptionManagerInterface exists beside PushSubscriptionManagerInterface because the subject is different, not merely narrower: every method over there takes an Account and reads columns on Account, while Graph subscribes to me/calendars/{id}/events and one Microsoft mail account can mirror six calendars, each needing its own subscription, secret and expiry. Widening to Account|Calendar would open every method in GmailPushSubscriptionManager and GraphSubscriptionManager with an instanceof — a compile-time contract turned into a runtime cascade. The calendar interface also deliberately has no messageKey(): the mail contract has one because the accounts settings page renders per-provider copy for a control the user operates, and calendar push has no control.

Search and timeline are separate from the file driver. SearchableDriverInterface and TimelineDriverInterface are not folded into IntegrationDriverInterface because a WebDAV share has nothing to offer either, and folding them in would force five drivers to carry a method that throws. VerifiableDriverInterface was split out the other way — verify() is the one thing every connection owes whatever it connects to, and a CalDAV calendar driver has to answer it without pretending to hold files.

config/services.yaml carries the tagging, and its _instanceof block is where the boundaries are stated in enforceable form. It says in as many words that a calendar sync driver is not an integration driver and must not be tagged as one, that a calendar push manager is neither, and that an alert channel is none of the three.

DTOs cross boundaries

Anything passing between layers with more than two fields is a final readonly class under src/Domain/DTO/, not an array. The docblock says what it carries that its members do not obviously imply — App\Domain\DTO\Mail\IngestedMessage carries the owning account rather than letting the pipeline read it off the message, because under Gmailify a Gmail account fetches mail addressed to a sibling and the two are not the same one.

The strongest instance is App\Domain\DTO\Calendar\SharedOccurrence, where the DTO is the security control rather than a convenience: a public template never receives a CalendarEvent, so a busy/free link cannot leak a title through a tooltip, a data attribute, a JSON payload or an .ics, because the object being rendered has not got one. See the Security model.

Messenger: three transports and three workers

config/packages/messenger.yaml declares three live transports, and the split is about who is waiting:

Transport Carries Retry
export anything leaving plMail — sends, flag/label propagation, attachment uploads, mail and notifier messages 2s base, ×3, 5 attempts, 60s cap
ingest mail coming in and the work that immediately follows it, plus calendar sync 5s base, ×3, 5 attempts, 300s cap
maintenance backfills, rule runs over existing mail, RunCommandMessage, calendar push registration as ingest

export is tighter on purpose: its failures are a relay refusing a connection or a provider blinking, which clear in seconds, and somebody is watching the outcome. ingest's window used to be the Symfony defaults — 1s/2s/4s — a seven-second span that exhausted every attempt before a rate limit had any chance to end, so a recoverable failure dead-lettered as reliably as an unrecoverable one. max_delay is kept inside the worker's --time-limit=3600 so a delayed retry is never left waiting on a restart.

Separate transports are not enough on their own. A worker already inside a long handler cannot pick up a send however it is prioritised, so each transport has its own process: worker-export, worker-ingest, worker-maintenance and worker-bulk in compose.yaml. A fifth transport, async, is kept routing nothing, so envelopes queued before the split still have somewhere to land; the maintenance worker drains it.

bulk is the newest of them and the one whose reason is easiest to mistake for maintenance: it carries whole-view mark-read and archive runs, which are long like a backfill but are not like one in the way that matters — somebody pressed a button and is watching an indicator, so queueing them behind an embedding backfill would make the job slower than the inline version it replaced. It is also the only transport with a DSN of its own (MESSENGER_BULK_DSN), which exists so the browser suite can make this one queue real and consumed while leaving the other three in memory and unhandled.

Two routing decisions are load-bearing rather than tidy:

ApplyGmailLabelsMessage was unrouted until recently, which meant every Gmail label change — archive, trash, star, mark read — made a live Google API call inside the HTTP request the user was waiting on, while its IMAP and Graph counterparts were queued. The same click behaved differently depending on which account it landed on.

Messages themselves are readonly and carry ids and scalars only, never entities, and pair 1:1 by name with their handler (SyncAccountMessage / SyncAccountMessageHandler). That is not style: handlers run on long-lived workers that clear the entity manager between envelopes, so a serialised entity is a reference to a manager that no longer exists.

The scheduler

App\Infrastructure\Scheduler\MaintenanceSchedule is the one place recurring work is declared. It is consumed by messenger:consume scheduler_default — the scheduler service in compose.yaml — and nothing runs these otherwise, which is the state the project was in before, with logs and orphaned blobs growing without bound.

Cron Command Why that cadence
*/15 * * * * app:mail:sync Neither Gmail push nor Graph subscriptions guarantee delivery and IDLE connections drop; polling is the backstop
7-59/15 * * * * app:calendar:sync --stale The mechanism for CalDAV and ICS feeds, the backstop for Google and Graph. Offset off the quarter hour so it does not stack on the mail sweep — they share one worker
20 * * * * app:calendar:push Hourly, not because anything expires that fast, but because registration fails for deployment reasons that have nothing to do with the click that connected the calendar
* * * * * app:mail:wake-snoozed A minute is the unit people pick a wake time in
* * * * * app:calendar:alerts Same argument, plus the interval is the bound on how late a reminder can be
0 4 * * * app:push:renew --repair Gmail watches last 7 days, Graph subscriptions ~3
50 3 * * * app:calendar:materialise Rolls the occurrence horizon forward, so a repeating event does not quietly run out of dates
30 4 * * * app:monitoring:prune Log entries and dead heartbeats
0 5 * * 0 app:prune:blobs Weekly; it walks three directory trees and a week of orphans is a rounding error

The schedule is stateful() against the cache and processOnlyLastMissedRun(true), so a worker that was down over a scheduled run catches up rather than silently skipping the day — but only once, because these are all idempotent sweeps and replaying a backlog five times over is pure waste. Times are spread across the small hours rather than stacked on midnight, for the same reason the calendar sweep is offset: one worker, and a long prune should not hold up a sync.

Every one of these commands is also runnable by hand, and every command in the tree is listed in CONTRIBUTING.md with a one-line description. That table is part of the definition of done.

Entities, and the invariants that are structural

Attributes only, Types:: constants, enumType: for enums, and onDelete declared at the join column so the database enforces what the code assumes. State is public, with public private(set) where the outside must not write; there are eight public function get… methods in the entire src/Entity tree and each is doing something a property cannot.

Two habits are worth calling out because they are where correctness actually lives.

Every index and unique constraint carries a comment saying what it protects and why the columns are in that order. uniq_calendar_booking_page_start on calendar_booking is not an optimisation — it is the only thing that stops two strangers taking the same half hour, and the page id leads because the constraint is about one page's slots. uniq_calendar_event_calendar_uid scopes UID uniqueness to one calendar, which is what makes a copy of a meeting on a second calendar legal rather than merely tolerated.

Timestamps come from App\Domain\Trait\TimestampableTrait, on every entity, with no exceptions — including the tables written once. One rule for every entity is worth more than the bytes an exception saves, and nothing has to decide which kind of entity it is looking at. The trait needs #[ORM\HasLifecycleCallbacks] on the adopting class and Doctrine silently does nothing without it, so TimestampableTest checks every adopting entity for the attribute — the requirement the trait cannot enforce itself.

Things that bite

A new Messenger message with no routing entry runs synchronously. Symfony handles an unrouted message in the dispatching process, so a job written to get work out of an HTTP request silently stays in it. config/packages/messenger.yaml names this for RegisterCalendarPushMessage and CalendarSubscriberTest asserts the routing; a new message gets neither for free.

A new command in MaintenanceSchedule does nothing until the scheduler service is running. It is a separate container consuming scheduler_default. php bin/console debug:scheduler shows the next run of each, and is the fastest way to find out that the answer is "never".

A new tagged implementation inherits its registry's ordering rules. The registries take the first implementation that claims the subject, so a supports() written too broadly silently steals another driver's work rather than failing. MailSenderRegistry orders by explicit tag priority (GmailApiSender at 10, SmtpMailSender at 0) precisely because declaration order is not a contract.

A _instanceof tag is inherited by anything implementing the interface. That is why VerifiableDriverInterface is tagged app.integration_driver and CalendarSyncDriverInterface deliberately is not: a calendar driver that also implements VerifiableDriverInterfaceCalDavCalendarDriver and IcsUrlCalendarDriver both do — must be reachable by the connect and test paths without appearing in the file-picker registry.

A repository method used once is still a repository method. The temptation to inline a findBy() in a service is what erased the reasoning from the sync engine's predecessors: findRemoteRowsNotIn() and findRowsTheRemoteNeverGave() are exact complements, and which one a caller wants is a decision with a paragraph behind it, not a filter to retype.

Adding an enum case is only safe where the match is exhaustive. The pattern in this codebase is match ($this) over every case with no default, so a new case is a compile-time-ish error. Where somebody wrote a predicate as a comparison instead — the self::Llm !== $this that EventSource::isTrusted() used to be — the next case silently inherited the permission the method existed to withhold.