249
src/libs/Emails/Email.ts
Normal file
249
src/libs/Emails/Email.ts
Normal file
@ -0,0 +1,249 @@
|
||||
import type { ImapMessageAttributes } from 'imap'
|
||||
import Imap from 'imap'
|
||||
|
||||
interface EmailAddress {
|
||||
name?: string
|
||||
address: string
|
||||
}
|
||||
|
||||
interface Headers {
|
||||
to: Array<EmailAddress>
|
||||
from: EmailAddress
|
||||
date: Date
|
||||
subject: string
|
||||
}
|
||||
|
||||
export default class Email {
|
||||
public constructor(
|
||||
private imap: Imap,
|
||||
private mailbox: Imap.Box,
|
||||
public readonly id: number
|
||||
) {}
|
||||
|
||||
public async getAttributes(): Promise<ImapMessageAttributes | Array<ImapMessageAttributes>> {
|
||||
return new Promise<ImapMessageAttributes | Array<ImapMessageAttributes>>((res) => {
|
||||
this.imap.fetch(this.id, {
|
||||
bodies: [],
|
||||
struct: true
|
||||
}).on('message', (msg) => msg.on('attributes', (attrs) => {
|
||||
res(attrs.struct as Array<ImapMessageAttributes>)
|
||||
}))
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
public async getFlags(): Promise<Array<string>> {
|
||||
|
||||
return new Promise<Array<string>>((res) => {
|
||||
this.imap.fetch(this.id, {
|
||||
bodies: [],
|
||||
struct: false
|
||||
}).on('message', (msg) => msg.on('attributes', (attrs) => {
|
||||
res(attrs.flags)
|
||||
}))
|
||||
})
|
||||
}
|
||||
|
||||
public async getText() {
|
||||
const part = await this.getTextPart()
|
||||
if (!part) {
|
||||
return ''
|
||||
}
|
||||
return this.fetchPart(part.partID).then(this.decodeEmail)
|
||||
}
|
||||
|
||||
public async isSeen() {
|
||||
return this.getFlags().then((arr) => arr.includes('\\Seen'))
|
||||
}
|
||||
|
||||
public async setIsSeen(isSeen: boolean): Promise<boolean> {
|
||||
console.log(this.mailbox.flags, this.mailbox.permFlags, '\\\\Seen')
|
||||
console.log('isSeen', isSeen, await this.isSeen())
|
||||
if (await this.isSeen() === isSeen) {
|
||||
return isSeen
|
||||
}
|
||||
if (isSeen) {
|
||||
return new Promise<boolean>((res, rej) => {
|
||||
this.imap.addFlags(this.id, 'SEEN', (err) => {
|
||||
if (err) {
|
||||
rej(err)
|
||||
return
|
||||
}
|
||||
res(isSeen)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
return new Promise<boolean>((res, rej) => {
|
||||
this.imap.addKeywords(this.id, 'SEEN', (err) => {
|
||||
if (err) {
|
||||
rej(err)
|
||||
return
|
||||
}
|
||||
res(isSeen)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
public async getHeaders(): Promise<Headers> {
|
||||
const req = this.imap.fetch(this.id, {
|
||||
bodies: ['HEADER'],
|
||||
struct: false
|
||||
})
|
||||
return new Promise<Headers>((res) => {
|
||||
req.on('message', (msg) => msg.on('body', (stream) => {
|
||||
let bfr = ''
|
||||
stream
|
||||
.on('data', (chunk) => bfr += chunk.toString('utf8'))
|
||||
.once('end', () => {
|
||||
const tmp = Imap.parseHeader(bfr)
|
||||
function t(email: string): EmailAddress {
|
||||
if (email.includes('<')) {
|
||||
const [name, addr] = email.split('<', 2)
|
||||
if (!addr || !name) {
|
||||
return {
|
||||
address: email
|
||||
}
|
||||
}
|
||||
return {
|
||||
name,
|
||||
address: addr.slice(0, addr.indexOf('>'))
|
||||
}
|
||||
}
|
||||
return {
|
||||
address: email
|
||||
}
|
||||
}
|
||||
// console.log(tmp)
|
||||
res({
|
||||
subject: tmp.subject?.[0] as string,
|
||||
to: tmp.to?.map(t) ?? [],
|
||||
from: t(tmp.from?.[0] ?? ''),
|
||||
date: new Date(tmp.date?.[0] ?? '')
|
||||
})
|
||||
})
|
||||
}))
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* hold the attachment ID
|
||||
*/
|
||||
public async getAttachments(): Promise<Array<any>> {
|
||||
return this.getAttachmentParts()
|
||||
}
|
||||
|
||||
public async downloadAttachment(attachment: any): Promise<{filename: string, data: Buffer}> {
|
||||
const req = this.imap.fetch(this.id, {
|
||||
bodies: [attachment.partID],
|
||||
struct: true
|
||||
})
|
||||
return new Promise((res) => {
|
||||
req.on('message', (msg) => {
|
||||
const filename = attachment.params.name
|
||||
const encoding = attachment.encoding
|
||||
console.log(filename, encoding, msg)
|
||||
let buffer = new Uint8Array(0)
|
||||
msg.on('body', (stream) => {
|
||||
stream.on('data', (chunk: Buffer) => {
|
||||
// merge into one common buffer
|
||||
const len = buffer.length + chunk.length
|
||||
const merged = new Uint8Array(len)
|
||||
merged.set(buffer)
|
||||
merged.set(chunk, buffer.length)
|
||||
buffer = merged
|
||||
}).once('end', () => {
|
||||
res({filename, data: Buffer.from(
|
||||
Buffer
|
||||
.from(buffer)
|
||||
.toString('ascii'),
|
||||
'base64'
|
||||
)})
|
||||
})
|
||||
}).once('end', () => {
|
||||
console.log('ended')
|
||||
}).once('error', (err) => {
|
||||
console.log(err)
|
||||
})
|
||||
}).once('error', (err) => {
|
||||
console.log(err)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
// this is defined but eslint do not seems to reconize it
|
||||
// eslint-disable-next-line no-undef
|
||||
private fetchPart(partID: string, encoding: BufferEncoding = 'utf8'): Promise<string> {
|
||||
const req = this.imap.fetch(this.id, {
|
||||
bodies: partID,
|
||||
struct: true
|
||||
})
|
||||
return new Promise((res) => {
|
||||
req.on('message', (msg) => {
|
||||
msg.on('body', (strm) => {
|
||||
let bfr = ''
|
||||
strm.on('data', (chunk: Buffer) => bfr += chunk.toString(encoding))
|
||||
.once('end', () => res(bfr))
|
||||
})
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
private async getTextPart(attrs?: ImapMessageAttributes | Array<ImapMessageAttributes>): Promise<{partID: string} | null> {
|
||||
if (!attrs) {
|
||||
attrs = await this.getAttributes()
|
||||
}
|
||||
// @ts-expect-error IMAP does not return the correct type
|
||||
for (const item of attrs) {
|
||||
if (Array.isArray(item)) {
|
||||
return this.getTextPart(item)
|
||||
} else if (item.type === 'text' && item.subtype === 'plain') {
|
||||
return item
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
private async getAttachmentParts(attrs?: ImapMessageAttributes | Array<ImapMessageAttributes>): Promise<Array<{partID: string}>> {
|
||||
if (!attrs) {
|
||||
attrs = await this.getAttributes()
|
||||
}
|
||||
const attachments = []
|
||||
for (const item of attrs) {
|
||||
if (Array.isArray(item)) {
|
||||
attachments.push(...await this.getAttachmentParts(item))
|
||||
} else if (item.disposition && ['inline', 'attachment'].indexOf(item.disposition.type.toLowerCase()) > -1) {
|
||||
attachments.push(item)
|
||||
}
|
||||
|
||||
}
|
||||
return attachments
|
||||
}
|
||||
|
||||
private decodeEmail(data: string): string {
|
||||
// normalise end-of-line signals
|
||||
data = data.replace(/(\r\n|\n|\r)/g, '\n')
|
||||
|
||||
// replace equals sign at end-of-line with nothing
|
||||
data = data.replace(/=\n/g, '')
|
||||
|
||||
// encoded text might contain percent signs
|
||||
// decode each section separately
|
||||
const bits = data.split('%')
|
||||
for (let idx = 0; idx < bits.length; idx++) {
|
||||
let char = bits[idx]
|
||||
if (!char) {
|
||||
continue
|
||||
}
|
||||
|
||||
// replace equals sign with percent sign
|
||||
char = char.replace(/=/g, '%')
|
||||
|
||||
// decode the section
|
||||
bits[idx] = decodeURIComponent(char)
|
||||
}
|
||||
|
||||
// join the sections back together
|
||||
return bits.join('%')
|
||||
}
|
||||
}
|
234
src/libs/Emails/EmailServer.ts
Normal file
234
src/libs/Emails/EmailServer.ts
Normal file
@ -0,0 +1,234 @@
|
||||
import { objectFind } from '@dzeio/object-util'
|
||||
import Imap from 'imap'
|
||||
import { getEnv, requireEnv } from 'libs/Env'
|
||||
import nodemailer from 'nodemailer'
|
||||
import type Mail from 'nodemailer/lib/mailer'
|
||||
import Email from './Email'
|
||||
import htmlEmail from './HTML'
|
||||
|
||||
export default class EmailServer {
|
||||
private static instances: Record<string, EmailServer> = {}
|
||||
|
||||
public debug: boolean
|
||||
|
||||
private currentBox: Imap.Box | null = null
|
||||
|
||||
private imap: Imap
|
||||
|
||||
private smtp: nodemailer.Transporter
|
||||
|
||||
private readonly config: {
|
||||
imap: {
|
||||
host: string
|
||||
port: number
|
||||
tls: boolean
|
||||
username: string
|
||||
}
|
||||
smtp: {
|
||||
host: string
|
||||
port: number
|
||||
secure: boolean
|
||||
username: string
|
||||
}
|
||||
}
|
||||
|
||||
private constructor(
|
||||
private username: string,
|
||||
password: string,
|
||||
initConfig?: {
|
||||
auth?: {
|
||||
username: string
|
||||
password: string
|
||||
}
|
||||
imap?: {
|
||||
host: string
|
||||
port?: number
|
||||
tls?: boolean
|
||||
username?: string
|
||||
password?: string
|
||||
}
|
||||
smtp?: {
|
||||
host: string
|
||||
port?: number
|
||||
secure?: boolean
|
||||
username?: string
|
||||
password?: string
|
||||
},
|
||||
debug?: boolean
|
||||
}
|
||||
) {
|
||||
this.debug = initConfig?.debug ?? getEnv('EMAIL_DEBUG', 'false') === 'true'
|
||||
|
||||
this.config = {
|
||||
imap: {
|
||||
host: initConfig?.imap?.host ?? requireEnv('IMAP_HOST'),
|
||||
port: initConfig?.imap?.port ?? Number.parseInt(getEnv('IMAP_PORT', '993'), 10),
|
||||
tls: initConfig?.imap?.tls ?? !!getEnv('IMAP_SECURE'),
|
||||
username: initConfig?.imap?.username ?? initConfig?.auth?.username ?? username
|
||||
},
|
||||
smtp: {
|
||||
host: initConfig?.smtp?.host ?? requireEnv('SMTP_HOST'),
|
||||
port: initConfig?.smtp?.port ?? Number.parseInt(getEnv('SMTP_PORT', '465'), 10),
|
||||
secure: initConfig?.smtp?.secure ?? !!getEnv('SMTP_SECURE'),
|
||||
username: initConfig?.smtp?.username ?? initConfig?.auth?.username ?? username
|
||||
}
|
||||
}
|
||||
|
||||
EmailServer.instances[username] = this
|
||||
|
||||
this.imap = new Imap({
|
||||
user: this.config.imap.username,
|
||||
password: initConfig?.imap?.password ?? initConfig?.auth?.password ?? password,
|
||||
host: this.config.imap.host,
|
||||
port: this.config.imap.port,
|
||||
tls: this.config.imap.tls,
|
||||
debug: (info: string) => {
|
||||
if (this.debug) {
|
||||
console.log('IMAP[DEBUG]:', info)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// biome-ignore lint/suspicious/noExplicitAny: library return `any`
|
||||
const smtpLogger = (level: string) => (...params: Array<any>) => {
|
||||
if (this.debug) {
|
||||
console.log(`SMTP[${level}]:`, ...params)
|
||||
}
|
||||
}
|
||||
|
||||
this.smtp = nodemailer.createTransport({
|
||||
host: this.config.smtp.host,
|
||||
port: this.config.smtp.port,
|
||||
secure: this.config.smtp.secure,
|
||||
auth: {
|
||||
user: this.config.smtp.username,
|
||||
pass: initConfig?.smtp?.password ?? initConfig?.auth?.password ?? password
|
||||
},
|
||||
logger: {
|
||||
level: (level: string) => {
|
||||
if (this.debug) {
|
||||
console.log('SMTP[]:', level)
|
||||
}
|
||||
},
|
||||
trace: smtpLogger('TRACE'),
|
||||
debug: smtpLogger('DEBUG'),
|
||||
info: smtpLogger('INFO'),
|
||||
warn: smtpLogger('WARN'),
|
||||
error: smtpLogger('ERROR'),
|
||||
fatal: smtpLogger('FATAL'),
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
public static async getInstance(type: 'credo' | 'gti') {
|
||||
const items = {
|
||||
credo: {
|
||||
username: requireEnv('CREDO_MAIL_USER'),
|
||||
password: requireEnv('CREDO_MAIL_PASS'),
|
||||
},
|
||||
gti: {
|
||||
username: requireEnv('GTI_MAIL_USER'),
|
||||
password: requireEnv('GTI_MAIL_PASS'),
|
||||
}
|
||||
} as const
|
||||
const correct = items[type]
|
||||
const tmp = objectFind(EmailServer.instances, (_, key) => key === type)
|
||||
let instance = tmp?.value ?? undefined
|
||||
|
||||
if (!instance) {
|
||||
instance = new EmailServer(correct.username, correct.password)
|
||||
EmailServer.instances[type] = instance
|
||||
await instance.connect()
|
||||
}
|
||||
|
||||
return instance
|
||||
}
|
||||
|
||||
public destroy() {
|
||||
delete EmailServer.instances[this.username]
|
||||
this.imap.end()
|
||||
}
|
||||
|
||||
public listEmails(): Promise<Array<Email>> {
|
||||
return new Promise((res, rej) => {
|
||||
this.imap.search(['ALL'], (err, uids) => {
|
||||
if (err) {
|
||||
rej(err)
|
||||
return
|
||||
}
|
||||
res(uids.map((uid) => this.getEmail(uid)))
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
public getEmail(id: number) {
|
||||
if (!this.currentBox) {
|
||||
throw new Error('can\'t fetch a mail while out of a box')
|
||||
}
|
||||
return new Email(this.imap, this.currentBox, id)
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param content the email text content
|
||||
* @param recipient the email recipient (who to send it to)
|
||||
* @param subject the email subject
|
||||
* @param footer the email footer
|
||||
*/
|
||||
// eslint-disable-next-line complexity
|
||||
public async sendEmail(content: string, recipient: string | Array<string>, subject: string, footer?: {path?: string, id?: string}, options: Mail.Options = {}) {
|
||||
if (typeof recipient !== 'string' && recipient.length === 0) {
|
||||
if (this.debug) {
|
||||
console.log('Email canceled, no recipient')
|
||||
}
|
||||
return
|
||||
}
|
||||
const domain = requireEnv('APP_URL')
|
||||
const footerTxt = `\nIdentifiant de conversation: {{${footer?.id}}}
|
||||
|
||||
${footer?.path ? `Votre lien vers le site internet: https://${domain}${footer?.path}` : '' }`
|
||||
|
||||
const req: Mail.Options = Object.assign({
|
||||
bcc: recipient,
|
||||
from: getEnv('SMTP_FROM') ?? this.username,
|
||||
subject,
|
||||
text: content + (footer ? footerTxt : ''),
|
||||
html: footer ? htmlEmail(content, footer?.id, footer?.path ? `${domain}${footer?.path}` : undefined) : undefined
|
||||
}, options)
|
||||
|
||||
if (this.debug) {
|
||||
console.log('------------------- SEND EMAIL DEBUG -------------------')
|
||||
console.log(req)
|
||||
console.log('------------------- SEND EMAIL DEBUG -------------------')
|
||||
} else {
|
||||
const res = await this.smtp.sendMail(req)
|
||||
if (this.debug) {
|
||||
console.log('------------------- SENT EMAIL DEBUG -------------------')
|
||||
console.log(res)
|
||||
console.log('------------------- SENT EMAIL DEBUG -------------------')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private connect() {
|
||||
console.log('Starting connection to IMAP')
|
||||
return new Promise<void>((res, rej) => {
|
||||
this.imap.once('ready', () => {
|
||||
console.log('connection to IMAP ready, opening box')
|
||||
this.imap.openBox(requireEnv('IMAP_INBOX', 'INBOX'), true, (err, box) => {
|
||||
if (err) {
|
||||
rej(err)
|
||||
return
|
||||
}
|
||||
this.currentBox = box
|
||||
console.log('inbox open, ready for queries!')
|
||||
res()
|
||||
})
|
||||
}).once('error', (err: Error) => {
|
||||
console.log('An error occured while connecting to the IMAP server', this.config.imap)
|
||||
rej(err)
|
||||
})
|
||||
this.imap.connect()
|
||||
})
|
||||
}
|
||||
}
|
74
src/libs/Emails/SMTP.ts
Normal file
74
src/libs/Emails/SMTP.ts
Normal file
@ -0,0 +1,74 @@
|
||||
import nodemailer from 'nodemailer'
|
||||
import type NodeMailer from 'nodemailer/lib/mailer'
|
||||
|
||||
/**
|
||||
* Environment variables used
|
||||
* EMAIL_USERNAME
|
||||
* EMAIL_PASSWORD
|
||||
* EMAIL_HOST
|
||||
* EMAIL_FROM
|
||||
*/
|
||||
// eslint-disable-next-line @typescript-eslint/ban-types
|
||||
export default class Mailer<Templates extends Record<string, Record<string, any>> = {}> {
|
||||
public constructor(
|
||||
private fetcher: (template: keyof Templates, ext: 'html' | 'txt') => Promise<{
|
||||
content: string
|
||||
title: string
|
||||
} | null>, private settings: {
|
||||
username: string
|
||||
password: string
|
||||
host: string
|
||||
from: string
|
||||
secure?: boolean
|
||||
tls?: boolean
|
||||
}) {}
|
||||
|
||||
/**
|
||||
* Send the Email
|
||||
* @param template the Template to use
|
||||
* @param to the destination Email
|
||||
* @param vars variables of the email, don't give subject as it is added inside the function
|
||||
* @param options Email options
|
||||
*/
|
||||
public async send<T extends keyof Templates>(template: T, to: string | Array<string>, vars: Templates[T], options?: Omit<NodeMailer.Options, 'to' | 'from' | 'subject' | 'html' | 'text'>) {
|
||||
const mailer = nodemailer.createTransport({
|
||||
host: this.settings.host,
|
||||
auth: {
|
||||
user: this.settings.username,
|
||||
pass: this.settings.password
|
||||
},
|
||||
logger: true,
|
||||
secure: true
|
||||
})
|
||||
const { title } = await this.fetcher(template, 'txt') ?? { title: '' }
|
||||
await mailer.sendMail(Object.assign(options ?? {}, {
|
||||
to,
|
||||
from: this.settings.from,
|
||||
subject: title,
|
||||
html: await this.html(template, { ...vars, subject: title }),
|
||||
text: await this.text(template, { ...vars, subject: title })
|
||||
}))
|
||||
}
|
||||
|
||||
public html<T extends keyof Templates>(template: T, vars: Templates[T]) {
|
||||
return this.generateTemplate(template, vars, 'html')
|
||||
}
|
||||
|
||||
public text<T extends keyof Templates>(template: T, vars: Templates[T]) {
|
||||
return this.generateTemplate(template, vars, 'txt')
|
||||
}
|
||||
|
||||
private async generateTemplate<T extends keyof Templates>(template: T, _vars: Templates[T], ext: 'html' | 'txt') {
|
||||
try {
|
||||
const txt = await this.fetcher(template, ext)
|
||||
if (!txt) {
|
||||
console.warn(`Error, Template not found (${template as string} - ${ext})`)
|
||||
return undefined
|
||||
}
|
||||
return txt.content
|
||||
} catch {
|
||||
console.warn(`Error, Template not found (${template as string} - ${ext})`)
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
}
|
Reference in New Issue
Block a user