Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Document a simple use case from start to finish #51

Open
andrewperry opened this issue Nov 24, 2022 · 3 comments
Open

Document a simple use case from start to finish #51

andrewperry opened this issue Nov 24, 2022 · 3 comments

Comments

@andrewperry
Copy link

andrewperry commented Nov 24, 2022

Thanks for the amazing module.

Maybe the readme makes more sense for people who are already using handlebars, but I am looking to implement it in products to make use of your work with i18n, so it's unclear for me.

The readme doesn't seem to provide a simple use case of passing both the language variables AND the regular handlebars variables AND the 18n template to the library to return the appropriate language output with the regular handlebars placeholders filled in.

Maybe this could be added in the Quick Example or under the API section?

@andrewperry
Copy link
Author

andrewperry commented Nov 24, 2022

Looking more closely at the examples, perhaps I just need to be thinking of it being the template that is using the library and provided data to render the work rather than the library being passed the template and data to return a result, and that the use case I am talking about is just for an API.

@andrewperry
Copy link
Author

I think where I am getting confused, is that these examples don't use a translations.json file like what would be generated from https://github.com/fwalzel/handlebars-i18n-cli but instead 'hard code' the translations in the "resources".

@Bjoernstjerne
Copy link

Bjoernstjerne commented Feb 17, 2023

Using this with nodemailer and handlebars. In hbs file we use {{username}} for normal data and {{__ "registerIntroduction" projectName=projectName}} for a combination of data with translation. Maybe this can help you:

import {
    ConfigCustomer,
    ProjectCustomer,
    Whitelabels,
    MailConfig,
} from '@necom/lib';
import SMTPTransport from 'nodemailer/lib/smtp-transport';
import * as fs from 'fs';
import { promisify } from 'util';
import { EmailTaskPayload, EmailTaskUnion } from '../workers/emailWorker';
import HandlebarsI18n from '../internal/handlebarsI18n';
import i18next, { TFunction } from 'i18next';
import Backend from 'i18next-fs-backend';
import {
    DeleteAccountData,
    DeleteAccountSuccessData,
    RegisterData,
    RegisterSuccessData,
    ResetPasswordData,
    TitleTranslation,
} from '../emailTemplates/default/types';
import path from 'path';
import expressHandlebars, { ExpressHandlebars } from 'express-handlebars';
import type * as Handlebars from 'handlebars';
import TurndownService from 'turndown';
import * as Mail from 'nodemailer/lib/mailer';
import { NodemailerExpressHandlebarsOptions } from 'nodemailer-express-handlebars';
import MailMessage from 'nodemailer/lib/mailer/mail-message';
import crypto from 'crypto';
import { Headers } from 'nodemailer/lib/mailer';
import { getDomainName } from './path';

interface TemplateOptions {
    template?: string;
    context?: unknown;
}

interface InternalMailMessage extends MailMessage {
    data: Mail.Options & TemplateOptions;
}

type HbsTransporter = Transporter & {
    sendMail(
        mailOptions: Mail.Options & TemplateOptions,
        callback: (err: Error | null, info: SentMessageInfo) => void,
    ): void;
    sendMail(
        mailOptions: Mail.Options & TemplateOptions,
    ): Promise<SentMessageInfo>;
};

export enum EmailTypes {
    Register = 'Register',
    RegisterSuccess = 'RegisterSuccess',
    ResetPassword = 'ResetPassword',
    DeleteAccount = 'DeleteAccount',
    DeleteAccountSuccess = 'DeleteAccountSuccess',
}

export type EmailRegisterPayload = RegisterData;
export type EmailRegisterSuccessPayload = RegisterSuccessData;
export type EmailDeleteAccountPayload = DeleteAccountData;
export type EmailDeleteAccountSuccessPayload = DeleteAccountSuccessData;
export type EmailResetPasswordPayload = ResetPasswordData;

export type EmailPayload =
    | EmailRegisterPayload
    | EmailDeleteAccountSuccessPayload
    | EmailRegisterSuccessPayload
    | EmailResetPasswordPayload;

type KeysOfUnion<T> = T extends T ? keyof T : never;
type DomainKey = KeysOfUnion<Whitelabels>;

type DomainConfig = {
    from: string;
    transportOptions: SMTPTransport.Options;
    mailConfig: MailConfig;
    envelopeFrom?: string | null;
    tFunction?: TFunction;
    templatePath?: string;
    handlebars?: typeof Handlebars;
};

export class MailService {
    //private readonly data: MailConfig['templateData'];
    private domainConfigs: Record<DomainKey, DomainConfig>;
    private readonly projectName: string;
    private readonly projectConfig: ConfigCustomer<Whitelabels>;
    private jobId: string;

    constructor(projectConfig: ProjectCustomer<unknown>) {
        this.projectConfig = projectConfig.config;
        this.projectName = projectConfig.config.name;
        const domains = projectConfig.config.domains;
        const domainConfigs: Partial<Record<DomainKey, DomainConfig>> = {};
        for (const [domain, node] of Object.entries(domains)) {
            const x = domain as DomainKey;
            const mailConfig = node.mailConfig;
            domainConfigs[x] = {
                mailConfig: mailConfig,
                from: `"${mailConfig.fromName}" <${mailConfig.fromAddress}>`,
                transportOptions: {
                    host: mailConfig.mailHost,
                    port: mailConfig.mailPort,
                    secure: mailConfig.mailSecure,
                },
            };
            if (mailConfig.mailTls) {
                domainConfigs[x].transportOptions.tls = mailConfig.mailTls;
            } else {
                domainConfigs[x].transportOptions.ignoreTLS = true;
            }
            if (mailConfig.mailUsername && mailConfig.mailPassword) {
                domainConfigs[x].transportOptions.auth = {
                    user: mailConfig.mailUsername,
                    pass: mailConfig.mailPassword,
                };
            }
            if (mailConfig.envelope) {
                domainConfigs[x].envelopeFrom = mailConfig.envelope.from;
            }
        }
        this.domainConfigs = domainConfigs as Record<DomainKey, DomainConfig>;
    }

    async sendMail(
        payload: EmailTaskUnion & EmailTaskPayload,
        jobId: string,
    ): Promise<void> {
        this.jobId = jobId;
        if (payload.domain === '') payload.domain = null;
        const domainConfig = await this.init(payload.domain);
        let emailPayload: EmailPayload;
        let type: EmailTypes;
        switch (payload.type) {
            case 'RegisterReminder':
            case 'Register': {
                type = EmailTypes.Register;
                const emailRegisterPayload: EmailRegisterPayload = {
                    ...domainConfig.mailConfig.templateData,
                    title: TitleTranslation.registerTitle,
                    username: payload.name,
                    registerButtonUrl: this.activationLink(
                        payload.validateEmailToken,
                        payload.domain,
                    ),
                    deleteRegistrationUrl: this.deleteLink(
                        payload.deleteToken,
                        payload.domain,
                    ),
                    unsubscribeUrl: this.unsubscribeLink(
                        payload.address,
                        payload.domain,
                    ),
                };
                emailPayload = emailRegisterPayload;
                break;
            }
            case 'RegisterSuccess': {
                type = EmailTypes.RegisterSuccess;
                const emailRegisterSuccessPayload: EmailRegisterSuccessPayload =
                    {
                        ...domainConfig.mailConfig.templateData,
                        title: TitleTranslation.registerSuccessTitle,
                        username: payload.name,
                        unsubscribeUrl: this.unsubscribeLink(
                            payload.address,
                            payload.domain,
                        ),
                    };
                emailPayload = emailRegisterSuccessPayload;
                break;
            }
            case 'ResetPassword': {
                type = EmailTypes.ResetPassword;
                const emailResetPasswordPayload: EmailResetPasswordPayload = {
                    ...domainConfig.mailConfig.templateData,
                    title: TitleTranslation.resetPasswordTitle,
                    unsubscribeUrl: this.unsubscribeLink(
                        payload.address,
                        payload.domain,
                    ),
                    username: payload.name,
                    resetPasswordButtonUrl: this.resetPasswordLink(
                        payload.resetPasswordToken,
                        payload.domain,
                    ),
                };
                emailPayload = emailResetPasswordPayload;
                break;
            }
            case 'DeleteAccount': {
                type = EmailTypes.DeleteAccount;
                const emailDeleteAccountPayload: EmailDeleteAccountPayload = {
                    ...domainConfig.mailConfig.templateData,
                    title: TitleTranslation.deleteAccountTitle,
                    unsubscribeUrl: this.unsubscribeLink(
                        payload.address,
                        payload.domain,
                    ),
                    username: payload.name,
                    deleteAccountUrl: this.deleteLink(
                        payload.deleteToken,
                        payload.domain,
                    ),
                };
                emailPayload = emailDeleteAccountPayload;
                break;
            }
            case 'DeleteAccountSuccess': {
                type = EmailTypes.DeleteAccountSuccess;
                const emailDeleteAccountPayload: EmailDeleteAccountSuccessPayload =
                    {
                        ...domainConfig.mailConfig.templateData,
                        title: TitleTranslation.deleteAccountTitle,
                        unsubscribeUrl: this.unsubscribeLink(
                            payload.address,
                            payload.domain,
                        ),
                        username: payload.name,
                    };
                emailPayload = emailDeleteAccountPayload;
                break;
            }
        }
        await this.send(
            {
                address: payload.address,
                name: payload.name,
                languageCode: payload.languageCode,
                domain: payload.domain,
            },
            type,
            emailPayload,
            domainConfig,
        );
    }

    private async send(
        to: {
            name: string;
            address: string;
            languageCode: string;
            domain: string | null;
        },
        type: EmailTypes,
        context: EmailPayload,
        domainConfig: DomainConfig,
    ): Promise<void> {
        const templateName = type[0].toLowerCase() + type.slice(1);
        if (to.languageCode in i18next.languages) {
            if (i18next.language !== to.languageCode)
                await i18next.changeLanguage(to.languageCode);
        }
        const subject = i18next.t(`${type}Subject`);
        const trans = nodemailer.createTransport(domainConfig.transportOptions);
        const viewEngine = expressHandlebars.create({
            handlebars: domainConfig.handlebars,
            layoutsDir: path.resolve(domainConfig.templatePath, './layouts/'),
            partialsDir: [
                path.resolve(domainConfig.templatePath, './partials/'),
            ],
            extname: '.hbs',
        });
        trans.use(
            'compile',
            this.hbs({
                viewEngine: viewEngine,
                extName: '.hbs',
                viewPath: domainConfig.templatePath,
            }),
        );

        const trans2 = trans as HbsTransporter;
        const headers: Headers = [];
        const result = await trans2.sendMail({
            from: domainConfig.from,
            to: to.address,
            replyTo: domainConfig.from,
            envelope: {
                from: domainConfig.envelopeFrom
                    ? domainConfig.envelopeFrom
                    : domainConfig.from,
                to: to.address,
            },
            subject: subject,
            template: templateName,
            context: { ...context, username: to.name },
            headers: [...headers, { key: 'X-RM-Category', value: type }],
        });
        console.log(this.jobId, result);
    }

    private hbs = (options: NodemailerExpressHandlebarsOptions) => {
        const generator = new TemplateGenerator(options);
        return async (
            mail: InternalMailMessage,
            cb: (err?: Error | null) => void,
        ) => {
            await generator.render(mail, cb);
        };
    };

    private init = async (domain: string | null): Promise<DomainConfig> => {
        let domainName = this.projectName;
        if (domain) domainName = getDomainName(domain);
        if (!this.domainConfigs[domainName as DomainKey].templatePath) {
            const templatePath = path.join(
                __dirname,
                `/../emailTemplates/${domainName}`,
            );
            try {
                await promisify(fs.stat)(templatePath);
                this.domainConfigs[domainName as DomainKey].templatePath =
                    templatePath;
            } catch (e) {
                this.domainConfigs[domainName as DomainKey].templatePath =
                    path.join(__dirname, '/../emailTemplates/default');
            }
        }
        if (!this.domainConfigs[domainName as DomainKey].tFunction) {
            const languages = this.projectConfig.languages.map(
                (language) => language.code,
            );
            let localesPath = path.join(__dirname, `/../locales/${domainName}`);
            try {
                await promisify(fs.stat)(localesPath);
            } catch (e) {
                localesPath = path.join(__dirname, '/../locales/default');
            }
            const translationFile = `${localesPath}/{{lng}}/{{ns}}.json`;
            this.domainConfigs[domainName as DomainKey].tFunction =
                await i18next.use(Backend).init({
                    lng: this.projectConfig.languages[0].code,
                    supportedLngs: languages,
                    preload: languages,
                    ns: ['email'],
                    defaultNS: 'email',
                    backend: {
                        loadPath: translationFile,
                    },
                });
            const service = new HandlebarsI18n();
            this.domainConfigs[domainName as DomainKey].handlebars =
                service.init(i18next);
        }
        return this.domainConfigs[domainName as DomainKey];
    };
    private buildLink = (
        string: string,
        domain: string,
        backend = true,
    ): string => {
        let separator = '/#/';
        if (backend) separator = '/';
        if (string[0] === '/') {
            if (backend) separator = '/';
            else separator = '/#';
        }
        const internalDomain = domain
            ? domain
            : this.projectConfig.defaultDomain;
        return `https://api.${internalDomain}${separator}${string}`;
    };
    activationLink = (validateEmailToken: string, domain: string): string => {
        return this.buildLink(`activate/${validateEmailToken}`, domain);
    };
    deleteLink = (deleteToken: string, domain: string): string => {
        return this.buildLink(`delete/${deleteToken}`, domain);
    };
    unsubscribeLink = (email: string, domain: string): string => {
        return this.buildLink(`unsubscribe/${email}`, domain);
    };
    resetPasswordLink = (
        resetPasswordToken: string,
        domain: string,
    ): string => {
        return this.buildLink(`resetPassword/${resetPasswordToken}`, domain);
    };
}

class TemplateGenerator {
    viewEngine: NodemailerExpressHandlebarsOptions['viewEngine'];
    viewPath: string;
    extName: string;
    turnDown: TurndownService;

    constructor(opts: NodemailerExpressHandlebarsOptions) {
        const viewEngine = opts.viewEngine || {};
        if ('renderView' in viewEngine) {
            this.viewEngine = viewEngine;
        } else {
            this.viewEngine = expressHandlebars.create(viewEngine);
        }
        this.viewPath = opts.viewPath;
        this.extName = opts.extName || '.handlebars';
        this.turnDown = new TurndownService();
    }

    render = async (
        mail: InternalMailMessage,
        cb: (err?: Error | null) => void,
    ) => {
        if (mail.data.html) return cb();

        const templatePath = path.join(
            this.viewPath,
            mail.data.template + this.extName,
        );

        if (this.viewEngine instanceof ExpressHandlebars) {
            this.viewEngine.renderView(
                templatePath,
                mail.data.context,
                (err, body) => {
                    if (err) return cb(err);

                    mail.data.html = body;
                    mail.data.text = this.turnDown.turndown(body);
                    cb();
                },
            );
        }
    };
}

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

No branches or pull requests

2 participants