initial commit

This commit is contained in:
Elizabeth Hunt 2024-12-14 23:53:26 -08:00
commit 5b790723fa
Signed by: simponic
GPG Key ID: 2909B9A7FF6213EE
14 changed files with 986 additions and 0 deletions

21
.drone.yml Normal file
View 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
View 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
View File

@ -0,0 +1,6 @@
FROM oven/bun
COPY . /app
WORKDIR /app/
RUN bun install
RUN bun test
CMD bun run /app/index.ts

BIN
bun.lockb Executable file

Binary file not shown.

3
index.ts Normal file
View File

@ -0,0 +1,3 @@
import { main } from "./src/api";
main(parseInt(process.env.PORT ?? "3000"));

18
package.json Normal file
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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()),
)();
});