Aller-Couleur / handlebars-i18n

handlebars-i18next.js adds the internationalization features of i18next and Intl to handlebars.js
Other
16 stars 6 forks source link

Document a simple use case from start to finish #51

Closed andrewperry closed 1 month ago

andrewperry commented 1 year ago

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 commented 1 year ago

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 commented 1 year ago

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 commented 1 year ago

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();
                },
            );
        }
    };
}