From 5b790723fa0a9a751908ddb43afbd9b114afdbf0 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 + 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 ++++++++++++++++++++++++ 14 files changed, 986 insertions(+) create mode 100644 .drone.yml create mode 100644 .gitignore create mode 100644 Dockerfile 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/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()), + )(); +});