feat: Filemagedon
Some checks failed
Build, check & Test / run (push) Failing after 1m45s
Lint / run (push) Failing after 48s
Build Docker Image / build_docker (push) Failing after 3m18s

Signed-off-by: Avior <git@avior.me>
This commit is contained in:
2024-09-11 14:38:58 +02:00
parent 3e91597dca
commit bc97d9106b
45 changed files with 4548 additions and 64 deletions

249
src/libs/Emails/Email.ts Normal file
View 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('%')
}
}

View 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
View 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
}
}
}