@dudousxd/adonis-authkit-server 0.3.0 → 0.5.0

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.
Files changed (58) hide show
  1. package/build/commands/commands.json +28 -0
  2. package/build/commands/doctor.d.ts +10 -0
  3. package/build/commands/doctor.js +66 -0
  4. package/build/commands/rotate_keys.d.ts +10 -0
  5. package/build/commands/rotate_keys.js +53 -0
  6. package/build/host/views/account/email-confirmed.edge +15 -0
  7. package/build/host/views/account/security.edge +83 -0
  8. package/build/host/views/account/tokens.edge +7 -4
  9. package/build/host/views/admin/sessions.edge +89 -0
  10. package/build/host/views/admin/users.edge +1 -0
  11. package/build/host/views/mfa-challenge.edge +29 -23
  12. package/build/index.d.ts +5 -4
  13. package/build/index.js +3 -3
  14. package/build/src/accounts/account_store.d.ts +46 -1
  15. package/build/src/accounts/account_store.js +4 -0
  16. package/build/src/accounts/lucid_store/core.d.ts +5 -4
  17. package/build/src/accounts/lucid_store/core.js +67 -2
  18. package/build/src/adapters/adapter_contract.d.ts +17 -0
  19. package/build/src/adapters/database_adapter.d.ts +9 -5
  20. package/build/src/adapters/database_adapter.js +13 -6
  21. package/build/src/adapters/redis_adapter.d.ts +11 -5
  22. package/build/src/adapters/redis_adapter.js +16 -7
  23. package/build/src/audit/audit_sink.d.ts +1 -1
  24. package/build/src/define_config.d.ts +102 -0
  25. package/build/src/define_config.js +46 -3
  26. package/build/src/doctor/checks.d.ts +51 -0
  27. package/build/src/doctor/checks.js +231 -0
  28. package/build/src/host/admin_clients_service.js +12 -5
  29. package/build/src/host/admin_sessions_service.d.ts +63 -0
  30. package/build/src/host/admin_sessions_service.js +127 -0
  31. package/build/src/host/controllers/account_mfa_controller.js +6 -2
  32. package/build/src/host/controllers/account_security_controller.d.ts +16 -0
  33. package/build/src/host/controllers/account_security_controller.js +119 -0
  34. package/build/src/host/controllers/account_session_controller.js +2 -1
  35. package/build/src/host/controllers/admin/admin_sessions_controller.d.ts +14 -0
  36. package/build/src/host/controllers/admin/admin_sessions_controller.js +64 -0
  37. package/build/src/host/controllers/interaction_controller.d.ts +11 -0
  38. package/build/src/host/controllers/interaction_controller.js +55 -12
  39. package/build/src/host/default_mailer.d.ts +17 -0
  40. package/build/src/host/default_mailer.js +94 -9
  41. package/build/src/host/email_templates.d.ts +4 -0
  42. package/build/src/host/email_templates.js +5 -2
  43. package/build/src/host/i18n.d.ts +358 -11
  44. package/build/src/host/i18n.js +393 -12
  45. package/build/src/host/login_notify.d.ts +20 -0
  46. package/build/src/host/login_notify.js +71 -0
  47. package/build/src/host/register_auth_host.js +12 -0
  48. package/build/src/host/validators.d.ts +32 -0
  49. package/build/src/host/validators.js +14 -0
  50. package/build/src/keys/keystore.d.ts +43 -0
  51. package/build/src/keys/keystore.js +74 -0
  52. package/build/src/observability/metrics_controller.js +4 -4
  53. package/build/src/provider/build_provider.js +23 -0
  54. package/build/src/provider/device_sources.d.ts +6 -0
  55. package/build/src/provider/device_sources.js +65 -0
  56. package/build/src/provider/interaction_actions.d.ts +6 -1
  57. package/build/src/provider/interaction_actions.js +9 -2
  58. package/package.json +2 -2
@@ -0,0 +1,64 @@
1
+ import '../../augmentations.js';
2
+ import { ACCOUNT_SESSION_KEY } from '../../middleware/account_auth.js';
3
+ import { AdminSessionsService } from '../../admin_sessions_service.js';
4
+ /**
5
+ * Inspeção/revogação das sessões e grants ativos de uma conta no console admin.
6
+ * Lista as `Session` (logins do IdP) + os `Grant` (autorizações por client) da
7
+ * conta, com a contagem de access/refresh tokens por grant. Degrada graciosamente
8
+ * quando o adapter OIDC não enumera (`list`), espelhando o CRUD de clients.
9
+ */
10
+ export default class AdminSessionsController {
11
+ /** GET /admin/users/:id/sessions — lista sessões + grants da conta. */
12
+ async index(ctx) {
13
+ const service = await ctx.containerResolver.make('authkit.server');
14
+ const cfg = service.config;
15
+ const render = cfg.render;
16
+ const accountId = ctx.request.param('id');
17
+ const account = await cfg.accountStore.findById(accountId);
18
+ const sessions = new AdminSessionsService(service);
19
+ const supported = sessions.canList;
20
+ const sessionList = supported ? await sessions.listSessions(accountId) : [];
21
+ const grantList = supported ? await sessions.listGrants(accountId) : [];
22
+ const revoked = ctx.session.flashMessages.get('sessionsRevoked');
23
+ return render(ctx, 'admin/sessions', {
24
+ csrfToken: ctx.request.csrfToken,
25
+ supported,
26
+ accountId,
27
+ email: account?.email ?? '',
28
+ revoked: revoked ?? null,
29
+ sessions: sessionList.map((s) => ({
30
+ id: s.id,
31
+ loginTs: s.loginTs ? new Date(s.loginTs * 1000).toISOString() : '',
32
+ amr: (s.amr ?? []).join(', '),
33
+ })),
34
+ grants: grantList.map((g) => ({
35
+ id: g.id,
36
+ clientId: g.clientId ?? '',
37
+ accessTokens: g.accessTokens,
38
+ refreshTokens: g.refreshTokens,
39
+ })),
40
+ });
41
+ }
42
+ /** POST /admin/users/:id/revoke-sessions — destrói sessões + grants da conta. */
43
+ async revoke(ctx) {
44
+ const service = await ctx.containerResolver.make('authkit.server');
45
+ const cfg = service.config;
46
+ const accountId = ctx.request.param('id');
47
+ const sessions = new AdminSessionsService(service);
48
+ const result = await sessions.revokeAll(accountId);
49
+ await cfg.audit?.record({
50
+ type: 'session.revoked_all',
51
+ accountId,
52
+ actorId: ctx.session.get(ACCOUNT_SESSION_KEY) ?? null,
53
+ ip: ctx.request.ip?.() ?? null,
54
+ metadata: {
55
+ sessions: result.sessions,
56
+ grants: result.grants,
57
+ accessTokens: result.accessTokens,
58
+ refreshTokens: result.refreshTokens,
59
+ },
60
+ });
61
+ ctx.session.flash('sessionsRevoked', result);
62
+ return ctx.response.redirect(`/admin/users/${accountId}/sessions`);
63
+ }
64
+ }
@@ -19,6 +19,17 @@ export default class AuthInteractionController {
19
19
  * OU um recovery code (`recoveryCode`). Em caso de sucesso finaliza a interaction.
20
20
  */
21
21
  mfaVerify(ctx: HttpContext): Promise<any>;
22
+ /**
23
+ * true se o authorize request exige MFA via acr_values (contém o mfaAcr da
24
+ * config de step-up). `acr_values` é a string separada por espaços padrão OIDC.
25
+ */
26
+ private acrRequiresMfa;
27
+ /**
28
+ * Monta o acr/amr do step-up quando o client solicitou o mfaAcr e um 2º fator
29
+ * foi verificado. `method` é o método do segundo fator (totp/recovery/webauthn).
30
+ * Retorna `undefined` quando não há step-up — completeLogin usa o default.
31
+ */
32
+ private stepUpExtra;
22
33
  /** true se o store suporta passkeys E a conta tem ao menos uma registrada. */
23
34
  private hasPasskeys;
24
35
  /**
@@ -2,6 +2,7 @@ import '../augmentations.js';
2
2
  import { brandFor, isFirstParty } from '../branding.js';
3
3
  import { translate } from '../i18n.js';
4
4
  import { attemptPasswordLogin } from '../login_attempt.js';
5
+ import { notifyLoginSuccess } from '../login_notify.js';
5
6
  import { supportsPasskeys } from '../../accounts/account_store.js';
6
7
  const SESSION_KEY = 'authkit_login_email';
7
8
  /** accountId aguardando o 2º fator depois da senha verificada. */
@@ -106,10 +107,25 @@ export default class AuthInteractionController {
106
107
  });
107
108
  }
108
109
  const acc = result.account;
109
- // MFA gate: se a conta tem TOTP ativo, NÃO finaliza a interaction agora —
110
- // guarda o accountId pendente na sessão e renderiza o desafio do 2º fator.
110
+ // Step-up auth (acr_values): o client pode EXIGIR MFA nesta requisição
111
+ // solicitando o `mfaAcr` em acr_values, mesmo que a conta tenha MFA opcional.
112
+ const mfaRequired = this.acrRequiresMfa(cfg, details);
113
+ // MFA gate: força o 2º fator se a conta tem TOTP ativo OU se o client exige MFA
114
+ // via acr. Não finaliza a interaction agora — guarda o accountId pendente.
111
115
  const mfa = (await cfg.accountStore.getMfaState?.(acc.id)) ?? { enabled: false };
112
- if (mfa.enabled) {
116
+ if (mfa.enabled || mfaRequired) {
117
+ if (mfaRequired && !mfa.enabled) {
118
+ // Client exige MFA mas a conta não tem MFA enrolado: bloqueia este login
119
+ // com a instrução de configurar MFA no console (não há 2º fator a desafiar).
120
+ return render(ctx, 'mfa-challenge', {
121
+ uid: ctx.request.param('uid'),
122
+ csrfToken: ctx.request.csrfToken,
123
+ brand,
124
+ passkeyAvailable: false,
125
+ error: translate(cfg.messages, 'mfa_challenge.required_no_enrollment'),
126
+ noEnrollment: true,
127
+ });
128
+ }
113
129
  ctx.session.put(MFA_PENDING_KEY, acc.id);
114
130
  // Passkey disponível como alternativa ao TOTP se o store suporta E a conta
115
131
  // tem ao menos uma credencial registrada.
@@ -123,7 +139,7 @@ export default class AuthInteractionController {
123
139
  }
124
140
  // Sem MFA: finaliza a interaction (escreve o 303 de volta para o client).
125
141
  await service.interactions.completeLogin(ctx, acc.id);
126
- await cfg.audit?.record({ type: 'login.success', accountId: acc.id, email, ip, clientId });
142
+ await notifyLoginSuccess(ctx, cfg, { accountId: acc.id, email, ip, clientId });
127
143
  // Clean up the session key after a successful login.
128
144
  ctx.session.forget(SESSION_KEY);
129
145
  }
@@ -174,14 +190,38 @@ export default class AuthInteractionController {
174
190
  // Sucesso no 2º fator: finaliza a interaction para o accountId pendente.
175
191
  ctx.session.forget(MFA_PENDING_KEY);
176
192
  ctx.session.forget(SESSION_KEY);
177
- await cfg.audit?.record({
178
- type: 'login.success',
193
+ await notifyLoginSuccess(ctx, cfg, {
179
194
  accountId,
180
195
  ip,
181
196
  clientId,
182
197
  metadata: { mfa: usedRecovery ? 'recovery' : 'totp' },
183
198
  });
184
- await service.interactions.completeLogin(ctx, accountId);
199
+ // Step-up: um 2º fator foi de fato verificado — carimba acr/amr no id_token
200
+ // se o client solicitou o mfaAcr nesta requisição.
201
+ await service.interactions.completeLogin(ctx, accountId, this.stepUpExtra(cfg, details, usedRecovery ? 'recovery' : 'totp'));
202
+ }
203
+ /**
204
+ * true se o authorize request exige MFA via acr_values (contém o mfaAcr da
205
+ * config de step-up). `acr_values` é a string separada por espaços padrão OIDC.
206
+ */
207
+ acrRequiresMfa(cfg, details) {
208
+ const mfaAcr = cfg.stepUp?.mfaAcr;
209
+ if (!mfaAcr)
210
+ return false;
211
+ const raw = details?.params?.acr_values;
212
+ if (typeof raw !== 'string' || !raw)
213
+ return false;
214
+ return raw.split(/\s+/).includes(mfaAcr);
215
+ }
216
+ /**
217
+ * Monta o acr/amr do step-up quando o client solicitou o mfaAcr e um 2º fator
218
+ * foi verificado. `method` é o método do segundo fator (totp/recovery/webauthn).
219
+ * Retorna `undefined` quando não há step-up — completeLogin usa o default.
220
+ */
221
+ stepUpExtra(cfg, details, method) {
222
+ if (!this.acrRequiresMfa(cfg, details))
223
+ return undefined;
224
+ return { acr: cfg.stepUp.mfaAcr, amr: ['mfa', method] };
185
225
  }
186
226
  /** true se o store suporta passkeys E a conta tem ao menos uma registrada. */
187
227
  async hasPasskeys(cfg, accountId) {
@@ -200,11 +240,15 @@ export default class AuthInteractionController {
200
240
  const cfg = service.config;
201
241
  const accountId = ctx.session.get(MFA_PENDING_KEY);
202
242
  if (!accountId) {
203
- return ctx.response.badRequest({ message: 'Sessão expirada' });
243
+ return ctx.response.badRequest({
244
+ message: translate(cfg.messages, 'errors.session_expired'),
245
+ });
204
246
  }
205
247
  const generated = await cfg.accountStore.generatePasskeyAuthenticationOptions?.(accountId);
206
248
  if (!generated) {
207
- return ctx.response.notFound({ message: 'Nenhuma passkey registrada' });
249
+ return ctx.response.notFound({
250
+ message: translate(cfg.messages, 'errors.no_passkey_registered'),
251
+ });
208
252
  }
209
253
  ctx.session.put(PASSKEY_AUTH_CHALLENGE_KEY, generated.challenge);
210
254
  return generated.options;
@@ -261,14 +305,13 @@ export default class AuthInteractionController {
261
305
  }
262
306
  ctx.session.forget(MFA_PENDING_KEY);
263
307
  ctx.session.forget(SESSION_KEY);
264
- await cfg.audit?.record({
265
- type: 'login.success',
308
+ await notifyLoginSuccess(ctx, cfg, {
266
309
  accountId,
267
310
  ip,
268
311
  clientId,
269
312
  metadata: { mfa: 'webauthn' },
270
313
  });
271
- await service.interactions.completeLogin(ctx, accountId);
314
+ await service.interactions.completeLogin(ctx, accountId, this.stepUpExtra(cfg, details, 'webauthn'));
272
315
  }
273
316
  /**
274
317
  * GET /auth/interaction/:uid/switch
@@ -28,6 +28,23 @@ export declare function sendPasswordResetEmail(ctx: HttpContext, data: {
28
28
  email: string;
29
29
  resetUrl: string;
30
30
  }): Promise<void>;
31
+ /**
32
+ * Envia o e-mail de confirmação de TROCA de e-mail para o NOVO endereço.
33
+ * Best-effort: no fallback (sem mail) loga o link; nunca lança.
34
+ */
35
+ export declare function sendEmailChangeConfirmationEmail(ctx: HttpContext, data: {
36
+ email: string;
37
+ confirmUrl: string;
38
+ }): Promise<void>;
39
+ /**
40
+ * Envia o e-mail de alerta de NOVO acesso à conta (login de um IP novo).
41
+ * Best-effort: no fallback (sem mail) loga o evento; nunca lança.
42
+ */
43
+ export declare function sendNewLoginEmail(ctx: HttpContext, data: {
44
+ email: string;
45
+ ip: string;
46
+ when: string;
47
+ }): Promise<void>;
31
48
  /**
32
49
  * Envia o e-mail de verificação pelo mailer default do host.
33
50
  * Best-effort: no fallback (sem mail) loga o link; nunca lança.
@@ -7,6 +7,7 @@ var __rewriteRelativeImportExtension = (this && this.__rewriteRelativeImportExte
7
7
  return path;
8
8
  };
9
9
  import { renderTransactionalEmail } from './email_templates.js';
10
+ import { resolveMessages, translate, } from './i18n.js';
10
11
  let mailServicePromise;
11
12
  /**
12
13
  * Importa o service de mail do HOST de forma preguiçosa e fail-safe.
@@ -78,6 +79,22 @@ function resolveBrand(ctx) {
78
79
  }
79
80
  return { appName: 'AuthKit' };
80
81
  }
82
+ /**
83
+ * Resolve o catálogo de mensagens i18n a partir do `config/authkit.ts` (i18n).
84
+ * Cai no default (`en`) se a config não for resolvível. Usado para localizar
85
+ * os e-mails transacionais (assunto/cabeçalho/corpo).
86
+ */
87
+ function resolveMailMessages(ctx) {
88
+ try {
89
+ const resolver = ctx.containerResolver;
90
+ const i18n = resolver.app?.config?.get?.('authkit')?.i18n;
91
+ return { messages: resolveMessages(i18n), locale: i18n?.locale ?? 'en' };
92
+ }
93
+ catch {
94
+ // sem config authkit resolvível — usa o default `en`.
95
+ return { messages: resolveMessages(), locale: 'en' };
96
+ }
97
+ }
81
98
  async function sendEmail(ctx, to, content) {
82
99
  const mail = await loadMail();
83
100
  if (!mail)
@@ -97,14 +114,17 @@ async function sendEmail(ctx, to, content) {
97
114
  export async function sendPasswordResetEmail(ctx, data) {
98
115
  try {
99
116
  const brand = resolveBrand(ctx);
117
+ const { messages: t, locale } = resolveMailMessages(ctx);
100
118
  const content = renderTransactionalEmail({
101
119
  brand,
102
- subject: 'Redefinição de senha',
103
- heading: 'Redefinição de senha',
104
- intro: `Recebemos um pedido para redefinir a senha da sua conta em ${brand.appName}. Clique no botão abaixo para escolher uma nova senha. Se não foi você, ignore este e-mail.`,
105
- ctaLabel: 'Redefinir senha',
120
+ locale,
121
+ linkFallback: translate(t, 'mail.common.link_fallback'),
122
+ subject: translate(t, 'mail.reset.subject'),
123
+ heading: translate(t, 'mail.reset.heading'),
124
+ intro: translate(t, 'mail.reset.intro'),
125
+ ctaLabel: translate(t, 'mail.reset.cta'),
106
126
  ctaUrl: data.resetUrl,
107
- footnote: 'Por segurança, este link expira em breve e só pode ser usado uma vez.',
127
+ footnote: translate(t, 'mail.reset.fallback'),
108
128
  });
109
129
  const sent = await sendEmail(ctx, data.email, content);
110
130
  if (!sent) {
@@ -115,6 +135,68 @@ export async function sendPasswordResetEmail(ctx, data) {
115
135
  ctx.logger.error({ err: error, email: data.email }, 'authkit: falha ao enviar e-mail de redefinição de senha');
116
136
  }
117
137
  }
138
+ /**
139
+ * Envia o e-mail de confirmação de TROCA de e-mail para o NOVO endereço.
140
+ * Best-effort: no fallback (sem mail) loga o link; nunca lança.
141
+ */
142
+ export async function sendEmailChangeConfirmationEmail(ctx, data) {
143
+ try {
144
+ const brand = resolveBrand(ctx);
145
+ const { messages: t, locale } = resolveMailMessages(ctx);
146
+ const content = renderTransactionalEmail({
147
+ brand,
148
+ locale,
149
+ linkFallback: translate(t, 'mail.common.link_fallback'),
150
+ subject: translate(t, 'mail.email_change.subject'),
151
+ heading: translate(t, 'mail.email_change.heading'),
152
+ intro: translate(t, 'mail.email_change.intro'),
153
+ ctaLabel: translate(t, 'mail.email_change.cta'),
154
+ ctaUrl: data.confirmUrl,
155
+ footnote: translate(t, 'mail.email_change.fallback'),
156
+ });
157
+ const sent = await sendEmail(ctx, data.email, content);
158
+ if (!sent) {
159
+ ctx.logger.info({ confirmUrl: data.confirmUrl, email: data.email }, 'authkit: link de confirmação de troca de e-mail (dev — @adonisjs/mail ausente)');
160
+ }
161
+ }
162
+ catch (error) {
163
+ ctx.logger.error({ err: error, email: data.email }, 'authkit: falha ao enviar confirmação de troca de e-mail');
164
+ }
165
+ }
166
+ /**
167
+ * Envia o e-mail de alerta de NOVO acesso à conta (login de um IP novo).
168
+ * Best-effort: no fallback (sem mail) loga o evento; nunca lança.
169
+ */
170
+ export async function sendNewLoginEmail(ctx, data) {
171
+ try {
172
+ const brand = resolveBrand(ctx);
173
+ const { messages: t, locale } = resolveMailMessages(ctx);
174
+ const origin = `${ctx.request.protocol()}://${ctx.request.host()}`;
175
+ const intro = [
176
+ translate(t, 'mail.new_login.intro'),
177
+ translate(t, 'mail.new_login.when', { date: data.when }),
178
+ translate(t, 'mail.new_login.ip', { ip: data.ip }),
179
+ ].join(' ');
180
+ const content = renderTransactionalEmail({
181
+ brand,
182
+ locale,
183
+ linkFallback: translate(t, 'mail.common.link_fallback'),
184
+ subject: translate(t, 'mail.new_login.subject'),
185
+ heading: translate(t, 'mail.new_login.heading'),
186
+ intro,
187
+ ctaLabel: translate(t, 'account.security.title'),
188
+ ctaUrl: `${origin}/account/security`,
189
+ footnote: translate(t, 'mail.new_login.fallback'),
190
+ });
191
+ const sent = await sendEmail(ctx, data.email, content);
192
+ if (!sent) {
193
+ ctx.logger.info({ ip: data.ip, email: data.email }, 'authkit: alerta de novo acesso (dev — @adonisjs/mail ausente)');
194
+ }
195
+ }
196
+ catch (error) {
197
+ ctx.logger.error({ err: error, email: data.email }, 'authkit: falha ao enviar alerta de novo acesso');
198
+ }
199
+ }
118
200
  /**
119
201
  * Envia o e-mail de verificação pelo mailer default do host.
120
202
  * Best-effort: no fallback (sem mail) loga o link; nunca lança.
@@ -122,12 +204,15 @@ export async function sendPasswordResetEmail(ctx, data) {
122
204
  export async function sendEmailVerificationEmail(ctx, data) {
123
205
  try {
124
206
  const brand = resolveBrand(ctx);
207
+ const { messages: t, locale } = resolveMailMessages(ctx);
125
208
  const content = renderTransactionalEmail({
126
209
  brand,
127
- subject: 'Verifique seu e-mail',
128
- heading: 'Confirme seu e-mail',
129
- intro: `Bem-vindo(a) ao ${brand.appName}! Confirme seu endereço de e-mail clicando no botão abaixo para ativar sua conta.`,
130
- ctaLabel: 'Verificar e-mail',
210
+ locale,
211
+ linkFallback: translate(t, 'mail.common.link_fallback'),
212
+ subject: translate(t, 'mail.verify.subject'),
213
+ heading: translate(t, 'mail.verify.heading'),
214
+ intro: translate(t, 'mail.verify.intro'),
215
+ ctaLabel: translate(t, 'mail.verify.cta'),
131
216
  ctaUrl: data.verifyUrl,
132
217
  });
133
218
  const sent = await sendEmail(ctx, data.email, content);
@@ -30,6 +30,10 @@ interface EmailTemplateInput {
30
30
  ctaUrl: string;
31
31
  /** Linha auxiliar abaixo do botão (ex.: validade do link). */
32
32
  footnote?: string;
33
+ /** Texto que precede o link de fallback (i18n). Default em inglês. */
34
+ linkFallback?: string;
35
+ /** Locale do documento HTML (atributo `lang`). Default: 'en'. */
36
+ locale?: string;
33
37
  }
34
38
  export declare function renderTransactionalEmail(input: EmailTemplateInput): EmailContent;
35
39
  export {};
@@ -21,8 +21,11 @@ export function renderTransactionalEmail(input) {
21
21
  const accent = input.brand.accent || FALLBACK_ACCENT;
22
22
  const company = input.brand.company || appName;
23
23
  const year = '©'; // ano resolvido fora (sem Date.* aqui); rodapé usa só o nome.
24
+ const lang = input.locale || 'en';
25
+ const linkFallback = input.linkFallback ||
26
+ 'If the button does not work, copy and paste this link into your browser:';
24
27
  const html = `<!doctype html>
25
- <html lang="pt-BR">
28
+ <html lang="${esc(lang)}">
26
29
  <head>
27
30
  <meta charset="utf-8">
28
31
  <meta name="viewport" content="width=device-width, initial-scale=1">
@@ -42,7 +45,7 @@ export function renderTransactionalEmail(input) {
42
45
  <a href="${esc(input.ctaUrl)}" style="display:inline-block;padding:12px 24px;font-size:15px;font-weight:600;color:#ffffff;text-decoration:none;border-radius:8px;">${esc(input.ctaLabel)}</a>
43
46
  </td></tr></table>
44
47
  ${input.footnote ? `<p style="margin:24px 0 0;font-size:13px;line-height:1.5;color:#6b7280;">${esc(input.footnote)}</p>` : ''}
45
- <p style="margin:24px 0 0;font-size:13px;line-height:1.5;color:#6b7280;">Se o botão não funcionar, copie e cole este link no navegador:<br><a href="${esc(input.ctaUrl)}" style="color:${esc(accent)};word-break:break-all;">${esc(input.ctaUrl)}</a></p>
48
+ <p style="margin:24px 0 0;font-size:13px;line-height:1.5;color:#6b7280;">${esc(linkFallback)}<br><a href="${esc(input.ctaUrl)}" style="color:${esc(accent)};word-break:break-all;">${esc(input.ctaUrl)}</a></p>
46
49
  </td></tr>
47
50
  <tr><td style="padding:24px 28px 28px;border-top:1px solid #f3f4f6;">
48
51
  <p style="margin:0;font-size:12px;line-height:1.5;color:#9ca3af;">${esc(company)} ${year}</p>