Skip to content

Inbound mail: terminate port 25 and proxy it to the device over the tunnel - #63

Merged
cyberb merged 49 commits into
masterfrom
mail-inbound
Aug 8, 2026
Merged

Inbound mail: terminate port 25 and proxy it to the device over the tunnel#63
cyberb merged 49 commits into
masterfrom
mail-inbound

Conversation

@cyberb

@cyberb cyberb commented Aug 6, 2026

Copy link
Copy Markdown
Member

Receives mail for device domains on port 25 and relays the SMTP conversation
live down the frp tunnel the device already holds open.

Nothing is spooled and nothing is ever owned. The device answers RCPT and
DATA and its reply goes straight back to the sender, so recipient validation
is the device's, there is no mailbox list to sync, and a spam rejection
reaches the sender as a real 5xx from the MX. An unreachable device is a 4xx
and the sending MTA keeps the message, exactly as it would for any MX that is
down. That is what keeps this from being a mail server.

Shape

  • mailin terminates 25, binds a device on the first RCPT and splices the
    rest. A second recipient on another domain gets 452, because one DATA
    response covers every recipient and there is no way to report a partial
    delivery. Bodies stream rather than buffer.
  • Each device with the mail relay on is allocated a loopback port. frpc asks
    frps for it with a type = "tcp" proxy, and the auth plugin checks the
    requested port is the one that domain owns, so a device cannot claim
    another's inbound mail.
  • caddy fronts 25 and sends PROXY protocol v2, so the sender's address
    survives the hop and per-peer limits and any future reputation check see
    the real client.
  • MX moves to <label>.mx.<domain>, one name per device, so senders that
    group recipients by host do not batch two devices into one transaction.
  • mailnet holds the listener helpers both directions share.

Metering

Inbound bytes count against the same per-user relay allowance as web traffic.
Without that a device with the traffic relay off and the mail relay on had no
limit at all: frps meters every proxy, but attribution went through the
domain name and <domain>-smtp matched nothing, so the rows landed under a
name that resolved to no user.

Notes

  • Migration 000020, the port allocator and the usage join have all run
    against real MySQL in CI.
  • The api's route53 client now points at the dns faker in integration. It was
    writing to a real hosted zone on every run, which is also why no test had
    ever checked a dns record.
  • Infrastructure is in place on uat and prod: tcp/25 open, and *.mx A
    records resolving. Verified on uat with a full SMTP transaction from a
    remote host, STARTTLS included, against the Let's Encrypt wildcard.

Not covered here

Every test drives a fake SMTP device. No real device can request a tunnel
until the platform side lands, so end to end delivery into a real mailbox is
still unproven. The *.mx records were created by hand and will not follow an
instance whose IP changes.

cyberb added 19 commits August 5, 2026 14:41
The README still told you to pipe db/init.sql and db/update.sql into mysql
during deploy and local setup. Neither file exists: the schema moved to
golang-migrate with migrations embedded from backend/db/migrations, applied by
the api on startup, and 000017_baseline folded everything that came before.
Anyone following the local dev section hit a missing file.

Also record the layout in CLAUDE.md so it does not have to be inferred.
Inbound mail needs the relay to reach a device's postfix, and the device
reaches out through the frp tunnel it already holds open. An frp tcp proxy
forwards a loopback port on the relay host to port 25 on the device, so every
device that turns the mail relay on needs a port of its own.

Allocation picks the lowest free port in the range, reusing ports freed by
deleted domains rather than only ever growing. A unique index makes a
concurrent double-allocation fail rather than hand two devices the same port,
and the loser retries. Ports are kept once assigned, including when the mail
relay is switched back off, so a device does not silently move.

frps only binds ports listed in allowPorts, and proxyBindAddr already restricts
them to loopback, so these listeners are reachable from the relay host alone
and never from the internet. The range is repeated there because frps reads its
own config; it has to stay in step with mail_inbound.port_from/port_to.
The frps NewProxy hook only knew how to authorize http and https proxies,
which identify themselves by custom domain or subdomain. A tcp proxy carries
neither, so requestedNames returned nothing and every smtp tunnel would have
been denied.

Authorize tcp proxies on remote_port instead: the port has to be the one
allocated to the domain that owns the update token. Without that check any
device could claim any port and frps would forward another device's inbound
mail to it.

Also require the mail relay to be on, so switching it off closes the tunnel
rather than leaving a listener bound for a device whose MX no longer points
here.
frps meters every proxy, but attribution went through GetDomainByName, and a
proxy named <domain>-smtp matches no domain. Its rows landed in relay_traffic
under a name that resolved to no user, so recomputeOver skipped them: inbound
mail moved unlimited bytes, and a device with the traffic relay off and the
mail relay on had no byte limit at all.

Strip the suffix before the lookup so inbound draws on the same per-user
allowance as web traffic. One pool rather than a second allowance, otherwise
splitting traffic between the two would hand out double.

Enforcement follows for free: over is keyed by proxy name and Enforce runs on
frps NewUserConn, so an over-quota device starts refusing inbound SMTP
connections and senders retry on their own schedule.

GetRelayUsageForUser needs the same rule, or the account page would show a
figure excluding inbound while the limit that cut the user off included it.

No domain can end in -smtp, since every domain ends in a TLD, so trimming
first and looking up once is safe as well as cheaper.
Per-peer connection limits, the in-flight cap and the TLS certificate loader
have nothing to do with outbound relaying, and the inbound proxy needs all
three. Move them out of mailrelay so both sides share one implementation
instead of the inbound path growing a copy.

Named mailnet rather than mail because ioc.go already binds mail as a
parameter name for *service.Mail, and a package called mail would be shadowed
wherever the two met.

Pure move, no behaviour change.
Terminates port 25 for every device domain and relays the conversation live
down the frp tunnel. Nothing is spooled and nothing is ever owned: the device
answers RCPT and DATA, and its reply is handed straight back to the sender.

That is what keeps this from being a mail server. Recipient validation is the
device's, so there is no mailbox list to sync and no accept-then-bounce
backscatter. A rejection at DATA, which is where the device's spam filter
sits, reaches the sender as a real 5xx from the MX. An unreachable device is a
4xx and the sending MTA keeps the message, which is exactly what it already
does for any MX that is down.

One device per transaction. A second recipient on another domain gets 452 and
the sender re-sends it separately, because with one DATA response covering
every recipient there is no way to report a partial delivery. Recipients on
the same device pass through untouched.

Message bodies stream through io.Copy rather than being buffered, so memory
does not track message size. go-smtp normalises BDAT into the same reader, and
the device is always spoken to with DATA, so advertising CHUNKING costs
nothing.

STARTTLS is offered when a certificate is configured and never required:
port 25 is opportunistic, and a server demanding TLS silently loses mail from
the senders that do not offer it.

Connection and concurrency limits are the inbound server's own rather than
shared with the outbound relay, since the peers are the whole internet here
instead of a known set of devices.
MX pointed at the device's own A record, which under the CGNAT relay is the
relay's address, where nothing answers on 25. Devices using the mail relay now
publish MX <label>.mx.<redirect domain> instead.

The name is per device even though every one of them resolves to the same
relay, because senders that group recipients by host would otherwise batch two
syncloud devices into a single transaction, and one transaction can only be
forwarded to one device. Postfix groups by recipient domain and would not,
Exim groups by host and would. Distinct names keep them apart, and mailin's
452 stays as a fallback for senders that batch by address.

A custom domain has no label under the redirect domain, so its dots collapse
to dashes: example.com becomes example-com.mx. Ugly but unique, and only
visible to anyone reading the MX record.

One wildcard A record for *.mx covers every device, and the matching wildcard
certificate is what the Caddyfile entry is for; caddy obtains it over DNS-01
and serves nothing.

Devices without the mail relay keep pointing at themselves.
redirect-api runs as the unprivileged redirect user, so it cannot bind 25.
caddy already runs as root on the host network and owns 443 and 465, so it
takes 25 too and forwards to a high port, which leaves deploy.sh with no
firewall rules to manage and the api unprivileged.

A plain tcp proxy would replace every sender with 127.0.0.1, which would
collapse the whole internet onto one peer for connection limiting and would
leave any future connect-time reputation check with nothing to look at. caddy
sends PROXY protocol v2 instead and the listener is wrapped to parse it, so
RemoteAddr is the real sender and peerOf keeps working untouched.

A listener that parses PROXY headers believes whatever address they claim, so
it binds loopback only and the policy requires the header from loopback and
rejects it from anywhere else. Both matter: reachable from off-box, anyone
could forge a source address and walk past the per-peer limit.

Off by default, so the unit tests and anything speaking to the port directly
still work without a header.

STARTTLS is unaffected: caddy passes bytes through without touching TLS, so
the upgrade is still negotiated end to end with mailin's own certificate.
Drives the whole path the way a sending MTA would: connect to port 25 on the
host, through caddy and its PROXY header, into mailin, out over a real frpc
tunnel to a fake device speaking SMTP on the machine running the tests. The
relay traffic tests already worked this way, so the harness is the same one
with a tcp proxy instead of https.

Covers a port being assigned and staying stable across updates, a message
arriving at the device with its subject and recipient intact, an unknown
domain answered 550 by the real server rather than a stub, and a device
claiming a port it was not given being refused by the frps auth plugin.

Nothing covered the outbound relay at this level either, so this is the first
mail integration test in the suite.
activate_user asserts the shared mailhog inbox holds exactly one message, so
every create_user silently depended on whatever ran before it leaving nothing
behind. The relay quota tests break that: going over the limit sends an 80%
warning email that nothing clears, so the next create_user saw two messages
and failed on the activation assert.

Clearing at the start of create_user fixes the ordering dependency for good
rather than teaching one more test to tidy up after the one before it.
Clearing at the end of the relay tests would not be enough anyway, since the
accountant sends that warning from its own poll and it can land after the test
has finished.

py.test runs with -x, so this took the rest of the suite down with it.
caddy-l4 could not bind 25 on the uat host, something already holds it there.
A layer4 listener that fails takes the whole caddy config down with it, so
caddy crash looped and stopped serving 443 as well: uat went off the air, not
just its mail. The integration host has 25 free, which is why this only showed
up after test-api had passed.

mailin listens on 10025 directly and PROXY protocol goes back off. Getting
port 25 there is a deployment concern now rather than a caddy one, and a
PREROUTING redirect handles it without needing the port free, which is the
part caddy could not do.

The Go side keeps its PROXY protocol support behind the config flag, off by
default and still tested, since it costs nothing and applies again if caddy
ever fronts this on a port it can have.

Integration tests move to 10025 with it. They have to land in the same commit:
deploy uat runs after test-api, so leaving the tests on 25 would fail the step
before it and uat would stay down.
The earlier attempt asked caddy for :25, which is 0.0.0.0:25, and a wildcard
bind collides with anything already holding that port on a specific address.
uat had sendmail on 127.0.0.1:25, so caddy could not start, and a layer4
listener that fails takes the whole config with it: uat lost 443 as well.

Binding the host's own address instead only collides with something on that
same address, so a loopback-only MTA is no longer a problem. sendmail is gone
from both hosts now and the wildcard would work again, but nothing keeps them
that way, and a rebuilt image that ships an MTA would take caddy down rather
than just failing to accept mail. This holds either way.

The address cannot be a constant. Both hosts are EC2 and carry only their VPC
address, with the public one on a NAT, and it changes when an instance is
replaced, so deploy.sh resolves it and hands it to caddy like the domains it
already passes. It fails the deploy if it cannot work the address out, because
an empty value would silently expand back to the wildcard.

mailin goes back to loopback with PROXY protocol on, which is what keeps the
sender's address visible through the hop, and the integration tests go back to
port 25 so they exercise the real path.
Binding the host address instead would have let caddy start alongside anything
already holding 25 on another address. That is the wrong trade here: port 25
is meant to be ours, and something else listening on it is a fact worth
learning, not one worth quietly working around. A failed bind says so
immediately.

The cost is understood. caddy loads every listener as one config, so a
conflict stops the whole thing and takes 443 with it rather than only mail.
That is exactly what uat is for: it matches prod, so it catches this before
prod does, which is what happened with sendmail.

mailin stays on loopback behind caddy with PROXY protocol, so the sender's
address still survives the hop.
The retry loop caught every exception and dropped it, so a failure printed
"assert None" and the frpc log, which showed the tunnel coming up fine and
said nothing about the actual problem. The real cause was on the smtp side and
invisible.

Keep the last error and put it in the assertion, so the message names what
went wrong rather than only proving the tunnel was not to blame.
The suffix strip in OwnerLimit and the join in GetRelayUsageForUser were the
answer to inbound moving bytes nobody counted, and both were only ever
exercised against stubs. The join in particular had never run against a row
whose proxy name carries the -smtp suffix.

Two tests, mirroring what the relay traffic suite already does for web
traffic: bytes arriving through the mail tunnel show up in /relay/usage, and a
device over its monthly limit stops accepting inbound mail. The second is the
case that matters most, since a device with the traffic relay off and the mail
relay on had no limit at all before.

The mx record change is still unit tested only. Asserting it here needs the
dns faker to keep MX as well as TXT, and a dns client in the test image.
Without a certificate mailin offers no STARTTLS, so every sender falls back to
cleartext and gmail shows the hop as unencrypted. caddy already holds a real
wildcard for *.mx.<domain>, obtained over dns-01, but writes it 0600 root
owned inside directories that are 0700, and the api runs as redirect, so a
read only mount of the volume does not help.

A root cron copies it into place, installed by deploy.sh from the repo like
every other config rather than set up by hand. It also runs once during the
deploy so a fresh host does not wait for the first tick. The issuer directory
differs between the staging and production acme endpoints, so the script finds
the certificate instead of assuming the path, and copies only when the content
changed.

The loader now serves through GetCertificate and reloads when the file's
mtime moves, so a renewal is picked up without restarting the api and
dropping live sessions. If a reload ever fails it keeps serving the
certificate it already has, since an expiring certificate beats none.

Renewal happens about 30 days before expiry, so a daily copy has ample margin.
The mx change was the one part of inbound with no integration cover: the faker
dropped every record type but TXT, so nothing downstream could see what was
written. It now keeps MX as well, answers MX queries over dns, and exposes the
map on /faker/mx the way ses-faker already exposes its messages.

The test asserts both states rather than only the interesting one, so it would
catch the record being written for every domain rather than only those with
the mail relay on.
…ss change

Enabling the mail relay changed where mail should be delivered but not the dns
record that says so. UpdateDomainRecords is gated on change.Detector, which
compares addresses and the dkim key and knows nothing about the mail relay, so
a device turning it on with a steady ip kept an mx pointing at itself. Inbound
mail went to the device's own address, which under the traffic relay is a
front door with nothing listening on 25, and vanished.

The unit tests missed it because they call UpdateDomainRecords directly and
never pass through the gate. The integration test found it, once its first
assertion was fixed: records are only written on update, so a freshly acquired
domain has none, and it has to update once without the relay to establish the
baseline before turning it on.

The new service tests fail without the fix and cover the switch in both
directions, since turning it off has to move the record back.
The faker's route53 endpoint was only ever wired to caddy, for its dns-01
plugin, so the api went to real aws with the hosted zone secret. Every ci run
was mutating a real hosted zone, and nothing could see what it wrote, which is
why no integration test has ever checked a dns record and why the mx test
could not find one.

The ses faker already works this way through mail_relay.ses_endpoint, so this
follows it: an optional aws.endpoint, empty everywhere except integration, so
prod and uat are unchanged.

This may surface calls the faker does not implement yet. It only had to satisfy
caddy until now, and the aws sdk may ask for more.
cyberb added 9 commits August 6, 2026 22:56
Every device had a port of its own on the relay: allocated from a range,
stored on the domain, requested by frpc, checked by the auth plugin and
carried to the device through the domain update. All of that existed to give
mailin something to dial.

frp already solves this for the web proxy, which shares a single port and is
told apart by the name in the tls handshake. tcpmux does the same for raw tcp,
routing on the name in an http CONNECT, and mailin can send that itself: it is
our own code and it already dials the device. The earlier objection, that
postfix cannot speak CONNECT, stopped applying once mailin existed and I did
not revisit it.

So the port allocator, its column and unique index, the range and its ten
thousand device ceiling, the ports that were never reclaimed, and the auth
plugin's remote_port branch are all gone. tcpmux carries custom_domains, so
authorisation is the same name check the https proxy already used.

The reply to the CONNECT is read a byte at a time rather than through a
buffered reader: postfix greets the moment the stream is joined, and go-smtp's
client takes the connection rather than a reader, so anything read ahead could
not be handed back.

Also, the aws endpoint is now set in every config rather than branched on in
the container, and the mailin structs live one per file.
It logged the failure and carried on without starttls, which is the wrong way
round: every sender would fall back to cleartext, delivery would look healthy,
and only the log would say the hop was no longer encrypted. The outbound relay
already refuses to start in that case.

Empty paths still mean no tls on purpose, for the integration environment and
for a host where the certificate has not been copied across yet. A path that
is set but unreadable is a failure.
…unnel

Every session test stood up a fake multiplexer that spoke CONNECT and proxied
to a fake device, about sixty lines of protocol per package, repeated behind
each test. The integration tests already drive a real frpc through a real
frps, so that fake was proving something already proven, and a fake protocol
that is subtly wrong is worse than none.

Reaching a device is now a DeviceDialer given to the server. TunnelDialer
carries the CONNECT and is tested on its own, including that the device's
greeting survives the handshake, which is the whole reason the reply is read a
byte at a time. The session tests take a dialer that knows which devices have
a tunnel, so "no tunnel" is one line instead of a muxer that refuses.

Route collapsed with it: with the muxer behind the dialer the router only
names a device, so it returns a string.
It was mailnet only to avoid colliding with mail, the name ioc.go gives its
*service.Mail parameters in seven closures. Nothing needed both at once, so it
would have compiled either way, but a package that disappears the moment
someone writes a function taking both is a trap rather than a design.

The parameters are mailService now, so the name is free and the package is
mail, which is what it reads as at every call site.
Refusing to start without a certificate deadlocked every first deploy: caddy
has to obtain the wildcard before the copy can find one, and the copy runs in
the same deploy that starts the api, so the file cannot be there yet. uat only
worked because caddy already had the certificate from an earlier deploy.

Missing and unparseable are now different. A file that is not there yet means
starting without starttls and saying so, which the daily copy and the next
restart resolve. A file that is there and will not parse still stops the
service, because that is the case where a working certificate quietly turns
into cleartext and only the log knows.
There were two unrelated things called relay: the frp tunnel that carries app
traffic, and the smtp relay that hands outbound mail to ses. Talking about
either meant saying which one every time.

Nesting settles it by position rather than by a longer name. The package is
relay inside mail, next to the listener helpers it already shares. The two
files that need both import this one under an alias, which is what the
ambiguity actually costs.

Pure move, no behaviour change.
mail/relay next to mailin was half tidy: one named for its mechanism, one
abbreviated, and the mechanism one still shared a word with the frp relay.

They are inbound and outbound now, which is what actually tells them apart and
what we have been calling them all along. The alias goes with it, since
nothing else in the tree is called inbound or outbound.

Not smtpin and smtpout: smtp is already a package here, the gomail client that
sends redirect's own notifications, so an smtpout beside it would leave two
packages that both send mail out and no way to tell from the name which one is
the product.

Pure move, no behaviour change.
mailRelay and mailIn were left over from the old package names and no longer
matched each other or the packages they came from. They are mailOutbound and
mailInbound now.

Not inbound and outbound on their own: those are the package names, and a
parameter taking one of those would shadow the package, which is the trap that
mailnet existed to avoid until last week.
The usage query knew that a device's mail traffic is filed under its domain
with -smtp on the end, so the naming rule lived both in relay, as
SmtpProxySuffix, and again in sql as a CONCAT inside a join condition. Two
definitions in two languages, and the sql one could not be tested without a
database.

The db now answers two plain questions, which domains a user has and what
those proxies used, and relay decides which proxy names a domain implies.
OwnerLimit already stripped the suffix in go, so both directions of the rule
now sit next to each other.

Three tests cover it, including that traffic belonging to another user's
domain is not counted, which the join made awkward to check.
cyberb added 21 commits August 7, 2026 15:43
One spf string was a const and the other was written into the argument list,
so the pair that has to be understood together could not be read together.

They are unownedSpf and domainSpf now, next to each other with a line each
saying what they mean. The old name, defaultSpf, said which one was typed
first rather than what it does: it is the deny everything policy a domain
carries while nobody owns it.

The update call also drops its redundant err check on the way past.
The spf pair carried a line each explaining what it did, which the values
already say once the names do: denyAllSpf and allowDeviceAndMxSpf.

Swept the rest of this branch's comments with it. Most were restating the code
underneath, and the ones carrying reasoning belong in the history rather than
above the function.

One is left, on the byte at a time read in the dialer, because the constraint
it names is not visible from the code: go-smtp's client takes a connection
rather than a reader, so anything a buffered read pulled in could not be given
back, and the next reader would find the greeting already gone.
…g section outbound

The loader returned nil for the outbound relay in every environment, because
mail_relay.cert_file was never set anywhere: caddy terminates tls on 465 and
forwards plaintext to loopback, so that listener never sees a handshake of its
own. Asking a loader and getting nil was the caller describing something that
was never true.

Outbound takes no loader now and says AllowInsecureAuth outright, which is
what a caddy fronted loopback listener always wanted. The two dead config
options go with it.

That leaves inbound as the only user, and inbound cannot borrow anyone else's
tls because starttls happens mid stream. So no paths is an error there rather
than quiet cleartext. A path that is set but not copied across yet still
starts without starttls, which is the case a first deploy needs.

The section is mail_outbound to match the package, and its getters with it.
The mail_relay tables and the domain flag keep their names: those are the
feature users switch on, not the smtp server.
The three way branch in Start was there so a first deploy could come up before
caddy had obtained the wildcard, and the price was accepting mail in the clear
while that was true. Deferring it to the deploy would only have moved the
problem into a script, so the server waits instead.

No certificate means no listener. Start returns, the api comes up, and inbound
mail says it is waiting; a goroutine retries and binds the moment the
certificate lands. Port 25 refuses in the meantime, which sending mtas already
know how to handle, and nothing is ever taken unencrypted.

A certificate that will not parse still stops the api outright, since that is
a working install losing its tls rather than one that has not gained it yet.

The session tests now run against a server with a real certificate, which is
what production looks like; before they passed a path to nothing, which under
these rules means no listener at all.
Start was branching on which way the certificate was unavailable and running
its own retry, but inbound is the only thing that loads one and it is never
willing to run without it, so the waiting belongs in the loader.

Await blocks until the certificate can be used and reports each attempt that
could not. Start hands it a goroutine and serves when it returns.

Missing and unparseable stop being separate cases at the call site. Both mean
the same thing to a mail server: it will not take mail, it says why on every
attempt, and it starts the moment that changes. Unparseable no longer takes
the whole api down with it either, which was too much reach for one feature's
certificate: devices should not stop being able to update dns because an mx
certificate was truncated during a copy.

The test harness waits for the listener now, since starting is asynchronous
and the proxy protocol tests dial without retrying.
Everything about starting was asynchronous, so a taken port was a log line on
a service that stayed up with mail quietly absent. A port conflict never
resolves on its own, unlike a certificate that has not been copied yet, so it
belongs in the error Start returns: main stops at the first service that will
not start, which fails the deploy instead of hiding the problem.

No panic needed for that. Returning the error already exits with a message and
a non zero code, which reads better than a stack trace for a configuration
mistake.

The port is therefore open while the certificate is still being waited for,
and a sender that connects in that window waits rather than being refused.
Nothing is taken in the clear, and sending mtas already retry on a timeout, so
it costs a delay rather than a message.

The certificate tests now assert on the greeting rather than the connection,
since the connection no longer says whether mail is being taken.
Start should do its one time work while the caller waits and only go
asynchronous once there is nothing left that can fail. Inbound could not
follow that: it bound first and waited for its certificate afterwards, because
blocking would have stopped anything after it from starting at all.

The reason was the api, which never returned from Start. It served inline, so
it was both the last service and the thing keeping the process alive, and
every service before it had to avoid blocking or the api would never listen.

The api binds and hands serving to a goroutine like the others now, and main
waits once everything is up rather than relying on the last start never
finishing. Inbound then waits for its certificate, binds, and only then serves,
so a port already taken and a certificate that cannot be read both reach the
caller.

This does mean a host with no certificate yet holds up the rest of the api
until caddy has issued one. That is the trade for preparation being finished
before anything is announced as started.
Port 25 needed caddy's *.mx wildcard, which reached redirect-api through a
root cron that copied it out of the caddy_data volume. On a clean deploy that
copy always ran before caddy had finished ACME, and the next look was the
following 04:17, so the inbound server blocked the whole startup chain and
api.<domain> served 502 until then - permanently if ACME never succeeded.

A layer4 handler puts the upgrade where the certificate already is. It speaks
the greeting, EHLO and STARTTLS exchange, upgrades using caddy's managed certs
and relays to the plain listener on 10025, reconciling the session it has
already half-spoken with the greeting the upstream sends on connect. Cleartext
senders are still accepted, and data pipelined before the upgrade is refused.

redirect-api no longer holds a certificate at all, so the loader, the copy
script, the cron and the deploy hook are gone and Start only binds and serves.

The smtp_port column added earlier and dropped again when the design moved to
tcpmux is removed from the branch; uat is set back to migration 19 by hand.
The api was the last service and its Serve blocked, so main died with it and
docker restarted the container. That was traded for a background goroutine
with log.Fatalf and a bare select{} in main only because the inbound server
used to block ahead of it waiting for a certificate. Nothing blocks there any
more, so the original shape is back.

MailDevice moves to its own module and the comments the e2e tests already
cover are dropped.
Only uat ever had the cron installed and it is cleaned there by hand, so the
removal does not belong in every future deploy.
The tunnel test delivers in the clear, so the upgrade the plugin exists for was
only exercised by unit tests, and deploy-verify only grepped for the STARTTLS
capability, which is advertised whether or not caddy holds a certificate.

The new test connects to the mx hostname so sni picks the wildcard the way a
sending server does, upgrades, delivers through the tunnel and checks the
certificate presented is the *.mx one.
… backend

The mail relay toggle was compared with an || next to the detector call, which
left the detector reporting a change it had not been given both sides of. It
takes the existing and the new flag now, and the dns tests use the real
detector instead of a stub that could not see the difference.

relay_start_backend binds a fixed port and shutdown only stops the serve loop,
so the socket stayed open and the next relay test failed with EADDRINUSE.
The device lived inside the python tests: a hand rolled smtp server plus an
frpc process each test started and stopped, so the tests carried infrastructure
alongside what they were actually asserting.

device-faker joins dns-faker and ses-faker on the test host. It records what it
is delivered, owns the tunnels, and takes its behaviour over rest, so a test
now only names a domain and a token. Infrastructure detail - frps address, tls
server name, frpc path - is passed once at startup by test-setup.

Being able to drive its behaviour covers what only unit tests reached before:
a device refusing a recipient, refusing a message, and dropping the connection
mid data, plus a second domain on one transaction being deferred.
syncloudlib already provides a device fixture that test_backup uses for ssh.
smtplib.SMTP.data raises only when the 354 is wrong; the final reply comes back
as a tuple, so a refusal from the device was being read as no failure at all.
ci/sim holds nothing but build output, so the directory is ignored rather than
each binary, and one glob covers a faker built in place. That caught a 10MB
dns-faker binary committed on this branch, which gitignore could never have
helped with once tracked.

The device faker's own tests go: a faker is test scaffolding, so all three
build steps are now just a build.
Both sides of the loopback hop had it as an option, but every environment set
it true, the caddy handler is its only client, and the accessor defaulted to
false - so a missing key would have left the api reading a binary v2 header as
smtp commands and taken all inbound mail down.

The option only existed so most unit tests could skip the header. They speak it
now, which is the only configuration that ships.
No environment set rspamd_reject_on_error, so the flag always read true and the
fail-open branch was reachable only from a test stub. Scan now returns a named
error rather than a nil that had to be prepared at start.
Relaying traffic already replaces the device's a record with the relay address,
so mail for those domains has been arriving at redirect all along and finding
nothing listening on 25. Keying the mx off the traffic relay makes that the
record it should have been, and the change detector follows the same flag.

Keying it off the mail relay would have moved the mx for devices that reach the
internet fine and only wanted outbound help, taking working inbound mail and
routing it through a tunnel they do not need.
The router still refused anything without the mail relay, so moving the mx to
the traffic relay would have pointed senders at a server that answered 550
rather than the deferral they got before.
@cyberb
cyberb merged commit be3bc75 into master Aug 8, 2026
1 check passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant