initial commit
This commit is contained in:
commit
5b790723fa
21
.drone.yml
Normal file
21
.drone.yml
Normal file
@ -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
|
175
.gitignore
vendored
Normal file
175
.gitignore
vendored
Normal file
@ -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
|
6
Dockerfile
Normal file
6
Dockerfile
Normal file
@ -0,0 +1,6 @@
|
||||
FROM oven/bun
|
||||
COPY . /app
|
||||
WORKDIR /app/
|
||||
RUN bun install
|
||||
RUN bun test
|
||||
CMD bun run /app/index.ts
|
3
index.ts
Normal file
3
index.ts
Normal file
@ -0,0 +1,3 @@
|
||||
import { main } from "./src/api";
|
||||
|
||||
main(parseInt(process.env.PORT ?? "3000"));
|
18
package.json
Normal file
18
package.json
Normal file
@ -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"
|
||||
}
|
||||
}
|
40
src/api.ts
Normal file
40
src/api.ts
Normal file
@ -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;
|
||||
};
|
155
src/duration.ts
Normal file
155
src/duration.ts
Normal file
@ -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<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 type DurationBuilderField<T> = (
|
||||
arg: T,
|
||||
) => (builder: DurationBuilder) => DurationBuilder;
|
||||
|
||||
export const withMillis: DurationBuilderField<number> =
|
||||
(millis) => (builder) => ({
|
||||
...builder,
|
||||
millis,
|
||||
});
|
||||
|
||||
export const withSeconds: DurationBuilderField<number> =
|
||||
(seconds) => (builder) => ({
|
||||
...builder,
|
||||
seconds,
|
||||
});
|
||||
|
||||
export const withMinutes: DurationBuilderField<number> =
|
||||
(minutes) => (builder) => ({
|
||||
...builder,
|
||||
minutes,
|
||||
});
|
||||
|
||||
export const withHours: DurationBuilderField<number> =
|
||||
(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<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),
|
||||
);
|
||||
};
|
275
src/email.ts
Normal file
275
src/email.ts
Normal file
@ -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<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);
|
||||
},
|
||||
),
|
||||
);
|
25
src/job.ts
Normal file
25
src/job.ts
Normal file
@ -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;
|
||||
}
|
10
src/logger.ts
Normal file
10
src/logger.ts
Normal file
@ -0,0 +1,10 @@
|
||||
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(`[${new Date().toUTCString()}] ` + message),
|
||||
};
|
27
tsconfig.json
Normal file
27
tsconfig.json
Normal file
@ -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
|
||||
}
|
||||
}
|
78
tst/duration.spec.ts
Normal file
78
tst/duration.spec.ts
Normal file
@ -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"'));
|
||||
});
|
||||
});
|
153
tst/email.spec.ts
Normal file
153
tst/email.spec.ts
Normal file
@ -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<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()),
|
||||
)();
|
||||
});
|
Loading…
Reference in New Issue
Block a user