diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..d11b9a6 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,5 @@ +# Prevent Git history from leaking +**/.git + +# `npm ci` makes this redundant, but it's a good practice +**/node_modules \ No newline at end of file diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..edd41b1 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,17 @@ +FROM node:16 + +RUN git config --global url."https://github.com/".insteadOf ssh://git@github.com: + +WORKDIR /kill-the-newsletter + +COPY . . +RUN npm ci +RUN npm dedupe --production + +ENV WEB_PORT=4000 +ENV EMAIL_PORT=2525 +ENV BASE_URL=http://localhost:4000 +ENV SMTP_URL=smtp://localhost +ENV ISSUE_REPORT_EMAIL=kill-the-newsletter@leafac.com + +CMD node . \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index 25ee6b0..61cb25e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -809,6 +809,17 @@ } ] }, + "node_modules/bent": { + "version": "7.0.3", + "resolved": "git+ssh://git@github.com/aral/bent.git#16a959683c6916204c28a1c870cb7b399c9215a9", + "integrity": "sha512-chqIf23RhvbSoWyBqnLvti3MbZ1IOFFnj5q1qk5onVhTWI62WEuvfsjiX7dQcu35oCsamQlKP6RkBzsecSYcAw==", + "license": "Apache-2.0", + "dependencies": { + "bytesish": "^0.4.1", + "caseless": "~0.12.0", + "is-stream": "^2.0.0" + } + }, "node_modules/better-sqlite3": { "version": "8.0.1", "resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-8.0.1.tgz", @@ -7722,6 +7733,16 @@ "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==" }, + "bent": { + "version": "git+ssh://git@github.com/aral/bent.git#16a959683c6916204c28a1c870cb7b399c9215a9", + "integrity": "sha512-chqIf23RhvbSoWyBqnLvti3MbZ1IOFFnj5q1qk5onVhTWI62WEuvfsjiX7dQcu35oCsamQlKP6RkBzsecSYcAw==", + "from": "bent@github:aral/bent#errors-with-response-headers", + "requires": { + "bytesish": "^0.4.1", + "caseless": "~0.12.0", + "is-stream": "^2.0.0" + } + }, "better-sqlite3": { "version": "8.0.1", "resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-8.0.1.tgz", @@ -12436,4 +12457,4 @@ } } } -} +} \ No newline at end of file diff --git a/source/index.ts b/source/index.ts new file mode 100644 index 0000000..1b8870c --- /dev/null +++ b/source/index.ts @@ -0,0 +1,527 @@ +#!/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}’.`); + } +}