index.ts 5.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191
  1. import { Email, PostalMime } from "./email.ts";
  2. import { handleSmtp } from "./smtp.ts";
  3. import { SqliteStore } from "./store.ts";
  4. function escapeHTML(unsafe: string): string {
  5. return unsafe
  6. .replace(/&/g, "&")
  7. .replace(/</g, "&lt;")
  8. .replace(/>/g, "&gt;")
  9. .replace(/"/g, "&quot;")
  10. .replace(/'/g, "&#039;");
  11. }
  12. const CONTENT_SECURITY_POLICY = [
  13. "default-src 'self'",
  14. "img-src *",
  15. "style-src 'unsafe-inline' *",
  16. "frame-src data:",
  17. ].join("; ");
  18. class Server {
  19. constructor(readonly store: SqliteStore) {}
  20. async getLatestEmails(
  21. recipient: string,
  22. interval: number = 600,
  23. ): Promise<Array<{ id: string; receivedAt: Date; email: Email }>> {
  24. const newerThan = new Date();
  25. newerThan.setSeconds(newerThan.getSeconds() - interval);
  26. const emails = this.store.getLatestEmails(recipient, newerThan);
  27. console.time("parseEmail");
  28. const result = Promise.all(emails.map(async ({ data, ...rest }) => {
  29. return {
  30. email: await new PostalMime().parse(data),
  31. ...rest,
  32. };
  33. }));
  34. console.timeEnd("parseEmail");
  35. return result;
  36. }
  37. async start() {
  38. this.startSmtp();
  39. this.startHttp();
  40. }
  41. async startSmtp() {
  42. const listener = Deno.listen({ port: 2525 });
  43. for await (const conn of listener) {
  44. handleSmtp(conn, (recipient, sender, data) => {
  45. this.store.saveEmail(recipient, sender, data);
  46. });
  47. }
  48. }
  49. async startHttp() {
  50. const listener = Deno.listen({ port: 8000 });
  51. for await (const conn of listener) {
  52. this.serveHttp(conn);
  53. }
  54. }
  55. async serveHttp(conn: Deno.Conn) {
  56. const httpConn = Deno.serveHttp(conn);
  57. for await (const requestEvent of httpConn) {
  58. this.handle(requestEvent)
  59. .then((response) => requestEvent.respondWith(response))
  60. .catch((err) => console.error(err));
  61. }
  62. }
  63. async handle(requestEvent: Deno.RequestEvent): Promise<Response> {
  64. const url = new URL(requestEvent.request.url);
  65. const path = url.pathname;
  66. if (path.startsWith("/html/")) {
  67. const recipient = path.substring(6);
  68. return this.handleHtml(recipient);
  69. } else if (path.startsWith("/json/")) {
  70. const recipient = path.substring(6);
  71. return this.handleJson(recipient);
  72. } else {
  73. return new Response("Not found", {
  74. status: 404,
  75. });
  76. }
  77. }
  78. async handleHtml(recipient: string): Promise<Response> {
  79. const emails = await this.getLatestEmails(recipient, 24 * 60 * 60);
  80. console.time("renderHTML");
  81. const dateOptions = {
  82. weekday: "short",
  83. year: "numeric",
  84. month: "2-digit",
  85. day: "2-digit",
  86. hour: "2-digit",
  87. minute: "2-digit",
  88. timeZoneName: "short",
  89. } as const;
  90. const body = emails.map(({ email, receivedAt }) => {
  91. let result = "<div>";
  92. result += `<h2>${escapeHTML(email.subject ?? "<No Subject>")}</h2>`;
  93. if (email.from) {
  94. result += `<strong>${escapeHTML(email.from.name)} &lt;${
  95. escapeHTML(email.from.address)
  96. }&gt;</strong>`;
  97. }
  98. result += "<br>";
  99. result += (email.date ? new Date(email.date) : receivedAt)
  100. .toLocaleString(undefined, dateOptions);
  101. if (email.html) {
  102. result += `<iframe src="data:text/html;base64,${
  103. btoa(email.html)
  104. }"></iframe>`;
  105. } else {
  106. result += `<pre>${email.text}</pre>`;
  107. }
  108. result += "</div>";
  109. return result;
  110. }).join("\n");
  111. const html = `
  112. <html>
  113. <head>
  114. <title>smtp2rss</title>
  115. <style>
  116. body > div {
  117. margin: 10px;
  118. padding: 10px;
  119. border: solid 1px gray;
  120. border-radius: 10px;
  121. max-width: 800px;
  122. }
  123. iframe {
  124. width: 100%;
  125. height: 500px;
  126. }
  127. </style>
  128. </head>
  129. <body>${body}</body>
  130. </html>
  131. `;
  132. console.timeEnd("renderHTML");
  133. return new Response(html, {
  134. status: 200,
  135. headers: {
  136. "Content-Type": "text/html; charset=utf-8",
  137. "Content-Security-Policy": CONTENT_SECURITY_POLICY,
  138. },
  139. });
  140. }
  141. async handleJson(recipient: string): Promise<Response> {
  142. const emails = await this.getLatestEmails(recipient, 24 * 60 * 60);
  143. console.time("renderJSON");
  144. const items = emails.map(({ id, email, receivedAt }) => {
  145. const author = email.from
  146. ? { name: `${email.from.name} <${email.from.address}>` }
  147. : undefined;
  148. return {
  149. id,
  150. title: email.subject,
  151. content_text: email.text ?? "<No Content>",
  152. content_html: email.html,
  153. date_published: email.date ?? receivedAt.toString(),
  154. author,
  155. };
  156. });
  157. const json = JSON.stringify({
  158. version: "1.1",
  159. title: `Mail to ${recipient}`,
  160. items,
  161. });
  162. console.timeEnd("renderJSON");
  163. return new Response(json, {
  164. status: 200,
  165. headers: {
  166. "Content-Type": "application/feed+json; charset=utf-8",
  167. },
  168. });
  169. }
  170. }
  171. const configFile = Deno.args.length === 0 ? "config.json" : Deno.args[0];
  172. const config = JSON.parse(Deno.readTextFileSync(configFile));
  173. const server = new Server(new SqliteStore(config.store));
  174. server.start();