small refactorings
All checks were successful
continuous-integration/drone/push Build is passing

This commit is contained in:
Elizabeth Hunt 2024-12-15 13:05:50 -08:00
parent 6f45fe5a10
commit c0a96e82af
Signed by: simponic
GPG Key ID: 2909B9A7FF6213EE
4 changed files with 106 additions and 53 deletions

View File

@ -1,25 +1,20 @@
import { perform } from "./email"; import { perform } from "./email";
import type { EmailJob } from "./job"; import { redactJob, type EmailJob } from "./job";
import { ConsoleLogger } from "./logger"; import { ConsoleLogger } from "./logger";
export const main = (port: number) => { export const main = (port: number) => {
const server = Bun.serve({ const server = Bun.serve({
port, port,
async fetch(req) { async fetch(req) {
ConsoleLogger.log(`Received request: ${req.url}`)(); ConsoleLogger.info(`Received request: ${req.url}`)();
const url = new URL(req.url); const url = new URL(req.url);
if (req.method === "POST" && url.pathname === "/api/email") { if (req.method === "POST" && url.pathname === "/api/email") {
const job: EmailJob = await req.json(); const job: EmailJob = await req.json();
const jobInsensitive = redactJob(job);
const jobInsensitive = structuredClone(job);
jobInsensitive.from.username = "****REDACTED****";
jobInsensitive.from.password = "****REDACTED****";
jobInsensitive.to.username = "****REDACTED****";
jobInsensitive.to.password = "****REDACTED****";
const uuid = crypto.randomUUID(); const uuid = crypto.randomUUID();
ConsoleLogger.log( ConsoleLogger.info(
`[${uuid}] Received email job: ${JSON.stringify(jobInsensitive)}`, `[${uuid}] Received email job: ${JSON.stringify(jobInsensitive)}`,
)(); )();
@ -28,18 +23,18 @@ export const main = (port: number) => {
.then((result) => { .then((result) => {
if (result._tag === "Left") { if (result._tag === "Left") {
const error = result.left; const error = result.left;
ConsoleLogger.log( ConsoleLogger.warn(
`[${uuid}] job failure due to ${error.message}`, `[${uuid}] job failure due to ${error.message}`,
)(); )();
return new Response(error.message, { return new Response(error.message, {
status: 400, status: 400,
}); });
} }
ConsoleLogger.log(`[${uuid}] success`)(); ConsoleLogger.info(`[${uuid}] success`)();
return Response.json({ success: true }); return Response.json({ success: true });
}) })
.catch((e) => { .catch((e) => {
ConsoleLogger.log(`[${uuid}] internal failure due to ${e}`)(); ConsoleLogger.error(`[${uuid}] internal failure due to ${e}`)();
return new Response(e.message, { return new Response(e.message, {
status: 500, status: 500,
}); });
@ -48,6 +43,6 @@ export const main = (port: number) => {
return new Response("404!", { status: 404 }); return new Response("404!", { status: 404 });
}, },
}); });
ConsoleLogger.log(`Listening on port ${port}`)(); ConsoleLogger.info(`Listening on port ${port}`)();
return server; return server;
}; };

View File

@ -3,7 +3,7 @@ import * as TE from "fp-ts/lib/TaskEither";
import * as O from "fp-ts/lib/Option"; import * as O from "fp-ts/lib/Option";
import { createTransport } from "nodemailer"; import { createTransport } from "nodemailer";
import { toError } from "fp-ts/lib/Either"; import { toError } from "fp-ts/lib/Either";
import { pipe } from "fp-ts/lib/function"; import { flow, pipe } from "fp-ts/lib/function";
import { import {
ImapFlow, ImapFlow,
type FetchMessageObject, type FetchMessageObject,
@ -26,7 +26,7 @@ interface ImapClientI {
opts: Record<string, any>, opts: Record<string, any>,
) => Promise<boolean>; ) => Promise<boolean>;
logout: () => Promise<void>; logout: () => Promise<void>;
mailboxClose: () => Promise<void>; mailboxClose: () => Promise<boolean>;
} }
type Email = { type Email = {
@ -63,14 +63,24 @@ const ToErrorWithLock =
type EmailGenerator = ( type EmailGenerator = (
from: EmailFromInstruction, from: EmailFromInstruction,
to: EmailToInstruction, to: EmailToInstruction,
logger: Logger,
) => IO.IO<Email>; ) => IO.IO<Email>;
const generateEmail: EmailGenerator = const generateEmail: EmailGenerator = (
(from: EmailFromInstruction, to: EmailToInstruction) => () => ({ from: EmailFromInstruction,
from: from.email, to: EmailToInstruction,
to: to.email, logger: Logger,
subject: [new Date().toISOString(), crypto.randomUUID()].join(" | "), ) =>
text: crypto.randomUUID(), 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. * Get the transport layer for a mailbox to send a piece of mail.
@ -79,13 +89,12 @@ const generateEmail: EmailGenerator =
*/ */
type GetSendEmail = ( type GetSendEmail = (
from: EmailFromInstruction, from: EmailFromInstruction,
logger: Logger,
) => (email: Email) => TE.TaskEither<Error, Email>; ) => (email: Email) => TE.TaskEither<Error, Email>;
const getSendTransport: GetSendEmail = ({ const getSendTransport: GetSendEmail = (
username, { username, password, server, send_port },
password, _logger,
server, ) => {
send_port,
}) => {
const transport = createTransport({ const transport = createTransport({
host: server, host: server,
port: send_port, port: send_port,
@ -120,8 +129,12 @@ const getSendTransport: GetSendEmail = ({
*/ */
type GetImapClient = ( type GetImapClient = (
to: EmailToInstruction, to: EmailToInstruction,
logger: Logger,
) => TE.TaskEither<Error, ImapClientI>; ) => TE.TaskEither<Error, ImapClientI>;
const getImap: GetImapClient = ({ username, password, server, read_port }) => { const getImap: GetImapClient = (
{ username, password, server, read_port },
logger,
) => {
const imap = new ImapFlow({ const imap = new ImapFlow({
logger: false, logger: false,
host: server, host: server,
@ -132,7 +145,14 @@ const getImap: GetImapClient = ({ username, password, server, read_port }) => {
pass: password, 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 = ( const fetchMessages = (
imap: ImapClientI, imap: ImapClientI,
logger: Logger,
): TE.TaskEither<Error, FetchMessageObject[]> => ): TE.TaskEither<Error, FetchMessageObject[]> =>
TE.tryCatch( pipe(
() => TE.fromIO(logger.info("Fetching messages...")),
imap.fetchAll("*", { TE.chain(() =>
uid: true, TE.tryCatch(
envelope: true, () =>
headers: true, imap.fetchAll("*", {
bodyParts: ["text"], uid: true,
}), envelope: true,
toError, 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 = const bodyMatches =
message.bodyParts?.get("text")?.toString().trim() === email.text.trim(); message.bodyParts?.get("text")?.toString().trim() === email.text.trim();
const headers = message.headers?.toLocaleString(); const headers = message.headers?.toLocaleString();
const fromMatches = headers.includes(`Return-Path: <${email.from}>`); const fromMatches = headers?.includes(`Return-Path: <${email.from}>`);
const toMatches = headers.includes(`Delivered-To: ${email.to}`); const toMatches = headers?.includes(`Delivered-To: ${email.to}`);
return subjectMatches && bodyMatches && fromMatches && toMatches; return subjectMatches && bodyMatches && fromMatches && toMatches;
}; };
@ -175,6 +203,7 @@ const matchesEmail: EmailMatcher = (email) => (message) => {
* @param email is the email to search for. * @param email is the email to search for.
* @param retries is the number of retries left. * @param retries is the number of retries left.
* @param pollIntervalMs is the time to wait between retries. * @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). * @returns a Right(number) if the email was found, else a Left(error).
*/ */
type FindEmailUidInInbox = ( type FindEmailUidInInbox = (
@ -182,17 +211,17 @@ type FindEmailUidInInbox = (
equalsEmail: (message: FetchMessageObject) => boolean, equalsEmail: (message: FetchMessageObject) => boolean,
retries: number, retries: number,
pollIntervalMs: number, pollIntervalMs: number,
logger?: Logger, logger: Logger,
) => TE.TaskEither<Error, number>; ) => TE.TaskEither<Error, number>;
const findEmailUidInInbox: FindEmailUidInInbox = ( const findEmailUidInInbox: FindEmailUidInInbox = (
imap, imap,
equalsEmail, equalsEmail,
retries, retries,
pollIntervalMs, pollIntervalMs,
logger = ConsoleLogger, logger,
) => ) =>
pipe( pipe(
fetchMessages(imap), fetchMessages(imap, logger),
TE.flatMap((messages) => { TE.flatMap((messages) => {
const message = messages.find(equalsEmail); const message = messages.find(equalsEmail);
if (message) { if (message) {
@ -204,7 +233,7 @@ const findEmailUidInInbox: FindEmailUidInInbox = (
(e) => (e) =>
pipe( pipe(
TE.fromIO( TE.fromIO(
logger.log(`failed to find email; ${retries} retries left.`), logger.info(`Failed to find email; ${retries} retries left.`),
), ),
TE.chain(() => TE.chain(() =>
retries === 0 retries === 0
@ -212,14 +241,20 @@ const findEmailUidInInbox: FindEmailUidInInbox = (
: T.delay(pollIntervalMs)(TE.right(null)), : T.delay(pollIntervalMs)(TE.right(null)),
), ),
TE.chain(() => TE.chain(() =>
findEmailUidInInbox(imap, equalsEmail, retries - 1, pollIntervalMs), findEmailUidInInbox(
imap,
equalsEmail,
retries - 1,
pollIntervalMs,
logger,
),
), ),
), ),
(s) => (s) =>
pipe( pipe(
s, s,
TE.of, 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. * Perform an email job.
* @param job is the job to perform. * @param job is the job to perform.
* @param logger is the logger instance.
*/ */
export const perform = ( export const perform = (
{ from, to, readRetry: { retries, interval } }: EmailJob, { from, to, readRetry: { retries, interval } }: EmailJob,
@ -245,16 +281,19 @@ export const perform = (
findEmailUidInInboxImpl = findEmailUidInInbox, findEmailUidInInboxImpl = findEmailUidInInbox,
matchesEmailImpl = matchesEmail, matchesEmailImpl = matchesEmail,
}: Partial<EmailJobDependencies> = {}, }: Partial<EmailJobDependencies> = {},
logger: Logger = ConsoleLogger,
): TE.TaskEither<Error, boolean> => ): TE.TaskEither<Error, boolean> =>
pipe( pipe(
// arrange. // arrange.
TE.fromIO(generateEmailImpl(from, to)), TE.fromIO(generateEmailImpl(from, to, logger)),
TE.bindTo("email"), TE.bindTo("email"),
// act. // act.
TE.tap(({ email }) => 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.bind("mailboxLock", ({ imap }) =>
TE.tryCatch( TE.tryCatch(
() => imap.getMailboxLock("INBOX"), () => imap.getMailboxLock("INBOX"),
@ -269,6 +308,7 @@ export const perform = (
matchesEmailImpl(email), matchesEmailImpl(email),
retries, retries,
interval, interval,
logger,
), ),
TE.mapLeft(ToErrorWithLock(mailboxLock, imap)), TE.mapLeft(ToErrorWithLock(mailboxLock, imap)),
), ),

View File

@ -23,3 +23,15 @@ export interface Retry {
retries: number; retries: number;
interval: number; interval: number;
} }
export const redact = <T extends EmailInstruction>(instruction: T): T => ({
...instruction,
password: "REDACTED",
username: "REDACTED",
});
export const redactJob = (job: EmailJob): EmailJob => ({
...job,
from: redact(job.from),
to: redact(job.to),
});

View File

@ -1,10 +1,16 @@
import type { IO } from "fp-ts/lib/IO"; import type { IO } from "fp-ts/lib/IO";
export interface Logger { export interface Logger {
log: (message: string) => IO<void>; info: (message: string) => IO<void>;
error: (message: string) => IO<void>;
warn: (message: string) => IO<void>;
} }
export const ConsoleLogger: Logger = { export const ConsoleLogger: Logger = {
log: (message: string) => () => info: (message: string) => () =>
console.log(`[${new Date().toUTCString()}] ` + message), 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),
}; };