kill-the-newsletter/src/index.tsx

279 lines
7.2 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import express from "express";
import { SMTPServer } from "smtp-server";
import mailparser from "mailparser";
import React from "react";
import ReactDOMServer from "react-dom/server";
import xml2js from "xml2js";
import { promises as fs } from "fs";
import cryptoRandomString from "crypto-random-string";
export const webServer = express()
.use(express.static("static"))
.use(express.urlencoded({ extended: true }))
.get("/", (req, res) =>
res.send(
renderHTML(
<Layout>
<Form></Form>
</Layout>
)
)
)
.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);
})
.listen(8000);
export const emailServer = new SMTPServer({
disabledCommands: ["AUTH", "STARTTLS"],
onData(stream, session, callback) {
(async () => {
const email = await mailparser.simpleParser(stream);
const { entry } = Entry({
title: email.subject ?? "",
author: email.from?.text ?? "",
...(typeof email.html === "string"
? { content: email.html }
: { html: false, content: email.text ?? "" })
});
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);
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();
await fs.writeFile(path, renderXML(xml));
}
callback();
})().catch(error => {
console.error(error);
stream.resume();
callback(error);
});
}
}).listen(process.env.NODE_ENV === "production" ? 25 : 2525);
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" />
<link rel="stylesheet" type="text/css" href="/styles.css" />
<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>
);
}
function Created({ identifier }: { identifier: 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="https://www.kill-the-newsletter.com">
<strong>Create Another Inbox</strong>
</a>
</p>
</>
);
}
function Feed({ name, identifier }: { name: string; identifier: string }) {
return {
feed: {
$: { xmlns: "http://www.w3.org/2005/Atom" },
link: [
{
$: {
rel: "self",
type: "application/atom+xml",
href: feedURL(identifier)
}
},
{
$: {
rel: "alternate",
type: "text/html",
href: "https://www.kill-the-newsletter.com/"
}
}
],
id: urn(identifier),
title: name,
subtitle: `Kill the Newsletter! Inbox: ${feedEmail(
identifier
)}${feedURL(identifier)}`,
updated: now(),
...Entry({
title: `${name}” Inbox Created`,
author: "Kill the Newsletter!",
content: ReactDOMServer.renderToStaticMarkup(
<Created identifier={identifier}></Created>
)
})
}
};
}
function Entry({
title,
author,
content,
html
}: {
title: string;
author: string;
content: string;
html?: boolean;
}) {
return {
entry: {
id: urn(createIdentifier()),
title,
author: { name: author },
updated: now(),
content: {
...(html === false ? {} : { $: { type: "html" } }),
_: content
}
}
};
}
function createIdentifier(): string {
return cryptoRandomString({
length: 20,
characters: "1234567890qwertyuiopasdfghjklzxcvbnm"
});
}
function now(): string {
return new Date().toISOString();
}
function feedPath(identifier: string): string {
return `static/feeds/${identifier}.xml`;
}
function feedURL(identifier: string): string {
return `https://www.kill-the-newsletter.com/feeds/${identifier}.xml`;
}
function feedEmail(identifier: string): string {
return `${identifier}@kill-the-newsletter.com`;
}
function urn(identifier: string): string {
return `urn:kill-the-newsletter:${identifier}`;
}
function renderHTML(component: React.ReactElement): string {
return `<!DOCTYPE html>\n${ReactDOMServer.renderToStaticMarkup(component)}`;
}
function renderXML(xml: object): string {
return new xml2js.Builder().buildObject(xml);
}