|
@@ -0,0 +1,191 @@
|
|
|
+import { Email, PostalMime } from "./email.ts";
|
|
|
+import { handleSmtp } from "./smtp.ts";
|
|
|
+import { SqliteStore } from "./store.ts";
|
|
|
+
|
|
|
+function escapeHTML(unsafe: string): string {
|
|
|
+ return unsafe
|
|
|
+ .replace(/&/g, "&")
|
|
|
+ .replace(/</g, "<")
|
|
|
+ .replace(/>/g, ">")
|
|
|
+ .replace(/"/g, """)
|
|
|
+ .replace(/'/g, "'");
|
|
|
+}
|
|
|
+
|
|
|
+const CONTENT_SECURITY_POLICY = [
|
|
|
+ "default-src 'self'",
|
|
|
+ "img-src *",
|
|
|
+ "style-src 'unsafe-inline' *",
|
|
|
+ "frame-src data:",
|
|
|
+].join("; ");
|
|
|
+
|
|
|
+class Server {
|
|
|
+ constructor(readonly store: SqliteStore) {}
|
|
|
+
|
|
|
+ async getLatestEmails(
|
|
|
+ recipient: string,
|
|
|
+ interval: number = 600,
|
|
|
+ ): Promise<Array<{ id: string; receivedAt: Date; email: Email }>> {
|
|
|
+ const newerThan = new Date();
|
|
|
+ newerThan.setSeconds(newerThan.getSeconds() - interval);
|
|
|
+
|
|
|
+ const emails = this.store.getLatestEmails(recipient, newerThan);
|
|
|
+
|
|
|
+ console.time("parseEmail");
|
|
|
+ const result = Promise.all(emails.map(async ({ data, ...rest }) => {
|
|
|
+ return {
|
|
|
+ email: await new PostalMime().parse(data),
|
|
|
+ ...rest,
|
|
|
+ };
|
|
|
+ }));
|
|
|
+ console.timeEnd("parseEmail");
|
|
|
+
|
|
|
+ return result;
|
|
|
+ }
|
|
|
+
|
|
|
+ async start() {
|
|
|
+ this.startSmtp();
|
|
|
+ this.startHttp();
|
|
|
+ }
|
|
|
+
|
|
|
+ async startSmtp() {
|
|
|
+ const listener = Deno.listen({ port: 2525 });
|
|
|
+ for await (const conn of listener) {
|
|
|
+ handleSmtp(conn, (recipient, sender, data) => {
|
|
|
+ this.store.saveEmail(recipient, sender, data);
|
|
|
+ });
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ async startHttp() {
|
|
|
+ const listener = Deno.listen({ port: 8000 });
|
|
|
+ for await (const conn of listener) {
|
|
|
+ this.serveHttp(conn);
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ async serveHttp(conn: Deno.Conn) {
|
|
|
+ const httpConn = Deno.serveHttp(conn);
|
|
|
+ for await (const requestEvent of httpConn) {
|
|
|
+ this.handle(requestEvent)
|
|
|
+ .then((response) => requestEvent.respondWith(response))
|
|
|
+ .catch((err) => console.error(err));
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ async handle(requestEvent: Deno.RequestEvent): Promise<Response> {
|
|
|
+ const url = new URL(requestEvent.request.url);
|
|
|
+ const path = url.pathname;
|
|
|
+ if (path.startsWith("/html/")) {
|
|
|
+ const recipient = path.substring(6);
|
|
|
+ return this.handleHtml(recipient);
|
|
|
+ } else if (path.startsWith("/json/")) {
|
|
|
+ const recipient = path.substring(6);
|
|
|
+ return this.handleJson(recipient);
|
|
|
+ } else {
|
|
|
+ return new Response("Not found", {
|
|
|
+ status: 404,
|
|
|
+ });
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ async handleHtml(recipient: string): Promise<Response> {
|
|
|
+ const emails = await this.getLatestEmails(recipient, 24 * 60 * 60);
|
|
|
+ console.time("renderHTML");
|
|
|
+ const dateOptions = {
|
|
|
+ weekday: "short",
|
|
|
+ year: "numeric",
|
|
|
+ month: "2-digit",
|
|
|
+ day: "2-digit",
|
|
|
+ hour: "2-digit",
|
|
|
+ minute: "2-digit",
|
|
|
+ timeZoneName: "short",
|
|
|
+ } as const;
|
|
|
+ const body = emails.map(({ email, receivedAt }) => {
|
|
|
+ let result = "<div>";
|
|
|
+ result += `<h2>${escapeHTML(email.subject ?? "<No Subject>")}</h2>`;
|
|
|
+ if (email.from) {
|
|
|
+ result += `<strong>${escapeHTML(email.from.name)} <${
|
|
|
+ escapeHTML(email.from.address)
|
|
|
+ }></strong>`;
|
|
|
+ }
|
|
|
+ result += "<br>";
|
|
|
+ result += (email.date ? new Date(email.date) : receivedAt)
|
|
|
+ .toLocaleString(undefined, dateOptions);
|
|
|
+ if (email.html) {
|
|
|
+ result += `<iframe src="data:text/html;base64,${
|
|
|
+ btoa(email.html)
|
|
|
+ }"></iframe>`;
|
|
|
+ } else {
|
|
|
+ result += `<pre>${email.text}</pre>`;
|
|
|
+ }
|
|
|
+ result += "</div>";
|
|
|
+ return result;
|
|
|
+ }).join("\n");
|
|
|
+
|
|
|
+ const html = `
|
|
|
+ <html>
|
|
|
+ <head>
|
|
|
+ <title>smtp2rss</title>
|
|
|
+ <style>
|
|
|
+ body > div {
|
|
|
+ margin: 10px;
|
|
|
+ padding: 10px;
|
|
|
+ border: solid 1px gray;
|
|
|
+ border-radius: 10px;
|
|
|
+ max-width: 800px;
|
|
|
+ }
|
|
|
+ iframe {
|
|
|
+ width: 100%;
|
|
|
+ height: 500px;
|
|
|
+ }
|
|
|
+ </style>
|
|
|
+ </head>
|
|
|
+ <body>${body}</body>
|
|
|
+ </html>
|
|
|
+ `;
|
|
|
+ console.timeEnd("renderHTML");
|
|
|
+ return new Response(html, {
|
|
|
+ status: 200,
|
|
|
+ headers: {
|
|
|
+ "Content-Type": "text/html; charset=utf-8",
|
|
|
+ "Content-Security-Policy": CONTENT_SECURITY_POLICY,
|
|
|
+ },
|
|
|
+ });
|
|
|
+ }
|
|
|
+
|
|
|
+ async handleJson(recipient: string): Promise<Response> {
|
|
|
+ const emails = await this.getLatestEmails(recipient, 24 * 60 * 60);
|
|
|
+ console.time("renderJSON");
|
|
|
+ const items = emails.map(({ id, email, receivedAt }) => {
|
|
|
+ const author = email.from
|
|
|
+ ? { name: `${email.from.name} <${email.from.address}>` }
|
|
|
+ : undefined;
|
|
|
+ return {
|
|
|
+ id,
|
|
|
+ title: email.subject,
|
|
|
+ content_text: email.text ?? "<No Content>",
|
|
|
+ content_html: email.html,
|
|
|
+ date_published: email.date ?? receivedAt.toString(),
|
|
|
+ author,
|
|
|
+ };
|
|
|
+ });
|
|
|
+ const json = JSON.stringify({
|
|
|
+ version: "1.1",
|
|
|
+ title: `Mail to ${recipient}`,
|
|
|
+ items,
|
|
|
+ });
|
|
|
+ console.timeEnd("renderJSON");
|
|
|
+ return new Response(json, {
|
|
|
+ status: 200,
|
|
|
+ headers: {
|
|
|
+ "Content-Type": "application/feed+json; charset=utf-8",
|
|
|
+ },
|
|
|
+ });
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+const configFile = Deno.args.length === 0 ? "config.json" : Deno.args[0];
|
|
|
+const config = JSON.parse(Deno.readTextFileSync(configFile));
|
|
|
+
|
|
|
+const server = new Server(new SqliteStore(config.store));
|
|
|
+server.start();
|