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