diff --git a/src/api.ts b/src/api.ts index 722dc70..f4eb16f 100644 --- a/src/api.ts +++ b/src/api.ts @@ -15,7 +15,7 @@ export const main = (port: number) => { return new Response(prevalidatedJob.left, { status: 400 }); } const job = prevalidatedJob.right; - + const jobInsensitive = structuredClone(job); jobInsensitive.from.username = "****REDACTED****"; jobInsensitive.from.password = "****REDACTED****"; diff --git a/src/duration.ts b/src/duration.ts index e8dc7d1..69c3f3f 100644 --- a/src/duration.ts +++ b/src/duration.ts @@ -160,7 +160,7 @@ export const transformDurations = (obj: any): E.Either => { const entries = Object.entries(o); for (let [key, value] of entries) { - if (key === "duration" && typeof value === "string") { + if ((key === "duration" || key === "interval") && typeof value === "string") { return parse(value); } else if (typeof value === "object" && value !== null) { const result = transform(value); @@ -176,4 +176,4 @@ export const transformDurations = (obj: any): E.Either => { }; return transform(obj); -} +}; diff --git a/src/email.ts b/src/email.ts index 906b86d..0e4bd88 100644 --- a/src/email.ts +++ b/src/email.ts @@ -4,16 +4,27 @@ 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 { + 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; + fetchAll: ( + range: string, + options: FetchQueryObject, + ) => Promise; connect: () => Promise; getMailboxLock: (mailbox: string) => Promise; - messageDelete: (uids: number[], opts: Record) => Promise; + messageDelete: ( + uids: number[], + opts: Record, + ) => Promise; logout: () => Promise; } @@ -34,8 +45,13 @@ class ErrorWithLock extends Error { } } -const ToErrorWithLock = (lock?: MailboxLockObject, imap?: ImapClientI) => (error: unknown) => - new ErrorWithLock(error instanceof Error ? error.message : "Unknown error", lock, imap); +const ToErrorWithLock = + (lock?: MailboxLockObject, imap?: ImapClientI) => (error: unknown) => + new ErrorWithLock( + error instanceof Error ? error.message : "Unknown error", + lock, + imap, + ); /** * Generate a unique email. @@ -43,31 +59,42 @@ const ToErrorWithLock = (lock?: MailboxLockObject, imap?: ImapClientI) => (error * @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() -}); +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 }) => { +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 + pass: password, }, tls: { - rejectUnauthorized: false - } + rejectUnauthorized: false, + }, }); return (email: Email) => TE.tryCatch( @@ -79,9 +106,9 @@ const getSendTransport: GetSendEmail = ({ username, password, server, send_port } else { resolve(email); } - }) + }), ), - toError + toError, ); }; @@ -90,7 +117,9 @@ const getSendTransport: GetSendEmail = ({ username, password, server, send_port * @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; +type GetImapClient = ( + to: EmailToInstruction, +) => TE.TaskEither; const getImap: GetImapClient = ({ username, password, server, read_port }) => { const imap = new ImapFlow({ logger: false, @@ -99,8 +128,8 @@ const getImap: GetImapClient = ({ username, password, server, read_port }) => { secure: true, auth: { user: username, - pass: password - } + pass: password, + }, }); return TE.tryCatch(() => imap.connect().then(() => imap), toError); }; @@ -109,16 +138,18 @@ const getImap: GetImapClient = ({ username, password, server, read_port }) => { * @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 => +const fetchMessages = ( + imap: ImapClientI, +): TE.TaskEither => TE.tryCatch( () => imap.fetchAll("*", { uid: true, envelope: true, headers: true, - bodyParts: ["text"] + bodyParts: ["text"], }), - toError + toError, ); /** @@ -129,7 +160,8 @@ const fetchMessages = (imap: ImapClientI): TE.TaskEither (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 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}`); @@ -151,7 +183,13 @@ type FindEmailUidInInbox = ( pollIntervalMs: number, logger?: Logger, ) => TE.TaskEither; -const findEmailUidInInbox: FindEmailUidInInbox = (imap, equalsEmail, retries, pollIntervalMs, logger = ConsoleLogger) => +const findEmailUidInInbox: FindEmailUidInInbox = ( + imap, + equalsEmail, + retries, + pollIntervalMs, + logger = ConsoleLogger, +) => pipe( fetchMessages(imap), TE.flatMap((messages) => { @@ -164,17 +202,25 @@ const findEmailUidInInbox: FindEmailUidInInbox = (imap, equalsEmail, retries, po 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)) + 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"))) - ) - ) + TE.tap(() => TE.fromIO(logger.log("Email succeeded"))), + ), + ), ); export type EmailJobDependencies = { @@ -196,23 +242,35 @@ export const perform = ( getSendImpl = getSendTransport, getImapImpl = getImap, findEmailUidInInboxImpl = findEmailUidInInbox, - matchesEmailImpl = matchesEmail - }: Partial = {} + 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.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))), + 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)) - ) + findEmailUidInInboxImpl( + imap, + matchesEmailImpl(email), + retries, + interval, + ), + TE.mapLeft(ToErrorWithLock(mailboxLock, imap)), + ), ), // cleanup. TE.bind("deleted", ({ imap, uid, mailboxLock }) => @@ -228,7 +286,10 @@ export const perform = ( } if (O.isSome(e.imap)) { const imap = e.imap.value; - return pipe(TE.tryCatch(() => imap.logout(), toError), TE.flatMap(() => TE.left(e))); + return pipe( + TE.tryCatch(() => imap.logout(), toError), + TE.flatMap(() => TE.left(e)), + ); } return TE.left(e); }, @@ -236,6 +297,6 @@ export const perform = ( mailboxLock.release(); imap.logout(); return TE.right(deleted); - } - ) + }, + ), );