This content originally appeared on dbushell.com (blog) and was authored by dbushell.com (blog)
They say never operate your own email server. It’s all fine and dandy until Google et al. arbitrarily ban your IP address. Doesn’t matter if you configure DMARC, DKIM, and SPF — straight to jail. But that only applies to sending email. I think. I’m testing that theory.
How easy is it to receive emails? Turns out it’s almost too easy.
I coded an SMTP server in TypeScript. I added a couple of DNS records on a spare domain (redacted for obvious reasons). I rawdogged port 25 on my public IP address. I sent myself a test email from Gmail and it worked!
Join me on the adventure of how I got this far.
The Server
I’m using Deno flavoured TypeScript and below is the basic wrapper. I’ve simplified the example code below to illustrate specific concepts.
Open the TCP server and pass off connections to an async handle
function.
const listener = Deno.listen({
hostname: "0.0.0.0",
port: 25,
});
for await (const conn of listener) {
handle(conn)
.catch((error) => console.error(error))
.finally(() => conn.close());
}
// TODO: Define `handle`...
The handler immediately responds in plain text. Wikipedia has a good example of a full message exchange. Only the number codes really matter.
async function handle(conn: Deno.TcpConn) {
await conn.write("220 mail.example.com SMTP Ready\r\n");
// TODO: Read incoming data...
}
Then it reads buffered data until the connection closes. Commands are ASCII ending with the carriage return, line feed combo. I get a little fancy with the TextDecoder
so that it throws an error on malformed text.
// Read incoming data...
const buffer = new Uint8Array(1024);
let decoder = new TextDecoder("utf-8", { fatal: true });
let data = "";
while (true) {
const read = await conn.read(buffer);
if (read === null) break;
data += decoder.decode(
buffer.subarray(0, read),
{ stream: true }
);
if (data.endsWith("\r\n")) {
// TODO: Handle commands...
data = "";
decoder = new TextDecoder("utf-8", { fatal: true });
}
}
Later I decided that giving unbridled while (true)
to a 3rd-party was not a smart move. I added a couple of protections:
- A generous 30 second timeout
- A maximum 1 MB message size
const timeout = AbortSignal.timeout(30_000);
while (true) {
if (timeout.aborted) throw new Error(timeout.reason);
// [POLL DATA]
if (data.length > 1_048_576) {
await conn.write("552 5.3.4 Message size exceeds limit\r\n");
break;
}
By the way, this exact code never throws because the main thread is blocked. The abort signal task is never executed. Replacing the placeholder comment with an await
to read data unblocks the event loop.
Handling commands is easy if you’re careless.
if (data.startsWith("QUIT")) {
await conn.write("221 Bye\r\n");
break;
}
else if (data.startsWith("HELO")) {
await conn.write("250 OK\r\n");
}
else if (data.startsWith("EHLO")) {
await conn.write("250-mail.example.com\r\n");
await conn.write("250 SIZE 1048576\r\n");
}
// More commands...
I don’t even bother to parse commands properly. (Note to self: do a proper job.) If there is any command I don’t recognise I close the connection immediately.
else {
await conn.write("500\r\n");
throw new Error();
}
Start TLS
It was at this stage in my journey that I learnt of the STARTTLS
command.
The STARTTLS keyword is used to tell the SMTP client that the SMTP server is currently able to negotiate the use of TLS. It takes no parameters.
This is supposed to be included as part of the response to EHLO
.
if (data.startsWith("EHLO")) {
await conn.write("250-mail.example.com\r\n");
await conn.write("250-SIZE 1048576\r\n");
await conn.write("250 STARTTLS\r\n");
}
It’s worth noting at this point I’ve tested nothing in the wild. Had I tested I would have saved myself days of work.
I found Deno’s startTls
function which looked ideal. But no, this only works from the client’s perspective (issue #18451). One does not simply code the TLS handshake. (Some time later I found Mat’s @typemail/smtp — this looks much easier in Node!)
It’s possible for an SMTP server to listen securely on port 465 with TLS by default. Deno has listenTls
to replace listen
.
Say no more!
Side quest: code an ACME library
Side quest status: success!
So after that 48 hour side quest I now have a TLS certificate.
Which is useless because mail servers deliver to each other on port 25 unencrypted before upgrading with STARTTLS
and I’m still blocked there. It’s confusing. Clients can connect directly over TLS to post emails (I think). Whatever, the only way to know for sure is to test in production.
if (data.startsWith("STARTTLS")) {
await conn.write("502 Command not implemented\r\n");
}
And this brings me back to the screenshot above. I opened the firewall on my router and let the emails in.
And guess that? Google et al. don’t give a hoot about privacy! Even my beloved Proton will happily send unencrypted plain text emails. Barely compliant and poorly configured server held together by else if
statements and a dream? Take the email! My server is suspect af and yet they handoff emails no sweat. Not their problem.
If I tried to send email that’d be another story. For my project I’m just collecting email newsletter; did I mention that? We’ll see if they continue to deliver.
Security
If you have a port open on a public IP address you will be found. Especially if it’s a known port like 25. There are bots that literally scan every port of every IP.
I log all messages in and out of my SMTP server. I use the free IPinfo.io service to do my own snooping. Here is an example of Google stopping by for a cup of tea.
Conn: 35.203.211.24:60104 {"ip":"35.203.211.24","asn":"AS396982","as_name":"Google LLC","as_domain":"google.com","country_code":"GB","country":"United Kingdom","continent_code":"EU","continent":"Europe"}
Open: 35.203.211.24:60104 (1)
--> 220 mail.[REDACTED] SMTP ready
<-- GET / HTTP/1.1
Host: [REDACTED]:25
User-Agent: Hello from Palo Alto Networks, find out more about our scans in https://docs-cortex.paloaltonetworks.com/r/1/Cortex-Xpanse/Scanning-activity
Accept-Encoding: gzip
--> 500
Close: 35.203.211.24:60104 (0)
I decided it was best to block all connections from outside NA and EU. For my purposes those would be very unlikely.
This one looked interesting:
Conn: 118.194.251.17:45058 {"ip":"118.194.251.17","asn":"AS135377","as_name":"UCLOUD INFORMATION TECHNOLOGY (HK) LIMITED","as_domain":"ucloud.cn","country_code":"TH","country":"Thailand","continent_code":"AS","continent":"Asia","date":1757022589274}
Reject: 118.194.251.17
Sorry for the lack of hospitality :(
When it’s running, my SMTP server is inside a container on a dedicated machine that is fire-walled off from the LAN. I won’t provide exact schematics because that would only highlight weaknesses in my setup. I’d prefer not to be hacked into oblivion.
My server validates SPF and DKIM signatures of any email it receives. RFC 6376 was a formidable foe that had me close to tears. I know I don’t need to code all this myself, but where’s the fun in that? I’m throwing away emails that have malformed encoding. In this case parsing Quoted-Printable and MIME Words formats myself did not look fun. I found Mat’s lettercoder package that does a perfect job.
I added a concurrent connection and rate limiter too. @ me if I’m missing another trick.
Now what?
The plan is to keep the SMTP server live and collect sample data. I want to know how feasible it is to run. I’m collecting email newsletters with the idea of designing a dedicated reader. I dislike newsletters in my inbox. This may be integrated into my Croissant RSS app. Of course, Kill the Newsletter! can do that job already.
If it proves to be too much hassle I’ll slam the door on port 25.
Does anybody know a hosting provided that allows port 25? I was going to use a Digital Ocean droplet for this task but that’s blocked.
This content originally appeared on dbushell.com (blog) and was authored by dbushell.com (blog)

dbushell.com (blog) | Sciencx (2025-09-05T10:00:00+00:00) I Let The Emails In. Retrieved from https://www.scien.cx/2025/09/05/i-let-the-emails-in/
Please log in to upload a file.
There are no updates yet.
Click the Upload button above to add an update.