| 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 | | | |