@oondemand/oon-core-back 0.4.28 → 0.4.30

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@oondemand/oon-core-back",
3
- "version": "0.4.28",
3
+ "version": "0.4.30",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "git+https://github.com/oondemand/oon-platform.git",
@@ -9,19 +9,65 @@ const worker = require("./worker");
9
9
  const { enabled } = require("./scope");
10
10
  const { keyring } = require("./secretStore");
11
11
 
12
- function context(req) { return { ...(req.accessContext || {}), userId: req.accessContext?.userId || req.usuario?.id, fetchImpl: req.app.locals.transactionalEmailFetch }; }
12
+ function context(req) {
13
+ return {
14
+ ...(req.accessContext || {}),
15
+ userId: req.accessContext?.userId || req.usuario?.id,
16
+ fetchImpl: req.app.locals.transactionalEmailFetch,
17
+ };
18
+ }
19
+
13
20
  function buildRouter() {
14
- const router = express.Router(); router.use(auth);
15
- router.get("/health", requirePermission("transactional-email.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("transactional-email.config.read"), asyncHandler(async (req, res) => res.json({ configuration: await service.getConfiguration(context(req)) })));
17
- router.put("/configuration", requirePermission("transactional-email.config.manage"), asyncHandler(async (req, res) => res.json({ configuration: await service.saveConfiguration(req.body || {}, context(req)) })));
18
- router.get("/templates", requirePermission("transactional-email.templates.read"), asyncHandler(async (req, res) => res.json({ results: await service.listTemplates(context(req)) })));
19
- router.post("/templates/:code/versions", requirePermission("transactional-email.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("transactional-email.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("transactional-email.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("transactional-email.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("transactional-email.dispatches.read"), asyncHandler(async (req, res) => res.json({ results: await service.listDispatches(context(req), req.query) })));
24
- router.post("/dispatches/:id/retry", requirePermission("transactional-email.dispatches.retry"), asyncHandler(async (req, res) => res.json(await service.retryDispatch(req.params.id, context(req)))));
21
+ const router = express.Router();
22
+ router.use(auth);
23
+
24
+ router.get("/health", requirePermission("transactional-email.config.read"), asyncHandler(async (req, res) => {
25
+ let secretStore = "active";
26
+ try { keyring(); } catch { secretStore = "degraded"; }
27
+ const config = await service.getConfiguration(context(req));
28
+ const status = !config?.credentialConfigured
29
+ ? "configuration_required"
30
+ : secretStore === "degraded" || worker.health().status === "degraded" ? "degraded" : "active";
31
+ res.json({ declared: enabled(), status, secretStore, worker: worker.health(), configuration: config });
32
+ }));
33
+
34
+ router.get("/configuration", requirePermission("transactional-email.config.read"), asyncHandler(async (req, res) => {
35
+ res.json({ configuration: await service.getConfiguration(context(req)) });
36
+ }));
37
+ router.put("/configuration", requirePermission("transactional-email.config.manage"), asyncHandler(async (req, res) => {
38
+ res.json({ configuration: await service.saveConfiguration(req.body || {}, context(req)) });
39
+ }));
40
+ router.post("/configuration/test", requirePermission("transactional-email.config.manage"), asyncHandler(async (req, res) => {
41
+ res.json({ configuration: await service.testConfiguration(req.body || {}, context(req)) });
42
+ }));
43
+
44
+ router.get("/templates", requirePermission("transactional-email.templates.read"), asyncHandler(async (req, res) => {
45
+ res.json({ results: await service.listTemplates(context(req)) });
46
+ }));
47
+ router.post("/templates/:code/versions", requirePermission("transactional-email.templates.manage"), asyncHandler(async (req, res) => {
48
+ res.status(201).json(await service.createTemplateVersion(req.params.code, req.body || {}, context(req)));
49
+ }));
50
+ router.post("/templates/:code/preview", requirePermission("transactional-email.templates.read"), asyncHandler(async (req, res) => {
51
+ res.json(await service.previewTemplate(req.params.code, req.body || {}, context(req)));
52
+ }));
53
+ router.post("/templates/:code/versions/:version/activate", requirePermission("transactional-email.templates.manage"), asyncHandler(async (req, res) => {
54
+ res.json(await service.activateTemplate(req.params.code, Number(req.params.version), context(req)));
55
+ }));
56
+ router.post("/templates/:code/versions/:version/archive", requirePermission("transactional-email.templates.manage"), asyncHandler(async (req, res) => {
57
+ res.json(await service.archiveTemplate(req.params.code, Number(req.params.version), context(req)));
58
+ }));
59
+ router.post("/templates/:code/test", requirePermission("transactional-email.templates.manage"), asyncHandler(async (req, res) => {
60
+ res.status(202).json(await service.testTemplate(req.params.code, req.body || {}, context(req)));
61
+ }));
62
+
63
+ router.get("/dispatches", requirePermission("transactional-email.dispatches.read"), asyncHandler(async (req, res) => {
64
+ res.json({ results: await service.listDispatches(context(req), req.query) });
65
+ }));
66
+ router.post("/dispatches/:id/retry", requirePermission("transactional-email.dispatches.retry"), asyncHandler(async (req, res) => {
67
+ res.json(await service.retryDispatch(req.params.id, context(req)));
68
+ }));
69
+
25
70
  return router;
26
71
  }
72
+
27
73
  module.exports = { buildRouter };
@@ -8,59 +8,351 @@ const providers = require("./providers");
8
8
  const { validateDefinition, renderTemplate, contentHash } = require("./templates");
9
9
  const { TransactionalEmailError } = require("./errors");
10
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 }; }
11
+ function assertEnabled() {
12
+ if (!enabled()) throw new TransactionalEmailError("Capability de e-mail não declarada pelo App.", { code: "EMAIL_CAPABILITY_NOT_DECLARED", statusCode: 404 });
13
+ }
14
+
15
+ function sanitizedConfig(config) {
16
+ if (!config) return null;
17
+ return {
18
+ scopeKey: config.scopeKey,
19
+ provider: config.provider,
20
+ apiKeyMasked: config.apiKeyMasked,
21
+ fingerprint: config.fingerprint,
22
+ credentialConfigured: Boolean(config.credentialConfigured),
23
+ senderEmail: config.senderEmail,
24
+ senderName: config.senderName,
25
+ replyTo: config.replyTo,
26
+ enabled: Boolean(config.enabled),
27
+ status: config.status,
28
+ connectionStatus: config.connectionStatus,
29
+ lastTestAt: config.lastTestAt,
30
+ lastError: config.lastError,
31
+ updatedAt: config.updatedAt,
32
+ };
33
+ }
34
+
35
+ function sanitizeError(error) {
36
+ return {
37
+ code: String(error?.code || "EMAIL_PROVIDER_ERROR").slice(0, 80),
38
+ message: String(error?.message || "Falha no envio.").slice(0, 240),
39
+ retryable: error?.retryable === true,
40
+ };
41
+ }
14
42
 
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()); }
43
+ function configuredValue(input, field, fallback = "") {
44
+ return Object.prototype.hasOwnProperty.call(input || {}, field) ? input[field] : fallback;
45
+ }
46
+
47
+ function resolvedConfigurationInput(input = {}, current = {}) {
48
+ return {
49
+ senderEmail: String(configuredValue(input, "senderEmail", current.senderEmail) || "").trim().toLowerCase(),
50
+ senderName: String(configuredValue(input, "senderName", current.senderName) || "").trim(),
51
+ replyTo: String(configuredValue(input, "replyTo", current.replyTo) || "").trim().toLowerCase(),
52
+ enabled: configuredValue(input, "enabled", current.enabled) !== false,
53
+ };
54
+ }
55
+
56
+ function validEmail(value) {
57
+ return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(String(value || ""));
58
+ }
59
+
60
+ async function configuration(context = {}) {
61
+ assertEnabled();
62
+ const { Config } = getModels();
63
+ for (const scopeKey of candidateScopeKeys(context)) {
64
+ const value = await Config.findOne({ scopeKey, provider: "sendgrid" }).lean();
65
+ if (value?.enabled && value?.credentialConfigured) return value;
66
+ }
67
+ throw new TransactionalEmailError("Configure API key e remetente para habilitar os envios.", { code: "EMAIL_CONFIGURATION_REQUIRED", statusCode: 503 });
68
+ }
69
+
70
+ async function getConfiguration(context = {}) {
71
+ assertEnabled();
72
+ const { Config } = getModels();
73
+ return sanitizedConfig(await Config.findOne({ scopeKey: requestedScopeKey(context), provider: "sendgrid" }).lean());
74
+ }
17
75
 
18
76
  async function saveConfiguration(input, context = {}) {
19
77
  assertEnabled();
20
- const senderEmail = String(input.senderEmail || "").trim().toLowerCase();
21
78
  const { Config } = getModels();
22
79
  const scopeKey = requestedScopeKey(context);
23
80
  const current = await Config.findOne({ scopeKey, provider: "sendgrid" });
81
+ const values = resolvedConfigurationInput(input, current || {});
24
82
  const suppliedApiKey = String(input.apiKey || "").trim();
25
83
  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);
84
+
85
+ if (!apiKey || !validEmail(values.senderEmail) || (values.replyTo && !validEmail(values.replyTo))) {
86
+ throw new TransactionalEmailError("API key, remetente e reply-to precisam ser válidos.", { code: "EMAIL_CONFIGURATION_INVALID" });
30
87
  }
88
+
89
+ await providers.sendgrid.validateCredential(apiKey, context);
31
90
  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 });
91
+ const update = {
92
+ encryptedEnvelope: envelope,
93
+ apiKeyMasked: secrets.maskSecret(apiKey),
94
+ fingerprint: secrets.fingerprint(apiKey),
95
+ credentialConfigured: true,
96
+ senderEmail: values.senderEmail,
97
+ senderName: values.senderName,
98
+ replyTo: values.replyTo,
99
+ enabled: values.enabled,
100
+ status: values.enabled ? "active" : "configuration_required",
101
+ connectionStatus: "valid",
102
+ configuredBy: context.userId,
103
+ };
104
+ if (suppliedApiKey && current?.encryptedEnvelope?.ciphertext) {
105
+ update.previousEnvelopes = [...(current.previousEnvelopes || []), current.encryptedEnvelope].slice(-3);
106
+ }
107
+
108
+ const saved = await Config.findOneAndUpdate(
109
+ { scopeKey, provider: "sendgrid" },
110
+ { $set: update, $unset: { lastError: 1 }, $setOnInsert: { scopeKey, provider: "sendgrid" } },
111
+ { upsert: true, new: true },
112
+ );
35
113
  return sanitizedConfig(saved.toObject());
36
114
  }
37
115
 
38
- async function listTemplates(context = {}) { assertEnabled(); const { Template } = getModels(); return Template.find({ scopeKey: requestedScopeKey(context) }).sort({ code: 1, version: -1 }).lean(); }
116
+ async function testConfiguration(input, context = {}) {
117
+ assertEnabled();
118
+ const { Config } = getModels();
119
+ const scopeKey = requestedScopeKey(context);
120
+ const current = await Config.findOne({ scopeKey, provider: "sendgrid" });
121
+ const recipient = String(input.recipient || "").trim().toLowerCase();
122
+
123
+ if (!current?.encryptedEnvelope?.ciphertext || !current.credentialConfigured) {
124
+ throw new TransactionalEmailError("Configure a credencial antes de refazer o teste.", { code: "EMAIL_CONFIGURATION_REQUIRED", statusCode: 409 });
125
+ }
126
+ if (!validEmail(recipient)) {
127
+ throw new TransactionalEmailError("Informe um destinatário válido para o teste.", { code: "EMAIL_RECIPIENT_INVALID" });
128
+ }
129
+
130
+ const testedAt = new Date();
131
+ try {
132
+ const apiKey = secrets.decrypt(current.encryptedEnvelope);
133
+ await providers.sendgrid.validateCredential(apiKey, context);
134
+ await providers.sendgrid.send({
135
+ to: [recipient],
136
+ from: { email: current.senderEmail, name: current.senderName },
137
+ replyTo: current.replyTo,
138
+ subject: "Teste de e-mail transacional Oon",
139
+ text: "Configuração validada. Este envio confirma que remetente e credencial estão operacionais.",
140
+ html: "<p><strong>Configuração validada.</strong></p><p>Este envio confirma que remetente e credencial estão operacionais.</p>",
141
+ }, apiKey, context);
142
+ const saved = await Config.findOneAndUpdate(
143
+ { _id: current._id },
144
+ { $set: { connectionStatus: "valid", status: current.enabled ? "active" : "configuration_required", lastTestAt: testedAt }, $unset: { lastError: 1 } },
145
+ { new: true },
146
+ );
147
+ return sanitizedConfig(saved.toObject());
148
+ } catch (error) {
149
+ const normalized = sanitizeError(error);
150
+ await Config.updateOne(
151
+ { _id: current._id },
152
+ { $set: { connectionStatus: "invalid", status: "degraded", lastTestAt: testedAt, lastError: normalized.message } },
153
+ );
154
+ throw error;
155
+ }
156
+ }
157
+
158
+ async function listTemplates(context = {}) {
159
+ assertEnabled();
160
+ const { Template } = getModels();
161
+ return Template.find({ scopeKey: requestedScopeKey(context) }).sort({ code: 1, version: -1 }).lean();
162
+ }
163
+
39
164
  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);
165
+ assertEnabled();
166
+ const { Template } = getModels();
167
+ const scopeKey = requestedScopeKey(context);
168
+ const definition = validateDefinition({ ...input, code, initialStatus: "draft", attachmentsPolicy: input.attachmentsPolicy || { allowed: false } }, code);
41
169
  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 });
170
+ return Template.create({
171
+ ...definition,
172
+ scopeKey,
173
+ code,
174
+ version: Number(latest?.version || 0) + 1,
175
+ status: "draft",
176
+ origin: "interface",
177
+ contentHash: contentHash(definition),
178
+ createdBy: context.userId,
179
+ });
180
+ }
181
+
182
+ async function findTemplateVersion(code, version, context = {}) {
183
+ const { Template } = getModels();
184
+ const scopeKey = requestedScopeKey(context);
185
+ const filter = { scopeKey, code };
186
+ if (version !== undefined && version !== null && version !== "") {
187
+ const parsed = Number(version);
188
+ if (!Number.isInteger(parsed) || parsed < 1) {
189
+ throw new TransactionalEmailError("Versão de template inválida.", { code: "EMAIL_TEMPLATE_VERSION_INVALID" });
190
+ }
191
+ filter.version = parsed;
192
+ } else {
193
+ filter.status = "active";
194
+ }
195
+ const template = await Template.findOne(filter).lean();
196
+ if (!template) {
197
+ throw new TransactionalEmailError("Versão de template não encontrada.", { code: "EMAIL_TEMPLATE_NOT_FOUND", statusCode: 404 });
198
+ }
199
+ return template;
200
+ }
201
+
202
+ async function previewTemplate(code, input, context = {}) {
203
+ assertEnabled();
204
+ let template;
205
+ if (input.definition) {
206
+ template = validateDefinition({
207
+ ...input.definition,
208
+ code,
209
+ initialStatus: "draft",
210
+ attachmentsPolicy: input.definition.attachmentsPolicy || { allowed: false },
211
+ }, code);
212
+ } else {
213
+ template = await findTemplateVersion(code, input.version, context);
214
+ }
215
+ const rendered = renderTemplate(template, input.variables || {});
216
+ return {
217
+ code,
218
+ version: template.version || null,
219
+ name: template.name,
220
+ locale: template.locale,
221
+ ...rendered,
222
+ };
223
+ }
224
+
225
+ async function activateTemplate(code, version, context = {}) {
226
+ assertEnabled();
227
+ const { Template } = getModels();
228
+ const scopeKey = requestedScopeKey(context);
229
+ const target = await Template.findOne({ scopeKey, code, version });
230
+ if (!target) throw new TransactionalEmailError("Versão de template não encontrada.", { code: "EMAIL_TEMPLATE_NOT_FOUND", statusCode: 404 });
231
+ await Template.updateMany({ scopeKey, code, status: "active" }, { $set: { status: "archived" } });
232
+ target.status = "active";
233
+ target.activatedAt = new Date();
234
+ target.createdBy ||= context.userId;
235
+ await target.save();
236
+ return target;
237
+ }
238
+
239
+ async function archiveTemplate(code, version, context = {}) {
240
+ assertEnabled();
241
+ const { Template } = getModels();
242
+ const value = await Template.findOneAndUpdate(
243
+ { scopeKey: requestedScopeKey(context), code, version },
244
+ { $set: { status: "archived" } },
245
+ { new: true },
246
+ );
247
+ if (!value) throw new TransactionalEmailError("Template não encontrado.", { code: "EMAIL_TEMPLATE_NOT_FOUND", statusCode: 404 });
248
+ return value;
249
+ }
250
+
251
+ async function testTemplate(code, input, context = {}) {
252
+ const version = input.version === undefined || input.version === null || input.version === "" ? undefined : Number(input.version);
253
+ if (version !== undefined && (!Number.isInteger(version) || version < 1)) {
254
+ throw new TransactionalEmailError("Versão de template inválida.", { code: "EMAIL_TEMPLATE_VERSION_INVALID" });
255
+ }
256
+ return send({
257
+ templateCode: code,
258
+ templateVersion: version,
259
+ to: input.to,
260
+ variables: input.variables || {},
261
+ idempotencyKey: `template-test:${code}:${version || "active"}:${crypto.randomUUID()}`,
262
+ correlationId: input.correlationId,
263
+ }, context);
264
+ }
265
+
266
+ function recipients(to) {
267
+ const values = (Array.isArray(to) ? to : [to])
268
+ .map((value) => String(value || "").trim().toLowerCase())
269
+ .filter(Boolean);
270
+ if (!values.length || values.some((value) => !validEmail(value))) {
271
+ throw new TransactionalEmailError("Destinatário inválido.", { code: "EMAIL_RECIPIENT_INVALID" });
272
+ }
273
+ return [...new Set(values)];
43
274
  }
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
275
 
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
276
  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 });
277
+ assertEnabled();
278
+ if (input.attachments?.length) throw new TransactionalEmailError("Anexos não são permitidos nesta versão.", { code: "EMAIL_ATTACHMENTS_NOT_ALLOWED" });
279
+ const scopeKey = requestedScopeKey(context);
280
+ const to = recipients(input.to);
281
+ const { Template, Dispatch } = getModels();
282
+ const templateFilter = { scopeKey, code: input.templateCode };
283
+ if (input.templateVersion !== undefined) templateFilter.version = input.templateVersion;
284
+ else templateFilter.status = "active";
285
+ const template = await Template.findOne(templateFilter).lean();
286
+ if (!template) {
287
+ const message = input.templateVersion !== undefined ? "Versão de template não encontrada." : "Template ativo não encontrado.";
288
+ const code = input.templateVersion !== undefined ? "EMAIL_TEMPLATE_NOT_FOUND" : "EMAIL_TEMPLATE_NOT_ACTIVE";
289
+ throw new TransactionalEmailError(message, { code, statusCode: 409 });
290
+ }
54
291
  renderTemplate(template, input.variables || {});
55
- const idempotencyKey = String(input.idempotencyKey || "").trim(); if (!idempotencyKey) throw new TransactionalEmailError("idempotencyKey é obrigatória.", { code: "EMAIL_IDEMPOTENCY_KEY_REQUIRED" });
292
+ const idempotencyKey = String(input.idempotencyKey || "").trim();
293
+ if (!idempotencyKey) throw new TransactionalEmailError("idempotencyKey é obrigatória.", { code: "EMAIL_IDEMPOTENCY_KEY_REQUIRED" });
56
294
  try {
57
295
  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))) });
296
+ const dispatch = await Dispatch.create({
297
+ scopeKey,
298
+ templateCode: template.code,
299
+ templateVersion: template.version,
300
+ protectedPayload: secrets.encrypt({ to, variables: input.variables || {} }),
301
+ payloadExpiresAt: new Date(Date.now() + retention * 86400000),
302
+ recipientFingerprint: secrets.fingerprint(to.join(",")),
303
+ idempotencyKey,
304
+ correlationId: String(input.correlationId || crypto.randomUUID()),
305
+ status: "accepted",
306
+ nextRetryAt: new Date(),
307
+ maxAttempts: Math.max(1, Math.min(10, Number(input.maxAttempts || 5))),
308
+ });
59
309
  return { dispatchId: String(dispatch._id), status: "accepted", correlationId: dispatch.correlationId };
60
310
  } 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 };
311
+ if (error?.code !== 11000) throw error;
312
+ const existing = await Dispatch.findOne({ scopeKey, idempotencyKey }).lean();
313
+ return { dispatchId: String(existing._id), status: "duplicate", providerMessageId: existing.providerMessageId, correlationId: existing.correlationId };
62
314
  }
63
315
  }
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 };
316
+
317
+ async function listDispatches(context = {}, query = {}) {
318
+ const { Dispatch } = getModels();
319
+ const limit = Math.min(100, Math.max(1, Number(query.limit || 25)));
320
+ return Dispatch.find(
321
+ { scopeKey: requestedScopeKey(context), ...(query.status ? { status: query.status } : {}) },
322
+ { protectedPayload: 0 },
323
+ ).sort({ createdAt: -1 }).limit(limit).lean();
324
+ }
325
+
326
+ async function retryDispatch(id, context = {}) {
327
+ const { Dispatch } = getModels();
328
+ const filter = { _id: id, scopeKey: requestedScopeKey(context), status: "dead_letter", protectedPayload: { $exists: true } };
329
+ const dispatch = await Dispatch.findOneAndUpdate(
330
+ filter,
331
+ {
332
+ $set: { status: "accepted", nextRetryAt: new Date(), leaseOwner: null, leaseExpiresAt: null },
333
+ $push: { retryAudit: { actorId: context.userId, at: new Date(), previousStatus: "dead_letter" } },
334
+ },
335
+ { new: true },
336
+ );
337
+ if (!dispatch) throw new TransactionalEmailError("Dispatch inelegível para reprocessamento.", { code: "EMAIL_DISPATCH_NOT_RETRYABLE", statusCode: 409 });
338
+ return dispatch;
339
+ }
340
+
341
+ module.exports = {
342
+ configuration,
343
+ getConfiguration,
344
+ saveConfiguration,
345
+ testConfiguration,
346
+ listTemplates,
347
+ createTemplateVersion,
348
+ previewTemplate,
349
+ activateTemplate,
350
+ archiveTemplate,
351
+ testTemplate,
352
+ send,
353
+ listDispatches,
354
+ retryDispatch,
355
+ sanitizeError,
356
+ sanitizedConfig,
357
+ resolvedConfigurationInput,
358
+ };