Browse Source

Initial commit

Thomas Dy 2 years ago
commit
25572bfdfc
8 changed files with 449 additions and 0 deletions
  1. 2 0
      .gitignore
  2. 9 0
      Makefile
  3. 3 0
      config.json.stub
  4. 5 0
      email.ts
  5. 191 0
      index.ts
  6. 33 0
      postal-mime.d.ts
  7. 154 0
      smtp.ts
  8. 52 0
      store.ts

+ 2 - 0
.gitignore

@@ -0,0 +1,2 @@
+config.json
+*.db

+ 9 - 0
Makefile

@@ -0,0 +1,9 @@
+PHONY: dev
+
+dev:
+	deno run \
+		--watch \
+		--allow-net \
+		--allow-read=config.json,store.db,store.db-journal \
+		--allow-write=store.db,store.db-journal \
+		index.ts

+ 3 - 0
config.json.stub

@@ -0,0 +1,3 @@
+{
+  "store": "store.db"
+}

+ 5 - 0
email.ts

@@ -0,0 +1,5 @@
+// @deno-types="./postal-mime.d.ts"
+export {
+  default as PostalMime,
+  type Email,
+} from "https://cdn.skypack.dev/postal-mime@1.0.12";

+ 191 - 0
index.ts

@@ -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, "&lt;")
+    .replace(/>/g, "&gt;")
+    .replace(/"/g, "&quot;")
+    .replace(/'/g, "&#039;");
+}
+
+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)} &lt;${
+          escapeHTML(email.from.address)
+        }&gt;</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();

+ 33 - 0
postal-mime.d.ts

@@ -0,0 +1,33 @@
+export default class PostalMime {
+  parse(data: string): Promise<Email>;
+}
+
+export interface Header {
+  key: string;
+  value: string;
+}
+
+export interface Address {
+  address: string;
+  name: string;
+}
+
+export interface Attachment {
+  filename: string;
+  mimeType: string;
+  disposition: "attachment" | "inline" | null;
+  related: boolean;
+  contentId: string;
+  content: ArrayBuffer;
+}
+
+export interface Email {
+  headers: Header[];
+  from?: Address;
+  to?: Address[];
+  subject?: string;
+  date?: string;
+  text?: string;
+  html?: string;
+  attachments: Attachment[];
+}

+ 154 - 0
smtp.ts

@@ -0,0 +1,154 @@
+import { iterateReader } from "https://deno.land/std@0.133.0/streams/conversion.ts";
+
+async function* newlineIterator(iterator: AsyncIterableIterator<Uint8Array>) {
+  const decoder = new TextDecoder();
+  let buffer = "";
+  for await (const chunk of iterator) {
+    let chunkString = decoder.decode(chunk);
+    const newlinePos = chunkString.indexOf("\r\n");
+    if (newlinePos >= 0) {
+      const out = buffer + chunkString.substring(0, newlinePos);
+      buffer = chunkString.substring(newlinePos + 2);
+      yield out;
+    } else {
+      buffer += chunkString;
+    }
+
+    let pos: number;
+    while ((pos = buffer.indexOf("\r\n")) >= 0) {
+      const out = buffer.substring(0, pos);
+      buffer = buffer.substring(pos + 2);
+      yield out;
+    }
+  }
+}
+
+enum State {
+  WaitingHello,
+  WaitingMail,
+  WaitingRecipient,
+  WaitingData,
+  ReceivingData,
+}
+
+export type OnReceive = (
+  recipient: string,
+  sender: string,
+  data: string,
+) => void;
+
+export class Session {
+  private state: State;
+  private encoder: TextEncoder;
+  private data: string;
+  private recipient: string;
+  private sender: string;
+
+  constructor(readonly conn: Deno.Conn, readonly onReceive: OnReceive) {
+    this.state = State.WaitingHello;
+    this.encoder = new TextEncoder();
+    this.data = "";
+    this.recipient = "";
+    this.sender = "";
+  }
+
+  async start() {
+    this.output("220 smtp2rss");
+    for await (const chunk of newlineIterator(iterateReader(this.conn))) {
+      this.input(chunk);
+    }
+  }
+
+  input(data: string) {
+    switch (this.state) {
+      case State.WaitingHello:
+        return this.waitingHello(data);
+      case State.WaitingMail:
+        return this.waitingMail(data);
+      case State.WaitingRecipient:
+        return this.waitingRecipient(data);
+      case State.WaitingData:
+        return this.waitingData(data);
+      case State.ReceivingData:
+        return this.receivingData(data);
+    }
+  }
+
+  waitingHello(data: string) {
+    const command = data.substring(0, 4);
+    if (command === "HELO" || command === "EHLO") {
+      this.state = State.WaitingMail;
+      this.output("250 OK");
+    } else if (command === "QUIT") {
+      this.output("221 smtp2rss closing");
+      this.conn.close();
+    } else {
+      this.output("500 Invalid state");
+    }
+  }
+
+  waitingMail(data: string) {
+    const command = data.substring(0, 4);
+    if (command === "MAIL") {
+      const match = data.match(/^MAIL FROM:<([^>]*)>.*$/);
+      if (match !== null) {
+        this.sender = match[1];
+        this.state = State.WaitingRecipient;
+        this.output("250 OK");
+      } else {
+        this.output("500 Invalid sender");
+      }
+    } else {
+      this.output("500 Invalid state");
+    }
+  }
+
+  waitingRecipient(data: string) {
+    const command = data.substring(0, 4);
+    if (command === "RCPT") {
+      const match = data.match(/^RCPT TO:<([^>]*)>.*$/);
+      if (match !== null) {
+        this.recipient = match[1];
+        this.state = State.WaitingData;
+        this.output("250 OK");
+      } else {
+        this.output("500 Invalid sender");
+      }
+    } else {
+      this.output("500 Invalid state");
+    }
+  }
+
+  waitingData(data: string) {
+    const command = data.substring(0, 4);
+    if (command === "DATA") {
+      this.state = State.ReceivingData;
+      this.output("354 Waiting for data");
+    } else {
+      this.output("500 Invalid state");
+    }
+  }
+
+  receivingData(data: string) {
+    if (data === ".") {
+      this.state = State.WaitingMail;
+      this.output("250 OK");
+
+      this.onReceive(this.recipient, this.sender, this.data);
+      this.recipient = "";
+      this.sender = "";
+      this.data = "";
+    } else {
+      this.data += data;
+    }
+  }
+
+  output(response: string) {
+    this.conn.write(this.encoder.encode(`${response}\r\n`));
+  }
+}
+
+export async function handleSmtp(conn: Deno.Conn, onReceive: OnReceive) {
+  const session = new Session(conn, onReceive);
+  await session.start();
+}

+ 52 - 0
store.ts

@@ -0,0 +1,52 @@
+import { DB } from "https://deno.land/x/sqlite@v3.1.1/mod.ts";
+
+function dateToEpoch(dateStr: string | Date) {
+  const date = typeof dateStr === "string" ? new Date(dateStr) : dateStr;
+  return Math.floor(date.getTime() / 1000);
+}
+
+export class SqliteStore {
+  private db: DB;
+
+  constructor(path: string) {
+    this.db = new DB(path);
+    this.db.query(`
+      CREATE TABLE IF NOT EXISTS emails (
+        id INTEGER PRIMARY KEY,
+        recipient TEXT NOT NULL,
+        sender TEXT NOT NULL,
+        data TEXT NOT NULL,
+        received_at INTEGER NOT NULL
+      );
+    `);
+  }
+
+  saveEmail(recipient: string, sender: string, data: string) {
+    this.db.query("INSERT INTO emails VALUES (?, ?, ?, ?, ?)", [
+      null,
+      recipient,
+      sender,
+      data,
+      dateToEpoch(new Date()),
+    ]);
+  }
+
+  getLatestEmails(
+    recipient: string,
+    newerThan: Date,
+  ): Array<{ id: string; receivedAt: Date; data: string }> {
+    const result = this.db.query<[string, string, number]>(
+      `
+      SELECT id, data, received_at FROM emails
+      WHERE recipient = ?
+        AND received_at > ?
+      ORDER BY id DESC
+    `,
+      [recipient, dateToEpoch(newerThan)],
+    );
+
+    return result.map(([id, data, receivedAt]) => {
+      return { id, data, receivedAt: new Date(receivedAt * 1000) };
+    });
+  }
+}