Commit Graph

205 Commits (2b2bb439a5efe330cd9c7a9f2de660c367c034e0)

Author SHA1 Message Date
nexxo 2b2bb439a5 Stop the console logout from destroying a shared portal session
Where the console and the portal share a host (shared/fallback mode —
this VM's own mode), both guards keep their login key in the SAME
session. session()->invalidate() is flush() + migrate(true) — it
discards every attribute in the session, not only the operator's, so
signing out of the console silently signed a customer out of the
portal too if the same browser carried both. Replaced with
session()->regenerate(): a new session id, so there is no fixation
risk, while attributes belonging to any other guard survive untouched.
2026-07-28 13:58:42 +02:00
nexxo 5e22e16291 Send a guest bounced off the console to the console's own sign-in
ApplicationBuilder::withMiddleware() defaults redirectGuestsTo() to
fn () => route('login') before bootstrap/app.php's own callback ever
runs — always the portal's Fortify page, regardless of which side of
the site turned the guest away. In non-exclusive (shared, this VM's
own mode) that sends a guest bounced off /admin to a sign-in form whose
table holds no operators: no 404 any more, but a sign-in that can never
succeed. Exclusive mode happened to self-correct only because
RestrictAdminHost 404s the wrong host before this ever ran.

Fixed for both modes at once rather than depending on which one a given
deployment runs: ask AdminArea, the one place routes/web.php's own
registration and RestrictAdminHost already agree is the source of truth
for "is this the console".
2026-07-28 13:32:05 +02:00
nexxo 47812cfca9 Authorize the console's live feed on the operator guard, not the default
POST /broadcasting/auth carries only the 'web' middleware group
(Laravel's own withBroadcasting() registers it that way), so
PusherBroadcaster::auth() resolved retrieveUser() on the DEFAULT guard
before the admin.runs channel's own Auth::guard('operator') check ever
ran — found nobody, since an operator is never signed in on 'web', and
threw straight from there. The guard was correct but unreachable.
Fixed by naming both guards on the channel registration itself.

Also adds broadcasting/auth to RestrictAdminHost::SHARED: in exclusive
mode it was neither admin.* by name nor on the shared list, so it 404'd
on the console host too — degrading silently to wire:poll.4s rather
than breaking outright, which is why nothing screamed.
2026-07-28 13:31:50 +02:00
nexxo 54fb6deb03 Make the RBAC move retry-safe and order-safe, and round-trip permissions
Three faults in the same migration:

- up()'s removal loop was driven by operatorUserIds() (model_has_roles
  WHERE model_type=User) via the $carried array — but carryAcross()
  repoints model_has_roles to Operator for every operator before a
  single users row is deleted. A run that stops partway (the
  customer-conflict abort, or a dropped connection on real MariaDB,
  which this file deliberately runs without a transaction) leaves that
  worklist empty on the next attempt, so `migrate` records the
  migration as applied while every leftover users row still grants a
  live portal login on the operator's own password. Now driven by
  `operators` joined to `users` on email: that finds every
  carried-but-not-yet-deleted row regardless of which run carried it.

- operatorUserIds() had no ORDER BY. carryAcross() remaps four owned
  columns in place, keyed on the old users.id, while operators.id hands
  out new values in the same order the list is walked — unordered, a
  later operator's freshly assigned id can equal an earlier operator's
  still-unprocessed users.id, and the second remap catches a row the
  first one already rewrote. One operator ends up silently owning
  another operator's VPN peer. Added ->orderBy('model_id').

- down() restored model_has_roles but not model_has_permissions, so an
  operator holding a direct (not role-granted) permission lost it on
  rollback. Mirrored the same move carryAcross() does forward.

Also removes seed_roles_and_permissions' now-stale
User::OPERATOR_ROLES/hasAnyRole()/assignRole() bootstrap loop — all
three gone from User per this branch's own work — which only a deep
rollback (down() of the move migration recreates is_admin users) would
ever have reached, but would fatal when it did.
2026-07-28 13:31:34 +02:00
nexxo 6e81d6e93e Delete Customer::assertNotAdmin() — the fault it guarded is now impossible
It checked Auth::guard('operator')->check() instead of the users row it
was handed, so with an operator's own session still attached (shared
mode carries the same cookie through both legs of impersonation) it
threw the moment a customer's email already had a users row — a 500 on
the first impersonation of anyone who had signed into the portal
themselves. Deleting it rather than fixing the check: operators live in
`operators` now, never in `users` (R21), so a users row can no longer
BE an operator account for this to guard against. Also drops the dead
`'is_admin' => false` write — the column reads from nowhere any more.
2026-07-28 13:31:14 +02:00
nexxo 5a40107256 Return to the console by name after impersonation, not a bare path
redirect()->to(AdminArea::home()) resolved against whatever host the
leave() POST itself arrived on — the portal's, once the console has a
hostname of its own — landing the operator on the portal's dashboard
instead of back at the console. route('admin.overview') is domain-bound
to the console and generates the correct cross-host URL, matching what
this line did before this branch. The test named after this behaviour
now actually asserts it.
2026-07-28 13:30:56 +02:00
nexxo 88d9a66f44 Sign the impersonation link against the portal's own host, not the console's 2026-07-28 12:24:53 +02:00
nexxo bdb9c0a04a Write down that the console and the portal share no identity 2026-07-28 12:15:13 +02:00
nexxo e519653f66 Let the owner make two-factor compulsory, without locking themselves out 2026-07-28 12:08:08 +02:00
nexxo 26c84e3695 Hand impersonation over by signed link, not by a cookie that cannot cross hosts 2026-07-28 12:02:22 +02:00
nexxo 73dec7f685 Prove ConfirmsPassword resolves its guard, not the ambient default
actingAs()/Livewire::actingAs() call Auth::shouldUse(), which made every
existing test for this trait pass identically whether confirmPassword() asked
for the right guard explicitly or just read whichever guard happened to be
default — reverting the explicit guard() call to a bare auth()->user() passed
the full suite. This test signs the operator in without actingAs and
deliberately decouples the ambient default guard from their session before
calling confirmPassword(), so only the explicit resolution can succeed.
2026-07-28 11:54:13 +02:00
nexxo 8f8a0ad4f7 Stop the console serving the portal's sign-in page
Deleted tests/Feature/Auth/ConsoleLoginLandsInTheConsoleTest.php — it tested
the three deleted response classes. Its intent (an operator's sign-in landing
in the console, not the portal) is covered by OperatorLoginTest's "signs an
operator in on the operator guard" and OperatorTwoFactorChallengeTest's "signs
the operator in once the code actually checks out", both confirmed present
before deletion; the route-separation half is covered by the new
ConsoleHostSeparationTest cases added in this commit.
2026-07-28 11:45:34 +02:00
nexxo 3f318c3f6a Give the console a front door of its own 2026-07-28 11:45:22 +02:00
nexxo 513cadc5ce Confirm a console password against the console's own record 2026-07-28 11:17:34 +02:00
nexxo f54d00b3e7 Move the value remap and constraint swap before the delete, and fix the seeder 2026-07-28 11:00:09 +02:00
nexxo 87c49de6e5 Move the console's identity out of the customer table 2026-07-28 10:31:43 +02:00
nexxo 8d1fa52a5a Give the people who run CluPilot a table of their own 2026-07-28 09:20:54 +02:00
nexxo 2a06b5dfb2 Say EU and stop there, as the approved template does 2026-07-28 09:19:24 +02:00
nexxo 2979f008b5 Stop a mismatched-key decrypt from crashing the mailbox test button
tests / pest (push) Failing after 7m15s Details
tests / assets (push) Successful in 21s Details
tests / release (push) Has been skipped Details
tests / pest (pull_request) Failing after 7m17s Details
tests / assets (pull_request) Successful in 24s Details
tests / release (pull_request) Has been skipped Details
2026-07-28 07:26:42 +02:00
nexxo a6a9c76660 Trust an explicitly configured mail host or port over the placeholder
env('MAIL_HOST')/env('MAIL_PORT') returning anything, or MAIL_URL supplying
either, now counts as "supplied" and wins over the sentinel comparison — a
relay genuinely on 127.0.0.1:2525 no longer reads as unconfigured. The
sentinel stays as a fallback for when neither signal says anything (including
under config:cache, where env() goes null and this collapses to today's
behaviour).
2026-07-28 07:10:53 +02:00
nexxo 15c81489d4 Stop downgrading an opportunistic smtp scheme to plaintext
MAIL_SCHEME=smtp is Symfony's opportunistic scheme (upgrade to STARTTLS
if offered, don't fail if not) — not a "no encryption" statement.
Mapping it to 'none' silently turned every existing MAIL_SCHEME=smtp
installation's encrypted-when-possible connection into an always-
plaintext one. Maps to 'tls' (STARTTLS required) instead: stricter than
smtp's own default, so a relay lacking STARTTLS now fails loudly rather
than leaking credentials.
2026-07-28 06:47:11 +02:00
nexxo 729f57755a Clear every mailbox's verification when the shared server config changes
saveServer() wrote host/port/encryption without touching any mailbox, so
the console kept showing every mailbox as verified against a server
nothing had tested since. Mirrors EditMailbox::save()'s existing guard on
a single mailbox's own identity, just at server scope: only clears when a
field actually changed, so re-saving unedited values leaves a real
verification alone.

Both places now delegate the actual clear to new Mailbox::
invalidateVerification()/invalidateAllVerifications() methods rather than
touching last_verified_at directly, sharing the mutation. The "did this
change" comparison stays separate in each caller - one diffs a loaded
model's attributes, the other diffs persisted settings against incoming
scalars, and forcing them through one function would add machinery no
actual duplication justifies.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-28 06:36:06 +02:00
nexxo 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>
2026-07-28 06:21:28 +02:00
nexxo 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.
2026-07-28 05:54:21 +02:00
nexxo 40a6b3269d Trust a stated mail scheme over the port guess when seeding encryption
MAIL_SCHEME=smtps or an smtps:// MAIL_URL on a non-465 port used to be
downgraded to STARTTLS because mail.encryption was derived from the port
alone. Resolve the scheme first (from MAIL_SCHEME directly, or from the
URL's own scheme via the same ConfigurationUrlParser Laravel uses) and
only fall back to the port heuristic when nothing was actually stated -
which is also what keeps this machine's invalid MAIL_SCHEME=tls from
silently steering the result.
2026-07-28 05:53:51 +02:00
nexxo 74ac406fc0 Make isUsable() apply the same 32-byte rule encrypter() does
isUsable() checked only "is SECRETS_KEY nonempty", so a set-but-malformed
key (wrong length, garbage base64) read back as usable even though
encrypter() rejects it two lines below. EditMailbox::save() and
MailboxTester::run() both gate on isUsable() specifically to avoid an
uncaught RuntimeException reaching the operator; a lying isUsable() meant
that guard did not fire in exactly the configuration it exists for.

Both methods now read resolveKey() — the base64: prefix, the raw-base64
path, the 32-byte check — so they cannot disagree about a value either
one is given. SecretVault::isUsable() and the secrets console page's
"no key" banner both delegate down to this and are covered here too, not
assumed to inherit the fix correctly.
2026-07-28 05:09:09 +02:00
nexxo f0c3bd9c2e Resolve MAIL_URL before seeding, so a takeover doesn't blank out a working relay
The seed migration read config('mail.mailers.smtp.host') and its three
siblings directly. An install configuring SMTP through MAIL_URL (the
other form config/mail.php supports) never sets those individual keys —
MailManager only substitutes the URL's components in at transport-build
time, inside its own protected getConfig(). Read raw, that left the
migration seeding config/mail.php's own placeholder defaults, which the
UNCONFIGURED_HOST/UNCONFIGURED_PORT guards then read as genuinely unset:
blank host, port 0, an unconfigured mailbox, for an install that was
sending mail successfully.

resolveSmtpConfig() calls the same Illuminate\Support\ConfigurationUrlParser
MailManager::getConfig() itself delegates to, so this is the same
resolution rather than a second copy of its merge order. Verified against
the real (protected) getConfig() via reflection: the URL wins for
host/port/username/password wherever it actually supplies one, and an
unset MAIL_URL leaves this a no-op.
2026-07-28 05:08:58 +02:00
nexxo e8a3b8d5be Seed the mailbox address from mail.from.address, not the SMTP login
The takeover migration seeded `address` from MAIL_USERNAME and left
`username` null unconditionally, conflating the SMTP login with the
sender address. Whenever the two differ — an API-key username, a
shared relay login — mail would go out From the login instead of the
address the system was actually configured to send from, exactly like
this installation: MAIL_USERNAME is no-reply@clupilot.com but
MAIL_FROM_ADDRESS is hello@clupilot.local.

`address` now reads config('mail.from.address'), falling back to the
SMTP login only when that reads back config/mail.php's own unset
placeholder (env('MAIL_FROM_ADDRESS', 'hello@example.com') — the same
masking problem UNCONFIGURED_HOST already guards against for
MAIL_HOST). `username` is set to the SMTP login only when it actually
differs from the resolved address, since Mailbox::smtpUsername()
already falls back to address when username is null. display_name
now reads config('mail.from.name') instead of a hardcoded literal.

The placeholder domain for the four non-configured mailboxes still
comes from the SMTP login, not from mail.from.address — the two can
name different domains, and only the login's is proven to accept mail
for a given install.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-28 04:49:37 +02:00
nexxo 3278f063ea Validate purpose mappings against real mailboxes, and clear stale verification on identity changes
savePurposes() only checked that each key was a nonempty string, so a
stale or tampered mailbox key (reachable by posting straight to
/livewire/update) saved successfully while MailboxResolver silently
returned null for it. Every purpose's key must now name a mailbox that
exists; system's must additionally be active, since every unmapped or
inactive OTHER purpose falls through to it.

EditMailbox::save() also cleared last_verified_at only when the
password changed, but smtpUsername() depends on address and username
too (falling back to address when username is blank) — changing
either while leaving the password untouched kept showing a successful
verification for an identity nothing had actually tested.
2026-07-28 04:35:16 +02:00
nexxo 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.
2026-07-28 04:35:03 +02:00
nexxo 5de1f45703 Close two ways a mail.manage session could take over outgoing mail
I3: nothing stopped an operator unchecking Aktiv on the mailbox that
mail.purpose.system points at. MailboxResolver::active() filters an
inactive mailbox at BOTH the direct lookup and the system fallback, so
that one checkbox could take mail.purpose.system to null and every
purpose without its own mapping down with it — the existing
system_required guard on savePurposes() reads as if this were covered,
but it only blocks the empty-mapping path, not this one.
EditMailbox::save() now refuses active=false against the mailbox 'system'
currently points at, the same way savePurposes() already refuses to leave
'system' empty.

I4: Admin\Mail::saveServer() writes mail.host — the platform's outbound
relay for every purpose mailbox at once — and CloudReady::toMail() puts a
customer's Nextcloud admin password in cleartext in the message body. So a
signed-in mail.manage session was already a credential-interception
primitive, the same threat Admin\Secrets' second gate exists for. Both
saveServer() and EditMailbox::save() (only when a new password is actually
being typed) now also require passwordRecentlyConfirmed(), following the
ConfirmsPassword pattern Secrets.php and Settings.php's two-factor actions
already use — including their per-action UI shape (an inline confirm form
scoped to the gated action), not a whole-page lock, so address, display
name, username, no-reply, active and the purpose mapping stay reachable
without confirming, per the brief's capability split.

Both gated and ungated paths are covered by tests, and both guards were
mutated away and confirmed to fail before being restored.
2026-07-28 03:50:19 +02:00
nexxo 7102d01bcc Keep the purpose mailer through the queue instead of losing it to the default
Mail::to($x)->queue($mail) resolves the DEFAULT mailer, and
Illuminate\Mail\Mailer::queue() (vendor Mailer.php:482) does
`$view->mailer($this->name)->queue($this->queue)` — overwriting
MaintenanceAnnouncementMail/MaintenanceCancelledMail's own 'cp_maintenance'
with the default mailer's name ('array' in tests, 'log'/whatever
MAIL_MAILER is in prod) before the job is even built. Both mails kept
sending through the old .env account instead of MailboxTransport. CloudReady
was never affected — MailChannel.php:66 preserves a notification's own
mailer.

Fix: name the mailer before queueing — Mail::mailer($mail->mailer)->to(...).
$this->name on the resolved Mailer is then already $mail->mailer, so the
vendor overwrite becomes a no-op instead of a silent downgrade. Confirmed by
reading the vendor source, not assumed.

SenderAddressTest's "names the purpose mailer..." test asserted
$mail->mailer on a bare, never-queued mailable, which proves nothing about
what survives queueing — the constructor sets it right regardless of what
MaintenanceNotifier does with it. Replaced it with one that drives
MaintenanceNotifier::deliver() under Queue::fake() and inspects the pushed
SendQueuedMailable's mailable. Verified it fails against the pre-fix code
first (the pushed job carried mailer = 'array'), then verified the fix
makes it pass, then mutated the fix away and watched it fail again.
2026-07-28 03:50:02 +02:00
nexxo 99b7393361 Stop committing a real password, and say the truth about what carried over
MailboxSeedMigrationTest.php had the actual .env MAIL_PASSWORD for
no-reply@clupilot.com hard-coded into a test fixture — replaced with an
obvious dummy. Grepped the whole branch (576cfca..HEAD) and the working
tree for that string and for every other real secret value in .env;
nothing else leaked.

Separately, the seed migration's SECRETS_KEY-missing warning claimed a
password "was NOT copied into the mailbox table" even when a vault row
already existed and its ciphertext WAS carried across a few lines below,
unconditionally on $canEncrypt. An operator debugging a live mail outage
would have been sent to re-enter a password that was already sitting in
the column. The warning now distinguishes the two cases and says which
one actually happened.
2026-07-28 03:49:37 +02:00
nexxo befb67327f Prove a mailbox works by actually sending from it 2026-07-28 02:56:47 +02:00
nexxo a9c777c79a Rename the SECRETS_KEY page test to match what it actually checks
It only ever loaded the page and asserted the banner, never a password
save — but its name claimed the crash-on-save case, which is exactly the
promise that let that bug ship unnoticed once. The test that earns the
crash claim is its new neighbour, added for the same finding.
2026-07-28 02:24:58 +02:00
nexxo 67a3e78cb4 Stop the mailbox password save from crashing without SECRETS_KEY
EditMailbox::save() now checks SecretCipher::isUsable() before touching a
typed password and reports it as a form error, instead of letting
SecretCipher::encrypt() throw all the way to Laravel's debug page. Also
switches the mail.manage migration to Role::findOrCreate, matching every
sibling capability migration, so a squashed replay can't hit
RoleDoesNotExist.
2026-07-28 02:22:36 +02:00
nexxo 4211f3dfab Give the mailboxes a page, and the support sender its own capability 2026-07-28 01:57:11 +02:00
nexxo a12ab148b3 Close the data-loss paths the review found in the mailbox takeover
The vault's mail.password row is no longer deleted by this migration at
all, in either direction: deleting it unconditionally destroyed a real
password whenever MAIL_USERNAME was empty (nowhere to carry it to), and
even on a successful carry-over a later rollback had nothing left to fall
back to. Reproduced both against real MariaDB, along with a migrations-
table drift that crashed a retry on the mailboxes.key unique constraint —
fixed with Mailbox::firstOrNew() and a transaction around each direction.
down() now goes through the new Settings::forget() instead of a raw
delete, so it stops leaving the settings cache holding what it just
removed. A skipped .env password (SECRETS_KEY still unset) now prints an
operator-visible line instead of migrating clean and failing silently
later. Host/port that resolve to config/mail.php's own placeholder
defaults are treated as unset rather than as a real relay.
2026-07-28 01:15:50 +02:00
nexxo 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.
2026-07-28 00:24:54 +02:00
nexxo c899d41946 Pin CloudReady's null-mailbox guard and share it with the trait
A mutation the reviewer ran (if ($box !== null) -> if (true)) left every
test green: CloudReady hand-rolled the same null-mailbox guard as
SendsFromMailbox, but nothing exercised its null branch. Added the
missing test, then extracted the decision itself (null mailbox -> no
sender fields, no_reply -> no Reply-To) into one shared method so an
Envelope-shaped and a MailMessage-shaped consumer can no longer disagree.
2026-07-27 23:59:05 +02:00
nexxo 64a33c675e Put a real sender on every mail, and a reply address where one helps 2026-07-27 23:40:34 +02:00
nexxo 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>
2026-07-27 23:17:42 +02:00
nexxo 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>
2026-07-27 23:02:18 +02:00
nexxo 0be9c61484 Make a deactivated mailbox fall back like a deleted one
Review of Task 4: MailboxResolver::for() only fell back on a missing
mapping or a missing row. Deactivating a mailbox — the operator's
explicit "do not use this" — left it resolvable anyway, so
MailboxTransport reached it, found isConfigured() false, and threw
instead of quietly using the next candidate the way every other
"this mailbox is unusable" case does. Worse, __toString() still
reported the deactivated address as though it were healthy.

named() now runs through a new active() filter at BOTH lookup sites —
the purpose's own mapping and the system fallback — so an inactive
system mailbox is refused exactly like a missing one, not handed out
as the last resort. A missing password stays deliberately unfiltered
here: that is a MailboxTransport concern (loud failure naming the
mailbox), not a resolver fallback trigger — silently rerouting mail
because a password hasn't been typed yet would hide the gap instead
of surfacing it.

Added two tests and mutated active() to a no-op filter to confirm they
catch it: both failed (one expected the system mailbox, got null; the
other expected null, got the inactive mailbox's full attribute dump).
Reverted, reran, both pass again.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-27 22:57:22 +02:00
nexxo 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>
2026-07-27 22:28:37 +02:00
nexxo 06b84c5cd5 Say which kind of mail leaves from which mailbox 2026-07-27 22:00:16 +02:00
nexxo c5021c4aa7 Make the SECRETS_KEY test actually discriminate from APP_KEY
The old test rotated app.key and expected the password to stay
readable, but Laravel's encrypter is a container singleton resolved
once — config()->set('app.key', ...) never rebuilds it, so the
rotation the test performed was invisible to any code path going
through it. Confirmed by mutation: rewiring Mailbox to Crypt::
encryptString/decryptString (genuinely APP_KEY-keyed, the exact
regression this constraint exists to forbid) left the old test green.

Replace it with two tests: a positive proof that rotating SECRETS_KEY
makes a stored password unreadable (the one that discriminates), and
a separate check that rotating APP_KEY does not, with
forgetInstance('encrypter') so that rotation is actually visible to
whatever the password accessor resolves.

Re-ran the same mutation against the new test: it fails (exit 1, the
SECRETS_KEY test reports the expected DecryptException was not
thrown), then passes again once the mutation is reverted.
2026-07-27 21:49:46 +02:00
nexxo aaddffc096 Reuse the existing UUID trait instead of a second copy of the logic
Mailbox hand-rolled the same creating-time UUID assignment that
App\Models\Concerns\HasUuid already provides to 17 other models under
R11 (URLs address records by UUID, not integer PK), and skipped the
route-key binding that comes with it. Swap onto the trait, and cover
both the assignment and the route key with a test matching the
convention already used for Host/Order.

Also match MailboxFactory's @extends annotation to how the other
factories in the repo write it (Pint's fully_qualified_strict_types
rule turns a fully-qualified docblock reference into an import plus a
short name anyway, so this is what it would end up as either way).
2026-07-27 21:31:39 +02:00
nexxo 270ec942d6 Give every sending address a record of its own 2026-07-27 21:25:35 +02:00
nexxo 38c5518f45 Drop the stray path comment after the opening PHP tag 2026-07-27 21:19:32 +02:00