@omnizap-system/omnizap 2.6.2 → 2.6.3

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.
Files changed (48) hide show
  1. package/.env.example +24 -0
  2. package/app/config/index.js +4 -0
  3. package/app/configParts/adminIdentity.js +29 -0
  4. package/app/configParts/baileysConfig.js +116 -0
  5. package/app/configParts/groupUtils.js +221 -0
  6. package/app/configParts/loggerConfig.js +185 -0
  7. package/app/configParts/messagePersistenceService.js +169 -7
  8. package/app/configParts/sessionConfig.js +85 -0
  9. package/app/connection/baileysCompatibility.test.js +9 -0
  10. package/app/connection/baileysDbAuthState.js +205 -9
  11. package/app/connection/baileysLibsignalPatch.js +210 -0
  12. package/app/connection/groupOwnerWriteStateResolver.js +53 -21
  13. package/app/connection/socketController.js +95 -25
  14. package/app/connection/socketController.multiSession.test.js +20 -0
  15. package/app/controllers/messagePipeline/preProcessingMiddlewares.js +17 -3
  16. package/app/controllers/messageProcessingPipeline.js +2 -0
  17. package/app/controllers/messageProcessingPipeline.test.js +15 -13
  18. package/app/services/multiSession/assignmentBalancerService.js +1 -6
  19. package/app/services/multiSession/groupOwnershipRepository.js +9 -44
  20. package/app/services/multiSession/groupOwnershipService.js +9 -90
  21. package/app/services/multiSession/groupOwnershipService.test.js +12 -4
  22. package/app/services/multiSession/sessionRegistryService.js +6 -60
  23. package/app/utils/antiLink/antiLinkModule.js +54 -24
  24. package/docs/security/omnizap-static-security-headers.conf +3 -3
  25. package/package.json +3 -2
  26. package/public/comandos/commands-catalog.json +1 -1
  27. package/public/css/payments-react.css +478 -0
  28. package/public/js/apps/homeReactApp.js +2 -2
  29. package/public/js/apps/paymentsCancelReactApp.js +45 -0
  30. package/public/js/apps/paymentsReactApp.js +399 -0
  31. package/public/js/apps/paymentsSuccessReactApp.js +148 -0
  32. package/public/pages/pagamentos-cancelado.html +21 -0
  33. package/public/pages/pagamentos-sucesso.html +21 -0
  34. package/public/pages/pagamentos.html +30 -0
  35. package/scripts/deploy.sh +3 -0
  36. package/scripts/new-whatsapp-session.sh +247 -0
  37. package/server/controllers/admin/systemAdminController.js +4 -17
  38. package/server/controllers/payments/paymentsController.js +731 -0
  39. package/server/controllers/system/systemController.js +4 -30
  40. package/server/email/emailAutomationRuntime.js +36 -1
  41. package/server/email/emailAutomationService.js +42 -1
  42. package/server/email/emailTemplateService.js +137 -31
  43. package/server/http/httpRequestUtils.js +18 -14
  44. package/server/middleware/securityHeaders.js +15 -2
  45. package/server/routes/indexRouter.js +27 -7
  46. package/server/routes/payments/paymentsRouter.js +47 -0
  47. package/server/routes/static/staticPageRouter.js +3 -0
  48. package/vite.config.mjs +3 -0
@@ -190,9 +190,7 @@ export const listSystemAdminSessions = async ({ status = null, limit = 200 } = {
190
190
  if (row?.sessionId) knownSessionIds.add(row.sessionId);
191
191
  }
192
192
 
193
- const selectedSessionIds = Array.from(knownSessionIds)
194
- .filter(Boolean)
195
- .slice(0, safeLimit);
193
+ const selectedSessionIds = Array.from(knownSessionIds).filter(Boolean).slice(0, safeLimit);
196
194
  const registryBySession = new Map((registryRows || []).map((row) => [row.sessionId, row]));
197
195
 
198
196
  const sessions = selectedSessionIds.map((sessionId) => {
@@ -226,14 +224,7 @@ export const listSystemAdminSessions = async ({ status = null, limit = 200 } = {
226
224
  };
227
225
  };
228
226
 
229
- export const listSystemAdminAssignments = async (
230
- {
231
- groupJid = null,
232
- ownerSessionId = null,
233
- includeExpired = false,
234
- limit = 200,
235
- } = {},
236
- ) => {
227
+ export const listSystemAdminAssignments = async ({ groupJid = null, ownerSessionId = null, includeExpired = false, limit = 200 } = {}) => {
237
228
  const safeGroupJid = normalizeOptional(groupJid, 255);
238
229
  const safeOwnerSessionId = normalizeOptional(ownerSessionId, 64);
239
230
  const safeIncludeExpired = normalizeBoolean(includeExpired, false);
@@ -269,16 +260,7 @@ export const listSystemAdminAssignments = async (
269
260
  };
270
261
  };
271
262
 
272
- export const setSystemAdminGroupPin = async (
273
- {
274
- groupJid,
275
- pinned,
276
- sessionId = null,
277
- reason = null,
278
- changedBy = 'admin_api',
279
- metadata = null,
280
- } = {},
281
- ) => {
263
+ export const setSystemAdminGroupPin = async ({ groupJid, pinned, sessionId = null, reason = null, changedBy = 'admin_api', metadata = null } = {}) => {
282
264
  const outcome = await groupOwnershipService.setPinned({
283
265
  groupJid,
284
266
  pinned,
@@ -307,15 +289,7 @@ export const setSystemAdminGroupPin = async (
307
289
  };
308
290
  };
309
291
 
310
- export const forceSystemAdminGroupFailover = async (
311
- {
312
- groupJid,
313
- targetSessionId,
314
- reason = 'admin_force_failover',
315
- changedBy = 'admin_api',
316
- metadata = null,
317
- } = {},
318
- ) => {
292
+ export const forceSystemAdminGroupFailover = async ({ groupJid, targetSessionId, reason = 'admin_force_failover', changedBy = 'admin_api', metadata = null } = {}) => {
319
293
  const outcome = await groupOwnershipService.forceAssign({
320
294
  groupJid,
321
295
  sessionId: targetSessionId,
@@ -38,6 +38,19 @@ let inFlight = false;
38
38
  let timerHandle = null;
39
39
  let nextDelayMs = EMAIL_AUTOMATION_POLL_INTERVAL_MS;
40
40
 
41
+ const maskEmailForLogs = (value) => {
42
+ const normalized = String(value || '')
43
+ .trim()
44
+ .toLowerCase()
45
+ .slice(0, 255);
46
+ const [localPartRaw, domainRaw] = normalized.split('@');
47
+ const localPart = String(localPartRaw || '').trim();
48
+ const domain = String(domainRaw || '').trim();
49
+ if (!localPart || !domain) return 'invalid-email';
50
+ const localMasked = localPart.length <= 2 ? `${localPart.charAt(0) || '*'}*` : `${localPart.slice(0, 2)}***`;
51
+ return `${localMasked}@${domain}`;
52
+ };
53
+
41
54
  const applyDelayJitter = (delayMs) => {
42
55
  const baseDelay = Math.max(250, Math.floor(Number(delayMs) || 0));
43
56
  if (EMAIL_AUTOMATION_IDLE_JITTER_PERCENT <= 0) return baseDelay;
@@ -119,6 +132,16 @@ export const runEmailAutomationTick = async ({ maxPerTick = EMAIL_AUTOMATION_MAX
119
132
  });
120
133
 
121
134
  stats.sent += 1;
135
+ logger.debug('E-mail da fila entregue com sucesso.', {
136
+ action: 'email_automation_delivery_succeeded',
137
+ task_id: task.id,
138
+ template_key: task.template_key || null,
139
+ recipient_email_masked: maskEmailForLogs(task.recipient_email),
140
+ attempts: task.attempts,
141
+ max_attempts: task.max_attempts,
142
+ provider_message_id: delivery?.messageId || null,
143
+ provider_response: delivery?.response || null,
144
+ });
122
145
  } catch (error) {
123
146
  stats.failed += 1;
124
147
  await failEmailOutboxTask(task.id, {
@@ -129,8 +152,12 @@ export const runEmailAutomationTick = async ({ maxPerTick = EMAIL_AUTOMATION_MAX
129
152
  logger.warn('Falha ao entregar e-mail da fila.', {
130
153
  action: 'email_automation_delivery_failed',
131
154
  task_id: task.id,
132
- recipient_email: task.recipient_email,
155
+ template_key: task.template_key || null,
156
+ recipient_email_masked: maskEmailForLogs(task.recipient_email),
133
157
  attempts: task.attempts,
158
+ max_attempts: task.max_attempts,
159
+ retry_delay_seconds: safeRetryDelay,
160
+ metadata_keys: Object.keys(task?.metadata || {}).slice(0, 20),
134
161
  error: error?.message,
135
162
  });
136
163
  }
@@ -138,6 +165,14 @@ export const runEmailAutomationTick = async ({ maxPerTick = EMAIL_AUTOMATION_MAX
138
165
 
139
166
  if (stats.claimed > 0) {
140
167
  await refreshQueueDepthMetrics().catch(() => null);
168
+ logger.info('Processamento da fila de e-mail concluído.', {
169
+ action: 'email_automation_tick_processed',
170
+ claimed: stats.claimed,
171
+ sent: stats.sent,
172
+ failed: stats.failed,
173
+ max_per_tick: safeMaxPerTick,
174
+ retry_delay_seconds: safeRetryDelay,
175
+ });
141
176
  }
142
177
 
143
178
  return stats;
@@ -1,3 +1,4 @@
1
+ import logger from '#logger';
1
2
  import { enqueueEmailOutbox, getEmailOutboxStatusSnapshot } from './emailOutboxRepository.js';
2
3
  import { renderEmailTemplate } from './emailTemplateService.js';
3
4
  import { getEmailTransportMetadata } from './emailTransportService.js';
@@ -8,6 +9,22 @@ const normalizeEmail = (value) =>
8
9
  .toLowerCase()
9
10
  .slice(0, 255);
10
11
 
12
+ const maskEmailForLogs = (value) => {
13
+ const normalized = normalizeEmail(value);
14
+ const [localPartRaw, domainRaw] = normalized.split('@');
15
+ const localPart = String(localPartRaw || '').trim();
16
+ const domain = String(domainRaw || '').trim();
17
+ if (!localPart || !domain) return 'invalid-email';
18
+ const localMasked = localPart.length <= 2 ? `${localPart.charAt(0) || '*'}*` : `${localPart.slice(0, 2)}***`;
19
+ return `${localMasked}@${domain}`;
20
+ };
21
+
22
+ const resolveEmailDomain = (value) => {
23
+ const normalized = normalizeEmail(value);
24
+ if (!normalized.includes('@')) return null;
25
+ return normalized.split('@')[1] || null;
26
+ };
27
+
11
28
  const normalizeOptionalText = (value, maxLength = 500_000) => {
12
29
  const normalized =
13
30
  String(value || '')
@@ -31,6 +48,12 @@ const resolveEmailBodyFromPayload = ({ templateKey = '', templateData = {}, subj
31
48
 
32
49
  const renderedTemplate = normalizedTemplateKey ? renderEmailTemplate(normalizedTemplateKey, normalizedTemplateData) : null;
33
50
 
51
+ if (normalizedTemplateKey && !renderedTemplate && !normalizeOptionalText(subject, 180) && !normalizeOptionalText(text, 120_000) && !normalizeOptionalText(html, 500_000)) {
52
+ const error = new Error(`Template de e-mail inválido ou indisponível: "${normalizedTemplateKey}".`);
53
+ error.statusCode = 400;
54
+ throw error;
55
+ }
56
+
34
57
  const normalizedSubject = normalizeOptionalText(subject, 180) || renderedTemplate?.subject || '';
35
58
  const normalizedText = normalizeOptionalText(text, 120_000) || renderedTemplate?.text || null;
36
59
  const normalizedHtml = normalizeOptionalText(html, 500_000) || renderedTemplate?.html || null;
@@ -72,6 +95,8 @@ export const queueAutomatedEmail = async ({ to, name = '', templateKey = '', tem
72
95
  html,
73
96
  });
74
97
 
98
+ const safeMetadata = normalizePayloadObject(metadata);
99
+
75
100
  const taskId = await enqueueEmailOutbox({
76
101
  recipientEmail: normalizedEmail,
77
102
  recipientName: normalizeOptionalText(name, 120),
@@ -80,7 +105,7 @@ export const queueAutomatedEmail = async ({ to, name = '', templateKey = '', tem
80
105
  htmlBody: body.html_body,
81
106
  templateKey: body.template_key,
82
107
  templatePayload: body.template_payload,
83
- metadata: normalizePayloadObject(metadata),
108
+ metadata: safeMetadata,
84
109
  priority,
85
110
  scheduledAt,
86
111
  maxAttempts,
@@ -93,6 +118,22 @@ export const queueAutomatedEmail = async ({ to, name = '', templateKey = '', tem
93
118
  throw error;
94
119
  }
95
120
 
121
+ logger.info('E-mail enfileirado para processamento.', {
122
+ action: 'email_outbox_enqueued',
123
+ task_id: taskId,
124
+ template_key: body.template_key || null,
125
+ recipient_email_masked: maskEmailForLogs(normalizedEmail),
126
+ recipient_domain: resolveEmailDomain(normalizedEmail),
127
+ subject_length: body.subject.length,
128
+ has_text_body: Boolean(body.text_body),
129
+ has_html_body: Boolean(body.html_body),
130
+ metadata_keys: Object.keys(safeMetadata).slice(0, 20),
131
+ priority: Number.isFinite(Number(priority)) ? Number(priority) : null,
132
+ scheduled_at: scheduledAt ? String(scheduledAt) : null,
133
+ max_attempts: Number.isFinite(Number(maxAttempts)) ? Number(maxAttempts) : null,
134
+ idempotency_key_present: Boolean(String(idempotencyKey || '').trim()),
135
+ });
136
+
96
137
  return {
97
138
  task_id: taskId,
98
139
  recipient_email: normalizedEmail,
@@ -1,7 +1,9 @@
1
1
  import { now as __timeNow, nowIso as __timeNowIso, toUnixMs as __timeNowMs } from '#time';
2
+ import logger from '#logger';
2
3
  import { resolveAdminPhoneFromEnv, resolveBotPhoneFromEnv, resolveSupportPhoneFromEnv } from '../../utils/whatsapp/contactEnv.js';
3
4
  const DEFAULT_SITE_ORIGIN = 'https://omnizap.shop';
4
5
  const DEFAULT_BRAND_NAME = 'OmniZap';
6
+ const DEFAULT_BRAND_LOGO_PATH = '/assets/images/brand-logo-128.webp';
5
7
 
6
8
  const resolveSiteOrigin = () =>
7
9
  String(process.env.SITE_ORIGIN || process.env.WHATSAPP_LOGIN_BASE_URL || DEFAULT_SITE_ORIGIN)
@@ -28,6 +30,24 @@ const escapeHtml = (value) =>
28
30
  .replace(/"/g, '&quot;')
29
31
  .replace(/'/g, '&#39;');
30
32
 
33
+ const summarizePayloadKeys = (value, maxItems = 24) => {
34
+ if (!value || typeof value !== 'object' || Array.isArray(value)) return [];
35
+ return Object.keys(value)
36
+ .map((key) =>
37
+ String(key || '')
38
+ .trim()
39
+ .slice(0, 64),
40
+ )
41
+ .filter(Boolean)
42
+ .slice(0, maxItems);
43
+ };
44
+
45
+ const clipTemplatePreview = (value, maxLength = 140) =>
46
+ String(value || '')
47
+ .replace(/\s+/g, ' ')
48
+ .trim()
49
+ .slice(0, maxLength);
50
+
31
51
  const normalizeHttpUrl = (value, fallback = '') => {
32
52
  const normalized = String(value || '')
33
53
  .trim()
@@ -48,6 +68,19 @@ const normalizeEmailAddress = (value) => {
48
68
  return candidate.slice(0, 255);
49
69
  };
50
70
 
71
+ const resolveBrandLogoUrl = ({ siteOrigin = '', payloadLogoUrl = '' } = {}) => {
72
+ const fallbackLogoUrl = normalizeHttpUrl(`${String(siteOrigin || DEFAULT_SITE_ORIGIN).replace(/\/+$/, '')}${DEFAULT_BRAND_LOGO_PATH}`, `${DEFAULT_SITE_ORIGIN}${DEFAULT_BRAND_LOGO_PATH}`);
73
+
74
+ const explicitLogoUrl = normalizeHttpUrl(payloadLogoUrl || process.env.EMAIL_BRAND_LOGO_URL || '', '');
75
+ if (!explicitLogoUrl) return fallbackLogoUrl;
76
+
77
+ const explicitLower = explicitLogoUrl.toLowerCase();
78
+ const looksLikeFavicon = explicitLower.endsWith('/favicon.ico') || explicitLower.includes('/favicon-');
79
+ if (looksLikeFavicon) return fallbackLogoUrl;
80
+
81
+ return explicitLogoUrl;
82
+ };
83
+
51
84
  const normalizePhoneDigits = (value, maxLength = 20) =>
52
85
  String(value || '')
53
86
  .replace(/\D+/g, '')
@@ -89,7 +122,10 @@ const resolveBrandConfig = (payload = {}) => {
89
122
  siteOrigin,
90
123
  brandName: normalizeText(payload?.brandName || process.env.EMAIL_BRAND_NAME || DEFAULT_BRAND_NAME, 80) || DEFAULT_BRAND_NAME,
91
124
  brandTagline: normalizeText(payload?.brandTagline || process.env.EMAIL_BRAND_TAGLINE || 'Automação profissional para WhatsApp.', 120) || null,
92
- brandLogoUrl: normalizeHttpUrl(payload?.brandLogoUrl || payload?.logoUrl || process.env.EMAIL_BRAND_LOGO_URL || '', ''),
125
+ brandLogoUrl: resolveBrandLogoUrl({
126
+ siteOrigin,
127
+ payloadLogoUrl: payload?.brandLogoUrl || payload?.logoUrl || '',
128
+ }),
93
129
  supportUrl: resolvedSupportUrl,
94
130
  supportLabel: resolvedSupportLabel || 'Central de suporte',
95
131
  supportEmail: replyToAddress || fromAddress || '',
@@ -128,53 +164,75 @@ const renderEmailLayout = ({ payload = {}, preheader = '', heading = '', greetin
128
164
  const safeSecurityNote = normalizeText(securityNote, 220);
129
165
  const safeFooterMessage = normalizeText(footerMessage, 220);
130
166
  const year = __timeNow().getUTCFullYear();
167
+ const generatedAt = __timeNowIso();
131
168
 
132
- const logoBlock = brand.brandLogoUrl ? `<img src="${escapeHtml(brand.brandLogoUrl)}" alt="${escapeHtml(brand.brandName)}" width="132" style="display:block;border:0;outline:none;text-decoration:none;height:auto;margin:0 auto;" />` : `<div style="display:inline-block;font-size:26px;font-weight:800;color:#0f172a;letter-spacing:0.2px;">${escapeHtml(brand.brandName)}</div>`;
169
+ const logoBlock = brand.brandLogoUrl ? `<img src="${escapeHtml(brand.brandLogoUrl)}" alt="${escapeHtml(brand.brandName)}" width="138" style="display:block;border:0;outline:none;text-decoration:none;height:auto;" />` : `<div style="display:inline-block;font-size:24px;font-weight:800;line-height:1.1;color:#ffffff;letter-spacing:0.3px;">${escapeHtml(brand.brandName)}</div>`;
133
170
 
134
- const greetingBlock = safeGreeting ? `<p style="margin:0 0 10px;color:#0f172a;font-size:16px;font-weight:700;line-height:1.5;">${escapeHtml(safeGreeting)}</p>` : '';
135
- const introBlock = safeIntro ? `<p style="margin:0 0 14px;color:#334155;font-size:15px;line-height:1.65;">${escapeHtml(safeIntro)}</p>` : '';
171
+ const headingBlock = safeHeading ? `<h1 style="margin:0;color:#0f172a;font-size:27px;line-height:1.2;font-weight:800;letter-spacing:0.1px;">${escapeHtml(safeHeading)}</h1>` : '';
172
+ const greetingBlock = safeGreeting ? `<p style="margin:0 0 10px;color:#0f172a;font-size:16px;font-weight:700;line-height:1.6;">${escapeHtml(safeGreeting)}</p>` : '';
173
+ const introBlock = safeIntro ? `<p style="margin:0 0 14px;color:#334155;font-size:15px;line-height:1.7;">${escapeHtml(safeIntro)}</p>` : '';
136
174
  const bodyBlock = renderParagraphsHtml(body);
137
175
 
138
176
  const ctaBlock =
139
177
  safeCtaUrl && safeCtaLabel
140
178
  ? `
141
- <table role="presentation" cellpadding="0" cellspacing="0" border="0" style="margin:10px 0 10px;">
142
- <tr>
143
- <td align="center" bgcolor="#1d4ed8" style="border-radius:10px;">
144
- <a href="${escapeHtml(safeCtaUrl)}" style="display:inline-block;padding:12px 20px;font-size:15px;line-height:1.2;font-weight:700;color:#ffffff;text-decoration:none;">${escapeHtml(safeCtaLabel)}</a>
145
- </td>
146
- </tr>
147
- </table>
148
- `.trim()
179
+ <table role="presentation" cellpadding="0" cellspacing="0" border="0" style="margin:14px 0 8px;">
180
+ <tr>
181
+ <td align="center" bgcolor="#1d4ed8" style="border-radius:10px;">
182
+ <a href="${escapeHtml(safeCtaUrl)}" style="display:inline-block;padding:13px 22px;font-size:15px;line-height:1.2;font-weight:700;color:#ffffff;text-decoration:none;letter-spacing:0.2px;">${escapeHtml(safeCtaLabel)}</a>
183
+ </td>
184
+ </tr>
185
+ </table>
186
+ `.trim()
149
187
  : '';
150
188
 
151
- const ctaHintBlock = safeCtaHint ? `<p style="margin:4px 0 0;color:#64748b;font-size:13px;line-height:1.55;">${escapeHtml(safeCtaHint)}</p>` : '';
152
- const secondaryCtaBlock = safeSecondaryCtaLabel && safeSecondaryCtaUrl ? `<p style="margin:10px 0 0;color:#1e293b;font-size:14px;line-height:1.6;">${escapeHtml(safeSecondaryCtaLabel)}: <a href="${escapeHtml(safeSecondaryCtaUrl)}" style="color:#2563eb;text-decoration:none;">${escapeHtml(safeSecondaryCtaUrl)}</a></p>` : '';
153
- const fallbackLinkBlock = safeCtaUrl ? `<p style="margin:12px 0 0;color:#64748b;font-size:12px;line-height:1.6;word-break:break-all;">Se o botão não funcionar, copie e cole este link no navegador: ${escapeHtml(safeCtaUrl)}</p>` : '';
154
- const securityNoteBlock = safeSecurityNote ? `<p style="margin:16px 0 0;padding:10px 12px;background:#f8fafc;border:1px solid #e2e8f0;border-radius:8px;color:#475569;font-size:12px;line-height:1.6;">${escapeHtml(safeSecurityNote)}</p>` : '';
155
-
189
+ const ctaHintBlock = safeCtaHint ? `<p style="margin:6px 0 0;color:#64748b;font-size:13px;line-height:1.6;">${escapeHtml(safeCtaHint)}</p>` : '';
190
+ const secondaryCtaBlock =
191
+ safeSecondaryCtaLabel && safeSecondaryCtaUrl
192
+ ? `
193
+ <p style="margin:12px 0 0;color:#334155;font-size:13px;line-height:1.65;">
194
+ ${escapeHtml(safeSecondaryCtaLabel)}:
195
+ <a href="${escapeHtml(safeSecondaryCtaUrl)}" style="color:#1d4ed8;text-decoration:none;font-weight:600;">${escapeHtml(safeSecondaryCtaUrl)}</a>
196
+ </p>
197
+ `.trim()
198
+ : '';
199
+ const fallbackLinkBlock = safeCtaUrl ? `<p style="margin:14px 0 0;color:#64748b;font-size:12px;line-height:1.7;word-break:break-all;">Se o botão não funcionar, copie e cole este link no navegador: ${escapeHtml(safeCtaUrl)}</p>` : '';
200
+ const securityNoteBlock = safeSecurityNote ? `<p style="margin:16px 0 0;padding:12px 13px;background:#f8fafc;border:1px solid #e2e8f0;border-radius:10px;color:#475569;font-size:12px;line-height:1.65;">${escapeHtml(safeSecurityNote)}</p>` : '';
201
+ const brandTaglineBlock = brand.brandTagline ? `<p style="margin:8px 0 0;color:#cbd5e1;font-size:13px;line-height:1.6;">${escapeHtml(brand.brandTagline)}</p>` : '';
156
202
  const supportEmailLine = brand.supportEmail ? `<span style="display:block;margin-top:6px;">E-mail: <a href="mailto:${escapeHtml(brand.supportEmail)}" style="color:#2563eb;text-decoration:none;">${escapeHtml(brand.supportEmail)}</a></span>` : '';
157
- const footerMessageBlock = safeFooterMessage ? `<span style="display:block;margin-top:6px;color:#64748b;">${escapeHtml(safeFooterMessage)}</span>` : '';
158
- const taglineBlock = brand.brandTagline ? `<p style="margin:8px 0 0;color:#64748b;font-size:13px;line-height:1.6;">${escapeHtml(brand.brandTagline)}</p>` : '';
203
+ const footerMessageBlock = safeFooterMessage ? `<span style="display:block;margin-top:7px;color:#64748b;">${escapeHtml(safeFooterMessage)}</span>` : '';
159
204
 
160
205
  return `
161
206
  <!doctype html>
162
207
  <html lang="pt-BR">
163
- <body style="margin:0;padding:0;background:#f1f5f9;font-family:'Segoe UI',Roboto,Helvetica,Arial,sans-serif;">
208
+ <body style="margin:0;padding:0;background:#eef2f8;font-family:'Segoe UI',Roboto,Helvetica,Arial,sans-serif;">
164
209
  <div style="display:none;max-height:0;overflow:hidden;opacity:0;color:transparent;">${escapeHtml(safePreheader)}</div>
165
- <table role="presentation" cellpadding="0" cellspacing="0" border="0" width="100%" style="background:#f1f5f9;padding:28px 10px;">
210
+ <table role="presentation" cellpadding="0" cellspacing="0" border="0" width="100%" style="background:#eef2f8;padding:26px 10px;">
166
211
  <tr>
167
212
  <td align="center">
168
213
  <table role="presentation" cellpadding="0" cellspacing="0" border="0" width="100%" style="max-width:640px;">
169
214
  <tr>
170
- <td align="center" style="padding:0 0 16px;">
171
- ${logoBlock}
172
- ${taglineBlock}
215
+ <td style="background:#0f172a;border-radius:16px 16px 0 0;padding:22px 24px 20px;">
216
+ <table role="presentation" cellpadding="0" cellspacing="0" border="0" width="100%">
217
+ <tr>
218
+ <td align="left" valign="middle" style="padding-right:12px;">
219
+ ${logoBlock}
220
+ ${brandTaglineBlock}
221
+ </td>
222
+ <td align="right" valign="middle" style="white-space:nowrap;">
223
+ <span style="display:inline-block;padding:6px 10px;border:1px solid rgba(148,163,184,0.45);border-radius:999px;font-size:11px;font-weight:700;letter-spacing:0.35px;text-transform:uppercase;color:#e2e8f0;">Comunicado oficial</span>
224
+ </td>
225
+ </tr>
226
+ </table>
227
+ </td>
228
+ </tr>
229
+ <tr>
230
+ <td style="background:#ffffff;border-left:1px solid #d9e2ef;border-right:1px solid #d9e2ef;padding:26px 24px 10px;">
231
+ ${headingBlock}
173
232
  </td>
174
233
  </tr>
175
234
  <tr>
176
- <td style="background:#ffffff;border:1px solid #dbe3ef;border-radius:14px;padding:26px 24px;box-shadow:0 4px 24px rgba(15,23,42,0.06);">
177
- <h1 style="margin:0 0 14px;color:#0f172a;font-size:25px;line-height:1.25;">${escapeHtml(safeHeading)}</h1>
235
+ <td style="background:#ffffff;border-left:1px solid #d9e2ef;border-right:1px solid #d9e2ef;padding:0 24px 22px;">
178
236
  ${greetingBlock}
179
237
  ${introBlock}
180
238
  ${bodyBlock}
@@ -186,11 +244,12 @@ const renderEmailLayout = ({ payload = {}, preheader = '', heading = '', greetin
186
244
  </td>
187
245
  </tr>
188
246
  <tr>
189
- <td style="padding:14px 2px 0;color:#64748b;font-size:12px;line-height:1.7;text-align:left;">
247
+ <td style="background:#ffffff;border:1px solid #d9e2ef;border-top:none;border-radius:0 0 16px 16px;padding:14px 24px 18px;color:#64748b;font-size:12px;line-height:1.7;">
190
248
  <span style="display:block;">${escapeHtml(brand.brandName)} © ${year}. Todos os direitos reservados.</span>
191
249
  <span style="display:block;margin-top:6px;">Central de suporte: <a href="${escapeHtml(brand.supportUrl)}" style="color:#2563eb;text-decoration:none;">${escapeHtml(brand.supportLabel)}</a></span>
192
250
  ${supportEmailLine}
193
251
  ${footerMessageBlock}
252
+ <span style="display:block;margin-top:7px;color:#94a3b8;font-size:11px;">Gerado em ${escapeHtml(generatedAt)} UTC.</span>
194
253
  </td>
195
254
  </tr>
196
255
  </table>
@@ -457,19 +516,66 @@ const TEMPLATE_BUILDERS = {
457
516
  };
458
517
 
459
518
  export const renderEmailTemplate = (templateKey, payload = {}) => {
519
+ const renderStartedAtMs = __timeNowMs();
460
520
  const normalizedTemplateKey = normalizeTemplateKey(templateKey);
461
- if (!normalizedTemplateKey) return null;
521
+ const payloadKeys = summarizePayloadKeys(payload);
522
+
523
+ if (!normalizedTemplateKey) {
524
+ logger.warn('Render de template de e-mail ignorado por chave inválida.', {
525
+ action: 'email_template_render_invalid_key',
526
+ template_key_raw: clipTemplatePreview(templateKey, 64),
527
+ payload_keys: payloadKeys,
528
+ });
529
+ return null;
530
+ }
462
531
  const builder = TEMPLATE_BUILDERS[normalizedTemplateKey];
463
- if (typeof builder !== 'function') return null;
532
+ if (typeof builder !== 'function') {
533
+ logger.warn('Template de e-mail não encontrado.', {
534
+ action: 'email_template_builder_not_found',
535
+ template_key: normalizedTemplateKey,
536
+ payload_keys: payloadKeys,
537
+ });
538
+ return null;
539
+ }
464
540
 
465
541
  const rendered = builder(payload || {});
466
- if (!rendered) return null;
542
+ if (!rendered) {
543
+ logger.warn('Template de e-mail retornou conteúdo vazio.', {
544
+ action: 'email_template_builder_empty',
545
+ template_key: normalizedTemplateKey,
546
+ payload_keys: payloadKeys,
547
+ });
548
+ return null;
549
+ }
467
550
 
468
551
  const subject = normalizeText(rendered.subject, 180);
469
552
  const text = normalizeText(rendered.text, 120_000);
470
553
  const html = normalizeText(rendered.html, 500_000);
471
554
 
472
- if (!subject || (!text && !html)) return null;
555
+ if (!subject || (!text && !html)) {
556
+ logger.warn('Template de e-mail inválido após normalização.', {
557
+ action: 'email_template_render_invalid_output',
558
+ template_key: normalizedTemplateKey,
559
+ subject_length: subject.length,
560
+ text_length: text.length,
561
+ html_length: html.length,
562
+ payload_keys: payloadKeys,
563
+ });
564
+ return null;
565
+ }
566
+
567
+ logger.debug('Template de e-mail renderizado com sucesso.', {
568
+ action: 'email_template_rendered',
569
+ template_key: normalizedTemplateKey,
570
+ subject_preview: clipTemplatePreview(subject),
571
+ subject_length: subject.length,
572
+ text_length: text.length,
573
+ html_length: html.length,
574
+ has_text: Boolean(text),
575
+ has_html: Boolean(html),
576
+ payload_keys: payloadKeys,
577
+ render_duration_ms: Math.max(0, __timeNowMs() - renderStartedAtMs),
578
+ });
473
579
 
474
580
  return {
475
581
  subject,
@@ -225,7 +225,7 @@ export const buildCookieString = (name, value, req, options = {}) => {
225
225
  return parts.join('; ');
226
226
  };
227
227
 
228
- export const readJsonBody = async (req, { maxBytes = 64 * 1024 } = {}) =>
228
+ export const readRawBody = async (req, { maxBytes = 64 * 1024 } = {}) =>
229
229
  new Promise((resolve, reject) => {
230
230
  const chunks = [];
231
231
  let total = 0;
@@ -243,20 +243,24 @@ export const readJsonBody = async (req, { maxBytes = 64 * 1024 } = {}) =>
243
243
  });
244
244
 
245
245
  req.on('end', () => {
246
- const raw = Buffer.concat(chunks).toString('utf8').trim();
247
- if (!raw) {
248
- resolve({});
249
- return;
250
- }
251
-
252
- try {
253
- resolve(JSON.parse(raw));
254
- } catch {
255
- const error = new Error('JSON invalido.');
256
- error.statusCode = 400;
257
- reject(error);
258
- }
246
+ resolve(Buffer.concat(chunks));
259
247
  });
260
248
 
261
249
  req.on('error', (error) => reject(error));
262
250
  });
251
+
252
+ export const readJsonBody = async (req, { maxBytes = 64 * 1024 } = {}) => {
253
+ const rawBuffer = await readRawBody(req, { maxBytes });
254
+ const raw = rawBuffer.toString('utf8').trim();
255
+ if (!raw) {
256
+ return {};
257
+ }
258
+
259
+ try {
260
+ return JSON.parse(raw);
261
+ } catch {
262
+ const error = new Error('JSON invalido.');
263
+ error.statusCode = 400;
264
+ throw error;
265
+ }
266
+ };
@@ -39,7 +39,9 @@ const HELMET_CSP_DIRECTIVES = {
39
39
  baseUri: ["'self'"],
40
40
  objectSrc: ["'none'"],
41
41
  frameAncestors: ["'self'"],
42
- formAction: ["'self'"],
42
+ // Google Identity Services usa submit interno para accounts.google.com/gsi/transform.
43
+ // Sem essa origem no form-action o login pode travar na etapa de transform.
44
+ formAction: ["'self'", 'https://accounts.google.com'],
43
45
  scriptSrc: ["'self'", "'unsafe-inline'", 'https://accounts.google.com', 'https://cdn.tailwindcss.com'],
44
46
  styleSrc: ["'self'", "'unsafe-inline'", 'https://fonts.googleapis.com', 'https://cdnjs.cloudflare.com'],
45
47
  imgSrc: ["'self'", 'data:', 'blob:', 'https:'],
@@ -71,6 +73,11 @@ const helmetMiddleware = helmet({
71
73
  directives: HELMET_CSP_DIRECTIVES,
72
74
  reportOnly: !HELMET_CSP_ENFORCE,
73
75
  },
76
+ // Fluxos OAuth/FedCM em popup (Google GIS) podem quebrar com COOP strict.
77
+ // Permitimos opener em popups mantendo isolamento para navegação principal.
78
+ crossOriginOpenerPolicy: {
79
+ policy: 'same-origin-allow-popups',
80
+ },
74
81
  crossOriginEmbedderPolicy: false,
75
82
  // Mantemos permissões explícitas para browser APIs sensíveis.
76
83
  permissionsPolicy: {
@@ -78,6 +85,7 @@ const helmetMiddleware = helmet({
78
85
  geolocation: [],
79
86
  microphone: [],
80
87
  camera: [],
88
+ 'identity-credentials-get': ['self'],
81
89
  },
82
90
  },
83
91
  });
@@ -86,7 +94,12 @@ const applyFallbackHeaders = (res) => {
86
94
  if (!res.getHeader('X-Content-Type-Options')) res.setHeader('X-Content-Type-Options', 'nosniff');
87
95
  if (!res.getHeader('X-Frame-Options')) res.setHeader('X-Frame-Options', 'DENY');
88
96
  if (!res.getHeader('Referrer-Policy')) res.setHeader('Referrer-Policy', 'strict-origin-when-cross-origin');
89
- if (!res.getHeader('Permissions-Policy')) res.setHeader('Permissions-Policy', 'geolocation=(), microphone=(), camera=()');
97
+ if (!res.getHeader('Permissions-Policy')) {
98
+ res.setHeader(
99
+ 'Permissions-Policy',
100
+ 'geolocation=(), microphone=(), camera=(), identity-credentials-get=(self)',
101
+ );
102
+ }
90
103
  if (FALLBACK_CSP_HEADER && !res.getHeader('Content-Security-Policy') && !res.getHeader('Content-Security-Policy-Report-Only')) {
91
104
  const cspHeaderName = HELMET_CSP_ENFORCE ? 'Content-Security-Policy' : 'Content-Security-Policy-Report-Only';
92
105
  res.setHeader(cspHeaderName, FALLBACK_CSP_HEADER);
@@ -3,6 +3,7 @@ import path from 'node:path';
3
3
  import { maybeHandleMetricsRequest } from './metrics/metricsRouter.js';
4
4
  import { maybeHandleHealthRequest, shouldHandleHealthPath } from './health/healthRouter.js';
5
5
  import { getEmailAutomationRouterConfig, maybeHandleEmailAutomationRequest, shouldHandleEmailAutomationPath } from './email/emailAutomationRouter.js';
6
+ import { getPaymentsRouterConfig, maybeHandlePaymentsRequest, shouldHandlePaymentsPath } from './payments/paymentsRouter.js';
6
7
  import { buildUserApiPaths, getUserRouterConfig, maybeHandleUserRequest, shouldHandleUserPath } from './user/userRouter.js';
7
8
  import { getSystemAdminRouterConfig, maybeHandleSystemAdminRequest, shouldHandleSystemAdminPath } from './admin/systemAdminRouter.js';
8
9
  import { getStickerSiteRouterConfig, maybeHandleStickerSiteRequest, shouldHandleStickerSitePath } from './sticker/stickerSiteRouter.js';
@@ -76,6 +77,16 @@ const loadEmailAutomationConfigSafe = async () => {
76
77
  }
77
78
  };
78
79
 
80
+ const loadPaymentsConfigSafe = async () => {
81
+ try {
82
+ return await getPaymentsRouterConfig();
83
+ } catch {
84
+ return {
85
+ apiBasePath: '/api/payments',
86
+ };
87
+ }
88
+ };
89
+
79
90
  const loadStickerSiteConfigSafe = async () => {
80
91
  try {
81
92
  return await getStickerSiteRouterConfig();
@@ -129,10 +140,11 @@ const loadGrafanaProxyConfigSafe = async () => {
129
140
 
130
141
  export const getIndexRouteConfigs = async () => {
131
142
  if (!indexRouteConfigsPromise) {
132
- indexRouteConfigsPromise = Promise.all([loadUserConfigSafe(), loadSystemAdminConfigSafe(), loadEmailAutomationConfigSafe(), loadStickerSiteConfigSafe(), loadStickerDataConfigSafe(), loadStickerApiConfigSafe(), loadGrafanaProxyConfigSafe()]).then(([userConfig, systemAdminConfig, emailAutomationConfig, stickerSiteConfig, stickerDataConfig, stickerApiConfig, grafanaProxyConfig]) => ({
143
+ indexRouteConfigsPromise = Promise.all([loadUserConfigSafe(), loadSystemAdminConfigSafe(), loadEmailAutomationConfigSafe(), loadPaymentsConfigSafe(), loadStickerSiteConfigSafe(), loadStickerDataConfigSafe(), loadStickerApiConfigSafe(), loadGrafanaProxyConfigSafe()]).then(([userConfig, systemAdminConfig, emailAutomationConfig, paymentsConfig, stickerSiteConfig, stickerDataConfig, stickerApiConfig, grafanaProxyConfig]) => ({
133
144
  userConfig,
134
145
  systemAdminConfig,
135
146
  emailAutomationConfig,
147
+ paymentsConfig,
136
148
  grafanaProxyConfig,
137
149
  stickerConfig: {
138
150
  ...stickerSiteConfig,
@@ -156,6 +168,7 @@ export const routeRequest = async (req, res, { pathname, url, metricsPath = '/me
156
168
  const userConfig = resolvedConfigs?.userConfig || null;
157
169
  const systemAdminConfig = resolvedConfigs?.systemAdminConfig || null;
158
170
  const emailAutomationConfig = resolvedConfigs?.emailAutomationConfig || null;
171
+ const paymentsConfig = resolvedConfigs?.paymentsConfig || null;
159
172
  const grafanaProxyConfig = resolvedConfigs?.grafanaProxyConfig || null;
160
173
  const stickerConfig = resolvedConfigs?.stickerConfig || null;
161
174
 
@@ -180,14 +193,21 @@ export const routeRequest = async (req, res, { pathname, url, metricsPath = '/me
180
193
  return sendNotFound(req, res);
181
194
  }
182
195
 
183
- // 4) Grafana proxy (/api/grafana e alias /grafana)
196
+ // 4) Payments API
197
+ if (shouldHandlePaymentsPath(pathname, paymentsConfig)) {
198
+ const handled = await maybeHandlePaymentsRequest(req, res, { pathname, url });
199
+ if (handled) return true;
200
+ return sendNotFound(req, res);
201
+ }
202
+
203
+ // 5) Grafana proxy (/api/grafana e alias /grafana)
184
204
  if (shouldHandleGrafanaProxyPath(pathname, grafanaProxyConfig)) {
185
205
  const handled = await maybeHandleGrafanaProxyRequest(req, res, { pathname, url, config: grafanaProxyConfig });
186
206
  if (handled) return true;
187
207
  return sendNotFound(req, res);
188
208
  }
189
209
 
190
- // 5) User
210
+ // 6) User
191
211
  const systemAdminCandidate = shouldHandleSystemAdminStep(pathname, systemAdminConfig);
192
212
  if (shouldHandleUserStep(pathname, userConfig)) {
193
213
  const handled = await maybeHandleUserRequest(req, res, { pathname, url });
@@ -197,14 +217,14 @@ export const routeRequest = async (req, res, { pathname, url, metricsPath = '/me
197
217
  if (!systemAdminCandidate) return sendNotFound(req, res);
198
218
  }
199
219
 
200
- // 6) System admin + legacy /stickers/admin
220
+ // 7) System admin + legacy /stickers/admin
201
221
  if (systemAdminCandidate) {
202
222
  const handled = await maybeHandleSystemAdminRequest(req, res, { pathname, url });
203
223
  if (handled) return true;
204
224
  return sendNotFound(req, res);
205
225
  }
206
226
 
207
- // 7) Sticker catalog apenas nos prefixes permitidos
227
+ // 8) Sticker catalog apenas nos prefixes permitidos
208
228
  if (shouldHandleStickerSitePath(pathname, stickerConfig)) {
209
229
  const handled = await maybeHandleStickerSiteRequest(req, res, { pathname, url });
210
230
  if (handled) return true;
@@ -236,14 +256,14 @@ export const routeRequest = async (req, res, { pathname, url, metricsPath = '/me
236
256
  return sendNotFound(req, res);
237
257
  }
238
258
 
239
- // 8) Paginas estaticas (templates em public/pages)
259
+ // 9) Paginas estaticas (templates em public/pages)
240
260
  if (shouldHandleStaticPagePath(pathname)) {
241
261
  const handled = await maybeHandleStaticPageRequest(req, res, { pathname });
242
262
  if (handled) return true;
243
263
  return sendNotFound(req, res);
244
264
  }
245
265
 
246
- // 9) 404 global
266
+ // 10) 404 global
247
267
  return sendNotFound(req, res);
248
268
  };
249
269