Fixes & refactorings
This commit is contained in:
parent
0402f641ff
commit
80ae8fab52
210
index.ts
210
index.ts
|
@ -1,10 +1,11 @@
|
||||||
import express from "express";
|
import express from "express";
|
||||||
import { SMTPServer } from "smtp-server";
|
import { SMTPServer } from "smtp-server";
|
||||||
import mailparser from "mailparser";
|
import mailparser from "mailparser";
|
||||||
import { promises as fs } from "fs";
|
|
||||||
import * as entities from "entities";
|
|
||||||
import { JSDOM } from "jsdom";
|
|
||||||
import * as sanitizeXMLString from "sanitize-xml-string";
|
import * as sanitizeXMLString from "sanitize-xml-string";
|
||||||
|
import * as entities from "entities";
|
||||||
|
import R from "escape-string-regexp";
|
||||||
|
import { JSDOM } from "jsdom";
|
||||||
|
import { promises as fs } from "fs";
|
||||||
import writeFileAtomic from "write-file-atomic";
|
import writeFileAtomic from "write-file-atomic";
|
||||||
import cryptoRandomString from "crypto-random-string";
|
import cryptoRandomString from "crypto-random-string";
|
||||||
|
|
||||||
|
@ -18,28 +19,26 @@ export const ISSUE_REPORT =
|
||||||
export const webServer = express()
|
export const webServer = express()
|
||||||
.use(express.static("static"))
|
.use(express.static("static"))
|
||||||
.use(express.urlencoded({ extended: true }))
|
.use(express.urlencoded({ extended: true }))
|
||||||
.get("/", (req, res) =>
|
.get("/", (req, res) => res.send(layout(newInbox())))
|
||||||
res.send(
|
|
||||||
layout(`
|
|
||||||
<form method="POST" action="/">
|
|
||||||
<p>
|
|
||||||
<input type="text" name="name" placeholder="Newsletter Name…" maxlength="500" size="30" required>
|
|
||||||
<button>Create Inbox</button>
|
|
||||||
</p>
|
|
||||||
</form>
|
|
||||||
`)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
.post("/", async (req, res, next) => {
|
.post("/", async (req, res, next) => {
|
||||||
try {
|
try {
|
||||||
const { name } = req.body;
|
const { name } = req.body;
|
||||||
const identifier = createIdentifier();
|
const identifier = createIdentifier();
|
||||||
await writeFileAtomic(alternatePath(identifier), created(identifier));
|
await writeFileAtomic(feedPath(identifier), feed(identifier, X(name)));
|
||||||
await writeFileAtomic(feedPath(identifier), feed(X(name), identifier));
|
const renderedCreated = created(identifier);
|
||||||
|
await addEntryToFeed(
|
||||||
|
identifier,
|
||||||
|
entry(
|
||||||
|
createIdentifier(),
|
||||||
|
`“${X(name)}” Inbox Created`,
|
||||||
|
"Kill the Newsletter!",
|
||||||
|
X(renderedCreated)
|
||||||
|
)
|
||||||
|
);
|
||||||
res.send(
|
res.send(
|
||||||
layout(`
|
layout(`
|
||||||
<p><strong>“${H(name)}” Inbox Created</strong></p>
|
<p><strong>“${H(name)}” Inbox Created</strong></p>
|
||||||
${created(identifier)}
|
${renderedCreated}
|
||||||
`)
|
`)
|
||||||
);
|
);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
@ -47,14 +46,6 @@ export const webServer = express()
|
||||||
next(error);
|
next(error);
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.get("/alternate", (req, res) =>
|
|
||||||
res.send(
|
|
||||||
layout(`
|
|
||||||
<p>Typically each entry in a feed includes a link<br>to an online version of the same content,<br>but the content from the entries in a <strong>Kill the Newsletter!</strong><br>feed come from emails—an online version may not even exist—<br>so you’re reading this instead.</p>
|
|
||||||
<p><a href="${BASE_URL}"><strong>Create an Inbox</strong></a></p>
|
|
||||||
`)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
.listen(WEB_PORT);
|
.listen(WEB_PORT);
|
||||||
|
|
||||||
export const emailServer = new SMTPServer({
|
export const emailServer = new SMTPServer({
|
||||||
|
@ -62,57 +53,30 @@ export const emailServer = new SMTPServer({
|
||||||
async onData(stream, session, callback) {
|
async onData(stream, session, callback) {
|
||||||
try {
|
try {
|
||||||
const email = await mailparser.simpleParser(stream);
|
const email = await mailparser.simpleParser(stream);
|
||||||
const identifier = createIdentifier();
|
|
||||||
const content =
|
const content =
|
||||||
typeof email.html === "string" ? email.html : email.textAsHtml ?? "";
|
typeof email.html === "string" ? email.html : email.textAsHtml ?? "";
|
||||||
await writeFileAtomic(alternatePath(identifier), content);
|
|
||||||
const newEntry = entry(
|
|
||||||
X(email.subject ?? ""),
|
|
||||||
X(email.from?.text ?? ""),
|
|
||||||
X(content),
|
|
||||||
identifier
|
|
||||||
);
|
|
||||||
for (const { address } of session.envelope.rcptTo) {
|
for (const { address } of session.envelope.rcptTo) {
|
||||||
const match = address.match(
|
const match = address.match(
|
||||||
new RegExp(`^(?<identifier>\\w+)@${EMAIL_DOMAIN}$`)
|
new RegExp(`^(?<identifier>\\w+)@${R(EMAIL_DOMAIN)}$`)
|
||||||
);
|
);
|
||||||
if (match?.groups === undefined) continue;
|
if (match?.groups === undefined) continue;
|
||||||
const path = feedPath(match.groups.identifier);
|
const identifier = match.groups.identifier.toLowerCase();
|
||||||
const xmlText = await fs.readFile(path, "utf8").catch(() => null);
|
await addEntryToFeed(
|
||||||
if (xmlText === null) continue;
|
identifier,
|
||||||
const xml = new JSDOM(xmlText, { contentType: "text/xml" });
|
entry(
|
||||||
const document = xml.window.document;
|
createIdentifier(),
|
||||||
const updated = document.querySelector("feed > updated");
|
X(email.subject ?? ""),
|
||||||
if (updated === null)
|
X(email.from?.text ?? ""),
|
||||||
throw new Error(`Can’t find ‘updated’ field in feed at ‘${path}’.`);
|
X(content)
|
||||||
updated.textContent = now();
|
)
|
||||||
const firstEntry = document.querySelector("feed > entry:first-of-type");
|
).catch((error) => {
|
||||||
if (firstEntry !== null)
|
console.error(error);
|
||||||
firstEntry.insertAdjacentHTML("beforebegin", newEntry);
|
});
|
||||||
else
|
|
||||||
document
|
|
||||||
.querySelector("feed")!
|
|
||||||
.insertAdjacentHTML("beforeend", newEntry);
|
|
||||||
while (
|
|
||||||
document.querySelector("feed > entry") !== null &&
|
|
||||||
xml.serialize().length > 500_000
|
|
||||||
) {
|
|
||||||
const lastEntry = document.querySelector("feed > entry:last-of-type");
|
|
||||||
const identifier = removeUrn(
|
|
||||||
lastEntry!.querySelector("id")!.textContent as string
|
|
||||||
);
|
|
||||||
await fs.unlink(alternatePath(identifier));
|
|
||||||
lastEntry!.remove();
|
|
||||||
}
|
|
||||||
await writeFileAtomic(
|
|
||||||
path,
|
|
||||||
`<?xml version="1.0" encoding="utf-8"?>${xml.serialize()}`
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
callback();
|
callback();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(
|
console.error(
|
||||||
`Error receiving email: ${JSON.stringify(session, null, 2)}`
|
`Failed to receive message: ‘${JSON.stringify(session, null, 2)}’`
|
||||||
);
|
);
|
||||||
console.error(error);
|
console.error(error);
|
||||||
stream.resume();
|
stream.resume();
|
||||||
|
@ -121,30 +85,76 @@ export const emailServer = new SMTPServer({
|
||||||
},
|
},
|
||||||
}).listen(EMAIL_PORT);
|
}).listen(EMAIL_PORT);
|
||||||
|
|
||||||
|
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()}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function layout(content: string): string {
|
function layout(content: string): string {
|
||||||
return `<!DOCTYPE html>
|
return `<!DOCTYPE html>
|
||||||
<html lang="en">
|
<html lang="en">
|
||||||
<head>
|
<meta charset="UTF-8">
|
||||||
<meta charset="UTF-8">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<title>Kill the Newsletter!</title>
|
||||||
<title>Kill the Newsletter!</title>
|
<meta name="author" content="Leandro Facchinetti">
|
||||||
<meta name="author" content="Leandro Facchinetti">
|
<meta name="description" content="Convert email newsletters into Atom feeds.">
|
||||||
<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="32x32" href="/favicon-32x32.png">
|
<link rel="icon" type="image/png" sizes="16x16" href="/favicon-16x16.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="icon" type="image/x-icon" href="/favicon.ico">
|
<link rel="stylesheet" type="text/css" href="/styles.css">
|
||||||
<link rel="stylesheet" type="text/css" href="/styles.css">
|
<header>
|
||||||
</head>
|
<h1><a href="/">Kill the Newsletter!</a></h1>
|
||||||
<body>
|
<p>Convert email newsletters into Atom feeds</p>
|
||||||
<header>
|
<p><img alt="Convert email newsletters into Atom feeds" src="/logo.svg"></p>
|
||||||
<h1><a href="/">Kill the Newsletter!</a></h1>
|
</header>
|
||||||
<p>Convert email newsletters into Atom feeds</p>
|
<main>${content}</main>
|
||||||
<p><img alt="Convert email newsletters into Atom feeds" src="/logo.svg"></p>
|
<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>
|
||||||
</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>
|
|
||||||
</body>
|
function newInbox(): string {
|
||||||
</html>
|
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>
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -160,15 +170,13 @@ function created(identifier: string): string {
|
||||||
`.trim();
|
`.trim();
|
||||||
}
|
}
|
||||||
|
|
||||||
function feed(name: string, identifier: string): string {
|
function feed(identifier: string, name: string): string {
|
||||||
return `<?xml version="1.0" encoding="utf-8"?>
|
return `<?xml version="1.0" encoding="utf-8"?>
|
||||||
<feed xmlns="http://www.w3.org/2005/Atom">
|
<feed xmlns="http://www.w3.org/2005/Atom">
|
||||||
<link rel="self" type="application/atom+xml" href="${feedURL(
|
<link rel="self" type="application/atom+xml" href="${feedURL(
|
||||||
identifier
|
identifier
|
||||||
)}"/>
|
)}"/>
|
||||||
<link rel="alternate" type="text/html" href="${alternateURL(
|
<link rel="alternate" type="text/html" href="${BASE_URL}"/>
|
||||||
identifier
|
|
||||||
)}"/>
|
|
||||||
<id>${urn(identifier)}</id>
|
<id>${urn(identifier)}</id>
|
||||||
<title>${name}</title>
|
<title>${name}</title>
|
||||||
<subtitle>Kill the Newsletter! Inbox: ${feedEmail(
|
<subtitle>Kill the Newsletter! Inbox: ${feedEmail(
|
||||||
|
@ -176,21 +184,15 @@ function feed(name: string, identifier: string): string {
|
||||||
)} → ${feedURL(identifier)}</subtitle>
|
)} → ${feedURL(identifier)}</subtitle>
|
||||||
<updated>${now()}</updated>
|
<updated>${now()}</updated>
|
||||||
<author><name>Kill the Newsletter!</name></author>
|
<author><name>Kill the Newsletter!</name></author>
|
||||||
${entry(
|
|
||||||
`“${name}” Inbox Created`,
|
|
||||||
"Kill the Newsletter!",
|
|
||||||
X(created(identifier)),
|
|
||||||
identifier
|
|
||||||
)}
|
|
||||||
</feed>
|
</feed>
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function entry(
|
function entry(
|
||||||
|
identifier: string,
|
||||||
title: string,
|
title: string,
|
||||||
author: string,
|
author: string,
|
||||||
content: string,
|
content: string
|
||||||
identifier: string
|
|
||||||
): string {
|
): string {
|
||||||
return `
|
return `
|
||||||
<entry>
|
<entry>
|
||||||
|
@ -213,6 +215,10 @@ function createIdentifier(): string {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getEntryIdentifier(entry: ParentNode): string {
|
||||||
|
return entry.querySelector("id")!.textContent!.split(":")[2];
|
||||||
|
}
|
||||||
|
|
||||||
function now(): string {
|
function now(): string {
|
||||||
return new Date().toISOString();
|
return new Date().toISOString();
|
||||||
}
|
}
|
||||||
|
@ -241,10 +247,6 @@ function urn(identifier: string): string {
|
||||||
return `urn:kill-the-newsletter:${identifier}`;
|
return `urn:kill-the-newsletter:${identifier}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function removeUrn(identifier: string): string {
|
|
||||||
return identifier.replace(urn(""), "");
|
|
||||||
}
|
|
||||||
|
|
||||||
function X(string: string): string {
|
function X(string: string): string {
|
||||||
return entities.encodeXML(sanitizeXMLString.sanitize(string));
|
return entities.encodeXML(sanitizeXMLString.sanitize(string));
|
||||||
}
|
}
|
||||||
|
|
|
@ -223,6 +223,14 @@
|
||||||
"ansi-styles": "^3.2.1",
|
"ansi-styles": "^3.2.1",
|
||||||
"escape-string-regexp": "^1.0.5",
|
"escape-string-regexp": "^1.0.5",
|
||||||
"supports-color": "^5.3.0"
|
"supports-color": "^5.3.0"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"escape-string-regexp": {
|
||||||
|
"version": "1.0.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz",
|
||||||
|
"integrity": "sha1-G2HAViGQqN/2rjuyzwIAyhMLhtQ=",
|
||||||
|
"dev": true
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"color-convert": {
|
"color-convert": {
|
||||||
|
@ -762,6 +770,13 @@
|
||||||
"ansi-styles": "^3.2.1",
|
"ansi-styles": "^3.2.1",
|
||||||
"escape-string-regexp": "^1.0.5",
|
"escape-string-regexp": "^1.0.5",
|
||||||
"supports-color": "^5.3.0"
|
"supports-color": "^5.3.0"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"escape-string-regexp": {
|
||||||
|
"version": "1.0.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz",
|
||||||
|
"integrity": "sha1-G2HAViGQqN/2rjuyzwIAyhMLhtQ="
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"color-convert": {
|
"color-convert": {
|
||||||
|
@ -1714,6 +1729,14 @@
|
||||||
"ansi-styles": "^3.2.1",
|
"ansi-styles": "^3.2.1",
|
||||||
"escape-string-regexp": "^1.0.5",
|
"escape-string-regexp": "^1.0.5",
|
||||||
"supports-color": "^5.3.0"
|
"supports-color": "^5.3.0"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"escape-string-regexp": {
|
||||||
|
"version": "1.0.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz",
|
||||||
|
"integrity": "sha1-G2HAViGQqN/2rjuyzwIAyhMLhtQ=",
|
||||||
|
"dev": true
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"color-convert": {
|
"color-convert": {
|
||||||
|
@ -1995,6 +2018,13 @@
|
||||||
"has-ansi": "^2.0.0",
|
"has-ansi": "^2.0.0",
|
||||||
"strip-ansi": "^3.0.0",
|
"strip-ansi": "^3.0.0",
|
||||||
"supports-color": "^2.0.0"
|
"supports-color": "^2.0.0"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"escape-string-regexp": {
|
||||||
|
"version": "1.0.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz",
|
||||||
|
"integrity": "sha1-G2HAViGQqN/2rjuyzwIAyhMLhtQ="
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"strip-ansi": {
|
"strip-ansi": {
|
||||||
|
@ -2556,9 +2586,9 @@
|
||||||
"integrity": "sha1-9EvaEtRbvfnLf4Yu5+SCez3TIlQ="
|
"integrity": "sha1-9EvaEtRbvfnLf4Yu5+SCez3TIlQ="
|
||||||
},
|
},
|
||||||
"escape-string-regexp": {
|
"escape-string-regexp": {
|
||||||
"version": "1.0.5",
|
"version": "4.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz",
|
"resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz",
|
||||||
"integrity": "sha1-G2HAViGQqN/2rjuyzwIAyhMLhtQ="
|
"integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA=="
|
||||||
},
|
},
|
||||||
"escodegen": {
|
"escodegen": {
|
||||||
"version": "1.14.1",
|
"version": "1.14.1",
|
||||||
|
@ -6327,6 +6357,13 @@
|
||||||
"ansi-styles": "^3.2.1",
|
"ansi-styles": "^3.2.1",
|
||||||
"escape-string-regexp": "^1.0.5",
|
"escape-string-regexp": "^1.0.5",
|
||||||
"supports-color": "^5.3.0"
|
"supports-color": "^5.3.0"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"escape-string-regexp": {
|
||||||
|
"version": "1.0.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz",
|
||||||
|
"integrity": "sha1-G2HAViGQqN/2rjuyzwIAyhMLhtQ="
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"color-convert": {
|
"color-convert": {
|
||||||
|
@ -8182,6 +8219,14 @@
|
||||||
"ansi-styles": "^3.2.1",
|
"ansi-styles": "^3.2.1",
|
||||||
"escape-string-regexp": "^1.0.5",
|
"escape-string-regexp": "^1.0.5",
|
||||||
"supports-color": "^5.3.0"
|
"supports-color": "^5.3.0"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"escape-string-regexp": {
|
||||||
|
"version": "1.0.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz",
|
||||||
|
"integrity": "sha1-G2HAViGQqN/2rjuyzwIAyhMLhtQ=",
|
||||||
|
"dev": true
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"ci-info": {
|
"ci-info": {
|
||||||
|
|
|
@ -13,6 +13,7 @@
|
||||||
"caddy-npm": "^2.1.1",
|
"caddy-npm": "^2.1.1",
|
||||||
"crypto-random-string": "^3.2.0",
|
"crypto-random-string": "^3.2.0",
|
||||||
"entities": "^2.0.0",
|
"entities": "^2.0.0",
|
||||||
|
"escape-string-regexp": "^4.0.0",
|
||||||
"express": "^4.17.1",
|
"express": "^4.17.1",
|
||||||
"jsdom": "^16.2.2",
|
"jsdom": "^16.2.2",
|
||||||
"mailparser": "^2.7.7",
|
"mailparser": "^2.7.7",
|
||||||
|
|
|
@ -2,8 +2,7 @@
|
||||||
@import "node_modules/typeface-pt-mono/index.css";
|
@import "node_modules/typeface-pt-mono/index.css";
|
||||||
|
|
||||||
body {
|
body {
|
||||||
font-family: "PT Sans", sans-serif;
|
font: 16px/1.5 "PT Sans", sans-serif;
|
||||||
line-height: 1.5;
|
|
||||||
text-align: center;
|
text-align: center;
|
||||||
padding: 0 1em;
|
padding: 0 1em;
|
||||||
margin: 2em 0;
|
margin: 2em 0;
|
||||||
|
|
229
test.ts
229
test.ts
|
@ -1,4 +1,4 @@
|
||||||
import { webServer, emailServer, WEB_PORT, EMAIL_PORT, EMAIL_DOMAIN } from ".";
|
import { webServer, emailServer, BASE_URL, EMAIL_DOMAIN, EMAIL_PORT } from ".";
|
||||||
import nodemailer from "nodemailer";
|
import nodemailer from "nodemailer";
|
||||||
import axios from "axios";
|
import axios from "axios";
|
||||||
import qs from "qs";
|
import qs from "qs";
|
||||||
|
@ -6,7 +6,18 @@ import { JSDOM } from "jsdom";
|
||||||
|
|
||||||
test("create feed", async () => {
|
test("create feed", async () => {
|
||||||
const identifier = await createFeed();
|
const identifier = await createFeed();
|
||||||
expect(await getFeed(identifier)).toMatch("My Feed");
|
const feed = await getFeed(identifier);
|
||||||
|
const entry = feed.querySelector("feed > entry:first-of-type")!;
|
||||||
|
const alternate = await getAlternate(
|
||||||
|
entry.querySelector("link")!.getAttribute("href")!
|
||||||
|
);
|
||||||
|
expect(feed.querySelector("feed > title")!.textContent).toBe("My Feed");
|
||||||
|
expect(entry.querySelector("title")!.textContent).toBe(
|
||||||
|
"“My Feed” Inbox Created"
|
||||||
|
);
|
||||||
|
expect(alternate.querySelector("p")!.textContent).toMatch(
|
||||||
|
"Sign up for the newsletter with"
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("receive email", () => {
|
describe("receive email", () => {
|
||||||
|
@ -20,8 +31,8 @@ describe("receive email", () => {
|
||||||
html: "<p>HTML content</p>",
|
html: "<p>HTML content</p>",
|
||||||
});
|
});
|
||||||
const after = await getFeed(identifier);
|
const after = await getFeed(identifier);
|
||||||
expect(after.match(/<updated>(.*)<\/updated>/)![1]).not.toMatch(
|
expect(after.querySelector("feed > updated")!.textContent).not.toBe(
|
||||||
before.match(/<updated>(.*)<\/updated>/)![1]
|
before.querySelector("feed > updated")!.textContent
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -34,9 +45,16 @@ describe("receive email", () => {
|
||||||
html: "<p>HTML content</p>",
|
html: "<p>HTML content</p>",
|
||||||
});
|
});
|
||||||
const feed = await getFeed(identifier);
|
const feed = await getFeed(identifier);
|
||||||
expect(feed).toMatch("publisher@example.com");
|
const entry = feed.querySelector("feed > entry:first-of-type")!;
|
||||||
expect(feed).toMatch("New Message");
|
const alternate = await getAlternate(
|
||||||
expect(feed).toMatch("HTML content");
|
entry.querySelector("link")!.getAttribute("href")!
|
||||||
|
);
|
||||||
|
expect(entry.querySelector("author > name")!.textContent).toBe(
|
||||||
|
"publisher@example.com"
|
||||||
|
);
|
||||||
|
expect(entry.querySelector("title")!.textContent).toBe("New Message");
|
||||||
|
expect(entry.querySelector("content")!.textContent).toMatch("HTML content");
|
||||||
|
expect(alternate.querySelector("p")!.textContent).toMatch("HTML content");
|
||||||
});
|
});
|
||||||
|
|
||||||
test("text content", async () => {
|
test("text content", async () => {
|
||||||
|
@ -48,7 +66,12 @@ describe("receive email", () => {
|
||||||
text: "TEXT content",
|
text: "TEXT content",
|
||||||
});
|
});
|
||||||
const feed = await getFeed(identifier);
|
const feed = await getFeed(identifier);
|
||||||
expect(feed).toMatch("TEXT content");
|
const entry = feed.querySelector("feed > entry:first-of-type")!;
|
||||||
|
const alternate = await getAlternate(
|
||||||
|
entry.querySelector("link")!.getAttribute("href")!
|
||||||
|
);
|
||||||
|
expect(entry.querySelector("content")!.textContent).toMatch("TEXT content");
|
||||||
|
expect(alternate.querySelector("p")!.textContent).toMatch("TEXT content");
|
||||||
});
|
});
|
||||||
|
|
||||||
test("rich text content", async () => {
|
test("rich text content", async () => {
|
||||||
|
@ -60,8 +83,13 @@ describe("receive email", () => {
|
||||||
text: "TEXT content\n\nhttps://www.leafac.com\n\nMore text",
|
text: "TEXT content\n\nhttps://www.leafac.com\n\nMore text",
|
||||||
});
|
});
|
||||||
const feed = await getFeed(identifier);
|
const feed = await getFeed(identifier);
|
||||||
expect(feed).toMatch("TEXT content");
|
const entry = feed.querySelector("feed > entry:first-of-type")!;
|
||||||
expect(feed).toMatch(`href="https://www.leafac.com"`);
|
const alternate = await getAlternate(
|
||||||
|
entry.querySelector("link")!.getAttribute("href")!
|
||||||
|
);
|
||||||
|
expect(alternate.querySelector("a")!.getAttribute("href")).toBe(
|
||||||
|
"https://www.leafac.com"
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("invalid XML character in HTML", async () => {
|
test("invalid XML character in HTML", async () => {
|
||||||
|
@ -73,7 +101,11 @@ describe("receive email", () => {
|
||||||
html: "<p>Invalid XML character (backspace): |\b|💩</p>",
|
html: "<p>Invalid XML character (backspace): |\b|💩</p>",
|
||||||
});
|
});
|
||||||
const feed = await getFeed(identifier);
|
const feed = await getFeed(identifier);
|
||||||
expect(feed).toMatch("Invalid XML character (backspace): ||💩");
|
const entry = feed.querySelector("feed > entry:first-of-type")!;
|
||||||
|
expect(entry.querySelector("content")!.textContent).toMatchInlineSnapshot(`
|
||||||
|
"<p>Invalid XML character (backspace): ||💩</p>
|
||||||
|
"
|
||||||
|
`);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("invalid XML character in text", async () => {
|
test("invalid XML character in text", async () => {
|
||||||
|
@ -85,68 +117,26 @@ describe("receive email", () => {
|
||||||
text: "Invalid XML character (backspace): |\b|💩",
|
text: "Invalid XML character (backspace): |\b|💩",
|
||||||
});
|
});
|
||||||
const feed = await getFeed(identifier);
|
const feed = await getFeed(identifier);
|
||||||
expect(feed).toMatch(
|
const entry = feed.querySelector("feed > entry:first-of-type")!;
|
||||||
"Invalid XML character (backspace): |&#x8;|&#x1F4A9;"
|
expect(entry.querySelector("content")!.textContent).toMatchInlineSnapshot(
|
||||||
|
`"<p>Invalid XML character (backspace): ||💩</p>"`
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("missing content", async () => {
|
test("missing ‘from’", async () => {
|
||||||
const identifier = await createFeed();
|
const identifier = await createFeed();
|
||||||
await emailClient.sendMail({
|
await emailClient.sendMail({
|
||||||
from: "publisher@example.com",
|
|
||||||
to: `${identifier}@${EMAIL_DOMAIN}`,
|
to: `${identifier}@${EMAIL_DOMAIN}`,
|
||||||
subject: "New Message",
|
subject: "New Message",
|
||||||
});
|
|
||||||
const feed = await getFeed(identifier);
|
|
||||||
expect(feed).toMatch("New Message");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("missing subject", async () => {
|
|
||||||
const identifier = await createFeed();
|
|
||||||
await emailClient.sendMail({
|
|
||||||
from: "publisher@example.com",
|
|
||||||
to: `${identifier}@${EMAIL_DOMAIN}`,
|
|
||||||
html: "<p>HTML content</p>",
|
html: "<p>HTML content</p>",
|
||||||
});
|
});
|
||||||
const feed = await getFeed(identifier);
|
const feed = await getFeed(identifier);
|
||||||
expect(feed).toMatch("HTML content");
|
const entry = feed.querySelector("feed > entry:first-of-type")!;
|
||||||
|
expect(entry.querySelector("author > name")!.textContent).toBe("");
|
||||||
|
expect(entry.querySelector("title")!.textContent).toBe("New Message");
|
||||||
});
|
});
|
||||||
|
|
||||||
test("truncation", async () => {
|
test("nonexistent ‘to’", async () => {
|
||||||
const identifier = await createFeed();
|
|
||||||
for (const repetition of [...new Array(4).keys()])
|
|
||||||
await emailClient.sendMail({
|
|
||||||
from: "publisher@example.com",
|
|
||||||
to: `${identifier}@${EMAIL_DOMAIN}`,
|
|
||||||
subject: "New Message",
|
|
||||||
text: `REPETITION ${repetition} `.repeat(10_000),
|
|
||||||
});
|
|
||||||
const feed = await getFeed(identifier);
|
|
||||||
expect(feed).toMatch("REPETITION 3");
|
|
||||||
expect(feed).not.toMatch("REPETITION 0");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("too big entry", async () => {
|
|
||||||
const identifier = await createFeed();
|
|
||||||
await emailClient.sendMail({
|
|
||||||
from: "publisher@example.com",
|
|
||||||
to: `${identifier}@${EMAIL_DOMAIN}`,
|
|
||||||
subject: "New Message",
|
|
||||||
text: `TOO BIG`.repeat(100_000),
|
|
||||||
});
|
|
||||||
expect(await getFeed(identifier)).not.toMatch("<entry>");
|
|
||||||
await emailClient.sendMail({
|
|
||||||
from: "publisher@example.com",
|
|
||||||
to: `${identifier}@${EMAIL_DOMAIN}`,
|
|
||||||
subject: "New Message",
|
|
||||||
text: `NORMAL SIZE`,
|
|
||||||
});
|
|
||||||
const feed = await getFeed(identifier);
|
|
||||||
expect(feed).toMatch("<entry>");
|
|
||||||
expect(feed).toMatch("NORMAL SIZE");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("nonexistent address", async () => {
|
|
||||||
await emailClient.sendMail({
|
await emailClient.sendMail({
|
||||||
from: "publisher@example.com",
|
from: "publisher@example.com",
|
||||||
to: `nonexistent@${EMAIL_DOMAIN}`,
|
to: `nonexistent@${EMAIL_DOMAIN}`,
|
||||||
|
@ -155,67 +145,114 @@ describe("receive email", () => {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
test("missing from", async () => {
|
test("missing ‘subject’", async () => {
|
||||||
const identifier = await createFeed();
|
const identifier = await createFeed();
|
||||||
await emailClient.sendMail({
|
await emailClient.sendMail({
|
||||||
|
from: "publisher@example.com",
|
||||||
to: `${identifier}@${EMAIL_DOMAIN}`,
|
to: `${identifier}@${EMAIL_DOMAIN}`,
|
||||||
subject: "New Message",
|
|
||||||
html: "<p>HTML content</p>",
|
html: "<p>HTML content</p>",
|
||||||
});
|
});
|
||||||
const feed = await getFeed(identifier);
|
const feed = await getFeed(identifier);
|
||||||
expect(feed).toMatch("HTML content");
|
const entry = feed.querySelector("feed > entry:first-of-type")!;
|
||||||
|
expect(entry.querySelector("title")!.textContent).toBe("");
|
||||||
|
expect(entry.querySelector("author > name")!.textContent).toBe(
|
||||||
|
"publisher@example.com"
|
||||||
|
);
|
||||||
});
|
});
|
||||||
});
|
|
||||||
|
|
||||||
describe("alternate", () => {
|
test("missing ‘content’", async () => {
|
||||||
test("HTML content", async () => {
|
|
||||||
const identifier = await createFeed();
|
const identifier = await createFeed();
|
||||||
await emailClient.sendMail({
|
await emailClient.sendMail({
|
||||||
from: "publisher@example.com",
|
from: "publisher@example.com",
|
||||||
to: `${identifier}@${EMAIL_DOMAIN}`,
|
to: `${identifier}@${EMAIL_DOMAIN}`,
|
||||||
subject: "New Message",
|
subject: "New Message",
|
||||||
html: "<p>HTML content</p>",
|
|
||||||
});
|
});
|
||||||
const feed = await getFeed(identifier);
|
const feed = await getFeed(identifier);
|
||||||
const xml = new JSDOM(feed, { contentType: "text/xml" });
|
const entry = feed.querySelector("feed > entry:first-of-type")!;
|
||||||
const document = xml.window.document;
|
expect(entry.querySelector("content")!.textContent!.trim()).toBe("");
|
||||||
const href = document
|
expect(entry.querySelector("title")!.textContent).toBe("New Message");
|
||||||
.querySelector("feed > entry link")!
|
});
|
||||||
.getAttribute("href") as string;
|
|
||||||
const alternate = await getAlternate(href);
|
test("truncation", async () => {
|
||||||
expect(feed).toMatch("publisher@example.com");
|
const identifier = await createFeed();
|
||||||
expect(feed).toMatch("New Message");
|
const alternatesURLs = new Array<string>();
|
||||||
expect(feed).toMatch("HTML content");
|
for (const repetition of [...new Array(4).keys()]) {
|
||||||
|
await emailClient.sendMail({
|
||||||
|
from: "publisher@example.com",
|
||||||
|
to: `${identifier}@${EMAIL_DOMAIN}`,
|
||||||
|
subject: "New Message",
|
||||||
|
text: `REPETITION ${repetition} `.repeat(10_000),
|
||||||
|
});
|
||||||
|
const feed = await getFeed(identifier);
|
||||||
|
const entry = feed.querySelector("feed > entry:first-of-type")!;
|
||||||
|
alternatesURLs.push(entry.querySelector("link")!.getAttribute("href")!);
|
||||||
|
}
|
||||||
|
const feed = await getFeed(identifier);
|
||||||
|
expect(
|
||||||
|
feed.querySelector("entry:first-of-type > content")!.textContent
|
||||||
|
).toMatch("REPETITION 3");
|
||||||
|
expect(
|
||||||
|
feed.querySelector("entry:last-of-type > content")!.textContent
|
||||||
|
).toMatch("REPETITION 1");
|
||||||
|
expect((await getAlternate(alternatesURLs[3]!)).textContent).toMatch(
|
||||||
|
"REPETITION 3"
|
||||||
|
);
|
||||||
|
await expect(getAlternate(alternatesURLs[0]!)).rejects.toThrowError();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("too big entry", async () => {
|
||||||
|
const identifier = await createFeed();
|
||||||
|
await emailClient.sendMail({
|
||||||
|
from: "publisher@example.com",
|
||||||
|
to: `${identifier}@${EMAIL_DOMAIN}`,
|
||||||
|
subject: "New Message",
|
||||||
|
text: "TOO BIG".repeat(100_000),
|
||||||
|
});
|
||||||
|
expect((await getFeed(identifier)).querySelector("entry")).toBeNull();
|
||||||
|
await emailClient.sendMail({
|
||||||
|
from: "publisher@example.com",
|
||||||
|
to: `${identifier}@${EMAIL_DOMAIN}`,
|
||||||
|
subject: "New Message",
|
||||||
|
text: `NORMAL SIZE`,
|
||||||
|
});
|
||||||
|
expect(
|
||||||
|
(await getFeed(identifier)).querySelector("entry > content")!.textContent
|
||||||
|
).toMatchInlineSnapshot(`"<p>NORMAL SIZE</p>"`);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const webClient = axios.create({
|
||||||
|
baseURL: BASE_URL,
|
||||||
|
});
|
||||||
|
const emailClient = nodemailer.createTransport(
|
||||||
|
`smtp://${EMAIL_DOMAIN}:${EMAIL_PORT}`
|
||||||
|
);
|
||||||
afterAll(() => {
|
afterAll(() => {
|
||||||
webServer.close();
|
webServer.close();
|
||||||
emailServer.close();
|
emailServer.close();
|
||||||
});
|
});
|
||||||
|
|
||||||
const webClient = axios.create({
|
|
||||||
baseURL: `http://localhost:${WEB_PORT}`,
|
|
||||||
});
|
|
||||||
const emailClient = nodemailer.createTransport(
|
|
||||||
`smtp://localhost:${EMAIL_PORT}`
|
|
||||||
);
|
|
||||||
|
|
||||||
async function createFeed(): Promise<string> {
|
async function createFeed(): Promise<string> {
|
||||||
return (
|
return JSDOM.fragment(
|
||||||
await webClient.post(
|
(
|
||||||
"/",
|
await webClient.post(
|
||||||
qs.stringify({
|
"/",
|
||||||
name: "My Feed",
|
qs.stringify({
|
||||||
})
|
name: "My Feed",
|
||||||
)
|
})
|
||||||
).data.match(/(\w{20}).xml/)![1];
|
)
|
||||||
|
).data
|
||||||
|
)
|
||||||
|
.querySelector("code")!
|
||||||
|
.textContent!.split("@")[0];
|
||||||
}
|
}
|
||||||
|
|
||||||
async function getFeed(identifier: string): Promise<string> {
|
async function getFeed(identifier: string): Promise<Document> {
|
||||||
return (await webClient.get(`/feeds/${identifier}.xml`)).data;
|
return new JSDOM((await webClient.get(`/feeds/${identifier}.xml`)).data, {
|
||||||
|
contentType: "text/xml",
|
||||||
|
}).window.document;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function getAlternate(url: string): Promise<string> {
|
async function getAlternate(url: string): Promise<DocumentFragment> {
|
||||||
return (await webClient.get(url)).data;
|
return JSDOM.fragment((await webClient.get(url)).data);
|
||||||
}
|
}
|
||||||
|
|
Loading…
Reference in New Issue