diff --git a/src/api.ts b/src/api.ts index 6132882..946826b 100644 --- a/src/api.ts +++ b/src/api.ts @@ -1,25 +1,20 @@ import { perform } from "./email"; -import type { EmailJob } from "./job"; +import { redactJob, type EmailJob } from "./job"; import { ConsoleLogger } from "./logger"; export const main = (port: number) => { const server = Bun.serve({ port, async fetch(req) { - ConsoleLogger.log(`Received request: ${req.url}`)(); + ConsoleLogger.info(`Received request: ${req.url}`)(); const url = new URL(req.url); if (req.method === "POST" && url.pathname === "/api/email") { const job: EmailJob = await req.json(); - - const jobInsensitive = structuredClone(job); - jobInsensitive.from.username = "****REDACTED****"; - jobInsensitive.from.password = "****REDACTED****"; - jobInsensitive.to.username = "****REDACTED****"; - jobInsensitive.to.password = "****REDACTED****"; + const jobInsensitive = redactJob(job); const uuid = crypto.randomUUID(); - ConsoleLogger.log( + ConsoleLogger.info( `[${uuid}] Received email job: ${JSON.stringify(jobInsensitive)}`, )(); @@ -28,18 +23,18 @@ export const main = (port: number) => { .then((result) => { if (result._tag === "Left") { const error = result.left; - ConsoleLogger.log( + ConsoleLogger.warn( `[${uuid}] job failure due to ${error.message}`, )(); return new Response(error.message, { status: 400, }); } - ConsoleLogger.log(`[${uuid}] success`)(); + ConsoleLogger.info(`[${uuid}] success`)(); return Response.json({ success: true }); }) .catch((e) => { - ConsoleLogger.log(`[${uuid}] internal failure due to ${e}`)(); + ConsoleLogger.error(`[${uuid}] internal failure due to ${e}`)(); return new Response(e.message, { status: 500, }); @@ -48,6 +43,6 @@ export const main = (port: number) => { return new Response("404!", { status: 404 }); }, }); - ConsoleLogger.log(`Listening on port ${port}`)(); + ConsoleLogger.info(`Listening on port ${port}`)(); return server; }; diff --git a/src/email.ts b/src/email.ts index 337572c..b822cd9 100644 --- a/src/email.ts +++ b/src/email.ts @@ -3,7 +3,7 @@ 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 { flow, pipe } from "fp-ts/lib/function"; import { ImapFlow, type FetchMessageObject, @@ -26,7 +26,7 @@ interface ImapClientI { opts: Record, ) => Promise; logout: () => Promise; - mailboxClose: () => Promise; + mailboxClose: () => Promise; } type Email = { @@ -63,14 +63,24 @@ const ToErrorWithLock = type EmailGenerator = ( from: EmailFromInstruction, to: EmailToInstruction, + logger: Logger, ) => 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(), - }); +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. @@ -79,13 +89,12 @@ const generateEmail: EmailGenerator = */ type GetSendEmail = ( from: EmailFromInstruction, + logger: Logger, ) => (email: Email) => TE.TaskEither; -const getSendTransport: GetSendEmail = ({ - username, - password, - server, - send_port, -}) => { +const getSendTransport: GetSendEmail = ( + { username, password, server, send_port }, + _logger, +) => { const transport = createTransport({ host: server, port: send_port, @@ -120,8 +129,12 @@ const getSendTransport: GetSendEmail = ({ */ type GetImapClient = ( to: EmailToInstruction, + logger: Logger, ) => TE.TaskEither; -const getImap: GetImapClient = ({ username, password, server, read_port }) => { +const getImap: GetImapClient = ( + { username, password, server, read_port }, + logger, +) => { const imap = new ImapFlow({ logger: false, host: server, @@ -132,7 +145,14 @@ const getImap: GetImapClient = ({ username, password, server, read_port }) => { pass: password, }, }); - return TE.tryCatch(() => imap.connect().then(() => imap), toError); + 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))), + ); }; /** @@ -141,16 +161,24 @@ const getImap: GetImapClient = ({ username, password, server, read_port }) => { */ const fetchMessages = ( imap: ImapClientI, + logger: Logger, ): TE.TaskEither => - TE.tryCatch( - () => - imap.fetchAll("*", { - uid: true, - envelope: true, - headers: true, - bodyParts: ["text"], - }), - toError, + 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))), ); /** @@ -164,8 +192,8 @@ const matchesEmail: EmailMatcher = (email) => (message) => { 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}`); + const fromMatches = headers?.includes(`Return-Path: <${email.from}>`); + const toMatches = headers?.includes(`Delivered-To: ${email.to}`); return subjectMatches && bodyMatches && fromMatches && toMatches; }; @@ -175,6 +203,7 @@ const matchesEmail: EmailMatcher = (email) => (message) => { * @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 = ( @@ -182,17 +211,17 @@ type FindEmailUidInInbox = ( equalsEmail: (message: FetchMessageObject) => boolean, retries: number, pollIntervalMs: number, - logger?: Logger, + logger: Logger, ) => TE.TaskEither; const findEmailUidInInbox: FindEmailUidInInbox = ( imap, equalsEmail, retries, pollIntervalMs, - logger = ConsoleLogger, + logger, ) => pipe( - fetchMessages(imap), + fetchMessages(imap, logger), TE.flatMap((messages) => { const message = messages.find(equalsEmail); if (message) { @@ -204,7 +233,7 @@ const findEmailUidInInbox: FindEmailUidInInbox = ( (e) => pipe( TE.fromIO( - logger.log(`failed to find email; ${retries} retries left.`), + logger.info(`Failed to find email; ${retries} retries left.`), ), TE.chain(() => retries === 0 @@ -212,14 +241,20 @@ const findEmailUidInInbox: FindEmailUidInInbox = ( : T.delay(pollIntervalMs)(TE.right(null)), ), TE.chain(() => - findEmailUidInInbox(imap, equalsEmail, retries - 1, pollIntervalMs), + findEmailUidInInbox( + imap, + equalsEmail, + retries - 1, + pollIntervalMs, + logger, + ), ), ), (s) => pipe( s, TE.of, - TE.tap(() => TE.fromIO(logger.log("Email succeeded"))), + TE.tap(() => TE.fromIO(logger.info("Email succeeded"))), ), ), ); @@ -235,6 +270,7 @@ export type EmailJobDependencies = { /** * 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, @@ -245,16 +281,19 @@ export const perform = ( findEmailUidInInboxImpl = findEmailUidInInbox, matchesEmailImpl = matchesEmail, }: Partial = {}, + logger: Logger = ConsoleLogger, ): TE.TaskEither => pipe( // arrange. - TE.fromIO(generateEmailImpl(from, to)), + TE.fromIO(generateEmailImpl(from, to, logger)), TE.bindTo("email"), // act. TE.tap(({ email }) => - pipe(getSendImpl(from)(email), TE.mapLeft(ToErrorWithLock())), + pipe(getSendImpl(from, logger)(email), TE.mapLeft(ToErrorWithLock())), + ), + TE.bind("imap", () => + pipe(getImapImpl(to, logger), TE.mapLeft(ToErrorWithLock())), ), - TE.bind("imap", () => pipe(getImapImpl(to), TE.mapLeft(ToErrorWithLock()))), TE.bind("mailboxLock", ({ imap }) => TE.tryCatch( () => imap.getMailboxLock("INBOX"), @@ -269,6 +308,7 @@ export const perform = ( matchesEmailImpl(email), retries, interval, + logger, ), TE.mapLeft(ToErrorWithLock(mailboxLock, imap)), ), diff --git a/src/job.ts b/src/job.ts index 2beabca..b1198f8 100644 --- a/src/job.ts +++ b/src/job.ts @@ -23,3 +23,15 @@ export interface Retry { retries: number; interval: number; } + +export const redact = (instruction: T): T => ({ + ...instruction, + password: "REDACTED", + username: "REDACTED", +}); + +export const redactJob = (job: EmailJob): EmailJob => ({ + ...job, + from: redact(job.from), + to: redact(job.to), +}); diff --git a/src/logger.ts b/src/logger.ts index 05d9fd9..ffe8f51 100644 --- a/src/logger.ts +++ b/src/logger.ts @@ -1,10 +1,16 @@ import type { IO } from "fp-ts/lib/IO"; export interface Logger { - log: (message: string) => IO; + info: (message: string) => IO; + error: (message: string) => IO; + warn: (message: string) => IO; } export const ConsoleLogger: Logger = { - log: (message: string) => () => - console.log(`[${new Date().toUTCString()}] ` + message), + info: (message: string) => () => + console.log(`[${new Date().toUTCString()}] INFO ` + message), + error: (message: string) => () => + console.error(`[${new Date().toUTCString()}] ERROR ` + message), + warn: (message: string) => () => + console.warn(`[${new Date().toUTCString()}] WARN ` + message), };