This commit is contained in:
parent
6f45fe5a10
commit
c0a96e82af
21
src/api.ts
21
src/api.ts
@ -1,25 +1,20 @@
|
|||||||
import { perform } from "./email";
|
import { perform } from "./email";
|
||||||
import type { EmailJob } from "./job";
|
import { redactJob, type EmailJob } from "./job";
|
||||||
import { ConsoleLogger } from "./logger";
|
import { ConsoleLogger } from "./logger";
|
||||||
|
|
||||||
export const main = (port: number) => {
|
export const main = (port: number) => {
|
||||||
const server = Bun.serve({
|
const server = Bun.serve({
|
||||||
port,
|
port,
|
||||||
async fetch(req) {
|
async fetch(req) {
|
||||||
ConsoleLogger.log(`Received request: ${req.url}`)();
|
ConsoleLogger.info(`Received request: ${req.url}`)();
|
||||||
|
|
||||||
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 job: EmailJob = await req.json();
|
const job: EmailJob = await req.json();
|
||||||
|
const jobInsensitive = redactJob(job);
|
||||||
const jobInsensitive = structuredClone(job);
|
|
||||||
jobInsensitive.from.username = "****REDACTED****";
|
|
||||||
jobInsensitive.from.password = "****REDACTED****";
|
|
||||||
jobInsensitive.to.username = "****REDACTED****";
|
|
||||||
jobInsensitive.to.password = "****REDACTED****";
|
|
||||||
|
|
||||||
const uuid = crypto.randomUUID();
|
const uuid = crypto.randomUUID();
|
||||||
ConsoleLogger.log(
|
ConsoleLogger.info(
|
||||||
`[${uuid}] Received email job: ${JSON.stringify(jobInsensitive)}`,
|
`[${uuid}] Received email job: ${JSON.stringify(jobInsensitive)}`,
|
||||||
)();
|
)();
|
||||||
|
|
||||||
@ -28,18 +23,18 @@ export const main = (port: number) => {
|
|||||||
.then((result) => {
|
.then((result) => {
|
||||||
if (result._tag === "Left") {
|
if (result._tag === "Left") {
|
||||||
const error = result.left;
|
const error = result.left;
|
||||||
ConsoleLogger.log(
|
ConsoleLogger.warn(
|
||||||
`[${uuid}] job failure due to ${error.message}`,
|
`[${uuid}] job failure due to ${error.message}`,
|
||||||
)();
|
)();
|
||||||
return new Response(error.message, {
|
return new Response(error.message, {
|
||||||
status: 400,
|
status: 400,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
ConsoleLogger.log(`[${uuid}] success`)();
|
ConsoleLogger.info(`[${uuid}] success`)();
|
||||||
return Response.json({ success: true });
|
return Response.json({ success: true });
|
||||||
})
|
})
|
||||||
.catch((e) => {
|
.catch((e) => {
|
||||||
ConsoleLogger.log(`[${uuid}] internal failure due to ${e}`)();
|
ConsoleLogger.error(`[${uuid}] internal failure due to ${e}`)();
|
||||||
return new Response(e.message, {
|
return new Response(e.message, {
|
||||||
status: 500,
|
status: 500,
|
||||||
});
|
});
|
||||||
@ -48,6 +43,6 @@ export const main = (port: number) => {
|
|||||||
return new Response("404!", { status: 404 });
|
return new Response("404!", { status: 404 });
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
ConsoleLogger.log(`Listening on port ${port}`)();
|
ConsoleLogger.info(`Listening on port ${port}`)();
|
||||||
return server;
|
return server;
|
||||||
};
|
};
|
||||||
|
114
src/email.ts
114
src/email.ts
@ -3,7 +3,7 @@ import * as TE from "fp-ts/lib/TaskEither";
|
|||||||
import * as O from "fp-ts/lib/Option";
|
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 { flow, pipe } from "fp-ts/lib/function";
|
||||||
import {
|
import {
|
||||||
ImapFlow,
|
ImapFlow,
|
||||||
type FetchMessageObject,
|
type FetchMessageObject,
|
||||||
@ -26,7 +26,7 @@ interface ImapClientI {
|
|||||||
opts: Record<string, any>,
|
opts: Record<string, any>,
|
||||||
) => Promise<boolean>;
|
) => Promise<boolean>;
|
||||||
logout: () => Promise<void>;
|
logout: () => Promise<void>;
|
||||||
mailboxClose: () => Promise<void>;
|
mailboxClose: () => Promise<boolean>;
|
||||||
}
|
}
|
||||||
|
|
||||||
type Email = {
|
type Email = {
|
||||||
@ -63,14 +63,24 @@ const ToErrorWithLock =
|
|||||||
type EmailGenerator = (
|
type EmailGenerator = (
|
||||||
from: EmailFromInstruction,
|
from: EmailFromInstruction,
|
||||||
to: EmailToInstruction,
|
to: EmailToInstruction,
|
||||||
|
logger: Logger,
|
||||||
) => IO.IO<Email>;
|
) => IO.IO<Email>;
|
||||||
const generateEmail: EmailGenerator =
|
const generateEmail: EmailGenerator = (
|
||||||
(from: EmailFromInstruction, to: EmailToInstruction) => () => ({
|
from: EmailFromInstruction,
|
||||||
from: from.email,
|
to: EmailToInstruction,
|
||||||
to: to.email,
|
logger: Logger,
|
||||||
subject: [new Date().toISOString(), crypto.randomUUID()].join(" | "),
|
) =>
|
||||||
text: crypto.randomUUID(),
|
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.
|
* Get the transport layer for a mailbox to send a piece of mail.
|
||||||
@ -79,13 +89,12 @@ const generateEmail: EmailGenerator =
|
|||||||
*/
|
*/
|
||||||
type GetSendEmail = (
|
type GetSendEmail = (
|
||||||
from: EmailFromInstruction,
|
from: EmailFromInstruction,
|
||||||
|
logger: Logger,
|
||||||
) => (email: Email) => TE.TaskEither<Error, Email>;
|
) => (email: Email) => TE.TaskEither<Error, Email>;
|
||||||
const getSendTransport: GetSendEmail = ({
|
const getSendTransport: GetSendEmail = (
|
||||||
username,
|
{ username, password, server, send_port },
|
||||||
password,
|
_logger,
|
||||||
server,
|
) => {
|
||||||
send_port,
|
|
||||||
}) => {
|
|
||||||
const transport = createTransport({
|
const transport = createTransport({
|
||||||
host: server,
|
host: server,
|
||||||
port: send_port,
|
port: send_port,
|
||||||
@ -120,8 +129,12 @@ const getSendTransport: GetSendEmail = ({
|
|||||||
*/
|
*/
|
||||||
type GetImapClient = (
|
type GetImapClient = (
|
||||||
to: EmailToInstruction,
|
to: EmailToInstruction,
|
||||||
|
logger: Logger,
|
||||||
) => TE.TaskEither<Error, ImapClientI>;
|
) => TE.TaskEither<Error, ImapClientI>;
|
||||||
const getImap: GetImapClient = ({ username, password, server, read_port }) => {
|
const getImap: GetImapClient = (
|
||||||
|
{ username, password, server, read_port },
|
||||||
|
logger,
|
||||||
|
) => {
|
||||||
const imap = new ImapFlow({
|
const imap = new ImapFlow({
|
||||||
logger: false,
|
logger: false,
|
||||||
host: server,
|
host: server,
|
||||||
@ -132,7 +145,14 @@ const getImap: GetImapClient = ({ username, password, server, read_port }) => {
|
|||||||
pass: password,
|
pass: password,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
return TE.tryCatch(() => imap.connect().then(() => imap), toError);
|
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))),
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -141,16 +161,24 @@ const getImap: GetImapClient = ({ username, password, server, read_port }) => {
|
|||||||
*/
|
*/
|
||||||
const fetchMessages = (
|
const fetchMessages = (
|
||||||
imap: ImapClientI,
|
imap: ImapClientI,
|
||||||
|
logger: Logger,
|
||||||
): TE.TaskEither<Error, FetchMessageObject[]> =>
|
): TE.TaskEither<Error, FetchMessageObject[]> =>
|
||||||
TE.tryCatch(
|
pipe(
|
||||||
() =>
|
TE.fromIO(logger.info("Fetching messages...")),
|
||||||
imap.fetchAll("*", {
|
TE.chain(() =>
|
||||||
uid: true,
|
TE.tryCatch(
|
||||||
envelope: true,
|
() =>
|
||||||
headers: true,
|
imap.fetchAll("*", {
|
||||||
bodyParts: ["text"],
|
uid: true,
|
||||||
}),
|
envelope: true,
|
||||||
toError,
|
headers: true,
|
||||||
|
bodyParts: ["text"],
|
||||||
|
}),
|
||||||
|
toError,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
TE.tap(() => TE.fromIO(logger.info("Fetched messages."))),
|
||||||
|
TE.tapError((error) => TE.fromIO(logger.error(error.message))),
|
||||||
);
|
);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -164,8 +192,8 @@ const matchesEmail: EmailMatcher = (email) => (message) => {
|
|||||||
const bodyMatches =
|
const bodyMatches =
|
||||||
message.bodyParts?.get("text")?.toString().trim() === email.text.trim();
|
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}`);
|
||||||
return subjectMatches && bodyMatches && fromMatches && toMatches;
|
return subjectMatches && bodyMatches && fromMatches && toMatches;
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -175,6 +203,7 @@ const matchesEmail: EmailMatcher = (email) => (message) => {
|
|||||||
* @param email is the email to search for.
|
* @param email is the email to search for.
|
||||||
* @param retries is the number of retries left.
|
* @param retries is the number of retries left.
|
||||||
* @param pollIntervalMs is the time to wait between retries.
|
* @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).
|
* @returns a Right(number) if the email was found, else a Left(error).
|
||||||
*/
|
*/
|
||||||
type FindEmailUidInInbox = (
|
type FindEmailUidInInbox = (
|
||||||
@ -182,17 +211,17 @@ type FindEmailUidInInbox = (
|
|||||||
equalsEmail: (message: FetchMessageObject) => boolean,
|
equalsEmail: (message: FetchMessageObject) => boolean,
|
||||||
retries: number,
|
retries: number,
|
||||||
pollIntervalMs: number,
|
pollIntervalMs: number,
|
||||||
logger?: Logger,
|
logger: Logger,
|
||||||
) => TE.TaskEither<Error, number>;
|
) => TE.TaskEither<Error, number>;
|
||||||
const findEmailUidInInbox: FindEmailUidInInbox = (
|
const findEmailUidInInbox: FindEmailUidInInbox = (
|
||||||
imap,
|
imap,
|
||||||
equalsEmail,
|
equalsEmail,
|
||||||
retries,
|
retries,
|
||||||
pollIntervalMs,
|
pollIntervalMs,
|
||||||
logger = ConsoleLogger,
|
logger,
|
||||||
) =>
|
) =>
|
||||||
pipe(
|
pipe(
|
||||||
fetchMessages(imap),
|
fetchMessages(imap, logger),
|
||||||
TE.flatMap((messages) => {
|
TE.flatMap((messages) => {
|
||||||
const message = messages.find(equalsEmail);
|
const message = messages.find(equalsEmail);
|
||||||
if (message) {
|
if (message) {
|
||||||
@ -204,7 +233,7 @@ const findEmailUidInInbox: FindEmailUidInInbox = (
|
|||||||
(e) =>
|
(e) =>
|
||||||
pipe(
|
pipe(
|
||||||
TE.fromIO(
|
TE.fromIO(
|
||||||
logger.log(`failed to find email; ${retries} retries left.`),
|
logger.info(`Failed to find email; ${retries} retries left.`),
|
||||||
),
|
),
|
||||||
TE.chain(() =>
|
TE.chain(() =>
|
||||||
retries === 0
|
retries === 0
|
||||||
@ -212,14 +241,20 @@ const findEmailUidInInbox: FindEmailUidInInbox = (
|
|||||||
: T.delay(pollIntervalMs)(TE.right(null)),
|
: T.delay(pollIntervalMs)(TE.right(null)),
|
||||||
),
|
),
|
||||||
TE.chain(() =>
|
TE.chain(() =>
|
||||||
findEmailUidInInbox(imap, equalsEmail, retries - 1, pollIntervalMs),
|
findEmailUidInInbox(
|
||||||
|
imap,
|
||||||
|
equalsEmail,
|
||||||
|
retries - 1,
|
||||||
|
pollIntervalMs,
|
||||||
|
logger,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
(s) =>
|
(s) =>
|
||||||
pipe(
|
pipe(
|
||||||
s,
|
s,
|
||||||
TE.of,
|
TE.of,
|
||||||
TE.tap(() => TE.fromIO(logger.log("Email succeeded"))),
|
TE.tap(() => TE.fromIO(logger.info("Email succeeded"))),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@ -235,6 +270,7 @@ export type EmailJobDependencies = {
|
|||||||
/**
|
/**
|
||||||
* Perform an email job.
|
* Perform an email job.
|
||||||
* @param job is the job to perform.
|
* @param job is the job to perform.
|
||||||
|
* @param logger is the logger instance.
|
||||||
*/
|
*/
|
||||||
export const perform = (
|
export const perform = (
|
||||||
{ from, to, readRetry: { retries, interval } }: EmailJob,
|
{ from, to, readRetry: { retries, interval } }: EmailJob,
|
||||||
@ -245,16 +281,19 @@ export const perform = (
|
|||||||
findEmailUidInInboxImpl = findEmailUidInInbox,
|
findEmailUidInInboxImpl = findEmailUidInInbox,
|
||||||
matchesEmailImpl = matchesEmail,
|
matchesEmailImpl = matchesEmail,
|
||||||
}: Partial<EmailJobDependencies> = {},
|
}: Partial<EmailJobDependencies> = {},
|
||||||
|
logger: Logger = ConsoleLogger,
|
||||||
): TE.TaskEither<Error, boolean> =>
|
): TE.TaskEither<Error, boolean> =>
|
||||||
pipe(
|
pipe(
|
||||||
// arrange.
|
// arrange.
|
||||||
TE.fromIO(generateEmailImpl(from, to)),
|
TE.fromIO(generateEmailImpl(from, to, logger)),
|
||||||
TE.bindTo("email"),
|
TE.bindTo("email"),
|
||||||
// act.
|
// act.
|
||||||
TE.tap(({ email }) =>
|
TE.tap(({ email }) =>
|
||||||
pipe(getSendImpl(from)(email), TE.mapLeft(ToErrorWithLock())),
|
pipe(getSendImpl(from, logger)(email), TE.mapLeft(ToErrorWithLock())),
|
||||||
|
),
|
||||||
|
TE.bind("imap", () =>
|
||||||
|
pipe(getImapImpl(to, logger), TE.mapLeft(ToErrorWithLock())),
|
||||||
),
|
),
|
||||||
TE.bind("imap", () => pipe(getImapImpl(to), TE.mapLeft(ToErrorWithLock()))),
|
|
||||||
TE.bind("mailboxLock", ({ imap }) =>
|
TE.bind("mailboxLock", ({ imap }) =>
|
||||||
TE.tryCatch(
|
TE.tryCatch(
|
||||||
() => imap.getMailboxLock("INBOX"),
|
() => imap.getMailboxLock("INBOX"),
|
||||||
@ -269,6 +308,7 @@ export const perform = (
|
|||||||
matchesEmailImpl(email),
|
matchesEmailImpl(email),
|
||||||
retries,
|
retries,
|
||||||
interval,
|
interval,
|
||||||
|
logger,
|
||||||
),
|
),
|
||||||
TE.mapLeft(ToErrorWithLock(mailboxLock, imap)),
|
TE.mapLeft(ToErrorWithLock(mailboxLock, imap)),
|
||||||
),
|
),
|
||||||
|
12
src/job.ts
12
src/job.ts
@ -23,3 +23,15 @@ export interface Retry {
|
|||||||
retries: number;
|
retries: number;
|
||||||
interval: number;
|
interval: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const redact = <T extends EmailInstruction>(instruction: T): T => ({
|
||||||
|
...instruction,
|
||||||
|
password: "REDACTED",
|
||||||
|
username: "REDACTED",
|
||||||
|
});
|
||||||
|
|
||||||
|
export const redactJob = (job: EmailJob): EmailJob => ({
|
||||||
|
...job,
|
||||||
|
from: redact(job.from),
|
||||||
|
to: redact(job.to),
|
||||||
|
});
|
||||||
|
@ -1,10 +1,16 @@
|
|||||||
import type { IO } from "fp-ts/lib/IO";
|
import type { IO } from "fp-ts/lib/IO";
|
||||||
|
|
||||||
export interface Logger {
|
export interface Logger {
|
||||||
log: (message: string) => IO<void>;
|
info: (message: string) => IO<void>;
|
||||||
|
error: (message: string) => IO<void>;
|
||||||
|
warn: (message: string) => IO<void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const ConsoleLogger: Logger = {
|
export const ConsoleLogger: Logger = {
|
||||||
log: (message: string) => () =>
|
info: (message: string) => () =>
|
||||||
console.log(`[${new Date().toUTCString()}] ` + message),
|
console.log(`[${new Date().toUTCString()}] INFO ` + message),
|
||||||
|
error: (message: string) => () =>
|
||||||
|
console.error(`[${new Date().toUTCString()}] ERROR ` + message),
|
||||||
|
warn: (message: string) => () =>
|
||||||
|
console.warn(`[${new Date().toUTCString()}] WARN ` + message),
|
||||||
};
|
};
|
||||||
|
Loading…
Reference in New Issue
Block a user