2023-07-28 19:50:04 +02:00
|
|
|
import { Inject, Injectable } from '@nestjs/common'
|
|
|
|
|
import { ConfigService } from '@nestjs/config'
|
|
|
|
|
import * as Imap from 'imap'
|
2023-07-29 10:49:44 +02:00
|
|
|
import { MailParser, ParsedEmail } from 'mailparser-mit'
|
2023-07-28 19:50:04 +02:00
|
|
|
|
2023-08-29 10:44:12 +02:00
|
|
|
import { LoggerService } from '../logger/service.logger'
|
2024-03-13 23:59:16 +01:00
|
|
|
import { NtfyProvider } from '../ntfy/provider'
|
2023-07-28 19:50:04 +02:00
|
|
|
import { Email } from './types'
|
|
|
|
|
|
|
|
|
|
export enum EmailClientStatus {
|
|
|
|
|
NotReady,
|
|
|
|
|
Ready,
|
2023-08-10 13:33:49 +02:00
|
|
|
Error,
|
2023-07-28 19:50:04 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
@Injectable()
|
|
|
|
|
export class EmailClient {
|
|
|
|
|
public readonly imapClient: Imap
|
2023-09-05 13:09:10 +02:00
|
|
|
private readonly mailboxName: string
|
2023-07-28 19:50:04 +02:00
|
|
|
private mailbox?: Imap.Box
|
|
|
|
|
private status: EmailClientStatus
|
|
|
|
|
|
|
|
|
|
constructor(
|
|
|
|
|
@Inject(LoggerService)
|
|
|
|
|
private readonly loggerService: LoggerService,
|
|
|
|
|
|
2023-09-26 15:24:52 +02:00
|
|
|
@Inject(NtfyProvider)
|
|
|
|
|
private readonly ntfyProvider: NtfyProvider,
|
|
|
|
|
|
2023-07-28 19:50:04 +02:00
|
|
|
@Inject(ConfigService)
|
|
|
|
|
private readonly configService: ConfigService,
|
|
|
|
|
) {
|
2023-09-05 13:09:10 +02:00
|
|
|
this.mailboxName = this.configService.getOrThrow<string>('EMAIL_MAILBOX')
|
2023-07-28 19:50:04 +02:00
|
|
|
this.imapClient = new Imap({
|
|
|
|
|
host: this.configService.getOrThrow('EMAIL_HOST'),
|
|
|
|
|
port: this.configService.get('EMAIL_PORT', 993),
|
|
|
|
|
user: this.configService.getOrThrow('EMAIL_USER'),
|
|
|
|
|
password: this.configService.getOrThrow('EMAIL_PASSWORD'),
|
|
|
|
|
tls: true,
|
|
|
|
|
})
|
|
|
|
|
this.status = EmailClientStatus.NotReady
|
|
|
|
|
this.setupDefaultListeners()
|
|
|
|
|
this.connect()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
onModuleDestroy() {
|
|
|
|
|
this.imapClient.end()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private setStatus(status: EmailClientStatus) {
|
|
|
|
|
this.status = status
|
|
|
|
|
}
|
|
|
|
|
|
2023-09-26 15:22:05 +02:00
|
|
|
private connect() {
|
2023-07-28 19:50:04 +02:00
|
|
|
this.imapClient.connect()
|
|
|
|
|
}
|
|
|
|
|
|
2023-09-26 15:22:05 +02:00
|
|
|
private openBox() {
|
|
|
|
|
this.imapClient.openBox(this.mailboxName, (error, mailbox) => {
|
|
|
|
|
if (error) {
|
|
|
|
|
this.loggerService.error('Error opening mailbox', {
|
|
|
|
|
...error,
|
|
|
|
|
})
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
this.loggerService.debug('Mailbox opened', { mailbox })
|
|
|
|
|
this.mailbox = mailbox
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
2023-07-28 19:50:04 +02:00
|
|
|
private attempt(
|
|
|
|
|
label: string,
|
|
|
|
|
current: number,
|
|
|
|
|
max: number,
|
|
|
|
|
delayMs: number,
|
|
|
|
|
fn: () => unknown,
|
|
|
|
|
) {
|
|
|
|
|
if (current > max) {
|
|
|
|
|
throw Error(`Max attempts reached for ${label}`)
|
|
|
|
|
}
|
2023-07-29 10:49:44 +02:00
|
|
|
this.loggerService.debug(`Attempting ${label} [${current} / ${max}]`)
|
2023-07-28 19:50:04 +02:00
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
fn()
|
|
|
|
|
} catch (error: unknown) {
|
2023-07-29 10:49:44 +02:00
|
|
|
this.loggerService.debug(
|
2023-07-28 19:50:04 +02:00
|
|
|
`Attempting ${label} hit error at attempt ${current}`,
|
|
|
|
|
)
|
|
|
|
|
setTimeout(
|
|
|
|
|
() => this.attempt(label, current + 1, max, delayMs, fn),
|
|
|
|
|
delayMs,
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private async handleNewMail(
|
|
|
|
|
numMessages: number,
|
2023-09-23 23:01:24 +02:00
|
|
|
callback: (emails: Email[]) => Promise<void>,
|
2023-07-28 19:50:04 +02:00
|
|
|
) {
|
2024-04-11 09:54:13 +02:00
|
|
|
this.loggerService.debug(`Received ${numMessages} emails`)
|
2023-07-28 19:50:04 +02:00
|
|
|
const mailbox = await new Promise<Imap.Box>((res) => this.getMailbox(res))
|
|
|
|
|
|
|
|
|
|
const {
|
|
|
|
|
messages: { total: totalMessages, new: newMessages },
|
|
|
|
|
} = mailbox
|
|
|
|
|
|
2023-07-29 10:49:44 +02:00
|
|
|
this.loggerService.debug(
|
|
|
|
|
`Total messages: ${totalMessages}; New messages: ${newMessages}; Received messages: ${numMessages}`,
|
|
|
|
|
)
|
|
|
|
|
|
2023-07-28 19:50:04 +02:00
|
|
|
if (newMessages > 0) {
|
2023-09-05 13:09:10 +02:00
|
|
|
const emails = await this.fetchMailsFrom(
|
|
|
|
|
totalMessages - (numMessages - 1),
|
|
|
|
|
)
|
|
|
|
|
this.loggerService.debug(`Fetched ${emails.length} emails`)
|
2023-09-23 23:01:24 +02:00
|
|
|
await callback(emails)
|
2023-07-28 19:50:04 +02:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2023-09-23 23:01:24 +02:00
|
|
|
private _listen(callback: (emails: Email[]) => Promise<void>) {
|
2023-07-28 19:50:04 +02:00
|
|
|
// Don't start listening until we are ready
|
|
|
|
|
if (this.status === EmailClientStatus.NotReady) {
|
|
|
|
|
throw new Error('Not ready to listen')
|
|
|
|
|
}
|
|
|
|
|
|
2023-09-23 23:07:27 +02:00
|
|
|
this.imapClient.on('mail', (n: number) => this.handleNewMail(n, callback))
|
2023-07-28 19:50:04 +02:00
|
|
|
}
|
|
|
|
|
|
2023-09-23 23:01:24 +02:00
|
|
|
public listen(callback: (emails: Email[]) => Promise<void>) {
|
2023-07-28 19:50:04 +02:00
|
|
|
this.attempt('listen', 0, 5, 1000, () => {
|
|
|
|
|
this._listen(callback)
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private _getMailbox() {
|
|
|
|
|
if (this.mailbox == null) {
|
|
|
|
|
throw new Error('Mailbox not ready')
|
|
|
|
|
}
|
|
|
|
|
return this.mailbox
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public getMailbox(callback: (mailbox: Imap.Box) => void) {
|
|
|
|
|
this.attempt('getMailbox', 0, 5, 200, () => {
|
|
|
|
|
const mailbox = this._getMailbox()
|
|
|
|
|
callback(mailbox)
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private async generateEmail(msg: Imap.ImapMessage): Promise<Email> {
|
|
|
|
|
return new Promise((res, rej) => {
|
2023-07-29 10:49:44 +02:00
|
|
|
const mailParser = new MailParser({ defaultCharset: 'utf-8' })
|
|
|
|
|
let id: string
|
2023-07-28 19:50:04 +02:00
|
|
|
msg.on('body', async (stream) => {
|
|
|
|
|
try {
|
2023-07-29 10:49:44 +02:00
|
|
|
stream.pipe(mailParser)
|
2023-07-28 19:50:04 +02:00
|
|
|
} catch (error: unknown) {
|
|
|
|
|
rej(error)
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
msg.on('attributes', (attr) => {
|
2023-07-29 10:49:44 +02:00
|
|
|
id = `${attr.uid}`
|
2023-07-28 19:50:04 +02:00
|
|
|
})
|
|
|
|
|
|
|
|
|
|
msg.on('error', (error: Error) => rej(error))
|
|
|
|
|
|
2023-07-29 10:49:44 +02:00
|
|
|
mailParser.on('error', (error: Error) => rej(error))
|
|
|
|
|
|
|
|
|
|
mailParser.on('end', (mail: ParsedEmail) => {
|
|
|
|
|
const { from = [], subject = '', text = '' } = mail
|
|
|
|
|
const fromString = from
|
|
|
|
|
.map(({ address, name }) => `${name} <${address}>`)
|
|
|
|
|
.join(';')
|
2023-07-28 19:50:04 +02:00
|
|
|
res({
|
|
|
|
|
id,
|
2023-07-29 10:49:44 +02:00
|
|
|
from: fromString,
|
|
|
|
|
subject,
|
|
|
|
|
text,
|
2023-07-28 19:50:04 +02:00
|
|
|
})
|
|
|
|
|
})
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
2023-09-05 13:09:10 +02:00
|
|
|
public async fetchMailsFrom(startingMessageSeqNo: number) {
|
2023-07-28 19:50:04 +02:00
|
|
|
this.loggerService.debug(
|
2023-09-05 13:09:10 +02:00
|
|
|
`Fetching mails starting from ${startingMessageSeqNo}`,
|
2023-07-28 19:50:04 +02:00
|
|
|
)
|
2023-09-05 13:09:10 +02:00
|
|
|
const fetcher = this.imapClient.seq.fetch(`${startingMessageSeqNo}:*`, {
|
|
|
|
|
bodies: '',
|
|
|
|
|
})
|
2023-07-28 19:50:04 +02:00
|
|
|
|
|
|
|
|
return new Promise<Email[]>((res, rej) => {
|
|
|
|
|
const generateEmailPromises: Promise<Email>[] = []
|
|
|
|
|
fetcher.on('message', (msg: Imap.ImapMessage) => {
|
|
|
|
|
generateEmailPromises.push(this.generateEmail(msg))
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
fetcher.on('error', (error) => rej(error))
|
|
|
|
|
|
|
|
|
|
fetcher.on('end', async () => {
|
|
|
|
|
const emails = await Promise.all(generateEmailPromises)
|
|
|
|
|
res(emails)
|
|
|
|
|
})
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
2023-09-05 13:09:10 +02:00
|
|
|
public async markMailsSeen(messageIds: string[]) {
|
2023-09-23 23:01:24 +02:00
|
|
|
this.loggerService.debug(`Marking mails as seen (${messageIds.join(',')})`)
|
2023-09-05 13:09:10 +02:00
|
|
|
return new Promise<void>((res, rej) => {
|
|
|
|
|
this.imapClient.addFlags(messageIds.join(','), ['\\Seen'], (error) => {
|
|
|
|
|
if (error != null) {
|
|
|
|
|
rej(error)
|
|
|
|
|
}
|
|
|
|
|
res()
|
|
|
|
|
})
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
2023-07-28 19:50:04 +02:00
|
|
|
private setupDefaultListeners() {
|
|
|
|
|
this.imapClient.on('ready', () => {
|
|
|
|
|
this.loggerService.debug('email client ready')
|
2023-09-26 15:22:05 +02:00
|
|
|
if (this.status === EmailClientStatus.NotReady) {
|
|
|
|
|
this.setStatus(EmailClientStatus.Ready)
|
|
|
|
|
this.openBox()
|
|
|
|
|
}
|
2023-07-28 19:50:04 +02:00
|
|
|
})
|
|
|
|
|
|
|
|
|
|
this.imapClient.on('close', () => {
|
|
|
|
|
this.loggerService.debug('email client close')
|
2023-09-26 15:22:05 +02:00
|
|
|
if (this.status !== EmailClientStatus.Error) {
|
|
|
|
|
this.loggerService.debug('email client reconnecting')
|
2023-08-10 13:33:49 +02:00
|
|
|
this.setStatus(EmailClientStatus.NotReady)
|
2023-09-26 15:22:05 +02:00
|
|
|
this.connect()
|
|
|
|
|
}
|
2023-07-28 19:50:04 +02:00
|
|
|
})
|
2023-07-29 10:49:44 +02:00
|
|
|
|
2023-09-26 15:24:52 +02:00
|
|
|
this.imapClient.on('error', async (error: Error) => {
|
2023-07-29 10:49:44 +02:00
|
|
|
this.loggerService.error(`Error with imap client ${error.message}`)
|
2023-09-26 15:24:52 +02:00
|
|
|
await this.ntfyProvider.sendEmailClientErrorNotification(error.message)
|
2023-08-10 13:33:49 +02:00
|
|
|
this.setStatus(EmailClientStatus.Error)
|
2023-07-29 10:49:44 +02:00
|
|
|
})
|
2023-07-28 19:50:04 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public addCustomListener(
|
|
|
|
|
eventName: string,
|
|
|
|
|
listener: (...args: any[]) => void,
|
|
|
|
|
) {
|
|
|
|
|
this.imapClient.on(eventName, listener)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public removeCustomListener(
|
|
|
|
|
eventName: string,
|
|
|
|
|
listener: (...args: any[]) => void,
|
|
|
|
|
) {
|
|
|
|
|
this.imapClient.off(eventName, listener)
|
|
|
|
|
}
|
|
|
|
|
}
|