Files
pl_mail/config/services.yaml
T
Paul LütznerandClaude Opus 5 b38aaac04e Give the Mercure tokens an identity no installation has to configure
v0.2.31 could not mint a token at all on a TrueNAS install, and nothing an
operator set would have fixed it.

That release bound `iss` and `aud` to MERCURE_PUBLIC_URL. truenas.compose.yaml
leaves that BLANK ON PURPOSE — the setup screen asks for the public URL on first
boot and stores it in the database, which is long after the hub container has
had to be told what to trust. An empty issuer is not a weak issuer: the factory
refuses it outright with `The "iss" additional claim is required by RFC 9068
access tokens`, so there was no subscriber cookie, every publish threw, and the
symptom was a red dot indistinguishable from a hub that was simply down.

The mistake underneath was deriving a value that must exist at CONTAINER START
from one that only exists after SETUP. So it is a literal now, shared by the
application and every compose file, and no installation has to know it. An
identifier does not have to resolve — `.invalid` is reserved by RFC 2606 for
exactly this — and all that matters is that both halves agree on the same
string, which a literal guarantees and a derived value cannot. The same string
everywhere costs nothing: the signing secret is generated per installation, so
one install's token has never been usable against another's hub however it
identifies itself.

truenas.compose.yaml had been left behind twice more. It still carried the
"/healthz" check that v0.2.30 replaced in the other two files, so its hub would
have gone unhealthy the moment the image moved; and it had no pull_policy, so
the image never moved at all. That is the combination that produced the original
report: an application on the 1.0 protocol talking to a 0.x hub that nothing had
ever pulled.

The regression test empties MERCURE_PUBLIC_URL rather than reading the config,
because what broke was the derivation — a test that read the same placeholder
the code did would have passed throughout.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-21 13:09:36 +02:00

292 lines
15 KiB
YAML

# yaml-language-server: $schema=../vendor/symfony/dependency-injection/Loader/schema/services.schema.json
# This file is the entry point to configure your own services.
# Files in the packages/ subdirectory configure your dependencies.
# See also https://symfony.com/doc/current/service_container/import.html
# Put parameters here that don't need to change on each machine where the app is deployed
# https://symfony.com/doc/current/best_practices.html#use-parameters-for-application-configuration
parameters:
# Which clock a user who has never picked one is shown. Read through the
# `default:` processor rather than straight from the env so a deployment
# that never heard of the variable still gets a real zone instead of an
# empty string — UserTimezoneResolver would then fall back on its own, but
# this keeps the answer to "what did the install configure?" in one place.
app.fallback_timezone: 'Europe/Berlin'
app.default_timezone: '%env(default:app.fallback_timezone:APP_DEFAULT_TIMEZONE)%'
# Whether this install is a public demo. Read through `default:` so an
# install that never heard of the variable is a normal one — the accident
# worth preventing is a real deployment turning into a demo, not the
# reverse. See App\Service\Demo\DemoMode.
app.demo_mode_default: false
# The subscriber cookie's name. The Mercure 1.0 default is `__Secure-`
# prefixed, and a browser refuses a `__Secure-` cookie that did not arrive
# over HTTPS — which is right for every real install and wrong for the test
# stack, which serves plain HTTP on 127.0.0.1. Read through `default:` so an
# install that has never heard of the variable gets the safe name, and only
# a deployment that knows it is running without TLS has to say so.
mercure.cookie_name_default: '__Secure-mercure_access_token'
# Who plMail says issued its Mercure tokens, and which hub they are for.
#
# A CONSTANT, and deliberately not the installation's own URL. RFC 9068
# binds a token to an issuer and an audience, and the hub has to be told
# both at container start — but on a TrueNAS install the public URL is not
# known then: it is blank in the compose file and stored by the setup screen
# on first boot, in the database. Deriving these from it produced an empty
# `iss`, which the token factory refuses outright ("The \"iss\" additional
# claim is required by RFC 9068 access tokens"), so the install could mint
# no token at all — no live updates, and every publish throwing.
#
# An identifier does not have to resolve, and this one says so: `.invalid`
# is reserved by RFC 2606 for exactly this. What matters is that the
# application and the hub agree on the same string, which a literal
# guarantees and a derived value cannot.
#
# It is the same string on every install, and that costs nothing: the token
# is signed with a secret generated per installation, so one install's token
# has never been usable against another's hub whatever it claims.
mercure.identifier: 'https://plmail.invalid/.well-known/mercure'
mercure.cookie_name: '%env(default:mercure.cookie_name_default:MERCURE_COOKIE_NAME)%'
app.demo_mode: '%env(default:app.demo_mode_default:bool:APP_DEMO_MODE)%'
app.demo_ttl_default: 'PT2H'
app.demo_ttl: '%env(default:app.demo_ttl_default:APP_DEMO_TTL)%'
# Impressum fields. Blank by default, on purpose — the page says so rather
# than inventing an operator; see the note in .env.
#
# Through a named parameter rather than `default::`, which looks like it
# means "default to empty" and does not: the text after `default:` is a
# PARAMETER NAME, so an empty one resolves to null and the controller's
# string arguments blew up on an install that had never set these.
app.demo_impressum_blank: ''
app.demo_impressum_name: '%env(default:app.demo_impressum_blank:APP_DEMO_IMPRESSUM_NAME)%'
app.demo_impressum_address: '%env(default:app.demo_impressum_blank:APP_DEMO_IMPRESSUM_ADDRESS)%'
app.demo_impressum_email: '%env(default:app.demo_impressum_blank:APP_DEMO_IMPRESSUM_EMAIL)%'
app.demo_privacy_host: '%env(default:app.demo_impressum_blank:APP_DEMO_PRIVACY_HOST)%'
# How hard the embedding backfill is allowed to push, and how quickly it
# gets out of the way of somebody using the composer. Timid on purpose —
# see App\Service\Ai\BackfillPolicy, which carries the whole argument:
# backfill throughput does not matter and interactive latency does, and on
# the target box they share one integrated GPU.
#
# Read through `default:` so an install that never heard of these gets the
# tuned numbers. Deliberately NOT rows in the installation reference: the
# answer for almost everybody is "leave them alone".
app.ai_backfill_batch_size_default: 10
app.ai_backfill_batch_size: '%env(default:app.ai_backfill_batch_size_default:int:AI_BACKFILL_BATCH_SIZE)%'
# Two seconds of quiet between batches. A gap, not a throttle: it is what
# gives a click somewhere else a moment to reach the host first.
app.ai_backfill_pause_ms_default: 2000
app.ai_backfill_pause_ms: '%env(default:app.ai_backfill_pause_ms_default:int:AI_BACKFILL_PAUSE_MS)%'
# How long after the last interactive request the backfill keeps its hands
# off. Ninety seconds is far longer than any single request, which is the
# point: somebody clicking through several rewrites holds the pause open
# instead of leaving a window between clicks for a batch to slip into.
app.ai_backfill_cooldown_seconds_default: 90
app.ai_backfill_cooldown_seconds: '%env(default:app.ai_backfill_cooldown_seconds_default:int:AI_BACKFILL_COOLDOWN_SECONDS)%'
# How long to wait before retrying a host that answered nothing at all.
app.ai_backfill_retry_seconds_default: 60
app.ai_backfill_retry_seconds: '%env(default:app.ai_backfill_retry_seconds_default:int:AI_BACKFILL_RETRY_SECONDS)%'
services:
# default configuration for services in *this* file
_defaults:
autowire: true # Automatically injects dependencies in your services.
autoconfigure: true # Automatically registers your services as commands, event subscribers, etc.
# makes classes in src/ available to be used as services
# this creates a service per class whose id is the fully-qualified class name
App\:
resource: '../src/'
# Public because Kernel::boot() fetches it straight from the container to
# hand to EncryptedStringType — Doctrine builds types statically, outside
# dependency injection, so there is no other seam.
App\Infrastructure\Encryption\Encryptor:
public: true
# The policy is enforced in production and reported under debug, because the
# profiler toolbar and VarDumper inject inline scripts nothing can nonce.
App\Security\Csp\ContentSecurityPolicyListener:
arguments:
$debug: '%kernel.debug%'
App\Domain\Helper\AttachmentStorageHelper:
arguments:
$projectDir: '%kernel.project_dir%'
$storageDir: '%env(APP_STORAGE_DIR)%'
App\Domain\Helper\RawMessageStorage:
arguments:
$projectDir: '%kernel.project_dir%'
$storageDir: '%env(APP_STORAGE_DIR)%'
App\Domain\Helper\UploadStorage:
arguments:
$projectDir: '%kernel.project_dir%'
$storageDir: '%env(APP_STORAGE_DIR)%'
App\Domain\Helper\AvatarStorage:
arguments:
$projectDir: '%kernel.project_dir%'
$storageDir: '%env(APP_STORAGE_DIR)%'
App\Domain\Helper\SignatureStorage:
arguments:
$projectDir: '%kernel.project_dir%'
$storageDir: '%env(APP_STORAGE_DIR)%'
App\Command\Maintenance\PruneBlobsCommand:
arguments:
$projectDir: '%kernel.project_dir%'
App\Service\Ai\BackfillPolicy:
arguments:
$batchSize: '%app.ai_backfill_batch_size%'
$pauseMs: '%app.ai_backfill_pause_ms%'
$cooldownSeconds: '%app.ai_backfill_cooldown_seconds%'
$retrySeconds: '%app.ai_backfill_retry_seconds%'
App\Service\Mail\AttachmentThumbnailer:
arguments:
$projectDir: '%kernel.project_dir%'
App\Service\Mail\MessageFrameScript:
arguments:
$projectDir: '%kernel.project_dir%'
App\Service\Mail\ImageProxyFetcher:
arguments:
$projectDir: '%kernel.project_dir%'
App\Service\OAuth\OAuthProviderFactory:
arguments:
$googleClientId: '%env(GOOGLE_OAUTH_CLIENT_ID)%'
$googleClientSecret: '%env(GOOGLE_OAUTH_CLIENT_SECRET)%'
$microsoftClientId: '%env(MICROSOFT_OAUTH_CLIENT_ID)%'
$microsoftClientSecret: '%env(MICROSOFT_OAUTH_CLIENT_SECRET)%'
$microsoftTenant: '%env(MICROSOFT_OAUTH_TENANT)%'
App\Service\Graph\GraphAccountSyncer:
tags: ['app.account_syncer']
# Must be registered BEFORE SmtpMailSender so the API sender wins.
App\Service\Mail\GraphApiSender:
tags: ['app.mail_sender']
App\Service\Gmail\GmailPushSubscriptionManager:
tags: ['app.push_subscription_manager']
App\Service\Graph\GraphSubscriptionManager:
tags: ['app.push_subscription_manager']
# Above every real sender, and it claims accounts only when demo mode is
# on. That ordering is the whole guarantee: a demo instance cannot reach an
# SMTP relay because the sender that would have done it is never asked.
App\Service\Mail\DemoMailSender:
tags: [{ name: app.mail_sender, priority: 100 }]
App\Service\Mail\GmailApiSender:
tags: [{ name: app.mail_sender, priority: 10 }]
App\Service\Mail\SmtpMailSender:
tags: [{ name: app.mail_sender, priority: 0 }]
App\Service\Mail\MailSenderRegistry:
arguments:
$senders: !tagged_iterator app.mail_sender
# App passwords and JWTs share the JMAP firewall and both arrive as a
# Bearer token. This keeps the JWT authenticator from claiming (and then
# failing) an app password.
App\Security\JwtBearerTokenExtractor:
decorates: lexik_jwt_authentication.extractor.authorization_header_extractor
arguments:
$inner: '@.inner'
# "Remember this device" is a row rather than a signed cookie, so revoking
# one takes effect immediately — see App\Security\TwoFactor\DatabaseTrustedDeviceManager.
# scheb_2fa.yaml points trusted_device.manager at this service.
App\Security\TwoFactor\DatabaseTrustedDeviceManager:
arguments:
$lifetime: '%scheb_two_factor.trusted_device.lifetime%'
$extendLifetime: '%scheb_two_factor.trusted_device.extend_lifetime%'
# Request-scoped: the pending cookie must not survive into the next
# request, which would write it for whoever asks next.
App\Security\TwoFactor\TrustedDeviceCookieJar:
tags: [{ name: kernel.reset, method: reset }]
arguments:
$cookieName: '%scheb_two_factor.trusted_device.cookie_name%'
$cookieSecure: '%scheb_two_factor.trusted_device.cookie_secure%'
$cookieSameSite: '%scheb_two_factor.trusted_device.cookie_same_site%'
$cookiePath: '%scheb_two_factor.trusted_device.cookie_path%'
$cookieDomain: '%scheb_two_factor.trusted_device.cookie_domain%'
App\Jmap\Session\SessionBuilder:
arguments:
$vapidPublicKey: '%env(VAPID_PUBLIC_KEY)%'
App\Jmap\Push\WebPushSender:
arguments:
$vapidSubject: '%env(VAPID_SUBJECT)%'
$vapidPublicKey: '%env(VAPID_PUBLIC_KEY)%'
$vapidPrivateKey: '%env(VAPID_PRIVATE_KEY)%'
# Web Push first, so the iteration order of the registry matches the order
# the transports were added. Nothing depends on it — the registry keys by
# transport rather than scanning — but a list whose order is arbitrary
# invites a future supports() scan to rely on it.
App\Jmap\Push\PushSenderRegistry:
arguments:
$senders: !tagged_iterator app.push_sender
App\Jmap\Method\MethodRegistry:
arguments:
$methods: !tagged_iterator app.jmap_method
App\Service\Integration\IntegrationDriverRegistry:
arguments:
$drivers: !tagged_iterator app.integration_driver
App\Service\Calendar\CalendarSyncDriverRegistry:
arguments:
$drivers: !tagged_iterator app.calendar_sync_driver
_instanceof:
App\Domain\Interface\AccountSyncerInterface:
tags: ['app.account_syncer']
# The broader interface, so a driver that only knows how to prove its
# credentials — a calendar connection, which holds no files — is still
# reachable by the connect and test paths. IntegrationDriverInterface
# extends it, so the file drivers are tagged by this same rule.
App\Domain\Interface\VerifiableDriverInterface:
tags: ['app.integration_driver']
# A calendar sync driver is not an integration driver and must not be
# tagged as one: it holds no files, and the two registries answer
# different questions — one takes a Provider, this one takes a
# CalendarSource that may carry a mail Account instead.
App\Domain\Interface\CalendarSyncDriverInterface:
tags: ['app.calendar_sync_driver']
# And a calendar push manager is neither of those. It is keyed on a
# Calendar where PushSubscriptionManagerInterface is keyed on an
# Account, so the two must not share a tag: the mail managers cannot
# answer supports(Calendar) and the calendar ones cannot answer
# supports(Account). See CalendarPushSubscriptionManagerInterface for
# why that is two interfaces rather than one widened one.
App\Domain\Interface\CalendarPushSubscriptionManagerInterface:
tags: ['app.calendar_push_manager']
# One channel per RFC 8984 alert action. Not folded into any of the
# tags above: a channel is chosen by what the alert asked for, and it
# neither syncs, verifies nor pushes on behalf of an Account.
App\Domain\Interface\AlertChannelInterface:
tags: ['app.alert_channel']
# One sender per outbound push transport. Not one of the tags above
# either: those are keyed on an Account or a Calendar and are about
# asking a MAIL provider to notify plMail, where this is plMail
# notifying a device it already holds a subscription for.
App\Domain\Interface\PushSenderInterface:
tags: ['app.push_sender']
App\Jmap\Method\JmapMethod:
tags: ['app.jmap_method']
# Adding a wizard step is a new handler class and a new enum case —
# nothing has to be registered here by hand.
App\Service\Onboarding\OnboardingStepHandlerInterface:
tags: ['app.onboarding_step']