Framework

Repository Author Commit message Committed SHA  
 
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.

latte by dg FirstLastSepNode: {first}, {last}, {sep} outside {foreach} triggers a deprecation warning at compile time

Previously it crashed at runtime with cryptic 'Call to a member function isFirst() on null'. Templates receiving $iterator from the caller keep working for now; becomes a compile-time error in Latte 3.2.

latte by dg Filters: last, random, implode and commas accept iterable like their counterparts first, slice, filter
latte by dg added |map filter, counterpart of |filter
latte by dg Filters: date() with DateInterval and default format printed the format string literally, now requires explicit format
latte by dg Filters: padLeft/padRight with empty pad string and divisibleBy zero throw a comprehensible exception instead of DivisionByZeroError
latte by dg Filters: indent() no longer interprets $ and \ in indentation characters as regexp backreferences
latte by dg Filters: explode() and random() throw RuntimeException on invalid UTF-8 instead of TypeError
latte by dg Filters: reverse and column no longer lose elements of iterators with duplicate keys

Last synchronization: 2026-08-12 17:02:56