rl-core-api 0.19.7 → 0.19.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -139,6 +139,23 @@ Swagger, o remetente dos emails e o emissor exibido no app autenticador:
139
139
  Quem já declarava `TOTP_ISSUER` ou `EMAIL_FROM_NAME` continua igual: eles só
140
140
  caem no `APP_NAME` quando estão ausentes.
141
141
 
142
+ ### Os emails
143
+
144
+ O HTML das três mensagens do core — código 2FA, redefinição de senha e
145
+ primeiro acesso — mora em `src/core/mailer/templates/`, um arquivo `.hbs` por
146
+ mensagem, mais o `layout.hbs` (cabeçalho, título e rodapé) e o `header.hbs`
147
+ (logo ou nome). O `MailerService` só monta os dados e chama o
148
+ `MailTemplateService`, que compila e guarda o template compilado.
149
+
150
+ Handlebars escapa o que interpola, então nome de usuário entra no email sem
151
+ ninguém precisar lembrar de escapar. O que é HTML montado pelo próprio serviço
152
+ — o corpo dentro da moldura — passa por `raw()`, que é a única porta de
153
+ entrada sem escape.
154
+
155
+ Os `.hbs` viajam no pacote publicado (`assets` do `nest-cli.json` os copia
156
+ para o `dist`) e são resolvidos por `__dirname`, então valem igual em
157
+ desenvolvimento e instalados em `node_modules`.
158
+
142
159
  Para acrescentar variáveis suas, estenda o schema:
143
160
 
144
161
  ```ts
@@ -0,0 +1,10 @@
1
+ import * as Handlebars from "handlebars";
2
+ export type MailTemplateContext = Record<string, string | number | boolean | null | undefined | Handlebars.SafeString>;
3
+ export declare class MailTemplateService {
4
+ private readonly directory;
5
+ private readonly compiled;
6
+ render(name: string, context: MailTemplateContext): string;
7
+ raw(html: string): Handlebars.SafeString;
8
+ private templateOf;
9
+ private sourceOf;
10
+ }
@@ -0,0 +1,79 @@
1
+ "use strict";
2
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
+ if (k2 === undefined) k2 = k;
4
+ var desc = Object.getOwnPropertyDescriptor(m, k);
5
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
+ desc = { enumerable: true, get: function() { return m[k]; } };
7
+ }
8
+ Object.defineProperty(o, k2, desc);
9
+ }) : (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ o[k2] = m[k];
12
+ }));
13
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
14
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
15
+ }) : function(o, v) {
16
+ o["default"] = v;
17
+ });
18
+ var __decorate = (this && this.__decorate) || function (decorators, target, key, desc) {
19
+ var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
20
+ if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc);
21
+ else for (var i = decorators.length - 1; i >= 0; i--) if (d = decorators[i]) r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r;
22
+ return c > 3 && r && Object.defineProperty(target, key, r), r;
23
+ };
24
+ var __importStar = (this && this.__importStar) || (function () {
25
+ var ownKeys = function(o) {
26
+ ownKeys = Object.getOwnPropertyNames || function (o) {
27
+ var ar = [];
28
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
29
+ return ar;
30
+ };
31
+ return ownKeys(o);
32
+ };
33
+ return function (mod) {
34
+ if (mod && mod.__esModule) return mod;
35
+ var result = {};
36
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
37
+ __setModuleDefault(result, mod);
38
+ return result;
39
+ };
40
+ })();
41
+ Object.defineProperty(exports, "__esModule", { value: true });
42
+ exports.MailTemplateService = void 0;
43
+ const node_fs_1 = require("node:fs");
44
+ const node_path_1 = require("node:path");
45
+ const common_1 = require("@nestjs/common");
46
+ const Handlebars = __importStar(require("handlebars"));
47
+ let MailTemplateService = class MailTemplateService {
48
+ constructor() {
49
+ this.directory = (0, node_path_1.join)(__dirname, "templates");
50
+ this.compiled = new Map();
51
+ }
52
+ render(name, context) {
53
+ return this.templateOf(name)(context);
54
+ }
55
+ raw(html) {
56
+ return new Handlebars.SafeString(html);
57
+ }
58
+ templateOf(name) {
59
+ const cached = this.compiled.get(name);
60
+ if (cached) {
61
+ return cached;
62
+ }
63
+ const template = Handlebars.compile(this.sourceOf(name));
64
+ this.compiled.set(name, template);
65
+ return template;
66
+ }
67
+ sourceOf(name) {
68
+ try {
69
+ return (0, node_fs_1.readFileSync)((0, node_path_1.join)(this.directory, `${name}.hbs`), "utf8");
70
+ }
71
+ catch {
72
+ throw new Error(`Template de email não encontrado: ${name}.hbs`);
73
+ }
74
+ }
75
+ };
76
+ exports.MailTemplateService = MailTemplateService;
77
+ exports.MailTemplateService = MailTemplateService = __decorate([
78
+ (0, common_1.Injectable)()
79
+ ], MailTemplateService);
@@ -9,13 +9,14 @@ Object.defineProperty(exports, "__esModule", { value: true });
9
9
  exports.MailerModule = void 0;
10
10
  const common_1 = require("@nestjs/common");
11
11
  const mailer_service_1 = require("./mailer.service");
12
+ const mailTemplate_service_1 = require("./mailTemplate.service");
12
13
  let MailerModule = class MailerModule {
13
14
  };
14
15
  exports.MailerModule = MailerModule;
15
16
  exports.MailerModule = MailerModule = __decorate([
16
17
  (0, common_1.Global)(),
17
18
  (0, common_1.Module)({
18
- providers: [mailer_service_1.MailerService],
19
+ providers: [mailTemplate_service_1.MailTemplateService, mailer_service_1.MailerService],
19
20
  exports: [mailer_service_1.MailerService],
20
21
  })
21
22
  ], MailerModule);
@@ -1,12 +1,12 @@
1
1
  import { EnvService } from "../config/env.service";
2
+ import { MailTemplateService } from "./mailTemplate.service";
2
3
  export declare class MailerService {
3
4
  private readonly env;
5
+ private readonly templates;
4
6
  private readonly logger;
5
7
  private readonly transporter;
6
- constructor(env: EnvService);
8
+ constructor(env: EnvService, templates: MailTemplateService);
7
9
  private send;
8
- private escapeHtml;
9
- private header;
10
10
  private layout;
11
11
  sendTwoFactorCode(to: string, code: string, userName: string): Promise<void>;
12
12
  sendPasswordReset(to: string, token: string, userName: string): Promise<void>;
@@ -47,9 +47,11 @@ exports.MailerService = void 0;
47
47
  const common_1 = require("@nestjs/common");
48
48
  const nodemailer = __importStar(require("nodemailer"));
49
49
  const env_service_1 = require("../config/env.service");
50
+ const mailTemplate_service_1 = require("./mailTemplate.service");
50
51
  let MailerService = MailerService_1 = class MailerService {
51
- constructor(env) {
52
+ constructor(env, templates) {
52
53
  this.env = env;
54
+ this.templates = templates;
53
55
  this.logger = new common_1.Logger(MailerService_1.name);
54
56
  this.transporter = nodemailer.createTransport({
55
57
  host: this.env.get("EMAIL_HOST"),
@@ -72,64 +74,35 @@ let MailerService = MailerService_1 = class MailerService {
72
74
  throw error;
73
75
  }
74
76
  }
75
- escapeHtml(value) {
76
- return value
77
- .replace(/&/g, "&amp;")
78
- .replace(/</g, "&lt;")
79
- .replace(/>/g, "&gt;")
80
- .replace(/"/g, "&quot;")
81
- .replace(/'/g, "&#39;");
82
- }
83
- header(brand) {
84
- if (brand.logoUrl) {
85
- return `<img src="${brand.logoUrl}" alt="${this.escapeHtml(brand.name)}" style="max-height:40px;max-width:200px;">`;
86
- }
87
- return `<span style="font-size:20px;font-weight:bold;color:${brand.color};">${this.escapeHtml(brand.name)}</span>`;
88
- }
89
77
  layout(title, body) {
90
78
  const brand = this.env.brand();
91
- return `
92
- <div style="font-family: Arial, sans-serif; padding: 20px; max-width: 600px; margin: 0 auto;">
93
- <div style="padding-bottom:20px;border-bottom:1px solid #ddd;margin-bottom:24px;">
94
- ${this.header(brand)}
95
- </div>
96
- <h2 style="color: #333;">${title}</h2>
97
- ${body}
98
- <hr style="border: none; border-top: 1px solid #ddd; margin: 30px 0;">
99
- <p style="color: #999; font-size: 12px;">
100
- Mensagem automática de ${this.escapeHtml(brand.name)}, não responda.
101
- </p>
102
- </div>`;
79
+ return this.templates.render("layout", {
80
+ title,
81
+ body: this.templates.raw(body),
82
+ header: this.templates.raw(this.templates.render("header", {
83
+ logoUrl: brand.logoUrl,
84
+ name: brand.name,
85
+ color: brand.color,
86
+ })),
87
+ brandName: brand.name,
88
+ });
103
89
  }
104
90
  async sendTwoFactorCode(to, code, userName) {
105
- const expiry = this.env.get("TWO_FACTOR_EMAIL_CODE_EXPIRY");
106
- const color = this.env.brand().color;
107
- const body = `
108
- <p>Olá ${this.escapeHtml(userName)},</p>
109
- <p>Seu código de verificação (2FA) é:</p>
110
- <div style="text-align:center;margin:30px 0;">
111
- <div style="background:#f5f5f5;padding:20px;border-radius:5px;display:inline-block;">
112
- <h1 style="margin:0;font-size:36px;letter-spacing:8px;color:${color};">${code}</h1>
113
- </div>
114
- </div>
115
- <p><strong>Este código expira em ${expiry} minutos.</strong></p>
116
- <p>Se você não tentou fazer login, proteja sua conta imediatamente.</p>`;
91
+ const body = this.templates.render("twoFactorCode", {
92
+ userName,
93
+ code,
94
+ color: this.env.brand().color,
95
+ expiry: this.env.get("TWO_FACTOR_EMAIL_CODE_EXPIRY"),
96
+ });
117
97
  await this.send(to, "Seu código de verificação (2FA)", this.layout("Autenticação em duas etapas", body));
118
98
  }
119
99
  async sendPasswordReset(to, token, userName) {
120
- const url = `${this.env.get("APP_URL")}/reset-password?token=${token}`;
121
- const expiry = this.env.get("PASSWORD_RESET_TOKEN_EXPIRY");
122
- const color = this.env.brand().color;
123
- const body = `
124
- <p>Olá ${this.escapeHtml(userName)},</p>
125
- <p>Recebemos uma solicitação para redefinir sua senha. Clique no botão abaixo:</p>
126
- <div style="text-align:center;margin:30px 0;">
127
- <a href="${url}" style="background:${color};color:#fff;padding:12px 30px;text-decoration:none;border-radius:5px;display:inline-block;">Redefinir senha</a>
128
- </div>
129
- <p>Ou copie e cole este link no navegador:</p>
130
- <p style="word-break:break-all;color:#666;">${url}</p>
131
- <p><strong>Este link expira em ${expiry} minutos.</strong></p>
132
- <p>Se você não solicitou, ignore este email.</p>`;
100
+ const body = this.templates.render("passwordReset", {
101
+ userName,
102
+ url: this.templates.raw(`${this.env.get("APP_URL")}/reset-password?token=${token}`),
103
+ color: this.env.brand().color,
104
+ expiry: this.env.get("PASSWORD_RESET_TOKEN_EXPIRY"),
105
+ });
133
106
  await this.send(to, "Redefinição de senha", this.layout("Redefinição de senha", body));
134
107
  }
135
108
  formatExpiry(minutes) {
@@ -140,24 +113,20 @@ let MailerService = MailerService_1 = class MailerService {
140
113
  return hours === 1 ? "1 hora" : `${hours} horas`;
141
114
  }
142
115
  async sendFirstAccess(to, token, userName) {
143
- const url = `${this.env.get("APP_URL")}/reset-password?token=${token}`;
144
- const expiry = this.formatExpiry(this.env.get("FIRST_ACCESS_TOKEN_EXPIRY"));
145
116
  const brand = this.env.brand();
146
- const body = `
147
- <p>Olá ${this.escapeHtml(userName)},</p>
148
- <p>Sua conta em ${this.escapeHtml(brand.name)} foi criada. Para acessar, defina sua senha clicando no botão abaixo:</p>
149
- <div style="text-align:center;margin:30px 0;">
150
- <a href="${url}" style="background:${brand.color};color:#fff;padding:12px 30px;text-decoration:none;border-radius:5px;display:inline-block;">Definir minha senha</a>
151
- </div>
152
- <p>Ou copie e cole este link no navegador:</p>
153
- <p style="word-break:break-all;color:#666;">${url}</p>
154
- <p><strong>Este link expira em ${expiry}.</strong></p>
155
- <p>No primeiro login você também precisará configurar a autenticação em duas etapas (2FA).</p>`;
117
+ const body = this.templates.render("firstAccess", {
118
+ userName,
119
+ brandName: brand.name,
120
+ url: this.templates.raw(`${this.env.get("APP_URL")}/reset-password?token=${token}`),
121
+ color: brand.color,
122
+ expiry: this.formatExpiry(this.env.get("FIRST_ACCESS_TOKEN_EXPIRY")),
123
+ });
156
124
  await this.send(to, "Defina sua senha de primeiro acesso", this.layout("Primeiro acesso", body));
157
125
  }
158
126
  };
159
127
  exports.MailerService = MailerService;
160
128
  exports.MailerService = MailerService = MailerService_1 = __decorate([
161
129
  (0, common_1.Injectable)(),
162
- __metadata("design:paramtypes", [env_service_1.EnvService])
130
+ __metadata("design:paramtypes", [env_service_1.EnvService,
131
+ mailTemplate_service_1.MailTemplateService])
163
132
  ], MailerService);
@@ -0,0 +1,9 @@
1
+ <p>Olá {{userName}},</p>
2
+ <p>Sua conta em {{brandName}} foi criada. Para acessar, defina sua senha clicando no botão abaixo:</p>
3
+ <div style="text-align:center;margin:30px 0;">
4
+ <a href="{{url}}" style="background:{{color}};color:#fff;padding:12px 30px;text-decoration:none;border-radius:5px;display:inline-block;">Definir minha senha</a>
5
+ </div>
6
+ <p>Ou copie e cole este link no navegador:</p>
7
+ <p style="word-break:break-all;color:#666;">{{url}}</p>
8
+ <p><strong>Este link expira em {{expiry}}.</strong></p>
9
+ <p>No primeiro login você também precisará configurar a autenticação em duas etapas (2FA).</p>
@@ -0,0 +1,12 @@
1
+ {{!--
2
+ O cabeçalho da marca: a logo quando houver uma, senão o nome.
3
+
4
+ A logo precisa ser URL absoluta e pública (`APP_LOGO_URL`) — o cliente de
5
+ email busca a imagem do servidor dele e não alcança os assets da interface.
6
+ O `alt` com o nome cobre quem bloqueia imagem por padrão, que é a maioria.
7
+ --}}
8
+ {{#if logoUrl}}
9
+ <img src="{{logoUrl}}" alt="{{name}}" style="max-height:40px;max-width:200px;">
10
+ {{else}}
11
+ <span style="font-size:20px;font-weight:bold;color:{{color}};">{{name}}</span>
12
+ {{/if}}
@@ -0,0 +1,18 @@
1
+ {{!--
2
+ A moldura de toda mensagem: cabeçalho da marca, título, corpo e rodapé.
3
+
4
+ `header` e `body` chegam como HTML pronto, marcado por `raw()` no serviço; o
5
+ resto é escapado pelo Handlebars, que é o que tira do serviço o trabalho de
6
+ lembrar do escape em cada nome de usuário interpolado.
7
+ --}}
8
+ <div style="font-family: Arial, sans-serif; padding: 20px; max-width: 600px; margin: 0 auto;">
9
+ <div style="padding-bottom:20px;border-bottom:1px solid #ddd;margin-bottom:24px;">
10
+ {{header}}
11
+ </div>
12
+ <h2 style="color: #333;">{{title}}</h2>
13
+ {{body}}
14
+ <hr style="border: none; border-top: 1px solid #ddd; margin: 30px 0;">
15
+ <p style="color: #999; font-size: 12px;">
16
+ Mensagem automática de {{brandName}}, não responda.
17
+ </p>
18
+ </div>
@@ -0,0 +1,9 @@
1
+ <p>Olá {{userName}},</p>
2
+ <p>Recebemos uma solicitação para redefinir sua senha. Clique no botão abaixo:</p>
3
+ <div style="text-align:center;margin:30px 0;">
4
+ <a href="{{url}}" style="background:{{color}};color:#fff;padding:12px 30px;text-decoration:none;border-radius:5px;display:inline-block;">Redefinir senha</a>
5
+ </div>
6
+ <p>Ou copie e cole este link no navegador:</p>
7
+ <p style="word-break:break-all;color:#666;">{{url}}</p>
8
+ <p><strong>Este link expira em {{expiry}} minutos.</strong></p>
9
+ <p>Se você não solicitou, ignore este email.</p>
@@ -0,0 +1,9 @@
1
+ <p>Olá {{userName}},</p>
2
+ <p>Seu código de verificação (2FA) é:</p>
3
+ <div style="text-align:center;margin:30px 0;">
4
+ <div style="background:#f5f5f5;padding:20px;border-radius:5px;display:inline-block;">
5
+ <h1 style="margin:0;font-size:36px;letter-spacing:8px;color:{{color}};">{{code}}</h1>
6
+ </div>
7
+ </div>
8
+ <p><strong>Este código expira em {{expiry}} minutos.</strong></p>
9
+ <p>Se você não tentou fazer login, proteja sua conta imediatamente.</p>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "rl-core-api",
3
- "version": "0.19.7",
3
+ "version": "0.19.8",
4
4
  "description": "Core NestJS: autenticação com 2FA, RBAC, auditoria, notificações e listagens com filtro dinâmico",
5
5
  "author": "Rodrigo Liberti",
6
6
  "license": "MIT",
@@ -84,6 +84,7 @@
84
84
  "cookie-parser": "^1.4.7",
85
85
  "dotenv": "^17.4.2",
86
86
  "exceljs": "^4.4.0",
87
+ "handlebars": "^4.7.9",
87
88
  "helmet": "^8.3.0",
88
89
  "ioredis": "^5.11.1",
89
90
  "mysql2": "^3.23.2",