Commit Graph

14 Commits (main)

Author SHA1 Message Date
nexxo fed4acf31c Accept terms instead of a start date, and fix the sender no server accepts
**The test mail was rejected: "553 5.7.1 Sender address rejected: not
owned by user no-reply@…".** Every purpose mailbox here has its own SMTP
account, and a mail server lets an account send only from the address it
owns. Mail::to() hands back a pending mail bound to the DEFAULT mailer and
sendNow() then ignores the mailer the mailable asked for, so a mail
addressed support@ went out over the no-reply@ login. It sends through the
mailable's own mailer now, and the cloud-ready preview — the one mailable
with no mailbox at all — takes the provisioning mailbox like the real
notification does.

Writing the test for that found the same bug in production code:
**InvoiceMail and OrderConfirmationMail set their From to billing@ and
never named a mailer**, so both went out over mail.default's no-reply@
login. On this installation no customer had ever received an invoice mail
or an order confirmation; they rendered perfectly, queued without
complaint and were refused at the door. MailSenderOwnershipTest now scans
for the mismatch: a mail that takes a mailbox From must use that
mailbox's mailer.

**The box on the order page accepts the terms now**, not an immediate
start. It used to carry the whole FAGG §16 sentence, which read like a
choice between "now" and "in fourteen days" — and there is no second
option. The terms are what regulate the sale, so they had to exist:
resources/views/legal/terms.blade.php replaces the placeholder with
fourteen sections written from what this software actually does — the
delivery, the capacity queue, the full refund on withdrawal, the
cancellation at period end, the deletion deadlines. The company data
comes from CompanyProfile, so the page and the invoices cannot drift. No
availability figure and no liability cap has been invented.

No order goes through without it: the button is unusable until the box is
ticked and says why, and CheckoutController still refuses server-side —
the browser half refuses nothing. The request field is `terms_accepted`;
the Stripe metadata key stays `immediate_start`, because a session opened
before a deploy is paid after it and the webhook would find nothing under
a new name.

**"Wird der Account nach fünf Tagen gelöscht?"** Only an unconfirmed one.
That was the whole of what we said, so the answer looked like yes. The
second rule now exists and is stated: PruneDormantAccounts removes a
confirmed account after a year when it never had a package — no customer
record at all, which is where every order, contract and seven-year invoice
hangs. A fortnight's notice goes out first, once, and `dormant_warned_at`
is what permits the deletion: an account whose warning never went out is
never removed. Signing in resets the clock, measured off the device rows
because users has no last_login_at.

Both deadlines are said in the portal settings, on the verification page,
and in the terms — each reading the number off the command that enforces
it.

Also: the wordmark scan matched any element whose text merely BEGINS with
the company name, which a paragraph of terms does. It looks for the lockup
form now (no whitespace after the tag), which is what it was always about.
The seven checkout tests in the parallel session's files were posting the
old field name and now post the new one.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 15:22:35 +02:00
nexxo c354ad1463 Make mails readable on a phone, and let an operator look at one
Three things the owner reported, and one sentence that promised something
this product does not do.

**The mails had a fixed width.** components/mail/layout.blade.php set
width="600" and width:600px, so every phone showed a 600px canvas in a
390px window and one had to scroll sideways to read a sentence. Now the
table is width="100%" with max-width:600px — fluid, with 600 as a ceiling
rather than a floor. A mail cannot fix this with a media query (Outlook
renders with Word and drops <style> blocks), which is why the whole file
is inline attributes and why the shrinking has to be built in. Side
padding went 40px -> 24px in the layout and in all nine mail views:
40 each side leaves 278px of a 390px screen to read in.

**There was no way to look at a mail.** An invoice mail needs an invoice,
a maintenance announcement needs a window, and "register an account to
see whether the confirmation reads well" is not a workflow — which is how
the fixed width survived for months. App\Services\Mail\MailPreviews
builds all nine from sample data, every record with make() and never
create(), so a preview leaves no invoice, order or window behind and
draws no number from a series. Two ways to see one: in a tab as the
document itself, or sent to the signed-in operator's own address. There
is no recipient field — that would be a form for mailing strangers.

sendNow(), not send(): every mailable here implements ShouldQueue, and
send() defers to the queue for those, which would have put a failure in a
worker's log while the button reported success to somebody waiting for
the mail. The test caught that.

**The booking page had no shell.** Order declared layouts.portal — the
bare shell the sign-in pages use, no navigation and no padding — so the
page sat flush against the top and bottom of the window with no way back
to anything. layouts.portal-app, like every other portal page.

**The consent box said something untrue.** Its refusal read "otherwise we
can only build your cloud once the withdrawal period has ended", which
describes a path that does not exist: nothing here delivers fourteen days
later, and an unticked box simply means no order. Said plainly now, and
said on the page before somebody runs into the refusal.

Also: GrantedPlanPortalTest asserted assertDontSee('179'), which matches
any id containing those digits — and ids climb through a suite run
because a rolled-back transaction does not reset an auto-increment
counter. It passed alone and failed in company. Asserts on '179,00' now,
which is what a leaked price would actually look like.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 14:50:37 +02:00
nexxo a84251260c Read the mailbox password where it actually is, and let the console test it
tests / pest (push) Failing after 9m6s Details
tests / assets (push) Successful in 22s Details
tests / release (push) Has been skipped Details
The inbox kept saying no mailbox was set up while the password sat right
there on the settings page. isConfigured() read
config('services.inbound_mail.password'), which is only ever the .env
value: this project deliberately does not overlay stored secrets onto config
at boot — SecretVault's own docblock says so, and HttpMonitoringClient reads
its token at the point of use for exactly that reason. My docblock even
asserted the opposite mechanism, which I never verified. It reads the vault
now, and falls back to .env because SecretVault::get() already does.

And a way to find out. "Verbindung prüfen" saves the form first — testing
what is on screen while the server still holds the previous values would
report on a mailbox nobody configured — then performs the same four verbs
the real fetch performs: LOGIN, SELECT, SEARCH UNSEEN. A check that proves
something easier than the actual job is worse than none.

What it reports back is one of five fixed words, never the server's own
prose: an IMAP error carries the command it failed on, and for LOGIN that
command contains the password. Each word maps to a sentence an operator can
act on — address and port, mailbox and password, the folder.

The line saying when the mailbox was last reached is on both pages, and it
is written by the SCHEDULED run as well as by the button. Otherwise "last
checked" would mean "last time somebody pressed a button", which is the less
interesting of the two: a mailbox that stopped answering at three in the
morning is the one worth seeing, and nobody presses a button at three in the
morning. It is also shown when the answer is "never" — an empty inbox and a
mailbox that stopped answering look identical, and this line is the whole
difference.

A fetch against a mailbox that just refused the login now takes nothing in
and records the refusal, rather than reporting "nothing new" — which was
true and useless.

Also: HostStepsTest asserted the literal clupilot.com for a host name. That
was true until CLUPILOT_DNS_ZONE was set to clupilot.cloud on this
installation, and then a correct configuration failed a test. The zone is
pinned in the test now; what it is about is the shape of the name and which
address it carries.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 13:12:29 +02:00
nexxo 9da1358802 Stop charging VAT to the customers who owe us none
A verified EU business outside Austria gets reverse charge — rate 0, no VAT
line, the note on the invoice — and was still charged 214,80 € for a 179,00 €
package, because one Stripe Price carried the domestic gross for everybody.
There is no VAT line on their document to reclaim, so it was a flat 20 %
surcharge on exactly the customers who read their invoices, and the document
then stated the whole 214,80 € as net at 0 %, so they self-accounted their own
VAT on a base a fifth too large.

A Stripe Price can ask who is buying after all: there are two per sellable
thing now, the domestic gross and the bare net, both live on one Product, and
the checkout picks by TaxTreatment. The rule is Austria B2B 20 %, other EU B2B
without VAT — a domestic business still pays the gross, reclaims it as input
tax, and the price on the website is still the price charged for them.

- stripe:sync-catalogue mints and archives both halves of every pair, per plan
  and per module; `reverse_charge` on stripe_plan_prices/stripe_addon_prices
  says which is which, and joining it on subscriptions.stripe_price_id says
  which one a running contract is billed on. A rate change moves the gross
  Price and leaves the net one alone, because the net owes nothing to the rate.
- A status that changes after the sale converges: a verification (or a lapse)
  makes stripe:reprice-subscriptions move the contract onto the other Price
  with PRORATE_NONE, because the term is already paid for. The module items
  follow the same way.
- Downstream follows: the invoice total equals what was taken, the proof
  register expects the figure this customer is actually charged (so a correct
  reverse-charge sale is no longer flagged as a mismatch, and an overcharged
  one is), the setup fee obeys the same rule, and the booking page quotes what
  it will charge.
- StripeClient gained activatePrice(). Archived Prices were brought back in our
  own table and left inactive at Stripe, so a rate that moved and moved back
  pointed the catalogue at a Price no checkout could be opened for.

An unverified number is still charged the gross — an unchecked string must
never be a discount — and where the net Price is missing the checkout refuses
rather than reaching for the domestic one.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 02:15:41 +02:00
nexxo 33566cd404 Show the customer, and write the answers once
tests / pest (push) Failing after 9m49s Details
tests / assets (push) Successful in 21s Details
tests / release (push) Has been skipped Details
Two complaints, one cause: the customer page was a compose box with three
lists under it. It showed nothing ABOUT the customer, and every answer was
typed from scratch.

The page is five tabs now. Stammdaten — contact, phone, since when,
language, and the tax standing: consumer or business, with "nobody asked"
saying so rather than passing for business, because that answer decides
whether a withdrawal right exists at all. Paket — the contract's own
FROZEN figures, the modules at the price they were booked at, and the
machine: address, state, host, datacenter. Zahlungen — invoices with net,
gross and their PDF, and the orders beside them, because an order is a
purchase and an invoice is a document about one. Nachrichten — the portal
requests in the customer's own words, the mail they sent, the mail we
sent. Schreiben — last, because it is what you do after reading.

The tab is in the query string, like Settings and Integrations: a reload,
a bookmark and the back button all land where the operator was, and a link
can point at the tab that matters.

And templates, under Betrieb → E-Mail-Vorlagen. Most customer questions
are the same five questions, and typing the answer again every time is how
two customers get told two different things about one subject. A template
is INSERTED into the compose field with the customer's own details filled
in — never sent as it stands, because the last two sentences always belong
to the person asking. What goes out is what the operator saw and edited.

Three decisions worth naming. {{contact}} takes the contact person where
one is on record and the company name otherwise, because "Guten Tag
Beispiel GmbH" is the line that gives away a mail written by a database.
{{amount}} is the gross figure — what the price sheet quoted them and what
their bank statement says. And a typo comes out as {{kunde}}, standing
there in the field where the operator sees it, rather than as an empty
string that produces "Guten Tag ,". A placeholder with no value for this
customer stays empty; nothing is invented.

Placeholders are named in English because they are identifiers, not prose:
a template has to survive somebody switching the console's language. The
list the page documents is the renderer's own, so a token added in one
place appears in the other without being written down twice.

Blade's echo tag ends at the first }} it finds, which turned a literal
placeholder in the documentation into compiled nonsense. Assembled in PHP
now, and caught by the test that renders the page rather than by somebody
opening it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-29 22:37:30 +02:00
nexxo e376b571be Read the support mailbox into the console
tests / pest (push) Failing after 7m59s Details
tests / assets (push) Successful in 20s Details
tests / release (push) Has been skipped Details
No, not an API token: mailcow's API administers domains and mailboxes —
it does not read mail. Reading a mailbox is IMAP whoever runs the server,
so that is what this is. Host, user and folder are console settings; the
password is a vault entry, like every other credential that opens
something.

IMAP by hand rather than a dependency. Four verbs are needed — LOGIN,
SELECT, SEARCH UNSEEN, FETCH — and the parsing that follows is the real
work either way. It is bounded by what the console shows: a sender, a
subject, the words a person wrote, and the NAMES of anything attached.
Real mail from real people arrives encoded, so it handles RFC 2047
subjects (every German subject with an umlaut in it), quoted-printable
and base64 bodies, Windows charsets, and multipart — text wins over HTML,
because a console is not a mail client and the words are in the text
part. A message it cannot make sense of is skipped and logged, never
guessed at: a garbled question in front of an operator is worse than one
they go and read in the mailbox, where it still is.

ATTACHMENTS ARE NOT STORED. Only their names and sizes, so an operator
knows something was attached and can go and look if it matters. Keeping
whatever a stranger chooses to send would make this application a malware
store with a web interface in front of it.

Whose a mail is, is decided by the sender's ADDRESS and by nothing else —
not by a name in the subject, not by anything in the body. A mail is easy
to write and this decision attaches a stranger's words to a real
customer's file; there is a test that forges the name and the subject and
still gets nothing. An address nobody recognises stays unassigned, shown
FIRST and never hidden: that is a new enquiry, or a customer writing from
their private account, and it is the mail that must not be missed. An
operator can place it by hand from the same row.

Nothing is deleted on the mail server, ever. Messages are flagged seen
once the row is safely written — and only then, because flagging first
and failing after would lose the message with no second copy anywhere.
Filing one away is this console's own state; the mailbox is untouched.

Two fixes on the way past: the customer page printed
"customers.status.active" and "support.status.open" — a lang key with no
file behind it renders as itself and Laravel says nothing — and the same
page is now covered by a test proving each list is scoped to the customer
in the URL, which was reported as "unterhalb steht immer das selbe".

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-29 21:41:29 +02:00
nexxo 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>
2026-07-28 16:36:14 +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 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 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 befb67327f Prove a mailbox works by actually sending from it 2026-07-28 02:56:47 +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 06b84c5cd5 Say which kind of mail leaves from which mailbox 2026-07-27 22:00:16 +02:00