summaryrefslogtreecommitdiff
path: root/src/email.ts
diff options
context:
space:
mode:
Diffstat (limited to 'src/email.ts')
-rw-r--r--src/email.ts351
1 files changed, 0 insertions, 351 deletions
diff --git a/src/email.ts b/src/email.ts
deleted file mode 100644
index 328afab..0000000
--- a/src/email.ts
+++ /dev/null
@@ -1,351 +0,0 @@
-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, type Logger } from "./logger";
-
-interface ImapClientI {
- fetchAll: (
- range: string,
- options: FetchQueryObject,
- ) => Promise<FetchMessageObject[]>;
- connect: () => Promise<void>;
- getMailboxLock: (mailbox: string) => Promise<MailboxLockObject>;
- messageDelete: (
- uids: number[],
- opts: Record<string, any>,
- ) => Promise<boolean>;
- logout: () => Promise<void>;
- mailboxClose: () => Promise<boolean>;
-}
-
-type Email = {
- from: string;
- to: string;
- subject: string;
- text: string;
-};
-
-class ErrorWithLock extends Error {
- lock: O.Option<MailboxLockObject>;
- imap: O.Option<ImapClientI>;
- constructor(message: string, lock?: MailboxLockObject, imap?: ImapClientI) {
- super(message);
- this.lock = O.fromNullable(lock);
- this.imap = O.fromNullable(imap);
- }
-}
-
-const ToErrorWithLock =
- (lock?: MailboxLockObject, imap?: ImapClientI) => (error: unknown) =>
- new ErrorWithLock(
- error instanceof Error ? error.message : "Unknown error",
- lock,
- imap,
- );
-
-/**
- * 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,
- logger: Logger,
-) => IO.IO<Email>;
-const generateEmail: EmailGenerator = (
- from: EmailFromInstruction,
- to: EmailToInstruction,
- logger: Logger,
-) =>
- pipe(
- IO.of(logger.info("Generating email...")),
- IO.chain(() =>
- IO.of({
- 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,
- logger: Logger,
-) => (email: Email) => TE.TaskEither<Error, Email>;
-const getSendTransport: GetSendEmail = (
- { username, password, server, send_port },
- _logger,
-) => {
- 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,
- logger: Logger,
-) => TE.TaskEither<Error, ImapClientI>;
-const getImap: GetImapClient = (
- { username, password, server, read_port },
- logger,
-) => {
- const imap = new ImapFlow({
- logger: false,
- host: server,
- port: read_port,
- secure: true,
- auth: {
- user: username,
- pass: password,
- },
- });
- return pipe(
- TE.fromIO(logger.info("Connecting to IMAP server...")),
- TE.flatMap(() =>
- TE.tryCatch(() => imap.connect().then(() => imap), toError),
- ),
- TE.tap(() => TE.fromIO(logger.info("Connected to IMAP server."))),
- TE.tapError((error) => TE.fromIO(logger.error(error.message))),
- );
-};
-
-/**
- * @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,
- logger: Logger,
-): TE.TaskEither<Error, FetchMessageObject[]> =>
- pipe(
- TE.fromIO(logger.info("Fetching messages...")),
- TE.chain(() =>
- TE.tryCatch(
- () =>
- imap.fetchAll("*", {
- uid: true,
- envelope: true,
- headers: true,
- bodyParts: ["text"],
- }),
- toError,
- ),
- ),
- TE.tap(() => TE.fromIO(logger.info("Fetched messages."))),
- TE.tapError((error) => TE.fromIO(logger.error(error.message))),
- );
-
-/**
- * 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.
- * @param logger is the logger instance.
- * @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,
- logger: Logger,
-) => TE.TaskEither<Error, number>;
-const findEmailUidInInbox: FindEmailUidInInbox = (
- imap,
- equalsEmail,
- retries,
- pollIntervalMs,
- logger,
-) =>
- pipe(
- fetchMessages(imap, logger),
- 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(
- logger.info(`Failed to find email; ${retries} retries left.`),
- ),
- TE.chain(() =>
- retries === 0
- ? TE.left(e)
- : T.delay(pollIntervalMs)(TE.right(null)),
- ),
- TE.chain(() =>
- findEmailUidInInbox(
- imap,
- equalsEmail,
- retries - 1,
- pollIntervalMs,
- logger,
- ),
- ),
- ),
- (s) =>
- pipe(
- s,
- TE.of,
- TE.tap(() => TE.fromIO(logger.info("Email succeeded"))),
- ),
- ),
- );
-
-export type EmailJobDependencies = {
- generateEmailImpl: EmailGenerator;
- getSendImpl: GetSendEmail;
- getImapImpl: GetImapClient;
- findEmailUidInInboxImpl: FindEmailUidInInbox;
- matchesEmailImpl: EmailMatcher;
-};
-
-/**
- * Perform an email job.
- * @param job is the job to perform.
- * @param logger is the logger instance.
- */
-export const perform = (
- { from, to, readRetry: { retries, interval } }: EmailJob,
- {
- generateEmailImpl = generateEmail,
- getSendImpl = getSendTransport,
- getImapImpl = getImap,
- findEmailUidInInboxImpl = findEmailUidInInbox,
- matchesEmailImpl = matchesEmail,
- }: Partial<EmailJobDependencies> = {},
- logger: Logger = ConsoleLogger,
-): TE.TaskEither<Error, boolean> =>
- pipe(
- // arrange.
- TE.fromIO(generateEmailImpl(from, to, logger)),
- TE.bindTo("email"),
- // act.
- TE.tap(({ email }) =>
- pipe(getSendImpl(from, logger)(email), TE.mapLeft(ToErrorWithLock())),
- ),
- TE.bind("imap", () =>
- pipe(getImapImpl(to, logger), TE.mapLeft(ToErrorWithLock())),
- ),
- TE.bind("mailboxLock", ({ imap }) =>
- TE.tryCatch(
- () => imap.getMailboxLock("INBOX"),
- ToErrorWithLock(undefined, imap),
- ),
- ),
- // "assert".
- TE.bind("uid", ({ imap, email, mailboxLock }) =>
- pipe(
- findEmailUidInInboxImpl(
- imap,
- matchesEmailImpl(email),
- retries,
- interval,
- logger,
- ),
- TE.mapLeft(ToErrorWithLock(mailboxLock, imap)),
- ),
- ),
- // cleanup.
- TE.bind("deleted", ({ imap, uid, mailboxLock }) =>
- TE.tryCatch(
- () => imap.messageDelete([uid], { uid: true }),
- ToErrorWithLock(mailboxLock, imap),
- ),
- ),
- TE.fold(
- (e) => {
- if (O.isSome(e.lock)) {
- e.lock.value.release();
- }
- if (O.isSome(e.imap)) {
- const imap = e.imap.value;
- return pipe(
- TE.tryCatch(
- () => imap.mailboxClose().then(() => imap.logout()),
- toError,
- ),
- TE.flatMap(() => TE.left(e)),
- );
- }
- return TE.left(e);
- },
- ({ mailboxLock, deleted, imap }) => {
- mailboxLock.release();
- return pipe(
- TE.tryCatch(
- () => imap.mailboxClose().then(() => imap.logout()),
- toError,
- ),
- TE.flatMap(() => TE.right(deleted)),
- );
- },
- ),
- );