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'
|
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
|
|
|
|
|
private mailbox?: Imap.Box
|
|
|
|
|
private status: EmailClientStatus
|
|
|
|
|
|
|
|
|
|
constructor(
|
|
|
|
|
@Inject(LoggerService)
|
|
|
|
|
private readonly loggerService: LoggerService,
|
|
|
|
|
|
|
|
|
|
@Inject(ConfigService)
|
|
|
|
|
private readonly configService: ConfigService,
|
|
|
|
|
) {
|
|
|
|
|
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
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public connect() {
|
|
|
|
|
this.imapClient.connect()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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,
|
|
|
|
|
callback: (emails: Email[]) => void,
|
|
|
|
|
) {
|
|
|
|
|
this.loggerService.log(`Received ${numMessages} emails`)
|
|
|
|
|
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) {
|
|
|
|
|
const startingMessage = totalMessages - newMessages + 1 // starting message is inclusive
|
|
|
|
|
const emails = await this.fetchMails(`${startingMessage}`, '*')
|
|
|
|
|
callback(emails)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private _listen(callback: (emails: Email[]) => void) {
|
|
|
|
|
// Don't start listening until we are ready
|
|
|
|
|
if (this.status === EmailClientStatus.NotReady) {
|
|
|
|
|
throw new Error('Not ready to listen')
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const mailbox = this.configService.get<string>('EMAIL_MAILBOX', 'INBOX')
|
|
|
|
|
this.imapClient.openBox(mailbox, (error, mailbox) => {
|
|
|
|
|
if (error) {
|
|
|
|
|
this.loggerService.error('Error opening mailbox', {
|
|
|
|
|
...error,
|
|
|
|
|
})
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
this.mailbox = mailbox
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
this.imapClient.on('mail', (n: number) => this.handleNewMail(n, callback))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public listen(callback: (emails: Email[]) => void) {
|
|
|
|
|
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
|
|
|
})
|
|
|
|
|
})
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public async fetchMails(startingMessageId: string, endingMessageId: string) {
|
|
|
|
|
this.loggerService.debug(
|
|
|
|
|
`Fetching mails ${startingMessageId}:${endingMessageId}`,
|
|
|
|
|
)
|
|
|
|
|
const fetcher = this.imapClient.fetch(
|
|
|
|
|
`${startingMessageId}:${endingMessageId}`,
|
|
|
|
|
{
|
|
|
|
|
bodies: '',
|
2023-07-29 10:49:44 +02:00
|
|
|
markSeen: true,
|
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)
|
|
|
|
|
})
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private setupDefaultListeners() {
|
|
|
|
|
this.imapClient.on('ready', () => {
|
|
|
|
|
this.loggerService.debug('email client ready')
|
|
|
|
|
this.setStatus(EmailClientStatus.Ready)
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
this.imapClient.on('close', () => {
|
|
|
|
|
this.loggerService.debug('email client close')
|
2023-08-10 13:33:49 +02:00
|
|
|
if (this.status !== EmailClientStatus.Error)
|
|
|
|
|
this.setStatus(EmailClientStatus.NotReady)
|
2023-07-28 19:50:04 +02:00
|
|
|
})
|
|
|
|
|
|
|
|
|
|
this.imapClient.on('end', () => {
|
|
|
|
|
this.loggerService.debug('email client end')
|
2023-08-10 13:33:49 +02:00
|
|
|
if (this.status !== EmailClientStatus.Error)
|
|
|
|
|
this.setStatus(EmailClientStatus.NotReady)
|
2023-07-28 19:50:04 +02:00
|
|
|
})
|
2023-07-29 10:49:44 +02:00
|
|
|
|
|
|
|
|
this.imapClient.on('error', (error: Error) => {
|
|
|
|
|
this.loggerService.error(`Error with imap client ${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)
|
|
|
|
|
}
|
|
|
|
|
}
|