Inbound mail: terminate port 25 and proxy it to the device over the tunnel - #63
Merged
Conversation
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.
This was referenced Aug 6, 2026
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.
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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
mailinterminates 25, binds a device on the first RCPT and splices therest. 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.
frps for it with a
type = "tcp"proxy, and the auth plugin checks therequested port is the one that domain owns, so a device cannot claim
another's inbound mail.
survives the hop and per-peer limits and any future reputation check see
the real client.
<label>.mx.<domain>, one name per device, so senders thatgroup recipients by host do not batch two devices into one transaction.
mailnetholds 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>-smtpmatched nothing, so the rows landed under aname that resolved to no user.
Notes
000020, the port allocator and the usage join have all runagainst real MySQL in CI.
writing to a real hosted zone on every run, which is also why no test had
ever checked a dns record.
*.mxArecords 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
*.mxrecords were created by hand and will not follow aninstance whose IP changes.