#!/usr/bin/env node import path from "path"; import express from "express"; import { SMTPServer } from "smtp-server"; import mailparser from "mailparser"; import escapeStringRegexp from "escape-string-regexp"; 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"; 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") ); 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 ); `, ]); 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 ( req.body.name === undefined || req.body.name.trim() === "" || req.body.name.length > 500 ) return res.status(400).send( layout( html`

Error: Missing newsletter name. Try again.

` ) ); const reference = newReference(); const created = html`

Sign up for the newsletter with
${reference}@${webApplication.get("email host")}

Subscribe to the Atom feed at
${webApplication.get("url")}/feeds/${reference}.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

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

“${req.body.name}” inbox created $${created}

`) ); }); const emailApplication = new SMTPServer(); function newReference(): string { return cryptoRandomString({ length: 16, characters: "abcdefghijklmnopqrstuvwxyz0123456789", }); } 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}’.`); } /* export const webServer = express() .use(["/feeds", "/alternate"], (req, res, next) => { res.header("X-Robots-Tag", "noindex"); next(); }) .get( alternatePath(":feedIdentifier", ":entryIdentifier"), async (req, res, next) => { try { const { feedIdentifier, entryIdentifier } = req.params; const path = feedFilePath(feedIdentifier); let text; try { text = await fs.readFile(path, "utf8"); } catch { return res.sendStatus(404); } const feed = new JSDOM(text, { contentType: "text/xml" }); const document = feed.window.document; const link = document.querySelector( `link[href="${alternateURL(feedIdentifier, entryIdentifier)}"]` ); if (link === null) return res.sendStatus(404); res.send( entities.decodeXML( link.parentElement!.querySelector("content")!.textContent! ) ); } catch (error) { console.error(error); next(error); } } ) 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 new Set( session.envelope.rcptTo.map(({ address }) => address) )) { const match = address.match( new RegExp( `^(?\\w+)@${escapeStringRegexp(EMAIL_DOMAIN)}$` ) ); if (match?.groups === undefined) continue; const identifier = match.groups.identifier.toLowerCase(); const path = feedFilePath(identifier); let text; try { text = await fs.readFile(path, "utf8"); } catch { continue; } const feed = new JSDOM(text, { contentType: "text/xml" }); const document = feed.window.document; const updated = document.querySelector("feed > updated"); if (updated === null) { console.error(`Field ‘updated’ not found: ‘${path}’`); continue; } updated.textContent = now(); const renderedEntry = entry( identifier, createIdentifier(), X(email.subject ?? ""), X(email.from?.text ?? ""), X(content) ); const firstEntry = document.querySelector("feed > entry:first-of-type"); if (firstEntry === null) document .querySelector("feed")! .insertAdjacentHTML("beforeend", renderedEntry); else firstEntry.insertAdjacentHTML("beforebegin", renderedEntry); while (feed.serialize().length > 500_000) { const lastEntry = document.querySelector("feed > entry:last-of-type"); if (lastEntry === null) break; lastEntry.remove(); } await writeFileAtomic( path, html`${feed.serialize()}`.trim() ); } 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); function feed(identifier: string, name: string, initialEntry: string): string { return html` ${urn(identifier)} ${name} Kill the Newsletter! Inbox: ${feedEmail(identifier)} → ${feedURL(identifier)} ${now()} Kill the Newsletter! ${initialEntry} `.trim(); } function entry( feedIdentifier: string, entryIdentifier: string, title: string, author: string, content: string ): string { return html` ${urn(entryIdentifier)} ${title} ${author} ${now()} ${content} `.trim(); } function alternatePath( feedIdentifier: string, entryIdentifier: string ): string { return `/alternate/${feedIdentifier}/${entryIdentifier}.html`; } function alternateURL(feedIdentifier: string, entryIdentifier: string): string { return `${webApplication.get("url")}${alternatePath(feedIdentifier, entryIdentifier)}`; } function urn(identifier: string): string { return `urn:kill-the-newsletter:${identifier}`; } */