This commit is contained in:
commit
4fd40b1f9d
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