aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorElizabeth Hunt <elizabeth.hunt@simponic.xyz>2024-07-21 15:41:56 -0700
committerElizabeth Hunt <elizabeth.hunt@simponic.xyz>2024-07-21 15:41:56 -0700
commit452c41a75aa2f9036614d677fda3867221db959e (patch)
tree98f2ee2ccd0858a120c5cd618641615f0fc5e026
downloadmon.hatecomputers.club-452c41a75aa2f9036614d677fda3867221db959e.tar.gz
mon.hatecomputers.club-452c41a75aa2f9036614d677fda3867221db959e.zip
add a simple email job
-rw-r--r--.gitignore178
-rw-r--r--README.md15
-rwxr-xr-xbun.lockbbin0 -> 19655 bytes
-rw-r--r--index.ts12
-rw-r--r--package.json18
-rw-r--r--src/canary/email.ts275
-rw-r--r--src/canary/index.ts2
-rw-r--r--src/canary/job.ts41
-rw-r--r--src/canary/testable.ts24
-rw-r--r--src/config.ts7
-rw-r--r--src/index.ts0
-rw-r--r--src/publisher/index.ts27
-rw-r--r--src/util/duration.ts155
-rw-r--r--src/util/index.ts1
-rw-r--r--src/util/logger.ts9
-rw-r--r--tsconfig.json27
-rw-r--r--tst/canary/email.spec.ts155
-rw-r--r--tst/config.spec.ts0
-rw-r--r--tst/util/duration.spec.ts78
19 files changed, 1024 insertions, 0 deletions
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..ea6e4ad
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,178 @@
+# 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
+
+config.json
+tests.json \ No newline at end of file
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..e85f298
--- /dev/null
+++ b/README.md
@@ -0,0 +1,15 @@
+# canary
+
+To install dependencies:
+
+```bash
+bun install
+```
+
+To run:
+
+```bash
+bun run index.ts
+```
+
+This project was created using `bun init` in bun v1.1.3. [Bun](https://bun.sh) is a fast all-in-one JavaScript runtime.
diff --git a/bun.lockb b/bun.lockb
new file mode 100755
index 0000000..afe7a4c
--- /dev/null
+++ b/bun.lockb
Binary files differ
diff --git a/index.ts b/index.ts
new file mode 100644
index 0000000..4c181c3
--- /dev/null
+++ b/index.ts
@@ -0,0 +1,12 @@
+import * as S from "fp-ts/String";
+import * as M from "fp-ts/Map";
+import { getMonoid } from "fp-ts/lib/Array";
+
+enum DurationUnit {
+ MILLISECOND = "ms",
+ SECOND = "sec",
+ MINUTE = "min",
+ HOUR = "hr",
+}
+
+const unitMap = M.fromFoldable(S.Ord, getMonoid<String>());
diff --git a/package.json b/package.json
new file mode 100644
index 0000000..ad1f7c8
--- /dev/null
+++ b/package.json
@@ -0,0 +1,18 @@
+{
+ "name": "canary",
+ "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/canary/email.ts b/src/canary/email.ts
new file mode 100644
index 0000000..384212b
--- /dev/null
+++ b/src/canary/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 "../util/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/canary/index.ts b/src/canary/index.ts
new file mode 100644
index 0000000..1d42dec
--- /dev/null
+++ b/src/canary/index.ts
@@ -0,0 +1,2 @@
+export * from "./job";
+export * from "./testable";
diff --git a/src/canary/job.ts b/src/canary/job.ts
new file mode 100644
index 0000000..61af876
--- /dev/null
+++ b/src/canary/job.ts
@@ -0,0 +1,41 @@
+import { D } from "../util";
+
+export interface PingJob {
+ hosts: string[];
+}
+
+export interface Retry {
+ retries: number;
+ interval: D.Duration;
+}
+
+export interface HealthCheckJob {
+ healthcheck_routes: string[];
+}
+
+export interface DnsJob {
+ resolutions: { [key: string]: string };
+}
+
+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 type Job = EmailJob | PingJob | HealthCheckJob | DnsJob;
diff --git a/src/canary/testable.ts b/src/canary/testable.ts
new file mode 100644
index 0000000..d79aa99
--- /dev/null
+++ b/src/canary/testable.ts
@@ -0,0 +1,24 @@
+import type { TaskEither } from "fp-ts/lib/TaskEither";
+import type { Job } from "./job";
+import type { D } from "../util";
+
+export enum TestType {
+ EMAIL = "email",
+ PING = "ping",
+ HEALTHCHECK = "healthcheck",
+ DNS = "dns",
+}
+
+export interface Schedule {
+ every: D.Duration;
+ jitter: D.Duration;
+}
+
+export interface Test {
+ name: string;
+ type: TestType;
+ job: Job;
+ schedule: Schedule;
+}
+
+export type Testable<T extends Job> = (job: T) => TaskEither<Error, boolean>;
diff --git a/src/config.ts b/src/config.ts
new file mode 100644
index 0000000..dd96169
--- /dev/null
+++ b/src/config.ts
@@ -0,0 +1,7 @@
+import type { Publisher } from "./publisher";
+
+export interface Config {
+ result_publishers: Publisher[];
+ dns: string[];
+ timeout: string;
+}
diff --git a/src/index.ts b/src/index.ts
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/src/index.ts
diff --git a/src/publisher/index.ts b/src/publisher/index.ts
new file mode 100644
index 0000000..e721160
--- /dev/null
+++ b/src/publisher/index.ts
@@ -0,0 +1,27 @@
+import type { Duration, Result } from "../util";
+
+export enum PublisherType {
+ DISCORD = "discord",
+ NTFY = "ntfy",
+}
+
+export interface DiscordPost {
+ webhook: string;
+ role_id: string;
+}
+
+export interface NtfyPost {
+ webhook: string;
+}
+
+export type PublisherPost = DiscordPost | NtfyPost;
+
+export interface Publisher {
+ type: PublisherType;
+ at: Duration;
+ post: PublisherPost;
+}
+
+export interface ResultPublishable {
+ publish(testResult: Result<boolean, Error>): void;
+}
diff --git a/src/util/duration.ts b/src/util/duration.ts
new file mode 100644
index 0000000..9c10375
--- /dev/null
+++ b/src/util/duration.ts
@@ -0,0 +1,155 @@
+import { flow, pipe } from "fp-ts/function";
+import * as E from "fp-ts/Either";
+import * as S from "fp-ts/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 const withMillis =
+ (millis: number) =>
+ (builder: DurationBuilder): DurationBuilder => ({
+ ...builder,
+ millis,
+ });
+
+export const withSeconds =
+ (seconds: number) =>
+ (builder: DurationBuilder): DurationBuilder => ({
+ ...builder,
+ seconds,
+ });
+
+export const withMinutes =
+ (minutes: number) =>
+ (builder: DurationBuilder): DurationBuilder => ({
+ ...builder,
+ minutes,
+ });
+
+export const withHours =
+ (hours: number) =>
+ (builder: DurationBuilder): DurationBuilder => ({
+ ...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/util/index.ts b/src/util/index.ts
new file mode 100644
index 0000000..acf8771
--- /dev/null
+++ b/src/util/index.ts
@@ -0,0 +1 @@
+export * as D from "./duration";
diff --git a/src/util/logger.ts b/src/util/logger.ts
new file mode 100644
index 0000000..28e38f0
--- /dev/null
+++ b/src/util/logger.ts
@@ -0,0 +1,9 @@
+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(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/canary/email.spec.ts b/tst/canary/email.spec.ts
new file mode 100644
index 0000000..fa22e37
--- /dev/null
+++ b/tst/canary/email.spec.ts
@@ -0,0 +1,155 @@
+import { mock, test, expect, beforeEach } from "bun:test";
+import * as TE from "fp-ts/lib/TaskEither";
+import { perform, type EmailJobDependencies } from "../../src/canary/email";
+import type {
+ EmailFromInstruction,
+ EmailToInstruction,
+} from "../../src/canary";
+import { constVoid, pipe } from "fp-ts/lib/function";
+
+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()),
+ )();
+});
diff --git a/tst/config.spec.ts b/tst/config.spec.ts
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/tst/config.spec.ts
diff --git a/tst/util/duration.spec.ts b/tst/util/duration.spec.ts
new file mode 100644
index 0000000..de4af92
--- /dev/null
+++ b/tst/util/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 { D } from "../../src/util";
+
+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"'));
+ });
+});