История коммитов

.
refactor(mail): queue a message through a typed dto
Every producer wrote the queue row by hand: the recipient, the subject and the template variables shared one untyped array, so a misspelled key was found by the cron a minute later, if at all. A message is now queued through MailQueueInterface::push() with a QueuedEmailDTO, and the six call sites in registration, profile and contacts no longer touch the model at all.

Putting a message in and taking messages out are separate interfaces on purpose: a module needs neither claiming nor retrying, and the sender needs no producer methods.

The addresses of a message are validated as it is queued, so a broken one is reported where the mistake was made instead of exhausting the delivery attempts first. The contact form catches that: its notification address comes from the settings of the site rather than a validated form, and a typo there must not fail the visitor whose message was already saved.

The dto also closes a hole of the old shape: template variables are merged last, so a variable named email_to can no longer redirect the message.
.
docs: bump the documentation submodule
.
feat(mail): send every message as html and text, and address it properly
A message carrying only html reads as bulk mail to a spam filter and is unreadable in a client that shows text. Each message now goes out as both: the text form comes from a .txt.twig template beside the html one - written here for all six emails of the theme - and is derived from the markup by the converter of symfony/mime when a template does not provide one. The text templates reuse the strings of the html ones, so nothing new has to be translated.

Twig now picks its escaping strategy from the file name instead of always escaping as html. Every existing .twig template is html and is escaped exactly as before; a .txt.twig one is not markup, and escaping it turned the ampersand of a confirmation link into & in the text a reader follows.

The queue understands reply_to, reply_to_name, cc and bcc, the last two taking one address or several. The contact form notification sets reply-to to the visitor who wrote it, so answering the notification answers them rather than the mailbox of the site.

Messages can be signed with dkim, configured by the dkim section. A key that cannot be loaded is logged and ignored: the mail of a site must not stop because of it.
.
docs: bump the documentation submodule
.
fix(mail): retry a failed delivery instead of losing the message
A transport error stamped the message as sent, so it was never tried again and nobody could tell it had not arrived. Delivery is now tracked: a message that could not be sent keeps its place and is tried again after a configurable delay, and once the attempts run out it is recorded as failed with its error - never as sent. A message that cannot be built at all (no recipient, an address no server would accept, a template that does not render) is given up on at once, because every future run would fail the same way, and the rest of the batch still goes out.

A batch is now claimed before it is read, so two workers - the cron of a busy site firing again before the previous run finished - cannot pick the same message, and a claim nobody released within the timeout goes back into circulation. The claim needs no row locks, so it works on the older MySQL versions as well.

EmailSender is a service with its dependencies injected rather than a static method reaching into the container, and reports what the run did. The queue lives behind EmailQueueInterface. The table is defined once in MailSchema, shared by the installer, the new mail:upgrade-schema command and the tests; mail:cleanup removes delivered messages, never the failed ones.

Also fixes MailRenderer dying with a TypeError when no translator was registered yet, which is what a process rendering a message outside the usual bootstrap runs into.
.
feat(mail): configure the mailer through a dsn
The mail settings are now compiled into a Symfony Mailer DSN by MailDsnResolver, and a site can write that DSN directly instead: every transport the mailer supports becomes reachable, including the provider bridges and the failover/roundrobin schemes. The structured transport + options form keeps working and is compiled down to the same string.

Fixes of the previous builder: the encryption setting was passed as the Swiftmailer options encryption/auth_mode, which Symfony ignores, so port 465 could never connect - the scheme is now smtps where implicit TLS is meant; a username without a password was dropped entirely; verify_peer, auto_tls, require_tls and local_domain are supported; sendmail without a command falls back to sendmail_path of php.ini; the native and null transports were added.

The transport also receives the logger and the http client, the latter being what the API transports of the provider bridges send through.
.
docs: bump the documentation submodule
Points at docs(security): describe the html sanitizer.
.
feat(security): let a module declare its own html policy
A policy was an enum case, so the only way for a module to have one was to patch
the core and lose the patch on the next update. A module now registers a service
implementing HtmlPolicyProviderInterface — PSRContainerFactory tags it, the same
way it does for voters and permission providers — and asks for the policy by
name:

$this->sanitizer->sanitize($text, 'my-module.signature');

HtmlPolicyDefinition describes what the content may carry without naming a
library: elements with their attributes, allowed classes, linkify, link schemes,
frame targets. The shape maps onto symfony/html-sanitizer as directly as onto
HTMLPurifier, so it does not tie the abstraction to the current library.

It is an allow list and cannot be widened into something unsafe: elements that
carry behaviour, attributes starting with 'on' and executable schemes are
refused where they are declared, with an exception that names the mistake.
Asking for an undeclared policy throws instead of falling back, and the built-in
policies stay reachable only through the enum, so a module cannot claim the name
the whole site is cleaned by.
.
docs(agents): describe the html sanitizer and its policies
escaping.md gains a section on sanitizing rich content: which interface to take,
what each policy is for, that toPlainText sanitizes before stripping, and that a
URL is validated by scheme rather than run through a markup sanitizer.

The security checklist checks the policy fits the content and flags a caller
that builds a sanitizer of its own.
.
refactor(security): drop the raw purifier from the container
Nothing asks for \HTMLPurifier any more, so the service, its alias and the
Johncms\Security\HTMLPurifier factory are gone. The library is now reachable
only through HtmlPurifierFactory, behind HtmlSanitizerInterface.