diff options
author | Elizabeth Hunt <elizabeth.hunt@simponic.xyz> | 2024-12-14 23:53:26 -0800 |
---|---|---|
committer | Elizabeth Hunt <elizabeth.hunt@simponic.xyz> | 2024-12-14 23:55:51 -0800 |
commit | 4fd40b1f9de400a5d859789e1dad3e1a4ba6587c (patch) | |
tree | 74fbae949aa3fb9711c06e31cb6649e90a8cdb97 /src | |
download | uptime-4fd40b1f9de400a5d859789e1dad3e1a4ba6587c.tar.gz uptime-4fd40b1f9de400a5d859789e1dad3e1a4ba6587c.zip |
initial commit
Diffstat (limited to 'src')
-rw-r--r-- | src/api.ts | 40 | ||||
-rw-r--r-- | src/duration.ts | 155 | ||||
-rw-r--r-- | src/email.ts | 275 | ||||
-rw-r--r-- | src/job.ts | 25 | ||||
-rw-r--r-- | src/logger.ts | 10 |
5 files changed, 505 insertions, 0 deletions
diff --git a/src/api.ts b/src/api.ts new file mode 100644 index 0000000..d8a3008 --- /dev/null +++ b/src/api.ts @@ -0,0 +1,40 @@ +import { perform } from "./email"; +import 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}`)(); + + 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****"; + + ConsoleLogger.log( + `Received email job: ${JSON.stringify(jobInsensitive)}`, + )(); + + const performEmailTest = perform(job)(); + return await performEmailTest + .then(() => { + return Response.json({ success: true }); + }) + .catch((error) => { + return new Response(error.message, { + status: 400, + }); + }); + } + return new Response("404!", { status: 404 }); + }, + }); + ConsoleLogger.log(`Listening on port ${port}`)(); + return server; +}; diff --git a/src/duration.ts b/src/duration.ts new file mode 100644 index 0000000..3d1a44c --- /dev/null +++ b/src/duration.ts @@ -0,0 +1,155 @@ +import { flow, pipe } from "fp-ts/function"; +import * as E from "fp-ts/lib/Either"; +import * as S from "fp-ts/lib/string"; +import * as O from "fp-ts/lib/Option"; +import * as R from "fp-ts/lib/ReadonlyArray"; + +export type Duration = number; + +export enum DurationUnit { + MILLISECOND, + SECOND, + MINUTE, + HOUR, +} +const durationUnitMap: Record<string, DurationUnit> = { + ms: DurationUnit.MILLISECOND, + milliseconds: DurationUnit.MILLISECOND, + sec: DurationUnit.SECOND, + seconds: DurationUnit.SECOND, + min: DurationUnit.MINUTE, + minutes: DurationUnit.MINUTE, + hr: DurationUnit.HOUR, + hour: DurationUnit.HOUR, + hours: DurationUnit.HOUR, +}; +const getDurationUnit = (key: string): O.Option<DurationUnit> => + O.fromNullable(durationUnitMap[key.toLowerCase()]); + +export const getMs = (duration: Duration): number => duration; +export const getSeconds = (duration: Duration): number => duration / 1000; +export const getMinutes = (duration: Duration): number => + getSeconds(duration) / 60; +export const getHours = (duration: Duration): number => + getMinutes(duration) / 60; +export const format = (duration: Duration): string => { + const ms = getMs(duration) % 1000; + const seconds = getSeconds(duration) % 60; + const minutes = getMinutes(duration) % 60; + const hours = getHours(duration); + + return ( + [hours, minutes, seconds] + .map((x) => Math.floor(x).toString().padStart(2, "0")) + .join(":") + + "." + + ms.toString().padStart(3, "0") + ); +}; + +export interface DurationBuilder { + readonly millis: number; + readonly seconds: number; + readonly minutes: number; + readonly hours: number; +} +export const createDurationBuilder = (): DurationBuilder => ({ + millis: 0, + seconds: 0, + minutes: 0, + hours: 0, +}); + +export type DurationBuilderField<T> = ( + arg: T, +) => (builder: DurationBuilder) => DurationBuilder; + +export const withMillis: DurationBuilderField<number> = + (millis) => (builder) => ({ + ...builder, + millis, + }); + +export const withSeconds: DurationBuilderField<number> = + (seconds) => (builder) => ({ + ...builder, + seconds, + }); + +export const withMinutes: DurationBuilderField<number> = + (minutes) => (builder) => ({ + ...builder, + minutes, + }); + +export const withHours: DurationBuilderField<number> = + (hours) => (builder) => ({ + ...builder, + hours, + }); + +export const build = (builder: DurationBuilder): Duration => + builder.millis + + builder.seconds * 1000 + + builder.minutes * 60 * 1000 + + builder.hours * 60 * 60 * 1000; + +export const parse = (duration: string): E.Either<string, Duration> => { + const parts = pipe( + duration, + S.split(" "), + R.map(S.trim), + R.filter((part) => !S.isEmpty(part)), + ); + + const valueUnitPairs = pipe( + parts, + R.mapWithIndex((i, part) => { + const isUnit = i % 2 !== 0; + if (!isUnit) return E.right(O.none); + + const value = Number(parts[i - 1]); + if (isNaN(value)) return E.left(`bad value: "${parts[i - 1]}"`); + + const unit = getDurationUnit(part); + if (O.isNone(unit)) return E.left(`unknown duration type: ${part}`); + + return E.right(O.some([unit.value, value] as [DurationUnit, number])); + }), + E.sequenceArray, + E.map( + flow( + R.filter(O.isSome), + R.map(({ value }) => value), + ), + ), + ); + + return pipe( + valueUnitPairs, + E.flatMap( + R.reduce( + E.of<string, DurationBuilder>(createDurationBuilder()), + (builderEither, [unit, value]) => + pipe( + builderEither, + E.chain((builder) => { + switch (unit) { + case DurationUnit.MILLISECOND: + return E.right(withMillis(value)(builder)); + case DurationUnit.SECOND: + return E.right(withSeconds(value)(builder)); + case DurationUnit.MINUTE: + return E.right(withMinutes(value)(builder)); + case DurationUnit.HOUR: + return E.right(withHours(value)(builder)); + default: + return E.left(`unknown unit: ${unit}`); + } + }), + ), + ), + ), + E.map(build), + ); +}; diff --git a/src/email.ts b/src/email.ts new file mode 100644 index 0000000..a017aac --- /dev/null +++ b/src/email.ts @@ -0,0 +1,275 @@ +import type { EmailFromInstruction, EmailJob, EmailToInstruction } from "./job"; +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 { + 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 } from "./logger"; + +interface ImapClientI { + fetchAll: ( + range: string, + options: FetchQueryObject, + ) => Promise<FetchMessageObject[]>; + connect: () => Promise<void>; + getMailboxLock: (mailbox: string) => Promise<MailboxLockObject>; + messageDelete: (uids: number[]) => Promise<boolean>; + close: () => void; +} + +type Email = { + from: string; + to: string; + subject: string; + text: string; +}; + +class ErrorWithLock extends Error { + lock: O.Option<MailboxLockObject>; + constructor(message: string, lock?: MailboxLockObject) { + super(message); + this.lock = O.fromNullable(lock); + } +} +const ToErrorWithLock = (lock?: MailboxLockObject) => (error: unknown) => + new ErrorWithLock( + error instanceof Error ? error.message : "Unknown error", + lock, + ); + +/** + * Generate a unique email. + * @param from is the email to send from. + * @param to is the email to send to. + * @returns an {@link Email}. + */ +type EmailGenerator = ( + from: EmailFromInstruction, + to: EmailToInstruction, +) => IO.IO<Email>; +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<Error, Email>; +const getSendTransport: GetSendEmail = ({ + username, + password, + server, + send_port, +}) => { + const transport = createTransport({ + host: server, + port: send_port, + auth: { + user: username, + pass: password, + }, + tls: { + rejectUnauthorized: false, + }, + }); + return (email: Email) => + TE.tryCatch( + () => + new Promise<Email>((resolve, reject) => + transport.sendMail(email, (error) => { + if (error) { + reject(error); + } else { + resolve(email); + } + }), + ), + toError, + ); +}; + +/** + * Get an Imap client connected to a mailbox. + * @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<Error, ImapClientI>; +const getImap: GetImapClient = ({ username, password, server, read_port }) => { + const imap = new ImapFlow({ + logger: false, + host: server, + port: read_port, + secure: true, + auth: { + user: username, + pass: password, + }, + }); + return TE.tryCatch(() => imap.connect().then(() => imap), toError); +}; + +/** + * @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<Error, FetchMessageObject[]> => + TE.tryCatch( + () => + imap.fetchAll("*", { + uid: true, + envelope: true, + headers: true, + bodyParts: ["text"], + }), + toError, + ); + +/** + * Curry a function to check if a message matches an email. + * @param email is the email to match. + * @returns a function that takes a message and returns true if it matches the email. + */ +type EmailMatcher = (email: Email) => (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 headers = message.headers.toLocaleString(); + const fromMatches = headers.includes(`Return-Path: <${email.from}>`); + const toMatches = headers.includes(`Delivered-To: ${email.to}`); + return subjectMatches && bodyMatches && fromMatches && toMatches; +}; + +/** + * Find an email in the inbox. + * @param imap is the Imap client to search with. + * @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. + * @returns a Right(number) if the email was found, else a Left(error). + */ +type FindEmailUidInInbox = ( + imap: ImapClientI, + equalsEmail: (message: FetchMessageObject) => boolean, + retries: number, + pollIntervalMs: number, +) => TE.TaskEither<Error, number>; +const findEmailUidInInbox: FindEmailUidInInbox = ( + imap, + equalsEmail, + retries, + pollIntervalMs, +) => + pipe( + fetchMessages(imap), + TE.flatMap((messages) => { + const message = messages.find(equalsEmail); + if (message) { + return TE.right(message.uid); + } + return TE.left(new Error("Email message not found")); + }), + TE.fold( + (e) => + pipe( + TE.fromIO(ConsoleLogger.log(`failed; ${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.of, + ), + ); + +export type EmailJobDependencies = { + generateEmailImpl: EmailGenerator; + getSendImpl: GetSendEmail; + getImapImpl: GetImapClient; + findEmailUidInInboxImpl: FindEmailUidInInbox; + matchesEmailImpl: EmailMatcher; +}; + +/** + * Perform an email job. + * @param job is the job to perform. + */ +export const perform = ( + { from, to, readRetry: { retries, interval } }: EmailJob, + { + generateEmailImpl = generateEmail, + getSendImpl = getSendTransport, + getImapImpl = getImap, + findEmailUidInInboxImpl = findEmailUidInInbox, + matchesEmailImpl = matchesEmail, + }: Partial<EmailJobDependencies> = {}, +): TE.TaskEither<Error, boolean> => + pipe( + // arrange. + TE.fromIO(generateEmailImpl(from, to)), + TE.bindTo("email"), + // act. + 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()), + ), + // "assert". + TE.bind("uid", ({ imap, email, mailboxLock }) => + pipe( + findEmailUidInInboxImpl( + imap, + matchesEmailImpl(email), + retries, + interval, + ), + TE.mapLeft(ToErrorWithLock(mailboxLock)), + ), + ), + // cleanup. + TE.bind("deleted", ({ imap, uid, mailboxLock }) => + TE.tryCatch( + // () => imap.messageDelete([uid], { uid: true }), + () => imap.messageDelete([uid]), + ToErrorWithLock(mailboxLock), + ), + ), + TE.fold( + (e) => { + if (O.isSome(e.lock)) { + e.lock.value.release(); + } + return TE.left(e); + }, + ({ mailboxLock, deleted }) => { + mailboxLock.release(); + return TE.right(deleted); + }, + ), + ); diff --git a/src/job.ts b/src/job.ts new file mode 100644 index 0000000..2beabca --- /dev/null +++ b/src/job.ts @@ -0,0 +1,25 @@ +export interface EmailInstruction { + email: string; + username: string; + password: string; + server: string; +} + +export interface EmailFromInstruction extends EmailInstruction { + send_port: number; +} + +export interface EmailToInstruction extends EmailInstruction { + read_port: number; +} + +export interface EmailJob { + from: EmailFromInstruction; + to: EmailToInstruction; + readRetry: Retry; +} + +export interface Retry { + retries: number; + interval: number; +} diff --git a/src/logger.ts b/src/logger.ts new file mode 100644 index 0000000..05d9fd9 --- /dev/null +++ b/src/logger.ts @@ -0,0 +1,10 @@ +import type { IO } from "fp-ts/lib/IO"; + +export interface Logger { + log: (message: string) => IO<void>; +} + +export const ConsoleLogger: Logger = { + log: (message: string) => () => + console.log(`[${new Date().toUTCString()}] ` + message), +}; |