kill-the-newsletter/index.ts

257 lines
8.2 KiB
TypeScript
Raw Normal View History

2020-05-05 08:12:57 +02:00
import express from "express";
import { SMTPServer } from "smtp-server";
import mailparser from "mailparser";
2020-07-23 17:11:41 +02:00
import * as sanitizeXMLString from "sanitize-xml-string";
2020-05-05 08:12:57 +02:00
import * as entities from "entities";
2020-07-23 17:11:41 +02:00
import R from "escape-string-regexp";
2020-05-05 08:12:57 +02:00
import { JSDOM } from "jsdom";
2020-07-23 17:11:41 +02:00
import { promises as fs } from "fs";
2020-05-05 08:12:57 +02:00
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 }))
2020-07-23 17:11:41 +02:00
.get("/", (req, res) => res.send(layout(newInbox())))
2020-05-05 08:12:57 +02:00
.post("/", async (req, res, next) => {
try {
const { name } = req.body;
const identifier = createIdentifier();
2020-07-23 17:11:41 +02:00
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)
)
);
2020-05-05 08:12:57 +02:00
res.send(
layout(`
<p><strong>${H(name)} Inbox Created</strong></p>
2020-07-23 17:11:41 +02:00
${renderedCreated}
2020-05-05 08:12:57 +02:00
`)
);
} 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);
2020-07-14 19:42:41 +02:00
const content =
typeof email.html === "string" ? email.html : email.textAsHtml ?? "";
2020-05-05 08:12:57 +02:00
for (const { address } of session.envelope.rcptTo) {
const match = address.match(
2020-07-23 17:11:41 +02:00
new RegExp(`^(?<identifier>\\w+)@${R(EMAIL_DOMAIN)}$`)
2020-05-05 08:12:57 +02:00
);
if (match?.groups === undefined) continue;
2020-07-23 17:11:41 +02:00
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);
});
2020-05-05 08:12:57 +02:00
}
callback();
} catch (error) {
console.error(
2020-07-23 17:11:41 +02:00
`Failed to receive message: ${JSON.stringify(session, null, 2)}`
2020-05-05 08:12:57 +02:00
);
console.error(error);
stream.resume();
callback(new Error("Failed to receive message. Please try again."));
}
},
}).listen(EMAIL_PORT);
2020-07-23 17:11:41 +02:00
async function addEntryToFeed(
identifier: string,
entry: string
): Promise<void> {
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,
`<?xml version="1.0" encoding="utf-8"?>${feed.serialize()}`
);
}
2020-05-05 08:12:57 +02:00
function layout(content: string): string {
return `<!DOCTYPE html>
<html lang="en">
2020-07-23 17:11:41 +02:00
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Kill the Newsletter!</title>
<meta name="author" content="Leandro Facchinetti">
<meta name="description" content="Convert email newsletters into Atom feeds.">
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32x32.png">
<link rel="icon" type="image/png" sizes="16x16" href="/favicon-16x16.png">
<link rel="icon" type="image/x-icon" href="/favicon.ico">
<link rel="stylesheet" type="text/css" href="/styles.css">
<header>
<h1><a href="/">Kill the Newsletter!</a></h1>
<p>Convert email newsletters into Atom feeds</p>
<p><img alt="Convert email newsletters into Atom feeds" src="/logo.svg"></p>
</header>
<main>${content}</main>
<footer><p>By <a href="https://www.leafac.com">Leandro Facchinetti</a> · <a href="https://github.com/leafac/www.kill-the-newsletter.com">Source</a> · <a href="${ISSUE_REPORT}">Report an Issue</a></p></footer>
`;
}
function newInbox(): string {
return `
<form method="POST" action="/">
<p>
<input type="text" name="name" placeholder="Newsletter Name…" maxlength="500" size="30" required>
<button>Create Inbox</button>
</p>
</form>
2020-05-05 08:12:57 +02:00
`;
}
function created(identifier: string): string {
return `
<p>Sign up for the newsletter with<br><code>${feedEmail(
identifier
)}</code></p>
<p>Subscribe to the Atom feed at<br><code>${feedURL(identifier)}</code></p>
<p>Dont share these addresses.<br>They contain an identifier that other people could use<br>to send you spam and to control your newsletter subscriptions.</p>
<p>Enjoy your readings!</p>
<p><a href="${BASE_URL}"><strong>Create Another Inbox</strong></a></p>
`.trim();
}
2020-07-23 17:11:41 +02:00
function feed(identifier: string, name: string): string {
2020-05-05 08:12:57 +02:00
return `<?xml version="1.0" encoding="utf-8"?>
<feed xmlns="http://www.w3.org/2005/Atom">
<link rel="self" type="application/atom+xml" href="${feedURL(
identifier
)}"/>
2020-07-23 17:11:41 +02:00
<link rel="alternate" type="text/html" href="${BASE_URL}"/>
2020-05-05 08:12:57 +02:00
<id>${urn(identifier)}</id>
<title>${name}</title>
<subtitle>Kill the Newsletter! Inbox: ${feedEmail(
identifier
)} ${feedURL(identifier)}</subtitle>
<updated>${now()}</updated>
<author><name>Kill the Newsletter!</name></author>
</feed>
`;
}
2020-07-14 19:42:41 +02:00
function entry(
2020-07-23 17:11:41 +02:00
identifier: string,
2020-07-14 19:42:41 +02:00
title: string,
author: string,
2020-07-23 17:11:41 +02:00
content: string
2020-07-14 19:42:41 +02:00
): string {
2020-05-05 08:12:57 +02:00
return `
<entry>
2020-07-14 19:42:41 +02:00
<id>${urn(identifier)}</id>
2020-05-05 08:12:57 +02:00
<title>${title}</title>
<author><name>${author}</name></author>
<updated>${now()}</updated>
2020-07-14 19:42:41 +02:00
<link rel="alternate" type="text/html" href="${alternateURL(
identifier
)}"/>
2020-05-05 08:12:57 +02:00
<content type="html">${content}</content>
</entry>
`.trim();
}
function createIdentifier(): string {
return cryptoRandomString({
length: 20,
characters: "1234567890qwertyuiopasdfghjklzxcvbnm",
});
}
2020-07-23 17:11:41 +02:00
function getEntryIdentifier(entry: ParentNode): string {
return entry.querySelector("id")!.textContent!.split(":")[2];
}
2020-05-05 08:12:57 +02:00
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}`;
}
2020-07-14 19:42:41 +02:00
function alternatePath(identifier: string): string {
return `static/alternate/${identifier}.html`;
}
function alternateURL(identifier: string): string {
return `${BASE_URL}/alternate/${identifier}.html`;
}
2020-05-05 08:12:57 +02:00
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));
}