import express from "express";
import { SMTPServer } from "smtp-server";
import mailparser from "mailparser";
import { promises as fs } from "fs";
import * as entities from "entities";
import { JSDOM } from "jsdom";
import * as sanitizeXMLString from "sanitize-xml-string";
import writeFileAtomic from "write-file-atomic";
import cryptoRandomString from "crypto-random-string";
export const WEB_PORT = process.env.WEB_PORT ?? 8000;
export const EMAIL_PORT = process.env.EMAIL_PORT ?? 2525;
export const BASE_URL = process.env.BASE_URL ?? "http://localhost:8000";
export const EMAIL_DOMAIN = process.env.EMAIL_DOMAIN ?? "localhost";
export const ISSUE_REPORT =
process.env.ISSUE_REPORT ?? "mailto:kill-the-newsletter@leafac.com";
export const webServer = express()
.use(express.static("static"))
.use(express.urlencoded({ extended: true }))
.get("/", (req, res) =>
res.send(
layout(`
`)
)
)
.post("/", async (req, res, next) => {
try {
const { name } = req.body;
const identifier = createIdentifier();
await writeFileAtomic(feedPath(identifier), feed(X(name), identifier));
res.send(
layout(`
“${H(name)}” Inbox Created
${created(identifier)}
`)
);
} catch (error) {
console.error(error);
next(error);
}
})
.get("/alternate", (req, res) =>
res.send(
layout(`
Typically each entry in a feed includes a link to an online version of the same content, but the content from the entries in a Kill the Newsletter! feed come from emails—an online version may not even exist— so you’re reading this instead.
Create an Inbox
`)
)
)
.listen(WEB_PORT);
export const emailServer = new SMTPServer({
disabledCommands: ["AUTH", "STARTTLS"],
async onData(stream, session, callback) {
try {
const email = await mailparser.simpleParser(stream);
const newEntry = entry(
X(email.subject ?? ""),
X(email.from?.text ?? ""),
X(typeof email.html === "string" ? email.html : email.textAsHtml ?? "")
);
for (const { address } of session.envelope.rcptTo) {
const match = address.match(
new RegExp(`^(?\\w+)@${EMAIL_DOMAIN}$`)
);
if (match?.groups === undefined) continue;
const path = feedPath(match.groups.identifier);
const xmlText = await fs.readFile(path, "utf8").catch(() => null);
if (xmlText === null) continue;
const xml = new JSDOM(xmlText, { contentType: "text/xml" });
const document = xml.window.document;
const updated = document.querySelector("feed > updated");
if (updated === null)
throw new Error(`Can’t find ‘updated’ field in feed at ‘${path}’.`);
updated.textContent = now();
const firstEntry = document.querySelector("feed > entry:first-of-type");
if (firstEntry !== null)
firstEntry.insertAdjacentHTML("beforebegin", newEntry);
else
document
.querySelector("feed")!
.insertAdjacentHTML("beforeend", newEntry);
while (
document.querySelector("feed > entry") !== null &&
xml.serialize().length > 500_000
)
document.querySelector("feed > entry:last-of-type")!.remove();
await writeFileAtomic(
path,
`${xml.serialize()}`
);
}
callback();
} catch (error) {
console.error(
`Error receiving email: ${JSON.stringify(session, null, 2)}`
);
console.error(error);
stream.resume();
callback(new Error("Failed to receive message. Please try again."));
}
},
}).listen(EMAIL_PORT);
function layout(content: string): string {
return `
Kill the Newsletter!
${content}
`;
}
function created(identifier: string): string {
return `
Sign up for the newsletter with${feedEmail(
identifier
)}
Subscribe to the Atom feed at${feedURL(identifier)}
Don’t share these addresses. They contain an identifier that other people could use to send you spam and to control your newsletter subscriptions.
Enjoy your readings!
Create Another Inbox
`.trim();
}
function feed(name: string, identifier: string): string {
return `
${urn(identifier)}
${name}
Kill the Newsletter! Inbox: ${feedEmail(
identifier
)} → ${feedURL(identifier)}
${now()}
Kill the Newsletter!
${entry(
`“${name}” Inbox Created`,
"Kill the Newsletter!",
X(created(identifier))
)}
`;
}
function entry(title: string, author: string, content: string): string {
return `
${urn(createIdentifier())}
${title}
${author}
${now()}
${content}
`.trim();
}
function createIdentifier(): string {
return cryptoRandomString({
length: 20,
characters: "1234567890qwertyuiopasdfghjklzxcvbnm",
});
}
function now(): string {
return new Date().toISOString();
}
function feedPath(identifier: string): string {
return `static/feeds/${identifier}.xml`;
}
function feedURL(identifier: string): string {
return `${BASE_URL}/feeds/${identifier}.xml`;
}
function feedEmail(identifier: string): string {
return `${identifier}@${EMAIL_DOMAIN}`;
}
function urn(identifier: string): string {
return `urn:kill-the-newsletter:${identifier}`;
}
function X(string: string): string {
return entities.encodeXML(sanitizeXMLString.sanitize(string));
}
function H(string: string): string {
return entities.encodeHTML(sanitizeXMLString.sanitize(string));
}