@edge-base/server 0.1.1
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/admin-build/.gitkeep +0 -0
- package/admin-build/_app/env.js +1 -0
- package/admin-build/_app/immutable/assets/0.Bm6cF078.css +1 -0
- package/admin-build/_app/immutable/assets/1.BfW3pUNa.css +1 -0
- package/admin-build/_app/immutable/assets/11.CVmQOewb.css +1 -0
- package/admin-build/_app/immutable/assets/12.B1EhbRZT.css +1 -0
- package/admin-build/_app/immutable/assets/13.BvwYeuwE.css +1 -0
- package/admin-build/_app/immutable/assets/14.CdVfcO0R.css +1 -0
- package/admin-build/_app/immutable/assets/15.2yeZ66b-.css +1 -0
- package/admin-build/_app/immutable/assets/17.BVg0JEVu.css +1 -0
- package/admin-build/_app/immutable/assets/18.Rwnl3x_i.css +1 -0
- package/admin-build/_app/immutable/assets/20.DsPWA9AV.css +1 -0
- package/admin-build/_app/immutable/assets/21.Dz2RJ56c.css +1 -0
- package/admin-build/_app/immutable/assets/22.DwNLk5Ai.css +1 -0
- package/admin-build/_app/immutable/assets/23.CFpu0gOO.css +1 -0
- package/admin-build/_app/immutable/assets/24.Cy5LBeoJ.css +1 -0
- package/admin-build/_app/immutable/assets/25.pUyLVf-h.css +1 -0
- package/admin-build/_app/immutable/assets/26.DBcGrlXa.css +1 -0
- package/admin-build/_app/immutable/assets/27.BswYyAJD.css +1 -0
- package/admin-build/_app/immutable/assets/28.B4ueB1Kf.css +1 -0
- package/admin-build/_app/immutable/assets/29.B-qU6PdF.css +1 -0
- package/admin-build/_app/immutable/assets/3.Dg81Pgmd.css +1 -0
- package/admin-build/_app/immutable/assets/30.CsdWum94.css +1 -0
- package/admin-build/_app/immutable/assets/31.U6OwIp50.css +1 -0
- package/admin-build/_app/immutable/assets/4.CyawCCux.css +1 -0
- package/admin-build/_app/immutable/assets/5.C0YO2HTk.css +1 -0
- package/admin-build/_app/immutable/assets/8.Br5jd6kD.css +1 -0
- package/admin-build/_app/immutable/assets/Badge.EMYLHBxE.css +1 -0
- package/admin-build/_app/immutable/assets/Button.DpzMRTjK.css +1 -0
- package/admin-build/_app/immutable/assets/ConfirmDialog.DAnaWRRk.css +1 -0
- package/admin-build/_app/immutable/assets/EmptyState.CwKsu57Y.css +1 -0
- package/admin-build/_app/immutable/assets/Input.BDUSenmU.css +1 -0
- package/admin-build/_app/immutable/assets/Modal.Dm5B0Xie.css +1 -0
- package/admin-build/_app/immutable/assets/PageShell.CmU-Xh-b.css +1 -0
- package/admin-build/_app/immutable/assets/SchemaFieldEditor.g4NsCdno.css +1 -0
- package/admin-build/_app/immutable/assets/Select.BW4Keufm.css +1 -0
- package/admin-build/_app/immutable/assets/Skeleton.KWUulTKJ.css +1 -0
- package/admin-build/_app/immutable/assets/Tabs.CniGYb67.css +1 -0
- package/admin-build/_app/immutable/assets/TimeChart.BTCDAvmT.css +1 -0
- package/admin-build/_app/immutable/assets/Toggle.Cy_K12OM.css +1 -0
- package/admin-build/_app/immutable/assets/TopList.ClFzmPlA.css +1 -0
- package/admin-build/_app/immutable/chunks/7B47DvSx.js +1 -0
- package/admin-build/_app/immutable/chunks/7f08Id8e.js +1 -0
- package/admin-build/_app/immutable/chunks/8wJeQ7LN.js +1 -0
- package/admin-build/_app/immutable/chunks/B-h2afW5.js +1 -0
- package/admin-build/_app/immutable/chunks/B8vJP3wz.js +1 -0
- package/admin-build/_app/immutable/chunks/BR_fL5Yv.js +1 -0
- package/admin-build/_app/immutable/chunks/BY92tFS2.js +1 -0
- package/admin-build/_app/immutable/chunks/BcR-Rdj9.js +1 -0
- package/admin-build/_app/immutable/chunks/BdrwyZv8.js +1 -0
- package/admin-build/_app/immutable/chunks/Bh56EfQ_.js +1 -0
- package/admin-build/_app/immutable/chunks/BkrCkgYp.js +1 -0
- package/admin-build/_app/immutable/chunks/BmRjiP5k.js +1 -0
- package/admin-build/_app/immutable/chunks/BsokvhWC.js +1 -0
- package/admin-build/_app/immutable/chunks/C4D51vTW.js +1 -0
- package/admin-build/_app/immutable/chunks/C6puvcoR.js +2 -0
- package/admin-build/_app/immutable/chunks/CCKNu7m7.js +1 -0
- package/admin-build/_app/immutable/chunks/CWj6FrbW.js +1 -0
- package/admin-build/_app/immutable/chunks/Ce-ngf4p.js +5 -0
- package/admin-build/_app/immutable/chunks/Cs0GwzJA.js +1 -0
- package/admin-build/_app/immutable/chunks/CwROoZK0.js +1 -0
- package/admin-build/_app/immutable/chunks/CxCPv_Ut.js +1 -0
- package/admin-build/_app/immutable/chunks/CxbRue-5.js +1 -0
- package/admin-build/_app/immutable/chunks/CyqB6g-D.js +1 -0
- package/admin-build/_app/immutable/chunks/D5h5A1cc.js +2 -0
- package/admin-build/_app/immutable/chunks/DnyL7Zq-.js +1 -0
- package/admin-build/_app/immutable/chunks/DoPXzH7F.js +1 -0
- package/admin-build/_app/immutable/chunks/DrQSgw-f.js +1 -0
- package/admin-build/_app/immutable/chunks/DttM2zNO.js +1 -0
- package/admin-build/_app/immutable/chunks/DuXuUBWN.js +1 -0
- package/admin-build/_app/immutable/chunks/MdeqaOQx.js +10 -0
- package/admin-build/_app/immutable/chunks/NuUjtcO2.js +1 -0
- package/admin-build/_app/immutable/chunks/Q2nPFxS6.js +1 -0
- package/admin-build/_app/immutable/chunks/R6arueIl.js +1 -0
- package/admin-build/_app/immutable/chunks/UUazaC_N.js +1 -0
- package/admin-build/_app/immutable/chunks/cOYbrQxx.js +1 -0
- package/admin-build/_app/immutable/chunks/eFQHTGwA.js +1 -0
- package/admin-build/_app/immutable/chunks/ehbppgYb.js +1 -0
- package/admin-build/_app/immutable/chunks/glwixJlP.js +1 -0
- package/admin-build/_app/immutable/chunks/vApWTCBs.js +1 -0
- package/admin-build/_app/immutable/chunks/w89G9Xpi.js +1 -0
- package/admin-build/_app/immutable/chunks/wJsUhbfZ.js +1 -0
- package/admin-build/_app/immutable/chunks/zfauFM8P.js +1 -0
- package/admin-build/_app/immutable/entry/app.CcO-Uos3.js +2 -0
- package/admin-build/_app/immutable/entry/start.COebYq3I.js +1 -0
- package/admin-build/_app/immutable/nodes/0.CjtHKU-6.js +1 -0
- package/admin-build/_app/immutable/nodes/1.DEisjlM0.js +1 -0
- package/admin-build/_app/immutable/nodes/10.CvhdyWVB.js +1 -0
- package/admin-build/_app/immutable/nodes/11.DjHqcOvy.js +1 -0
- package/admin-build/_app/immutable/nodes/12.mQLz4Mj_.js +1 -0
- package/admin-build/_app/immutable/nodes/13.CBonZZyP.js +110 -0
- package/admin-build/_app/immutable/nodes/14.d-oiZL0j.js +3 -0
- package/admin-build/_app/immutable/nodes/15.CKPQsUYF.js +1 -0
- package/admin-build/_app/immutable/nodes/16.wPzAPQGx.js +1 -0
- package/admin-build/_app/immutable/nodes/17.DayhKyEZ.js +1 -0
- package/admin-build/_app/immutable/nodes/18.DKwS0Ir0.js +1 -0
- package/admin-build/_app/immutable/nodes/19.wPzAPQGx.js +1 -0
- package/admin-build/_app/immutable/nodes/2.BKoKrw1i.js +1 -0
- package/admin-build/_app/immutable/nodes/20.BvIkkkrW.js +1 -0
- package/admin-build/_app/immutable/nodes/21.DMaFhdHk.js +128 -0
- package/admin-build/_app/immutable/nodes/22.3xdgwuK1.js +1 -0
- package/admin-build/_app/immutable/nodes/23.8Bvgjbsl.js +112 -0
- package/admin-build/_app/immutable/nodes/24.DzSSzRhG.js +2 -0
- package/admin-build/_app/immutable/nodes/25.9KKYBnAE.js +2 -0
- package/admin-build/_app/immutable/nodes/26.Bhn9dfhY.js +1 -0
- package/admin-build/_app/immutable/nodes/27.kRLiC24G.js +1 -0
- package/admin-build/_app/immutable/nodes/28.BVIN1-7N.js +1 -0
- package/admin-build/_app/immutable/nodes/29.3yabZWj4.js +1 -0
- package/admin-build/_app/immutable/nodes/3.BFtSOkX7.js +2 -0
- package/admin-build/_app/immutable/nodes/30.CyCQlwaP.js +1 -0
- package/admin-build/_app/immutable/nodes/31.C4LDXjES.js +1 -0
- package/admin-build/_app/immutable/nodes/4.CvbiMlCa.js +1 -0
- package/admin-build/_app/immutable/nodes/5.C6BLv2eM.js +1 -0
- package/admin-build/_app/immutable/nodes/6.BcXvfl2P.js +1 -0
- package/admin-build/_app/immutable/nodes/7.CIuqhPiK.js +1 -0
- package/admin-build/_app/immutable/nodes/8.BQOR_JfO.js +1 -0
- package/admin-build/_app/immutable/nodes/9.NZqXQxPy.js +1 -0
- package/admin-build/_app/version.json +1 -0
- package/admin-build/favicon.svg +26 -0
- package/admin-build/index.html +45 -0
- package/openapi.json +19543 -0
- package/package.json +66 -0
- package/src/__tests__/admin-assets.test.ts +55 -0
- package/src/__tests__/admin-data-routes.test.ts +488 -0
- package/src/__tests__/admin-db-target.test.ts +103 -0
- package/src/__tests__/admin-routing.test.ts +31 -0
- package/src/__tests__/admin-user-management.test.ts +311 -0
- package/src/__tests__/analytics-query.test.ts +75 -0
- package/src/__tests__/auth-d1.test.ts +749 -0
- package/src/__tests__/auth-db-adapter.test.ts +73 -0
- package/src/__tests__/auth-jwt.test.ts +440 -0
- package/src/__tests__/auth-oauth.test.ts +389 -0
- package/src/__tests__/auth-password.test.ts +367 -0
- package/src/__tests__/auth-redirect.test.ts +87 -0
- package/src/__tests__/backup-restore.test.ts +711 -0
- package/src/__tests__/broadcast.test.ts +128 -0
- package/src/__tests__/cli.test.ts +178 -0
- package/src/__tests__/cloudflare-realtime.test.ts +113 -0
- package/src/__tests__/config.test.ts +469 -0
- package/src/__tests__/cors.test.ts +154 -0
- package/src/__tests__/cron.test.ts +302 -0
- package/src/__tests__/d1-handler.test.ts +402 -0
- package/src/__tests__/d1-sql.test.ts +120 -0
- package/src/__tests__/database-live-config.test.ts +42 -0
- package/src/__tests__/database-live-emitter.test.ts +56 -0
- package/src/__tests__/database-live-filters.test.ts +63 -0
- package/src/__tests__/database-live-route.test.ts +113 -0
- package/src/__tests__/db-sql.test.ts +163 -0
- package/src/__tests__/do-lifecycle.test.ts +263 -0
- package/src/__tests__/do-router.test.ts +729 -0
- package/src/__tests__/email-provider.test.ts +128 -0
- package/src/__tests__/email-templates.test.ts +528 -0
- package/src/__tests__/error-format.test.ts +250 -0
- package/src/__tests__/field-ops.test.ts +242 -0
- package/src/__tests__/functions-context.test.ts +334 -0
- package/src/__tests__/functions-d1-proxy.test.ts +229 -0
- package/src/__tests__/functions-registry-runtime-config.test.ts +17 -0
- package/src/__tests__/functions-route.test.ts +139 -0
- package/src/__tests__/internal-request.test.ts +77 -0
- package/src/__tests__/log-writer.test.ts +44 -0
- package/src/__tests__/logger.test.ts +58 -0
- package/src/__tests__/meta-admin-proxy.test.ts +48 -0
- package/src/__tests__/meta-export-coverage.test.ts +191 -0
- package/src/__tests__/meta-route-registration.test.ts +47 -0
- package/src/__tests__/namespace-dump.test.ts +28 -0
- package/src/__tests__/oauth-providers.test.ts +337 -0
- package/src/__tests__/openapi-coverage.test.ts +144 -0
- package/src/__tests__/pagination.test.ts +59 -0
- package/src/__tests__/password-policy.test.ts +191 -0
- package/src/__tests__/plugin-migrations.test.ts +379 -0
- package/src/__tests__/postgres-batch-compat.test.ts +133 -0
- package/src/__tests__/postgres-dialect.test.ts +328 -0
- package/src/__tests__/postgres-executor.test.ts +79 -0
- package/src/__tests__/postgres-field-ops-compat.test.ts +222 -0
- package/src/__tests__/postgres-schema-init.test.ts +105 -0
- package/src/__tests__/postgres-table-utils.test.ts +107 -0
- package/src/__tests__/presence.test.ts +199 -0
- package/src/__tests__/provider.test.ts +550 -0
- package/src/__tests__/public-user-profile.test.ts +339 -0
- package/src/__tests__/push-handlers.test.ts +179 -0
- package/src/__tests__/push-provider.test.ts +80 -0
- package/src/__tests__/push-token.test.ts +418 -0
- package/src/__tests__/query.test.ts +771 -0
- package/src/__tests__/rate-limit.test.ts +260 -0
- package/src/__tests__/room-access-policy.test.ts +101 -0
- package/src/__tests__/room-handler-context.test.ts +130 -0
- package/src/__tests__/room-monitoring.test.ts +138 -0
- package/src/__tests__/room-runtime-routing.test.ts +222 -0
- package/src/__tests__/room.test.ts +254 -0
- package/src/__tests__/route-parser.test.ts +490 -0
- package/src/__tests__/rules.test.ts +234 -0
- package/src/__tests__/runtime-surface-accounting.test.ts +120 -0
- package/src/__tests__/scheduled.test.ts +80 -0
- package/src/__tests__/schema.test.ts +1273 -0
- package/src/__tests__/security-hardening.test.ts +312 -0
- package/src/__tests__/server.unit.test.ts +333 -0
- package/src/__tests__/service-key-db-proxy.test.ts +650 -0
- package/src/__tests__/service-key-provider-bypass.test.ts +138 -0
- package/src/__tests__/service-key.test.ts +757 -0
- package/src/__tests__/smoke-skip-report.test.ts +72 -0
- package/src/__tests__/sms-provider.test.ts +39 -0
- package/src/__tests__/sql-route.test.ts +218 -0
- package/src/__tests__/storage-hook-context.test.ts +115 -0
- package/src/__tests__/totp.test.ts +200 -0
- package/src/__tests__/uuid.test.ts +144 -0
- package/src/__tests__/validation.test.ts +773 -0
- package/src/__tests__/websocket-pending.test.ts +163 -0
- package/src/_functions-registry.ts +51 -0
- package/src/bench-entry.ts +9 -0
- package/src/cloudflare-test.d.ts +1 -0
- package/src/durable-objects/auth-do.ts +49 -0
- package/src/durable-objects/database-do.ts +2240 -0
- package/src/durable-objects/database-live-do.ts +949 -0
- package/src/durable-objects/logs-do.ts +1200 -0
- package/src/durable-objects/room-runtime-base.ts +1604 -0
- package/src/durable-objects/rooms-do.ts +2191 -0
- package/src/generated-config.ts +6 -0
- package/src/index.ts +382 -0
- package/src/lib/admin-assets.ts +54 -0
- package/src/lib/admin-db-target.ts +301 -0
- package/src/lib/admin-routing.ts +35 -0
- package/src/lib/admin-user-management.ts +464 -0
- package/src/lib/analytics-adapter.ts +103 -0
- package/src/lib/analytics-query.ts +579 -0
- package/src/lib/auth-d1-service.ts +1193 -0
- package/src/lib/auth-d1.ts +1056 -0
- package/src/lib/auth-db-adapter.ts +289 -0
- package/src/lib/auth-redirect.ts +116 -0
- package/src/lib/cidr.ts +115 -0
- package/src/lib/client-ip.ts +51 -0
- package/src/lib/cloudflare-realtime.ts +251 -0
- package/src/lib/control-db.ts +36 -0
- package/src/lib/cron.ts +163 -0
- package/src/lib/d1-handler.ts +1425 -0
- package/src/lib/d1-schema-init.ts +255 -0
- package/src/lib/d1-sql.ts +33 -0
- package/src/lib/database-live-config.ts +24 -0
- package/src/lib/database-live-emitter.ts +111 -0
- package/src/lib/db-sql.ts +66 -0
- package/src/lib/do-retry.ts +36 -0
- package/src/lib/do-router.ts +270 -0
- package/src/lib/do-sql.ts +73 -0
- package/src/lib/email-provider.ts +379 -0
- package/src/lib/email-templates.ts +285 -0
- package/src/lib/email-translations.ts +422 -0
- package/src/lib/errors.ts +151 -0
- package/src/lib/functions.ts +2091 -0
- package/src/lib/hono.ts +56 -0
- package/src/lib/internal-request.ts +56 -0
- package/src/lib/jwt.ts +354 -0
- package/src/lib/log-writer.ts +272 -0
- package/src/lib/namespace-dump.ts +125 -0
- package/src/lib/oauth-providers.ts +1225 -0
- package/src/lib/op-parser.ts +99 -0
- package/src/lib/openapi.ts +146 -0
- package/src/lib/pagination.ts +19 -0
- package/src/lib/password-policy.ts +102 -0
- package/src/lib/password.ts +145 -0
- package/src/lib/plugin-migrations.ts +612 -0
- package/src/lib/postgres-executor.ts +203 -0
- package/src/lib/postgres-handler.ts +1102 -0
- package/src/lib/postgres-schema-init.ts +341 -0
- package/src/lib/postgres-table-utils.ts +87 -0
- package/src/lib/public-user-profile.ts +187 -0
- package/src/lib/push-provider.ts +409 -0
- package/src/lib/push-token.ts +294 -0
- package/src/lib/query-engine.ts +768 -0
- package/src/lib/room-monitoring.ts +97 -0
- package/src/lib/room-runtime.ts +14 -0
- package/src/lib/route-parser.ts +434 -0
- package/src/lib/schema.ts +538 -0
- package/src/lib/schemas.ts +152 -0
- package/src/lib/service-key.ts +419 -0
- package/src/lib/sms-provider.ts +230 -0
- package/src/lib/startup-config.ts +99 -0
- package/src/lib/totp.ts +242 -0
- package/src/lib/uuid.ts +87 -0
- package/src/lib/validation.ts +205 -0
- package/src/lib/version.ts +2 -0
- package/src/lib/websocket-pending.ts +40 -0
- package/src/middleware/auth.ts +169 -0
- package/src/middleware/captcha-verify.ts +217 -0
- package/src/middleware/cors.ts +159 -0
- package/src/middleware/error-handler.ts +54 -0
- package/src/middleware/internal-guard.ts +26 -0
- package/src/middleware/logger.ts +126 -0
- package/src/middleware/rate-limit.ts +283 -0
- package/src/middleware/rules.ts +475 -0
- package/src/routes/admin-auth.ts +447 -0
- package/src/routes/admin.ts +3501 -0
- package/src/routes/analytics-api.ts +290 -0
- package/src/routes/auth.ts +4222 -0
- package/src/routes/backup.ts +1466 -0
- package/src/routes/config.ts +53 -0
- package/src/routes/d1.ts +109 -0
- package/src/routes/database-live.ts +281 -0
- package/src/routes/functions.ts +155 -0
- package/src/routes/health.ts +32 -0
- package/src/routes/kv.ts +167 -0
- package/src/routes/oauth.ts +1055 -0
- package/src/routes/push.ts +1465 -0
- package/src/routes/room.ts +639 -0
- package/src/routes/schema-endpoint.ts +76 -0
- package/src/routes/sql.ts +176 -0
- package/src/routes/storage.ts +1674 -0
- package/src/routes/tables.ts +699 -0
- package/src/routes/users.ts +21 -0
- package/src/routes/vectorize.ts +372 -0
- package/src/types.ts +99 -0
|
@@ -0,0 +1,379 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* EmailProvider — Adapter pattern for external email services
|
|
3
|
+
*
|
|
4
|
+
* Workers cannot use SMTP directly. Instead, we use HTTP REST API-based
|
|
5
|
+
* external email services via a common interface.
|
|
6
|
+
*
|
|
7
|
+
* Supported: Resend (default), SendGrid, Mailgun, AWS SES
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
// ─── Interface ───
|
|
11
|
+
|
|
12
|
+
export interface EmailSendOptions {
|
|
13
|
+
to: string;
|
|
14
|
+
subject: string;
|
|
15
|
+
html: string;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface EmailSendResult {
|
|
19
|
+
success: boolean;
|
|
20
|
+
messageId?: string;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface EmailProvider {
|
|
24
|
+
send(options: EmailSendOptions): Promise<EmailSendResult>;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
interface EmailProviderEnv {
|
|
28
|
+
EDGEBASE_EMAIL_API_URL?: string;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const encoder = new TextEncoder();
|
|
32
|
+
|
|
33
|
+
function toUint8Array(value: ArrayBuffer | Uint8Array): Uint8Array {
|
|
34
|
+
return value instanceof Uint8Array ? value : new Uint8Array(value);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function toArrayBuffer(value: Uint8Array): ArrayBuffer {
|
|
38
|
+
return value.buffer.slice(value.byteOffset, value.byteOffset + value.byteLength) as ArrayBuffer;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function toHex(value: ArrayBuffer | Uint8Array): string {
|
|
42
|
+
return Array.from(toUint8Array(value))
|
|
43
|
+
.map((byte) => byte.toString(16).padStart(2, '0'))
|
|
44
|
+
.join('');
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
async function sha256Hex(input: string): Promise<string> {
|
|
48
|
+
const digest = await crypto.subtle.digest('SHA-256', encoder.encode(input));
|
|
49
|
+
return toHex(digest);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
async function hmacSha256(
|
|
53
|
+
key: string | Uint8Array,
|
|
54
|
+
value: string,
|
|
55
|
+
): Promise<Uint8Array> {
|
|
56
|
+
const keyData = typeof key === 'string' ? encoder.encode(key) : key;
|
|
57
|
+
const cryptoKey = await crypto.subtle.importKey(
|
|
58
|
+
'raw',
|
|
59
|
+
toArrayBuffer(keyData),
|
|
60
|
+
{ name: 'HMAC', hash: 'SHA-256' },
|
|
61
|
+
false,
|
|
62
|
+
['sign'],
|
|
63
|
+
);
|
|
64
|
+
const signature = await crypto.subtle.sign('HMAC', cryptoKey, encoder.encode(value));
|
|
65
|
+
return new Uint8Array(signature);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
type SESCredentials = {
|
|
69
|
+
accessKeyId: string;
|
|
70
|
+
secretAccessKey: string;
|
|
71
|
+
sessionToken?: string;
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
function parseSesCredentials(apiKey: string): SESCredentials | null {
|
|
75
|
+
const [accessKeyId, secretAccessKey, ...rest] = apiKey.split(':');
|
|
76
|
+
if (!accessKeyId || !secretAccessKey) {
|
|
77
|
+
return null;
|
|
78
|
+
}
|
|
79
|
+
const sessionToken = rest.length > 0 ? rest.join(':') : undefined;
|
|
80
|
+
return { accessKeyId, secretAccessKey, sessionToken };
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// ─── Resend Provider (Recommended, 3,000/month free) ───
|
|
84
|
+
|
|
85
|
+
export class ResendProvider implements EmailProvider {
|
|
86
|
+
constructor(
|
|
87
|
+
private apiKey: string,
|
|
88
|
+
private from: string,
|
|
89
|
+
) {}
|
|
90
|
+
|
|
91
|
+
async send(options: EmailSendOptions): Promise<EmailSendResult> {
|
|
92
|
+
const resp = await fetch('https://api.resend.com/emails', {
|
|
93
|
+
method: 'POST',
|
|
94
|
+
headers: {
|
|
95
|
+
Authorization: `Bearer ${this.apiKey}`,
|
|
96
|
+
'Content-Type': 'application/json',
|
|
97
|
+
},
|
|
98
|
+
body: JSON.stringify({
|
|
99
|
+
from: this.from,
|
|
100
|
+
to: options.to,
|
|
101
|
+
subject: options.subject,
|
|
102
|
+
html: options.html,
|
|
103
|
+
}),
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
if (!resp.ok) {
|
|
107
|
+
const text = await resp.text();
|
|
108
|
+
console.error('[EmailProvider:Resend] Failed:', resp.status, text);
|
|
109
|
+
return { success: false };
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const data = (await resp.json()) as { id?: string };
|
|
113
|
+
return { success: true, messageId: data.id };
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export class MockEmailProvider implements EmailProvider {
|
|
118
|
+
private endpoint: string;
|
|
119
|
+
|
|
120
|
+
constructor(
|
|
121
|
+
endpoint: string,
|
|
122
|
+
private from: string,
|
|
123
|
+
) {
|
|
124
|
+
this.endpoint = endpoint.replace(/\/$/, '');
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
async send(options: EmailSendOptions): Promise<EmailSendResult> {
|
|
128
|
+
const resp = await fetch(`${this.endpoint}/send`, {
|
|
129
|
+
method: 'POST',
|
|
130
|
+
headers: {
|
|
131
|
+
'Content-Type': 'application/json',
|
|
132
|
+
},
|
|
133
|
+
body: JSON.stringify({
|
|
134
|
+
from: this.from,
|
|
135
|
+
to: options.to,
|
|
136
|
+
subject: options.subject,
|
|
137
|
+
html: options.html,
|
|
138
|
+
}),
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
if (!resp.ok) {
|
|
142
|
+
const text = await resp.text();
|
|
143
|
+
console.error('[EmailProvider:Mock] Failed:', resp.status, text);
|
|
144
|
+
return { success: false };
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
const data = await resp.json().catch(() => ({})) as { id?: string; messageId?: string };
|
|
148
|
+
return { success: true, messageId: data.messageId ?? data.id };
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// ─── SendGrid Provider (100/day free) ───
|
|
153
|
+
|
|
154
|
+
export class SendGridProvider implements EmailProvider {
|
|
155
|
+
constructor(
|
|
156
|
+
private apiKey: string,
|
|
157
|
+
private from: string,
|
|
158
|
+
) {}
|
|
159
|
+
|
|
160
|
+
async send(options: EmailSendOptions): Promise<EmailSendResult> {
|
|
161
|
+
const resp = await fetch('https://api.sendgrid.com/v3/mail/send', {
|
|
162
|
+
method: 'POST',
|
|
163
|
+
headers: {
|
|
164
|
+
Authorization: `Bearer ${this.apiKey}`,
|
|
165
|
+
'Content-Type': 'application/json',
|
|
166
|
+
},
|
|
167
|
+
body: JSON.stringify({
|
|
168
|
+
personalizations: [{ to: [{ email: options.to }] }],
|
|
169
|
+
from: { email: this.from },
|
|
170
|
+
subject: options.subject,
|
|
171
|
+
content: [{ type: 'text/html', value: options.html }],
|
|
172
|
+
}),
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
if (!resp.ok) {
|
|
176
|
+
const text = await resp.text();
|
|
177
|
+
console.error('[EmailProvider:SendGrid] Failed:', resp.status, text);
|
|
178
|
+
return { success: false };
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// SendGrid returns 202 with x-message-id header
|
|
182
|
+
return {
|
|
183
|
+
success: true,
|
|
184
|
+
messageId: resp.headers.get('x-message-id') ?? undefined,
|
|
185
|
+
};
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// ─── Mailgun Provider (1,000/month free for 3 months) ───
|
|
190
|
+
|
|
191
|
+
export class MailgunProvider implements EmailProvider {
|
|
192
|
+
private domain: string;
|
|
193
|
+
|
|
194
|
+
constructor(
|
|
195
|
+
private apiKey: string,
|
|
196
|
+
private from: string,
|
|
197
|
+
domain?: string,
|
|
198
|
+
) {
|
|
199
|
+
// Extract domain from "from" address if not explicitly provided
|
|
200
|
+
this.domain = domain ?? from.split('@')[1] ?? 'example.com';
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
async send(options: EmailSendOptions): Promise<EmailSendResult> {
|
|
204
|
+
const formData = new FormData();
|
|
205
|
+
formData.append('from', this.from);
|
|
206
|
+
formData.append('to', options.to);
|
|
207
|
+
formData.append('subject', options.subject);
|
|
208
|
+
formData.append('html', options.html);
|
|
209
|
+
|
|
210
|
+
const resp = await fetch(
|
|
211
|
+
`https://api.mailgun.net/v3/${this.domain}/messages`,
|
|
212
|
+
{
|
|
213
|
+
method: 'POST',
|
|
214
|
+
headers: {
|
|
215
|
+
Authorization: `Basic ${btoa(`api:${this.apiKey}`)}`,
|
|
216
|
+
},
|
|
217
|
+
body: formData,
|
|
218
|
+
},
|
|
219
|
+
);
|
|
220
|
+
|
|
221
|
+
if (!resp.ok) {
|
|
222
|
+
const text = await resp.text();
|
|
223
|
+
console.error('[EmailProvider:Mailgun] Failed:', resp.status, text);
|
|
224
|
+
return { success: false };
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
const data = (await resp.json()) as { id?: string };
|
|
228
|
+
return { success: true, messageId: data.id };
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
// ─── AWS SES Provider ($0.10/1,000 emails) ───
|
|
233
|
+
|
|
234
|
+
export class SESProvider implements EmailProvider {
|
|
235
|
+
constructor(
|
|
236
|
+
private apiKey: string,
|
|
237
|
+
private from: string,
|
|
238
|
+
private region: string = 'us-east-1',
|
|
239
|
+
) {}
|
|
240
|
+
|
|
241
|
+
async send(options: EmailSendOptions): Promise<EmailSendResult> {
|
|
242
|
+
const credentials = parseSesCredentials(this.apiKey);
|
|
243
|
+
if (!credentials) {
|
|
244
|
+
console.error(
|
|
245
|
+
'[EmailProvider:SES] Invalid apiKey format. Expected accessKeyId:secretAccessKey[:sessionToken].',
|
|
246
|
+
);
|
|
247
|
+
return { success: false };
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
const host = `email.${this.region}.amazonaws.com`;
|
|
251
|
+
const path = '/v2/email/outbound-emails';
|
|
252
|
+
const endpoint = `https://${host}${path}`;
|
|
253
|
+
const payload = JSON.stringify({
|
|
254
|
+
FromEmailAddress: this.from,
|
|
255
|
+
Destination: { ToAddresses: [options.to] },
|
|
256
|
+
Content: {
|
|
257
|
+
Simple: {
|
|
258
|
+
Subject: { Data: options.subject, Charset: 'UTF-8' },
|
|
259
|
+
Body: {
|
|
260
|
+
Html: { Data: options.html, Charset: 'UTF-8' },
|
|
261
|
+
},
|
|
262
|
+
},
|
|
263
|
+
},
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
const now = new Date();
|
|
267
|
+
const amzDate = now.toISOString().replace(/[:-]|\.\d{3}/g, '');
|
|
268
|
+
const dateStamp = amzDate.slice(0, 8);
|
|
269
|
+
const payloadHash = await sha256Hex(payload);
|
|
270
|
+
const credentialScope = `${dateStamp}/${this.region}/ses/aws4_request`;
|
|
271
|
+
const canonicalHeadersEntries = [
|
|
272
|
+
['host', host],
|
|
273
|
+
['x-amz-content-sha256', payloadHash],
|
|
274
|
+
['x-amz-date', amzDate],
|
|
275
|
+
...(credentials.sessionToken
|
|
276
|
+
? [['x-amz-security-token', credentials.sessionToken] as const]
|
|
277
|
+
: []),
|
|
278
|
+
];
|
|
279
|
+
const canonicalHeaders = canonicalHeadersEntries
|
|
280
|
+
.map(([name, value]) => `${name}:${value}\n`)
|
|
281
|
+
.join('');
|
|
282
|
+
const signedHeaders = canonicalHeadersEntries.map(([name]) => name).join(';');
|
|
283
|
+
const canonicalRequest = [
|
|
284
|
+
'POST',
|
|
285
|
+
path,
|
|
286
|
+
'',
|
|
287
|
+
canonicalHeaders,
|
|
288
|
+
signedHeaders,
|
|
289
|
+
payloadHash,
|
|
290
|
+
].join('\n');
|
|
291
|
+
const stringToSign = [
|
|
292
|
+
'AWS4-HMAC-SHA256',
|
|
293
|
+
amzDate,
|
|
294
|
+
credentialScope,
|
|
295
|
+
await sha256Hex(canonicalRequest),
|
|
296
|
+
].join('\n');
|
|
297
|
+
|
|
298
|
+
const dateKey = await hmacSha256(`AWS4${credentials.secretAccessKey}`, dateStamp);
|
|
299
|
+
const regionKey = await hmacSha256(dateKey, this.region);
|
|
300
|
+
const serviceKey = await hmacSha256(regionKey, 'ses');
|
|
301
|
+
const signingKey = await hmacSha256(serviceKey, 'aws4_request');
|
|
302
|
+
const signature = toHex(await hmacSha256(signingKey, stringToSign));
|
|
303
|
+
const headers: Record<string, string> = {
|
|
304
|
+
'Content-Type': 'application/json',
|
|
305
|
+
Host: host,
|
|
306
|
+
'X-Amz-Date': amzDate,
|
|
307
|
+
'X-Amz-Content-Sha256': payloadHash,
|
|
308
|
+
Authorization:
|
|
309
|
+
`AWS4-HMAC-SHA256 Credential=${credentials.accessKeyId}/${credentialScope}, `
|
|
310
|
+
+ `SignedHeaders=${signedHeaders}, Signature=${signature}`,
|
|
311
|
+
};
|
|
312
|
+
|
|
313
|
+
if (credentials.sessionToken) {
|
|
314
|
+
headers['X-Amz-Security-Token'] = credentials.sessionToken;
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
const resp = await fetch(endpoint, {
|
|
318
|
+
method: 'POST',
|
|
319
|
+
headers,
|
|
320
|
+
body: payload,
|
|
321
|
+
});
|
|
322
|
+
|
|
323
|
+
if (!resp.ok) {
|
|
324
|
+
const text = await resp.text();
|
|
325
|
+
console.error('[EmailProvider:SES] Failed:', resp.status, text);
|
|
326
|
+
return { success: false };
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
const data = (await resp.json()) as { MessageId?: string };
|
|
330
|
+
return { success: true, messageId: data.MessageId };
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
// ─── Factory ───
|
|
335
|
+
|
|
336
|
+
export interface EmailConfig {
|
|
337
|
+
provider: 'resend' | 'sendgrid' | 'mailgun' | 'ses';
|
|
338
|
+
apiKey: string; // SES uses accessKeyId:secretAccessKey[:sessionToken]
|
|
339
|
+
from: string;
|
|
340
|
+
domain?: string; // Mailgun domain
|
|
341
|
+
region?: string; // SES region
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
/**
|
|
345
|
+
* Create an EmailProvider instance from config.
|
|
346
|
+
* Returns null if config is missing (email features disabled).
|
|
347
|
+
*/
|
|
348
|
+
export function createEmailProvider(
|
|
349
|
+
config?: EmailConfig,
|
|
350
|
+
env?: EmailProviderEnv,
|
|
351
|
+
): EmailProvider | null {
|
|
352
|
+
const mockEndpoint = env?.EDGEBASE_EMAIL_API_URL?.trim()?.replace(/\/$/, '');
|
|
353
|
+
if (mockEndpoint) {
|
|
354
|
+
return new MockEmailProvider(mockEndpoint, config?.from ?? 'noreply@example.com');
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
if (!config) {
|
|
358
|
+
return null;
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
if (!config.apiKey || !config.from) {
|
|
362
|
+
console.warn('[EmailProvider] apiKey and from are required. Email features disabled.');
|
|
363
|
+
return null;
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
switch (config.provider) {
|
|
367
|
+
case 'resend':
|
|
368
|
+
return new ResendProvider(config.apiKey, config.from);
|
|
369
|
+
case 'sendgrid':
|
|
370
|
+
return new SendGridProvider(config.apiKey, config.from);
|
|
371
|
+
case 'mailgun':
|
|
372
|
+
return new MailgunProvider(config.apiKey, config.from, config.domain);
|
|
373
|
+
case 'ses':
|
|
374
|
+
return new SESProvider(config.apiKey, config.from, config.region);
|
|
375
|
+
default:
|
|
376
|
+
console.warn(`[EmailProvider] Unknown provider: ${config.provider}. Email features disabled.`);
|
|
377
|
+
return null;
|
|
378
|
+
}
|
|
379
|
+
}
|
|
@@ -0,0 +1,285 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Email HTML templates for authentication flows.
|
|
3
|
+
*
|
|
4
|
+
* All 5 render functions accept an optional `locale` parameter (3rd argument)
|
|
5
|
+
* for i18n support. When locale is provided (and no custom template override),
|
|
6
|
+
* the built-in translated strings from email-translations.ts are used.
|
|
7
|
+
*
|
|
8
|
+
* Priority: custom template → built-in translation for locale → English default
|
|
9
|
+
*
|
|
10
|
+
* Users can override any template by providing custom HTML in `email.templates`
|
|
11
|
+
* config with {{variable}} placeholders for dynamic values.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { getStrings } from './email-translations.js';
|
|
15
|
+
|
|
16
|
+
// ─── Shared Styles ───
|
|
17
|
+
|
|
18
|
+
const SHARED_STYLES = `
|
|
19
|
+
body { margin: 0; padding: 0; background: #f4f4f7; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif; }
|
|
20
|
+
.container { max-width: 560px; margin: 40px auto; background: #ffffff; border-radius: 8px; box-shadow: 0 2px 8px rgba(0,0,0,0.06); overflow: hidden; }
|
|
21
|
+
.header { background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); padding: 32px 40px; }
|
|
22
|
+
.header h1 { margin: 0; color: #ffffff; font-size: 24px; font-weight: 600; }
|
|
23
|
+
.body { padding: 40px; color: #333; line-height: 1.6; }
|
|
24
|
+
.body p { margin: 0 0 16px; }
|
|
25
|
+
.btn { display: inline-block; background: #667eea; color: #ffffff !important; text-decoration: none; padding: 14px 32px; border-radius: 6px; font-weight: 600; font-size: 16px; margin: 8px 0 24px; }
|
|
26
|
+
.code { background: #f0f0f5; border: 1px solid #e0e0e8; border-radius: 6px; padding: 16px; text-align: center; font-size: 28px; font-weight: 700; letter-spacing: 4px; color: #333; margin: 8px 0 24px; }
|
|
27
|
+
.footer { padding: 24px 40px; background: #f9f9fb; color: #888; font-size: 12px; text-align: center; border-top: 1px solid #eee; }
|
|
28
|
+
.muted { color: #888; font-size: 13px; }
|
|
29
|
+
`;
|
|
30
|
+
|
|
31
|
+
// ─── Custom Template Renderer ───
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Replace {{varName}} placeholders with HTML-escaped values.
|
|
35
|
+
* Unknown placeholders are left as-is.
|
|
36
|
+
*/
|
|
37
|
+
function renderCustomTemplate(
|
|
38
|
+
template: string,
|
|
39
|
+
vars: Record<string, string | number>,
|
|
40
|
+
): string {
|
|
41
|
+
return template.replace(/\{\{(\w+)\}\}/g, (match, key) => {
|
|
42
|
+
const val = vars[key as string];
|
|
43
|
+
return val !== undefined ? escapeHtml(String(val)) : match;
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Replace {{varName}} placeholders in a translation string.
|
|
49
|
+
* Same as renderCustomTemplate but returns raw (not HTML-escaped) for use
|
|
50
|
+
* inside already-safe template contexts. Values ARE escaped.
|
|
51
|
+
*/
|
|
52
|
+
function renderTranslationString(
|
|
53
|
+
str: string,
|
|
54
|
+
vars: Record<string, string | number>,
|
|
55
|
+
): string {
|
|
56
|
+
return str.replace(/\{\{(\w+)\}\}/g, (match, key) => {
|
|
57
|
+
const val = vars[key as string];
|
|
58
|
+
return val !== undefined ? escapeHtml(String(val)) : match;
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// ─── Verify Email ───
|
|
63
|
+
|
|
64
|
+
export interface VerifyEmailVars {
|
|
65
|
+
appName: string;
|
|
66
|
+
verifyUrl: string;
|
|
67
|
+
token: string;
|
|
68
|
+
expiresInHours: number;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function renderVerifyEmail(vars: VerifyEmailVars, customTemplate?: string, locale?: string): string {
|
|
72
|
+
if (customTemplate) {
|
|
73
|
+
return renderCustomTemplate(customTemplate, {
|
|
74
|
+
appName: vars.appName,
|
|
75
|
+
verifyUrl: vars.verifyUrl,
|
|
76
|
+
token: vars.token,
|
|
77
|
+
expiresInHours: vars.expiresInHours,
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const lang = locale || 'en';
|
|
82
|
+
const s = getStrings(lang, 'verification');
|
|
83
|
+
|
|
84
|
+
return `<!DOCTYPE html>
|
|
85
|
+
<html lang="${escapeHtml(lang)}">
|
|
86
|
+
<head><meta charset="utf-8"><meta name="viewport" content="width=device-width"><style>${SHARED_STYLES}</style></head>
|
|
87
|
+
<body>
|
|
88
|
+
<div class="container">
|
|
89
|
+
<div class="header"><h1>${escapeHtml(vars.appName)}</h1></div>
|
|
90
|
+
<div class="body">
|
|
91
|
+
<p>${escapeHtml(s.heading)}</p>
|
|
92
|
+
<p>${escapeHtml(s.subheading ?? '')}</p>
|
|
93
|
+
<p style="text-align:center"><a class="btn" href="${escapeHtml(vars.verifyUrl)}">${escapeHtml(s.cta ?? 'Verify Email')}</a></p>
|
|
94
|
+
<p>${escapeHtml(s.tokenLabel ?? '')}</p>
|
|
95
|
+
<div class="code">${escapeHtml(vars.token)}</div>
|
|
96
|
+
<p class="muted">${renderTranslationString(s.expires, { expiresInHours: vars.expiresInHours })}</p>
|
|
97
|
+
<p class="muted">${escapeHtml(s.ignore)}</p>
|
|
98
|
+
</div>
|
|
99
|
+
<div class="footer">© ${escapeHtml(vars.appName)}</div>
|
|
100
|
+
</div>
|
|
101
|
+
</body>
|
|
102
|
+
</html>`;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// ─── Password Reset ───
|
|
106
|
+
|
|
107
|
+
export interface PasswordResetVars {
|
|
108
|
+
appName: string;
|
|
109
|
+
resetUrl: string;
|
|
110
|
+
token: string;
|
|
111
|
+
expiresInMinutes: number;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export function renderPasswordReset(vars: PasswordResetVars, customTemplate?: string, locale?: string): string {
|
|
115
|
+
if (customTemplate) {
|
|
116
|
+
return renderCustomTemplate(customTemplate, {
|
|
117
|
+
appName: vars.appName,
|
|
118
|
+
resetUrl: vars.resetUrl,
|
|
119
|
+
token: vars.token,
|
|
120
|
+
expiresInMinutes: vars.expiresInMinutes,
|
|
121
|
+
});
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
const lang = locale || 'en';
|
|
125
|
+
const s = getStrings(lang, 'passwordReset');
|
|
126
|
+
|
|
127
|
+
return `<!DOCTYPE html>
|
|
128
|
+
<html lang="${escapeHtml(lang)}">
|
|
129
|
+
<head><meta charset="utf-8"><meta name="viewport" content="width=device-width"><style>${SHARED_STYLES}</style></head>
|
|
130
|
+
<body>
|
|
131
|
+
<div class="container">
|
|
132
|
+
<div class="header"><h1>${escapeHtml(vars.appName)}</h1></div>
|
|
133
|
+
<div class="body">
|
|
134
|
+
<p>${escapeHtml(s.heading)}</p>
|
|
135
|
+
<p>${escapeHtml(s.subheading ?? '')}</p>
|
|
136
|
+
<p style="text-align:center"><a class="btn" href="${escapeHtml(vars.resetUrl)}">${escapeHtml(s.cta ?? 'Reset Password')}</a></p>
|
|
137
|
+
<p>${escapeHtml(s.tokenLabel ?? '')}</p>
|
|
138
|
+
<div class="code">${escapeHtml(vars.token)}</div>
|
|
139
|
+
<p class="muted">${renderTranslationString(s.expires, { expiresInMinutes: vars.expiresInMinutes })}</p>
|
|
140
|
+
<p class="muted">${escapeHtml(s.ignore)}</p>
|
|
141
|
+
</div>
|
|
142
|
+
<div class="footer">© ${escapeHtml(vars.appName)}</div>
|
|
143
|
+
</div>
|
|
144
|
+
</body>
|
|
145
|
+
</html>`;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// ─── Magic Link ───
|
|
149
|
+
|
|
150
|
+
export interface MagicLinkVars {
|
|
151
|
+
appName: string;
|
|
152
|
+
magicLinkUrl: string;
|
|
153
|
+
expiresInMinutes: number;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
export function renderMagicLink(vars: MagicLinkVars, customTemplate?: string, locale?: string): string {
|
|
157
|
+
if (customTemplate) {
|
|
158
|
+
return renderCustomTemplate(customTemplate, {
|
|
159
|
+
appName: vars.appName,
|
|
160
|
+
magicLinkUrl: vars.magicLinkUrl,
|
|
161
|
+
expiresInMinutes: vars.expiresInMinutes,
|
|
162
|
+
});
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
const lang = locale || 'en';
|
|
166
|
+
const s = getStrings(lang, 'magicLink');
|
|
167
|
+
|
|
168
|
+
return `<!DOCTYPE html>
|
|
169
|
+
<html lang="${escapeHtml(lang)}">
|
|
170
|
+
<head><meta charset="utf-8"><meta name="viewport" content="width=device-width"><style>${SHARED_STYLES}</style></head>
|
|
171
|
+
<body>
|
|
172
|
+
<div class="container">
|
|
173
|
+
<div class="header"><h1>${escapeHtml(vars.appName)}</h1></div>
|
|
174
|
+
<div class="body">
|
|
175
|
+
<p>${escapeHtml(s.heading)}</p>
|
|
176
|
+
<p>${escapeHtml(s.subheading ?? '')}</p>
|
|
177
|
+
<p style="text-align:center"><a class="btn" href="${escapeHtml(vars.magicLinkUrl)}">${escapeHtml(s.cta ?? 'Sign In')}</a></p>
|
|
178
|
+
<p class="muted">${renderTranslationString(s.expires, { expiresInMinutes: vars.expiresInMinutes })}</p>
|
|
179
|
+
<p class="muted">${escapeHtml(s.ignore)}</p>
|
|
180
|
+
</div>
|
|
181
|
+
<div class="footer">© ${escapeHtml(vars.appName)}</div>
|
|
182
|
+
</div>
|
|
183
|
+
</body>
|
|
184
|
+
</html>`;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// ─── Email OTP ───
|
|
188
|
+
|
|
189
|
+
export interface EmailOtpVars {
|
|
190
|
+
appName: string;
|
|
191
|
+
code: string;
|
|
192
|
+
expiresInMinutes: number;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
export function renderEmailOtp(vars: EmailOtpVars, customTemplate?: string, locale?: string): string {
|
|
196
|
+
if (customTemplate) {
|
|
197
|
+
return renderCustomTemplate(customTemplate, {
|
|
198
|
+
appName: vars.appName,
|
|
199
|
+
code: vars.code,
|
|
200
|
+
expiresInMinutes: vars.expiresInMinutes,
|
|
201
|
+
});
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
const lang = locale || 'en';
|
|
205
|
+
const s = getStrings(lang, 'emailOtp');
|
|
206
|
+
|
|
207
|
+
return `<!DOCTYPE html>
|
|
208
|
+
<html lang="${escapeHtml(lang)}">
|
|
209
|
+
<head><meta charset="utf-8"><meta name="viewport" content="width=device-width"><style>${SHARED_STYLES}</style></head>
|
|
210
|
+
<body>
|
|
211
|
+
<div class="container">
|
|
212
|
+
<div class="header"><h1>${escapeHtml(vars.appName)}</h1></div>
|
|
213
|
+
<div class="body">
|
|
214
|
+
<p>${escapeHtml(s.heading)}</p>
|
|
215
|
+
<p>${escapeHtml(s.instruction ?? '')}</p>
|
|
216
|
+
<div class="code">${escapeHtml(vars.code)}</div>
|
|
217
|
+
<p class="muted">${renderTranslationString(s.expires, { expiresInMinutes: vars.expiresInMinutes })}</p>
|
|
218
|
+
<p class="muted">${escapeHtml(s.ignore)}</p>
|
|
219
|
+
</div>
|
|
220
|
+
<div class="footer">© ${escapeHtml(vars.appName)}</div>
|
|
221
|
+
</div>
|
|
222
|
+
</body>
|
|
223
|
+
</html>`;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
// ─── Email Change Verification ───
|
|
227
|
+
|
|
228
|
+
export interface EmailChangeVars {
|
|
229
|
+
appName: string;
|
|
230
|
+
verifyUrl: string;
|
|
231
|
+
token: string;
|
|
232
|
+
newEmail: string;
|
|
233
|
+
expiresInHours: number;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
export function renderEmailChange(vars: EmailChangeVars, customTemplate?: string, locale?: string): string {
|
|
237
|
+
if (customTemplate) {
|
|
238
|
+
return renderCustomTemplate(customTemplate, {
|
|
239
|
+
appName: vars.appName,
|
|
240
|
+
verifyUrl: vars.verifyUrl,
|
|
241
|
+
token: vars.token,
|
|
242
|
+
newEmail: vars.newEmail,
|
|
243
|
+
expiresInHours: vars.expiresInHours,
|
|
244
|
+
});
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
const lang = locale || 'en';
|
|
248
|
+
const s = getStrings(lang, 'emailChange');
|
|
249
|
+
|
|
250
|
+
// instruction contains <strong>{{newEmail}}</strong> — render with variable substitution (not full escape)
|
|
251
|
+
const instructionHtml = s.instruction
|
|
252
|
+
? renderTranslationString(s.instruction, { newEmail: vars.newEmail })
|
|
253
|
+
: `To change your email to <strong>${escapeHtml(vars.newEmail)}</strong>, click the button below:`;
|
|
254
|
+
|
|
255
|
+
return `<!DOCTYPE html>
|
|
256
|
+
<html lang="${escapeHtml(lang)}">
|
|
257
|
+
<head><meta charset="utf-8"><meta name="viewport" content="width=device-width"><style>${SHARED_STYLES}</style></head>
|
|
258
|
+
<body>
|
|
259
|
+
<div class="container">
|
|
260
|
+
<div class="header"><h1>${escapeHtml(vars.appName)}</h1></div>
|
|
261
|
+
<div class="body">
|
|
262
|
+
<p>${escapeHtml(s.heading)}</p>
|
|
263
|
+
<p>${instructionHtml}</p>
|
|
264
|
+
<p style="text-align:center"><a class="btn" href="${escapeHtml(vars.verifyUrl)}">${escapeHtml(s.cta ?? 'Confirm Email Change')}</a></p>
|
|
265
|
+
<p>${escapeHtml(s.tokenLabel ?? '')}</p>
|
|
266
|
+
<div class="code">${escapeHtml(vars.token)}</div>
|
|
267
|
+
<p class="muted">${renderTranslationString(s.expires, { expiresInHours: vars.expiresInHours })}</p>
|
|
268
|
+
<p class="muted">${escapeHtml(s.ignore)}</p>
|
|
269
|
+
</div>
|
|
270
|
+
<div class="footer">© ${escapeHtml(vars.appName)}</div>
|
|
271
|
+
</div>
|
|
272
|
+
</body>
|
|
273
|
+
</html>`;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
// ─── Helpers ───
|
|
277
|
+
|
|
278
|
+
function escapeHtml(str: string): string {
|
|
279
|
+
return str
|
|
280
|
+
.replace(/&/g, '&')
|
|
281
|
+
.replace(/</g, '<')
|
|
282
|
+
.replace(/>/g, '>')
|
|
283
|
+
.replace(/"/g, '"')
|
|
284
|
+
.replace(/'/g, ''');
|
|
285
|
+
}
|