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>
|
|
|
|
|
Don’t 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
|
|
|
|
}
|