Files
pl_mail/.env
T
Paul LütznerandClaude Opus 5 1ad4188b84 Show a German user German time, and store what IMAP actually meant
An 11:00 appointment read 09:00 in Berlin. Both halves of that were real and
only fixing one would have made the other worse.

The display half: `date.timezone = UTC`, no `twig.date.timezone`, and not one
`|date()` call in the whole template tree passing a zone -- so a correct UTC
instant was formatted as UTC and shipped as a string. There was no user
timezone to render in, because the column did not exist.

The fix is a `kernel.request` subscriber setting the Twig environment's zone,
rather than an edit to every call site. The audit found `|date()` across mail,
calendar, topbar, settings and admin, and the failure mode of the by-hand
version is missing one and not knowing. It sets unconditionally rather than
returning early for an anonymous request: Twig is a shared service, and under a
worker runtime an early return leaves the previous user's zone in place for
whoever asks next.

The storage half is the one that made this dangerous. webklex parses the
`Date:` header with a bare `Carbon::parse()`, which keeps the header's offset,
and Doctrine formats a datetime in whatever zone the object carries -- so IMAP
stored `15:58:46` for a `13:58:46Z` instant. Gmail and Graph already normalised.
Which means IMAP mail looked *right* under the UTC-rendering bug, and fixing
rendering alone would have moved it two hours the other way. Existing rows are
wrong by the sender's offset; a resync corrects them and no migration pretends
otherwise.

The fallback is a parameter, never `date_default_timezone_get()` -- that is UTC
in the container and is precisely how this got in. `CalendarProvisioner` was
stamping every calendar `UTC` from the same call.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-02 10:09:45 +02:00

181 lines
8.9 KiB
Bash

# In all environments, the following files are loaded if they exist,
# the latter taking precedence over the former:
#
# * .env contains default values for the environment variables needed by the app
# * .env.local uncommitted file with local overrides
# * .env.$APP_ENV committed environment-specific defaults
# * .env.$APP_ENV.local uncommitted environment-specific overrides
#
# Real environment variables win over .env files.
#
# DO NOT DEFINE PRODUCTION SECRETS IN THIS FILE NOR IN ANY OTHER COMMITTED FILES.
# https://symfony.com/doc/current/configuration/secrets.html
#
# Run "composer dump-env prod" to compile .env files for production use (requires symfony/flex >=1.2).
# https://symfony.com/doc/current/best_practices.html#use-environment-variables-for-infrastructure-configuration
###> symfony/framework-bundle ###
APP_ENV=dev
# ─────────────────────────────────────────────────────────────────────────────
# READ THIS BEFORE PUTTING A SECRET IN THIS FILE.
#
# docker compose reads .env — this file — to fill in ${VAR} in compose.yaml.
# That makes anything set here an environment variable inside every container,
# which in turn tells the entrypoint "the operator supplied this" and switches
# off the first-run generation for it. A committed value here is therefore a
# secret shared by every plMail install in existence.
#
# So the three that matter are blank, and generated on first start into
# var/secrets/generated.env. Fill one in only if you mean to manage it
# yourself — in your shell, or here — and know that it then applies to every
# service.
# ─────────────────────────────────────────────────────────────────────────────
# Signs remember-me cookies, among other things; changing it logs everyone out.
APP_SECRET=
APP_SHARE_DIR=var/share
# Where the generated secrets live. Must be on a volume EVERY plMail service can
# see; one that cannot read it mints its own key and stops being able to read the
# others' data, which `app:secrets:init` detects and refuses to start on. Back
# this up: losing APP_ENCRYPTION_KEY makes every stored mail credential
# permanently unreadable.
APP_SECRETS_FILE=var/secrets/generated.env
# Where blobs live, relative to the project root: attachments, raw messages,
# uploads and avatars all sit under it. One knob so a deployment can put the
# whole lot on a single mount — see truenas.compose.yaml, which does exactly
# that. Attachment paths are stored relative to the project root, so changing
# this on an install with mail in it orphans what is already there.
APP_STORAGE_DIR=var
# The address plMail is reached at from outside — what Google and Microsoft
# call back to with push notifications. Blank on purpose: it is asked for on the
# setup screen and written to var/secrets/generated.env, because a worker
# building a push subscription has no request to infer a hostname from. Set it
# here or in the environment to manage it yourself.
APP_PUBLIC_URL=
# The clock a user who has never picked one in Settings is shown. Timestamps
# are stored in UTC and only ever converted for display, so changing this
# re-renders existing mail rather than rewriting it. NOT the container's PHP
# timezone, which is pinned to UTC on purpose — an install left on UTC shows
# every European user times that are plausibly wrong by their own offset.
APP_DEFAULT_TIMEZONE=Europe/Berlin
###< symfony/framework-bundle ###
###> symfony/routing ###
# Configure how to generate URLs in non-HTTP contexts, such as CLI commands.
# See https://symfony.com/doc/current/routing.html#generating-urls-in-commands
DEFAULT_URI=http://localhost
###< symfony/routing ###
###> doctrine/doctrine-bundle ###
# Format described at https://www.doctrine-project.org/projects/doctrine-dbal/en/latest/reference/configuration.html#connecting-using-a-url
# IMPORTANT: You MUST configure your server version, either here or in config/packages/doctrine.yaml
#
# DATABASE_URL="sqlite:///%kernel.project_dir%/var/data_%kernel.environment%.db"
# DATABASE_URL="mysql://app:!ChangeMe!@127.0.0.1:3306/app?serverVersion=8.0.32&charset=utf8mb4"
# DATABASE_URL="mysql://app:!ChangeMe!@127.0.0.1:3306/app?serverVersion=10.11.2-MariaDB&charset=utf8mb4"
# No password on purpose — see the note at the top of this file. The entrypoint
# builds the real one from the generated POSTGRES_PASSWORD and exports it, which
# wins over this.
#
# Not blank, though: Doctrine reads the driver out of the scheme, and an empty
# DSN has none — which fails the prod cache warmup during the Docker build with
# "could not find driver". A DSN carrying no credentials is not a secret.
DATABASE_URL="postgresql://app@database:5432/app?serverVersion=18&charset=utf8"
###< doctrine/doctrine-bundle ###
###> symfony/messenger ###
# Choose one of the transports below
# MESSENGER_TRANSPORT_DSN=amqp://guest:guest@localhost:5672/%2f/messages
# MESSENGER_TRANSPORT_DSN=redis://localhost:6379/messages
MESSENGER_TRANSPORT_DSN=doctrine://default?auto_setup=0
###< symfony/messenger ###
###> symfony/mailer ###
MAILER_DSN=null://null
###< symfony/mailer ###
###> app/encryption ###
# Base64-encoded 32-byte libsodium secretbox key used by the `encrypted_string`
# Doctrine type to encrypt account credentials at rest. SEPARATE from APP_SECRET.
#
# Blank on purpose — see the note at the top of this file. Generated on first
# start, and worth backing up: it is the only thing that can read the mailbox
# passwords and OAuth tokens in your database.
APP_ENCRYPTION_KEY=
###< app/encryption ###
APP_DEV_USER_EMAIL=
APP_DEV_USER_PASSWORD=
###> symfony/mercure-bundle ###
# See https://symfony.com/doc/current/mercure.html#configuration
# The URL of the Mercure hub, used by the app to publish updates (can be a local URL)
MERCURE_URL=http://mercure/.well-known/mercure
MERCURE_PUBLIC_URL=https://localhost/.well-known/mercure
# Blank on purpose — see the note at the top of this file. Generated on first
# start, and read by both the app and the hub container from the same file.
MERCURE_JWT_SECRET=
###< symfony/mercure-bundle ###
###> google oauth ###
GOOGLE_OAUTH_CLIENT_ID=
GOOGLE_OAUTH_CLIENT_SECRET=
GMAIL_PUBSUB_TOPIC=projects/your-project-id/topics/gmail-push
GMAIL_PUBSUB_VERIFICATION_TOKEN=
###< google oauth ###
###> microsoft/graph ###
MICROSOFT_OAUTH_CLIENT_ID=
MICROSOFT_OAUTH_CLIENT_SECRET=
# common | organizations | consumers | <tenant-guid>
# Must match the app registration's supported-account-types, or consent fails
# with AADSTS50194. `organizations` avoids the consumer-account edge cases
# (no immutable ids, reduced $filter support) if you don't need outlook.com.
MICROSOFT_OAUTH_TENANT=common
###< microsoft/graph ###
APP_DB_LOG_LEVEL=warning
APP_CONTAINER_NAME=web
TRUSTED_PROXIES=127.0.0.1,10.0.0.0/8,172.16.0.0/12,192.168.0.0/16
###> lexik/jwt-authentication-bundle ###
# Beside the generated secrets rather than in config/jwt: the keys are not in
# the image (they are gitignored), so each service would otherwise generate its
# own and reject tokens the others signed. Generated by `app:secrets:init`.
JWT_SECRET_KEY=%kernel.project_dir%/var/secrets/jwt/private.pem
JWT_PUBLIC_KEY=%kernel.project_dir%/var/secrets/jwt/public.pem
# Real passphrase lives in .env.local (untracked); config/jwt/*.pem is gitignored.
JWT_PASSPHRASE=
###< lexik/jwt-authentication-bundle ###
###> plmail/web-push ###
# VAPID keypair for JMAP PushSubscription + browser Web Push (RFC 8292).
# Generate with: bin/console app:push:generate-vapid-keys
# The PRIVATE key belongs in .env.local (untracked) — never here.
# In a container deployment there is no .env.local: set these as environment
# variables on the service instead (see truenas.compose.yaml x-config). A real
# env var overrides whatever "composer dump-env prod" baked into the image.
VAPID_SUBJECT=mailto:admin@example.com
VAPID_PUBLIC_KEY=
VAPID_PRIVATE_KEY=
###< plmail/web-push ###
###> plmail/integrations ###
# Integration provider credentials live in the database, not here — admins edit
# them under /admin?section=integrations. These two are the SSRF guard rails
# around the server address a user may type in for a self-hosted service.
#
# Self-hosted Nextcloud and Immich on a LAN often have no certificate, so
# allowing plain http is common. It is off by default so that sending an app
# password in the clear stays a deliberate choice rather than an accident.
INTEGRATIONS_ALLOW_HTTP=false
# Comma-separated hosts exempt from the private-range block, for reaching a
# service on the same Docker network or LAN, e.g. "nextcloud.lan,10.0.0.5".
# Everything else resolving into loopback, link-local or RFC1918 is refused —
# that block is what stops a user aiming plMail at the database container or
# at 169.254.169.254.
INTEGRATIONS_ALLOWED_HOSTS=
###< plmail/integrations ###