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, type Logger } from "./logger"; interface ImapClientI { fetchAll: (range: string, options: FetchQueryObject) => Promise; connect: () => Promise; getMailboxLock: (mailbox: string) => Promise; messageDelete: (uids: number[], opts: Record) => Promise; logout: () => Promise; } type Email = { from: string; to: string; subject: string; text: string; }; class ErrorWithLock extends Error { lock: O.Option; imap: O.Option; constructor(message: string, lock?: MailboxLockObject, imap?: ImapClientI) { super(message); this.lock = O.fromNullable(lock); this.imap = O.fromNullable(imap); } } const ToErrorWithLock = (lock?: MailboxLockObject, imap?: ImapClientI) => (error: unknown) => new ErrorWithLock(error instanceof Error ? error.message : "Unknown error", lock, imap); /** * 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; 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; 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((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; 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 => 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, logger?: Logger, ) => TE.TaskEither; const findEmailUidInInbox: FindEmailUidInInbox = (imap, equalsEmail, retries, pollIntervalMs, logger = ConsoleLogger) => 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(logger.log(`failed to find email; ${retries} retries left.`)), TE.chain(() => (retries === 0 ? TE.left(e) : T.delay(pollIntervalMs)(TE.right(null)))), TE.chain(() => findEmailUidInInbox(imap, equalsEmail, retries - 1, pollIntervalMs)) ), (s) => pipe( s, TE.of, TE.tap(() => TE.fromIO(logger.log("Email succeeded"))) ) ) ); 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 = {} ): TE.TaskEither => 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(undefined, imap))), // "assert". TE.bind("uid", ({ imap, email, mailboxLock }) => pipe( findEmailUidInInboxImpl(imap, matchesEmailImpl(email), retries, interval), TE.mapLeft(ToErrorWithLock(mailboxLock, imap)) ) ), // cleanup. TE.bind("deleted", ({ imap, uid, mailboxLock }) => TE.tryCatch( () => imap.messageDelete([uid], { uid: true }), ToErrorWithLock(mailboxLock, imap), ), ), TE.fold( (e) => { if (O.isSome(e.lock)) { e.lock.value.release(); } if (O.isSome(e.imap)) { const imap = e.imap.value; return pipe(TE.tryCatch(() => imap.logout(), toError), TE.flatMap(() => TE.left(e))); } return TE.left(e); }, ({ mailboxLock, deleted, imap }) => { mailboxLock.release(); imap.logout(); return TE.right(deleted); } ) );