Framework

Repository Author Commit message Committed SHA  
 
latte by dg HtmlHelpers: contenteditable, draggable, spellcheck & hidden are not boolean attributes (BC break)

None of them is a boolean attribute in HTML; they are enumerated ones, so
rendering them as a bare name was wrong in three separate ways:

- a string 'false' is truthy in PHP, so contenteditable={$s} rendered the
attribute present, i.e. the exact opposite of what was asked for
- the empty value is not a keyword of draggable, it maps to the invalid value
default 'auto', so draggable=true could not be expressed at all
- 'attribute missing' and 'attribute false' are different states; the false
value is what disables editing inside a contenteditable region, and dropping
the attribute silently inherited from the parent instead

Two new categories replace them. 'tristate' (contenteditable, draggable,
spellcheck, writingsuggestions) maps bool to the true/false keywords, exactly
like aria-* already does, so formatAriaAttribute now delegates to it. The
'valuedBool' one (hidden, popover) stays a flag but keeps a non-empty value,
so hidden="until-found" survives. Falsy values are tested first there: hidden
with a '0' coming from a database must not render hidden="0", which is an
invalid value and would map back to the hidden state.

mail by dg CssInliner: resolves declarations by the CSS cascade

Rules were applied in the order they appeared, so the last one to mention a
property won. A browser does not work that way: p.intro { color: red } followed
by p { color: blue } paints the intro red, while the inliner painted it blue.
The inlined mail therefore looked different from the page the CSS was written
for, and the more carefully the stylesheet was written, the more it diverged.

Declarations now compete the way they do in an author stylesheet: !important
first, then an existing inline style, then specificity, ties going to the later
rule. Each selector in a comma-separated list carries its own specificity, and
one part the DOM engine rejects (::marker) no longer discards the whole rule.
The argument of an ordinary functional pseudo-class is a keyword or an An+B
expression, not a selector, so the idents in :nth-child(odd) or :nth-child(-n+3)
do not count as type selectors; counting them would inflate specificity enough
to flip a winner.

Only the winner of each property is written out, so a property appears in the
style attribute once rather than several times with the earlier values trailing.
HTML attributes generated for Outlook (bgcolor, width) drop the !important
marker, which has no meaning in an attribute.

A '}' inside a style attribute would close the block the attribute is wrapped in
for parsing and turn the remainder into rules of its own, read back as the
element's inline declarations. Such an attribute is not a plain declaration list
and is kept verbatim.

mail by dg CssInliner: do not emit an attribute a value cannot express, and fold property case

- width: auto (or inherit, or calc(...)) was cast to an integer and emitted as
width="0", collapsing the cell in Outlook. The inliner thus broke a layout the
source CSS had left perfectly fine. A numeric attribute is now written only for
a plain length or percentage, and calc() no longer slips through on its '%'.
- Property names are case-insensitive in CSS but were compared verbatim, so COLOR
and color ended up as two separate declarations, and a WIDTH: 600px never
produced the width attribute for Outlook. Custom properties keep their case,
being genuinely case-sensitive.

mail by dg FileMailer: writes emails to .eml files instead of sending them

In development and in tests, mail must not leave the machine, yet it still needs
looking at. Redirecting everything to a developer's inbox (Interceptor) needs a
working transport, and the Tracy panel only shows the envelope, not the message.

FileMailer drops each message into a directory as a complete .eml file, which any
mail client opens -- the rendered HTML, the attachments, the headers, all of it.
It is a Mailer like any other, so it plugs in wherever a real transport goes, and
it signs with DKIM if given a signer, which makes the signature inspectable too.

mail by dg FallbackMailer: stops retrying a mailer that refused for good

Every failure was treated as worth another go. A message the server rejected
outright -- 550 no such user, bad credentials -- was resent to the same server
retryCount times, sleeping between rounds, for an answer that could not change.
Sending was slow to fail exactly when failing fast was the only useful outcome.

SendException now knows whether it is permanent, and SmtpException derives that
from the reply: 5xx is a permanent negative completion, 4xx a transient one
(RFC 5321, §4.2.1). A mailer that fails permanently drops out of the remaining
rounds, so the fallback is tried immediately and the others keep their retries.
A failure with no reply behind it (timeout, dropped connection) stays retryable,
as does a plain SendException, so existing mailers behave exactly as before.

mail by dg Message: added setUnsubscribe() for one-click unsubscribe

Gmail and Yahoo require bulk senders to offer one-click unsubscribe, and getting
it right means two headers that have to agree: List-Unsubscribe with the target,
and List-Unsubscribe-Post to announce that a bare POST is enough. A lone
List-Unsubscribe does not satisfy the requirement, which is easy to get wrong by
hand -- exactly the kind of knowledge the library should hold.

setUnsubscribe($url, $email) writes both, announcing one-click only alongside a
URL (a mailto target has nothing to POST to). The unsubscribe headers also join
the DKIM defaults, so the address a click goes to cannot be swapped in transit;
they are only signed when present, so no existing signature changes.

mail by dg Message: converts internationalized domains to punycode

An address like jan@příklad.cz passed validation and then travelled with a UTF-8
domain in the headers and, worse, in the SMTP envelope. That is only legal when
both peers negotiate SMTPUTF8 (RFC 6531); an ordinary server rejects the RCPT TO
or mangles the address, and the failure is puzzling because the address looked
fine going in.

The domain of every address -- From, To, Cc, Bcc, Reply-To, Return-Path -- is now
encoded to its ASCII form, so jan@xn--pklad-zsa96e.cz goes on the wire. Display
names keep their diacritics (they are MIME-encoded anyway), and so does the local
part: a non-ASCII local part really does need SMTPUTF8 and cannot be encoded away.

Needs ext-intl; without it the address is passed through unchanged, as before.

mail by dg MimePart: header names are case-insensitive

RFC 5322 header names are case-insensitive, but they were stored and looked up
by exact spelling: getHeader('from') did not find 'From', and setHeader('SUBJECT')
added a second Subject header next to the existing one rather than replacing it.
Everything that reads headers back -- DkimSigner, SmtpMailer, Interceptor -- had
to spell them exactly as Message writes them or silently see nothing.

Lookups now ignore case and the spelling first used is the one kept, so generated
messages are unchanged. DkimSigner matches the headers it signs the same way; it
compares names taken from the raw message against the configured list, which
would otherwise miss a header the configuration spells differently and produce a
signature the receiver cannot verify.

The remaining exact-spelling lookups follow suit: getEncodedMessage() finds the
Content-Type header to append the multipart boundary to -- an oddly spelled one
would have kept its multipart type but lost the boundary, a body no mail client
can parse -- and SendmailMailer strips the To and Subject lines that mail() adds
itself whatever their case.

mail by dg SmtpMailer: recovers from a persistent connection the server dropped

A persistent connection is kept open between sends, but the server hangs up on
idle sessions. The client only found out once it wrote into the dead socket,
partway through a message, and every later send on that mailer failed the same
way -- the connection was never re-established.

Before reusing a kept-open connection, the mailer now probes it with NOOP and
reconnects if it is gone. The probe costs one round trip, and only on a reused
connection: a non-persistent mailer dials fresh each time and never sends it.

mail by dg SmtpMailer: added XOAUTH2 authentication

Gmail and Microsoft 365 are retiring basic authentication for SMTP, so PLAIN and
LOGIN alone leave the mailer unable to talk to either without an app password.

setAccessToken() takes the OAuth 2.0 access token, either as a string or as a
callback resolved on every connection, which is what a token that expires needs.
Acquiring and refreshing the token stays with the caller: that is an OAuth
concern, not a mail one.

A rejected token gets the empty line the server waits for after its 334
challenge, so the final error is read instead of leaving the exchange half-open.

send() now cleans up after any throwable, not just SmtpException: the token
callback does I/O of its own and can throw anything at all -- a failed token
refresh, a JSON error. That would leave the socket open, greeted and never
authenticated, and reusing it would fail in ways that look like the server's
fault.

An access token with no username is refused outright: XOAUTH2 names the user in
its credential, so there is nothing to authenticate without one. Silently
skipping authentication would leave the server's puzzling 530 as all there is
to work with.

mail by dg SmtpMailer: authenticates only with mechanisms the server offers

Anything that was not PLAIN fell through to AUTH LOGIN, so a server advertising
neither (or only CRAM-MD5) got a blind AUTH LOGIN and the user got a cryptic
protocol error instead of an explanation. Both cases now raise an SmtpException
that names the problem, and the legacy 'AUTH=PLAIN LOGIN' advertisement is
recognized alongside the standard 'AUTH PLAIN LOGIN'.

With AUTH LOGIN and an empty password the password line was skipped entirely.
The server stays at its '334 password' prompt waiting for a line that never
comes, and the client blocks until the read times out. The line is now always
sent, so an empty password fails fast with the server's own 535 error.

mail by dg SmtpMailer: read() honours the timeout while data keeps arriving

The deadline was only consulted when fgets() came back empty, so it guarded
against a silent server but not against a talkative one. A server emitting an
endless multiline response (250-... with no final line) kept the loop running
forever, with $data growing on every iteration.

The deadline is now checked on every iteration, and a single response is capped
so a misbehaving server cannot make us allocate without bound before it expires.
fgets() itself is bounded by the same cap: without a length it reads to the next
newline however far away that is, and a server sending none would exhaust memory
inside the call, before the cap ever got a say.

mail by dg SmtpMailer: write() checks the result of fwrite()

fwrite() on a socket writes as much as fits into the send buffer and returns the
number of bytes actually written, which for a large message (an attachment) is
routinely less than the whole payload. The rest was silently dropped: the server
then saw a truncated DATA block. A failing write went unnoticed as well, and the
error surfaced only later as a confusing read timeout.

The write now loops until the whole line is out and turns a failure into an
SmtpException naming the reason. Each pass hands fwrite() a fixed window rather
than the whole remainder, whose re-copying would make a large attachment written
in socket-sized pieces quadratic; and a stale error is cleared beforehand so it
is not reported as ours.

mail by dg SmtpMailer: STARTTLS accepts only TLS 1.2 and 1.3

The crypto method combined STREAM_CRYPTO_METHOD_TLS_CLIENT with the TLS 1.1 and
1.2 flags, so a server could still negotiate TLS 1.0/1.1 -- deprecated by RFC
8996 and rejected by every current mail provider.

A failed handshake also raised a raw PHP warning and then a bare 'Unable to
connect via TLS.' The warning is now captured and its message carried into the
SmtpException, so the actual reason (bad certificate, protocol mismatch) shows
up in the error instead of being swallowed.

mail by dg SmtpMailer: STARTTLS defaults to the submission port 587

With encryption: 'tls' and no explicit port, the mailer dialed port 25 -- the
MTA relay port, where submission with credentials is commonly refused -- while
the documentation promised 587. Ports now follow the encryption: 465 for
implicit SSL, 587 for STARTTLS, 25 for a plain connection.

Address building moves to getAddress() and opening the stream to openStream(),
which lets the tests drive the mailer over a socket pair instead of a network.

mail by dg DkimSigner: added Ed25519 signing (RFC 8463) and header oversigning

Ed25519 keys are raw base64 (a 32-byte seed or a 64-byte secret key) while RSA
keys are PEM, so the algorithm is detected from the key itself and no new
argument is needed. Signing goes through ext-sodium, since ext-openssl cannot
sign with Ed25519 keys. The a= tag follows the detected algorithm.

Oversigning lists a header in h= one time more than it is hashed. The extra
mention takes the null input (RFC 6376, §3.7), so it changes nothing about the
signed data -- but appending another instance of the header, a second From:,
which is what many clients display, now breaks the signature. A receiver hashes
every header h= names, so oversigned headers join the hashed set even when
signHeaders does not name them: a name must appear in h= exactly as many times
as it is hashed, plus one for the oversign, or the signature cannot verify.
Opt-in via the new $oversignHeaders argument; From is the recommended value.

One test rebuilds the hash input the way a receiver does, walking h= and
consuming one instance of each header per mention, and checks the signature
verifies against it with openssl_verify().

mail by dg DkimSigner: relaxed canonicalization collapses every WSP sequence

RFC 6376 §3.4.2 step 3 requires all sequences of one or more WSP characters to
become a single space. The pattern only matched runs of two or more, so a lone
tab survived canonicalization: a message with a tab in a signed header produced
a signature the receiver could not verify (it canonicalizes the tab away).

mail by dg DkimSigner: dropped the l= body length tag [SECURITY]

The l= tag states how many bytes of the body the signature covers. Anything
beyond that length stays unsigned, so an attacker can append arbitrary content
to an intercepted message and the DKIM signature still verifies. RFC 6376 §8.2
warns about exactly this, and receivers (Gmail, mail-tester) penalize its use.

Without l= the signature covers the whole body and appended content breaks it.

mail by dg added AGENTS.md & DOCS
mail by dg phpstan fixes
mail by dg improved phpdoc types
mail by dg readonly properties
mail by dg cs
latte by dg Released version 3.1.6
latte by dg TemplateParserHtml: n:attribute checks the tag parser generator protocol like {tags} do

A misbehaving tag parser used as an n:attribute produced raw PHP errors ("Cannot get return value of a generator that hasn't returned", "Attempt to assign property on string") instead of a comprehensible exception. Also unifies the ensureIsConsumed()/popTag() order.

latte by dg Tracy: panel shows render time per template, total and self

Built on the new Extension::afterRender() hook. Self time excludes nested templates, so it shows which template actually consumes the time.

latte by dg Extension: added afterRender() hook, called in finally

It fires even when rendering ends early via {exitIf} or is interrupted by an exception, so extensions can reliably clean up or measure.

latte by dg Helpers: resolveParams() memoizes the reflection scan of the params class

The class was re-reflected on every render; the scan result is identical for all instances, only closure binding differs. About 2.6x faster.

latte by dg Engine: template hash extended from 40 to 64 bits

At 10k templates the collision probability was 1 in 22,000, and a collision silently renders a different template. Cache::isCacheFile() no longer hardcodes the hash length.

latte by dg Tag::closestTag() matches subclasses, as its phpDoc promises

It compared the exact class name, so subclassing core nodes silently broke {iterateWhile}, {rollback} and {include parent}.

latte by dg HtmlHelpers: xlink:href in inline SVG is a URL attribute and gets sanitized
latte by dg HtmlHelpers: classifyScriptType() strips ASCII whitespace around MIME type like browsers do [security]

<script type=" text/javascript "> was classified as raw text while the browser executes it as JavaScript, so variables were printed without JS escaping.

Last synchronization: 2026-08-04 10:03:32