@oondemand/oon-core-back 0.4.15 → 0.4.16
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/package.json +3 -1
- package/src/api/core.routes.js +1 -0
- package/src/boot/createApp.js +2 -0
- package/src/boot/loadCentral.js +14 -1
- package/src/boot/startServer.js +4 -0
- package/src/capabilities/index.js +3 -0
- package/src/capabilities/transactionalEmail/bootstrap.js +37 -0
- package/src/capabilities/transactionalEmail/errors.js +13 -0
- package/src/capabilities/transactionalEmail/index.js +11 -0
- package/src/capabilities/transactionalEmail/models.js +21 -0
- package/src/capabilities/transactionalEmail/providers.js +21 -0
- package/src/capabilities/transactionalEmail/routes.js +27 -0
- package/src/capabilities/transactionalEmail/scope.js +48 -0
- package/src/capabilities/transactionalEmail/secretStore.js +45 -0
- package/src/capabilities/transactionalEmail/service.js +66 -0
- package/src/capabilities/transactionalEmail/templates.js +53 -0
- package/src/capabilities/transactionalEmail/worker.js +34 -0
- package/src/delivery/buildImage.js +17 -0
- package/src/index.js +1 -0
- package/src/manifest/appManifest.js +55 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@oondemand/oon-core-back",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.16",
|
|
4
4
|
"repository": {
|
|
5
5
|
"type": "git",
|
|
6
6
|
"url": "git+https://github.com/oondemand/oon-platform.git",
|
|
@@ -40,11 +40,13 @@
|
|
|
40
40
|
".": "./src/index.js"
|
|
41
41
|
},
|
|
42
42
|
"dependencies": {
|
|
43
|
+
"ajv": "^8.17.1",
|
|
43
44
|
"axios": "^1.7.7",
|
|
44
45
|
"cors": "^2.8.5",
|
|
45
46
|
"dotenv": "^16.4.5",
|
|
46
47
|
"express": "^4.21.2",
|
|
47
48
|
"helmet": "^8.0.0",
|
|
49
|
+
"handlebars": "^4.7.8",
|
|
48
50
|
"mongoose": "^8.5.4",
|
|
49
51
|
"morgan": "^1.10.0"
|
|
50
52
|
},
|
package/src/api/core.routes.js
CHANGED
package/src/boot/createApp.js
CHANGED
|
@@ -17,6 +17,7 @@ const statusRoutes = require("../api/status.routes");
|
|
|
17
17
|
const authRoutes = require("../api/auth.routes");
|
|
18
18
|
const activationRoutes = require("../api/activation.routes");
|
|
19
19
|
const activationGuard = require("../core/middlewares/activationGuard");
|
|
20
|
+
const transactionalEmail = require("../capabilities/transactionalEmail");
|
|
20
21
|
|
|
21
22
|
function createApp() {
|
|
22
23
|
const app = express();
|
|
@@ -34,6 +35,7 @@ function createApp() {
|
|
|
34
35
|
app.use("/auth", authRoutes);
|
|
35
36
|
app.use("/core", accessRoutes);
|
|
36
37
|
app.use("/core", coreRoutes);
|
|
38
|
+
if (transactionalEmail.enabled()) app.use("/core/transactional-email", transactionalEmail.buildRouter());
|
|
37
39
|
|
|
38
40
|
if (integrationsEnabled(ctx.config)) {
|
|
39
41
|
const integrationRouter = buildIntegrationRouter();
|
package/src/boot/loadCentral.js
CHANGED
|
@@ -39,6 +39,7 @@ const APP_CONFIG_KEYS = new Set([
|
|
|
39
39
|
"activation",
|
|
40
40
|
"modules",
|
|
41
41
|
"capabilities",
|
|
42
|
+
"capabilitySettings",
|
|
42
43
|
]);
|
|
43
44
|
|
|
44
45
|
function assertConfigBoundary(config, appManifest) {
|
|
@@ -58,6 +59,16 @@ function assertConfigBoundary(config, appManifest) {
|
|
|
58
59
|
message: "autenticação de Centrais member/portal é fornecida pelo OonCore.",
|
|
59
60
|
});
|
|
60
61
|
}
|
|
62
|
+
if (Object.prototype.hasOwnProperty.call(config, "activationHooks")) {
|
|
63
|
+
if (appManifest.appKind !== "root-central" || !config.activationHooks || typeof config.activationHooks !== "object") {
|
|
64
|
+
issues.push({ path: "central.config.js.activationHooks", message: "é permitido somente para a root-central." });
|
|
65
|
+
} else {
|
|
66
|
+
const allowedHooks = new Set(["hasActiveRootApp", "rootBootstrap"]);
|
|
67
|
+
for (const [key, value] of Object.entries(config.activationHooks)) {
|
|
68
|
+
if (!allowedHooks.has(key) || typeof value !== "function") issues.push({ path: `central.config.js.activationHooks.${key}`, message: "hook de runtime não suportado." });
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
}
|
|
61
72
|
if (issues.length) throw new AppManifestError("central.config.js viola a fronteira declarativa.", issues);
|
|
62
73
|
}
|
|
63
74
|
|
|
@@ -73,7 +84,9 @@ function loadCentral(cwd = process.cwd()) {
|
|
|
73
84
|
const exported = require(configPath);
|
|
74
85
|
const config = typeof exported === "function" ? exported() : exported;
|
|
75
86
|
assertConfigBoundary(config, appManifest);
|
|
76
|
-
|
|
87
|
+
const { activationHooks, ...runtimeConfig } = config || {};
|
|
88
|
+
ctx.setConfig(runtimeConfig);
|
|
89
|
+
if (activationHooks) ctx.setConfig({ activation: { ...ctx.config.activation, ...activationHooks } });
|
|
77
90
|
}
|
|
78
91
|
|
|
79
92
|
const domainManifestPath = path.join(cwd, DOMAIN_MANIFEST_FILENAME);
|
package/src/boot/startServer.js
CHANGED
|
@@ -2,6 +2,7 @@ const ctx = require("../core/context");
|
|
|
2
2
|
const { connectDB } = require("./database");
|
|
3
3
|
const { createApp } = require("./createApp");
|
|
4
4
|
const { loadCentral } = require("./loadCentral");
|
|
5
|
+
const transactionalEmail = require("../capabilities/transactionalEmail");
|
|
5
6
|
|
|
6
7
|
/**
|
|
7
8
|
* Sobe a aplicação completa: carrega a Central do consumidor, conecta no
|
|
@@ -13,6 +14,7 @@ const { loadCentral } = require("./loadCentral");
|
|
|
13
14
|
async function start({ cwd = process.cwd(), listen = true } = {}) {
|
|
14
15
|
loadCentral(cwd);
|
|
15
16
|
await connectDB();
|
|
17
|
+
await transactionalEmail.initialize();
|
|
16
18
|
const app = createApp();
|
|
17
19
|
|
|
18
20
|
if (!listen) return { app };
|
|
@@ -23,6 +25,8 @@ async function start({ cwd = process.cwd(), listen = true } = {}) {
|
|
|
23
25
|
console.log("****************************************************************");
|
|
24
26
|
console.log(`${ctx.config.serviceName} rodando na porta ${port} e conectado ao MongoDB`);
|
|
25
27
|
console.log("****************************************************************");
|
|
28
|
+
const stop = () => transactionalEmail.stopWorker();
|
|
29
|
+
server.once("close", stop);
|
|
26
30
|
resolve({ app, server });
|
|
27
31
|
});
|
|
28
32
|
});
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
|
|
3
3
|
const pdfRendering = require("./pdfRendering");
|
|
4
|
+
const transactionalEmail = require("./transactionalEmail");
|
|
4
5
|
|
|
5
6
|
const pdf = Object.freeze({ render: pdfRendering.render });
|
|
6
7
|
|
|
@@ -9,4 +10,6 @@ module.exports = Object.freeze({
|
|
|
9
10
|
pdfRendering: pdf,
|
|
10
11
|
configurePdfRendering: pdfRendering.configurePdfRendering,
|
|
11
12
|
PdfRenderingError: pdfRendering.PdfRenderingError,
|
|
13
|
+
transactionalEmail,
|
|
14
|
+
TransactionalEmailError: transactionalEmail.TransactionalEmailError,
|
|
12
15
|
});
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
const fs = require("node:fs");
|
|
4
|
+
const path = require("node:path");
|
|
5
|
+
const ctx = require("../../core/context");
|
|
6
|
+
const { getModels, syncIndexes } = require("./models");
|
|
7
|
+
const { capabilityConfig, appScopeKey, enabled } = require("./scope");
|
|
8
|
+
const { validateDefinition, contentHash } = require("./templates");
|
|
9
|
+
const { TransactionalEmailError } = require("./errors");
|
|
10
|
+
|
|
11
|
+
function definitionPath(relative) {
|
|
12
|
+
const root = path.dirname(ctx.config.appManifestPath || path.join(process.cwd(), "central.app.json"));
|
|
13
|
+
const resolved = path.resolve(root, relative);
|
|
14
|
+
if (resolved !== root && !resolved.startsWith(`${root}${path.sep}`)) throw new TransactionalEmailError("Template escapou da raiz do App.", { code: "EMAIL_TEMPLATE_PATH_INVALID" });
|
|
15
|
+
return resolved;
|
|
16
|
+
}
|
|
17
|
+
async function bootstrap() {
|
|
18
|
+
if (!enabled()) return { enabled: false, created: [], preserved: [] };
|
|
19
|
+
await syncIndexes();
|
|
20
|
+
const { Template } = getModels();
|
|
21
|
+
const scopeKey = appScopeKey();
|
|
22
|
+
const result = { enabled: true, scopeKey, created: [], preserved: [] };
|
|
23
|
+
for (const declared of capabilityConfig().templates || []) {
|
|
24
|
+
let input; try { input = JSON.parse(fs.readFileSync(definitionPath(declared.definition), "utf8")); } catch (cause) { throw new TransactionalEmailError(`Não foi possível carregar ${declared.definition}: ${cause.message}`, { code: "EMAIL_TEMPLATE_DEFINITION_UNAVAILABLE" }); }
|
|
25
|
+
const definition = validateDefinition({ ...input, code: declared.code, initialStatus: declared.initialStatus }, declared.code);
|
|
26
|
+
try {
|
|
27
|
+
const created = await Template.create({ scopeKey, code: definition.code, version: 1, name: definition.name, description: definition.description, subject: definition.subject, htmlContent: definition.htmlContent, textContent: definition.textContent, variablesSchema: definition.variablesSchema, locale: definition.locale, attachmentsPolicy: definition.attachmentsPolicy, status: definition.initialStatus, origin: "manifest", contentHash: contentHash(definition), activatedAt: definition.initialStatus === "active" ? new Date() : undefined });
|
|
28
|
+
result.created.push({ code: created.code, version: created.version });
|
|
29
|
+
} catch (error) {
|
|
30
|
+
if (error?.code !== 11000) throw error;
|
|
31
|
+
result.preserved.push({ code: declared.code });
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
console.log(JSON.stringify({ event: "transactionalEmail.bootstrap", ...result }));
|
|
35
|
+
return result;
|
|
36
|
+
}
|
|
37
|
+
module.exports = { bootstrap, definitionPath };
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
const GenericError = require("../../core/errors/GenericError");
|
|
4
|
+
|
|
5
|
+
class TransactionalEmailError extends GenericError {
|
|
6
|
+
constructor(message, { code = "TRANSACTIONAL_EMAIL_ERROR", statusCode = 422, retryable = false, details } = {}) {
|
|
7
|
+
super(message, { code, statusCode, details });
|
|
8
|
+
this.name = "TransactionalEmailError";
|
|
9
|
+
this.retryable = retryable;
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
module.exports = { TransactionalEmailError };
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
const service = require("./service");
|
|
4
|
+
const worker = require("./worker");
|
|
5
|
+
const scope = require("./scope");
|
|
6
|
+
const { bootstrap } = require("./bootstrap");
|
|
7
|
+
const { buildRouter } = require("./routes");
|
|
8
|
+
const { TransactionalEmailError } = require("./errors");
|
|
9
|
+
|
|
10
|
+
async function initialize() { if (!scope.enabled()) return { enabled: false }; const result = await bootstrap(); worker.startWorker(); return result; }
|
|
11
|
+
module.exports = { ...service, ...scope, ...worker, initialize, bootstrap, buildRouter, TransactionalEmailError };
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
const mongoose = require("mongoose");
|
|
4
|
+
|
|
5
|
+
const envelope = { keyId: String, version: Number, algorithm: String, iv: String, authTag: String, ciphertext: String };
|
|
6
|
+
const common = { scopeKey: { type: String, required: true, index: true } };
|
|
7
|
+
|
|
8
|
+
function model(name, schema, collection) { return mongoose.models[name] || mongoose.model(name, schema, collection); }
|
|
9
|
+
function getModels() {
|
|
10
|
+
const ConfigSchema = new mongoose.Schema({ ...common, provider: { type: String, default: "sendgrid" }, encryptedEnvelope: envelope, previousEnvelopes: [envelope], apiKeyMasked: String, fingerprint: String, credentialConfigured: Boolean, senderEmail: String, senderName: String, replyTo: String, enabled: { type: Boolean, default: false }, status: String, connectionStatus: String, lastTestAt: Date, lastError: String, configuredBy: String }, { timestamps: true, strict: true });
|
|
11
|
+
ConfigSchema.index({ scopeKey: 1, provider: 1 }, { unique: true });
|
|
12
|
+
const TemplateSchema = new mongoose.Schema({ ...common, code: String, version: Number, name: String, description: String, subject: String, htmlContent: String, textContent: String, variablesSchema: mongoose.Schema.Types.Mixed, locale: String, attachmentsPolicy: mongoose.Schema.Types.Mixed, status: { type: String, enum: ["draft", "active", "archived"] }, origin: { type: String, enum: ["manifest", "interface"] }, contentHash: String, createdBy: String, activatedAt: Date }, { timestamps: true, strict: true });
|
|
13
|
+
TemplateSchema.index({ scopeKey: 1, code: 1, version: 1 }, { unique: true });
|
|
14
|
+
TemplateSchema.index({ scopeKey: 1, code: 1, status: 1 }, { unique: true, partialFilterExpression: { status: "active" } });
|
|
15
|
+
const DispatchSchema = new mongoose.Schema({ ...common, templateCode: String, templateVersion: Number, protectedPayload: envelope, payloadExpiresAt: Date, recipientFingerprint: String, idempotencyKey: String, correlationId: String, providerMessageId: String, status: { type: String, enum: ["accepted", "processing", "provider_accepted", "failed", "dead_letter", "cancelled"] }, attempts: { type: Number, default: 0 }, maxAttempts: { type: Number, default: 5 }, nextRetryAt: Date, leaseOwner: String, leaseExpiresAt: Date, sentAt: Date, failedAt: Date, lastError: { code: String, message: String, retryable: Boolean }, retryAudit: [{ actorId: String, at: Date, previousStatus: String }] }, { timestamps: true, strict: true });
|
|
16
|
+
DispatchSchema.index({ scopeKey: 1, idempotencyKey: 1 }, { unique: true });
|
|
17
|
+
DispatchSchema.index({ status: 1, nextRetryAt: 1, leaseExpiresAt: 1 });
|
|
18
|
+
return { Config: model("OonTransactionalEmailConfig", ConfigSchema, "oon_transactional_email_configs"), Template: model("OonEmailTemplate", TemplateSchema, "oon_email_templates"), Dispatch: model("OonEmailDispatch", DispatchSchema, "oon_email_dispatches") };
|
|
19
|
+
}
|
|
20
|
+
async function syncIndexes() { const models = getModels(); return Promise.all(Object.values(models).map((entry) => entry.syncIndexes())); }
|
|
21
|
+
module.exports = { getModels, syncIndexes };
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
const { TransactionalEmailError } = require("./errors");
|
|
4
|
+
|
|
5
|
+
function providerError(response, body) {
|
|
6
|
+
const retryable = response.status === 429 || response.status >= 500;
|
|
7
|
+
return new TransactionalEmailError(retryable ? "SendGrid temporariamente indisponível." : "SendGrid recusou a solicitação.", { code: retryable ? "EMAIL_PROVIDER_TRANSIENT" : "EMAIL_PROVIDER_REJECTED", statusCode: retryable ? 503 : 422, retryable, details: { providerStatus: response.status, providerCode: body?.errors?.[0]?.field } });
|
|
8
|
+
}
|
|
9
|
+
async function request(path, apiKey, options = {}) {
|
|
10
|
+
const response = await (options.fetchImpl || globalThis.fetch)(`https://api.sendgrid.com/v3${path}`, { ...options, headers: { authorization: `Bearer ${apiKey}`, "content-type": "application/json", ...(options.headers || {}) } });
|
|
11
|
+
let body; try { body = await response.json(); } catch { body = undefined; }
|
|
12
|
+
if (!response.ok) throw providerError(response, body);
|
|
13
|
+
return { response, body };
|
|
14
|
+
}
|
|
15
|
+
async function validateCredential(apiKey, options = {}) { await request("/scopes", apiKey, { fetchImpl: options.fetchImpl }); return { valid: true }; }
|
|
16
|
+
async function send(message, apiKey, options = {}) {
|
|
17
|
+
const payload = { personalizations: [{ to: message.to.map((email) => ({ email })) }], from: { email: message.from.email, name: message.from.name || undefined }, reply_to: message.replyTo ? { email: message.replyTo } : undefined, subject: message.subject, content: [{ type: "text/plain", value: message.text }, { type: "text/html", value: message.html }] };
|
|
18
|
+
const { response } = await request("/mail/send", apiKey, { method: "POST", body: JSON.stringify(payload), fetchImpl: options.fetchImpl });
|
|
19
|
+
return { providerMessageId: response.headers.get("x-message-id") || undefined };
|
|
20
|
+
}
|
|
21
|
+
module.exports = { sendgrid: { validateCredential, send } };
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
const express = require("express");
|
|
4
|
+
const auth = require("../../core/middlewares/auth");
|
|
5
|
+
const { requirePermission } = require("../../core/middlewares/permissions");
|
|
6
|
+
const { asyncHandler } = require("../../core/utils/helpers");
|
|
7
|
+
const service = require("./service");
|
|
8
|
+
const worker = require("./worker");
|
|
9
|
+
const { enabled } = require("./scope");
|
|
10
|
+
const { keyring } = require("./secretStore");
|
|
11
|
+
|
|
12
|
+
function context(req) { return { ...(req.accessContext || {}), userId: req.accessContext?.userId || req.usuario?.id, fetchImpl: req.app.locals.transactionalEmailFetch }; }
|
|
13
|
+
function buildRouter() {
|
|
14
|
+
const router = express.Router(); router.use(auth);
|
|
15
|
+
router.get("/health", requirePermission("transactionalEmail.config.read"), asyncHandler(async (req, res) => { let secretStore = "active"; try { keyring(); } catch { secretStore = "degraded"; } const config = await service.getConfiguration(context(req)); const status = !config?.credentialConfigured ? "configuration_required" : secretStore === "degraded" || worker.health().status === "degraded" ? "degraded" : "active"; res.json({ declared: enabled(), status, secretStore, worker: worker.health(), configuration: config }); }));
|
|
16
|
+
router.get("/configuration", requirePermission("transactionalEmail.config.read"), asyncHandler(async (req, res) => res.json({ configuration: await service.getConfiguration(context(req)) })));
|
|
17
|
+
router.put("/configuration", requirePermission("transactionalEmail.config.manage"), asyncHandler(async (req, res) => res.json({ configuration: await service.saveConfiguration(req.body || {}, context(req)) })));
|
|
18
|
+
router.get("/templates", requirePermission("transactionalEmail.templates.read"), asyncHandler(async (req, res) => res.json({ results: await service.listTemplates(context(req)) })));
|
|
19
|
+
router.post("/templates/:code/versions", requirePermission("transactionalEmail.templates.manage"), asyncHandler(async (req, res) => res.status(201).json(await service.createTemplateVersion(req.params.code, req.body || {}, context(req)))));
|
|
20
|
+
router.post("/templates/:code/versions/:version/activate", requirePermission("transactionalEmail.templates.manage"), asyncHandler(async (req, res) => res.json(await service.activateTemplate(req.params.code, Number(req.params.version), context(req)))));
|
|
21
|
+
router.post("/templates/:code/versions/:version/archive", requirePermission("transactionalEmail.templates.manage"), asyncHandler(async (req, res) => res.json(await service.archiveTemplate(req.params.code, Number(req.params.version), context(req)))));
|
|
22
|
+
router.post("/templates/:code/test", requirePermission("transactionalEmail.templates.manage"), asyncHandler(async (req, res) => res.status(202).json(await service.testTemplate(req.params.code, req.body || {}, context(req)))));
|
|
23
|
+
router.get("/dispatches", requirePermission("transactionalEmail.dispatches.read"), asyncHandler(async (req, res) => res.json({ results: await service.listDispatches(context(req), req.query) })));
|
|
24
|
+
router.post("/dispatches/:id/retry", requirePermission("transactionalEmail.dispatches.retry"), asyncHandler(async (req, res) => res.json(await service.retryDispatch(req.params.id, context(req)))));
|
|
25
|
+
return router;
|
|
26
|
+
}
|
|
27
|
+
module.exports = { buildRouter };
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
const ctx = require("../../core/context");
|
|
4
|
+
const { TransactionalEmailError } = require("./errors");
|
|
5
|
+
|
|
6
|
+
function capabilityConfig(config = ctx.config) {
|
|
7
|
+
return config.capabilitySettings?.["core.transactional-email"] || config.appManifest?.capabilitySettings?.["core.transactional-email"];
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
function enabled(config = ctx.config) {
|
|
11
|
+
return config.capabilities?.includes("core.transactional-email") && capabilityConfig(config)?.enabled === true;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function appCode(context = {}, config = ctx.config) {
|
|
15
|
+
const runtimeCode = String(config.appCode || config.appManifest?.id || "").trim().toLowerCase();
|
|
16
|
+
const requestedCode = String(context.appCode || "").trim().toLowerCase();
|
|
17
|
+
if (requestedCode && runtimeCode && requestedCode !== runtimeCode) {
|
|
18
|
+
throw new TransactionalEmailError("O contexto não pertence a este App.", { code: "EMAIL_APP_SCOPE_DENIED", statusCode: 403 });
|
|
19
|
+
}
|
|
20
|
+
return runtimeCode;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function appScopeKey(context = {}, config = ctx.config) {
|
|
24
|
+
const code = appCode(context, config);
|
|
25
|
+
if (!code) throw new TransactionalEmailError("APP_CODE não resolvido para e-mail transacional.", { code: "EMAIL_APP_CONTEXT_REQUIRED", statusCode: 500 });
|
|
26
|
+
return `app:${code}`;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function tenantScopeKey(context = {}, config = ctx.config) {
|
|
30
|
+
const tenantId = String(context.tenantId || "").trim();
|
|
31
|
+
if (!tenantId) throw new TransactionalEmailError("Contexto de tenant obrigatório para e-mail transacional.", { code: "EMAIL_TENANT_CONTEXT_REQUIRED", statusCode: 403 });
|
|
32
|
+
return `${appScopeKey(context, config)}:tenant:${tenantId}`;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function requestedScopeKey(context = {}, config = ctx.config) {
|
|
36
|
+
const policy = capabilityConfig(config)?.scopePolicy || "app_only";
|
|
37
|
+
if (policy === "app_only") return appScopeKey(context, config);
|
|
38
|
+
if (policy === "tenant_required") return tenantScopeKey(context, config);
|
|
39
|
+
return context.tenantId ? tenantScopeKey(context, config) : appScopeKey(context, config);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function candidateScopeKeys(context = {}, config = ctx.config) {
|
|
43
|
+
const policy = capabilityConfig(config)?.scopePolicy || "app_only";
|
|
44
|
+
if (policy === "tenant_override" && context.tenantId) return [tenantScopeKey(context, config), appScopeKey(context, config)];
|
|
45
|
+
return [requestedScopeKey(context, config)];
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
module.exports = { capabilityConfig, enabled, appScopeKey, tenantScopeKey, requestedScopeKey, candidateScopeKeys };
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
const crypto = require("node:crypto");
|
|
4
|
+
const { TransactionalEmailError } = require("./errors");
|
|
5
|
+
|
|
6
|
+
function keyring(env = process.env) {
|
|
7
|
+
let input;
|
|
8
|
+
if (env.OON_TRANSACTIONAL_EMAIL_KEK_KEYRING) {
|
|
9
|
+
try { input = JSON.parse(env.OON_TRANSACTIONAL_EMAIL_KEK_KEYRING); } catch { input = null; }
|
|
10
|
+
} else if (env.OON_TRANSACTIONAL_EMAIL_KEK) {
|
|
11
|
+
input = { currentKeyId: env.OON_TRANSACTIONAL_EMAIL_KEK_ID || "v1", keys: { [env.OON_TRANSACTIONAL_EMAIL_KEK_ID || "v1"]: env.OON_TRANSACTIONAL_EMAIL_KEK } };
|
|
12
|
+
}
|
|
13
|
+
const keys = new Map();
|
|
14
|
+
for (const [id, encoded] of Object.entries(input?.keys || {})) {
|
|
15
|
+
const key = Buffer.from(String(encoded), "base64");
|
|
16
|
+
if (key.length === 32) keys.set(id, key);
|
|
17
|
+
}
|
|
18
|
+
if (!input?.currentKeyId || !keys.has(input.currentKeyId)) throw new TransactionalEmailError("SecretStore de e-mail indisponível.", { code: "EMAIL_SECRET_STORE_UNAVAILABLE", statusCode: 503 });
|
|
19
|
+
return { currentKeyId: input.currentKeyId, keys };
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function encrypt(value, options = {}) {
|
|
23
|
+
const ring = options.keyring || keyring(options.env);
|
|
24
|
+
const iv = crypto.randomBytes(12);
|
|
25
|
+
const cipher = crypto.createCipheriv("aes-256-gcm", ring.keys.get(ring.currentKeyId), iv);
|
|
26
|
+
const ciphertext = Buffer.concat([cipher.update(JSON.stringify(value), "utf8"), cipher.final()]);
|
|
27
|
+
return { keyId: ring.currentKeyId, version: 1, algorithm: "AES-256-GCM", iv: iv.toString("base64"), authTag: cipher.getAuthTag().toString("base64"), ciphertext: ciphertext.toString("base64") };
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function decrypt(envelope, options = {}) {
|
|
31
|
+
try {
|
|
32
|
+
const ring = options.keyring || keyring(options.env);
|
|
33
|
+
const key = ring.keys.get(envelope?.keyId);
|
|
34
|
+
if (!key) throw new Error("keyId ausente");
|
|
35
|
+
const decipher = crypto.createDecipheriv("aes-256-gcm", key, Buffer.from(envelope.iv, "base64"));
|
|
36
|
+
decipher.setAuthTag(Buffer.from(envelope.authTag, "base64"));
|
|
37
|
+
return JSON.parse(Buffer.concat([decipher.update(Buffer.from(envelope.ciphertext, "base64")), decipher.final()]).toString("utf8"));
|
|
38
|
+
} catch (cause) {
|
|
39
|
+
throw new TransactionalEmailError("Não foi possível abrir o segredo de e-mail.", { code: "EMAIL_SECRET_STORE_UNAVAILABLE", statusCode: 503, details: { keyId: envelope?.keyId } });
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function fingerprint(value) { return crypto.createHash("sha256").update(String(value)).digest("hex").slice(0, 16); }
|
|
44
|
+
function maskSecret(value) { const text = String(value || ""); return text ? `${text.slice(0, 3)}…${text.slice(-4)}` : undefined; }
|
|
45
|
+
module.exports = { keyring, encrypt, decrypt, fingerprint, maskSecret };
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
const crypto = require("node:crypto");
|
|
4
|
+
const { getModels } = require("./models");
|
|
5
|
+
const { candidateScopeKeys, requestedScopeKey, capabilityConfig, enabled } = require("./scope");
|
|
6
|
+
const secrets = require("./secretStore");
|
|
7
|
+
const providers = require("./providers");
|
|
8
|
+
const { validateDefinition, renderTemplate, contentHash } = require("./templates");
|
|
9
|
+
const { TransactionalEmailError } = require("./errors");
|
|
10
|
+
|
|
11
|
+
function assertEnabled() { if (!enabled()) throw new TransactionalEmailError("Capability de e-mail não declarada pelo App.", { code: "EMAIL_CAPABILITY_NOT_DECLARED", statusCode: 404 }); }
|
|
12
|
+
function sanitizedConfig(config) { if (!config) return null; return { scopeKey: config.scopeKey, provider: config.provider, apiKeyMasked: config.apiKeyMasked, fingerprint: config.fingerprint, credentialConfigured: Boolean(config.credentialConfigured), senderEmail: config.senderEmail, senderName: config.senderName, replyTo: config.replyTo, enabled: Boolean(config.enabled), status: config.status, connectionStatus: config.connectionStatus, lastTestAt: config.lastTestAt, lastError: config.lastError, updatedAt: config.updatedAt }; }
|
|
13
|
+
function sanitizeError(error) { return { code: String(error?.code || "EMAIL_PROVIDER_ERROR").slice(0, 80), message: String(error?.message || "Falha no envio.").slice(0, 240), retryable: error?.retryable === true }; }
|
|
14
|
+
|
|
15
|
+
async function configuration(context = {}) { assertEnabled(); const { Config } = getModels(); for (const scopeKey of candidateScopeKeys(context)) { const value = await Config.findOne({ scopeKey, provider: "sendgrid" }).lean(); if (value?.enabled && value?.credentialConfigured) return value; } throw new TransactionalEmailError("Configure API key e remetente para habilitar os envios.", { code: "EMAIL_CONFIGURATION_REQUIRED", statusCode: 503 }); }
|
|
16
|
+
async function getConfiguration(context = {}) { assertEnabled(); const { Config } = getModels(); return sanitizedConfig(await Config.findOne({ scopeKey: requestedScopeKey(context), provider: "sendgrid" }).lean()); }
|
|
17
|
+
|
|
18
|
+
async function saveConfiguration(input, context = {}) {
|
|
19
|
+
assertEnabled();
|
|
20
|
+
const senderEmail = String(input.senderEmail || "").trim().toLowerCase();
|
|
21
|
+
const { Config } = getModels();
|
|
22
|
+
const scopeKey = requestedScopeKey(context);
|
|
23
|
+
const current = await Config.findOne({ scopeKey, provider: "sendgrid" });
|
|
24
|
+
const suppliedApiKey = String(input.apiKey || "").trim();
|
|
25
|
+
const apiKey = suppliedApiKey || (current?.encryptedEnvelope?.ciphertext ? secrets.decrypt(current.encryptedEnvelope) : "");
|
|
26
|
+
if (!apiKey || !senderEmail.includes("@")) throw new TransactionalEmailError("API key e remetente válidos são obrigatórios.", { code: "EMAIL_CONFIGURATION_INVALID" });
|
|
27
|
+
await providers.sendgrid.validateCredential(apiKey, context);
|
|
28
|
+
if (input.testRecipient) {
|
|
29
|
+
await providers.sendgrid.send({ to: [String(input.testRecipient).trim()], from: { email: senderEmail, name: input.senderName }, replyTo: input.replyTo, subject: "Teste de e-mail transacional Oon", text: "Configuração validada.", html: "<p>Configuração validada.</p>" }, apiKey, context);
|
|
30
|
+
}
|
|
31
|
+
const envelope = suppliedApiKey ? secrets.encrypt(apiKey) : current.encryptedEnvelope;
|
|
32
|
+
const update = { encryptedEnvelope: envelope, apiKeyMasked: secrets.maskSecret(apiKey), fingerprint: secrets.fingerprint(apiKey), credentialConfigured: true, senderEmail, senderName: String(input.senderName || "").trim(), replyTo: String(input.replyTo || "").trim().toLowerCase(), enabled: input.enabled !== false, status: input.enabled === false ? "configuration_required" : "active", connectionStatus: "valid", lastTestAt: new Date(), lastError: undefined, configuredBy: context.userId };
|
|
33
|
+
if (suppliedApiKey && current?.encryptedEnvelope?.ciphertext) update.previousEnvelopes = [...(current.previousEnvelopes || []), current.encryptedEnvelope].slice(-3);
|
|
34
|
+
const saved = await Config.findOneAndUpdate({ scopeKey, provider: "sendgrid" }, { $set: update, $setOnInsert: { scopeKey, provider: "sendgrid" } }, { upsert: true, new: true });
|
|
35
|
+
return sanitizedConfig(saved.toObject());
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
async function listTemplates(context = {}) { assertEnabled(); const { Template } = getModels(); return Template.find({ scopeKey: requestedScopeKey(context) }).sort({ code: 1, version: -1 }).lean(); }
|
|
39
|
+
async function createTemplateVersion(code, input, context = {}) {
|
|
40
|
+
const { Template } = getModels(); const scopeKey = requestedScopeKey(context); const definition = validateDefinition({ ...input, code, initialStatus: "draft", attachmentsPolicy: input.attachmentsPolicy || { allowed: false } }, code);
|
|
41
|
+
const latest = await Template.findOne({ scopeKey, code }).sort({ version: -1 }).lean();
|
|
42
|
+
return Template.create({ ...definition, scopeKey, code, version: Number(latest?.version || 0) + 1, status: "draft", origin: "interface", contentHash: contentHash(definition), createdBy: context.userId });
|
|
43
|
+
}
|
|
44
|
+
async function activateTemplate(code, version, context = {}) { const { Template } = getModels(); const scopeKey = requestedScopeKey(context); const target = await Template.findOne({ scopeKey, code, version }); if (!target) throw new TransactionalEmailError("Versão de template não encontrada.", { code: "EMAIL_TEMPLATE_NOT_FOUND", statusCode: 404 }); await Template.updateMany({ scopeKey, code, status: "active" }, { $set: { status: "archived" } }); target.status = "active"; target.activatedAt = new Date(); target.createdBy ||= context.userId; await target.save(); return target; }
|
|
45
|
+
async function archiveTemplate(code, version, context = {}) { const { Template } = getModels(); const value = await Template.findOneAndUpdate({ scopeKey: requestedScopeKey(context), code, version }, { $set: { status: "archived" } }, { new: true }); if (!value) throw new TransactionalEmailError("Template não encontrado.", { code: "EMAIL_TEMPLATE_NOT_FOUND", statusCode: 404 }); return value; }
|
|
46
|
+
async function testTemplate(code, input, context = {}) { return send({ templateCode: code, to: input.to, variables: input.variables || {}, idempotencyKey: `template-test:${code}:${crypto.randomUUID()}`, correlationId: input.correlationId }, context); }
|
|
47
|
+
|
|
48
|
+
function recipients(to) { const values = (Array.isArray(to) ? to : [to]).map((value) => String(value || "").trim().toLowerCase()).filter(Boolean); if (!values.length || values.some((value) => !value.includes("@"))) throw new TransactionalEmailError("Destinatário inválido.", { code: "EMAIL_RECIPIENT_INVALID" }); return [...new Set(values)]; }
|
|
49
|
+
async function send(input, context = {}) {
|
|
50
|
+
assertEnabled(); if (input.attachments?.length) throw new TransactionalEmailError("Anexos não são permitidos nesta versão.", { code: "EMAIL_ATTACHMENTS_NOT_ALLOWED" });
|
|
51
|
+
const scopeKey = requestedScopeKey(context); const to = recipients(input.to); const { Template, Dispatch } = getModels();
|
|
52
|
+
const template = await Template.findOne({ scopeKey, code: input.templateCode, status: "active" }).lean();
|
|
53
|
+
if (!template) throw new TransactionalEmailError("Template ativo não encontrado.", { code: "EMAIL_TEMPLATE_NOT_ACTIVE", statusCode: 409 });
|
|
54
|
+
renderTemplate(template, input.variables || {});
|
|
55
|
+
const idempotencyKey = String(input.idempotencyKey || "").trim(); if (!idempotencyKey) throw new TransactionalEmailError("idempotencyKey é obrigatória.", { code: "EMAIL_IDEMPOTENCY_KEY_REQUIRED" });
|
|
56
|
+
try {
|
|
57
|
+
const retention = capabilityConfig()?.retentionDays || 30;
|
|
58
|
+
const dispatch = await Dispatch.create({ scopeKey, templateCode: template.code, templateVersion: template.version, protectedPayload: secrets.encrypt({ to, variables: input.variables || {} }), payloadExpiresAt: new Date(Date.now() + retention * 86400000), recipientFingerprint: secrets.fingerprint(to.join(",")), idempotencyKey, correlationId: String(input.correlationId || crypto.randomUUID()), status: "accepted", nextRetryAt: new Date(), maxAttempts: Math.max(1, Math.min(10, Number(input.maxAttempts || 5))) });
|
|
59
|
+
return { dispatchId: String(dispatch._id), status: "accepted", correlationId: dispatch.correlationId };
|
|
60
|
+
} catch (error) {
|
|
61
|
+
if (error?.code !== 11000) throw error; const existing = await Dispatch.findOne({ scopeKey, idempotencyKey }).lean(); return { dispatchId: String(existing._id), status: "duplicate", providerMessageId: existing.providerMessageId, correlationId: existing.correlationId };
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
async function listDispatches(context = {}, query = {}) { const { Dispatch } = getModels(); const limit = Math.min(100, Math.max(1, Number(query.limit || 25))); return Dispatch.find({ scopeKey: requestedScopeKey(context), ...(query.status ? { status: query.status } : {}) }, { protectedPayload: 0 }).sort({ createdAt: -1 }).limit(limit).lean(); }
|
|
65
|
+
async function retryDispatch(id, context = {}) { const { Dispatch } = getModels(); const filter = { _id: id, scopeKey: requestedScopeKey(context), status: "dead_letter", protectedPayload: { $exists: true } }; const dispatch = await Dispatch.findOneAndUpdate(filter, { $set: { status: "accepted", nextRetryAt: new Date(), leaseOwner: null, leaseExpiresAt: null }, $push: { retryAudit: { actorId: context.userId, at: new Date(), previousStatus: "dead_letter" } } }, { new: true }); if (!dispatch) throw new TransactionalEmailError("Dispatch inelegível para reprocessamento.", { code: "EMAIL_DISPATCH_NOT_RETRYABLE", statusCode: 409 }); return dispatch; }
|
|
66
|
+
module.exports = { configuration, getConfiguration, saveConfiguration, listTemplates, createTemplateVersion, activateTemplate, archiveTemplate, testTemplate, send, listDispatches, retryDispatch, sanitizeError, sanitizedConfig };
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
const crypto = require("node:crypto");
|
|
4
|
+
const Handlebars = require("handlebars");
|
|
5
|
+
const Ajv = require("ajv");
|
|
6
|
+
const { TransactionalEmailError } = require("./errors");
|
|
7
|
+
|
|
8
|
+
const ajv = new Ajv({ allErrors: true, strict: true });
|
|
9
|
+
const remoteContent = /<(?:script|iframe|link|img)\b[^>]*(?:src|href)\s*=\s*["']https?:\/\//i;
|
|
10
|
+
const compileOptions = { strict: true, noEscape: false, knownHelpersOnly: true, knownHelpers: {} };
|
|
11
|
+
|
|
12
|
+
function assertRestrictedHandlebars(source) {
|
|
13
|
+
const ast = Handlebars.parse(source);
|
|
14
|
+
function visit(node) {
|
|
15
|
+
if (!node || typeof node !== "object") return;
|
|
16
|
+
if (["BlockStatement", "PartialStatement", "PartialBlockStatement", "SubExpression", "Decorator", "DecoratorBlock"].includes(node.type)) {
|
|
17
|
+
throw new Error(`construção Handlebars não permitida: ${node.type}`);
|
|
18
|
+
}
|
|
19
|
+
if (node.type === "MustacheStatement" && (node.escaped === false || node.params?.length || node.hash?.pairs?.length || node.path?.type !== "PathExpression")) {
|
|
20
|
+
throw new Error("somente interpolação escapada de variáveis é permitida");
|
|
21
|
+
}
|
|
22
|
+
for (const value of Object.values(node)) {
|
|
23
|
+
if (Array.isArray(value)) value.forEach(visit);
|
|
24
|
+
else if (value && typeof value === "object") visit(value);
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
visit(ast);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function validateDefinition(input, expectedCode) {
|
|
31
|
+
const definition = { ...input };
|
|
32
|
+
const required = ["code", "name", "subject", "htmlContent", "textContent", "variablesSchema", "locale", "initialStatus", "attachmentsPolicy"];
|
|
33
|
+
for (const field of required) if (definition[field] === undefined) throw new TransactionalEmailError(`Template sem ${field}.`, { code: "EMAIL_TEMPLATE_INVALID" });
|
|
34
|
+
if (expectedCode && definition.code !== expectedCode) throw new TransactionalEmailError("O código do template difere do manifesto.", { code: "EMAIL_TEMPLATE_INVALID" });
|
|
35
|
+
if (!/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(definition.code)) throw new TransactionalEmailError("Código de template inválido.", { code: "EMAIL_TEMPLATE_INVALID" });
|
|
36
|
+
if (!["draft", "active"].includes(definition.initialStatus)) throw new TransactionalEmailError("Status inicial inválido.", { code: "EMAIL_TEMPLATE_INVALID" });
|
|
37
|
+
if (definition.attachmentsPolicy?.allowed !== false) throw new TransactionalEmailError("Anexos não são permitidos nesta versão.", { code: "EMAIL_ATTACHMENTS_NOT_ALLOWED" });
|
|
38
|
+
if (remoteContent.test(definition.htmlContent)) throw new TransactionalEmailError("Conteúdo remoto não é permitido no HTML.", { code: "EMAIL_TEMPLATE_REMOTE_CONTENT" });
|
|
39
|
+
try { ajv.compile(definition.variablesSchema); for (const source of [definition.subject, definition.htmlContent, definition.textContent]) { assertRestrictedHandlebars(source); Handlebars.precompile(source, compileOptions); } }
|
|
40
|
+
catch (cause) { throw new TransactionalEmailError(`Template inválido: ${cause.message}`, { code: "EMAIL_TEMPLATE_INVALID" }); }
|
|
41
|
+
return definition;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function renderTemplate(template, variables) {
|
|
45
|
+
const validate = ajv.compile(template.variablesSchema || {});
|
|
46
|
+
if (!validate(variables)) throw new TransactionalEmailError("Variáveis não atendem ao schema do template.", { code: "EMAIL_VARIABLES_INVALID", details: { errors: validate.errors } });
|
|
47
|
+
try {
|
|
48
|
+
return { subject: Handlebars.compile(template.subject, compileOptions)(variables), html: Handlebars.compile(template.htmlContent, compileOptions)(variables), text: Handlebars.compile(template.textContent, compileOptions)(variables) };
|
|
49
|
+
} catch (cause) { throw new TransactionalEmailError(`Falha ao renderizar template: ${cause.message}`, { code: "EMAIL_TEMPLATE_RENDER_FAILED" }); }
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function contentHash(definition) { return crypto.createHash("sha256").update(JSON.stringify(definition)).digest("hex"); }
|
|
53
|
+
module.exports = { validateDefinition, renderTemplate, contentHash };
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
const crypto = require("node:crypto");
|
|
4
|
+
const { getModels } = require("./models");
|
|
5
|
+
const { configuration, sanitizeError } = require("./service");
|
|
6
|
+
const secrets = require("./secretStore");
|
|
7
|
+
const providers = require("./providers");
|
|
8
|
+
const { renderTemplate } = require("./templates");
|
|
9
|
+
|
|
10
|
+
const state = { running: false, owner: `${process.pid}:${crypto.randomUUID()}`, lastProgressAt: null, timer: null };
|
|
11
|
+
function backoff(attempt) { const base = Math.min(3600000, 1000 * (2 ** Math.max(0, attempt - 1))); return base + Math.floor(Math.random() * Math.max(250, base * 0.2)); }
|
|
12
|
+
async function lease() { const { Dispatch } = getModels(); const now = new Date(); return Dispatch.findOneAndUpdate({ status: { $in: ["accepted", "failed", "processing"] }, nextRetryAt: { $lte: now }, $or: [{ leaseExpiresAt: { $exists: false } }, { leaseExpiresAt: null }, { leaseExpiresAt: { $lte: now } }] }, { $set: { status: "processing", leaseOwner: state.owner, leaseExpiresAt: new Date(Date.now() + 60000) }, $inc: { attempts: 1 } }, { new: true, sort: { nextRetryAt: 1, createdAt: 1 } }); }
|
|
13
|
+
async function processOne() {
|
|
14
|
+
const dispatch = await lease(); if (!dispatch) return false;
|
|
15
|
+
const { Template } = getModels();
|
|
16
|
+
try {
|
|
17
|
+
const config = await configuration({ appCode: dispatch.scopeKey.split(":")[1], tenantId: dispatch.scopeKey.includes(":tenant:") ? dispatch.scopeKey.split(":tenant:")[1] : undefined });
|
|
18
|
+
const template = await Template.findOne({ scopeKey: dispatch.scopeKey, code: dispatch.templateCode, version: dispatch.templateVersion }).lean();
|
|
19
|
+
const payload = secrets.decrypt(dispatch.protectedPayload); const rendered = renderTemplate(template, payload.variables); const apiKey = secrets.decrypt(config.encryptedEnvelope);
|
|
20
|
+
const result = await providers.sendgrid.send({ to: payload.to, from: { email: config.senderEmail, name: config.senderName }, replyTo: config.replyTo, ...rendered }, apiKey);
|
|
21
|
+
dispatch.status = "provider_accepted"; dispatch.providerMessageId = result.providerMessageId; dispatch.sentAt = new Date(); dispatch.leaseOwner = undefined; dispatch.leaseExpiresAt = undefined; dispatch.lastError = undefined;
|
|
22
|
+
} catch (error) {
|
|
23
|
+
const normalized = sanitizeError(error); dispatch.lastError = normalized; dispatch.leaseOwner = undefined; dispatch.leaseExpiresAt = undefined;
|
|
24
|
+
if (normalized.retryable && dispatch.attempts < dispatch.maxAttempts) { dispatch.status = "failed"; dispatch.nextRetryAt = new Date(Date.now() + backoff(dispatch.attempts)); }
|
|
25
|
+
else { dispatch.status = "dead_letter"; dispatch.failedAt = new Date(); }
|
|
26
|
+
}
|
|
27
|
+
await dispatch.save(); state.lastProgressAt = new Date(); return true;
|
|
28
|
+
}
|
|
29
|
+
async function scrubExpired() { const { Dispatch } = getModels(); await Dispatch.updateMany({ payloadExpiresAt: { $lte: new Date() }, status: { $in: ["provider_accepted", "dead_letter", "cancelled"] } }, { $unset: { protectedPayload: 1 } }); }
|
|
30
|
+
async function tick() { if (!state.running) return; try { let count = 0; while (state.running && count < 20 && await processOne()) count += 1; if (Math.random() < 0.05) await scrubExpired(); } catch (error) { console.error(JSON.stringify({ event: "transactionalEmail.worker.failed", code: error?.code || "EMAIL_WORKER_FAILED" })); } finally { if (state.running) state.timer = setTimeout(tick, 1000); } }
|
|
31
|
+
function startWorker() { if (state.running) return; state.running = true; state.timer = setTimeout(tick, 10); }
|
|
32
|
+
function stopWorker() { state.running = false; if (state.timer) clearTimeout(state.timer); state.timer = null; }
|
|
33
|
+
function health() { const stale = state.running && state.lastProgressAt && Date.now() - state.lastProgressAt.getTime() > 300000; return { running: state.running, owner: state.owner, lastProgressAt: state.lastProgressAt, status: !state.running || stale ? "degraded" : "active" }; }
|
|
34
|
+
module.exports = { startWorker, stopWorker, processOne, health, backoff, state };
|
|
@@ -72,6 +72,23 @@ function prepareAppManifest(workspace, deliveryDirectory) {
|
|
|
72
72
|
|
|
73
73
|
if (fs.existsSync(manifestSource)) {
|
|
74
74
|
fs.copyFileSync(manifestSource, manifestTarget);
|
|
75
|
+
const manifest = JSON.parse(fs.readFileSync(manifestSource, "utf8"));
|
|
76
|
+
const declaredTemplates = manifest.capabilitySettings?.["core.transactional-email"]?.templates || [];
|
|
77
|
+
const workspaceRoot = fs.realpathSync(workspace);
|
|
78
|
+
for (const declared of declaredTemplates) {
|
|
79
|
+
const relative = String(declared?.definition || "");
|
|
80
|
+
const source = path.resolve(workspace, relative);
|
|
81
|
+
const realSource = fs.realpathSync(source);
|
|
82
|
+
if (realSource !== workspaceRoot && !realSource.startsWith(`${workspaceRoot}${path.sep}`)) {
|
|
83
|
+
throw new Error(`Definição de template fora da raiz do App: ${relative}`);
|
|
84
|
+
}
|
|
85
|
+
const target = path.resolve(manifestDirectory, relative);
|
|
86
|
+
if (target !== manifestDirectory && !target.startsWith(`${manifestDirectory}${path.sep}`)) {
|
|
87
|
+
throw new Error(`Destino de template inválido: ${relative}`);
|
|
88
|
+
}
|
|
89
|
+
fs.mkdirSync(path.dirname(target), { recursive: true });
|
|
90
|
+
fs.copyFileSync(realSource, target);
|
|
91
|
+
}
|
|
75
92
|
}
|
|
76
93
|
|
|
77
94
|
return manifestTarget;
|
package/src/index.js
CHANGED
|
@@ -19,6 +19,8 @@ const SUPPORTED_AUTHORIZATION_MODES = Object.freeze(["none", "rbac"]);
|
|
|
19
19
|
// Compatibilidade de leitura para manifestos anteriores à separação das duas dimensões.
|
|
20
20
|
const SUPPORTED_ACCESS_POLICIES = Object.freeze(["tenant_members", "explicit_users", "rbac"]);
|
|
21
21
|
const SUPPORTED_DEPLOYMENT_MODES = Object.freeze(["singleton", "shared", "dedicated", "hybrid"]);
|
|
22
|
+
const TRANSACTIONAL_EMAIL_CAPABILITY = "core.transactional-email";
|
|
23
|
+
const EMAIL_SCOPE_POLICIES = Object.freeze(["app_only", "tenant_required", "tenant_override"]);
|
|
22
24
|
const APP_KIND_ROLE = Object.freeze({
|
|
23
25
|
"root-central": "root",
|
|
24
26
|
"member-central": "member",
|
|
@@ -135,6 +137,54 @@ function validateActivation(activation, issues) {
|
|
|
135
137
|
}
|
|
136
138
|
}
|
|
137
139
|
|
|
140
|
+
function validateCapabilitySettings(settings, capabilities, issues) {
|
|
141
|
+
if (settings === undefined) return;
|
|
142
|
+
if (!isPlainObject(settings)) {
|
|
143
|
+
addIssue(issues, "capabilitySettings", "deve ser um objeto.");
|
|
144
|
+
return;
|
|
145
|
+
}
|
|
146
|
+
for (const [capability, config] of Object.entries(settings)) {
|
|
147
|
+
const base = `capabilitySettings.${capability}`;
|
|
148
|
+
if (!capabilities?.includes(capability)) {
|
|
149
|
+
addIssue(issues, base, "exige a mesma capability declarada em capabilities.");
|
|
150
|
+
continue;
|
|
151
|
+
}
|
|
152
|
+
if (capability !== TRANSACTIONAL_EMAIL_CAPABILITY) {
|
|
153
|
+
addIssue(issues, base, "capability configurável desconhecida.");
|
|
154
|
+
continue;
|
|
155
|
+
}
|
|
156
|
+
if (!isPlainObject(config)) {
|
|
157
|
+
addIssue(issues, base, "deve ser um objeto.");
|
|
158
|
+
continue;
|
|
159
|
+
}
|
|
160
|
+
const allowed = new Set(["enabled", "scopePolicy", "templates", "retentionDays", "attachmentsPolicy"]);
|
|
161
|
+
for (const key of Object.keys(config)) if (!allowed.has(key)) addIssue(issues, `${base}.${key}`, "propriedade não suportada.");
|
|
162
|
+
if (typeof config.enabled !== "boolean") addIssue(issues, `${base}.enabled`, "deve ser booleano.");
|
|
163
|
+
if (!EMAIL_SCOPE_POLICIES.includes(config.scopePolicy)) addIssue(issues, `${base}.scopePolicy`, `deve ser um de: ${EMAIL_SCOPE_POLICIES.join(", ")}.`);
|
|
164
|
+
if (config.retentionDays !== undefined && (!Number.isInteger(config.retentionDays) || config.retentionDays < 1 || config.retentionDays > 90)) {
|
|
165
|
+
addIssue(issues, `${base}.retentionDays`, "deve ser inteiro entre 1 e 90.");
|
|
166
|
+
}
|
|
167
|
+
if (!Array.isArray(config.templates)) {
|
|
168
|
+
addIssue(issues, `${base}.templates`, "deve ser uma lista.");
|
|
169
|
+
continue;
|
|
170
|
+
}
|
|
171
|
+
const codes = new Set();
|
|
172
|
+
config.templates.forEach((template, index) => {
|
|
173
|
+
const item = `${base}.templates[${index}]`;
|
|
174
|
+
if (!isPlainObject(template)) { addIssue(issues, item, "deve ser um objeto."); return; }
|
|
175
|
+
const templateAllowed = new Set(["code", "definition", "initialStatus"]);
|
|
176
|
+
for (const key of Object.keys(template)) if (!templateAllowed.has(key)) addIssue(issues, `${item}.${key}`, "propriedade não suportada.");
|
|
177
|
+
if (typeof template.code !== "string" || !/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(template.code)) addIssue(issues, `${item}.code`, "deve ser um código em kebab-case.");
|
|
178
|
+
else if (codes.has(template.code)) addIssue(issues, `${item}.code`, `template duplicado: ${template.code}.`);
|
|
179
|
+
else codes.add(template.code);
|
|
180
|
+
if (typeof template.definition !== "string" || !template.definition.trim() || path.isAbsolute(template.definition) || template.definition.split(/[\\/]/).includes("..")) {
|
|
181
|
+
addIssue(issues, `${item}.definition`, "deve ser um caminho relativo sem escapar da raiz do App.");
|
|
182
|
+
}
|
|
183
|
+
if (!["draft", "active"].includes(template.initialStatus)) addIssue(issues, `${item}.initialStatus`, "deve ser draft ou active.");
|
|
184
|
+
});
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
138
188
|
function validateDimension(value, field, supported, issues) {
|
|
139
189
|
if (value === undefined) return;
|
|
140
190
|
if (!supported.includes(value)) {
|
|
@@ -170,7 +220,7 @@ function validateAppManifest(input, { coreVersion = corePackage.version } = {})
|
|
|
170
220
|
const allowedTopLevel = new Set([
|
|
171
221
|
"$schema", "schemaVersion", "id", "name", "slug", "appKind",
|
|
172
222
|
"tenancyModel", "availability", "accessMode", "authorizationMode", "accessPolicy", "deploymentMode",
|
|
173
|
-
"modules", "capabilities", "compatibility", "activation", "rbac",
|
|
223
|
+
"modules", "capabilities", "capabilitySettings", "compatibility", "activation", "rbac",
|
|
174
224
|
]);
|
|
175
225
|
for (const key of Object.keys(input)) {
|
|
176
226
|
if (!allowedTopLevel.has(key)) addIssue(issues, key, "propriedade não suportada.");
|
|
@@ -233,6 +283,7 @@ function validateAppManifest(input, { coreVersion = corePackage.version } = {})
|
|
|
233
283
|
else seen.add(capability);
|
|
234
284
|
});
|
|
235
285
|
}
|
|
286
|
+
validateCapabilitySettings(input.capabilitySettings, input.capabilities, issues);
|
|
236
287
|
validateCompatibility(input.compatibility, issues, coreVersion);
|
|
237
288
|
validateActivation(input.activation, issues);
|
|
238
289
|
validateRbac(input.rbac, issues);
|
|
@@ -268,6 +319,7 @@ function appManifestToConfig(manifest, manifestPath) {
|
|
|
268
319
|
},
|
|
269
320
|
modules: { ...manifest.modules },
|
|
270
321
|
capabilities: [...manifest.capabilities],
|
|
322
|
+
capabilitySettings: JSON.parse(JSON.stringify(manifest.capabilitySettings || {})),
|
|
271
323
|
rbac: manifest.rbac ? JSON.parse(JSON.stringify(manifest.rbac)) : undefined,
|
|
272
324
|
appManifest: manifest,
|
|
273
325
|
appManifestPath: manifestPath,
|
|
@@ -303,6 +355,8 @@ module.exports = {
|
|
|
303
355
|
SUPPORTED_AUTHORIZATION_MODES,
|
|
304
356
|
SUPPORTED_ACCESS_POLICIES,
|
|
305
357
|
SUPPORTED_DEPLOYMENT_MODES,
|
|
358
|
+
TRANSACTIONAL_EMAIL_CAPABILITY,
|
|
359
|
+
EMAIL_SCOPE_POLICIES,
|
|
306
360
|
APP_KIND_DIMENSIONS,
|
|
307
361
|
AppManifestError,
|
|
308
362
|
parseVersion,
|