import express from "express"; import { SMTPServer } from "smtp-server"; import mailparser from "mailparser"; import * as sanitizeXMLString from "sanitize-xml-string"; import * as entities from "entities"; import R from "escape-string-regexp"; import { JSDOM } from "jsdom"; import { promises as fs } from "fs"; 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(newInbox()))) .post("/", async (req, res, next) => { try { const { name } = req.body; const identifier = createIdentifier(); await writeFileAtomic(feedPath(identifier), feed(identifier, X(name))); const renderedCreated = created(identifier); await addEntryToFeed( identifier, entry( createIdentifier(), `“${X(name)}” Inbox Created`, "Kill the Newsletter!", X(renderedCreated) ) ); res.send( layout(`

“${H(name)}” Inbox Created

${renderedCreated} `) ); } catch (error) { console.error(error); next(error); } }) .listen(WEB_PORT); export const emailServer = new SMTPServer({ disabledCommands: ["AUTH", "STARTTLS"], async onData(stream, session, callback) { try { const email = await mailparser.simpleParser(stream); const content = typeof email.html === "string" ? email.html : email.textAsHtml ?? ""; for (const { address } of session.envelope.rcptTo) { const match = address.match( new RegExp(`^(?\\w+)@${R(EMAIL_DOMAIN)}$`) ); if (match?.groups === undefined) continue; const identifier = match.groups.identifier.toLowerCase(); await addEntryToFeed( identifier, entry( createIdentifier(), X(email.subject ?? ""), X(email.from?.text ?? ""), X(content) ) ).catch((error) => { console.error(error); }); } callback(); } catch (error) { console.error( `Failed to receive message: ‘${JSON.stringify(session, null, 2)}’` ); console.error(error); stream.resume(); callback(new Error("Failed to receive message. Please try again.")); } }, }).listen(EMAIL_PORT); async function addEntryToFeed( identifier: string, entry: string ): Promise { const path = feedPath(identifier); let text; try { text = await fs.readFile(path, "utf8"); } catch { return; } const feed = new JSDOM(text, { contentType: "text/xml" }); const document = feed.window.document; const updated = document.querySelector("feed > updated"); if (updated === null) throw new Error(`Field ‘updated’ not found: ‘${path}’`); updated.textContent = now(); const firstEntry = document.querySelector("feed > entry:first-of-type"); if (firstEntry === null) document.querySelector("feed")!.insertAdjacentHTML("beforeend", entry); else firstEntry.insertAdjacentHTML("beforebegin", entry); const entryDocument = JSDOM.fragment(entry); await writeFileAtomic( alternatePath(getEntryIdentifier(entryDocument)), entities.decodeXML(entryDocument.querySelector("content")!.textContent!) ); while (feed.serialize().length > 500_000) { const entry = document.querySelector("feed > entry:last-of-type"); if (entry === null) break; entry.remove(); const path = alternatePath(getEntryIdentifier(entry)); await fs.unlink(path).catch(() => { console.error(`File not found: ‘${path}’`); }); } await writeFileAtomic( path, `${feed.serialize()}` ); } function layout(content: string): string { return ` Kill the Newsletter!

Kill the Newsletter!

Convert email newsletters into Atom feeds

Convert email newsletters into Atom feeds

${content}
`; } function newInbox(): string { return `

`; } 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(identifier: string, name: string): string { return ` ${urn(identifier)} ${name} Kill the Newsletter! Inbox: ${feedEmail( identifier )} → ${feedURL(identifier)} ${now()} Kill the Newsletter! `; } function entry( identifier: string, title: string, author: string, content: string ): string { return ` ${urn(identifier)} ${title} ${author} ${now()} ${content} `.trim(); } function createIdentifier(): string { return cryptoRandomString({ length: 20, characters: "1234567890qwertyuiopasdfghjklzxcvbnm", }); } function getEntryIdentifier(entry: ParentNode): string { return entry.querySelector("id")!.textContent!.split(":")[2]; } 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 alternatePath(identifier: string): string { return `static/alternate/${identifier}.html`; } function alternateURL(identifier: string): string { return `${BASE_URL}/alternate/${identifier}.html`; } 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)); }