This commit is contained in:
parent
4f1e974623
commit
9dcd721bd1
18
src/api.ts
18
src/api.ts
@ -1,5 +1,6 @@
|
|||||||
import { transformDurations } from "./duration";
|
import { parse } from "./duration";
|
||||||
import { perform } from "./email";
|
import { perform } from "./email";
|
||||||
|
import type { EmailJob } from "./job";
|
||||||
import { ConsoleLogger } from "./logger";
|
import { ConsoleLogger } from "./logger";
|
||||||
|
|
||||||
export const main = (port: number) => {
|
export const main = (port: number) => {
|
||||||
@ -10,12 +11,17 @@ export const main = (port: number) => {
|
|||||||
|
|
||||||
const url = new URL(req.url);
|
const url = new URL(req.url);
|
||||||
if (req.method === "POST" && url.pathname === "/api/email") {
|
if (req.method === "POST" && url.pathname === "/api/email") {
|
||||||
const prevalidatedJob = transformDurations(await req.json());
|
const prevalidatedJob = await req.json();
|
||||||
if (prevalidatedJob._tag === "Left") {
|
const interval = parse(prevalidatedJob.readRetry.interval as string);
|
||||||
return new Response(prevalidatedJob.left, { status: 400 });
|
if (interval._tag === "Left") {
|
||||||
|
return new Response(interval.left, { status: 400 });
|
||||||
}
|
}
|
||||||
const job = prevalidatedJob.right;
|
prevalidatedJob.readRetry.interval = interval;
|
||||||
|
const job: EmailJob = {
|
||||||
|
...prevalidatedJob,
|
||||||
|
readRetry: { ...prevalidatedJob.readRetry, interval },
|
||||||
|
};
|
||||||
|
|
||||||
const jobInsensitive = structuredClone(job);
|
const jobInsensitive = structuredClone(job);
|
||||||
jobInsensitive.from.username = "****REDACTED****";
|
jobInsensitive.from.username = "****REDACTED****";
|
||||||
jobInsensitive.from.password = "****REDACTED****";
|
jobInsensitive.from.password = "****REDACTED****";
|
||||||
|
@ -154,26 +154,3 @@ export const parse = (duration: string): E.Either<string, Duration> => {
|
|||||||
E.map(build),
|
E.map(build),
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const transformDurations = (obj: any): E.Either<string, any> => {
|
|
||||||
const transform = (o: any): E.Either<string, any> => {
|
|
||||||
const entries = Object.entries(o);
|
|
||||||
|
|
||||||
for (let [key, value] of entries) {
|
|
||||||
if (key === "duration" && typeof value === "string") {
|
|
||||||
return parse(value);
|
|
||||||
} else if (typeof value === "object" && value !== null) {
|
|
||||||
const result = transform(value);
|
|
||||||
if (E.isLeft(result)) {
|
|
||||||
return result;
|
|
||||||
} else {
|
|
||||||
o[key] = result.right;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return E.right(o);
|
|
||||||
};
|
|
||||||
|
|
||||||
return transform(obj);
|
|
||||||
}
|
|
||||||
|
147
src/email.ts
147
src/email.ts
@ -4,16 +4,27 @@ import * as O from "fp-ts/lib/Option";
|
|||||||
import { createTransport } from "nodemailer";
|
import { createTransport } from "nodemailer";
|
||||||
import { toError } from "fp-ts/lib/Either";
|
import { toError } from "fp-ts/lib/Either";
|
||||||
import { pipe } from "fp-ts/lib/function";
|
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 IO from "fp-ts/lib/IO";
|
||||||
import * as T from "fp-ts/lib/Task";
|
import * as T from "fp-ts/lib/Task";
|
||||||
import { ConsoleLogger, type Logger } from "./logger";
|
import { ConsoleLogger, type Logger } from "./logger";
|
||||||
|
|
||||||
interface ImapClientI {
|
interface ImapClientI {
|
||||||
fetchAll: (range: string, options: FetchQueryObject) => Promise<FetchMessageObject[]>;
|
fetchAll: (
|
||||||
|
range: string,
|
||||||
|
options: FetchQueryObject,
|
||||||
|
) => Promise<FetchMessageObject[]>;
|
||||||
connect: () => Promise<void>;
|
connect: () => Promise<void>;
|
||||||
getMailboxLock: (mailbox: string) => Promise<MailboxLockObject>;
|
getMailboxLock: (mailbox: string) => Promise<MailboxLockObject>;
|
||||||
messageDelete: (uids: number[], opts: Record<string, any>) => Promise<boolean>;
|
messageDelete: (
|
||||||
|
uids: number[],
|
||||||
|
opts: Record<string, any>,
|
||||||
|
) => Promise<boolean>;
|
||||||
logout: () => Promise<void>;
|
logout: () => Promise<void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -34,8 +45,13 @@ class ErrorWithLock extends Error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const ToErrorWithLock = (lock?: MailboxLockObject, imap?: ImapClientI) => (error: unknown) =>
|
const ToErrorWithLock =
|
||||||
new ErrorWithLock(error instanceof Error ? error.message : "Unknown error", lock, imap);
|
(lock?: MailboxLockObject, imap?: ImapClientI) => (error: unknown) =>
|
||||||
|
new ErrorWithLock(
|
||||||
|
error instanceof Error ? error.message : "Unknown error",
|
||||||
|
lock,
|
||||||
|
imap,
|
||||||
|
);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Generate a unique email.
|
* Generate a unique email.
|
||||||
@ -43,31 +59,42 @@ const ToErrorWithLock = (lock?: MailboxLockObject, imap?: ImapClientI) => (error
|
|||||||
* @param to is the email to send to.
|
* @param to is the email to send to.
|
||||||
* @returns an {@link Email}.
|
* @returns an {@link Email}.
|
||||||
*/
|
*/
|
||||||
type EmailGenerator = (from: EmailFromInstruction, to: EmailToInstruction) => IO.IO<Email>;
|
type EmailGenerator = (
|
||||||
const generateEmail: EmailGenerator = (from: EmailFromInstruction, to: EmailToInstruction) => () => ({
|
from: EmailFromInstruction,
|
||||||
from: from.email,
|
to: EmailToInstruction,
|
||||||
to: to.email,
|
) => IO.IO<Email>;
|
||||||
subject: [new Date().toISOString(), crypto.randomUUID()].join(" | "),
|
const generateEmail: EmailGenerator =
|
||||||
text: crypto.randomUUID()
|
(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.
|
* Get the transport layer for a mailbox to send a piece of mail.
|
||||||
* @param param0 is the mailbox to send from.
|
* @param param0 is the mailbox to send from.
|
||||||
* @returns a function that takes an email and sends it.
|
* @returns a function that takes an email and sends it.
|
||||||
*/
|
*/
|
||||||
type GetSendEmail = (from: EmailFromInstruction) => (email: Email) => TE.TaskEither<Error, Email>;
|
type GetSendEmail = (
|
||||||
const getSendTransport: GetSendEmail = ({ username, password, server, send_port }) => {
|
from: EmailFromInstruction,
|
||||||
|
) => (email: Email) => TE.TaskEither<Error, Email>;
|
||||||
|
const getSendTransport: GetSendEmail = ({
|
||||||
|
username,
|
||||||
|
password,
|
||||||
|
server,
|
||||||
|
send_port,
|
||||||
|
}) => {
|
||||||
const transport = createTransport({
|
const transport = createTransport({
|
||||||
host: server,
|
host: server,
|
||||||
port: send_port,
|
port: send_port,
|
||||||
auth: {
|
auth: {
|
||||||
user: username,
|
user: username,
|
||||||
pass: password
|
pass: password,
|
||||||
},
|
},
|
||||||
tls: {
|
tls: {
|
||||||
rejectUnauthorized: false
|
rejectUnauthorized: false,
|
||||||
}
|
},
|
||||||
});
|
});
|
||||||
return (email: Email) =>
|
return (email: Email) =>
|
||||||
TE.tryCatch(
|
TE.tryCatch(
|
||||||
@ -79,9 +106,9 @@ const getSendTransport: GetSendEmail = ({ username, password, server, send_port
|
|||||||
} else {
|
} else {
|
||||||
resolve(email);
|
resolve(email);
|
||||||
}
|
}
|
||||||
})
|
}),
|
||||||
),
|
),
|
||||||
toError
|
toError,
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -90,7 +117,9 @@ const getSendTransport: GetSendEmail = ({ username, password, server, send_port
|
|||||||
* @param param0 is the mailbox to read from.
|
* @param param0 is the mailbox to read from.
|
||||||
* @returns a Right({@link ImapFlow}) if it connected, else an Left(error).
|
* @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 getImap: GetImapClient = ({ username, password, server, read_port }) => {
|
||||||
const imap = new ImapFlow({
|
const imap = new ImapFlow({
|
||||||
logger: false,
|
logger: false,
|
||||||
@ -99,8 +128,8 @@ const getImap: GetImapClient = ({ username, password, server, read_port }) => {
|
|||||||
secure: true,
|
secure: true,
|
||||||
auth: {
|
auth: {
|
||||||
user: username,
|
user: username,
|
||||||
pass: password
|
pass: password,
|
||||||
}
|
},
|
||||||
});
|
});
|
||||||
return TE.tryCatch(() => imap.connect().then(() => imap), toError);
|
return TE.tryCatch(() => imap.connect().then(() => imap), toError);
|
||||||
};
|
};
|
||||||
@ -109,16 +138,18 @@ const getImap: GetImapClient = ({ username, password, server, read_port }) => {
|
|||||||
* @param imap is the Imap client to fetch messages from.
|
* @param imap is the Imap client to fetch messages from.
|
||||||
* @returns a Right({@link FetchMessageObject}[]) if successful, else a Left(error).
|
* @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(
|
TE.tryCatch(
|
||||||
() =>
|
() =>
|
||||||
imap.fetchAll("*", {
|
imap.fetchAll("*", {
|
||||||
uid: true,
|
uid: true,
|
||||||
envelope: true,
|
envelope: true,
|
||||||
headers: true,
|
headers: true,
|
||||||
bodyParts: ["text"]
|
bodyParts: ["text"],
|
||||||
}),
|
}),
|
||||||
toError
|
toError,
|
||||||
);
|
);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -129,7 +160,8 @@ const fetchMessages = (imap: ImapClientI): TE.TaskEither<Error, FetchMessageObje
|
|||||||
type EmailMatcher = (email: Email) => (message: FetchMessageObject) => boolean;
|
type EmailMatcher = (email: Email) => (message: FetchMessageObject) => boolean;
|
||||||
const matchesEmail: EmailMatcher = (email) => (message) => {
|
const matchesEmail: EmailMatcher = (email) => (message) => {
|
||||||
const subjectMatches = email.subject === message.envelope.subject;
|
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 headers = message.headers.toLocaleString();
|
||||||
const fromMatches = headers.includes(`Return-Path: <${email.from}>`);
|
const fromMatches = headers.includes(`Return-Path: <${email.from}>`);
|
||||||
const toMatches = headers.includes(`Delivered-To: ${email.to}`);
|
const toMatches = headers.includes(`Delivered-To: ${email.to}`);
|
||||||
@ -151,7 +183,13 @@ type FindEmailUidInInbox = (
|
|||||||
pollIntervalMs: number,
|
pollIntervalMs: number,
|
||||||
logger?: Logger,
|
logger?: Logger,
|
||||||
) => TE.TaskEither<Error, number>;
|
) => TE.TaskEither<Error, number>;
|
||||||
const findEmailUidInInbox: FindEmailUidInInbox = (imap, equalsEmail, retries, pollIntervalMs, logger = ConsoleLogger) =>
|
const findEmailUidInInbox: FindEmailUidInInbox = (
|
||||||
|
imap,
|
||||||
|
equalsEmail,
|
||||||
|
retries,
|
||||||
|
pollIntervalMs,
|
||||||
|
logger = ConsoleLogger,
|
||||||
|
) =>
|
||||||
pipe(
|
pipe(
|
||||||
fetchMessages(imap),
|
fetchMessages(imap),
|
||||||
TE.flatMap((messages) => {
|
TE.flatMap((messages) => {
|
||||||
@ -164,17 +202,25 @@ const findEmailUidInInbox: FindEmailUidInInbox = (imap, equalsEmail, retries, po
|
|||||||
TE.fold(
|
TE.fold(
|
||||||
(e) =>
|
(e) =>
|
||||||
pipe(
|
pipe(
|
||||||
TE.fromIO(logger.log(`failed to find email; ${retries} retries left.`)),
|
TE.fromIO(
|
||||||
TE.chain(() => (retries === 0 ? TE.left(e) : T.delay(pollIntervalMs)(TE.right(null)))),
|
logger.log(`failed to find email; ${retries} retries left.`),
|
||||||
TE.chain(() => findEmailUidInInbox(imap, equalsEmail, retries - 1, pollIntervalMs))
|
),
|
||||||
|
TE.chain(() =>
|
||||||
|
retries === 0
|
||||||
|
? TE.left(e)
|
||||||
|
: T.delay(pollIntervalMs)(TE.right(null)),
|
||||||
|
),
|
||||||
|
TE.chain(() =>
|
||||||
|
findEmailUidInInbox(imap, equalsEmail, retries - 1, pollIntervalMs),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
(s) =>
|
(s) =>
|
||||||
pipe(
|
pipe(
|
||||||
s,
|
s,
|
||||||
TE.of,
|
TE.of,
|
||||||
TE.tap(() => TE.fromIO(logger.log("Email succeeded")))
|
TE.tap(() => TE.fromIO(logger.log("Email succeeded"))),
|
||||||
)
|
),
|
||||||
)
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
export type EmailJobDependencies = {
|
export type EmailJobDependencies = {
|
||||||
@ -196,23 +242,35 @@ export const perform = (
|
|||||||
getSendImpl = getSendTransport,
|
getSendImpl = getSendTransport,
|
||||||
getImapImpl = getImap,
|
getImapImpl = getImap,
|
||||||
findEmailUidInInboxImpl = findEmailUidInInbox,
|
findEmailUidInInboxImpl = findEmailUidInInbox,
|
||||||
matchesEmailImpl = matchesEmail
|
matchesEmailImpl = matchesEmail,
|
||||||
}: Partial<EmailJobDependencies> = {}
|
}: Partial<EmailJobDependencies> = {},
|
||||||
): TE.TaskEither<Error, boolean> =>
|
): TE.TaskEither<Error, boolean> =>
|
||||||
pipe(
|
pipe(
|
||||||
// arrange.
|
// arrange.
|
||||||
TE.fromIO(generateEmailImpl(from, to)),
|
TE.fromIO(generateEmailImpl(from, to)),
|
||||||
TE.bindTo("email"),
|
TE.bindTo("email"),
|
||||||
// act.
|
// 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("imap", () => pipe(getImapImpl(to), TE.mapLeft(ToErrorWithLock()))),
|
||||||
TE.bind("mailboxLock", ({ imap }) => TE.tryCatch(() => imap.getMailboxLock("INBOX"), ToErrorWithLock(undefined, imap))),
|
TE.bind("mailboxLock", ({ imap }) =>
|
||||||
|
TE.tryCatch(
|
||||||
|
() => imap.getMailboxLock("INBOX"),
|
||||||
|
ToErrorWithLock(undefined, imap),
|
||||||
|
),
|
||||||
|
),
|
||||||
// "assert".
|
// "assert".
|
||||||
TE.bind("uid", ({ imap, email, mailboxLock }) =>
|
TE.bind("uid", ({ imap, email, mailboxLock }) =>
|
||||||
pipe(
|
pipe(
|
||||||
findEmailUidInInboxImpl(imap, matchesEmailImpl(email), retries, interval),
|
findEmailUidInInboxImpl(
|
||||||
TE.mapLeft(ToErrorWithLock(mailboxLock, imap))
|
imap,
|
||||||
)
|
matchesEmailImpl(email),
|
||||||
|
retries,
|
||||||
|
interval,
|
||||||
|
),
|
||||||
|
TE.mapLeft(ToErrorWithLock(mailboxLock, imap)),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
// cleanup.
|
// cleanup.
|
||||||
TE.bind("deleted", ({ imap, uid, mailboxLock }) =>
|
TE.bind("deleted", ({ imap, uid, mailboxLock }) =>
|
||||||
@ -228,7 +286,10 @@ export const perform = (
|
|||||||
}
|
}
|
||||||
if (O.isSome(e.imap)) {
|
if (O.isSome(e.imap)) {
|
||||||
const imap = e.imap.value;
|
const imap = e.imap.value;
|
||||||
return pipe(TE.tryCatch(() => imap.logout(), toError), TE.flatMap(() => TE.left(e)));
|
return pipe(
|
||||||
|
TE.tryCatch(() => imap.logout(), toError),
|
||||||
|
TE.flatMap(() => TE.left(e)),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
return TE.left(e);
|
return TE.left(e);
|
||||||
},
|
},
|
||||||
@ -236,6 +297,6 @@ export const perform = (
|
|||||||
mailboxLock.release();
|
mailboxLock.release();
|
||||||
imap.logout();
|
imap.logout();
|
||||||
return TE.right(deleted);
|
return TE.right(deleted);
|
||||||
}
|
},
|
||||||
)
|
),
|
||||||
);
|
);
|
||||||
|
Loading…
Reference in New Issue
Block a user