mirror of
https://github.com/HeyPuter/puter.git
synced 2026-09-22 13:15:49 +00:00
fix: failed email alarms
This commit is contained in:
@@ -161,19 +161,23 @@ export class EmailClient extends PuterClient {
|
||||
* Render a template and send it to `to`. `options.replyTo` sets the
|
||||
* Reply-To header (e.g. so a recipient can respond to the originator of the
|
||||
* message rather than the no-reply From address).
|
||||
*
|
||||
* Returns the transport's send result, or `null` when none is configured —
|
||||
* {@link sendRaw} drops the message instead of throwing. A caller that
|
||||
* treats undelivered mail as a failure must check for `null` too.
|
||||
*/
|
||||
async send<T extends EmailTemplateName>(
|
||||
to: string,
|
||||
template: T,
|
||||
values: Record<string, unknown> = {},
|
||||
options: { replyTo?: string } = {},
|
||||
): Promise<void> {
|
||||
) {
|
||||
const compiled = this.compiledTemplates[template];
|
||||
if (!compiled) {
|
||||
throw new Error(`Unknown email template: ${template}`);
|
||||
}
|
||||
|
||||
await this.sendRaw({
|
||||
return await this.sendRaw({
|
||||
from: this.defaultFrom(),
|
||||
to,
|
||||
subject: compiled.subject(values),
|
||||
|
||||
@@ -21,12 +21,11 @@
|
||||
* E2E-style tests for AuthController signup, login, and token-grant flows.
|
||||
*
|
||||
* Drives the controller's extracted route-handler methods directly with
|
||||
* synthetic req/res shapes — that way we exercise the full controller
|
||||
* logic (DB writes via in-memory sqlite, real password hashing, real
|
||||
* JWT signing/verifying via TokenService, real PermissionService writes)
|
||||
* without needing the HTTP layer's middleware (rate limiting, captcha,
|
||||
* anti-CSRF) to play along. Aligns with AGENTS.md: "Prefer test server
|
||||
* over mocking deps."
|
||||
* synthetic req/res shapes — that way we exercise the full controller logic (DB
|
||||
* writes via in-memory sqlite, real password hashing, real JWT
|
||||
* signing/verifying via TokenService, real PermissionService writes) without
|
||||
* needing the HTTP layer's middleware (rate limiting, captcha, anti-CSRF) to
|
||||
* play along. Aligns with AGENTS.md: "Prefer test server over mocking deps."
|
||||
*/
|
||||
|
||||
import bcrypt from 'bcrypt';
|
||||
@@ -1391,12 +1390,13 @@ describe('AuthController.handleElevate', () => {
|
||||
);
|
||||
expect(res.body).toMatchObject({ elevated: true });
|
||||
expect(res.cookies.puter_elevated).toBeDefined();
|
||||
expect(res.cookies.puter_elevated.opts).toMatchObject({ httpOnly: true });
|
||||
expect(res.cookies.puter_elevated.opts).toMatchObject({
|
||||
httpOnly: true,
|
||||
});
|
||||
|
||||
// The minted cookie satisfies verifyStepUpSession for this same user.
|
||||
const { verifyStepUpSession } = await import(
|
||||
'../../core/http/middleware/stepUpSession.js'
|
||||
);
|
||||
const { verifyStepUpSession } =
|
||||
await import('../../core/http/middleware/stepUpSession.js');
|
||||
const ok = verifyStepUpSession(
|
||||
{
|
||||
cookies: { puter_elevated: res.cookies.puter_elevated.value },
|
||||
@@ -1422,9 +1422,7 @@ describe('AuthController.handleElevate', () => {
|
||||
|
||||
it('2FA account: a live TOTP code elevates; a wrong code is rejected', async () => {
|
||||
const { TOTP } = await import('otpauth');
|
||||
const { createSecret } = await import(
|
||||
'../../services/auth/OTPUtil.js'
|
||||
);
|
||||
const { createSecret } = await import('../../services/auth/OTPUtil.js');
|
||||
const { user, actor } = await makeUserAndActor();
|
||||
const { secret } = createSecret(user.username);
|
||||
await server.stores.user.update(user.id, {
|
||||
@@ -1501,9 +1499,8 @@ describe('AuthController.handleElevate', () => {
|
||||
|
||||
it('the elevation token is never honored as a main auth token', async () => {
|
||||
const { user } = await makeUserAndActor();
|
||||
const { signStepUpToken } = await import(
|
||||
'../../core/http/middleware/stepUpSession.js'
|
||||
);
|
||||
const { signStepUpToken } =
|
||||
await import('../../core/http/middleware/stepUpSession.js');
|
||||
const token = signStepUpToken(server.services.token, {
|
||||
uuid: user.uuid,
|
||||
});
|
||||
@@ -1602,10 +1599,8 @@ describe('AuthController account-lifecycle route gating', () => {
|
||||
const gate = requireUserActorGate();
|
||||
const run = (actor: Partial<Actor>) =>
|
||||
new Promise<unknown>((resolve) => {
|
||||
gate(
|
||||
{ actor } as never,
|
||||
{} as never,
|
||||
(err?: unknown) => resolve(err),
|
||||
gate({ actor } as never, {} as never, (err?: unknown) =>
|
||||
resolve(err),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -1811,7 +1806,10 @@ describe('AuthController grant flows', () => {
|
||||
{ origin: { host: 'evil' }, permission: 'service:x:ii:read' },
|
||||
{ origin: 'https://a.test', permission: ['service:x:ii:read'] },
|
||||
{ app_uid: 12345, permission: 'service:x:ii:read' },
|
||||
{ origin: `https://${'a'.repeat(5000)}.test`, permission: 'service:x:ii:read' },
|
||||
{
|
||||
origin: `https://${'a'.repeat(5000)}.test`,
|
||||
permission: 'service:x:ii:read',
|
||||
},
|
||||
];
|
||||
for (const body of cases) {
|
||||
await expect(
|
||||
@@ -2126,7 +2124,8 @@ describe('AuthController grant flows', () => {
|
||||
const NOTHING = 'nothing-in-particular';
|
||||
server.services.permission.registerRewriter({
|
||||
id: `test-grant-only-${prefix}`,
|
||||
matches: (permission: string) => permission.startsWith(`${prefix}:`),
|
||||
matches: (permission: string) =>
|
||||
permission.startsWith(`${prefix}:`),
|
||||
// The real rewriter's condition, verbatim.
|
||||
rewrite: async (permission: string) =>
|
||||
Context.get('is_grant_user_app_permission')
|
||||
@@ -2172,7 +2171,6 @@ describe('AuthController grant flows', () => {
|
||||
// And it must not have written the sentinel as a row of its own.
|
||||
expect(await storedPermissions()).not.toContain(NOTHING);
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
// ── Token grants: get-user-app-token / check-app ───────────────────
|
||||
@@ -2250,7 +2248,7 @@ describe('AuthController.handleGetUserAppToken + handleCheckApp', () => {
|
||||
// the latency with every test still passing.
|
||||
it('runs the permission grant, token mint and AppData mkdir concurrently', async () => {
|
||||
const order: string[] = [];
|
||||
const defer = <T,>(label: string, value: T) => {
|
||||
const defer = <T>(label: string, value: T) => {
|
||||
order.push(`${label}:start`);
|
||||
return new Promise<T>((resolve) =>
|
||||
setTimeout(() => {
|
||||
@@ -2727,6 +2725,39 @@ describe('AuthController.handleSendConfirmEmail', () => {
|
||||
expect(after!.email_confirm_code).not.toBe(before!.email_confirm_code);
|
||||
expect(String(after!.email_confirm_code).length).toBe(6);
|
||||
});
|
||||
|
||||
// The resend is the recovery path for a lost code, so it must not fail
|
||||
// silently either.
|
||||
it('alarms — and still returns {} — when the resend send throws', async () => {
|
||||
const { actor } = await makeUserAndActor();
|
||||
const alarm = vi
|
||||
.spyOn(server.clients.alarm, 'create')
|
||||
.mockImplementation(() => undefined);
|
||||
const send = vi
|
||||
.spyOn(server.clients.email, 'send')
|
||||
.mockRejectedValue(new Error('smtp down'));
|
||||
try {
|
||||
const res = makeRes();
|
||||
await controller.handleSendConfirmEmail(
|
||||
makeReq({}, { actor }),
|
||||
res,
|
||||
);
|
||||
// Not the caller's fault; still a 200.
|
||||
expect(res.body).toEqual({});
|
||||
|
||||
const raised = alarm.mock.calls.find(
|
||||
(c) => c[0] === 'auth:confirmation-email-send-failed:resend',
|
||||
);
|
||||
expect(raised).toBeTruthy();
|
||||
const fields = raised![2] as Record<string, unknown>;
|
||||
expect(fields.stage).toBe('resend');
|
||||
expect(fields.detail).toBe('smtp down');
|
||||
expect(fields.error).toBeInstanceOf(Error);
|
||||
} finally {
|
||||
send.mockRestore();
|
||||
alarm.mockRestore();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// The per-account / per-number SMS send caps used to live here as a hardcoded
|
||||
@@ -2777,7 +2808,10 @@ describe('AuthController.handleSendConfirmPhone validation', () => {
|
||||
const { actor } = await makeUserAndActor();
|
||||
const createVerification = vi.fn(async () => ({ status: 'success' }));
|
||||
await withPrelude(
|
||||
stubPrelude({ isCountrySupported: () => false, createVerification }),
|
||||
stubPrelude({
|
||||
isCountrySupported: () => false,
|
||||
createVerification,
|
||||
}),
|
||||
async () => {
|
||||
await expect(
|
||||
controller.handleSendConfirmPhone(
|
||||
@@ -2949,7 +2983,10 @@ describe('AuthController.handleSendConfirmPhone validation', () => {
|
||||
const createVerification = vi.fn(async () => ({ status: 'success' }));
|
||||
await withPrelude(stubPrelude({ createVerification }), async () => {
|
||||
await controller.handleSendConfirmPhone(
|
||||
makeReq({ phone: '+14155550123' }, { actor, ip: '203.0.113.7' }),
|
||||
makeReq(
|
||||
{ phone: '+14155550123' },
|
||||
{ actor, ip: '203.0.113.7' },
|
||||
),
|
||||
makeRes(),
|
||||
);
|
||||
});
|
||||
@@ -3152,7 +3189,10 @@ describe('AuthController phone verification — staging & reuse', () => {
|
||||
expect(res.body).toMatchObject({ phone_verified: true });
|
||||
},
|
||||
);
|
||||
expect(checkVerification).toHaveBeenCalledWith('+14155550123', '123456');
|
||||
expect(checkVerification).toHaveBeenCalledWith(
|
||||
'+14155550123',
|
||||
'123456',
|
||||
);
|
||||
const after = await server.stores.user.getById(user.id, {
|
||||
force: true,
|
||||
});
|
||||
@@ -3891,28 +3931,25 @@ describe('AuthController SMS → card fallback', () => {
|
||||
phone: '+14155550123',
|
||||
});
|
||||
await openFallback(user.id);
|
||||
await withFallbackConfig(
|
||||
{ enabled: true },
|
||||
async () => {
|
||||
const res = makeRes();
|
||||
await withCardSetupOverride(
|
||||
(data) => {
|
||||
data.enabled = true;
|
||||
data.client_secret = 'seti_secret';
|
||||
data.publishable_key = 'pk_test';
|
||||
},
|
||||
() =>
|
||||
controller.handleCardVerificationSetup(
|
||||
makeReq({}, { actor }),
|
||||
res,
|
||||
),
|
||||
);
|
||||
expect(res.body).toEqual({
|
||||
client_secret: 'seti_secret',
|
||||
publishable_key: 'pk_test',
|
||||
});
|
||||
},
|
||||
);
|
||||
await withFallbackConfig({ enabled: true }, async () => {
|
||||
const res = makeRes();
|
||||
await withCardSetupOverride(
|
||||
(data) => {
|
||||
data.enabled = true;
|
||||
data.client_secret = 'seti_secret';
|
||||
data.publishable_key = 'pk_test';
|
||||
},
|
||||
() =>
|
||||
controller.handleCardVerificationSetup(
|
||||
makeReq({}, { actor }),
|
||||
res,
|
||||
),
|
||||
);
|
||||
expect(res.body).toEqual({
|
||||
client_secret: 'seti_secret',
|
||||
publishable_key: 'pk_test',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('still 409s card setup when no send has opened the fallback', async () => {
|
||||
@@ -4007,27 +4044,24 @@ describe('AuthController SMS → card fallback', () => {
|
||||
phone: '+14155550123',
|
||||
});
|
||||
await openFallback(user.id);
|
||||
await withFallbackConfig(
|
||||
{ enabled: true },
|
||||
async () => {
|
||||
const res = makeRes();
|
||||
await withCardConfirmOverride(
|
||||
(data) => {
|
||||
data.enabled = true;
|
||||
data.verified = true;
|
||||
},
|
||||
() =>
|
||||
controller.handleCardVerificationConfirm(
|
||||
makeReq({ setup_intent_id: 'seti_1' }, { actor }),
|
||||
res,
|
||||
),
|
||||
);
|
||||
expect(res.body).toMatchObject({
|
||||
card_verified: true,
|
||||
phone_verified: true,
|
||||
});
|
||||
},
|
||||
);
|
||||
await withFallbackConfig({ enabled: true }, async () => {
|
||||
const res = makeRes();
|
||||
await withCardConfirmOverride(
|
||||
(data) => {
|
||||
data.enabled = true;
|
||||
data.verified = true;
|
||||
},
|
||||
() =>
|
||||
controller.handleCardVerificationConfirm(
|
||||
makeReq({ setup_intent_id: 'seti_1' }, { actor }),
|
||||
res,
|
||||
),
|
||||
);
|
||||
expect(res.body).toMatchObject({
|
||||
card_verified: true,
|
||||
phone_verified: true,
|
||||
});
|
||||
});
|
||||
const after = await server.stores.user.getById(user.id, {
|
||||
force: true,
|
||||
});
|
||||
@@ -4041,24 +4075,21 @@ describe('AuthController SMS → card fallback', () => {
|
||||
phone: '+14155550123',
|
||||
});
|
||||
await openFallback(user.id);
|
||||
await withFallbackConfig(
|
||||
{ enabled: true },
|
||||
async () => {
|
||||
const res = makeRes();
|
||||
await withCardConfirmOverride(
|
||||
(data) => {
|
||||
data.enabled = true;
|
||||
data.verified = true;
|
||||
},
|
||||
() =>
|
||||
controller.handleCardVerificationConfirm(
|
||||
makeReq({ setup_intent_id: 'seti_1' }, { actor }),
|
||||
res,
|
||||
),
|
||||
);
|
||||
expect(res.body).toMatchObject({ phone_verified: true });
|
||||
},
|
||||
);
|
||||
await withFallbackConfig({ enabled: true }, async () => {
|
||||
const res = makeRes();
|
||||
await withCardConfirmOverride(
|
||||
(data) => {
|
||||
data.enabled = true;
|
||||
data.verified = true;
|
||||
},
|
||||
() =>
|
||||
controller.handleCardVerificationConfirm(
|
||||
makeReq({ setup_intent_id: 'seti_1' }, { actor }),
|
||||
res,
|
||||
),
|
||||
);
|
||||
expect(res.body).toMatchObject({ phone_verified: true });
|
||||
});
|
||||
const after = await server.stores.user.getById(user.id, {
|
||||
force: true,
|
||||
});
|
||||
@@ -5006,10 +5037,7 @@ describe('AuthController.handleCheckPermissions + handleListPermissions', () =>
|
||||
const permission = 'service:tl-app:ii:read';
|
||||
await inCtx(actor, () =>
|
||||
controller.handleGrantUserApp(
|
||||
makeReq(
|
||||
{ app_uid: app.uid, permission, extra: {} },
|
||||
{ actor },
|
||||
),
|
||||
makeReq({ app_uid: app.uid, permission, extra: {} }, { actor }),
|
||||
makeRes(),
|
||||
),
|
||||
);
|
||||
@@ -5063,8 +5091,11 @@ describe('AuthController.handleCheckPermissions + handleListPermissions', () =>
|
||||
holderRes,
|
||||
);
|
||||
expect(
|
||||
(holderRes.body as { user_to_myself: Array<{ user: string; permission: string }> })
|
||||
.user_to_myself,
|
||||
(
|
||||
holderRes.body as {
|
||||
user_to_myself: Array<{ user: string; permission: string }>;
|
||||
}
|
||||
).user_to_myself,
|
||||
).toContainEqual(
|
||||
expect.objectContaining({
|
||||
user: user.username,
|
||||
@@ -5165,10 +5196,7 @@ describe('AuthController session endpoints', () => {
|
||||
const uuid = (sessionRes.session as { uuid: string }).uuid;
|
||||
await expect(
|
||||
controller.handleRenameSession(
|
||||
makeReq(
|
||||
{ label: 'pwned' },
|
||||
{ actor: a2, params: { uuid } },
|
||||
),
|
||||
makeReq({ label: 'pwned' }, { actor: a2, params: { uuid } }),
|
||||
makeRes(),
|
||||
),
|
||||
).rejects.toMatchObject({ statusCode: 404 });
|
||||
@@ -6154,6 +6182,46 @@ describe('AuthController.handleSignup additional branches', () => {
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
// No mail transport in the test server, so `send` returns null — the
|
||||
// silent-drop case.
|
||||
it('alarms when the signup confirmation email is not delivered', async () => {
|
||||
const alarm = vi
|
||||
.spyOn(server.clients.alarm, 'create')
|
||||
.mockImplementation(() => undefined);
|
||||
try {
|
||||
await withSignupValidateOverride(
|
||||
(event) => {
|
||||
event.requires_email_confirmation = true;
|
||||
},
|
||||
async () => {
|
||||
const username = `cefail_${uniq()}`;
|
||||
await controller.handleSignup(
|
||||
makeReq({
|
||||
username,
|
||||
email: `${username}@test.local`,
|
||||
password: 'correct-horse-battery',
|
||||
}),
|
||||
makeRes(),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
const raised = alarm.mock.calls.find(
|
||||
(c) => c[0] === 'auth:confirmation-email-send-failed:signup',
|
||||
);
|
||||
expect(raised).toBeTruthy();
|
||||
const fields = raised![2] as Record<string, unknown>;
|
||||
expect(fields.stage).toBe('signup');
|
||||
// No phone/card gate outstanding: stuck on the email alone.
|
||||
expect(fields.sole_gate).toBe(true);
|
||||
expect(fields.email_domain).toBe('test.local');
|
||||
expect(raised![3]).toBe('warning');
|
||||
expect(raised![4]).toEqual({ dedup: true });
|
||||
} finally {
|
||||
alarm.mockRestore();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('AuthController.handleSendPassRecoveryEmail additional branches', () => {
|
||||
@@ -6743,7 +6811,6 @@ describe('AuthController.handleRevokeSession additional branches', () => {
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
// -- auth_id preservation on forced re-login --
|
||||
|
||||
describe('AuthController auth_id preservation on reauth', () => {
|
||||
@@ -7001,10 +7068,7 @@ describe('AuthController auth_id preservation on reauth', () => {
|
||||
const ip = `127.0.${Math.floor(Math.random() * 200)}.10`;
|
||||
await expect(
|
||||
controller.handleSignup(
|
||||
makeReq(
|
||||
{ is_temp: true, reauth_token: 'not-a-jwt' },
|
||||
{ ip },
|
||||
),
|
||||
makeReq({ is_temp: true, reauth_token: 'not-a-jwt' }, { ip }),
|
||||
makeRes(),
|
||||
),
|
||||
).rejects.toMatchObject({ statusCode: 401 });
|
||||
@@ -7058,9 +7122,9 @@ describe('AuthController auth_id preservation on reauth', () => {
|
||||
/**
|
||||
* The relay stands in for the popup's `postMessage` hand-off on
|
||||
* cross-origin-isolated openers, where COOP has severed `window.opener`.
|
||||
* postMessage is audience-bound for free (it posts with `targetOrigin`);
|
||||
* these tests pin the equivalent binding on the server-side path, since the
|
||||
* session id is a link-borne value and not a secret.
|
||||
* postMessage is audience-bound for free (it posts with `targetOrigin`); these
|
||||
* tests pin the equivalent binding on the server-side path, since the session
|
||||
* id is a link-borne value and not a secret.
|
||||
*/
|
||||
describe('AuthController.loginWait audience binding', () => {
|
||||
const OPENER = 'https://opener.test';
|
||||
@@ -7069,15 +7133,18 @@ describe('AuthController.loginWait audience binding', () => {
|
||||
const mintAppToken = async (actor: Actor, origin: string) => {
|
||||
const res = makeRes();
|
||||
await inCtx(actor, () =>
|
||||
controller.handleGetUserAppToken(makeReq({ origin }, { actor }), res),
|
||||
controller.handleGetUserAppToken(
|
||||
makeReq({ origin }, { actor }),
|
||||
res,
|
||||
),
|
||||
);
|
||||
return (res.body as { token: string }).token;
|
||||
};
|
||||
|
||||
/**
|
||||
* Start a wait, then relay `token` into it. The handler resolves the
|
||||
* origin (async, DB-backed) before subscribing, so the emit is retried
|
||||
* until the wait settles rather than fired after a fixed sleep.
|
||||
* Start a wait, then relay `token` into it. The handler resolves the origin
|
||||
* (async, DB-backed) before subscribing, so the emit is retried until the
|
||||
* wait settles rather than fired after a fixed sleep.
|
||||
*/
|
||||
const waitWithRelay = async (
|
||||
session: string,
|
||||
@@ -7085,7 +7152,10 @@ describe('AuthController.loginWait audience binding', () => {
|
||||
token: string | null,
|
||||
) => {
|
||||
const res = makeRes();
|
||||
const waiting = controller.loginWait(makeReq({ session }, { headers }), res);
|
||||
const waiting = controller.loginWait(
|
||||
makeReq({ session }, { headers }),
|
||||
res,
|
||||
);
|
||||
const settled = waiting.then(
|
||||
() => 'ok' as const,
|
||||
(e: unknown) => e,
|
||||
|
||||
@@ -1120,8 +1120,9 @@ export class AuthController extends PuterController {
|
||||
) {
|
||||
const sendCode = body.send_confirmation_code ?? true;
|
||||
try {
|
||||
let sent;
|
||||
if (sendCode) {
|
||||
await this.clients.email.send(
|
||||
sent = await this.clients.email.send(
|
||||
user!.email!,
|
||||
'email_verification_code',
|
||||
{
|
||||
@@ -1130,14 +1131,18 @@ export class AuthController extends PuterController {
|
||||
);
|
||||
} else {
|
||||
const link = `${this.config.origin ?? ''}/confirm-email-by-token?token=${email_confirm_token}&user_uuid=${user!.uuid}`;
|
||||
await this.clients.email.send(
|
||||
sent = await this.clients.email.send(
|
||||
user!.email!,
|
||||
'email_verification_link',
|
||||
{ link },
|
||||
);
|
||||
}
|
||||
// `null` = dropped for want of a transport; silent otherwise.
|
||||
if (sent === null) {
|
||||
this.#confirmationEmailFailed('signup', user!, null);
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('[signup] email send failed:', e);
|
||||
this.#confirmationEmailFailed('signup', user!, e);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1159,7 +1164,8 @@ export class AuthController extends PuterController {
|
||||
is_temp: user!.password === null && user!.email === null,
|
||||
ip:
|
||||
(req?.headers?.['x-forwarded-for'] as
|
||||
string | undefined) ||
|
||||
| string
|
||||
| undefined) ||
|
||||
(
|
||||
req as unknown as {
|
||||
connection?: { remoteAddress?: string };
|
||||
@@ -1269,13 +1275,16 @@ export class AuthController extends PuterController {
|
||||
|
||||
if (this.clients.email) {
|
||||
try {
|
||||
await this.clients.email.send(
|
||||
const sent = await this.clients.email.send(
|
||||
user.email,
|
||||
'email_verification_code',
|
||||
{ code },
|
||||
);
|
||||
if (sent === null) {
|
||||
this.#confirmationEmailFailed('resend', user, null);
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('[send-confirm-email] send failed:', e);
|
||||
this.#confirmationEmailFailed('resend', user, e);
|
||||
}
|
||||
}
|
||||
res.json({});
|
||||
@@ -1386,6 +1395,64 @@ export class AuthController extends PuterController {
|
||||
res.json({ email_confirmed: true, original_client_socket_id });
|
||||
}
|
||||
|
||||
/**
|
||||
* Alarm on a confirmation email that did not reach the recipient. A
|
||||
* `requires_email_confirmation` account is refused by
|
||||
* `requireVerifiedAccount` everywhere, so a lost code leaves an account
|
||||
* that cannot be used. `cause === null` is the silent case: `sendRaw` drops
|
||||
* the message rather than throwing when no transport is configured.
|
||||
*
|
||||
* `sole_gate` reports that no phone/card gate is outstanding either, so
|
||||
* this user is stuck on the email alone. `dedup` because one broken mail
|
||||
* path fails once per signup and is still one thing to fix.
|
||||
*/
|
||||
#confirmationEmailFailed(
|
||||
stage: 'signup' | 'resend',
|
||||
user: {
|
||||
uuid?: string | null;
|
||||
username?: string | null;
|
||||
email?: string | null;
|
||||
requires_phone_verification?: unknown;
|
||||
requires_card_verification?: unknown;
|
||||
},
|
||||
cause: unknown,
|
||||
): void {
|
||||
const email = user.email ?? null;
|
||||
const detail =
|
||||
cause instanceof Error
|
||||
? cause.message
|
||||
: cause === null
|
||||
? 'no transport configured (message dropped)'
|
||||
: String(cause);
|
||||
console.warn(
|
||||
`[${stage === 'signup' ? 'signup' : 'send-confirm-email'}] ` +
|
||||
`confirmation email not delivered: ${detail}`,
|
||||
);
|
||||
// Best-effort: failing to alarm must not fail the signup.
|
||||
try {
|
||||
this.clients.alarm?.create(
|
||||
`auth:confirmation-email-send-failed:${stage}`,
|
||||
'Confirmation email could not be sent — gated accounts cannot be used until it arrives',
|
||||
{
|
||||
stage,
|
||||
user_uid: user.uuid ?? null,
|
||||
username: user.username ?? null,
|
||||
email,
|
||||
email_domain: email?.split('@')[1] ?? null,
|
||||
sole_gate:
|
||||
!user.requires_phone_verification &&
|
||||
!user.requires_card_verification,
|
||||
detail,
|
||||
...(cause instanceof Error ? { error: cause } : {}),
|
||||
},
|
||||
'warning',
|
||||
{ dedup: true },
|
||||
);
|
||||
} catch (e) {
|
||||
console.warn(`[${stage}] confirmation-email alarm failed:`, e);
|
||||
}
|
||||
}
|
||||
|
||||
// -- Phone verification (SMS via Prelude) ------------------------
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user