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, "'"); } 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> { 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 { 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 { 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 = "
"; result += `

${escapeHTML(email.subject ?? "")}

`; if (email.from) { result += `${escapeHTML(email.from.name)} <${ escapeHTML(email.from.address) }>`; } result += "
"; result += (email.date ? new Date(email.date) : receivedAt) .toLocaleString(undefined, dateOptions); if (email.html) { result += ``; } else { result += `
${email.text}
`; } result += "
"; return result; }).join("\n"); const html = ` smtp2rss ${body} `; 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 { 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 ?? "", 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();