#!/usr/bin/env node import path from "path"; import express from "express"; import { SMTPServer } from "smtp-server"; import mailparser from "mailparser"; import fs from "fs-extra"; import cryptoRandomString from "crypto-random-string"; import { html, HTML } from "@leafac/html"; import { css, process as processCSS } from "@leafac/css"; import javascript from "tagged-template-noop"; import { sql, Database } from "@leafac/sqlite"; import databaseMigrate from "@leafac/sqlite-migration"; const VERSION = require("../package.json").version; export default function killTheNewsletter( rootDirectory: string ): { webApplication: express.Express; emailApplication: SMTPServer } { const webApplication = express(); const baseUrl = process.env.BASE_URL ?? "http://localhost:4000"; webApplication.set("url", baseUrl); const smtpUrl = process.env.SMTP_URL ?? "smtp://localhost:2525"; webApplication.set("email", smtpUrl); const issueReportEmail = process.env.ISSUE_REPORT_EMAIL ?? "kill-the-newsletter@leafac.com"; webApplication.set("administrator", `mailto:${issueReportEmail}`); fs.ensureDirSync(rootDirectory); const database = new Database( path.join(rootDirectory, "kill-the-newsletter.db") ); databaseMigrate(database, [ sql` CREATE TABLE "feeds" ( "id" INTEGER PRIMARY KEY AUTOINCREMENT, "createdAt" TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, "updatedAt" TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, "reference" TEXT NOT NULL UNIQUE, "title" TEXT NOT NULL ); CREATE TABLE "entries" ( "id" INTEGER PRIMARY KEY AUTOINCREMENT, "createdAt" TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, "reference" TEXT NOT NULL UNIQUE, "feed" INTEGER NOT NULL REFERENCES "feeds", "title" TEXT NOT NULL, "author" TEXT NOT NULL, "content" TEXT NOT NULL ); `, sql` CREATE INDEX "entriesFeed" ON "entries" ("feed"); `, ]); webApplication.use(express.static(path.join(__dirname, "../public"))); webApplication.use(express.urlencoded({ extended: true })); const logo = fs.readFileSync(path.join(__dirname, "../public/logo.svg")); function layout(body: HTML): HTML { return processCSS(html` Kill the Newsletter!

Kill the Newsletter!

Convert email newsletters into Atom feeds

$${logo}

$${body}
`); } webApplication.get<{}, HTML, {}, {}, {}>("/", (req, res) => { res.send( layout(html`

`) ); }); webApplication.post<{}, HTML, { name?: string }, {}, {}>("/", (req, res) => { if ( typeof req.body.name !== "string" || req.body.name.trim() === "" || req.body.name.length > 500 ) return res.status(422).send( layout( html`

Error: Missing newsletter name. Try again.

` ) ); const feedReference = newReference(); const welcomeTitle = `“${req.body.name}” inbox created`; const welcomeContent = html`

Sign up for the newsletter with
${feedReference}@${new URL(webApplication.get("email")) .hostname}

Subscribe to the Atom feed at
${webApplication.get("url")}/feeds/${feedReference}.xml

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

`; database.executeTransaction(() => { const feedId = database.run( sql`INSERT INTO "feeds" ("reference", "title") VALUES (${feedReference}, ${req.body.name})` ).lastInsertRowid; database.run( sql` INSERT INTO "entries" ("reference", "feed", "title", "author", "content") VALUES ( ${newReference()}, ${feedId}, ${welcomeTitle}, ${"Kill the Newsletter!"}, ${welcomeContent} ) ` ); }); res.send( layout(html`

${welcomeTitle}

$${welcomeContent} `) ); }); function renderFeed(feedReference: string): HTML | undefined { const feed = database.get<{ id: number; updatedAt: string; title: string; }>( sql`SELECT "id", "updatedAt", "title" FROM "feeds" WHERE "reference" = ${feedReference}` ); if (feed === undefined) return; const entries = database.all<{ createdAt: string; reference: string; title: string; author: string; content: string; }>( sql` SELECT "createdAt", "reference", "title", "author", "content" FROM "entries" WHERE "feed" = ${feed.id} ORDER BY "id" DESC ` ); return html` urn:kill-the-newsletter:${feedReference} ${feed.title} Kill the Newsletter! Inbox: ${feedReference}@${new URL(webApplication.get("email")).hostname} → ${webApplication.get("url")}/feeds/${feedReference}.xml ${new Date(feed.updatedAt).toISOString()} Kill the Newsletter! $${entries.map( (entry) => html` urn:kill-the-newsletter:${entry.reference} ${entry.title} ${entry.author} ${new Date(entry.createdAt).toISOString()} ${entry.content} ` )} `.trim(); } webApplication.get<{ feedReference: string }, HTML, {}, {}, {}>( "/feeds/:feedReference.xml", (req, res, next) => { const feed = renderFeed(req.params.feedReference); if (feed === undefined) return next(); res.type("atom").header("X-Robots-Tag", "noindex").send(feed); } ); webApplication.get<{ entryReference: string }, HTML, {}, {}, {}>( "/alternates/:entryReference.html", (req, res, next) => { const entry = database.get<{ content: string }>( sql`SELECT "content" FROM "entries" WHERE "reference" = ${req.params.entryReference}` ); if (entry === undefined) return next(); res.header("X-Robots-Tag", "noindex").send(entry.content); } ); webApplication.use((req, res) => { res.send( layout(html`

404 Not found

Create a new inbox

`) ); }); const emailApplication = new SMTPServer({ disabledCommands: ["AUTH", "STARTTLS"], async onData(stream, session, callback) { try { const email = await mailparser.simpleParser(stream); const from = email.from?.text ?? ""; const subject = email.subject ?? ""; const body = typeof email.html === "string" ? email.html : email.textAsHtml ?? ""; database.executeTransaction(() => { for (const address of new Set( session.envelope.rcptTo.map( (smtpServerAddress) => smtpServerAddress.address ) )) { const addressParts = address.split("@"); if (addressParts.length !== 2) continue; const [feedReference, hostname] = addressParts; if (hostname !== new URL(webApplication.get("email")).hostname) continue; const feed = database.get<{ id: number }>( sql`SELECT "id" FROM "feeds" WHERE "reference" = ${feedReference}` ); if (feed === undefined) continue; database.run( sql` INSERT INTO "entries" ("reference", "feed", "title", "author", "content") VALUES ( ${newReference()}, ${feed.id}, ${subject}, ${from}, ${body} ) ` ); database.run( sql`UPDATE "feeds" SET "updatedAt" = CURRENT_TIMESTAMP WHERE "id" = ${feed.id}` ); while (renderFeed(feedReference)!.length > 500_000) database.run( sql`DELETE FROM "entries" WHERE "feed" = ${feed.id} ORDER BY "id" ASC LIMIT 1` ); } }); 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.")); } }, }); function newReference(): string { return cryptoRandomString({ length: 16, characters: "abcdefghijklmnopqrstuvwxyz0123456789", }); } return { webApplication, emailApplication }; } if (require.main === module) { console.log(`Kill the Newsletter!/${VERSION}`); if (process.argv[2] === undefined) { const { webApplication, emailApplication } = killTheNewsletter( path.join(process.cwd(), "data") ); const webPort = process.env.WEB_PORT ?? new URL(webApplication.get("url")).port; webApplication.listen(webPort, () => { console.log(`Web server started at port ${webPort}`); }); const emailPort = process.env.EMAIL_PORT ?? new URL(webApplication.get("email")).port; emailApplication.listen(emailPort, () => { console.log(`Email server started at port ${emailPort}`); }); } else { const configurationFile = path.resolve(process.argv[2]); require(configurationFile)(require); console.log(`Configuration loaded from ‘${configurationFile}’.`); } }