Merge
This commit is contained in:
commit
3b26026461
|
@ -0,0 +1,5 @@
|
|||
# Prevent Git history from leaking
|
||||
**/.git
|
||||
|
||||
# `npm ci` makes this redundant, but it's a good practice
|
||||
**/node_modules
|
|
@ -0,0 +1,17 @@
|
|||
FROM node:16
|
||||
|
||||
RUN git config --global url."https://github.com/".insteadOf ssh://git@github.com:
|
||||
|
||||
WORKDIR /kill-the-newsletter
|
||||
|
||||
COPY . .
|
||||
RUN npm ci
|
||||
RUN npm dedupe --production
|
||||
|
||||
ENV WEB_PORT=4000
|
||||
ENV EMAIL_PORT=2525
|
||||
ENV BASE_URL=http://localhost:4000
|
||||
ENV SMTP_URL=smtp://localhost
|
||||
ENV ISSUE_REPORT_EMAIL=kill-the-newsletter@leafac.com
|
||||
|
||||
CMD node .
|
|
@ -809,6 +809,17 @@
|
|||
}
|
||||
]
|
||||
},
|
||||
"node_modules/bent": {
|
||||
"version": "7.0.3",
|
||||
"resolved": "git+ssh://git@github.com/aral/bent.git#16a959683c6916204c28a1c870cb7b399c9215a9",
|
||||
"integrity": "sha512-chqIf23RhvbSoWyBqnLvti3MbZ1IOFFnj5q1qk5onVhTWI62WEuvfsjiX7dQcu35oCsamQlKP6RkBzsecSYcAw==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"bytesish": "^0.4.1",
|
||||
"caseless": "~0.12.0",
|
||||
"is-stream": "^2.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/better-sqlite3": {
|
||||
"version": "8.0.1",
|
||||
"resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-8.0.1.tgz",
|
||||
|
@ -7722,6 +7733,16 @@
|
|||
"resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz",
|
||||
"integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA=="
|
||||
},
|
||||
"bent": {
|
||||
"version": "git+ssh://git@github.com/aral/bent.git#16a959683c6916204c28a1c870cb7b399c9215a9",
|
||||
"integrity": "sha512-chqIf23RhvbSoWyBqnLvti3MbZ1IOFFnj5q1qk5onVhTWI62WEuvfsjiX7dQcu35oCsamQlKP6RkBzsecSYcAw==",
|
||||
"from": "bent@github:aral/bent#errors-with-response-headers",
|
||||
"requires": {
|
||||
"bytesish": "^0.4.1",
|
||||
"caseless": "~0.12.0",
|
||||
"is-stream": "^2.0.0"
|
||||
}
|
||||
},
|
||||
"better-sqlite3": {
|
||||
"version": "8.0.1",
|
||||
"resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-8.0.1.tgz",
|
||||
|
|
|
@ -0,0 +1,527 @@
|
|||
#!/usr/bin/env node
|
||||
|
||||
import path from "path";
|
||||
import express from "express";
|
||||
import { SMTPServer } from "smtp-server";
|
||||
import mailparser from "mailparser";
|
||||
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 javascript from "tagged-template-noop";
|
||||
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();
|
||||
|
||||
const baseUrl = process.env.BASE_URL ?? "http://localhost:4000";
|
||||
webApplication.set("url", baseUrl);
|
||||
|
||||
const smtpUrl = process.env.SMTP_URL ?? "smtp://localhost:2525";
|
||||
webApplication.set("email", smtpUrl);
|
||||
|
||||
const issueReportEmail =
|
||||
process.env.ISSUE_REPORT_EMAIL ?? "kill-the-newsletter@leafac.com";
|
||||
webApplication.set("administrator", `mailto:${issueReportEmail}`);
|
||||
|
||||
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
|
||||
);
|
||||
`,
|
||||
sql`
|
||||
CREATE INDEX "entriesFeed" ON "entries" ("feed");
|
||||
`,
|
||||
]);
|
||||
|
||||
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`
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta
|
||||
name="viewport"
|
||||
content="width=device-width, initial-scale=1.0"
|
||||
/>
|
||||
<meta name="generator" content="Kill the Newsletter!/${VERSION}" />
|
||||
<meta
|
||||
name="description"
|
||||
content="Convert email newsletters into Atom feeds."
|
||||
/>
|
||||
<title>Kill the Newsletter!</title>
|
||||
</head>
|
||||
<body
|
||||
style="${css`
|
||||
@at-root {
|
||||
body {
|
||||
font-size: 0.875rem;
|
||||
-webkit-text-size-adjust: 100%;
|
||||
line-height: 1.5;
|
||||
font-family: --apple-system, BlinkMacSystemFont, "Segoe UI",
|
||||
Roboto, Oxygen, Ubuntu, Cantarell, "Open Sans",
|
||||
"Helvetica Neue", sans-serif;
|
||||
max-width: 450px;
|
||||
padding: 0 1rem;
|
||||
margin: 1rem auto;
|
||||
text-align: center;
|
||||
overflow-wrap: break-word;
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
color: #d4d4d4;
|
||||
background-color: #1e1e1e;
|
||||
}
|
||||
}
|
||||
|
||||
code {
|
||||
font-family: SFMono-Regular, Consolas, "Liberation Mono", Menlo,
|
||||
monospace;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 1.3rem;
|
||||
}
|
||||
|
||||
footer {
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
a {
|
||||
color: inherit;
|
||||
transition: color 0.2s;
|
||||
|
||||
&:hover {
|
||||
color: #29adff;
|
||||
}
|
||||
|
||||
h1 &,
|
||||
footer & {
|
||||
text-decoration: none;
|
||||
}
|
||||
}
|
||||
|
||||
input,
|
||||
button {
|
||||
all: unset;
|
||||
padding: 0.1rem 1rem;
|
||||
border: 1px solid gainsboro;
|
||||
border-radius: 5px;
|
||||
box-shadow: inset 0 1px 1px #ffffff10, 0 1px 3px #00000010;
|
||||
transition: border-color 0.2s;
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
border-color: dimgray;
|
||||
}
|
||||
|
||||
@supports (-webkit-touch-callout: none) {
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
&:focus {
|
||||
border-color: #29adff;
|
||||
}
|
||||
}
|
||||
|
||||
button {
|
||||
background-color: white;
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
background-color: #5a5a5a;
|
||||
}
|
||||
|
||||
&:active {
|
||||
color: white;
|
||||
background-color: #29adff;
|
||||
}
|
||||
}
|
||||
}
|
||||
`}"
|
||||
>
|
||||
<header>
|
||||
<h1>
|
||||
<a href="${webApplication.get("url")}/">Kill the Newsletter!</a>
|
||||
</h1>
|
||||
<p>Convert email newsletters into Atom feeds</p>
|
||||
<p
|
||||
style="${css`
|
||||
@media (prefers-color-scheme: dark) {
|
||||
path {
|
||||
fill: #d4d4d4;
|
||||
}
|
||||
}
|
||||
`}"
|
||||
>
|
||||
$${logo}
|
||||
</p>
|
||||
</header>
|
||||
<main>$${body}</main>
|
||||
<footer>
|
||||
<p>
|
||||
By <a href="https://leafac.com">Leandro Facchinetti</a> ·
|
||||
<a href="https://patreon.com/leafac">Patreon</a> ·
|
||||
<a href="https://paypal.me/LeandroFacchinetti">PayPal</a> ·
|
||||
<a href="https://github.com/leafac/kill-the-newsletter.com"
|
||||
>Source</a
|
||||
> ·
|
||||
<a href="${webApplication.get("administrator")}"
|
||||
>Report an issue</a
|
||||
>
|
||||
</p>
|
||||
</footer>
|
||||
<script>
|
||||
for (const copyable of document.querySelectorAll(".copyable"))
|
||||
copyable.insertAdjacentHTML(
|
||||
"afterend",
|
||||
$${"`"}$${html`<br />
|
||||
<button
|
||||
type="button"
|
||||
onclick="${javascript`
|
||||
(async () => {
|
||||
await navigator.clipboard.writeText("\${copyable.textContent}");
|
||||
const originalTextContent = this.textContent;
|
||||
this.textContent = "Copied";
|
||||
await new Promise(resolve => window.setTimeout(resolve, 500));
|
||||
this.textContent = originalTextContent;
|
||||
})();
|
||||
`}"
|
||||
>
|
||||
Copy
|
||||
</button>`}$${"`"}
|
||||
);
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
`);
|
||||
}
|
||||
|
||||
webApplication.get<{}, HTML, {}, {}, {}>("/", (req, res) => {
|
||||
res.send(
|
||||
layout(html`
|
||||
<form
|
||||
method="POST"
|
||||
action="${webApplication.get("url")}"
|
||||
style="${css`
|
||||
max-width: 300px;
|
||||
margin: 0 auto;
|
||||
|
||||
input,
|
||||
button {
|
||||
box-sizing: border-box;
|
||||
width: 100%;
|
||||
}
|
||||
`}"
|
||||
>
|
||||
<p>
|
||||
<input
|
||||
type="text"
|
||||
name="name"
|
||||
placeholder="Newsletter name…"
|
||||
maxlength="500"
|
||||
required
|
||||
pattern=".*\\S.*"
|
||||
autocomplete="off"
|
||||
autofocus
|
||||
/>
|
||||
</p>
|
||||
<p>
|
||||
<button>Create Inbox</button>
|
||||
</p>
|
||||
</form>
|
||||
`)
|
||||
);
|
||||
});
|
||||
|
||||
webApplication.post<{}, HTML, { name?: string }, {}, {}>("/", (req, res) => {
|
||||
if (
|
||||
typeof req.body.name !== "string" ||
|
||||
req.body.name.trim() === "" ||
|
||||
req.body.name.length > 500
|
||||
)
|
||||
return res.status(422).send(
|
||||
layout(
|
||||
html`
|
||||
<p>
|
||||
Error: Missing newsletter name.
|
||||
<a href="${webApplication.get("url")}/"
|
||||
><strong>Try again</strong></a
|
||||
>.
|
||||
</p>
|
||||
`
|
||||
)
|
||||
);
|
||||
|
||||
const feedReference = newReference();
|
||||
const welcomeTitle = `“${req.body.name}” inbox created`;
|
||||
const welcomeContent = html`
|
||||
<p>
|
||||
Sign up for the newsletter with<br />
|
||||
<code class="copyable"
|
||||
>${feedReference}@${new URL(webApplication.get("email"))
|
||||
.hostname}</code
|
||||
>
|
||||
</p>
|
||||
<p>
|
||||
Subscribe to the Atom feed at<br />
|
||||
<code class="copyable"
|
||||
>${webApplication.get("url")}/feeds/${feedReference}.xml</code
|
||||
>
|
||||
</p>
|
||||
<p>
|
||||
<strong>Don’t share these addresses.</strong><br />
|
||||
They contain an identifier that other people could use to send you spam
|
||||
and to control your newsletter subscriptions.
|
||||
</p>
|
||||
<p><strong>Enjoy your readings!</strong></p>
|
||||
<p>
|
||||
<a href="${webApplication.get("url")}/"
|
||||
><strong>Create another inbox</strong></a
|
||||
>
|
||||
</p>
|
||||
`;
|
||||
|
||||
database.executeTransaction(() => {
|
||||
const feedId = database.run(
|
||||
sql`INSERT INTO "feeds" ("reference", "title") VALUES (${feedReference}, ${req.body.name})`
|
||||
).lastInsertRowid;
|
||||
database.run(
|
||||
sql`
|
||||
INSERT INTO "entries" ("reference", "feed", "title", "author", "content")
|
||||
VALUES (
|
||||
${newReference()},
|
||||
${feedId},
|
||||
${welcomeTitle},
|
||||
${"Kill the Newsletter!"},
|
||||
${welcomeContent}
|
||||
)
|
||||
`
|
||||
);
|
||||
});
|
||||
|
||||
res.send(
|
||||
layout(html`
|
||||
<p><strong>${welcomeTitle}</strong></p>
|
||||
$${welcomeContent}
|
||||
`)
|
||||
);
|
||||
});
|
||||
|
||||
function renderFeed(feedReference: string): HTML | undefined {
|
||||
const feed = database.get<{
|
||||
id: number;
|
||||
updatedAt: string;
|
||||
title: string;
|
||||
}>(
|
||||
sql`SELECT "id", "updatedAt", "title" FROM "feeds" WHERE "reference" = ${feedReference}`
|
||||
);
|
||||
if (feed === undefined) return;
|
||||
|
||||
const entries = database.all<{
|
||||
createdAt: string;
|
||||
reference: string;
|
||||
title: string;
|
||||
author: string;
|
||||
content: string;
|
||||
}>(
|
||||
sql`
|
||||
SELECT "createdAt", "reference", "title", "author", "content"
|
||||
FROM "entries"
|
||||
WHERE "feed" = ${feed.id}
|
||||
ORDER BY "id" DESC
|
||||
`
|
||||
);
|
||||
|
||||
return html`
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<feed xmlns="http://www.w3.org/2005/Atom">
|
||||
<link
|
||||
rel="self"
|
||||
type="application/atom+xml"
|
||||
href="${webApplication.get("url")}/feeds/${feedReference}.xml"
|
||||
/>
|
||||
<link
|
||||
rel="alternate"
|
||||
type="text/html"
|
||||
href="${webApplication.get("url")}/"
|
||||
/>
|
||||
<id>urn:kill-the-newsletter:${feedReference}</id>
|
||||
<title>${feed.title}</title>
|
||||
<subtitle
|
||||
>Kill the Newsletter! Inbox:
|
||||
${feedReference}@${new URL(webApplication.get("email")).hostname} →
|
||||
${webApplication.get("url")}/feeds/${feedReference}.xml</subtitle
|
||||
>
|
||||
<updated>${new Date(feed.updatedAt).toISOString()}</updated>
|
||||
<author><name>Kill the Newsletter!</name></author>
|
||||
$${entries.map(
|
||||
(entry) => html`
|
||||
<entry>
|
||||
<id>urn:kill-the-newsletter:${entry.reference}</id>
|
||||
<title>${entry.title}</title>
|
||||
<author><name>${entry.author}</name></author>
|
||||
<updated>${new Date(entry.createdAt).toISOString()}</updated>
|
||||
<link
|
||||
rel="alternate"
|
||||
type="text/html"
|
||||
href="${webApplication.get(
|
||||
"url"
|
||||
)}/alternates/${entry.reference}.html"
|
||||
/>
|
||||
<content type="html">${entry.content}</content>
|
||||
</entry>
|
||||
`
|
||||
)}
|
||||
</feed>
|
||||
`.trim();
|
||||
}
|
||||
|
||||
webApplication.get<{ feedReference: string }, HTML, {}, {}, {}>(
|
||||
"/feeds/:feedReference.xml",
|
||||
(req, res, next) => {
|
||||
const feed = renderFeed(req.params.feedReference);
|
||||
if (feed === undefined) return next();
|
||||
res.type("atom").header("X-Robots-Tag", "noindex").send(feed);
|
||||
}
|
||||
);
|
||||
|
||||
webApplication.get<{ entryReference: string }, HTML, {}, {}, {}>(
|
||||
"/alternates/:entryReference.html",
|
||||
(req, res, next) => {
|
||||
const entry = database.get<{ content: string }>(
|
||||
sql`SELECT "content" FROM "entries" WHERE "reference" = ${req.params.entryReference}`
|
||||
);
|
||||
if (entry === undefined) return next();
|
||||
res.header("X-Robots-Tag", "noindex").send(entry.content);
|
||||
}
|
||||
);
|
||||
|
||||
webApplication.use((req, res) => {
|
||||
res.send(
|
||||
layout(html`
|
||||
<p><strong>404 Not found</strong></p>
|
||||
<p>
|
||||
<a href="${webApplication.get("url")}/"
|
||||
><strong>Create a new inbox</strong></a
|
||||
>
|
||||
</p>
|
||||
`)
|
||||
);
|
||||
});
|
||||
|
||||
const emailApplication = new SMTPServer({
|
||||
disabledCommands: ["AUTH", "STARTTLS"],
|
||||
async onData(stream, session, callback) {
|
||||
try {
|
||||
const email = await mailparser.simpleParser(stream);
|
||||
const from = email.from?.text ?? "";
|
||||
const subject = email.subject ?? "";
|
||||
const body =
|
||||
typeof email.html === "string" ? email.html : email.textAsHtml ?? "";
|
||||
database.executeTransaction(() => {
|
||||
for (const address of new Set(
|
||||
session.envelope.rcptTo.map(
|
||||
(smtpServerAddress) => smtpServerAddress.address
|
||||
)
|
||||
)) {
|
||||
const addressParts = address.split("@");
|
||||
if (addressParts.length !== 2) continue;
|
||||
const [feedReference, hostname] = addressParts;
|
||||
if (hostname !== new URL(webApplication.get("email")).hostname)
|
||||
continue;
|
||||
const feed = database.get<{ id: number }>(
|
||||
sql`SELECT "id" FROM "feeds" WHERE "reference" = ${feedReference}`
|
||||
);
|
||||
if (feed === undefined) continue;
|
||||
database.run(
|
||||
sql`
|
||||
INSERT INTO "entries" ("reference", "feed", "title", "author", "content")
|
||||
VALUES (
|
||||
${newReference()},
|
||||
${feed.id},
|
||||
${subject},
|
||||
${from},
|
||||
${body}
|
||||
)
|
||||
`
|
||||
);
|
||||
database.run(
|
||||
sql`UPDATE "feeds" SET "updatedAt" = CURRENT_TIMESTAMP WHERE "id" = ${feed.id}`
|
||||
);
|
||||
while (renderFeed(feedReference)!.length > 500_000)
|
||||
database.run(
|
||||
sql`DELETE FROM "entries" WHERE "feed" = ${feed.id} ORDER BY "id" ASC LIMIT 1`
|
||||
);
|
||||
}
|
||||
});
|
||||
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."));
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
function newReference(): string {
|
||||
return cryptoRandomString({
|
||||
length: 16,
|
||||
characters: "abcdefghijklmnopqrstuvwxyz0123456789",
|
||||
});
|
||||
}
|
||||
|
||||
return { webApplication, emailApplication };
|
||||
}
|
||||
|
||||
if (require.main === module) {
|
||||
console.log(`Kill the Newsletter!/${VERSION}`);
|
||||
if (process.argv[2] === undefined) {
|
||||
const { webApplication, emailApplication } = killTheNewsletter(
|
||||
path.join(process.cwd(), "data")
|
||||
);
|
||||
const webPort =
|
||||
process.env.WEB_PORT ?? new URL(webApplication.get("url")).port;
|
||||
webApplication.listen(webPort, () => {
|
||||
console.log(`Web server started at port ${webPort}`);
|
||||
});
|
||||
const emailPort =
|
||||
process.env.EMAIL_PORT ?? new URL(webApplication.get("email")).port;
|
||||
emailApplication.listen(emailPort, () => {
|
||||
console.log(`Email server started at port ${emailPort}`);
|
||||
});
|
||||
} else {
|
||||
const configurationFile = path.resolve(process.argv[2]);
|
||||
require(configurationFile)(require);
|
||||
console.log(`Configuration loaded from ‘${configurationFile}’.`);
|
||||
}
|
||||
}
|
Loading…
Reference in New Issue