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