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

This commit is contained in:
Elizabeth Hunt 2024-12-15 02:07:48 -08:00
parent 4f1e974623
commit c4385abb33
Signed by: simponic
GPG Key ID: 2909B9A7FF6213EE
3 changed files with 116 additions and 72 deletions

View File

@ -1,5 +1,6 @@
import { transformDurations } from "./duration"; import { parse } from "./duration";
import { perform } from "./email"; import { perform } from "./email";
import type { EmailJob } from "./job";
import { ConsoleLogger } from "./logger"; import { ConsoleLogger } from "./logger";
export const main = (port: number) => { export const main = (port: number) => {
@ -10,11 +11,16 @@ export const main = (port: number) => {
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 prevalidatedJob = transformDurations(await req.json()); const prevalidatedJob = await req.json();
if (prevalidatedJob._tag === "Left") { const interval = parse(prevalidatedJob.readRetry.interval);
return new Response(prevalidatedJob.left, { status: 400 }); 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); const jobInsensitive = structuredClone(job);
jobInsensitive.from.username = "****REDACTED****"; jobInsensitive.from.username = "****REDACTED****";

View File

@ -154,26 +154,3 @@ export const parse = (duration: string): E.Either<string, Duration> => {
E.map(build), E.map(build),
); );
}; };
export const transformDurations = (obj: any): E.Either<string, any> => {
const transform = (o: any): E.Either<string, any> => {
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);
}

View File

@ -4,16 +4,27 @@ 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 { 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 IO from "fp-ts/lib/IO";
import * as T from "fp-ts/lib/Task"; import * as T from "fp-ts/lib/Task";
import { ConsoleLogger, type Logger } from "./logger"; import { ConsoleLogger, type Logger } from "./logger";
interface ImapClientI { interface ImapClientI {
fetchAll: (range: string, options: FetchQueryObject) => Promise<FetchMessageObject[]>; fetchAll: (
range: string,
options: FetchQueryObject,
) => Promise<FetchMessageObject[]>;
connect: () => Promise<void>; connect: () => Promise<void>;
getMailboxLock: (mailbox: string) => Promise<MailboxLockObject>; getMailboxLock: (mailbox: string) => Promise<MailboxLockObject>;
messageDelete: (uids: number[], opts: Record<string, any>) => Promise<boolean>; messageDelete: (
uids: number[],
opts: Record<string, any>,
) => Promise<boolean>;
logout: () => Promise<void>; logout: () => Promise<void>;
} }
@ -34,8 +45,13 @@ class ErrorWithLock extends Error {
} }
} }
const ToErrorWithLock = (lock?: MailboxLockObject, imap?: ImapClientI) => (error: unknown) => const ToErrorWithLock =
new ErrorWithLock(error instanceof Error ? error.message : "Unknown error", lock, imap); (lock?: MailboxLockObject, imap?: ImapClientI) => (error: unknown) =>
new ErrorWithLock(
error instanceof Error ? error.message : "Unknown error",
lock,
imap,
);
/** /**
* Generate a unique email. * Generate a unique email.
@ -43,31 +59,42 @@ const ToErrorWithLock = (lock?: MailboxLockObject, imap?: ImapClientI) => (error
* @param to is the email to send to. * @param to is the email to send to.
* @returns an {@link Email}. * @returns an {@link Email}.
*/ */
type EmailGenerator = (from: EmailFromInstruction, to: EmailToInstruction) => IO.IO<Email>; type EmailGenerator = (
const generateEmail: EmailGenerator = (from: EmailFromInstruction, to: EmailToInstruction) => () => ({ from: EmailFromInstruction,
to: EmailToInstruction,
) => IO.IO<Email>;
const generateEmail: EmailGenerator =
(from: EmailFromInstruction, to: EmailToInstruction) => () => ({
from: from.email, from: from.email,
to: to.email, to: to.email,
subject: [new Date().toISOString(), crypto.randomUUID()].join(" | "), subject: [new Date().toISOString(), crypto.randomUUID()].join(" | "),
text: crypto.randomUUID() 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.
* @param param0 is the mailbox to send from. * @param param0 is the mailbox to send from.
* @returns a function that takes an email and sends it. * @returns a function that takes an email and sends it.
*/ */
type GetSendEmail = (from: EmailFromInstruction) => (email: Email) => TE.TaskEither<Error, Email>; type GetSendEmail = (
const getSendTransport: GetSendEmail = ({ username, password, server, send_port }) => { from: EmailFromInstruction,
) => (email: Email) => TE.TaskEither<Error, Email>;
const getSendTransport: GetSendEmail = ({
username,
password,
server,
send_port,
}) => {
const transport = createTransport({ const transport = createTransport({
host: server, host: server,
port: send_port, port: send_port,
auth: { auth: {
user: username, user: username,
pass: password pass: password,
}, },
tls: { tls: {
rejectUnauthorized: false rejectUnauthorized: false,
} },
}); });
return (email: Email) => return (email: Email) =>
TE.tryCatch( TE.tryCatch(
@ -79,9 +106,9 @@ const getSendTransport: GetSendEmail = ({ username, password, server, send_port
} else { } else {
resolve(email); resolve(email);
} }
}) }),
), ),
toError toError,
); );
}; };
@ -90,7 +117,9 @@ const getSendTransport: GetSendEmail = ({ username, password, server, send_port
* @param param0 is the mailbox to read from. * @param param0 is the mailbox to read from.
* @returns a Right({@link ImapFlow}) if it connected, else an Left(error). * @returns a Right({@link ImapFlow}) if it connected, else an Left(error).
*/ */
type GetImapClient = (to: EmailToInstruction) => TE.TaskEither<Error, ImapClientI>; type GetImapClient = (
to: EmailToInstruction,
) => TE.TaskEither<Error, ImapClientI>;
const getImap: GetImapClient = ({ username, password, server, read_port }) => { const getImap: GetImapClient = ({ username, password, server, read_port }) => {
const imap = new ImapFlow({ const imap = new ImapFlow({
logger: false, logger: false,
@ -99,8 +128,8 @@ const getImap: GetImapClient = ({ username, password, server, read_port }) => {
secure: true, secure: true,
auth: { auth: {
user: username, user: username,
pass: password pass: password,
} },
}); });
return TE.tryCatch(() => imap.connect().then(() => imap), toError); 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. * @param imap is the Imap client to fetch messages from.
* @returns a Right({@link FetchMessageObject}[]) if successful, else a Left(error). * @returns a Right({@link FetchMessageObject}[]) if successful, else a Left(error).
*/ */
const fetchMessages = (imap: ImapClientI): TE.TaskEither<Error, FetchMessageObject[]> => const fetchMessages = (
imap: ImapClientI,
): TE.TaskEither<Error, FetchMessageObject[]> =>
TE.tryCatch( TE.tryCatch(
() => () =>
imap.fetchAll("*", { imap.fetchAll("*", {
uid: true, uid: true,
envelope: true, envelope: true,
headers: true, headers: true,
bodyParts: ["text"] bodyParts: ["text"],
}), }),
toError toError,
); );
/** /**
@ -129,7 +160,8 @@ const fetchMessages = (imap: ImapClientI): TE.TaskEither<Error, FetchMessageObje
type EmailMatcher = (email: Email) => (message: FetchMessageObject) => boolean; type EmailMatcher = (email: Email) => (message: FetchMessageObject) => boolean;
const matchesEmail: EmailMatcher = (email) => (message) => { const matchesEmail: EmailMatcher = (email) => (message) => {
const subjectMatches = email.subject === message.envelope.subject; 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 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}`);
@ -151,7 +183,13 @@ type FindEmailUidInInbox = (
pollIntervalMs: number, pollIntervalMs: number,
logger?: Logger, logger?: Logger,
) => TE.TaskEither<Error, number>; ) => TE.TaskEither<Error, number>;
const findEmailUidInInbox: FindEmailUidInInbox = (imap, equalsEmail, retries, pollIntervalMs, logger = ConsoleLogger) => const findEmailUidInInbox: FindEmailUidInInbox = (
imap,
equalsEmail,
retries,
pollIntervalMs,
logger = ConsoleLogger,
) =>
pipe( pipe(
fetchMessages(imap), fetchMessages(imap),
TE.flatMap((messages) => { TE.flatMap((messages) => {
@ -164,17 +202,25 @@ const findEmailUidInInbox: FindEmailUidInInbox = (imap, equalsEmail, retries, po
TE.fold( TE.fold(
(e) => (e) =>
pipe( pipe(
TE.fromIO(logger.log(`failed to find email; ${retries} retries left.`)), TE.fromIO(
TE.chain(() => (retries === 0 ? TE.left(e) : T.delay(pollIntervalMs)(TE.right(null)))), logger.log(`failed to find email; ${retries} retries left.`),
TE.chain(() => findEmailUidInInbox(imap, equalsEmail, retries - 1, pollIntervalMs)) ),
TE.chain(() =>
retries === 0
? TE.left(e)
: T.delay(pollIntervalMs)(TE.right(null)),
),
TE.chain(() =>
findEmailUidInInbox(imap, equalsEmail, retries - 1, pollIntervalMs),
),
), ),
(s) => (s) =>
pipe( pipe(
s, s,
TE.of, TE.of,
TE.tap(() => TE.fromIO(logger.log("Email succeeded"))) TE.tap(() => TE.fromIO(logger.log("Email succeeded"))),
) ),
) ),
); );
export type EmailJobDependencies = { export type EmailJobDependencies = {
@ -196,23 +242,35 @@ export const perform = (
getSendImpl = getSendTransport, getSendImpl = getSendTransport,
getImapImpl = getImap, getImapImpl = getImap,
findEmailUidInInboxImpl = findEmailUidInInbox, findEmailUidInInboxImpl = findEmailUidInInbox,
matchesEmailImpl = matchesEmail matchesEmailImpl = matchesEmail,
}: Partial<EmailJobDependencies> = {} }: Partial<EmailJobDependencies> = {},
): TE.TaskEither<Error, boolean> => ): TE.TaskEither<Error, boolean> =>
pipe( pipe(
// arrange. // arrange.
TE.fromIO(generateEmailImpl(from, to)), TE.fromIO(generateEmailImpl(from, to)),
TE.bindTo("email"), TE.bindTo("email"),
// act. // 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("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". // "assert".
TE.bind("uid", ({ imap, email, mailboxLock }) => TE.bind("uid", ({ imap, email, mailboxLock }) =>
pipe( pipe(
findEmailUidInInboxImpl(imap, matchesEmailImpl(email), retries, interval), findEmailUidInInboxImpl(
TE.mapLeft(ToErrorWithLock(mailboxLock, imap)) imap,
) matchesEmailImpl(email),
retries,
interval,
),
TE.mapLeft(ToErrorWithLock(mailboxLock, imap)),
),
), ),
// cleanup. // cleanup.
TE.bind("deleted", ({ imap, uid, mailboxLock }) => TE.bind("deleted", ({ imap, uid, mailboxLock }) =>
@ -228,7 +286,10 @@ export const perform = (
} }
if (O.isSome(e.imap)) { if (O.isSome(e.imap)) {
const imap = e.imap.value; 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); return TE.left(e);
}, },
@ -236,6 +297,6 @@ export const perform = (
mailboxLock.release(); mailboxLock.release();
imap.logout(); imap.logout();
return TE.right(deleted); return TE.right(deleted);
} },
) ),
); );