diff options
author | Elizabeth Hunt <elizabeth.hunt@simponic.xyz> | 2024-12-14 23:53:26 -0800 |
---|---|---|
committer | Elizabeth Hunt <elizabeth.hunt@simponic.xyz> | 2024-12-14 23:55:51 -0800 |
commit | 4fd40b1f9de400a5d859789e1dad3e1a4ba6587c (patch) | |
tree | 74fbae949aa3fb9711c06e31cb6649e90a8cdb97 /src/email.ts | |
download | uptime-4fd40b1f9de400a5d859789e1dad3e1a4ba6587c.tar.gz uptime-4fd40b1f9de400a5d859789e1dad3e1a4ba6587c.zip |
initial commit
Diffstat (limited to 'src/email.ts')
-rw-r--r-- | src/email.ts | 275 |
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); + }, + ), + ); |