summaryrefslogtreecommitdiff
path: root/lib/email.ts
diff options
context:
space:
mode:
authorElizabeth Hunt <me@liz.coffee>2025-08-17 23:50:24 -0700
committerElizabeth Hunt <me@liz.coffee>2025-08-17 23:53:38 -0700
commit1a4fc9535a89b58e8b67c8996ade0b833116af3a (patch)
treed16f3129d7bb69f204bba8422e909354195a0042 /lib/email.ts
parent157dc327e8fe63541b517cfbeeaf202a3e8553a5 (diff)
downloaduptime-1a4fc9535a89b58e8b67c8996ade0b833116af3a.tar.gz
uptime-1a4fc9535a89b58e8b67c8996ade0b833116af3a.zip
Move to pengueno.
Diffstat (limited to 'lib/email.ts')
-rw-r--r--lib/email.ts200
1 files changed, 200 insertions, 0 deletions
diff --git a/lib/email.ts b/lib/email.ts
new file mode 100644
index 0000000..38beb0c
--- /dev/null
+++ b/lib/email.ts
@@ -0,0 +1,200 @@
+import { ImapFlow, type FetchMessageObject, type FetchQueryObject, type MailboxLockObject } from 'imapflow';
+import { EmailSendInstruction, type Email, EmailRecvInstruction } from '@emprespresso/uptime';
+import { Either, ITraceable, LogLevel, Metric, Optional, ServerTrace, TraceUtil } from '@emprespresso/pengueno';
+import { createTransport } from 'nodemailer';
+
+interface IRecv {
+ connect: () => Promise<void>;
+ logout: () => Promise<void>;
+
+ getMailboxLock: (mailbox: string) => Promise<MailboxLockObject>;
+ mailboxClose: () => Promise<boolean>;
+
+ fetchAll: (range: string, options: FetchQueryObject) => Promise<FetchMessageObject[]>;
+ messageDelete: (uids: number[], opts: Record<string, any>) => Promise<boolean>;
+}
+
+interface ISend {
+ sendMail: (email: Email) => Promise<Email>;
+}
+
+export class Outbox {
+ public constructor(private readonly sender: ISend) {}
+
+ public async send(email: ITraceable<Email>) {
+ return email
+ .flatMap(TraceUtil.withMetricTrace(Outbox.Metrics.SEND))
+ .peek((res) => res.trace.traceScope(LogLevel.INFO).trace('Sending email'))
+ .map((email) => Either.fromFailableAsync<Error, Email>(() => this.sender.sendMail(email.get())))
+ .peek(TraceUtil.promiseify(TraceUtil.traceResultingEither(Outbox.Metrics.SEND)))
+ .get();
+ }
+
+ public static async from(instruction: ITraceable<EmailSendInstruction>) {
+ return instruction
+ .flatMap(TraceUtil.withMetricTrace(Outbox.Metrics.INIT))
+ .peek((res) => res.trace.traceScope(LogLevel.INFO).trace('Initializing outbox'))
+ .map((instruction) =>
+ createTransport({
+ host: instruction.get().send_host,
+ port: instruction.get().send_port,
+ auth: {
+ user: instruction.get().username,
+ pass: instruction.get().password,
+ },
+ tls: {
+ rejectUnauthorized: false,
+ },
+ }),
+ )
+ .map(
+ (transport) =>
+ new Outbox({
+ sendMail: (email: Email) =>
+ new Promise<Email>((resolve, reject) =>
+ transport.get().sendMail(email, (error) => {
+ if (error) {
+ reject(error);
+ } else {
+ resolve(email);
+ }
+ }),
+ ),
+ }),
+ )
+ .peek(TraceUtil.withMetricTrace(Outbox.Metrics.INIT))
+ .map((x) => Either.right<Error, Outbox>(x.get()))
+ .get();
+ }
+
+ private static Metrics = {
+ INIT: Metric.fromName('Outbox.Initialize').asResult(),
+ SEND: Metric.fromName('Outbox.Send').asResult(),
+ };
+}
+
+export class Inbox {
+ public constructor(
+ private readonly recv: IRecv,
+ private lock?: ITraceable<MailboxLockObject, ServerTrace>,
+ ) {}
+
+ public async deleteMessages(_uids: Array<number> | number) {
+ const uids = Array.isArray(_uids) ? _uids : [_uids];
+ return this.lock!.flatMap(TraceUtil.withMetricTrace(Inbox.Metrics.DELETE))
+ .peek((res) => res.trace.traceScope(LogLevel.INFO).trace(`Deleting messages ${uids}`))
+ .map(() => Either.fromFailableAsync<Error, boolean>(() => this.recv.messageDelete(uids, { uid: true })))
+ .peek(TraceUtil.promiseify(TraceUtil.traceResultingEither(Inbox.Metrics.DELETE)))
+ .get();
+ }
+
+ public async listMessages() {
+ return this.lock!.flatMap(TraceUtil.withMetricTrace(Inbox.Metrics.LIST))
+ .peek((res) => res.trace.traceScope(LogLevel.INFO).trace('Listing messages'))
+ .map(() =>
+ Either.fromFailableAsync<Error, Array<FetchMessageObject>>(() =>
+ this.recv.fetchAll('*', {
+ uid: true,
+ envelope: true,
+ headers: true,
+ bodyParts: ['text'],
+ }),
+ ),
+ )
+ .peek(TraceUtil.promiseify(TraceUtil.traceResultingEither(Inbox.Metrics.LIST)))
+ .peek(
+ TraceUtil.promiseify((res) =>
+ res.trace.trace(
+ res.get().fold(
+ (err) => err.toString(),
+ (ok) => `Found ${ok.length} messages`,
+ ),
+ ),
+ ),
+ )
+ .get();
+ }
+
+ public async close() {
+ await this.recv.mailboxClose().then(() => this.recv.logout());
+ this.lock!.peek((l) => l.get().release())
+ .peek(t => t.trace.trace(Inbox.Metrics.LOCK))
+ .peek((res) => res.trace.traceScope(LogLevel.INFO).trace('Released lock'))
+ .get();
+ delete this.lock;
+ }
+
+ public async find(email: ITraceable<Email>) {
+ return email
+ .flatMap(TraceUtil.withMetricTrace(Inbox.Metrics.FIND))
+ .map((e) =>
+ this.listMessages().then((eitherMessages) =>
+ eitherMessages.flatMap((messages) =>
+ Either.fromFailable(() =>
+ Optional.from(messages.find((message) => this.equals(e.get(), message))).get(),
+ ),
+ ),
+ ),
+ )
+ .peek(TraceUtil.promiseify(TraceUtil.traceResultingEither(Inbox.Metrics.FIND)))
+ .get();
+ }
+
+ public static from(instruction: ITraceable<EmailRecvInstruction, ServerTrace>) {
+ const client = new ImapFlow({
+ logger: false,
+ host: instruction.get().read_host,
+ port: instruction.get().read_port,
+ secure: true,
+ auth: {
+ user: instruction.get().username,
+ pass: instruction.get().password,
+ },
+ });
+ return instruction
+ .move(client)
+ .flatMap(TraceUtil.withMetricTrace(Inbox.Metrics.INIT))
+ .peek((res) => res.trace.traceScope(LogLevel.INFO).trace('Initializing mailbox'))
+ .map((tClient) =>
+ Either.fromFailableAsync<Error, MailboxLockObject>(() =>
+ tClient
+ .get()
+ .connect()
+ .then(() => tClient.get().getMailboxLock('INBOX')),
+ ).then((eitherLock) =>
+ eitherLock
+ .mapRight((lock) => tClient.move(lock).flatMap(TraceUtil.withMetricTrace(Inbox.Metrics.LOCK)))
+ .mapRight((traceableLock) => new Inbox(tClient.get(), traceableLock)),
+ ),
+ )
+ .peek(TraceUtil.promiseify(TraceUtil.traceResultingEither(Inbox.Metrics.INIT)))
+ .peek(
+ TraceUtil.promiseify((res) =>
+ res.trace.trace(
+ res.get().fold(
+ (err) => `Could not initialize mailbox: ${err.stack}`,
+ (ok) => `Initialized mailbox`,
+ ),
+ ),
+ ),
+ )
+ .get();
+ }
+
+ private equals(email: Email, message: FetchMessageObject) {
+ 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;
+ }
+
+ private static Metrics = {
+ INIT: Metric.fromName('Inbox.Initialize').asResult(),
+ FIND: Metric.fromName('Inbox.FindMessage').asResult(),
+ LOCK: Metric.fromName('Inbox.Lock').asResult(),
+ LIST: Metric.fromName('Inbox.List').asResult(),
+ DELETE: Metric.fromName('Inbox.Delete').asResult(),
+ };
+}