@adonis-agora/authkit-server 1.0.0-rc.13 → 1.0.0-rc.15

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/CHANGELOG.md CHANGED
@@ -1,5 +1,19 @@
1
1
  # @adonis-agora/authkit-server
2
2
 
3
+ ## 1.0.0-rc.15
4
+
5
+ ### Patch Changes
6
+
7
+ - [#477](https://github.com/DavideCarvalho/adonis-agora-authkit/pull/477) [`c17adde`](https://github.com/DavideCarvalho/adonis-agora-authkit/commit/c17addeef2bbc80a1fea41ab5b6848b37fad7f24) Thanks [@DavideCarvalho](https://github.com/DavideCarvalho)! - The `authkit:accounts:expire-scan --warn` inactivity warning now goes through the default mailer like every other built-in email: the `mail.onAccountExpirationWarning` hook still wins, otherwise it is sent in the host locale with the host branding (new `mail.account_expiration.*` messages, en + pt-BR) instead of a hardcoded English email.
8
+
9
+ `branding` without `default` (or without `firstParty`) is now resolved once: the default brand falls back to `branding.company`, then `'AuthKit'`, the same precedence the mailer used. Screens without a known client (session expired, throttled, login/consent for an unlisted client) now show the company name and a theme accent instead of the generic fallback, and a missing `firstParty` no longer crashes the consent check. `BrandingConfigInput` makes both fields optional in `defineConfig`.
10
+
11
+ ## 1.0.0-rc.14
12
+
13
+ ### Patch Changes
14
+
15
+ - [#476](https://github.com/DavideCarvalho/adonis-agora-authkit/pull/476) [`b9f9119`](https://github.com/DavideCarvalho/adonis-agora-authkit/commit/b9f9119a4a030b1fb03befd73d018abd88e8bf6c) Thanks [@DavideCarvalho](https://github.com/DavideCarvalho)! - Default emails (new login / new device alerts, password reset, verification, magic link, sudo link, OTP unlock, email change, security notices, org invitations, `sendAuthkitCodeEmail`) now always use the host's `i18n.locale` and `branding`. They used to read the config through `ctx.containerResolver.app`, which a real request's container resolver does not have, so they silently fell back to English and the "AuthKit" brand. The mailer now uses the resolved config the caller already has, or resolves the `authkit.server` binding, and logs a warning once when it cannot find the config. The header falls back to `branding.company` when `branding.default` is absent.
16
+
3
17
  ## 1.0.0-rc.13
4
18
 
5
19
  ### Patch Changes
package/build/index.d.ts CHANGED
@@ -75,7 +75,7 @@ export { computeAdminStats } from './src/host/admin_stats_service.js';
75
75
  export { authkitUserProvider } from './src/host/adonis_auth_user_provider.js';
76
76
  export type { BotProtectionAction, BotProtectionConfigInput, BotProtectionSetting, BotProtectionVerifyInput, BotProtectionWidget, ResolvedBotProtectionConfig, } from './src/host/bot_protection.js';
77
77
  export { botProtectionApplies, DEFAULT_BOT_TOKEN_FIELDS, extractBotToken, guardBotProtection, resolveBotProtection, resolveEffectiveBotProtection, verifyBotProtection, } from './src/host/bot_protection.js';
78
- export type { BrandingConfig, ClientBrand } from './src/host/branding.js';
78
+ export type { BrandingConfig, BrandingConfigInput, ClientBrand } from './src/host/branding.js';
79
79
  export { brandFor, isFirstParty, isFirstPartyClient } from './src/host/branding.js';
80
80
  export type { ApplicationType } from './src/host/client_metadata.js';
81
81
  export { assertClientMetadata, ClientMetadataError, redirectUriProblem, } from './src/host/client_metadata.js';
@@ -11,15 +11,20 @@
11
11
  * queries ao audit, o que é compatível com volumes moderados (<= 50k contas).
12
12
  * Para volumes maiores, o operador deve implementar um sink com agregação nativa.
13
13
  * - `--warn`: envia e-mail de aviso ("sua conta expira em N dias — faça login") via
14
- * hook `mail.onAccountExpirationWarning` + template default. Deduplicado via
15
- * audit `account.expiration_warned` — não re-avisa quem já foi avisado dentro
16
- * da janela de `warnDays` dias.
14
+ * hook `mail.onAccountExpirationWarning` ou, sem ele, o mailer default (i18n +
15
+ * branding). Deduplicado via audit `account.expiration_warned` — não re-avisa
16
+ * quem já foi avisado dentro da janela de `warnDays` dias.
17
17
  * - `--dry-run`: reporta sem enviar e-mails nem auditar.
18
18
  * - `--json`: output machine-readable.
19
19
  *
20
20
  * Designed pra cron do host (ex.: `0 2 * * * node ace authkit:accounts:expire-scan --warn`).
21
21
  */
22
22
  import type { ApplicationService } from '@adonisjs/core/types';
23
+ type ScanLogger = {
24
+ info: (m: string) => void;
25
+ warn: (m: string) => void;
26
+ error: (m: string) => void;
27
+ };
23
28
  export interface ExpireScanResult {
24
29
  /** Contas já expiradas (bloqueadas no login). */
25
30
  expired: Array<{
@@ -52,9 +57,6 @@ export declare function runExpireScan(app: ApplicationService, opts: {
52
57
  dryRun?: boolean;
53
58
  warn?: boolean;
54
59
  json?: boolean;
55
- logger: {
56
- info: (m: string) => void;
57
- warn: (m: string) => void;
58
- error: (m: string) => void;
59
- };
60
+ logger: ScanLogger;
60
61
  }): Promise<ExpireScanResult>;
62
+ export {};
@@ -11,22 +11,15 @@
11
11
  * queries ao audit, o que é compatível com volumes moderados (<= 50k contas).
12
12
  * Para volumes maiores, o operador deve implementar um sink com agregação nativa.
13
13
  * - `--warn`: envia e-mail de aviso ("sua conta expira em N dias — faça login") via
14
- * hook `mail.onAccountExpirationWarning` + template default. Deduplicado via
15
- * audit `account.expiration_warned` — não re-avisa quem já foi avisado dentro
16
- * da janela de `warnDays` dias.
14
+ * hook `mail.onAccountExpirationWarning` ou, sem ele, o mailer default (i18n +
15
+ * branding). Deduplicado via audit `account.expiration_warned` — não re-avisa
16
+ * quem já foi avisado dentro da janela de `warnDays` dias.
17
17
  * - `--dry-run`: reporta sem enviar e-mails nem auditar.
18
18
  * - `--json`: output machine-readable.
19
19
  *
20
20
  * Designed pra cron do host (ex.: `0 2 * * * node ace authkit:accounts:expire-scan --warn`).
21
21
  */
22
- var __rewriteRelativeImportExtension = (this && this.__rewriteRelativeImportExtension) || function (path, preserveJsx) {
23
- if (typeof path === "string" && /^\.\.?\//.test(path)) {
24
- return path.replace(/\.(tsx)$|((?:\.d)?)((?:\.[^./]+?)?)\.([cm]?)ts$/i, function (m, tsx, d, ext, cm) {
25
- return tsx ? preserveJsx ? ".jsx" : ".js" : d && (!ext || !cm) ? m : (d + ext + "." + cm.toLowerCase() + "js");
26
- });
27
- }
28
- return path;
29
- };
22
+ import { sendAccountExpirationWarningEmail } from '../host/default_mailer.js';
30
23
  import { RuntimeSettings, settingsLocation } from '../host/runtime_settings.js';
31
24
  import { resolveEffectiveAccountExpiration } from '../host/runtime_toggles.js';
32
25
  import { ADMIN_LIST_DEFAULT_SIZE, LIST_FIRST_PAGE } from '../pagination.js';
@@ -114,7 +107,7 @@ export async function runExpireScan(app, opts) {
114
107
  }
115
108
  else {
116
109
  // Envia e-mail de aviso.
117
- await sendWarnEmail(cfg, account, daysUntilExpiry);
110
+ await sendWarnEmail(cfg, account, daysUntilExpiry, logger);
118
111
  // Audita para deduplicação.
119
112
  await audit.record({
120
113
  type: 'account.expiration_warned',
@@ -199,31 +192,38 @@ async function wasWarnedRecently(audit, accountId, warnDays) {
199
192
  return false;
200
193
  }
201
194
  }
202
- /** Envia e-mail de aviso de expiração iminente. Best-effort (silencioso em caso de erro). */
203
- async function sendWarnEmail(cfg, account, expiresInDays) {
195
+ /**
196
+ * Envia o aviso de expiração iminente pelo MESMO caminho dos outros e-mails da lib: o hook
197
+ * `mail.onAccountExpirationWarning` do host tem prioridade; sem ele, o mailer default
198
+ * (`@adonisjs/mail`) com o locale e o branding do config resolvido. Best-effort: nunca
199
+ * propaga erros de e-mail (o scan segue para a próxima conta).
200
+ */
201
+ async function sendWarnEmail(cfg, account, expiresInDays, logger) {
204
202
  try {
205
203
  if (cfg.mail?.onAccountExpirationWarning) {
206
204
  await cfg.mail.onAccountExpirationWarning({ email: account.email, expiresInDays });
207
205
  return;
208
206
  }
209
- // Default: tenta via @adonisjs/mail se disponível.
210
- const mailSpecifier = '@adonisjs/mail/services/main';
211
- const mailMod = await import(__rewriteRelativeImportExtension(mailSpecifier)).catch(() => null);
212
- const mail = mailMod?.default ?? null;
213
- if (!mail)
214
- return; // Sem mail configurado — silêncio.
215
- await mail.send((message) => {
216
- message
217
- .to(account.email)
218
- .subject('Your account will be deactivated due to inactivity')
219
- .html(`<p>Your account will be deactivated in <strong>${expiresInDays} day(s)</strong> due to inactivity. Sign in to keep your account active.</p>`)
220
- .text(`Your account will be deactivated in ${expiresInDays} day(s) due to inactivity. Sign in to keep your account active.`);
207
+ await sendAccountExpirationWarningEmail({ logger: mailLogger(logger) }, cfg, {
208
+ email: account.email,
209
+ expiresInDays,
221
210
  });
222
211
  }
223
212
  catch {
224
213
  // Best-effort: nunca propaga erros de e-mail.
225
214
  }
226
215
  }
216
+ /** Adapta o logger do Ace (`info(msg)`) ao formato pino (`info(obj, msg)`) do mailer. */
217
+ function mailLogger(logger) {
218
+ const format = (obj, msg) => typeof msg === 'string' ? `${msg} ${JSON.stringify(obj, errorReplacer)}` : String(obj);
219
+ return {
220
+ info: (obj, msg) => logger.info(format(obj, msg)),
221
+ error: (obj, msg) => logger.error(format(obj, msg)),
222
+ };
223
+ }
224
+ function errorReplacer(_key, value) {
225
+ return value instanceof Error ? value.message : value;
226
+ }
227
227
  function emptyResult(dryRun, inactiveDays, warnDays, auditSupported = false) {
228
228
  return {
229
229
  expired: [],
@@ -8,7 +8,7 @@ import type { AuditSink } from './audit/audit_sink.js';
8
8
  import type { ConsoleAuthorize } from './console_authorize.js';
9
9
  import { type EventsConfigInput } from './events/dispatcher.js';
10
10
  import { type BotProtectionConfigInput, type ResolvedBotProtectionConfig } from './host/bot_protection.js';
11
- import { type BrandingConfig } from './host/branding.js';
11
+ import { type BrandingConfig, type BrandingConfigInput } from './host/branding.js';
12
12
  import { type PolicyRouteOption } from './host/config_locks.js';
13
13
  import type { CustomLoginMethods } from './host/custom_login.js';
14
14
  import type { CustomMfaMethods } from './host/custom_mfa.js';
@@ -959,8 +959,8 @@ export interface AuthServerConfigInput {
959
959
  * {@link InteractionRecoveryConfigInput}.
960
960
  */
961
961
  interactionRecovery?: InteractionRecoveryConfigInput;
962
- /** Configuração de branding por cliente. */
963
- branding?: BrandingConfig;
962
+ /** Configuração de branding por cliente (`default`/`firstParty` opcionais). */
963
+ branding?: BrandingConfigInput;
964
964
  /**
965
965
  * Allowlist dos clients FIRST-PARTY: quem recebe as claims de AUTORIZAÇÃO
966
966
  * (`<globalRolesClaim>`, `org_id`, `org_slug`, `org_role`) nos tokens.
@@ -404,7 +404,7 @@ export default class AccountApiController {
404
404
  });
405
405
  }
406
406
  else {
407
- await sendEmailChangeConfirmationEmail(ctx, { email: newEmail, confirmUrl });
407
+ await sendEmailChangeConfirmationEmail(ctx, { email: newEmail, confirmUrl }, cfg);
408
408
  }
409
409
  if (account) {
410
410
  if (cfg.mail?.onEmailChangeNotice) {
@@ -272,7 +272,7 @@ export default class AccountOrgsApiController {
272
272
  if (c.cfg.mail?.onOrgInvitation)
273
273
  await c.cfg.mail.onOrgInvitation(payload);
274
274
  else
275
- await sendOrgInvitationEmail(ctx, payload);
275
+ await sendOrgInvitationEmail(ctx, payload, c.cfg);
276
276
  }
277
277
  catch {
278
278
  /* best-effort */
@@ -314,7 +314,7 @@ export class AdminOrgsService {
314
314
  await this.cfg.mail.onOrgInvitation(payload);
315
315
  }
316
316
  else if (ctx) {
317
- await sendOrgInvitationEmail(ctx, payload);
317
+ await sendOrgInvitationEmail(ctx, payload, this.cfg);
318
318
  }
319
319
  }
320
320
  catch {
@@ -312,7 +312,7 @@ export class AdminUsersService {
312
312
  });
313
313
  }
314
314
  else {
315
- await sendPasswordResetEmail(ctx, { email, resetUrl });
315
+ await sendPasswordResetEmail(ctx, { email, resetUrl }, this.cfg);
316
316
  }
317
317
  }
318
318
  }
@@ -15,13 +15,26 @@ export interface BrandingConfig {
15
15
  firstParty: string[];
16
16
  audienceLabels?: Record<string, string>;
17
17
  }
18
+ /**
19
+ * O que o host declara em `defineConfig({ branding })`. `default` e `firstParty` são
20
+ * opcionais: sem `default` a marca de quem não tem client conhecido vem da `company`
21
+ * (ver {@link resolveDefaultBrand}); sem `firstParty`, nenhum client pula o consent.
22
+ */
23
+ export type BrandingConfigInput = Omit<BrandingConfig, 'default' | 'firstParty'> & Partial<Pick<BrandingConfig, 'default' | 'firstParty'>>;
18
24
  /**
19
25
  * Resolve a config de branding: quando o host não declara `branding`, cai no
20
26
  * {@link DEFAULT_BRANDING} acima (tema neutro, sem clients customizados) em
21
27
  * vez de deixar `cfg.branding` como `undefined` — o que fazia a MENOR config
22
28
  * documentada (sem branding) quebrar a primeira tela de login/consent/signup.
23
29
  */
24
- export declare function resolveBranding(input: BrandingConfig | undefined): BrandingConfig;
30
+ export declare function resolveBranding(input: BrandingConfigInput | undefined): BrandingConfig;
31
+ /**
32
+ * A marca default de um host — o ÚNICO lugar que decide o nome/cor de quem não tem
33
+ * client conhecido (telas sem client, e-mails). Precedência do nome:
34
+ * `branding.default.appName` → `branding.company` → `'AuthKit'`; as cores caem no
35
+ * tema neutro. Tolera `branding` parcial/ausente (configs crus, fixtures).
36
+ */
37
+ export declare function resolveDefaultBrand(branding: Partial<BrandingConfigInput> | undefined): BrandingConfig['default'];
25
38
  export declare function isFirstParty(cfg: BrandingConfig, clientId: string | undefined): boolean;
26
39
  /**
27
40
  * O client é FIRST-PARTY, ou seja, recebe as claims de autorização
@@ -27,7 +27,36 @@ const DEFAULT_BRANDING = {
27
27
  * documentada (sem branding) quebrar a primeira tela de login/consent/signup.
28
28
  */
29
29
  export function resolveBranding(input) {
30
- return input ?? DEFAULT_BRANDING;
30
+ if (!input)
31
+ return DEFAULT_BRANDING;
32
+ // Hosts declaram na prática só `company` + `clients` (sem `default`, às vezes sem
33
+ // `firstParty`). Sem normalizar, `brandFor` devolvia uma marca SEM `appName`/`accent`
34
+ // para todo fluxo sem client conhecido (console de conta, recuperação, throttle) — as
35
+ // telas caíam no fallback genérico enquanto os e-mails já usavam a `company` — e
36
+ // `isFirstParty` estourava em `firstParty.includes`.
37
+ return {
38
+ ...input,
39
+ clients: input.clients ?? {},
40
+ default: resolveDefaultBrand(input),
41
+ firstParty: input.firstParty ?? [],
42
+ };
43
+ }
44
+ /**
45
+ * A marca default de um host — o ÚNICO lugar que decide o nome/cor de quem não tem
46
+ * client conhecido (telas sem client, e-mails). Precedência do nome:
47
+ * `branding.default.appName` → `branding.company` → `'AuthKit'`; as cores caem no
48
+ * tema neutro. Tolera `branding` parcial/ausente (configs crus, fixtures).
49
+ */
50
+ export function resolveDefaultBrand(branding) {
51
+ const declared = branding?.default;
52
+ const neutral = DEFAULT_BRANDING.default;
53
+ return {
54
+ ...declared,
55
+ appName: declared?.appName || branding?.company || neutral.appName,
56
+ accent: declared?.accent || neutral.accent,
57
+ accentSoft: declared?.accentSoft || declared?.accent || neutral.accentSoft,
58
+ tagline: declared?.tagline ?? neutral.tagline,
59
+ };
31
60
  }
32
61
  export function isFirstParty(cfg, clientId) {
33
62
  return !!clientId && cfg.firstParty.includes(clientId);
@@ -226,7 +226,7 @@ export default class AccountOrgsController {
226
226
  await cfg.mail.onOrgInvitation(payload);
227
227
  }
228
228
  else {
229
- await sendOrgInvitationEmail(ctx, payload);
229
+ await sendOrgInvitationEmail(ctx, payload, cfg);
230
230
  }
231
231
  }
232
232
  catch {
@@ -439,7 +439,7 @@ export default class AccountSecurityController {
439
439
  await sendEmailChangeConfirmationEmail(ctx, {
440
440
  email: newEmail,
441
441
  confirmUrl,
442
- });
442
+ }, cfg);
443
443
  }
444
444
  // 2. Aviso de segurança → e-mail ATUAL (best-effort, fail-safe).
445
445
  if (account) {
@@ -1098,7 +1098,7 @@ export default class AuthInteractionController {
1098
1098
  await cfg.mail.onOtpUnlock({ email: account.email, unlockUrl, token: raw }).catch(() => { });
1099
1099
  }
1100
1100
  else {
1101
- await sendOtpUnlockEmail(ctx, { email: account.email, unlockUrl }).catch(() => { });
1101
+ await sendOtpUnlockEmail(ctx, { email: account.email, unlockUrl }, cfg).catch(() => { });
1102
1102
  }
1103
1103
  }
1104
1104
  catch {
@@ -1200,7 +1200,7 @@ export default class AuthInteractionController {
1200
1200
  await cfg.mail.onMagicLink({ email: to, magicUrl, token: issued.token, code, channel });
1201
1201
  }
1202
1202
  else {
1203
- await sendMagicLinkEmail(ctx, { email: to, magicUrl, code, channel });
1203
+ await sendMagicLinkEmail(ctx, { email: to, magicUrl, code, channel }, cfg);
1204
1204
  }
1205
1205
  }
1206
1206
  }
@@ -210,7 +210,7 @@ export default class AuthRegistrationController {
210
210
  await cfg.mail.onEmailVerification({ email: data.email, verifyUrl, token: issued.token });
211
211
  }
212
212
  else {
213
- await sendEmailVerificationEmail(ctx, { email: data.email, verifyUrl });
213
+ await sendEmailVerificationEmail(ctx, { email: data.email, verifyUrl }, cfg);
214
214
  }
215
215
  }
216
216
  }
@@ -255,7 +255,7 @@ export default class AuthRegistrationController {
255
255
  await cfg.mail.onMagicLink({ email: to, magicUrl, token: issued.token, code });
256
256
  }
257
257
  else {
258
- await sendMagicLinkEmail(ctx, { email: to, magicUrl, code });
258
+ await sendMagicLinkEmail(ctx, { email: to, magicUrl, code }, cfg);
259
259
  }
260
260
  }
261
261
  }
@@ -279,7 +279,7 @@ export default class AuthRegistrationController {
279
279
  await cfg.mail.onMagicLink({ email: data.email, magicUrl, token, code, kind: 'signup' });
280
280
  }
281
281
  else {
282
- await sendMagicLinkEmail(ctx, { email: data.email, magicUrl, code });
282
+ await sendMagicLinkEmail(ctx, { email: data.email, magicUrl, code }, cfg);
283
283
  }
284
284
  }
285
285
  // Resposta uniforme: "enviamos um link" (não vaza existência da conta).
@@ -436,7 +436,7 @@ export default class AuthRegistrationController {
436
436
  await cfg.mail.onPasswordReset({ email: to, resetUrl: url, token: result.token });
437
437
  }
438
438
  else {
439
- await sendPasswordResetEmail(ctx, { email: to, resetUrl: url });
439
+ await sendPasswordResetEmail(ctx, { email: to, resetUrl: url }, cfg);
440
440
  }
441
441
  }
442
442
  return render(ctx, 'forgot', {
@@ -21,6 +21,11 @@ type MailService = any;
21
21
  * @internal
22
22
  */
23
23
  export declare function __setMailLoaderForTests(fn: (() => Promise<MailService | null>) | undefined): void;
24
+ /**
25
+ * Permite rearmar o warning "config não resolvido" (usado em testes).
26
+ * @internal
27
+ */
28
+ export declare function __resetMailConfigWarningForTests(): void;
24
29
  /**
25
30
  * Envia o e-mail de redefinição de senha pelo mailer default do host.
26
31
  * Best-effort: no fallback (sem mail) loga o link; nunca lança.
@@ -28,7 +33,7 @@ export declare function __setMailLoaderForTests(fn: (() => Promise<MailService |
28
33
  export declare function sendPasswordResetEmail(ctx: HttpContext, data: {
29
34
  email: string;
30
35
  resetUrl: string;
31
- }): Promise<void>;
36
+ }, cfg?: ResolvedServerConfig): Promise<void>;
32
37
  /**
33
38
  * Envia o e-mail de confirmação de TROCA de e-mail para o NOVO endereço.
34
39
  * Best-effort: no fallback (sem mail) loga o link; nunca lança.
@@ -36,7 +41,7 @@ export declare function sendPasswordResetEmail(ctx: HttpContext, data: {
36
41
  export declare function sendEmailChangeConfirmationEmail(ctx: HttpContext, data: {
37
42
  email: string;
38
43
  confirmUrl: string;
39
- }): Promise<void>;
44
+ }, cfg?: ResolvedServerConfig): Promise<void>;
40
45
  /**
41
46
  * Envia o e-mail de alerta de NOVO acesso à conta (login de um IP novo).
42
47
  * Best-effort: no fallback (sem mail) loga o evento; nunca lança.
@@ -65,7 +70,7 @@ export declare function sendMagicLinkEmail(ctx: HttpContext, data: {
65
70
  magicUrl: string;
66
71
  code?: string;
67
72
  channel?: 'code' | 'link';
68
- }): Promise<void>;
73
+ }, cfg?: ResolvedServerConfig): Promise<void>;
69
74
  /**
70
75
  * Envia um e-mail só com um código (sem link: quem digita é a pessoa, em outro lugar) pelo mailer
71
76
  * default do host, com o branding e o i18n do authkit. Os textos saem do catálogo pelas chaves
@@ -81,7 +86,7 @@ export declare function sendAuthkitCodeEmail(ctx: HttpContext, data: {
81
86
  /** Prefixo das chaves no catálogo (`'mail.poppy_sign_in'`). */
82
87
  messages: string;
83
88
  params?: Record<string, string | number>;
84
- }): Promise<void>;
89
+ }, cfg?: ResolvedServerConfig): Promise<void>;
85
90
  /**
86
91
  * Envia o link de CONFIRMAÇÃO DE IDENTIDADE (sudo) pelo mailer default do host.
87
92
  *
@@ -111,7 +116,7 @@ export declare function sendAuthkitCodeEmail(ctx: HttpContext, data: {
111
116
  export declare function sendSudoLinkEmail(ctx: HttpContext, data: {
112
117
  email: string;
113
118
  sudoUrl: string;
114
- }): Promise<boolean>;
119
+ }, cfg?: ResolvedServerConfig): Promise<boolean>;
115
120
  /**
116
121
  * Envia o e-mail de aviso de segurança ao e-mail ATUAL quando uma troca de
117
122
  * e-mail é solicitada ("Alguém pediu troca para X — se não foi você...").
@@ -149,6 +154,19 @@ export declare function sendSecurityNoticeEmail(ctx: HttpContext, cfg: ResolvedS
149
154
  export declare function sendOtpUnlockEmail(ctx: HttpContext, data: {
150
155
  email: string;
151
156
  unlockUrl: string;
157
+ }, cfg?: ResolvedServerConfig): Promise<void>;
158
+ /**
159
+ * Envia o aviso "sua conta será desativada por inatividade em N dias" pelo mailer
160
+ * default do host, com branding e i18n. Chamado pelo `authkit:accounts:expire-scan --warn`
161
+ * quando o host não define `mail.onAccountExpirationWarning` — fora de uma request, então
162
+ * aceita só um `{ logger }` (pino-like) no lugar do `HttpContext`, e o config resolvido
163
+ * vem do comando. Best-effort: sem `@adonisjs/mail` loga o aviso; nunca lança.
164
+ */
165
+ export declare function sendAccountExpirationWarningEmail(ctx: HttpContext | {
166
+ logger: Pick<HttpContext['logger'], 'info' | 'error'>;
167
+ }, cfg: ResolvedServerConfig, data: {
168
+ email: string;
169
+ expiresInDays: number;
152
170
  }): Promise<void>;
153
171
  /**
154
172
  * Sends the organization invitation email through the host default mailer.
@@ -170,7 +188,7 @@ export declare function sendOrgInvitationEmail(ctx: HttpContext, data: {
170
188
  role: string;
171
189
  acceptUrl: string;
172
190
  token: string;
173
- }): Promise<void>;
191
+ }, cfg?: ResolvedServerConfig): Promise<void>;
174
192
  /**
175
193
  * Envia o e-mail de verificação pelo mailer default do host.
176
194
  * Best-effort: no fallback (sem mail) loga o link; nunca lança.
@@ -178,5 +196,5 @@ export declare function sendOrgInvitationEmail(ctx: HttpContext, data: {
178
196
  export declare function sendEmailVerificationEmail(ctx: HttpContext, data: {
179
197
  email: string;
180
198
  verifyUrl: string;
181
- }): Promise<void>;
199
+ }, cfg?: ResolvedServerConfig): Promise<void>;
182
200
  export {};
@@ -6,7 +6,9 @@ var __rewriteRelativeImportExtension = (this && this.__rewriteRelativeImportExte
6
6
  }
7
7
  return path;
8
8
  };
9
+ import { getBootedApp } from '../../services/booted_app.js';
9
10
  import { accountPath } from './account_paths.js';
11
+ import { resolveDefaultBrand } from './branding.js';
10
12
  import { renderTransactionalEmail } from './email_templates.js';
11
13
  import { resolveMessages, translate } from './i18n.js';
12
14
  import { authkitOrigin } from './origin.js';
@@ -38,76 +40,115 @@ export function __setMailLoaderForTests(fn) {
38
40
  mailServicePromise = undefined;
39
41
  }
40
42
  }
43
+ let warnedUnresolved = false;
41
44
  /**
42
- * Tenta descobrir o `from` default da config de mail do host. Se não houver,
43
- * retorna `undefined` (deixa o @adonisjs/mail aplicar o default da própria config).
45
+ * Permite rearmar o warning "config não resolvido" (usado em testes).
46
+ * @internal
44
47
  */
45
- function defaultFrom(ctx) {
46
- try {
47
- // Alcançamos as configs do HOST via o resolver do container. Esse formato é
48
- // específico do app (a lib não conhece o shape das configs nem `containerResolver.app`
49
- // é público), então tipamos apenas ESTE acesso como `unknown`/cast estreito — não o
50
- // `HttpContext` inteiro.
51
- const resolver = ctx.containerResolver;
52
- const get = resolver.app?.config?.get;
53
- // 1) `from` específico do authkit (defineConfig({ mail: { from } })) — PRIORIDADE:
54
- // permite que o auth use um remetente próprio, diferente do e-mail geral do app.
55
- const authkitFrom = get?.('authkit')?.mail?.from;
56
- if (authkitFrom)
57
- return authkitFrom;
58
- // 2) `from` global do host (config/mail.ts).
59
- const hostFrom = get?.('mail')?.from;
60
- if (hostFrom)
61
- return hostFrom;
62
- }
63
- catch {
64
- // sem config resolvível — deixa o @adonisjs/mail decidir.
48
+ export function __resetMailConfigWarningForTests() {
49
+ warnedUnresolved = false;
50
+ }
51
+ /**
52
+ * Encontra o config RESOLVIDO do authkit (`ResolvedServerConfig`).
53
+ *
54
+ * Por que não `ctx.containerResolver.app.config.get('authkit')`, como antes: o
55
+ * `ContainerResolver` do `@adonisjs/fold` não tem `.app` — então numa request real
56
+ * esse caminho dava `undefined` SEMPRE e todo e-mail default saía em `en` com a marca
57
+ * "AuthKit". E mesmo onde há um `app` (o ctx do SDK embedded), `config.get('authkit')`
58
+ * devolve o `ConfigProvider` preguiçoso do `defineConfig`, não o config resolvido —
59
+ * `.i18n`/`.branding`/`.mail` nunca existem nele.
60
+ *
61
+ * Ordem: o `cfg` que o chamador já tem → o binding `authkit.server` pelo container da
62
+ * request → pelo `app.container` do ctx → pelo app bootado capturado pelo provider.
63
+ */
64
+ async function resolveServerConfig(ctx, cfg) {
65
+ if (cfg)
66
+ return cfg;
67
+ const resolver = ctx?.containerResolver;
68
+ const containers = [
69
+ () => resolver,
70
+ () => resolver?.app?.container,
71
+ () => getBootedApp().container,
72
+ ];
73
+ for (const container of containers) {
74
+ try {
75
+ const maker = container();
76
+ if (typeof maker?.make !== 'function')
77
+ continue;
78
+ const service = (await maker.make('authkit.server'));
79
+ if (service?.config)
80
+ return service.config;
81
+ }
82
+ catch {
83
+ // tenta o próximo; a falha final é avisada em `resolveMailSettings`.
84
+ }
65
85
  }
66
86
  return undefined;
67
87
  }
68
88
  /**
69
- * Resolve a marca default a partir do `config/authkit.ts` (branding.default +
70
- * company). Usada para o cabeçalho/cor de acento/rodapé dos e-mails. Cai num
71
- * default neutro se a config não for resolvível.
89
+ * O `from` global do `config/mail.ts` do host (um objeto simples, não um provider),
90
+ * lido do `app` do ctx (SDK embedded) ou do app bootado. Ausente → `undefined`, e o
91
+ * `@adonisjs/mail` decide.
72
92
  */
73
- function resolveBrand(ctx) {
74
- try {
75
- const resolver = ctx.containerResolver;
76
- const branding = resolver.app?.config?.get?.('authkit')?.branding;
77
- if (branding) {
78
- return {
79
- appName: branding.default?.appName || branding.company || 'AuthKit',
80
- accent: branding.default?.accent,
81
- company: branding.company,
82
- };
93
+ function hostMailFrom(ctx) {
94
+ const apps = [
95
+ () => ctx?.containerResolver?.app,
96
+ () => getBootedApp(),
97
+ ];
98
+ for (const app of apps) {
99
+ try {
100
+ const from = app()?.config?.get?.('mail')?.from;
101
+ if (from)
102
+ return from;
103
+ }
104
+ catch {
105
+ // sem app/config de mail — tenta o próximo.
83
106
  }
84
107
  }
85
- catch {
86
- // sem config authkit resolvível — usa default neutro.
87
- }
88
- return { appName: 'AuthKit' };
108
+ return undefined;
89
109
  }
90
110
  /**
91
- * Resolve o catálogo de mensagens i18n a partir do `config/authkit.ts` (i18n).
92
- * Cai no default (`en`) se a config não for resolvível. Usado para localizar
93
- * os e-mails transacionais (assunto/cabeçalho/corpo).
111
+ * Marca, i18n e `from` dos e-mails default, a partir do config resolvido.
112
+ *
113
+ * Sem config resolvível cai num default neutro (`en`, "AuthKit") — mas NUNCA em
114
+ * silêncio: avisa uma vez por processo, porque um e-mail no idioma/marca errados é
115
+ * exatamente o tipo de bug que só aparece na caixa de entrada de um usuário.
94
116
  */
95
- function resolveMailMessages(ctx) {
96
- try {
97
- const resolver = ctx.containerResolver;
98
- const i18n = resolver.app?.config?.get?.('authkit')?.i18n;
99
- return { messages: resolveMessages(i18n), locale: i18n?.locale ?? 'en' };
100
- }
101
- catch {
102
- // sem config authkit resolvível — usa o default `en`.
103
- return { messages: resolveMessages(), locale: 'en' };
104
- }
117
+ async function resolveMailSettings(ctx, cfg) {
118
+ const resolved = await resolveServerConfig(ctx, cfg);
119
+ if (!resolved) {
120
+ if (!warnedUnresolved) {
121
+ warnedUnresolved = true;
122
+ const message = 'authkit: config do authkit não resolvido para os e-mails default — usando locale `en` e a marca "AuthKit". Registre o AuthkitServerProvider ou passe o config resolvido.';
123
+ if (typeof ctx?.logger?.warn === 'function')
124
+ ctx.logger.warn(message);
125
+ else
126
+ console.warn(message);
127
+ }
128
+ // `en` de propósito: aqui NÃO há config nenhum (nem o default `pt-BR` do `defineConfig`,
129
+ // que um host sem `i18n` recebe pelo `cfg.locale` acima). Sem saber de quem é o app,
130
+ // o catálogo base — o único com todas as chaves — é o fallback honesto.
131
+ return { brand: { appName: 'AuthKit' }, messages: resolveMessages(), locale: 'en' };
132
+ }
133
+ const branding = resolved.branding;
134
+ const defaultBrand = resolveDefaultBrand(branding);
135
+ return {
136
+ brand: {
137
+ appName: defaultBrand.appName,
138
+ accent: defaultBrand.accent,
139
+ company: branding?.company,
140
+ },
141
+ messages: resolved.messages ?? resolveMessages(),
142
+ locale: resolved.locale ?? 'en',
143
+ // `from` próprio do authkit tem prioridade; senão o `from` global do `config/mail.ts`.
144
+ from: resolved.mail?.from ?? hostMailFrom(ctx),
145
+ };
105
146
  }
106
- async function sendEmail(ctx, to, content) {
147
+ async function sendEmail(settings, to, content) {
107
148
  const mail = await loadMail();
108
149
  if (!mail)
109
150
  return false;
110
- const from = defaultFrom(ctx);
151
+ const { from } = settings;
111
152
  await mail.send((message) => {
112
153
  if (from)
113
154
  message.from(from);
@@ -119,10 +160,10 @@ async function sendEmail(ctx, to, content) {
119
160
  * Envia o e-mail de redefinição de senha pelo mailer default do host.
120
161
  * Best-effort: no fallback (sem mail) loga o link; nunca lança.
121
162
  */
122
- export async function sendPasswordResetEmail(ctx, data) {
163
+ export async function sendPasswordResetEmail(ctx, data, cfg) {
123
164
  try {
124
- const brand = resolveBrand(ctx);
125
- const { messages: t, locale } = resolveMailMessages(ctx);
165
+ const settings = await resolveMailSettings(ctx, cfg);
166
+ const { brand, messages: t, locale } = settings;
126
167
  const content = renderTransactionalEmail({
127
168
  brand,
128
169
  locale,
@@ -134,7 +175,7 @@ export async function sendPasswordResetEmail(ctx, data) {
134
175
  ctaUrl: data.resetUrl,
135
176
  footnote: translate(t, 'mail.reset.fallback'),
136
177
  });
137
- const sent = await sendEmail(ctx, data.email, content);
178
+ const sent = await sendEmail(settings, data.email, content);
138
179
  if (!sent) {
139
180
  ctx.logger.info({ resetUrl: data.resetUrl, email: data.email }, 'authkit: link de redefinição de senha (dev — @adonisjs/mail ausente)');
140
181
  }
@@ -147,10 +188,10 @@ export async function sendPasswordResetEmail(ctx, data) {
147
188
  * Envia o e-mail de confirmação de TROCA de e-mail para o NOVO endereço.
148
189
  * Best-effort: no fallback (sem mail) loga o link; nunca lança.
149
190
  */
150
- export async function sendEmailChangeConfirmationEmail(ctx, data) {
191
+ export async function sendEmailChangeConfirmationEmail(ctx, data, cfg) {
151
192
  try {
152
- const brand = resolveBrand(ctx);
153
- const { messages: t, locale } = resolveMailMessages(ctx);
193
+ const settings = await resolveMailSettings(ctx, cfg);
194
+ const { brand, messages: t, locale } = settings;
154
195
  const content = renderTransactionalEmail({
155
196
  brand,
156
197
  locale,
@@ -162,7 +203,7 @@ export async function sendEmailChangeConfirmationEmail(ctx, data) {
162
203
  ctaUrl: data.confirmUrl,
163
204
  footnote: translate(t, 'mail.email_change.fallback'),
164
205
  });
165
- const sent = await sendEmail(ctx, data.email, content);
206
+ const sent = await sendEmail(settings, data.email, content);
166
207
  if (!sent) {
167
208
  ctx.logger.info({ confirmUrl: data.confirmUrl, email: data.email }, 'authkit: link de confirmação de troca de e-mail (dev — @adonisjs/mail ausente)');
168
209
  }
@@ -177,8 +218,8 @@ export async function sendEmailChangeConfirmationEmail(ctx, data) {
177
218
  */
178
219
  export async function sendNewLoginEmail(ctx, cfg, data) {
179
220
  try {
180
- const brand = resolveBrand(ctx);
181
- const { messages: t, locale } = resolveMailMessages(ctx);
221
+ const settings = await resolveMailSettings(ctx, cfg);
222
+ const { brand, messages: t, locale } = settings;
182
223
  const origin = authkitOrigin(cfg);
183
224
  const intro = [
184
225
  translate(t, 'mail.new_login.intro'),
@@ -196,7 +237,7 @@ export async function sendNewLoginEmail(ctx, cfg, data) {
196
237
  ctaUrl: `${origin}${accountPath('security')}`,
197
238
  footnote: translate(t, 'mail.new_login.fallback'),
198
239
  });
199
- const sent = await sendEmail(ctx, data.email, content);
240
+ const sent = await sendEmail(settings, data.email, content);
200
241
  if (!sent) {
201
242
  ctx.logger.info({ ip: data.ip, email: data.email }, 'authkit: alerta de novo acesso (dev — @adonisjs/mail ausente)');
202
243
  }
@@ -211,8 +252,8 @@ export async function sendNewLoginEmail(ctx, cfg, data) {
211
252
  */
212
253
  export async function sendNewDeviceLoginEmail(ctx, cfg, data) {
213
254
  try {
214
- const brand = resolveBrand(ctx);
215
- const { messages: t, locale } = resolveMailMessages(ctx);
255
+ const settings = await resolveMailSettings(ctx, cfg);
256
+ const { brand, messages: t, locale } = settings;
216
257
  const origin = authkitOrigin(cfg);
217
258
  const lines = [
218
259
  translate(t, 'mail.new_login.intro'),
@@ -233,7 +274,7 @@ export async function sendNewDeviceLoginEmail(ctx, cfg, data) {
233
274
  ctaUrl: `${origin}${accountPath('security')}`,
234
275
  footnote: translate(t, 'mail.new_login.fallback'),
235
276
  });
236
- const sent = await sendEmail(ctx, data.email, content);
277
+ const sent = await sendEmail(settings, data.email, content);
237
278
  if (!sent) {
238
279
  ctx.logger.info({ ip: data.ip, email: data.email, userAgent: data.userAgent }, 'authkit: alerta de novo dispositivo (dev — @adonisjs/mail ausente)');
239
280
  }
@@ -246,10 +287,10 @@ export async function sendNewDeviceLoginEmail(ctx, cfg, data) {
246
287
  * Envia o e-mail de magic link (login passwordless) pelo mailer default do host.
247
288
  * Best-effort: no fallback (sem mail) loga o link; nunca lança.
248
289
  */
249
- export async function sendMagicLinkEmail(ctx, data) {
290
+ export async function sendMagicLinkEmail(ctx, data, cfg) {
250
291
  try {
251
- const brand = resolveBrand(ctx);
252
- const { messages: t, locale } = resolveMailMessages(ctx);
292
+ const settings = await resolveMailSettings(ctx, cfg);
293
+ const { brand, messages: t, locale } = settings;
253
294
  // Login choose-first: o `channel` decide o que o e-mail SURFA (os dois tokens
254
295
  // continuam emitidos co-locados a montante — isto é só renderização).
255
296
  // - 'code' → e-mail SÓ com o código (sem botão/link), quando há código;
@@ -285,7 +326,7 @@ export async function sendMagicLinkEmail(ctx, data) {
285
326
  code: linkOnly ? undefined : data.code,
286
327
  codeLabel: translate(t, 'mail.magic_link.code_label'),
287
328
  });
288
- const sent = await sendEmail(ctx, data.email, content);
329
+ const sent = await sendEmail(settings, data.email, content);
289
330
  if (!sent) {
290
331
  ctx.logger.info({ magicUrl: data.magicUrl, email: data.email }, 'authkit: magic link de login (dev — @adonisjs/mail ausente)');
291
332
  }
@@ -303,10 +344,10 @@ export async function sendMagicLinkEmail(ctx, data) {
303
344
  *
304
345
  * Estável no 1.x (é o mailer das `personalAgents.extensions`).
305
346
  */
306
- export async function sendAuthkitCodeEmail(ctx, data) {
347
+ export async function sendAuthkitCodeEmail(ctx, data, cfg) {
307
348
  try {
308
- const brand = resolveBrand(ctx);
309
- const { messages: t, locale } = resolveMailMessages(ctx);
349
+ const settings = await resolveMailSettings(ctx, cfg);
350
+ const { brand, messages: t, locale } = settings;
310
351
  const key = (name) => `${data.messages}.${name}`;
311
352
  const content = renderTransactionalEmail({
312
353
  brand,
@@ -318,7 +359,7 @@ export async function sendAuthkitCodeEmail(ctx, data) {
318
359
  code: data.code,
319
360
  codeLabel: translate(t, key('code_label'), data.params),
320
361
  });
321
- const sent = await sendEmail(ctx, data.email, content);
362
+ const sent = await sendEmail(settings, data.email, content);
322
363
  if (!sent) {
323
364
  ctx.logger.info({ email: data.email, code: data.code, messages: data.messages, ...(data.params ?? {}) }, 'authkit: código por e-mail (dev — @adonisjs/mail ausente)');
324
365
  }
@@ -353,10 +394,10 @@ export async function sendAuthkitCodeEmail(ctx, data) {
353
394
  * apagar o pendente da sessão e recusar — flashar "link enviado" deixaria o
354
395
  * usuário esperando um e-mail que nunca vem, na tela que já é o gargalo dele.
355
396
  */
356
- export async function sendSudoLinkEmail(ctx, data) {
397
+ export async function sendSudoLinkEmail(ctx, data, cfg) {
357
398
  try {
358
- const brand = resolveBrand(ctx);
359
- const { messages: t, locale } = resolveMailMessages(ctx);
399
+ const settings = await resolveMailSettings(ctx, cfg);
400
+ const { brand, messages: t, locale } = settings;
360
401
  const content = renderTransactionalEmail({
361
402
  brand,
362
403
  locale,
@@ -368,7 +409,7 @@ export async function sendSudoLinkEmail(ctx, data) {
368
409
  ctaUrl: data.sudoUrl,
369
410
  footnote: translate(t, 'mail.sudo_link.fallback'),
370
411
  });
371
- const sent = await sendEmail(ctx, data.email, content);
412
+ const sent = await sendEmail(settings, data.email, content);
372
413
  if (!sent) {
373
414
  ctx.logger?.info({ sudoUrl: data.sudoUrl, email: data.email }, 'authkit: link de confirmação de identidade (dev — @adonisjs/mail ausente)');
374
415
  }
@@ -386,8 +427,8 @@ export async function sendSudoLinkEmail(ctx, data) {
386
427
  */
387
428
  export async function sendEmailChangeNoticeEmail(ctx, cfg, data) {
388
429
  try {
389
- const brand = resolveBrand(ctx);
390
- const { messages: t, locale } = resolveMailMessages(ctx);
430
+ const settings = await resolveMailSettings(ctx, cfg);
431
+ const { brand, messages: t, locale } = settings;
391
432
  const origin = authkitOrigin(cfg);
392
433
  const content = renderTransactionalEmail({
393
434
  brand,
@@ -400,7 +441,7 @@ export async function sendEmailChangeNoticeEmail(ctx, cfg, data) {
400
441
  ctaUrl: `${origin}${accountPath('security')}`,
401
442
  footnote: translate(t, 'mail.email_change_notice.fallback'),
402
443
  });
403
- const sent = await sendEmail(ctx, data.email, content);
444
+ const sent = await sendEmail(settings, data.email, content);
404
445
  if (!sent) {
405
446
  ctx.logger.info({ newEmail: data.newEmail, email: data.email }, 'authkit: aviso de troca de e-mail (dev — @adonisjs/mail ausente)');
406
447
  }
@@ -416,8 +457,8 @@ export async function sendEmailChangeNoticeEmail(ctx, cfg, data) {
416
457
  */
417
458
  export async function sendEmailChangedCompletedEmail(ctx, cfg, data) {
418
459
  try {
419
- const brand = resolveBrand(ctx);
420
- const { messages: t, locale } = resolveMailMessages(ctx);
460
+ const settings = await resolveMailSettings(ctx, cfg);
461
+ const { brand, messages: t, locale } = settings;
421
462
  const origin = authkitOrigin(cfg);
422
463
  const content = renderTransactionalEmail({
423
464
  brand,
@@ -433,7 +474,7 @@ export async function sendEmailChangedCompletedEmail(ctx, cfg, data) {
433
474
  ctaUrl: `${origin}${accountPath('security')}`,
434
475
  footnote: translate(t, 'mail.email_changed_completed.fallback'),
435
476
  });
436
- const sent = await sendEmail(ctx, data.oldEmail, content);
477
+ const sent = await sendEmail(settings, data.oldEmail, content);
437
478
  if (!sent) {
438
479
  ctx.logger.info({ oldEmail: data.oldEmail, newEmail: data.newEmail }, 'authkit: confirmação de troca de e-mail concluída (dev — @adonisjs/mail ausente)');
439
480
  }
@@ -449,8 +490,8 @@ export async function sendEmailChangedCompletedEmail(ctx, cfg, data) {
449
490
  */
450
491
  export async function sendSecurityNoticeEmail(ctx, cfg, data) {
451
492
  try {
452
- const brand = resolveBrand(ctx);
453
- const { messages: t, locale } = resolveMailMessages(ctx);
493
+ const settings = await resolveMailSettings(ctx, cfg);
494
+ const { brand, messages: t, locale } = settings;
454
495
  const origin = authkitOrigin(cfg);
455
496
  const kindKey = `mail.security_notice.kind_${data.kind}`;
456
497
  const kindLabel = translate(t, kindKey);
@@ -471,7 +512,7 @@ export async function sendSecurityNoticeEmail(ctx, cfg, data) {
471
512
  ctaUrl: `${origin}${accountPath('security')}`,
472
513
  footnote: translate(t, 'mail.security_notice.fallback'),
473
514
  });
474
- const sent = await sendEmail(ctx, data.email, content);
515
+ const sent = await sendEmail(settings, data.email, content);
475
516
  if (!sent) {
476
517
  ctx.logger.info({ kind: data.kind, email: data.email }, 'authkit: notificação de segurança (dev — @adonisjs/mail ausente)');
477
518
  }
@@ -484,10 +525,10 @@ export async function sendSecurityNoticeEmail(ctx, cfg, data) {
484
525
  * Envia o e-mail de desbloqueio do fator OTP (quando TOTP/recovery excede maxAttempts).
485
526
  * Best-effort: no fallback (sem mail) loga o link; nunca lança.
486
527
  */
487
- export async function sendOtpUnlockEmail(ctx, data) {
528
+ export async function sendOtpUnlockEmail(ctx, data, cfg) {
488
529
  try {
489
- const brand = resolveBrand(ctx);
490
- const { messages: t, locale } = resolveMailMessages(ctx);
530
+ const settings = await resolveMailSettings(ctx, cfg);
531
+ const { brand, messages: t, locale } = settings;
491
532
  const content = renderTransactionalEmail({
492
533
  brand,
493
534
  locale,
@@ -499,7 +540,7 @@ export async function sendOtpUnlockEmail(ctx, data) {
499
540
  ctaUrl: data.unlockUrl,
500
541
  footnote: translate(t, 'mail.otp_unlock.fallback'),
501
542
  });
502
- const sent = await sendEmail(ctx, data.email, content);
543
+ const sent = await sendEmail(settings, data.email, content);
503
544
  if (!sent) {
504
545
  ctx.logger.info({ unlockUrl: data.unlockUrl, email: data.email }, 'authkit: link de desbloqueio OTP (dev — @adonisjs/mail ausente)');
505
546
  }
@@ -508,6 +549,39 @@ export async function sendOtpUnlockEmail(ctx, data) {
508
549
  ctx.logger.error({ err: error, email: data.email }, 'authkit: falha ao enviar e-mail de desbloqueio OTP');
509
550
  }
510
551
  }
552
+ /**
553
+ * Envia o aviso "sua conta será desativada por inatividade em N dias" pelo mailer
554
+ * default do host, com branding e i18n. Chamado pelo `authkit:accounts:expire-scan --warn`
555
+ * quando o host não define `mail.onAccountExpirationWarning` — fora de uma request, então
556
+ * aceita só um `{ logger }` (pino-like) no lugar do `HttpContext`, e o config resolvido
557
+ * vem do comando. Best-effort: sem `@adonisjs/mail` loga o aviso; nunca lança.
558
+ */
559
+ export async function sendAccountExpirationWarningEmail(ctx, cfg, data) {
560
+ const httpCtx = ctx;
561
+ try {
562
+ const settings = await resolveMailSettings(httpCtx, cfg);
563
+ const { brand, messages: t, locale } = settings;
564
+ const origin = authkitOrigin(cfg);
565
+ const content = renderTransactionalEmail({
566
+ brand,
567
+ locale,
568
+ linkFallback: translate(t, 'mail.common.link_fallback'),
569
+ subject: translate(t, 'mail.account_expiration.subject', { days: data.expiresInDays }),
570
+ heading: translate(t, 'mail.account_expiration.heading', { days: data.expiresInDays }),
571
+ intro: translate(t, 'mail.account_expiration.intro', { days: data.expiresInDays }),
572
+ ctaLabel: translate(t, 'mail.account_expiration.cta'),
573
+ ctaUrl: `${origin}${accountPath('login')}`,
574
+ footnote: translate(t, 'mail.account_expiration.fallback'),
575
+ });
576
+ const sent = await sendEmail(settings, data.email, content);
577
+ if (!sent) {
578
+ httpCtx.logger?.info({ email: data.email, expiresInDays: data.expiresInDays }, 'authkit: aviso de expiração por inatividade (dev — @adonisjs/mail ausente)');
579
+ }
580
+ }
581
+ catch (error) {
582
+ httpCtx.logger?.error({ err: error, email: data.email }, 'authkit: falha ao enviar aviso de expiração por inatividade');
583
+ }
584
+ }
511
585
  /**
512
586
  * Sends the organization invitation email through the host default mailer.
513
587
  *
@@ -520,10 +594,10 @@ export async function sendOtpUnlockEmail(ctx, data) {
520
594
  *
521
595
  * Best-effort: never throws — invitation creation must not depend on delivery.
522
596
  */
523
- export async function sendOrgInvitationEmail(ctx, data) {
597
+ export async function sendOrgInvitationEmail(ctx, data, cfg) {
524
598
  try {
525
- const brand = resolveBrand(ctx);
526
- const { messages: t, locale } = resolveMailMessages(ctx);
599
+ const settings = await resolveMailSettings(ctx, cfg);
600
+ const { brand, messages: t, locale } = settings;
527
601
  const content = renderTransactionalEmail({
528
602
  brand,
529
603
  locale,
@@ -535,7 +609,7 @@ export async function sendOrgInvitationEmail(ctx, data) {
535
609
  ctaUrl: data.acceptUrl,
536
610
  footnote: translate(t, 'mail.org_invitation.fallback'),
537
611
  });
538
- const sent = await sendEmail(ctx, data.email, content);
612
+ const sent = await sendEmail(settings, data.email, content);
539
613
  if (!sent) {
540
614
  ctx.logger?.info({
541
615
  acceptUrl: data.acceptUrl,
@@ -553,10 +627,10 @@ export async function sendOrgInvitationEmail(ctx, data) {
553
627
  * Envia o e-mail de verificação pelo mailer default do host.
554
628
  * Best-effort: no fallback (sem mail) loga o link; nunca lança.
555
629
  */
556
- export async function sendEmailVerificationEmail(ctx, data) {
630
+ export async function sendEmailVerificationEmail(ctx, data, cfg) {
557
631
  try {
558
- const brand = resolveBrand(ctx);
559
- const { messages: t, locale } = resolveMailMessages(ctx);
632
+ const settings = await resolveMailSettings(ctx, cfg);
633
+ const { brand, messages: t, locale } = settings;
560
634
  const content = renderTransactionalEmail({
561
635
  brand,
562
636
  locale,
@@ -567,7 +641,7 @@ export async function sendEmailVerificationEmail(ctx, data) {
567
641
  ctaLabel: translate(t, 'mail.verify.cta'),
568
642
  ctaUrl: data.verifyUrl,
569
643
  });
570
- const sent = await sendEmail(ctx, data.email, content);
644
+ const sent = await sendEmail(settings, data.email, content);
571
645
  if (!sent) {
572
646
  ctx.logger.info({ verifyUrl: data.verifyUrl, email: data.email }, 'authkit: link de verificação de e-mail (dev — @adonisjs/mail ausente)');
573
647
  }
@@ -629,6 +629,11 @@ export declare const DEFAULT_MESSAGES: {
629
629
  'mail.otp_unlock.intro': string;
630
630
  'mail.otp_unlock.cta': string;
631
631
  'mail.otp_unlock.fallback': string;
632
+ 'mail.account_expiration.subject': string;
633
+ 'mail.account_expiration.heading': string;
634
+ 'mail.account_expiration.intro': string;
635
+ 'mail.account_expiration.cta': string;
636
+ 'mail.account_expiration.fallback': string;
632
637
  'account.confirm.page_title': string;
633
638
  'account.confirm.title': string;
634
639
  'account.confirm.intro': string;
@@ -1422,6 +1427,11 @@ export declare const PT_BR_MESSAGES: {
1422
1427
  'mail.otp_unlock.intro': string;
1423
1428
  'mail.otp_unlock.cta': string;
1424
1429
  'mail.otp_unlock.fallback': string;
1430
+ 'mail.account_expiration.subject': string;
1431
+ 'mail.account_expiration.heading': string;
1432
+ 'mail.account_expiration.intro': string;
1433
+ 'mail.account_expiration.cta': string;
1434
+ 'mail.account_expiration.fallback': string;
1425
1435
  'otp_unlock.page_title': string;
1426
1436
  'otp_unlock.ok_title': string;
1427
1437
  'otp_unlock.ok_body': string;
@@ -684,6 +684,12 @@ export const DEFAULT_MESSAGES = {
684
684
  'mail.otp_unlock.intro': 'Your two-factor authentication factor has been locked due to too many failed attempts. Click the button below to unlock it.',
685
685
  'mail.otp_unlock.cta': 'Unlock two-factor',
686
686
  'mail.otp_unlock.fallback': 'If you did not attempt to sign in, your account may be at risk — change your password immediately.',
687
+ // E-mail de aviso de expiração por inatividade (`authkit:accounts:expire-scan --warn`).
688
+ 'mail.account_expiration.subject': 'Your account will be deactivated due to inactivity',
689
+ 'mail.account_expiration.heading': 'Your account will be deactivated soon',
690
+ 'mail.account_expiration.intro': 'Your account will be deactivated in {days} day(s) due to inactivity. Sign in to keep your account active.',
691
+ 'mail.account_expiration.cta': 'Sign in',
692
+ 'mail.account_expiration.fallback': 'If you no longer use this account, you can ignore this email and it will be deactivated.',
687
693
  // Tela de desbloqueio OTP (/auth/otp-unlock/:token).
688
694
  // Sudo mode (confirm identity — /account/confirm).
689
695
  'account.confirm.page_title': 'Confirm your identity',
@@ -1561,6 +1567,12 @@ export const PT_BR_MESSAGES = {
1561
1567
  'mail.otp_unlock.intro': 'Sua verificação em duas etapas foi bloqueada após muitas tentativas incorretas. Clique no botão abaixo para desbloquear.',
1562
1568
  'mail.otp_unlock.cta': 'Desbloquear verificação',
1563
1569
  'mail.otp_unlock.fallback': 'Se você não tentou fazer login, sua conta pode estar em risco — altere sua senha imediatamente.',
1570
+ // E-mail de aviso de expiração por inatividade (pt-BR).
1571
+ 'mail.account_expiration.subject': 'Sua conta será desativada por inatividade',
1572
+ 'mail.account_expiration.heading': 'Sua conta será desativada em breve',
1573
+ 'mail.account_expiration.intro': 'Sua conta será desativada em {days} dia(s) por inatividade. Entre na sua conta para mantê-la ativa.',
1574
+ 'mail.account_expiration.cta': 'Entrar',
1575
+ 'mail.account_expiration.fallback': 'Se você não usa mais esta conta, pode ignorar este e-mail e ela será desativada.',
1564
1576
  // Tela de desbloqueio OTP (pt-BR).
1565
1577
  'otp_unlock.page_title': 'Desbloqueio da verificação em duas etapas',
1566
1578
  'otp_unlock.ok_title': 'Verificação em duas etapas desbloqueada',
@@ -154,7 +154,7 @@ export function magicLink() {
154
154
  delivered = true;
155
155
  }
156
156
  else {
157
- delivered = await sendSudoLinkEmail(ctx, { email: c.account.email, sudoUrl });
157
+ delivered = await sendSudoLinkEmail(ctx, { email: c.account.email, sudoUrl }, c.cfg);
158
158
  }
159
159
  }
160
160
  catch {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@adonis-agora/authkit-server",
3
- "version": "1.0.0-rc.13",
3
+ "version": "1.0.0-rc.15",
4
4
  "description": "AdonisJS OIDC/OAuth2 provider (Identity Provider) toolkit: ejectable auth server with sessions, rate-limiting, MFA/TOTP, audit log, federated logout and OpenTelemetry metrics.",
5
5
  "license": "MIT",
6
6
  "author": "dudousxd",