kill-the-newsletter/src/index.tsx

280 lines
7.3 KiB
TypeScript
Raw Normal View History

2020-03-18 04:07:48 +01:00
import express from "express";
2020-03-20 22:31:46 +01:00
import { SMTPServer } from "smtp-server";
2020-03-19 15:24:14 +01:00
import mailparser from "mailparser";
2020-03-18 04:07:48 +01:00
import React from "react";
2020-03-19 01:21:04 +01:00
import ReactDOMServer from "react-dom/server";
2020-03-19 15:24:14 +01:00
import xml2js from "xml2js";
2020-03-22 15:23:05 +01:00
import { promises as fs } from "fs";
2020-03-19 01:21:04 +01:00
import cryptoRandomString from "crypto-random-string";
2020-03-18 04:07:48 +01:00
2020-03-20 22:31:46 +01:00
export const webServer = express()
2020-03-19 16:50:15 +01:00
.use(express.static("static"))
.use(express.urlencoded({ extended: true }))
.get("/", (req, res) =>
res.send(
renderHTML(
<Layout>
<Form></Form>
</Layout>
)
2020-03-19 15:17:38 +01:00
)
)
2020-03-22 15:23:05 +01:00
.post("/", (req, res, next) => {
(async () => {
const name = req.body.name;
const identifier = createIdentifier();
await fs.writeFile(
feedPath(identifier),
renderXML(Feed({ name, identifier }))
);
res.send(
renderHTML(
<Layout>
<h1>{name} Inbox Created</h1>
<Created identifier={identifier}></Created>
</Layout>
)
);
})().catch(next);
2020-03-20 22:31:46 +01:00
})
2020-03-22 08:34:55 +01:00
.listen(8000);
2020-03-19 01:21:04 +01:00
2020-03-20 22:31:46 +01:00
export const emailServer = new SMTPServer({
2020-03-21 03:45:29 +01:00
disabledCommands: ["AUTH", "STARTTLS"],
2020-03-22 13:33:16 +01:00
onData(stream, session, callback) {
(async () => {
const email = await mailparser.simpleParser(stream);
const { entry } = Entry({
title: email.subject,
author: email.from.text,
// FIXME: https://github.com/DefinitelyTyped/DefinitelyTyped/pull/43234 / typeof email.html !== "boolean" => email.html !== false
2020-03-23 16:08:57 +01:00
...(typeof email.html === "boolean"
? { html: false, content: email.text ?? "" }
: { content: email.html })
2020-03-22 13:33:16 +01:00
});
2020-03-22 15:23:05 +01:00
for (const { address } of session.envelope.rcptTo) {
const match = address.match(/^(\w+)@kill-the-newsletter.com$/);
if (match === null) continue;
const identifier = match[1];
const path = feedPath(identifier);
const xmlText = await fs.readFile(path, "utf8").catch(() => null);
if (xmlText === null) continue;
const xml = await new xml2js.Parser().parseStringPromise(xmlText);
2020-03-22 13:33:16 +01:00
xml.feed.updated = now();
if (xml.feed.entry === undefined) xml.feed.entry = [];
xml.feed.entry.unshift(entry);
while (xml.feed.entry.length > 0 && renderXML(xml).length > 500_000)
xml.feed.entry.pop();
2020-03-22 15:23:05 +01:00
await fs.writeFile(path, renderXML(xml));
2020-03-22 13:33:16 +01:00
}
callback();
2020-03-22 16:42:08 +01:00
})().catch(error => {
console.error(error);
2020-03-22 16:50:18 +01:00
stream.resume();
callback(error);
2020-03-22 16:42:08 +01:00
});
2020-03-21 03:45:29 +01:00
}
2020-03-20 22:31:46 +01:00
}).listen(process.env.NODE_ENV === "production" ? 25 : 2525);
2020-03-19 01:58:41 +01:00
2020-03-19 01:21:04 +01:00
function Layout({ children }: { children: React.ReactNode }) {
return (
<html lang="en">
<head>
<meta charSet="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="author" content="Leandro Facchinetti" />
<meta
name="description"
content="Convert email newsletters into Atom feeds."
/>
<link
rel="icon"
type="image/png"
href="/favicon-32x32.png"
sizes="32x32"
/>
<link
rel="icon"
type="image/png"
href="/favicon-16x16.png"
sizes="16x16"
/>
<link rel="icon" type="image/x-icon" href="/favicon.ico" />
2020-03-20 19:46:52 +01:00
<link rel="stylesheet" type="text/css" href="/styles.css" />
2020-03-19 01:21:04 +01:00
<title>Kill the Newsletter!</title>
</head>
<body style={{ textAlign: "center" }}>
<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.png"
width="150"
/>
</p>
</header>
<main>{children}</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="mailto:kill-the-newsletter@leafac.com">Report an Issue</a>
</p>
</footer>
</body>
</html>
);
}
function Form() {
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-03-20 20:08:04 +01:00
function Created({ identifier }: { identifier: string }) {
2020-03-19 01:21:04 +01:00
return (
<>
<p>
Sign up for the newsletter with
<br />
2020-03-20 20:08:04 +01:00
<code>{feedEmail(identifier)}</code>
2020-03-19 01:21:04 +01:00
</p>
<p>
Subscribe to the Atom feed at
<br />
2020-03-20 20:08:04 +01:00
<code>{feedURL(identifier)}</code>
2020-03-19 01:21:04 +01:00
</p>
<p>
Dont share these addresses.
<br />
2020-03-20 20:08:04 +01:00
They contain an identifier that other people could use
2020-03-19 01:21:04 +01:00
<br />
to send you spam and to control your newsletter subscriptions.
</p>
<p>Enjoy your readings!</p>
<p>
<a href="https://www.kill-the-newsletter.com">
<strong>Create Another Inbox</strong>
</a>
</p>
</>
);
}
2020-03-20 20:08:04 +01:00
function Feed({ name, identifier }: { name: string; identifier: string }) {
2020-03-19 01:21:04 +01:00
return {
feed: {
$: { xmlns: "http://www.w3.org/2005/Atom" },
link: [
{
$: {
rel: "self",
type: "application/atom+xml",
2020-03-20 20:08:04 +01:00
href: feedURL(identifier)
2020-03-19 01:21:04 +01:00
}
},
{
$: {
rel: "alternate",
type: "text/html",
href: "https://www.kill-the-newsletter.com/"
}
}
],
2020-03-20 20:08:04 +01:00
id: urn(identifier),
2020-03-19 01:21:04 +01:00
title: name,
2020-03-20 20:08:04 +01:00
subtitle: `Kill the Newsletter! Inbox: ${feedEmail(
identifier
)} ${feedURL(identifier)}`,
2020-03-19 01:21:04 +01:00
updated: now(),
...Entry({
title: `${name}” Inbox Created`,
author: "Kill the Newsletter!",
content: ReactDOMServer.renderToStaticMarkup(
2020-03-20 20:08:04 +01:00
<Created identifier={identifier}></Created>
2020-03-19 01:21:04 +01:00
)
})
}
};
}
function Entry({
title,
author,
2020-03-23 16:08:57 +01:00
content,
html
2020-03-19 01:21:04 +01:00
}: {
title: string;
author: string;
content: string;
2020-03-23 16:08:57 +01:00
html?: boolean;
2020-03-19 01:21:04 +01:00
}) {
return {
entry: {
2020-03-21 16:58:28 +01:00
id: urn(createIdentifier()),
2020-03-19 01:21:04 +01:00
title,
author: { name: author },
updated: now(),
2020-03-23 16:08:57 +01:00
content: {
...(html === false ? {} : { $: { type: "html" } }),
_: content
}
2020-03-19 01:21:04 +01:00
}
};
}
2020-03-21 16:58:28 +01:00
function createIdentifier(): string {
2020-03-19 01:21:04 +01:00
return cryptoRandomString({
length: 20,
characters: "1234567890qwertyuiopasdfghjklzxcvbnm"
});
}
2020-03-20 19:38:14 +01:00
function now(): string {
2020-03-19 01:21:04 +01:00
return new Date().toISOString();
}
2020-03-20 20:08:04 +01:00
function feedPath(identifier: string): string {
return `static/feeds/${identifier}.xml`;
2020-03-19 01:21:04 +01:00
}
2020-03-20 20:08:04 +01:00
function feedURL(identifier: string): string {
return `https://www.kill-the-newsletter.com/feeds/${identifier}.xml`;
2020-03-19 01:21:04 +01:00
}
2020-03-21 08:31:34 +01:00
function feedEmail(identifier: string): string {
2020-03-20 20:08:04 +01:00
return `${identifier}@kill-the-newsletter.com`;
2020-03-19 01:21:04 +01:00
}
2020-03-20 20:08:04 +01:00
function urn(identifier: string): string {
return `urn:kill-the-newsletter:${identifier}`;
2020-03-19 01:21:04 +01:00
}
2020-03-19 03:41:40 +01:00
function renderHTML(component: React.ReactElement): string {
2020-03-19 01:21:04 +01:00
return `<!DOCTYPE html>\n${ReactDOMServer.renderToStaticMarkup(component)}`;
}
2020-03-19 03:41:40 +01:00
function renderXML(xml: object): string {
2020-03-19 15:24:14 +01:00
return new xml2js.Builder().buildObject(xml);
2020-03-19 01:21:04 +01:00
}