import * as FileSystem from 'fs'; import * as NodeMailer from 'nodemailer'; import { Attachment, Options as MailOptions } from 'nodemailer/lib/mailer'; import { DyFM_AnyError, DyFM_Array, DyFM_Error, DyFM_Error_Settings, DyFM_Log } from '@futdevpro/fsm-dynamo'; import { DyNTS_global_settings } from '../../_collections/global-settings.const'; import { DyNTS_TestEmailSink_Service } from '../../_modules/test-email-sink/_services/dynts-test-email-sink.service'; export interface DyNTS_EmailService_Settings { host: string, port: number, email: string, pass: string, senderName: string, rootPath: string, templateComponents?: DyNTS_EmailTemplateComponent[], } export class DyNTS_EmailTemplateComponent { name: string; selector: string; templatePath?: string; template?: string; stylePath?: string; styles?: string; headerLinks?: string = ''; properties?: string[] = []; subComponentSelectors?: string[] = []; constructor( set?: DyNTS_EmailTemplateComponent ) { if (set) { Object.assign(this, set); } } } export interface DyNTS_SendEmail_Settings { to: string; subject: string; /** direct email content, if provided, this will be used */ content?: string; templateComponentName?: string; templateProperties?: T; attachments?: Attachment[]; } /** * */ export class DyNTS_EmailService /* extends DyNTS_SingletonService */ { serviceName: string; private readonly nmTransporter: NodeMailer.Transporter; private readonly senderName: string; private readonly senderNEmail: string; private readonly components: DyNTS_EmailTemplateComponent[] = []; private readonly componentsBySelector: { [selector: string]: DyNTS_EmailTemplateComponent } = {}; private readonly componentsByName: { [componentName: string]: DyNTS_EmailTemplateComponent } = {}; private readonly styleLimitWarning: number = 16; readonly defaultErrorUserMsg = `We encountered an uncaught Email Service Error, ` + `\nplease contact the responsible development team.`; constructor ( set: DyNTS_EmailService_Settings ) { this.serviceName = this.constructor?.name; this.senderName = set.senderName; this.senderNEmail = `${set.senderName} <${set.email}>`; this.nmTransporter = NodeMailer.createTransport({ host: set.host, port: set.port, auth: { user: set.email, pass: set.pass, }, }); this.components = set.templateComponents ?? []; } async asyncPostConstruct(): Promise { try { if (this.components) { await DyFM_Array.asyncForEach( this.components, async (component: DyNTS_EmailTemplateComponent): Promise => { await this.loadEmailTemplateComponent(component); } ); this.connectComponents(); } DyFM_Log.success(`\nEmailService construction (${this.senderName}) Finished!`); } catch (error) { throw new DyFM_Error({ ...this._getDefaultErrorSettings('asyncPostConstruct', error, 'SYSTEM'), errorCode: `${DyNTS_global_settings.systemShortCodeName}|DyNTS-ES0-APC0`, }); } } /** * * @param set */ public async sendEmail( set: DyNTS_SendEmail_Settings, issuer: string ): Promise { try { let content: string; if (set.content) { content = set.content; } else { if (!set.templateComponentName) { throw new DyFM_Error({ ...this._getDefaultErrorSettings( 'sendEmail', new Error(`No email template component is given!`), issuer ), errorCode: `${DyNTS_global_settings.systemShortCodeName}|DyNTS-ES0-SE1`, }); } if (!this.componentsByName[set.templateComponentName]) { throw new DyFM_Error({ ...this._getDefaultErrorSettings( 'sendEmail', new Error( `No email template component found with this name! (${set.templateComponentName})` ), issuer ), errorCode: `${DyNTS_global_settings.systemShortCodeName}|DyNTS-ES0-SE2`, additionalContent: { availableComponenets: Object.keys(this.componentsByName), }, }); } content = await this.compileTemplateComponent( set.templateComponentName, set.templateProperties, issuer ); } // BFR-MASTERPROMPTER-015 (MP-19) — test-email SINK: local/test kornyezetben a teszt-cim-mintara // (default: *@e2e.test.futdevpro.local) meno kuldes NEM SMTP-re megy, hanem DB-be (olvasas: // DyNTS_getSentMailRoutingModule). Minden mas eset VALTOZATLAN; prod-on a sink hard-gate-elt. // Ez determinizalja a register/email-verify E2E journey-ket (a kezbesithetetlen cim nem dob SMTP-hibat). if (DyNTS_TestEmailSink_Service.shouldSink(set.to)) { await DyNTS_TestEmailSink_Service.storeSentMail({ to: set.to, subject: set.subject, html: content, issuer: issuer, }); return; } const mailOptions: MailOptions = { from: this.senderNEmail, to: set.to, subject: set.subject, html: content, attachments: set.attachments, }; await new Promise((resolve, reject): void => { this.nmTransporter.sendMail(mailOptions, (error): void => { if (error) { reject(error); } else { resolve(); } }); }); } catch (error) { if ((error as Error).message?.includes?.('all recipients were rejected')) { throw new DyFM_Error({ ...this._getDefaultErrorSettings('sendEmail', error, issuer), addECToUserMsg: false, errorCode: `${DyNTS_global_settings.systemShortCodeName}|DyNTS-ES0-SE4`, userMessage: `Can't send mail to ${set.to}!`, message: `sendEmail failed to ${set.to}`, }); } else { throw new DyFM_Error({ ...this._getDefaultErrorSettings('sendEmail', error, issuer), errorCode: `${DyNTS_global_settings.systemShortCodeName}|DyNTS-ES0-SE0`, message: `sendEmail failed to ${set.to}`, }); } } } private async compileTemplateComponent( componentName: string, componentProperties:T, issuer: string ): Promise { try { if (!this.componentsByName[componentName]) { throw new DyFM_Error({ ...this._getDefaultErrorSettings( 'setupComponent', new Error(`No email component found with this name! (${componentName})`), issuer ), errorCode: `${DyNTS_global_settings.systemShortCodeName}|DyNTS-ES0-SC1`, additionalContent: { availableComponents: Object.keys(this.componentsByName), }, }); } const component: DyNTS_EmailTemplateComponent = this.componentsByName[componentName]; let template: string = this.compileHTMLMainFrame(component); template = template.replace('', ''); template = await this.compileComponentProperties( component, componentProperties, template, issuer ); await DyFM_Array.asyncForEach( component.subComponentSelectors, async (subComponentSelector: string): Promise => { while (template.includes(`( subComponentSelector, componentProperties, template, issuer ); } } ); template = this.templateTrim(template); return template; } catch (error) { throw new DyFM_Error({ ...this._getDefaultErrorSettings('setupComponent', error, issuer), errorCode: `${DyNTS_global_settings.systemShortCodeName}|DyNTS-ES0-SC0`, message: `setupComponent failed! (${componentName})`, }); } } private compileHTMLMainFrame(component: DyNTS_EmailTemplateComponent): string { const allLinks: string[] = []; const allStyles: string[] = []; if (component.headerLinks) { allLinks.push(component.headerLinks); } if (component.styles) { allStyles.push(component.styles); } component.subComponentSelectors.forEach((subComponentSelector: string): void => { const subComponent: DyNTS_EmailTemplateComponent = this.componentsBySelector[subComponentSelector]; if (subComponent.headerLinks) { allLinks.push(subComponent.headerLinks.trim()); } if (subComponent.styles) { allStyles.push(subComponent.styles.trim()); } }); const styles = this.styleTrim(allStyles.join('')); const styleSize = this.getStringKBSize(styles); if (this.styleLimitWarning < styleSize) { DyFM_Log.warn( `\nDynamoNTS EmailService WARNING (${this.serviceName}), ` + `\nEmail template styles are too big! (${styleSize}KB)` + `\ncomponent: ${component.name} (${component.selector})` + `\nSome email clients may not support this!` + `\nThe limit is ${this.styleLimitWarning}KB!` ); } return `\n` + `\n` + `\n ` + `\n ${allLinks.join('')}` + `\n` + `\n ` + `\n ` + `\n ` + `\n ${component.template}` + `\n ` + `\n `; } /** trims all rows and remove everything between /*...*\/ */ private styleTrim(style: string): string { let result: string = style?.split('\n').map((row: string): string => row.trim()).join(''); while (result?.includes('/*')) { const start = result.indexOf('/*'); const end = result.indexOf('*/', start) + 2; result = result.substring(0, start) + result.substring(end); } return result; } /** removes HTML comments */ private templateTrim(template: string): string { let result: string = template; while (result.includes('', start) + 3; result = result.substring(0, start) + result.substring(end); } return result; } private getStringKBSize(str: string): number { const b: number = str.length * 2; return b / 1024; } private async compileComponentProperties( component: DyNTS_EmailTemplateComponent, componentProperties: T, template: string, issuer: string ): Promise { try { component.properties.forEach((propertyKey: string): void => { if (!componentProperties[propertyKey]) { throw new DyFM_Error({ ...this._getDefaultErrorSettings( 'setupComponent', new Error( `ComponentProperty missing from input! "${propertyKey}" for "${component.name}"` ), issuer ), errorCode: `${DyNTS_global_settings.systemShortCodeName}|DyNTS-ES0-SC4`, additionalContent: { componentProperties: Object.keys(componentProperties), }, }); } const propReg = new RegExp(`{{${propertyKey}}}`, 'g'); template = template.replace(propReg, componentProperties[propertyKey]); }); return template; } catch (error) { throw new DyFM_Error({ ...this._getDefaultErrorSettings('setupComponent', error, issuer), errorCode: `${DyNTS_global_settings.systemShortCodeName}|DyNTS-ES0-SC3`, }); } } private async compileSubComponent( subComponentSelector: string, componentProperties: T, template: string, issuer: string ): Promise { const subComponent: DyNTS_EmailTemplateComponent = this.componentsBySelector[subComponentSelector]; if (!subComponent) { throw new DyFM_Error({ ...this._getDefaultErrorSettings( 'setupComponent', new Error(`SubComponent missing from components! "${subComponentSelector}"`), issuer ), errorCode: `${DyNTS_global_settings.systemShortCodeName}|DyNTS-ES0-SC2`, additionalContent: this.componentsBySelector, }); } let subComponentTemplate: string = subComponent.template; const startTag = ``; const tagHTMLProperties: string = template.split(startTag)[1].split('>')[0]; let componentContent: string; if (!tagHTMLProperties) { componentContent = template.split(`${startTag}>`)[1].split(endTag)[0]; } else { componentContent = template.split(startTag)[1].split('>')[1].split(endTag)[0]; } const contentReg = new RegExp(``, 'g'); subComponentTemplate = subComponentTemplate.replace(contentReg, componentContent); subComponentTemplate = await this.compileComponentProperties( subComponent, componentProperties, subComponentTemplate, issuer ); const startTagReg = new RegExp(startTag, 'g'); const endTagReg = new RegExp(endTag, 'g'); template = template.replace(startTagReg, ''); /* while (template.includes(` { try { if (this.componentsBySelector[component.selector]) { DyFM_Log.warn( `Email component already loaded! (it will be skipped) "${component.selector}"` ); return; } if (!component.templatePath && !component.template) { throw new DyFM_Error({ ...this._getDefaultErrorSettings( 'loadComponent', new Error(`Component missing template and templatePath! "${component.name}"`), 'SYSTEM' ), errorCode: `${DyNTS_global_settings.systemShortCodeName}|DyNTS-ES0-LC2`, additionalContent: { component: component, }, }); } if (!component.template) { component.template = await this.readFile(component.templatePath, component.name).catch( async (error: DyFM_Error): Promise => { if (DyNTS_global_settings.autoResearchIssues) { // read folder contents, read parent folder contents const folderContents = FileSystem.readdirSync( component.templatePath.split('/').slice(0, -1).join('/') ); const parentFolderContents = FileSystem.readdirSync( component.templatePath.split('/').slice(0, -2).join('/') ); DyFM_Log.testWarn( `DynamoNTS EmailService WARNING, couldn't load email component's template; ` + `\n(${component.name} from ${component.templatePath})` + `\n folder contents: \n ${folderContents.join(', \n ')}` + `\n parent folder contents: \n ${parentFolderContents.join(', \n ')}` ); } else { DyFM_Log.error( `\nDynamoNTS EmailService ERROR, couldn't load email component's template; ` + `\n(${component.name} from ${component.templatePath})` ); } throw error; } ); } if (component.stylePath) { await new Promise((resolve, reject): void => { FileSystem.readFile(component.stylePath, 'utf8' , (err, styles): void => { if (err || !styles) { if (!err) { err = new Error( `Couldn't load email component's styles; ${component.name} ` + `from ${component.stylePath}` ); } reject( new DyFM_Error({ ...this._getDefaultErrorSettings('loadTemplate', err, 'SYSTEM'), errorCode: `${DyNTS_global_settings.systemShortCodeName}|DyNTS-ES0-LC4`, }) ); return; } component.styles = styles; resolve(); }); }); } component.properties = this.getTemplatePropertyKeys(component.template); this.componentsBySelector[component.selector] = component; this.componentsByName[component.name] = component; component.properties.forEach((propertyKey: string): void => { if (!component.template.includes(`{{${propertyKey}}}`)) { throw new DyFM_Error({ ...this._getDefaultErrorSettings( 'loadComponent', new Error( `TemplateProperty missing from template! (${propertyKey} for ${component.name})` ), 'SYSTEM' ), errorCode: `${DyNTS_global_settings.systemShortCodeName}|DyNTS-ES0-LC5`, additionalContent: component.template, }); } }); } catch (error) { throw new DyFM_Error({ ...this._getDefaultErrorSettings('loadComponent', error, 'SYSTEM'), errorCode: `${DyNTS_global_settings.systemShortCodeName}|DyNTS-ES0-LC0`, message: `loadComponent failed! (${component.name})`, }); } } private async readFile(path: string, nameOfTarget: string): Promise { return await new Promise((resolve, reject): void => { FileSystem.readFile(path, 'utf8' , (err, template): void => { if (err || !template) { err ??= new Error( `Couldn't load email component's template; \n ${nameOfTarget} ` + `\n from ${path}` ); reject( new DyFM_Error({ ...this._getDefaultErrorSettings('loadTemplate', err, 'SYSTEM'), errorCode: `${DyNTS_global_settings.systemShortCodeName}|DyNTS-ES0-LC3`, }) ); return; } resolve(template); }); }); } private connectComponents(): void { try { for (const componentSelector in this.componentsBySelector) { this.componentsBySelector[componentSelector].subComponentSelectors = []; const innerTags: string[] = this.componentsBySelector[componentSelector].template.split(' { const subComponentSelector = subComponents.split('>')[0].split(' ')[0]; if (subComponentSelector !== 'content') { const fullSelector = 'nts-' + subComponentSelector; if (fullSelector) { if (!this.componentsBySelector[subComponentSelector]) { throw new DyFM_Error({ ...this._getDefaultErrorSettings( 'connectComponents', new Error(`SubComponent missing from components! (${fullSelector})`), 'SYSTEM' ), errorCode: `${DyNTS_global_settings.systemShortCodeName}|DyNTS-ES0-CC1`, additionalContent: { availableComponenets: Object.keys(this.componentsBySelector), }, }); } this.componentsBySelector[componentSelector].subComponentSelectors.push( subComponentSelector ); } } } ); } } catch (error) { throw new DyFM_Error({ ...this._getDefaultErrorSettings('connectComponents', error, 'SYSTEM'), errorCode: `${DyNTS_global_settings.systemShortCodeName}|DyNTS-ES0-CC0`, message: `connectComponents failed!`, }); } } /** * * @param template * @returns */ private getTemplatePropertyKeys(template: string): string[] { try { const propertyKeys: string[] = []; let propertyOpenTagIndex = template.indexOf('{{'); let propertyCloseTagIndex = template.indexOf('}}', propertyOpenTagIndex + 2); while (propertyOpenTagIndex >= 0) { if (propertyCloseTagIndex === -1) { DyFM_Log.error( `\nDyNTS_EmailService ERROR, missing closing tag from email template! ` + `(${propertyOpenTagIndex} -)`, propertyKeys ); throw new DyFM_Error({ errorCode: `${DyNTS_global_settings.systemShortCodeName}|DyNTS-ES0-200`, addECToUserMsg: true, message: `ERROR, missing closing tag from email template! (${propertyOpenTagIndex} -)`, userMessage: this.defaultErrorUserMsg, }); } const newKey: string = template.substring(propertyOpenTagIndex + 2, propertyCloseTagIndex); if (!propertyKeys.includes(newKey)) { propertyKeys.push(newKey); } // KELLEZIDE? propertyOpenTagIndex = template.indexOf('{{', propertyOpenTagIndex + 2); propertyCloseTagIndex = template.indexOf('}}', propertyOpenTagIndex + 2); } // console.log('\n\n\nTEST propertyKeys: ', propertyKeys); return propertyKeys; } catch (error) { throw new DyFM_Error({ ...this._getDefaultErrorSettings('getTemplatePropertyKeys', error, 'SYSTEM'), errorCode: `${DyNTS_global_settings.systemShortCodeName}|DyNTS-ES0-GTPK0`, message: `getTemplatePropertyKeys failed!`, additionalContent: template, }); } } private _getDefaultErrorSettings( fnName: string, error: DyFM_AnyError, issuer: string ): DyFM_Error_Settings { return { status: (error as DyFM_Error)?.___status ?? 500, message: (error as Error)?.message ?? (error as DyFM_Error)?._message ?? `${fnName} was UNSUCCESSFUL (NTS; ${this.serviceName})`, addECToUserMsg: !(error as DyFM_Error)?.__userMessage, userMessage: (error as DyFM_Error)?.__userMessage ?? this.defaultErrorUserMsg, issuer: issuer, issuerService: this.serviceName, error: error, }; } protected getDefaultErrorSettings( fnName: string, error: DyFM_AnyError, issuer: string, /** @deprecated we wont support the separate user message in the future, use the message instead */ useMessageAsUserMessage: boolean = true, ): DyFM_Error_Settings { return { status: (error as DyFM_Error)?.___status ?? 500, message: (error as Error)?.message ?? (error as DyFM_Error)?._message ?? `${fnName} was UNSUCCESSFUL (${DyNTS_global_settings.systemShortCodeName})`, addECToUserMsg: !(error as DyFM_Error)?.__userMessage, userMessage: (error as DyFM_Error)?.__userMessage ?? (useMessageAsUserMessage ? (error as Error)?.message : this.defaultErrorUserMsg), issuer: issuer, issuerService: this.constructor?.name, error: error, }; } }