diff --git a/src/api.ts b/src/api.ts index 722dc70..678f40d 100644 --- a/src/api.ts +++ b/src/api.ts @@ -1,5 +1,6 @@ -import { transformDurations } from "./duration"; +import { parse } from "./duration"; import { perform } from "./email"; +import type { EmailJob } from "./job"; import { ConsoleLogger } from "./logger"; export const main = (port: number) => { @@ -10,12 +11,17 @@ export const main = (port: number) => { const url = new URL(req.url); if (req.method === "POST" && url.pathname === "/api/email") { - const prevalidatedJob = transformDurations(await req.json()); - if (prevalidatedJob._tag === "Left") { - return new Response(prevalidatedJob.left, { status: 400 }); + const prevalidatedJob = await req.json(); + const interval = parse(prevalidatedJob.readRetry.interval); + if (interval._tag === "Left") { + return new Response(interval.left, { status: 400 }); } - const job = prevalidatedJob.right; - + prevalidatedJob.readRetry.interval = interval; + const job: EmailJob = { + ...prevalidatedJob, + readRetry: { ...prevalidatedJob.readRetry, interval }, + }; + const jobInsensitive = structuredClone(job); jobInsensitive.from.username = "****REDACTED****"; jobInsensitive.from.password = "****REDACTED****"; diff --git a/src/duration.ts b/src/duration.ts index e8dc7d1..ad19921 100644 --- a/src/duration.ts +++ b/src/duration.ts @@ -154,26 +154,3 @@ export const parse = (duration: string): E.Either => { E.map(build), ); }; - -export const transformDurations = (obj: any): E.Either => { - const transform = (o: any): E.Either => { - const entries = Object.entries(o); - - for (let [key, value] of entries) { - if (key === "duration" && typeof value === "string") { - return parse(value); - } else if (typeof value === "object" && value !== null) { - const result = transform(value); - if (E.isLeft(result)) { - return result; - } else { - o[key] = result.right; - } - } - } - - return E.right(o); - }; - - 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); - } - ) + }, + ), );