summaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
authorElizabeth Hunt <elizabeth.hunt@simponic.xyz>2024-12-14 23:53:26 -0800
committerElizabeth Hunt <elizabeth.hunt@simponic.xyz>2024-12-14 23:55:51 -0800
commit4fd40b1f9de400a5d859789e1dad3e1a4ba6587c (patch)
tree74fbae949aa3fb9711c06e31cb6649e90a8cdb97 /src
downloaduptime-4fd40b1f9de400a5d859789e1dad3e1a4ba6587c.tar.gz
uptime-4fd40b1f9de400a5d859789e1dad3e1a4ba6587c.zip
initial commit
Diffstat (limited to 'src')
-rw-r--r--src/api.ts40
-rw-r--r--src/duration.ts155
-rw-r--r--src/email.ts275
-rw-r--r--src/job.ts25
-rw-r--r--src/logger.ts10
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),
+};