This commit is contained in:
parent
f8777a6adf
commit
53187bede7
137
src/email.ts
137
src/email.ts
@ -4,24 +4,16 @@ 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 { 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";
|
||||
import { ConsoleLogger, type Logger } from "./logger";
|
||||
|
||||
interface ImapClientI {
|
||||
fetchAll: (
|
||||
range: string,
|
||||
options: FetchQueryObject,
|
||||
) => Promise<FetchMessageObject[]>;
|
||||
fetchAll: (range: string, options: FetchQueryObject) => Promise<FetchMessageObject[]>;
|
||||
connect: () => Promise<void>;
|
||||
getMailboxLock: (mailbox: string) => Promise<MailboxLockObject>;
|
||||
messageDelete: (uids: number[], opts: any) => Promise<boolean>;
|
||||
messageDelete: (uids: number[], opts: Record<string, any>) => Promise<boolean>;
|
||||
close: () => void;
|
||||
}
|
||||
|
||||
@ -40,10 +32,7 @@ class ErrorWithLock extends Error {
|
||||
}
|
||||
}
|
||||
const ToErrorWithLock = (lock?: MailboxLockObject) => (error: unknown) =>
|
||||
new ErrorWithLock(
|
||||
error instanceof Error ? error.message : "Unknown error",
|
||||
lock,
|
||||
);
|
||||
new ErrorWithLock(error instanceof Error ? error.message : "Unknown error", lock);
|
||||
|
||||
/**
|
||||
* Generate a unique email.
|
||||
@ -51,42 +40,31 @@ const ToErrorWithLock = (lock?: MailboxLockObject) => (error: unknown) =>
|
||||
* @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(),
|
||||
});
|
||||
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,
|
||||
}) => {
|
||||
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,
|
||||
pass: password
|
||||
},
|
||||
tls: {
|
||||
rejectUnauthorized: false,
|
||||
},
|
||||
rejectUnauthorized: false
|
||||
}
|
||||
});
|
||||
return (email: Email) =>
|
||||
TE.tryCatch(
|
||||
@ -98,9 +76,9 @@ const getSendTransport: GetSendEmail = ({
|
||||
} else {
|
||||
resolve(email);
|
||||
}
|
||||
}),
|
||||
})
|
||||
),
|
||||
toError,
|
||||
toError
|
||||
);
|
||||
};
|
||||
|
||||
@ -109,9 +87,7 @@ const getSendTransport: GetSendEmail = ({
|
||||
* @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>;
|
||||
type GetImapClient = (to: EmailToInstruction) => TE.TaskEither<Error, ImapClientI>;
|
||||
const getImap: GetImapClient = ({ username, password, server, read_port }) => {
|
||||
const imap = new ImapFlow({
|
||||
logger: false,
|
||||
@ -120,8 +96,8 @@ const getImap: GetImapClient = ({ username, password, server, read_port }) => {
|
||||
secure: true,
|
||||
auth: {
|
||||
user: username,
|
||||
pass: password,
|
||||
},
|
||||
pass: password
|
||||
}
|
||||
});
|
||||
return TE.tryCatch(() => imap.connect().then(() => imap), toError);
|
||||
};
|
||||
@ -130,18 +106,16 @@ const getImap: GetImapClient = ({ username, password, server, read_port }) => {
|
||||
* @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[]> =>
|
||||
const fetchMessages = (imap: ImapClientI): TE.TaskEither<Error, FetchMessageObject[]> =>
|
||||
TE.tryCatch(
|
||||
() =>
|
||||
imap.fetchAll("*", {
|
||||
uid: true,
|
||||
envelope: true,
|
||||
headers: true,
|
||||
bodyParts: ["text"],
|
||||
bodyParts: ["text"]
|
||||
}),
|
||||
toError,
|
||||
toError
|
||||
);
|
||||
|
||||
/**
|
||||
@ -152,8 +126,7 @@ const fetchMessages = (
|
||||
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 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}`);
|
||||
@ -173,13 +146,9 @@ type FindEmailUidInInbox = (
|
||||
equalsEmail: (message: FetchMessageObject) => boolean,
|
||||
retries: number,
|
||||
pollIntervalMs: number,
|
||||
logger?: Logger,
|
||||
) => TE.TaskEither<Error, number>;
|
||||
const findEmailUidInInbox: FindEmailUidInInbox = (
|
||||
imap,
|
||||
equalsEmail,
|
||||
retries,
|
||||
pollIntervalMs,
|
||||
) =>
|
||||
const findEmailUidInInbox: FindEmailUidInInbox = (imap, equalsEmail, retries, pollIntervalMs, logger = ConsoleLogger) =>
|
||||
pipe(
|
||||
fetchMessages(imap),
|
||||
TE.flatMap((messages) => {
|
||||
@ -192,20 +161,17 @@ const findEmailUidInInbox: FindEmailUidInInbox = (
|
||||
TE.fold(
|
||||
(e) =>
|
||||
pipe(
|
||||
TE.fromIO(
|
||||
ConsoleLogger.log(`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),
|
||||
),
|
||||
TE.fromIO(logger.log(`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))
|
||||
),
|
||||
TE.of,
|
||||
),
|
||||
(s) =>
|
||||
pipe(
|
||||
s,
|
||||
TE.of,
|
||||
TE.tap(() => TE.fromIO(logger.log("Email succeeded")))
|
||||
)
|
||||
)
|
||||
);
|
||||
|
||||
export type EmailJobDependencies = {
|
||||
@ -227,32 +193,23 @@ export const perform = (
|
||||
getSendImpl = getSendTransport,
|
||||
getImapImpl = getImap,
|
||||
findEmailUidInInboxImpl = findEmailUidInInbox,
|
||||
matchesEmailImpl = matchesEmail,
|
||||
}: Partial<EmailJobDependencies> = {},
|
||||
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.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()),
|
||||
),
|
||||
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)),
|
||||
),
|
||||
findEmailUidInInboxImpl(imap, matchesEmailImpl(email), retries, interval),
|
||||
TE.mapLeft(ToErrorWithLock(mailboxLock))
|
||||
)
|
||||
),
|
||||
// cleanup.
|
||||
TE.bind("deleted", ({ imap, uid, mailboxLock }) =>
|
||||
@ -271,6 +228,6 @@ export const perform = (
|
||||
({ mailboxLock, deleted }) => {
|
||||
mailboxLock.release();
|
||||
return TE.right(deleted);
|
||||
},
|
||||
),
|
||||
}
|
||||
)
|
||||
);
|
||||
|
Loading…
Reference in New Issue
Block a user