summaryrefslogtreecommitdiff
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
downloaduptime-4fd40b1f9de400a5d859789e1dad3e1a4ba6587c.tar.gz
uptime-4fd40b1f9de400a5d859789e1dad3e1a4ba6587c.zip
initial commit
-rw-r--r--.drone.yml21
-rw-r--r--.gitignore175
-rw-r--r--Dockerfile6
-rw-r--r--README.md1
-rwxr-xr-xbun.lockbbin0 -> 14330 bytes
-rw-r--r--index.ts3
-rw-r--r--package.json18
-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
-rw-r--r--tsconfig.json27
-rw-r--r--tst/duration.spec.ts78
-rw-r--r--tst/email.spec.ts153
15 files changed, 987 insertions, 0 deletions
diff --git a/.drone.yml b/.drone.yml
new file mode 100644
index 0000000..d6d2cfe
--- /dev/null
+++ b/.drone.yml
@@ -0,0 +1,21 @@
+---
+kind: pipeline
+type: docker
+name: deploy
+
+steps:
+ - name: docker
+ image: plugins/docker
+ settings:
+ username:
+ from_secret: gitea_packpub_username
+ password:
+ from_secret: gitea_packpub_password
+ registry: git.simponic.xyz
+ repo: git.simponic.xyz/simponic/uptime
+
+trigger:
+ branch:
+ - main
+ event:
+ - push
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..9b1ee42
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,175 @@
+# Based on https://raw.githubusercontent.com/github/gitignore/main/Node.gitignore
+
+# Logs
+
+logs
+_.log
+npm-debug.log_
+yarn-debug.log*
+yarn-error.log*
+lerna-debug.log*
+.pnpm-debug.log*
+
+# Caches
+
+.cache
+
+# Diagnostic reports (https://nodejs.org/api/report.html)
+
+report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json
+
+# Runtime data
+
+pids
+_.pid
+_.seed
+*.pid.lock
+
+# Directory for instrumented libs generated by jscoverage/JSCover
+
+lib-cov
+
+# Coverage directory used by tools like istanbul
+
+coverage
+*.lcov
+
+# nyc test coverage
+
+.nyc_output
+
+# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
+
+.grunt
+
+# Bower dependency directory (https://bower.io/)
+
+bower_components
+
+# node-waf configuration
+
+.lock-wscript
+
+# Compiled binary addons (https://nodejs.org/api/addons.html)
+
+build/Release
+
+# Dependency directories
+
+node_modules/
+jspm_packages/
+
+# Snowpack dependency directory (https://snowpack.dev/)
+
+web_modules/
+
+# TypeScript cache
+
+*.tsbuildinfo
+
+# Optional npm cache directory
+
+.npm
+
+# Optional eslint cache
+
+.eslintcache
+
+# Optional stylelint cache
+
+.stylelintcache
+
+# Microbundle cache
+
+.rpt2_cache/
+.rts2_cache_cjs/
+.rts2_cache_es/
+.rts2_cache_umd/
+
+# Optional REPL history
+
+.node_repl_history
+
+# Output of 'npm pack'
+
+*.tgz
+
+# Yarn Integrity file
+
+.yarn-integrity
+
+# dotenv environment variable files
+
+.env
+.env.development.local
+.env.test.local
+.env.production.local
+.env.local
+
+# parcel-bundler cache (https://parceljs.org/)
+
+.parcel-cache
+
+# Next.js build output
+
+.next
+out
+
+# Nuxt.js build / generate output
+
+.nuxt
+dist
+
+# Gatsby files
+
+# Comment in the public line in if your project uses Gatsby and not Next.js
+
+# https://nextjs.org/blog/next-9-1#public-directory-support
+
+# public
+
+# vuepress build output
+
+.vuepress/dist
+
+# vuepress v2.x temp and cache directory
+
+.temp
+
+# Docusaurus cache and generated files
+
+.docusaurus
+
+# Serverless directories
+
+.serverless/
+
+# FuseBox cache
+
+.fusebox/
+
+# DynamoDB Local files
+
+.dynamodb/
+
+# TernJS port file
+
+.tern-port
+
+# Stores VSCode versions used for testing VSCode extensions
+
+.vscode-test
+
+# yarn v2
+
+.yarn/cache
+.yarn/unplugged
+.yarn/build-state.yml
+.yarn/install-state.gz
+.pnp.*
+
+# IntelliJ based IDEs
+.idea
+
+# Finder (MacOS) folder config
+.DS_Store
diff --git a/Dockerfile b/Dockerfile
new file mode 100644
index 0000000..10ced2a
--- /dev/null
+++ b/Dockerfile
@@ -0,0 +1,6 @@
+FROM oven/bun
+COPY . /app
+WORKDIR /app/
+RUN bun install
+RUN bun test
+CMD bun run /app/index.ts
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..864045d
--- /dev/null
+++ b/README.md
@@ -0,0 +1 @@
+just a few scripts for uptime-kuma.
diff --git a/bun.lockb b/bun.lockb
new file mode 100755
index 0000000..419813a
--- /dev/null
+++ b/bun.lockb
Binary files differ
diff --git a/index.ts b/index.ts
new file mode 100644
index 0000000..a352aee
--- /dev/null
+++ b/index.ts
@@ -0,0 +1,3 @@
+import { main } from "./src/api";
+
+main(parseInt(process.env.PORT ?? "3000"));
diff --git a/package.json b/package.json
new file mode 100644
index 0000000..9a99003
--- /dev/null
+++ b/package.json
@@ -0,0 +1,18 @@
+{
+ "name": "uptime",
+ "module": "index.ts",
+ "type": "module",
+ "devDependencies": {
+ "@types/bun": "latest",
+ "@types/imapflow": "^1.0.19",
+ "@types/nodemailer": "^6.4.15"
+ },
+ "peerDependencies": {
+ "typescript": "^5.0.0"
+ },
+ "dependencies": {
+ "fp-ts": "^2.16.7",
+ "imapflow": "^1.0.164",
+ "nodemailer": "^6.9.14"
+ }
+}
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),
+};
diff --git a/tsconfig.json b/tsconfig.json
new file mode 100644
index 0000000..238655f
--- /dev/null
+++ b/tsconfig.json
@@ -0,0 +1,27 @@
+{
+ "compilerOptions": {
+ // Enable latest features
+ "lib": ["ESNext", "DOM"],
+ "target": "ESNext",
+ "module": "ESNext",
+ "moduleDetection": "force",
+ "jsx": "react-jsx",
+ "allowJs": true,
+
+ // Bundler mode
+ "moduleResolution": "bundler",
+ "allowImportingTsExtensions": true,
+ "verbatimModuleSyntax": true,
+ "noEmit": true,
+
+ // Best practices
+ "strict": true,
+ "skipLibCheck": true,
+ "noFallthroughCasesInSwitch": true,
+
+ // Some stricter flags (disabled by default)
+ "noUnusedLocals": false,
+ "noUnusedParameters": false,
+ "noPropertyAccessFromIndexSignature": false
+ }
+}
diff --git a/tst/duration.spec.ts b/tst/duration.spec.ts
new file mode 100644
index 0000000..bcd50f5
--- /dev/null
+++ b/tst/duration.spec.ts
@@ -0,0 +1,78 @@
+import { pipe } from "fp-ts/function";
+import * as E from "fp-ts/Either";
+import { describe, test, expect } from "bun:test";
+import * as D from "../src/duration";
+
+describe("Duration Utility", () => {
+ test("get unit should convert correctly", () => {
+ expect(D.getMs(1000)).toBe(1000);
+ expect(D.getSeconds(1000)).toBe(1);
+ expect(D.getMinutes(60000)).toBe(1);
+ expect(D.getHours(3600000)).toBe(1);
+ });
+
+ test("format should format duration correctly", () => {
+ expect(D.format(3600000 + 237 + 5 * 60 * 1000)).toBe("01:05:00.237");
+ });
+});
+
+describe("DurationBuilder", () => {
+ test("createDurationBuilder should create a builder with zero values", () => {
+ const builder = D.createDurationBuilder();
+ expect(builder.millis).toBe(0);
+ expect(builder.seconds).toBe(0);
+ expect(builder.minutes).toBe(0);
+ expect(builder.hours).toBe(0);
+ });
+
+ test("withMillis should set fields correctly and with precedence", () => {
+ const builder = pipe(
+ D.createDurationBuilder(),
+ D.withMillis(0),
+ D.withSeconds(20),
+ D.withMinutes(30),
+ D.withHours(40),
+ D.withMillis(10),
+ );
+ expect(builder.millis).toBe(10);
+ expect(builder.seconds).toBe(20);
+ expect(builder.minutes).toBe(30);
+ expect(builder.hours).toBe(40);
+ });
+
+ test("build should calculate total duration correctly", () => {
+ const duration = pipe(
+ D.createDurationBuilder(),
+ D.withMillis(10),
+ D.withSeconds(20),
+ D.withMinutes(30),
+ D.withHours(40),
+ D.build,
+ );
+ expect(duration).toBe(
+ 10 + 20 * 1000 + 30 * 60 * 1000 + 40 * 60 * 60 * 1000,
+ );
+ });
+});
+
+describe("parse", () => {
+ test("should return right for a valid duration", () => {
+ expect(D.parse("10 seconds 1 hr 30 min")).toEqual(
+ E.right(1 * 60 * 60 * 1000 + 30 * 60 * 1000 + 10 * 1000),
+ );
+ });
+
+ test("should operate with order", () => {
+ expect(D.parse("1 hr 30 min 2 hours")).toEqual(
+ E.right(2 * 60 * 60 * 1000 + 30 * 60 * 1000),
+ );
+ });
+
+ test("returns left for unknown duration unit", () => {
+ expect(D.parse("1 xyz")).toEqual(E.left("unknown duration type: xyz"));
+ });
+
+ test("return left for invalid number", () => {
+ expect(D.parse("abc ms")).toEqual(E.left('bad value: "abc"'));
+ });
+});
diff --git a/tst/email.spec.ts b/tst/email.spec.ts
new file mode 100644
index 0000000..5f2aa90
--- /dev/null
+++ b/tst/email.spec.ts
@@ -0,0 +1,153 @@
+import { mock, test, expect } from "bun:test";
+import * as TE from "fp-ts/lib/TaskEither";
+
+import { constVoid, pipe } from "fp-ts/lib/function";
+import type { EmailFromInstruction, EmailToInstruction } from "../src/job";
+import { perform, type EmailJobDependencies } from "../src/email";
+
+const from: EmailFromInstruction = {
+ send_port: 465,
+ email: "test@localhost",
+ username: "test",
+ password: "password",
+ server: "localhost",
+};
+
+const to: EmailToInstruction = {
+ read_port: 993,
+ email: "test@localhost",
+ username: "test",
+ password: "password",
+ server: "localhost",
+};
+
+const getMocks = () => {
+ const lock = {
+ path: "INBOX",
+ release: mock(() => constVoid()),
+ };
+ const imap = {
+ fetchAll: mock(() => Promise.resolve([])),
+ connect: mock(() => Promise.resolve()),
+ getMailboxLock: mock(() => Promise.resolve(lock)),
+ messageDelete: mock(() => Promise.resolve(true)),
+ close: mock(() => constVoid()),
+ };
+
+ const mockDependencies: Partial<EmailJobDependencies> = {
+ getImapImpl: () => TE.right(imap),
+ getSendImpl: mock(() => (email: any) => TE.right(email)),
+ matchesEmailImpl: mock(() => () => true),
+ };
+
+ return { lock, imap, mockDependencies };
+};
+
+test("retries until message is in inbox", async () => {
+ const { imap, mockDependencies } = getMocks();
+
+ const retry = { retries: 3, interval: 400 };
+ const emailJob = { from, to, readRetry: retry };
+
+ let attempts = 0;
+ const messageInInbox = { uid: 1 } as any;
+ imap.fetchAll = mock(() => {
+ attempts++;
+ if (attempts === 3) {
+ return Promise.resolve([messageInInbox] as any);
+ }
+ return Promise.resolve([]);
+ });
+ mockDependencies.matchesEmailImpl = mock(
+ (_: any) => (message: any) => message.uid === 1,
+ );
+
+ await pipe(
+ perform(emailJob, mockDependencies),
+ TE.map((x) => {
+ expect(x).toBeTruthy();
+ expect(attempts).toBe(3);
+ }),
+ TE.mapLeft(() => expect(false).toBeTruthy()),
+ )();
+});
+
+test("failure to send message goes left", async () => {
+ const { mockDependencies } = getMocks();
+
+ const emailJob = { from, to, readRetry: { retries: 1, interval: 1 } };
+ mockDependencies.getSendImpl = mock(() => () => TE.left(new Error("fail")));
+
+ await pipe(
+ perform(emailJob, mockDependencies),
+ TE.map(() => expect(false).toBeTruthy()),
+ TE.mapLeft((e) => {
+ expect(e.message).toBe("fail");
+ }),
+ )();
+});
+
+test("goes left when message not ever received", async () => {
+ const { imap, mockDependencies } = getMocks();
+
+ const emailJob = { from, to, readRetry: { retries: 3, interval: 1 } };
+ imap.fetchAll = mock(() => Promise.resolve([]));
+
+ expect(
+ await pipe(
+ perform(emailJob, mockDependencies),
+ TE.map(() => expect(false).toBeTruthy()),
+ TE.mapLeft((e) => {
+ expect(e.message).toBe("Email message not found");
+ }),
+ )(),
+ );
+});
+
+test("releases lock on left", async () => {
+ const { lock, imap, mockDependencies } = getMocks();
+
+ const emailJob = { from, to, readRetry: { retries: 1, interval: 1 } };
+ imap.fetchAll = mock(() => Promise.resolve([]));
+
+ await pipe(
+ perform(emailJob, mockDependencies),
+ TE.map(() => expect(false).toBeTruthy()),
+ TE.mapLeft(() => {
+ expect(imap.getMailboxLock).toHaveBeenCalledTimes(1);
+ expect(lock.release).toHaveBeenCalledTimes(1);
+ }),
+ )();
+});
+
+test("releases lock on right", async () => {
+ const { lock, imap, mockDependencies } = getMocks();
+
+ const emailJob = { from, to, readRetry: { retries: 1, interval: 1 } };
+ mockDependencies.findEmailUidInInboxImpl = () => TE.right(1);
+
+ await pipe(
+ perform(emailJob, mockDependencies),
+ TE.map(() => {
+ expect(imap.getMailboxLock).toHaveBeenCalledTimes(1);
+ expect(lock.release).toHaveBeenCalledTimes(1);
+ }),
+ TE.mapLeft(() => expect(false).toBeTruthy()),
+ )();
+});
+
+test("cleans up sent messages from inbox", async () => {
+ const { imap, mockDependencies } = getMocks();
+
+ const emailJob = { from, to, readRetry: { retries: 1, interval: 1 } };
+ mockDependencies.findEmailUidInInboxImpl = () => TE.right(1);
+
+ await pipe(
+ perform(emailJob, mockDependencies),
+ TE.map(() => {
+ expect(imap.messageDelete).toHaveBeenCalledTimes(1);
+ expect(imap.messageDelete).toHaveBeenCalledWith([1]);
+ }),
+ TE.mapLeft(() => expect(false).toBeTruthy()),
+ )();
+});