diff options
Diffstat (limited to 'src/email.ts')
-rw-r--r-- | src/email.ts | 351 |
1 files changed, 0 insertions, 351 deletions
diff --git a/src/email.ts b/src/email.ts deleted file mode 100644 index 328afab..0000000 --- a/src/email.ts +++ /dev/null @@ -1,351 +0,0 @@ -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<FetchMessageObject[]>; - connect: () => Promise<void>; - getMailboxLock: (mailbox: string) => Promise<MailboxLockObject>; - messageDelete: ( - uids: number[], - opts: Record<string, any>, - ) => Promise<boolean>; - logout: () => Promise<void>; - mailboxClose: () => Promise<boolean>; -} - -type Email = { - from: string; - to: string; - subject: string; - text: string; -}; - -class ErrorWithLock extends Error { - lock: O.Option<MailboxLockObject>; - imap: O.Option<ImapClientI>; - 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, - logger: Logger, -) => IO.IO<Email>; -const generateEmail: EmailGenerator = ( - from: EmailFromInstruction, - to: EmailToInstruction, - logger: Logger, -) => - pipe( - IO.of(logger.info("Generating email...")), - IO.chain(() => - IO.of({ - 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, - logger: Logger, -) => (email: Email) => TE.TaskEither<Error, Email>; -const getSendTransport: GetSendEmail = ( - { username, password, server, send_port }, - _logger, -) => { - 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, - logger: Logger, -) => TE.TaskEither<Error, ImapClientI>; -const getImap: GetImapClient = ( - { username, password, server, read_port }, - logger, -) => { - const imap = new ImapFlow({ - logger: false, - host: server, - port: read_port, - secure: true, - auth: { - user: username, - pass: password, - }, - }); - return pipe( - TE.fromIO(logger.info("Connecting to IMAP server...")), - TE.flatMap(() => - TE.tryCatch(() => imap.connect().then(() => imap), toError), - ), - TE.tap(() => TE.fromIO(logger.info("Connected to IMAP server."))), - TE.tapError((error) => TE.fromIO(logger.error(error.message))), - ); -}; - -/** - * @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, - logger: Logger, -): TE.TaskEither<Error, FetchMessageObject[]> => - pipe( - TE.fromIO(logger.info("Fetching messages...")), - TE.chain(() => - TE.tryCatch( - () => - imap.fetchAll("*", { - uid: true, - envelope: true, - headers: true, - bodyParts: ["text"], - }), - toError, - ), - ), - TE.tap(() => TE.fromIO(logger.info("Fetched messages."))), - TE.tapError((error) => TE.fromIO(logger.error(error.message))), - ); - -/** - * 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. - * @param logger is the logger instance. - * @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<Error, number>; -const findEmailUidInInbox: FindEmailUidInInbox = ( - imap, - equalsEmail, - retries, - pollIntervalMs, - logger, -) => - pipe( - fetchMessages(imap, logger), - 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.info(`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, - logger, - ), - ), - ), - (s) => - pipe( - s, - TE.of, - TE.tap(() => TE.fromIO(logger.info("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. - * @param logger is the logger instance. - */ -export const perform = ( - { from, to, readRetry: { retries, interval } }: EmailJob, - { - generateEmailImpl = generateEmail, - getSendImpl = getSendTransport, - getImapImpl = getImap, - findEmailUidInInboxImpl = findEmailUidInInbox, - matchesEmailImpl = matchesEmail, - }: Partial<EmailJobDependencies> = {}, - logger: Logger = ConsoleLogger, -): TE.TaskEither<Error, boolean> => - pipe( - // arrange. - TE.fromIO(generateEmailImpl(from, to, logger)), - TE.bindTo("email"), - // act. - TE.tap(({ email }) => - pipe(getSendImpl(from, logger)(email), TE.mapLeft(ToErrorWithLock())), - ), - TE.bind("imap", () => - pipe(getImapImpl(to, logger), 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, - logger, - ), - 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.mailboxClose().then(() => imap.logout()), - toError, - ), - TE.flatMap(() => TE.left(e)), - ); - } - return TE.left(e); - }, - ({ mailboxLock, deleted, imap }) => { - mailboxLock.release(); - return pipe( - TE.tryCatch( - () => imap.mailboxClose().then(() => imap.logout()), - toError, - ), - TE.flatMap(() => TE.right(deleted)), - ); - }, - ), - ); |