8 Commits (feat/plan-marketing)
| Author | SHA1 | Message | Date |
|---|---|---|---|
|
|
254674f456 |
Bound the mail test-send and real send to a timeout instead of hanging
tests / pest (push) Failing after 7m25s
Details
tests / assets (push) Successful in 21s
Details
tests / release (push) Has been skipped
Details
A blocked outbound port (587 filtered at a firewall, as on the live server) left both MailboxTester and MailboxTransport blocking until PHP's default_socket_timeout (60s) or the reverse proxy cut the request — a 504 with no error the operator ever saw. Mail::build() honours a 'timeout' config key by calling SocketStream::setTimeout() (confirmed by reading MailManager::configureSmtpTransport() and by an end-to-end run against a genuinely unroutable host), so MailboxTester now passes 'timeout' => 10 through it. MailboxTransport builds its EsmtpTransport directly rather than through Mail::build(), so it sets the same call on the transport's stream, at 30s — a queue worker can afford to wait longer than an operator watching a browser, and a timed-out send is retried rather than lost. The test button also now shows a visible "sending" state while the request is in flight, matching the wire:loading span pattern already used on admin/host-create's save button — previously only wire:loading.attr="disabled" fired, leaving a greyed-out button with no other sign anything was happening. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
|
|
|
ebe29f8a5d |
Stop demanding a cipher, a real port, or a decrypted password a mailbox never uses
MailboxTester required a usable SECRETS_KEY unconditionally, refusing an unauthenticated relay's test-send on a fresh install with no key yet, even though real sending never touches the cipher for that mailbox. Gated the check on authenticates, same as the password-presence check beside it. Comparison pass across MailboxTester and MailboxTransport turned up two more of the same shape. MailboxTransport's delegate-cache fingerprint decrypted $box->password unconditionally, even when unauthenticated and never going to use it — a mailbox that once authenticated, stored a password, and later had authenticates unchecked would crash real sending the moment SECRETS_KEY became unusable. And MailboxTester had no guard against a stored port <1; Symfony silently reinterprets that as port 25 rather than refusing it, so the test button could reach whatever happens to listen there and report success for a configuration MailboxTransport refuses outright. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
|
|
|
5d306fce31 |
Let a mailbox send without a password when it never needed one
An SMTP relay with a host and a from address but no username or password - a trusted local or private-network relay - always used to be seeded as a placeholder, because the takeover required a username to treat an account as real, and Mailbox::isConfigured() always required a stored password. Laravel's own SMTP mailer has supported unauthenticated relays all along. Add mailboxes.authenticates (default true, so every existing row keeps its current behaviour) to tell "does not authenticate" apart from "authenticates, but nobody has typed the password yet" - the same empty password column used to mean both. The takeover migration now seeds the real account off the host/address alone and marks it authenticates=false only when neither a username nor a password resolved; isConfigured(), MailboxTransport and MailboxTester all read it before requiring or sending a password; the edit modal gets a checkbox for it with the password field's hint updated to match, in both languages. |
|
|
|
c55a5d2b49 |
Make ssl, tls, and none mean what they say on the wire
EsmtpTransport's autoTls (default true) and requireTls (default false) are independent of the implicit-TLS constructor argument: 'tls' left these at their defaults, so a server that omitted STARTTLS sent mailbox credentials in the clear while reporting success, and 'none' would silently upgrade the moment a server offered STARTTLS. Both MailboxTransport and MailboxTester already duplicated the "ssl, or port 465 ⇒ implicit" half of this decision independently; MailTlsPolicy is now the one place both read, for both dimensions. MailboxTester still builds through Mail::build() rather than a hand-built transport: MailManager::createSmtpTransport() forwards the whole config array to EsmtpTransportFactory as DSN options, which already reads auto_tls/require_tls itself, so the same policy reaches this path through keys the config array already supports. |
|
|
|
3e4d5472a7 |
Move the one SMTP account into the mailbox table it outgrew
The seed migration also guards against SECRETS_KEY being unset at migrate time (encrypting the .env password would otherwise throw and take the whole migration down, on a fresh install as much as in the test suite), and the four tests/Feature/Mail files written before this migration reset the mailbox table in their beforeEach so they keep testing what they did before mailboxes.key collided with the seeded rows. |
|
|
|
961bf62032 |
Make the DSN agree with send() when a mailbox has no password
Re-review of the previous fix round found the "cannot disagree" part of finding 4 was still open. resolution() had unified the log/array/null/ mailbox decision, but inside the mailbox branch describe() returned $box?->address unconditionally while delegate() additionally required isConfigured() before using it. A mailbox with active=true, a real address, and no password produced 'mailbox://support/support@clupilot.com' — reading as entirely healthy — while send() threw. That is exactly the harm the original finding named: the log says fine, the mail fails. resolution() now decides "unconfigured" too, not just "which non-delivering mode": it calls isConfigured() itself, once, and returns that verdict alongside the box. describe() and delegate() both branch on the SAME result again, the same way they already did for log/array/ null — a mailbox with no password now reads as 'support@clupilot.com [unconfigured]' rather than a plain address, and the address stays visible so an operator can tell WHICH mailbox needs fixing. Also renamed a test the re-review flagged as promising more than it checked: "registers a mailer per purpose without touching the database at boot" only ever verified the config array's shape — the DB-touching claim is a separate, already-passing test just below it (defines every purpose mailer as a plain literal...). Renamed to "registers a mailer for every purpose". No behaviour change. Added a test creating a mailbox with no password and asserting the DSN both names the address AND carries the marker, plus that send() still throws — proving agreement, not just a matching label painted on separately. Mutated describe()'s unconfigured branch to drop the marker: the test failed exactly on the missing 'unconfigured' string (DSN read as a plain, healthy-looking address again). Reverted, reran, passes. Full suite: 667 passed. vendor/bin/pint --test clean on both files. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
|
|
|
a39670f920 |
Close the safety-net gaps the review found in MailboxTransport
The reviewer continued the mutation sweep from Task 4's own commit and
found three more unpinned lines in delegate(), plus two real behaviour
gaps, all inside the same 40-line method:
- The fingerprint refresh (the headline feature: a worker's mailer
must pick up a corrected password without a restart) had no test —
mutating its check to "delegate === null" left the suite green.
- The implicit-TLS third argument to EsmtpTransport had no test either
— mutating it to a hardcoded true left the suite green, because the
only assertion touching it was instanceof EsmtpTransport, which the
hanging variant also satisfies.
- isLogging() only ever checked for 'log', but phpunit.xml sets
MAIL_MAILER=array for the whole suite. Task 5 wires real mailables
to these mailers next; the first feature test that sends one without
Mail::fake() would have opened a real SMTP connection.
- describe() and delegate() each ran their own copy of that guard —
which is exactly how deleting it from ONLY delegate() passed the
whole suite in the first place: __toString() kept reporting "log"
from its own untouched copy.
- An unconfigured host/port (Settings::get('mail.host', '') and a
stored null port casting to 0) built a transport pointed at
smtp://:587 or silently downgraded to plaintext port 25, instead of
refusing the way a missing mailbox already does.
Fixes: replaced isLogging() with resolution(), the one place that now
decides "log, array, null-default, or a real mailbox" — describe() and
delegate() both branch on ITS result, so a guard broken in one cannot
look fine in the other. Added an explicit RuntimeException for a blank
host or non-positive port, guarded the same way as the missing-mailbox
case just above it.
Tests: added coverage for the fingerprint rebuild (reuses the same
delegate until the password actually changes, proven by object
identity), the exact DSN string for both the STARTTLS (587) and
implicit-TLS (465) cases, 'array' and unset-default both landing on a
non-delivering transport, and the blank host/bad port throwing.
Mutated each of these four in turn on the final code and confirmed:
fingerprint check removed -> the reuse test fails (same object handed
back after the password changed); TLS argument hardcoded true -> the
587 DSN test fails ('smtps://...' where 'smtp://...:587' was
expected); NON_DELIVERING narrowed back to ['log'] -> both the array
and unset-default tests fail (a real EsmtpTransport where a safe
transport was expected); host/port guards removed -> both throw
expectations fail. Reverted each, reran, all twelve tests pass again.
Replaced the two tests that reached into delegate() via Closure::bind
to check "log or SMTP" with one behavioural test: point Settings at a
closed loopback port (127.0.0.1:1, so refusal is instant and entirely
local — no DNS, no dependency on this environment's network egress
policy) and assert Log::shouldReceive('debug')->once() while sending
for real through Mail::mailer('cp_support')->raw(...). Mutated
NON_DELIVERING to drop 'log' specifically and confirmed this test
alone catches it: a real connection attempt to the closed port throws
TransportException (Connection refused) in under 300ms. Reverted,
reran, passes again.
Also: the two tests that only checked the config array's shape and a
snapshot of the resolved address promised more than they verified —
CLAUDE.md R19 names a test that recomputes the implementation as
checking nothing. Added a source-level test that the five cp_* config
entries are parenthesis-free literals (same technique
DisplayTimezoneTest/IconLayoutTest use: a property of the file, not of
one run) as the actual "no query at boot" proof, and renamed the
send-time test to what it verifies now that the fingerprint test above
covers the live-refresh claim properly.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
|
|
|
095d8e694a |
Resolve the sending mailbox when the mail goes out, not at boot
Five purpose-named mailers (cp_maintenance, cp_provisioning, cp_support,
cp_billing, cp_system) land in config/mail.php as plain static entries —
no query runs to build them. Mail::extend('mailbox', ...) in
AppServiceProvider registers a transport that looks up its mailbox
through MailboxResolver only once a mailer is actually resolved, which
for queued mail is inside the worker at send time, never at boot.
Mutation-tested the brief's own three tests by deleting the MAIL_MAILER
guard from MailboxTransport::delegate(): all three stayed green. They
only exercise __toString()/describe(), a code path separate from the one
that actually sends. Added two tests that reach into delegate() itself
(a bound closure, since it's private) and assert the transport it
actually builds: LogTransport while MAIL_MAILER=log, EsmtpTransport once
it isn't. Re-ran the same mutation against the strengthened suite: it
now fails (EsmtpTransport where LogTransport was expected), then passes
again once reverted.
Verified two things against the installed versions before writing this:
LogTransport takes a Psr\Log\LoggerInterface and Log::channel(null)
resolves to the default channel rather than throwing, and
Mail::extend('mailbox', ...) is read by
MailManager::createSymfonyTransport() via $config['transport'] before it
would fall back to createSmtpTransport() — both matched the brief
exactly, no changes needed there.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|