#!/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 { sql, Database } from "@leafac/sqlite"; import databaseMigrate from "@leafac/sqlite-migration"; import javascript from "tagged-template-noop"; const VERSION = require("../package.json").version; export default function killTheNewsletter( rootDirectory: string ): { webApplication: express.Express; emailApplication: SMTPServer } { const webApplication = express(); webApplication.set("url", "http://localhost:4000"); webApplication.set("email port", 2525); webApplication.set("email host", "localhost"); webApplication.set("administrator", "mailto:kill-the-newsletter@leafac.com"); fs.ensureDirSync(rootDirectory); const database = new Database( path.join(rootDirectory, "kill-the-newsletter.db") ); database.function("newReference", (): string => cryptoRandomString({ length: 16, characters: "abcdefghijklmnopqrstuvwxyz0123456789", }) ); database.function( "entryFeedCreatedTitle", (title: string): string => `“${title}” inbox created` ); database.function( "entryFeedCreatedAuthor", (): string => "Kill the Newsletter!" ); database.function( "entryFeedCreatedContent", (feedReference: string): HTML => html`
Sign up for the newsletter with
${feedReference}@${webApplication.get("email host")}
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!
` ); 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 DEFAULT (newReference()), "title" TEXT NOT NULL ); CREATE TABLE "entries" ( "id" INTEGER PRIMARY KEY AUTOINCREMENT, "createdAt" TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, "reference" TEXT NOT NULL UNIQUE DEFAULT (newReference()), "feed" INTEGER NOT NULL REFERENCES "feeds", "title" TEXT NOT NULL, "author" TEXT NOT NULL, "content" TEXT NOT NULL ); CREATE TRIGGER "entryFeedCreated" AFTER INSERT ON "feeds" BEGIN INSERT INTO "entries" ("feed", "title", "author", "content") VALUES ( "NEW"."id", entryFeedCreatedTitle("NEW"."title"), entryFeedCreatedAuthor(), entryFeedCreatedContent("NEW"."reference") ); END; CREATE TRIGGER "feedsUpdatedAt" AFTER INSERT ON "entries" BEGIN UPDATE "feeds" SET "updatedAt" = CURRENT_TIMESTAMP WHERE "id" = "NEW"."feed"; END; `, ]); 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`Convert email newsletters into Atom feeds
$${logo}
Error: Missing newsletter name. Try again.
` ) ); const feedId = database.run( sql`INSERT INTO "feeds" ("title") VALUES (${req.body.name})` ).lastInsertRowid; const entry = database.get<{ title: string; content: HTML }>( sql`SELECT "title", "content" FROM "entries" WHERE "feed" = ${feedId}` )!; res.send( layout(html`${entry.title}
$${entry.content} `) ); }); 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} ` ); return html`404 Not found
`) ); }); const emailApplication = new SMTPServer({ disabledCommands: ["AUTH", "STARTTLS"], async onData(stream, session, callback) { try { const atHost = "@" + webApplication.get("email host"); 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 ?? ""; for (const address of new Set( session.envelope.rcptTo.map( (smtpServerAddress) => smtpServerAddress.address ) )) { if (!address.endsWith(atHost)) continue; const feedReference = address.slice(0, -atHost.length); const feed = database.get<{ id: number }>( sql`SELECT "id" FROM "feeds" WHERE "reference" = ${feedReference}` ); if (feed === undefined) continue; database.executeTransaction(() => { database.run( sql` INSERT INTO "entries" ("feed", "title", "author", "content") VALUES (${feed.id}, ${subject}, ${from}, ${body}) ` ); while (renderFeed(feedReference)!.length > 500_000) database.run( sql`DELETE FROM "entries" WHERE "feed" = ${feed.id} ORDER BY "createdAt" 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.")); } }, }); return { webApplication, emailApplication }; } if (require.main === module) { console.log(`Kill the Newsletter!/${VERSION}`); const configurationFile = path.resolve( process.argv[2] ?? path.join(process.cwd(), "configuration.js") ); require(configurationFile)(require); console.log(`Configuration file loaded from ‘${configurationFile}’.`); }