summaryrefslogtreecommitdiff
path: root/src/email.ts
diff options
context:
space:
mode:
Diffstat (limited to 'src/email.ts')
-rw-r--r--src/email.ts275
1 files changed, 275 insertions, 0 deletions
diff --git a/src/email.ts b/src/email.ts
new file mode 100644
index 0000000..a017aac
--- /dev/null
+++ b/src/email.ts
@@ -0,0 +1,275 @@
+import type { EmailFromInstruction, EmailJob, EmailToInstruction } from "./job";
+import * as TE from "fp-ts/lib/TaskEither";
+import * as O from "fp-ts/lib/Option";
+import { createTransport } from "nodemailer";
+import { toError } from "fp-ts/lib/Either";
+import { pipe } from "fp-ts/lib/function";
+import {
+ ImapFlow,
+ type FetchMessageObject,
+ type FetchQueryObject,
+ type MailboxLockObject,
+} from "imapflow";
+import * as IO from "fp-ts/lib/IO";
+import * as T from "fp-ts/lib/Task";
+import { ConsoleLogger } from "./logger";
+
+interface ImapClientI {
+ fetchAll: (
+ range: string,
+ options: FetchQueryObject,
+ ) => Promise<FetchMessageObject[]>;
+ connect: () => Promise<void>;
+ getMailboxLock: (mailbox: string) => Promise<MailboxLockObject>;
+ messageDelete: (uids: number[]) => Promise<boolean>;
+ close: () => void;
+}
+
+type Email = {
+ from: string;
+ to: string;
+ subject: string;
+ text: string;
+};
+
+class ErrorWithLock extends Error {
+ lock: O.Option<MailboxLockObject>;
+ constructor(message: string, lock?: MailboxLockObject) {
+ super(message);
+ this.lock = O.fromNullable(lock);
+ }
+}
+const ToErrorWithLock = (lock?: MailboxLockObject) => (error: unknown) =>
+ new ErrorWithLock(
+ error instanceof Error ? error.message : "Unknown error",
+ lock,
+ );
+
+/**
+ * Generate a unique email.
+ * @param from is the email to send from.
+ * @param to is the email to send to.
+ * @returns an {@link Email}.
+ */
+type EmailGenerator = (
+ from: EmailFromInstruction,
+ to: EmailToInstruction,
+) => IO.IO<Email>;
+const generateEmail: EmailGenerator =
+ (from: EmailFromInstruction, to: EmailToInstruction) => () => ({
+ from: from.email,
+ to: to.email,
+ subject: [new Date().toISOString(), crypto.randomUUID()].join(" | "),
+ text: crypto.randomUUID(),
+ });
+
+/**
+ * Get the transport layer for a mailbox to send a piece of mail.
+ * @param param0 is the mailbox to send from.
+ * @returns a function that takes an email and sends it.
+ */
+type GetSendEmail = (
+ from: EmailFromInstruction,
+) => (email: Email) => TE.TaskEither<Error, Email>;
+const getSendTransport: GetSendEmail = ({
+ username,
+ password,
+ server,
+ send_port,
+}) => {
+ const transport = createTransport({
+ host: server,
+ port: send_port,
+ auth: {
+ user: username,
+ pass: password,
+ },
+ tls: {
+ rejectUnauthorized: false,
+ },
+ });
+ return (email: Email) =>
+ TE.tryCatch(
+ () =>
+ new Promise<Email>((resolve, reject) =>
+ transport.sendMail(email, (error) => {
+ if (error) {
+ reject(error);
+ } else {
+ resolve(email);
+ }
+ }),
+ ),
+ toError,
+ );
+};
+
+/**
+ * Get an Imap client connected to a mailbox.
+ * @param param0 is the mailbox to read from.
+ * @returns a Right({@link ImapFlow}) if it connected, else an Left(error).
+ */
+type GetImapClient = (
+ to: EmailToInstruction,
+) => TE.TaskEither<Error, ImapClientI>;
+const getImap: GetImapClient = ({ username, password, server, read_port }) => {
+ const imap = new ImapFlow({
+ logger: false,
+ host: server,
+ port: read_port,
+ secure: true,
+ auth: {
+ user: username,
+ pass: password,
+ },
+ });
+ return TE.tryCatch(() => imap.connect().then(() => imap), toError);
+};
+
+/**
+ * @param imap is the Imap client to fetch messages from.
+ * @returns a Right({@link FetchMessageObject}[]) if successful, else a Left(error).
+ */
+const fetchMessages = (
+ imap: ImapClientI,
+): TE.TaskEither<Error, FetchMessageObject[]> =>
+ TE.tryCatch(
+ () =>
+ imap.fetchAll("*", {
+ uid: true,
+ envelope: true,
+ headers: true,
+ bodyParts: ["text"],
+ }),
+ toError,
+ );
+
+/**
+ * Curry a function to check if a message matches an email.
+ * @param email is the email to match.
+ * @returns a function that takes a message and returns true if it matches the email.
+ */
+type EmailMatcher = (email: Email) => (message: FetchMessageObject) => boolean;
+const matchesEmail: EmailMatcher = (email) => (message) => {
+ const subjectMatches = email.subject === message.envelope.subject;
+ const bodyMatches =
+ message.bodyParts.get("text")?.toString().trim() === email.text.trim();
+ const headers = message.headers.toLocaleString();
+ const fromMatches = headers.includes(`Return-Path: <${email.from}>`);
+ const toMatches = headers.includes(`Delivered-To: ${email.to}`);
+ return subjectMatches && bodyMatches && fromMatches && toMatches;
+};
+
+/**
+ * Find an email in the inbox.
+ * @param imap is the Imap client to search with.
+ * @param email is the email to search for.
+ * @param retries is the number of retries left.
+ * @param pollIntervalMs is the time to wait between retries.
+ * @returns a Right(number) if the email was found, else a Left(error).
+ */
+type FindEmailUidInInbox = (
+ imap: ImapClientI,
+ equalsEmail: (message: FetchMessageObject) => boolean,
+ retries: number,
+ pollIntervalMs: number,
+) => TE.TaskEither<Error, number>;
+const findEmailUidInInbox: FindEmailUidInInbox = (
+ imap,
+ equalsEmail,
+ retries,
+ pollIntervalMs,
+) =>
+ pipe(
+ fetchMessages(imap),
+ TE.flatMap((messages) => {
+ const message = messages.find(equalsEmail);
+ if (message) {
+ return TE.right(message.uid);
+ }
+ return TE.left(new Error("Email message not found"));
+ }),
+ TE.fold(
+ (e) =>
+ pipe(
+ TE.fromIO(ConsoleLogger.log(`failed; ${retries} retries left.`)),
+ TE.chain(() =>
+ retries === 0
+ ? TE.left(e)
+ : T.delay(pollIntervalMs)(TE.right(null)),
+ ),
+ TE.chain(() =>
+ findEmailUidInInbox(imap, equalsEmail, retries - 1, pollIntervalMs),
+ ),
+ ),
+ TE.of,
+ ),
+ );
+
+export type EmailJobDependencies = {
+ generateEmailImpl: EmailGenerator;
+ getSendImpl: GetSendEmail;
+ getImapImpl: GetImapClient;
+ findEmailUidInInboxImpl: FindEmailUidInInbox;
+ matchesEmailImpl: EmailMatcher;
+};
+
+/**
+ * Perform an email job.
+ * @param job is the job to perform.
+ */
+export const perform = (
+ { from, to, readRetry: { retries, interval } }: EmailJob,
+ {
+ generateEmailImpl = generateEmail,
+ getSendImpl = getSendTransport,
+ getImapImpl = getImap,
+ findEmailUidInInboxImpl = findEmailUidInInbox,
+ matchesEmailImpl = matchesEmail,
+ }: Partial<EmailJobDependencies> = {},
+): TE.TaskEither<Error, boolean> =>
+ pipe(
+ // arrange.
+ TE.fromIO(generateEmailImpl(from, to)),
+ TE.bindTo("email"),
+ // act.
+ TE.tap(({ email }) =>
+ pipe(getSendImpl(from)(email), TE.mapLeft(ToErrorWithLock())),
+ ),
+ TE.bind("imap", () => pipe(getImapImpl(to), TE.mapLeft(ToErrorWithLock()))),
+ TE.bind("mailboxLock", ({ imap }) =>
+ TE.tryCatch(() => imap.getMailboxLock("INBOX"), ToErrorWithLock()),
+ ),
+ // "assert".
+ TE.bind("uid", ({ imap, email, mailboxLock }) =>
+ pipe(
+ findEmailUidInInboxImpl(
+ imap,
+ matchesEmailImpl(email),
+ retries,
+ interval,
+ ),
+ TE.mapLeft(ToErrorWithLock(mailboxLock)),
+ ),
+ ),
+ // cleanup.
+ TE.bind("deleted", ({ imap, uid, mailboxLock }) =>
+ TE.tryCatch(
+ // () => imap.messageDelete([uid], { uid: true }),
+ () => imap.messageDelete([uid]),
+ ToErrorWithLock(mailboxLock),
+ ),
+ ),
+ TE.fold(
+ (e) => {
+ if (O.isSome(e.lock)) {
+ e.lock.value.release();
+ }
+ return TE.left(e);
+ },
+ ({ mailboxLock, deleted }) => {
+ mailboxLock.release();
+ return TE.right(deleted);
+ },
+ ),
+ );