add a simple email job
This commit is contained in:
		
						commit
						452c41a75a
					
				|  | @ -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 | ||||
|  | @ -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. | ||||
|  | @ -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>()); | ||||
|  | @ -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" | ||||
|   } | ||||
| } | ||||
|  | @ -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); | ||||
|       }, | ||||
|     ), | ||||
|   ); | ||||
|  | @ -0,0 +1,2 @@ | |||
| export * from "./job"; | ||||
| export * from "./testable"; | ||||
|  | @ -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; | ||||
|  | @ -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>; | ||||
|  | @ -0,0 +1,7 @@ | |||
| import type { Publisher } from "./publisher"; | ||||
| 
 | ||||
| export interface Config { | ||||
|   result_publishers: Publisher[]; | ||||
|   dns: string[]; | ||||
|   timeout: string; | ||||
| } | ||||
|  | @ -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; | ||||
| } | ||||
|  | @ -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) | ||||
|   ); | ||||
| }; | ||||
|  | @ -0,0 +1 @@ | |||
| export * as D from "./duration"; | ||||
|  | @ -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), | ||||
| }; | ||||
|  | @ -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 | ||||
|   } | ||||
| } | ||||
|  | @ -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()), | ||||
|   )(); | ||||
| }); | ||||
|  | @ -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"')); | ||||
|   }); | ||||
| }); | ||||
		Loading…
	
		Reference in New Issue