From 4fd40b1f9de400a5d859789e1dad3e1a4ba6587c Mon Sep 17 00:00:00 2001 From: Elizabeth Hunt Date: Sat, 14 Dec 2024 23:53:26 -0800 Subject: [PATCH] initial commit --- .drone.yml | 21 ++++ .gitignore | 175 +++++++++++++++++++++++++++ Dockerfile | 6 + README.md | 1 + bun.lockb | Bin 0 -> 14330 bytes index.ts | 3 + package.json | 18 +++ src/api.ts | 40 +++++++ src/duration.ts | 155 ++++++++++++++++++++++++ src/email.ts | 275 +++++++++++++++++++++++++++++++++++++++++++ src/job.ts | 25 ++++ src/logger.ts | 10 ++ tsconfig.json | 27 +++++ tst/duration.spec.ts | 78 ++++++++++++ tst/email.spec.ts | 153 ++++++++++++++++++++++++ 15 files changed, 987 insertions(+) create mode 100644 .drone.yml create mode 100644 .gitignore create mode 100644 Dockerfile create mode 100644 README.md create mode 100755 bun.lockb create mode 100644 index.ts create mode 100644 package.json create mode 100644 src/api.ts create mode 100644 src/duration.ts create mode 100644 src/email.ts create mode 100644 src/job.ts create mode 100644 src/logger.ts create mode 100644 tsconfig.json create mode 100644 tst/duration.spec.ts create mode 100644 tst/email.spec.ts 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 0000000000000000000000000000000000000000..419813af56f36cd02c8d19b74b5bc3cda62466ca GIT binary patch literal 14330 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 literal 0 HcmV?d00001 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 = { + 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 => + 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 = ( + arg: T, +) => (builder: DurationBuilder) => DurationBuilder; + +export const withMillis: DurationBuilderField = + (millis) => (builder) => ({ + ...builder, + millis, + }); + +export const withSeconds: DurationBuilderField = + (seconds) => (builder) => ({ + ...builder, + seconds, + }); + +export const withMinutes: DurationBuilderField = + (minutes) => (builder) => ({ + ...builder, + minutes, + }); + +export const withHours: DurationBuilderField = + (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 => { + 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(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; + connect: () => Promise; + getMailboxLock: (mailbox: string) => Promise; + messageDelete: (uids: number[]) => Promise; + close: () => void; +} + +type Email = { + from: string; + to: string; + subject: string; + text: string; +}; + +class ErrorWithLock extends Error { + lock: O.Option; + 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; +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; +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((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; +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 => + 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; +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 = {}, +): TE.TaskEither => + 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; +} + +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 = { + 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()), + )(); +});