cds-error-outbox 1.0.0

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/README.md ADDED
@@ -0,0 +1,283 @@
1
+ # cds-error-outbox
2
+
3
+ A production-ready, reusable **SAP CAP** (Node.js) plugin that automatically captures service errors, deduplicates them, and sends batched HTML email notifications — with **zero production dependencies**.
4
+
5
+ ---
6
+
7
+ ## Features
8
+
9
+ - Hooks into **all CAP services** via `srv.on('error')` — zero manual wiring required
10
+ - Stores errors in a CDS-managed `error.outbox.Errors` DB entity (auto-deployed)
11
+ - **Deduplicates** by `SHA-256(message + service + action)` — increments count instead of creating duplicate rows
12
+ - Sends **batched HTML email reports** on a configurable interval
13
+ - Pluggable email providers: **O365 (Microsoft Graph API)**, **SMTP (nodemailer)**, **Mock (dev/test)**
14
+ - Fully configurable via `cds.env.requires.errorOutbox`
15
+ - **Non-blocking** — error capture is fire-and-forget; the request pipeline is never delayed
16
+ - Never crashes the application — all internal failures are logged and swallowed
17
+
18
+ ---
19
+
20
+ ## Installation
21
+
22
+ ```bash
23
+ npm install cds-error-outbox
24
+ ```
25
+
26
+ Because `package.json` declares `"cds": { "plugin": true }`, CAP automatically loads `index.js` at startup.
27
+
28
+ > **Important:** Due to how CAP resolves symlinked local packages, you must explicitly require the plugin in your project's `srv/server.js` (or root `server.js`):
29
+ >
30
+ > ```js
31
+ > require('cds-error-outbox'); // ← add as the very first line
32
+ > // ... rest of your server.js
33
+ > ```
34
+
35
+ ---
36
+
37
+ ## Configuration
38
+
39
+ Add the following to your project's `package.json` under `cds.requires`, or to `.cdsrc.json`:
40
+
41
+ ```json
42
+ {
43
+ "cds": {
44
+ "requires": {
45
+ "errorOutbox": {
46
+ "enabled": true,
47
+ "interval": 300000,
48
+ "batchSize": 50,
49
+ "dedup": {
50
+ "enabled": true,
51
+ "windowMinutes": 10
52
+ },
53
+ "mail": {
54
+ "provider": "o365",
55
+ "tenantId": "<YOUR_TENANT_ID>",
56
+ "clientId": "<YOUR_CLIENT_ID>",
57
+ "clientSecret": "<YOUR_CLIENT_SECRET>",
58
+ "from": "errors@yourcompany.com",
59
+ "to": "devops@yourcompany.com"
60
+ }
61
+ }
62
+ }
63
+ }
64
+ }
65
+ ```
66
+
67
+ ### Configuration reference
68
+
69
+ | Key | Type | Default | Description |
70
+ |---|---|---|---|
71
+ | `enabled` | boolean | `true` | Enable/disable the entire plugin |
72
+ | `interval` | number (ms) | `300000` | Batch email job frequency |
73
+ | `batchSize` | number | `50` | Max errors per email batch |
74
+ | `dedup.enabled` | boolean | `true` | Enable hash-based deduplication |
75
+ | `dedup.windowMinutes` | number | `10` | Rolling dedup window in minutes |
76
+ | `mail.provider` | string | `'mock'` | `'o365'` \| `'smtp'` \| `'mock'` |
77
+ | `mail.from` | string | `''` | Sender address |
78
+ | `mail.to` | string | `''` | Recipient(s), comma-separated |
79
+ | `mail.tenantId` | string | `''` | Azure AD tenant ID (O365 only) |
80
+ | `mail.clientId` | string | `''` | Azure AD app client ID (O365 only) |
81
+ | `mail.clientSecret` | string | `''` | Azure AD app client secret (O365 only) |
82
+ | `mail.smtp.host` | string | `''` | SMTP host (SMTP only) |
83
+ | `mail.smtp.port` | number | `587` | SMTP port (SMTP only) |
84
+ | `mail.smtp.secure` | boolean | `false` | Use TLS (SMTP only) |
85
+ | `mail.smtp.auth.user` | string | `''` | SMTP username (SMTP only) |
86
+ | `mail.smtp.auth.pass` | string | `''` | SMTP password (SMTP only) |
87
+
88
+ ---
89
+
90
+ ## Email Providers
91
+
92
+ ### `mock` _(default — development/testing)_
93
+
94
+ Logs the email subject and metadata to the console. No external calls. Use this during local development.
95
+
96
+ ### `o365` — Microsoft Graph API
97
+
98
+ Uses the [Microsoft Graph `sendMail` API](https://learn.microsoft.com/en-us/graph/api/user-sendmail) with an OAuth 2.0 **client credentials** flow. No user login is required. Zero additional npm dependencies.
99
+
100
+ #### O365 Setup
101
+
102
+ 1. Go to [Azure Portal → App Registrations](https://portal.azure.com/#blade/Microsoft_AAD_RegisteredApps) and click **New registration**
103
+ 2. Note the **Application (client) ID** and **Directory (tenant) ID**
104
+ 3. Go to **Certificates & Secrets** → **New client secret** — note the secret **Value** (shown once)
105
+ 4. Go to **API Permissions** → **Add a permission** → **Microsoft Graph** → **Application permissions**
106
+ - Add: `Mail.Send`
107
+ 5. Click **Grant admin consent** for your organisation
108
+ 6. The `mail.from` address must be a **licensed Exchange Online mailbox** that the app registration has permission to send from
109
+ 7. Configure `cds.requires.errorOutbox.mail`:
110
+
111
+ ```json
112
+ {
113
+ "provider": "o365",
114
+ "tenantId": "<Directory (tenant) ID>",
115
+ "clientId": "<Application (client) ID>",
116
+ "clientSecret": "<Client Secret value>",
117
+ "from": "errors@yourcompany.com",
118
+ "to": "devops@yourcompany.com"
119
+ }
120
+ ```
121
+
122
+ > **Security:** Never commit `clientSecret` to source control.
123
+ >
124
+ > The O365 provider reads credentials from **env variables as a fallback** — values in `package.json` take priority, but any missing field is automatically picked up from the environment:
125
+ >
126
+ > ```bash
127
+ > export CDS_REQUIRES_ERROROUTBOX_MAIL_TENANTID="xxxx"
128
+ > export CDS_REQUIRES_ERROROUTBOX_MAIL_CLIENTID="xxxx"
129
+ > export CDS_REQUIRES_ERROROUTBOX_MAIL_CLIENTSECRET="xxxx"
130
+ > export CDS_REQUIRES_ERROROUTBOX_MAIL_FROM="errors@company.com"
131
+ > export CDS_REQUIRES_ERROROUTBOX_MAIL_TO="devops@company.com"
132
+ > ```
133
+ >
134
+ > On BTP / Cloud Foundry use `cf set-env <app> CDS_REQUIRES_ERROROUTBOX_MAIL_CLIENTSECRET "<value>"` instead of putting the secret in `manifest.yml`.
135
+
136
+ ### `smtp`
137
+
138
+ Requires `nodemailer` (optional peer dependency):
139
+
140
+ ```bash
141
+ npm install nodemailer
142
+ ```
143
+
144
+ ```json
145
+ {
146
+ "provider": "smtp",
147
+ "from": "errors@yourcompany.com",
148
+ "to": "devops@yourcompany.com",
149
+ "smtp": {
150
+ "host": "smtp.yourcompany.com",
151
+ "port": 587,
152
+ "secure": false,
153
+ "auth": { "user": "smtp-user", "pass": "smtp-password" }
154
+ }
155
+ }
156
+ ```
157
+
158
+ ---
159
+
160
+ ## How it works
161
+
162
+ ```
163
+ CAP service throws an error
164
+
165
+
166
+ srv.on('error') — fire-and-forget via setImmediate (non-blocking)
167
+
168
+
169
+ SHA-256( message | service | action )
170
+
171
+ ┌────┴────┐
172
+ │ │
173
+ duplicate? new?
174
+ │ │
175
+ ▼ ▼
176
+ UPDATE INSERT
177
+ count+1 count=1
178
+ lastSeen firstSeen/lastSeen
179
+
180
+
181
+ setInterval every `interval` ms
182
+
183
+
184
+ SELECT sent=false LIMIT batchSize (oldest first)
185
+
186
+
187
+ Format HTML (grouped by service)
188
+
189
+
190
+ provider.send(...)
191
+
192
+ ▼ (only on success)
193
+ UPDATE sent=true WHERE ID IN [...]
194
+ ```
195
+
196
+ ---
197
+
198
+ ## DB Entity
199
+
200
+ The plugin automatically adds the following entity to your project's database schema:
201
+
202
+ ```cds
203
+ namespace error.outbox;
204
+
205
+ entity Errors {
206
+ key ID : UUID;
207
+ hash : String(64);
208
+ service : String;
209
+ action : String;
210
+ message : LargeString;
211
+ stack : LargeString;
212
+ count : Integer;
213
+ firstSeen : Timestamp;
214
+ lastSeen : Timestamp;
215
+ sent : Boolean default false;
216
+ }
217
+ ```
218
+
219
+ After installing the plugin, register the model in your project's `db/` folder. Create a file `db/error-outbox.cds`:
220
+
221
+ ```cds
222
+ using from '../plugins/cds-error-outbox/db/model';
223
+ ```
224
+
225
+ Then run deploy:
226
+
227
+ ```bash
228
+ cds deploy --to sqlite # local development
229
+ cds build # production (BTP, HANA)
230
+ ```
231
+
232
+ ---
233
+
234
+ ## Environment variables
235
+
236
+ CAP maps nested config paths to environment variables. You can override any value at runtime without changing `package.json`:
237
+
238
+ ```bash
239
+ CDS_REQUIRES_ERROROUTBOX_ENABLED=true
240
+ CDS_REQUIRES_ERROROUTBOX_INTERVAL=60000
241
+ CDS_REQUIRES_ERROROUTBOX_MAIL_PROVIDER=o365
242
+ CDS_REQUIRES_ERROROUTBOX_MAIL_TENANTID=...
243
+ CDS_REQUIRES_ERROROUTBOX_MAIL_CLIENTID=...
244
+ CDS_REQUIRES_ERROROUTBOX_MAIL_CLIENTSECRET=...
245
+ CDS_REQUIRES_ERROROUTBOX_MAIL_FROM=errors@yourcompany.com
246
+ CDS_REQUIRES_ERROROUTBOX_MAIL_TO=devops@yourcompany.com
247
+ ```
248
+
249
+ ---
250
+
251
+ ## Project structure
252
+
253
+ ```
254
+ cds-error-outbox/
255
+ ├── package.json ← CAP plugin declaration (cds.plugin: true)
256
+ ├── index.js ← Entry point — calls bootstrap.initialize()
257
+
258
+ ├── config/
259
+ │ └── defaults.js ← Default config values
260
+
261
+ ├── lib/
262
+ │ ├── bootstrap.js ← Init orchestration (cds lifecycle hooks)
263
+ │ ├── config.js ← Deep merge + config loader (singleton)
264
+ │ ├── interceptor.js ← srv.on('error') hook (fire-and-forget)
265
+ │ ├── dedup.js ← SHA-256 hash + DB upsert logic
266
+ │ ├── scheduler.js ← Interval batch job
267
+ │ └── formatter.js ← HTML email builder
268
+
269
+ ├── providers/
270
+ │ ├── index.js ← Provider factory
271
+ │ ├── o365.js ← Microsoft Graph API (zero extra deps)
272
+ │ ├── smtp.js ← nodemailer wrapper (optional peer dep)
273
+ │ └── mock.js ← Console logger (dev/test)
274
+
275
+ └── db/
276
+ └── model.cds ← error.outbox.Errors entity
277
+ ```
278
+
279
+ ---
280
+
281
+ ## License
282
+
283
+ MIT
@@ -0,0 +1,46 @@
1
+ 'use strict';
2
+
3
+ module.exports = {
4
+ enabled: true,
5
+
6
+ /** How often (ms) the batch job runs to send unsent errors. */
7
+ interval: 300000,
8
+
9
+ /** Maximum number of unsent errors to include per email batch. */
10
+ batchSize: 50,
11
+
12
+ dedup: {
13
+ /** Whether to deduplicate errors by hash within the rolling window. */
14
+ enabled: true,
15
+
16
+ /** Only deduplicate within this time window (minutes). */
17
+ windowMinutes: 10
18
+ },
19
+
20
+ mail: {
21
+ /** Email provider: 'o365' | 'smtp' | 'mock' */
22
+ provider: 'mock',
23
+
24
+ /** Sender address (required for o365 and smtp). */
25
+ from: '',
26
+
27
+ /** Recipient address(es) — comma-separated for multiple (required for o365 and smtp). */
28
+ to: '',
29
+
30
+ /** === O365 / Microsoft Graph API options === */
31
+ tenantId: '',
32
+ clientId: '',
33
+ clientSecret: '',
34
+
35
+ /** === SMTP options (used only when provider = 'smtp') === */
36
+ smtp: {
37
+ host: '',
38
+ port: 587,
39
+ secure: false,
40
+ auth: {
41
+ user: '',
42
+ pass: ''
43
+ }
44
+ }
45
+ }
46
+ };
package/db/model.cds ADDED
@@ -0,0 +1,18 @@
1
+ namespace error.outbox;
2
+
3
+ entity Errors {
4
+ key ID : UUID;
5
+
6
+ hash : String(64);
7
+ service : String;
8
+ action : String;
9
+
10
+ message : LargeString;
11
+ stack : LargeString;
12
+
13
+ count : Integer;
14
+ firstSeen : Timestamp;
15
+ lastSeen : Timestamp;
16
+
17
+ sent : Boolean default false;
18
+ }
package/index.cds ADDED
@@ -0,0 +1 @@
1
+ using from './db/model';
package/index.js ADDED
@@ -0,0 +1,14 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * cds-error-outbox — CAP plugin entry point.
5
+ *
6
+ * CAP automatically requires this file when the package is installed,
7
+ * because package.json declares `"cds": { "plugin": true }`.
8
+ *
9
+ * The bootstrap.initialize() call registers CAP lifecycle listeners
10
+ * (cds.on('serving') and cds.on('served')) so no blocking work happens here.
11
+ */
12
+ const { initialize } = require('./lib/bootstrap');
13
+
14
+ initialize();
@@ -0,0 +1,71 @@
1
+ 'use strict';
2
+
3
+ const cds = require('@sap/cds');
4
+ const { loadConfig } = require('./config');
5
+ const { attach } = require('./interceptor');
6
+ const { start } = require('./scheduler');
7
+ const { getProvider } = require('../providers');
8
+
9
+ /**
10
+ * Initialize the cds-error-outbox plugin.
11
+ *
12
+ * Called once at plugin load time (from index.js).
13
+ * Uses CAP lifecycle events to ensure initialization order:
14
+ *
15
+ * cds.on('serving') → attach error interceptor to each served service
16
+ * cds.on('served') → start scheduler (DB is fully connected at this point)
17
+ */
18
+ function initialize() {
19
+ // ── Load and validate config ─────────────────────────────────────────────
20
+ let config;
21
+ try {
22
+ config = loadConfig();
23
+ } catch (err) {
24
+ console.error('[cds-error-outbox] Failed to load config — plugin disabled:', err.message);
25
+ return;
26
+ }
27
+
28
+ if (!config.enabled) {
29
+ console.info('[cds-error-outbox] Plugin is disabled (config.enabled = false).');
30
+ return;
31
+ }
32
+
33
+ // ── Resolve email provider ───────────────────────────────────────────────
34
+ let provider;
35
+ try {
36
+ provider = getProvider(config);
37
+ } catch (err) {
38
+ console.error('[cds-error-outbox] Failed to load email provider — plugin disabled:', err.message);
39
+ return;
40
+ }
41
+
42
+ // ── Attach interceptor to every served service ───────────────────────────
43
+ cds.on('serving', (srv) => {
44
+ try {
45
+ attach(srv, config);
46
+ } catch (err) {
47
+ console.error(
48
+ `[cds-error-outbox] Failed to attach interceptor to service "${srv.name}":`,
49
+ err.message
50
+ );
51
+ }
52
+ });
53
+
54
+ // ── Start scheduler after all services + DB are ready ───────────────────
55
+ // cds.on('served') fires once after ALL services have been bootstrapped
56
+ // and cds.db is guaranteed to be available.
57
+ cds.on('served', () => {
58
+ try {
59
+ start(config, provider);
60
+ } catch (err) {
61
+ console.error('[cds-error-outbox] Failed to start scheduler:', err.message);
62
+ }
63
+ });
64
+
65
+ console.info(
66
+ `[cds-error-outbox] Plugin initialized — ` +
67
+ `provider: ${config.mail.provider}, interval: ${config.interval}ms`
68
+ );
69
+ }
70
+
71
+ module.exports = { initialize };
package/lib/config.js ADDED
@@ -0,0 +1,67 @@
1
+ 'use strict';
2
+
3
+ const cds = require('@sap/cds');
4
+ const defaults = require('../config/defaults');
5
+
6
+ let _config = null;
7
+
8
+ /**
9
+ * Recursively deep-merge source into target.
10
+ * Arrays in source replace arrays in target (no merge).
11
+ *
12
+ * @param {Object} target
13
+ * @param {Object} source
14
+ * @returns {Object}
15
+ */
16
+ function deepMerge(target, source) {
17
+ if (!source || typeof source !== 'object') return target;
18
+
19
+ const result = Object.assign({}, target);
20
+
21
+ for (const key of Object.keys(source)) {
22
+ const srcVal = source[key];
23
+ const tgtVal = result[key];
24
+
25
+ if (
26
+ srcVal !== null &&
27
+ typeof srcVal === 'object' &&
28
+ !Array.isArray(srcVal) &&
29
+ tgtVal !== null &&
30
+ typeof tgtVal === 'object' &&
31
+ !Array.isArray(tgtVal)
32
+ ) {
33
+ result[key] = deepMerge(tgtVal, srcVal);
34
+ } else if (srcVal !== undefined) {
35
+ result[key] = srcVal;
36
+ }
37
+ }
38
+
39
+ return result;
40
+ }
41
+
42
+ /**
43
+ * Load and cache the merged config (defaults + cds.env.requires.errorOutbox).
44
+ * @returns {Object}
45
+ */
46
+ function loadConfig() {
47
+ if (_config) return _config;
48
+
49
+ const userConfig =
50
+ (cds.env &&
51
+ cds.env.requires &&
52
+ cds.env.requires.errorOutbox) ||
53
+ {};
54
+
55
+ _config = deepMerge(defaults, userConfig);
56
+ return _config;
57
+ }
58
+
59
+ /**
60
+ * Reset the cached config singleton.
61
+ * Useful in tests to reload config between test cases.
62
+ */
63
+ function resetConfig() {
64
+ _config = null;
65
+ }
66
+
67
+ module.exports = { loadConfig, resetConfig, deepMerge };
package/lib/dedup.js ADDED
@@ -0,0 +1,91 @@
1
+ 'use strict';
2
+
3
+ const crypto = require('crypto');
4
+
5
+ const ENTITY = 'error.outbox.Errors';
6
+
7
+ // Truncation limits to prevent oversized DB entries
8
+ const MAX_MESSAGE_LEN = 5000;
9
+ const MAX_STACK_LEN = 10000;
10
+
11
+ /**
12
+ * Creates a deterministic SHA-256 hash from the error's message, service, and action.
13
+ * Used as the dedup key.
14
+ *
15
+ * @param {string} message
16
+ * @param {string} service
17
+ * @param {string} action
18
+ * @returns {string} 64-character hex string
19
+ */
20
+ function createHash(message, service, action) {
21
+ return crypto
22
+ .createHash('sha256')
23
+ .update(`${message}|${service}|${action}`)
24
+ .digest('hex');
25
+ }
26
+
27
+ /**
28
+ * Find an existing unsent error record matching the given hash within the dedup window.
29
+ *
30
+ * @param {object} db - connected cds.db instance
31
+ * @param {string} hash
32
+ * @param {number} windowMinutes
33
+ * @returns {Promise<object|null>}
34
+ */
35
+ async function findExistingError(db, hash, windowMinutes) {
36
+ const cutoff = new Date(Date.now() - windowMinutes * 60 * 1000).toISOString();
37
+
38
+ const results = await db.run(
39
+ SELECT.from(ENTITY)
40
+ .where({ hash })
41
+ .and('lastSeen >', cutoff)
42
+ .and({ sent: false })
43
+ .limit(1)
44
+ );
45
+
46
+ return results && results.length > 0 ? results[0] : null;
47
+ }
48
+
49
+ /**
50
+ * Insert a new error row or increment the count on an existing one.
51
+ * Deduplication is based on the hash + dedup window + sent=false.
52
+ *
53
+ * @param {object} db - connected cds.db instance
54
+ * @param {object} errorData - { message, stack, service, action }
55
+ * @param {object} config - merged plugin config
56
+ */
57
+ async function upsertError(db, errorData, config) {
58
+ const { message, stack, service, action } = errorData;
59
+
60
+ const hash = createHash(message, service, action);
61
+ const now = new Date().toISOString();
62
+
63
+ const existing = config.dedup.enabled
64
+ ? await findExistingError(db, hash, config.dedup.windowMinutes)
65
+ : null;
66
+
67
+ if (existing) {
68
+ await db.run(
69
+ UPDATE(ENTITY)
70
+ .set({ count: existing.count + 1, lastSeen: now })
71
+ .where({ ID: existing.ID })
72
+ );
73
+ } else {
74
+ await db.run(
75
+ INSERT.into(ENTITY).entries({
76
+ ID: crypto.randomUUID(),
77
+ hash,
78
+ service: String(service || 'unknown'),
79
+ action: String(action || 'unknown'),
80
+ message: message ? String(message).substring(0, MAX_MESSAGE_LEN) : '',
81
+ stack: stack ? String(stack).substring(0, MAX_STACK_LEN) : '',
82
+ count: 1,
83
+ firstSeen: now,
84
+ lastSeen: now,
85
+ sent: false
86
+ })
87
+ );
88
+ }
89
+ }
90
+
91
+ module.exports = { createHash, findExistingError, upsertError };
@@ -0,0 +1,214 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Escape special HTML characters to prevent injection in the email body.
5
+ *
6
+ * @param {string} str
7
+ * @returns {string}
8
+ */
9
+ function escapeHtml(str) {
10
+ return String(str)
11
+ .replace(/&/g, '&amp;')
12
+ .replace(/</g, '&lt;')
13
+ .replace(/>/g, '&gt;')
14
+ .replace(/"/g, '&quot;')
15
+ .replace(/'/g, '&#039;');
16
+ }
17
+
18
+ /**
19
+ * Build an HTML email body from an array of error records, grouped by service.
20
+ *
21
+ * @param {object[]} errors - array of error.outbox.Errors records
22
+ * @returns {{ subject: string, html: string }}
23
+ */
24
+ function formatHtmlEmail(errors) {
25
+ const timestamp = new Date().toISOString();
26
+ const total = errors.reduce((sum, e) => sum + (e.count || 1), 0);
27
+
28
+ // Group records by service name
29
+ const groups = {};
30
+ for (const err of errors) {
31
+ const key = err.service || 'unknown';
32
+ if (!groups[key]) groups[key] = [];
33
+ groups[key].push(err);
34
+ }
35
+
36
+ const subject = `[CAP Error Outbox] ${total} occurrence(s) in ${errors.length} error(s) — ${timestamp}`;
37
+
38
+ const fmt = (ts) => ts ? String(ts).replace('T', ' ').replace(/\.\d{3}Z$/, ' UTC') : '-';
39
+
40
+ // ── Outlook-safe HTML email ──────────────────────────────────────────────
41
+ // Rules applied:
42
+ // 1. Every colored cell has BOTH bgcolor="" attribute AND style="background:"
43
+ // 2. Text colors use style="color:" on the immediate element (not parent)
44
+ // 3. No border-radius, no box-shadow, no gradients, no flexbox
45
+ // 4. display:block only via <p> tags, never on <span>
46
+ // 5. Table widths via width="" attribute, not just CSS
47
+ // 6. valign/align attributes used alongside CSS vertical-align/text-align
48
+
49
+ let html = `<!DOCTYPE html>
50
+ <html lang="en" xmlns:v="urn:schemas-microsoft-com:vml" xmlns:o="urn:schemas-microsoft-com:office:office">
51
+ <head>
52
+ <meta charset="UTF-8">
53
+ <meta name="viewport" content="width=device-width,initial-scale=1">
54
+ <meta name="color-scheme" content="light only">
55
+ <meta name="supported-color-schemes" content="light only">
56
+ <!--[if mso]><noscript><xml><o:OfficeDocumentSettings><o:AllowPNG/><o:PixelsPerInch>96</o:PixelsPerInch></o:OfficeDocumentSettings></xml></noscript><![endif]-->
57
+ <style>
58
+ body, table, td, p, a { -webkit-text-size-adjust:100%; -ms-text-size-adjust:100%; }
59
+ table, td { mso-table-lspace:0pt; mso-table-rspace:0pt; border-collapse:collapse; }
60
+ img { border:0; outline:none; text-decoration:none; }
61
+ /* Prevent Outlook dark mode */
62
+ [data-ogsc] body, [data-ogsb] body { background:#f4f4f4 !important; color:#222222 !important; }
63
+ </style>
64
+ </head>
65
+ <body style="margin:0;padding:0;background-color:#f4f4f4;" bgcolor="#f4f4f4">
66
+
67
+ <table width="100%" cellpadding="0" cellspacing="0" border="0" bgcolor="#f4f4f4" style="background-color:#f4f4f4;">
68
+ <tr>
69
+ <td align="center" style="padding:20px 10px;">
70
+
71
+ <!-- WRAPPER -->
72
+ <table width="700" cellpadding="0" cellspacing="0" border="0" style="width:700px;background-color:#ffffff;" bgcolor="#ffffff">
73
+
74
+ <!-- HEADER -->
75
+ <tr>
76
+ <td bgcolor="#b03a2e" style="background-color:#b03a2e;padding:24px 28px 20px;" align="left">
77
+ <p style="margin:0;font-family:Arial,Helvetica,sans-serif;font-size:20px;font-weight:bold;color:#ffffff;line-height:1.2;">CAP Error Outbox Report</p>
78
+ <p style="margin:6px 0 0;font-family:Arial,Helvetica,sans-serif;font-size:11px;color:#f4c7c3;line-height:1;">Generated: ${escapeHtml(timestamp)}</p>
79
+ </td>
80
+ </tr>
81
+
82
+ <!-- STATS -->
83
+ <tr>
84
+ <td bgcolor="#ffffff" style="background-color:#ffffff;padding:0;border-bottom:2px solid #e8e8e8;">
85
+ <table width="100%" cellpadding="0" cellspacing="0" border="0">
86
+ <tr>
87
+ <td width="33%" align="center" bgcolor="#ffffff" style="background-color:#ffffff;padding:16px 10px;border-right:1px solid #e8e8e8;">
88
+ <p style="margin:0;font-family:Arial,Helvetica,sans-serif;font-size:26px;font-weight:bold;color:#b03a2e;line-height:1;">${errors.length}</p>
89
+ <p style="margin:4px 0 0;font-family:Arial,Helvetica,sans-serif;font-size:10px;color:#888888;text-transform:uppercase;letter-spacing:0.5px;line-height:1;">Distinct Errors</p>
90
+ </td>
91
+ <td width="34%" align="center" bgcolor="#ffffff" style="background-color:#ffffff;padding:16px 10px;border-right:1px solid #e8e8e8;">
92
+ <p style="margin:0;font-family:Arial,Helvetica,sans-serif;font-size:26px;font-weight:bold;color:#b03a2e;line-height:1;">${total}</p>
93
+ <p style="margin:4px 0 0;font-family:Arial,Helvetica,sans-serif;font-size:10px;color:#888888;text-transform:uppercase;letter-spacing:0.5px;line-height:1;">Total Occurrences</p>
94
+ </td>
95
+ <td width="33%" align="center" bgcolor="#ffffff" style="background-color:#ffffff;padding:16px 10px;">
96
+ <p style="margin:0;font-family:Arial,Helvetica,sans-serif;font-size:26px;font-weight:bold;color:#b03a2e;line-height:1;">${Object.keys(groups).length}</p>
97
+ <p style="margin:4px 0 0;font-family:Arial,Helvetica,sans-serif;font-size:10px;color:#888888;text-transform:uppercase;letter-spacing:0.5px;line-height:1;">Services Affected</p>
98
+ </td>
99
+ </tr>
100
+ </table>
101
+ </td>
102
+ </tr>\n`;
103
+
104
+ for (const [service, serviceErrors] of Object.entries(groups)) {
105
+ html += `
106
+ <!-- SERVICE: ${escapeHtml(service)} -->
107
+ <tr>
108
+ <td bgcolor="#ffffff" style="background-color:#ffffff;padding:20px 28px 4px;">
109
+
110
+ <!-- Service label -->
111
+ <table width="100%" cellpadding="0" cellspacing="0" border="0" style="margin-bottom:10px;">
112
+ <tr>
113
+ <td bgcolor="#f8f8f8" style="background-color:#f8f8f8;border-left:3px solid #b03a2e;padding:7px 12px;">
114
+ <p style="margin:0;font-family:Arial,Helvetica,sans-serif;font-size:11px;font-weight:bold;color:#333333;text-transform:uppercase;letter-spacing:0.7px;">${escapeHtml(service)}</p>
115
+ </td>
116
+ </tr>
117
+ </table>
118
+
119
+ <!-- Error table -->
120
+ <table width="100%" cellpadding="0" cellspacing="0" border="0" style="border:1px solid #e8e8e8;margin-bottom:16px;">
121
+ <!-- Table header -->
122
+ <tr>
123
+ <td width="24" align="center" bgcolor="#2c3e50" style="background-color:#2c3e50;padding:8px 6px;border-right:1px solid #3d5166;">
124
+ <p style="margin:0;font-family:Arial,Helvetica,sans-serif;font-size:10px;font-weight:bold;color:#ffffff;text-transform:uppercase;">#</p>
125
+ </td>
126
+ <td width="110" bgcolor="#2c3e50" style="background-color:#2c3e50;padding:8px 10px;border-right:1px solid #3d5166;">
127
+ <p style="margin:0;font-family:Arial,Helvetica,sans-serif;font-size:10px;font-weight:bold;color:#ffffff;text-transform:uppercase;letter-spacing:0.4px;">Action</p>
128
+ </td>
129
+ <td bgcolor="#2c3e50" style="background-color:#2c3e50;padding:8px 10px;border-right:1px solid #3d5166;">
130
+ <p style="margin:0;font-family:Arial,Helvetica,sans-serif;font-size:10px;font-weight:bold;color:#ffffff;text-transform:uppercase;letter-spacing:0.4px;">Message</p>
131
+ </td>
132
+ <td width="44" align="center" bgcolor="#2c3e50" style="background-color:#2c3e50;padding:8px 6px;border-right:1px solid #3d5166;">
133
+ <p style="margin:0;font-family:Arial,Helvetica,sans-serif;font-size:10px;font-weight:bold;color:#ffffff;text-transform:uppercase;">Cnt</p>
134
+ </td>
135
+ <td width="118" bgcolor="#2c3e50" style="background-color:#2c3e50;padding:8px 10px;border-right:1px solid #3d5166;">
136
+ <p style="margin:0;font-family:Arial,Helvetica,sans-serif;font-size:10px;font-weight:bold;color:#ffffff;text-transform:uppercase;letter-spacing:0.4px;">First Seen</p>
137
+ </td>
138
+ <td width="118" bgcolor="#2c3e50" style="background-color:#2c3e50;padding:8px 10px;">
139
+ <p style="margin:0;font-family:Arial,Helvetica,sans-serif;font-size:10px;font-weight:bold;color:#ffffff;text-transform:uppercase;letter-spacing:0.4px;">Last Seen</p>
140
+ </td>
141
+ </tr>\n`;
142
+
143
+ serviceErrors.forEach((err, idx) => {
144
+ const count = err.count || 1;
145
+ const rowBg = idx % 2 === 1 ? '#f9f9f9' : '#ffffff';
146
+ const bdrClr = '#e8e8e8';
147
+
148
+ html += ` <!-- Row ${idx + 1} -->
149
+ <tr>
150
+ <td align="center" valign="top" bgcolor="${rowBg}" style="background-color:${rowBg};padding:9px 6px;border-top:1px solid ${bdrClr};border-right:1px solid ${bdrClr};">
151
+ <p style="margin:0;font-family:Arial,Helvetica,sans-serif;font-size:11px;color:#aaaaaa;">${idx + 1}</p>
152
+ </td>
153
+ <td valign="top" bgcolor="${rowBg}" style="background-color:${rowBg};padding:9px 10px;border-top:1px solid ${bdrClr};border-right:1px solid ${bdrClr};">
154
+ <p style="margin:0;font-family:Consolas,'Courier New',monospace;font-size:11px;color:#2c6fad;background-color:#eef4fb;padding:1px 5px;display:inline-block;">${escapeHtml(err.action || 'unknown')}</p>
155
+ </td>
156
+ <td valign="top" bgcolor="${rowBg}" style="background-color:${rowBg};padding:9px 10px;border-top:1px solid ${bdrClr};border-right:1px solid ${bdrClr};">
157
+ <p style="margin:0;font-family:Arial,Helvetica,sans-serif;font-size:12px;font-weight:bold;color:#b03a2e;">${escapeHtml(err.message || '-')}</p>
158
+ </td>
159
+ <td align="center" valign="top" bgcolor="${rowBg}" style="background-color:${rowBg};padding:9px 6px;border-top:1px solid ${bdrClr};border-right:1px solid ${bdrClr};">
160
+ <p style="margin:0;font-family:Arial,Helvetica,sans-serif;font-size:12px;font-weight:bold;color:#ffffff;background-color:${count >= 10 ? '#922b21' : '#c0392b'};padding:1px 7px;text-align:center;">${count}</p>
161
+ </td>
162
+ <td valign="top" bgcolor="${rowBg}" style="background-color:${rowBg};padding:9px 10px;border-top:1px solid ${bdrClr};border-right:1px solid ${bdrClr};">
163
+ <p style="margin:0;font-family:Consolas,'Courier New',monospace;font-size:10px;color:#888888;white-space:nowrap;">${escapeHtml(fmt(err.firstSeen))}</p>
164
+ </td>
165
+ <td valign="top" bgcolor="${rowBg}" style="background-color:${rowBg};padding:9px 10px;border-top:1px solid ${bdrClr};">
166
+ <p style="margin:0;font-family:Consolas,'Courier New',monospace;font-size:10px;color:#888888;white-space:nowrap;">${escapeHtml(fmt(err.lastSeen))}</p>
167
+ </td>
168
+ </tr>\n`;
169
+
170
+ // Stack trace as a separate full-width row
171
+ if (err.stack) {
172
+ const stackExcerpt = err.stack.split('\n').slice(0, 5).join('\n');
173
+ html += ` <tr>
174
+ <td valign="top" bgcolor="#fafafa" style="background-color:#fafafa;padding:0 0 0 24px;border-top:none;" colspan="6">
175
+ <table width="100%" cellpadding="0" cellspacing="0" border="0">
176
+ <tr>
177
+ <td bgcolor="#f2f2f2" style="background-color:#f2f2f2;padding:7px 10px;border-top:1px dashed #dddddd;border-left:3px solid #dddddd;">
178
+ <p style="margin:0;font-family:Consolas,'Courier New',monospace;font-size:10px;color:#666666;white-space:pre-wrap;line-height:1.5;">${escapeHtml(stackExcerpt)}</p>
179
+ </td>
180
+ </tr>
181
+ </table>
182
+ </td>
183
+ </tr>\n`;
184
+ }
185
+ });
186
+
187
+ html += ` </table>
188
+
189
+ </td>
190
+ </tr>\n`;
191
+ }
192
+
193
+ html += `
194
+ <!-- FOOTER -->
195
+ <tr>
196
+ <td bgcolor="#f4f4f4" style="background-color:#f4f4f4;padding:14px 28px;border-top:1px solid #e8e8e8;" align="center">
197
+ <p style="margin:0;font-family:Arial,Helvetica,sans-serif;font-size:10px;color:#aaaaaa;">Sent by <strong>cds-error-outbox</strong> &mdash; these errors are marked as sent and will not be repeated until new occurrences are captured.</p>
198
+ </td>
199
+ </tr>
200
+
201
+ </table>
202
+ <!-- /WRAPPER -->
203
+
204
+ </td>
205
+ </tr>
206
+ </table>
207
+
208
+ </body>
209
+ </html>`;
210
+
211
+ return { subject, html };
212
+ }
213
+
214
+ module.exports = { formatHtmlEmail, escapeHtml };
@@ -0,0 +1,48 @@
1
+ 'use strict';
2
+
3
+ const cds = require('@sap/cds');
4
+ const { upsertError } = require('./dedup');
5
+
6
+ /**
7
+ * Attach a fire-and-forget error interceptor to the given CDS service.
8
+ * The handler never blocks or delays the request pipeline — errors are
9
+ * persisted to the DB asynchronously via setImmediate.
10
+ *
11
+ * @param {object} srv - CDS service instance
12
+ * @param {object} config - merged plugin config
13
+ */
14
+ function attach(srv, config) {
15
+ srv.on('error', (err, req) => {
16
+ // setImmediate defers DB work to after the current event loop tick,
17
+ // ensuring the error response is sent to the client without any delay.
18
+ setImmediate(async () => {
19
+ try {
20
+ const message = (err && err.message) ? String(err.message) : String(err);
21
+ const stack = (err && err.stack) ? String(err.stack) : '';
22
+ const service = srv.name || 'unknown';
23
+ const action =
24
+ (req && req.event) ? req.event :
25
+ (req && req.path) ? req.path :
26
+ (req && req.method) ? req.method :
27
+ 'unknown';
28
+
29
+ // cds.tx() opens a brand-new root-level transaction that is completely
30
+ // independent of the failed request's already-rolled-back transaction.
31
+ // Without this, CAP's AsyncLocalStorage would propagate the dead
32
+ // transaction context into our INSERT/UPDATE, causing the
33
+ // "Transaction is rolled back" error.
34
+ await cds.tx(async (tx) => {
35
+ await upsertError(tx, { message, stack, service, action }, config);
36
+ });
37
+ } catch (internalError) {
38
+ // Never re-throw — log internally to avoid crashing the app.
39
+ console.error(
40
+ `[cds-error-outbox] Failed to persist error from service "${srv.name}":`,
41
+ internalError.message
42
+ );
43
+ }
44
+ });
45
+ });
46
+ }
47
+
48
+ module.exports = { attach };
@@ -0,0 +1,125 @@
1
+ 'use strict';
2
+
3
+ const cds = require('@sap/cds');
4
+ const { formatHtmlEmail } = require('./formatter');
5
+
6
+ const ENTITY = 'error.outbox.Errors';
7
+
8
+ let _handle = null;
9
+
10
+ /**
11
+ * Start the interval-based batch scheduler.
12
+ *
13
+ * @param {object} config - merged plugin config
14
+ * @param {object} provider - email provider instance ({ send: Function })
15
+ * @returns {NodeJS.Timeout} interval handle (can be passed to stop())
16
+ */
17
+ function start(config, provider) {
18
+ if (_handle) {
19
+ clearInterval(_handle);
20
+ _handle = null;
21
+ }
22
+
23
+ _handle = setInterval(() => {
24
+ runBatch(config, provider).catch((err) => {
25
+ // runBatch already guards internally; this is a last-resort safety net.
26
+ console.error('[cds-error-outbox] Unhandled error in runBatch:', err.message);
27
+ });
28
+ }, config.interval);
29
+
30
+ // Allow Node.js process to exit gracefully even if the interval is active.
31
+ if (typeof _handle.unref === 'function') _handle.unref();
32
+
33
+ console.info(
34
+ `[cds-error-outbox] Scheduler started — ` +
35
+ `interval: ${config.interval}ms, batchSize: ${config.batchSize}`
36
+ );
37
+
38
+ return _handle;
39
+ }
40
+
41
+ /**
42
+ * Stop the scheduler. Safe to call even if not started.
43
+ */
44
+ function stop() {
45
+ if (_handle) {
46
+ clearInterval(_handle);
47
+ _handle = null;
48
+ console.info('[cds-error-outbox] Scheduler stopped.');
49
+ }
50
+ }
51
+
52
+ /**
53
+ * Core batch routine:
54
+ * 1. Fetch unsent errors from DB (up to batchSize, oldest first).
55
+ * 2. Format them into an HTML email.
56
+ * 3. Send via the configured email provider.
57
+ * 4. Mark successfully sent records as sent=true.
58
+ *
59
+ * Each step is independently guarded so a failure in one step does not
60
+ * prevent the scheduler from running again on the next interval.
61
+ *
62
+ * @param {object} config
63
+ * @param {object} provider
64
+ */
65
+ async function runBatch(config, provider) {
66
+ // ── 1. Obtain DB connection ──────────────────────────────────────────────
67
+ let db;
68
+ try {
69
+ db = await cds.connect.to('db');
70
+ } catch (err) {
71
+ console.error('[cds-error-outbox] Cannot connect to DB — skipping batch:', err.message);
72
+ return;
73
+ }
74
+
75
+ // ── 2. Fetch unsent errors ───────────────────────────────────────────────
76
+ let errors;
77
+ try {
78
+ errors = await db.run(
79
+ SELECT.from(ENTITY)
80
+ .where({ sent: false })
81
+ .orderBy('lastSeen asc')
82
+ .limit(config.batchSize)
83
+ );
84
+ } catch (err) {
85
+ console.error('[cds-error-outbox] Failed to query unsent errors:', err.message);
86
+ return;
87
+ }
88
+
89
+ if (!errors || errors.length === 0) return;
90
+
91
+ // ── 3. Format email ──────────────────────────────────────────────────────
92
+ let subject, html;
93
+ try {
94
+ ({ subject, html } = formatHtmlEmail(errors));
95
+ } catch (err) {
96
+ console.error('[cds-error-outbox] Failed to format email body:', err.message);
97
+ return;
98
+ }
99
+
100
+ // ── 4. Send email ────────────────────────────────────────────────────────
101
+ try {
102
+ await provider.send(config.mail, subject, html);
103
+ } catch (err) {
104
+ // Do NOT mark as sent — will be retried on the next interval.
105
+ console.error(
106
+ `[cds-error-outbox] Email delivery failed (will retry on next interval): ${err.message}`
107
+ );
108
+ return;
109
+ }
110
+
111
+ // ── 5. Mark as sent (only after confirmed delivery) ──────────────────────
112
+ const ids = errors.map((e) => e.ID);
113
+ try {
114
+ await db.run(
115
+ UPDATE(ENTITY)
116
+ .set({ sent: true })
117
+ .where({ ID: { in: ids } })
118
+ );
119
+ console.info(`[cds-error-outbox] Batch complete — marked ${ids.length} error(s) as sent.`);
120
+ } catch (err) {
121
+ console.error('[cds-error-outbox] Failed to mark errors as sent:', err.message);
122
+ }
123
+ }
124
+
125
+ module.exports = { start, stop, runBatch };
package/package.json ADDED
@@ -0,0 +1,34 @@
1
+ {
2
+ "name": "cds-error-outbox",
3
+ "version": "1.0.0",
4
+ "description": "A reusable CAP plugin that captures service errors, deduplicates them, and sends batched email notifications.",
5
+ "main": "index.js",
6
+ "cds": {
7
+ "plugin": true,
8
+ "requires": {
9
+ "cds-error-outbox": {
10
+ "model": "cds-error-outbox"
11
+ }
12
+ }
13
+ },
14
+ "keywords": [
15
+ "sap-cap",
16
+ "cds",
17
+ "plugin",
18
+ "error",
19
+ "outbox",
20
+ "email"
21
+ ],
22
+ "license": "MIT",
23
+ "engines": {
24
+ "node": ">=18"
25
+ },
26
+ "peerDependencies": {
27
+ "@sap/cds": ">=7"
28
+ },
29
+ "peerDependenciesMeta": {
30
+ "nodemailer": {
31
+ "optional": true
32
+ }
33
+ }
34
+ }
@@ -0,0 +1,40 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Email provider factory.
5
+ *
6
+ * Returns a provider object with a `send(mailConfig, subject, html)` function
7
+ * based on config.mail.provider. Falls back to 'mock' for unknown values.
8
+ *
9
+ * Supported providers:
10
+ * 'o365' — Microsoft Graph API (client credentials, no extra deps)
11
+ * 'smtp' — nodemailer (optional peer dep: npm install nodemailer)
12
+ * 'mock' — console.log only, safe for development/testing
13
+ *
14
+ * @param {object} config - merged plugin config
15
+ * @returns {{ send: Function }}
16
+ */
17
+ function getProvider(config) {
18
+ const providerName = (config.mail && config.mail.provider)
19
+ ? String(config.mail.provider).toLowerCase()
20
+ : 'mock';
21
+
22
+ switch (providerName) {
23
+ case 'o365':
24
+ return require('./o365');
25
+
26
+ case 'smtp':
27
+ return require('./smtp');
28
+
29
+ case 'mock':
30
+ return require('./mock');
31
+
32
+ default:
33
+ console.warn(
34
+ `[cds-error-outbox] Unknown email provider "${providerName}". Falling back to mock.`
35
+ );
36
+ return require('./mock');
37
+ }
38
+ }
39
+
40
+ module.exports = { getProvider };
@@ -0,0 +1,23 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Mock email provider — logs to console only.
5
+ * No external calls are made.
6
+ *
7
+ * Use this in development and test environments to verify the plugin
8
+ * is capturing and formatting errors without needing real mail credentials.
9
+ *
10
+ * @param {object} mailConfig
11
+ * @param {string} subject
12
+ * @param {string} html
13
+ */
14
+ async function send(mailConfig, subject, html) {
15
+ console.log('[cds-error-outbox][mock] ──────────── Email (mock) ────────────');
16
+ console.log(`[cds-error-outbox][mock] From : ${mailConfig.from || '(not configured)'}`);
17
+ console.log(`[cds-error-outbox][mock] To : ${mailConfig.to || '(not configured)'}`);
18
+ console.log(`[cds-error-outbox][mock] Subject: ${subject}`);
19
+ console.log(`[cds-error-outbox][mock] HTML : ${html ? html.length : 0} chars`);
20
+ console.log('[cds-error-outbox][mock] ─────────────────────────────────────');
21
+ }
22
+
23
+ module.exports = { send };
@@ -0,0 +1,164 @@
1
+ 'use strict';
2
+
3
+ const https = require('https');
4
+ const qs = require('querystring');
5
+
6
+ /**
7
+ * Obtain an OAuth 2.0 access token using the client credentials grant.
8
+ * Endpoint: POST https://login.microsoftonline.com/{tenantId}/oauth2/v2.0/token
9
+ *
10
+ * @param {object} mailConfig - { tenantId, clientId, clientSecret }
11
+ * @returns {Promise<string>} Bearer access token
12
+ */
13
+ function getAccessToken(mailConfig) {
14
+ // Values from config take precedence; env variables are the fallback.
15
+ // Supported env variables:
16
+ // CDS_REQUIRES_ERROROUTBOX_MAIL_TENANTID
17
+ // CDS_REQUIRES_ERROROUTBOX_MAIL_CLIENTID
18
+ // CDS_REQUIRES_ERROROUTBOX_MAIL_CLIENTSECRET
19
+ const tenantId = mailConfig.tenantId || process.env.CDS_REQUIRES_ERROROUTBOX_MAIL_TENANTID || process.env.ERROR_OUTBOX_TENANT_ID;
20
+ const clientId = mailConfig.clientId || process.env.CDS_REQUIRES_ERROROUTBOX_MAIL_CLIENTID || process.env.ERROR_OUTBOX_CLIENT_ID;
21
+ const clientSecret = mailConfig.clientSecret || process.env.CDS_REQUIRES_ERROROUTBOX_MAIL_CLIENTSECRET || process.env.ERROR_OUTBOX_CLIENT_SECRET;
22
+
23
+ if (!tenantId || !clientId || !clientSecret) {
24
+ return Promise.reject(
25
+ new Error(
26
+ '[cds-error-outbox][o365] tenantId, clientId, and clientSecret are required. ' +
27
+ 'Set them in cds.requires.errorOutbox.mail or via env variables: ' +
28
+ 'CDS_REQUIRES_ERROROUTBOX_MAIL_TENANTID, CDS_REQUIRES_ERROROUTBOX_MAIL_CLIENTID, CDS_REQUIRES_ERROROUTBOX_MAIL_CLIENTSECRET'
29
+ )
30
+ );
31
+ }
32
+
33
+ const body = qs.stringify({
34
+ grant_type: 'client_credentials',
35
+ client_id: clientId,
36
+ client_secret: clientSecret,
37
+ scope: 'https://graph.microsoft.com/.default'
38
+ });
39
+
40
+ return new Promise((resolve, reject) => {
41
+ const options = {
42
+ hostname: 'login.microsoftonline.com',
43
+ path: `/${encodeURIComponent(tenantId)}/oauth2/v2.0/token`,
44
+ method: 'POST',
45
+ headers: {
46
+ 'Content-Type': 'application/x-www-form-urlencoded',
47
+ 'Content-Length': Buffer.byteLength(body)
48
+ }
49
+ };
50
+
51
+ const req = https.request(options, (res) => {
52
+ let data = '';
53
+ res.on('data', (chunk) => { data += chunk; });
54
+ res.on('end', () => {
55
+ try {
56
+ const parsed = JSON.parse(data);
57
+ if (parsed.access_token) {
58
+ resolve(parsed.access_token);
59
+ } else {
60
+ reject(
61
+ new Error(
62
+ `[cds-error-outbox][o365] Token request failed: ` +
63
+ (parsed.error_description || parsed.error || `HTTP ${res.statusCode}`)
64
+ )
65
+ );
66
+ }
67
+ } catch (e) {
68
+ reject(new Error(`[cds-error-outbox][o365] Failed to parse token response: ${e.message}`));
69
+ }
70
+ });
71
+ });
72
+
73
+ req.on('error', (err) =>
74
+ reject(new Error(`[cds-error-outbox][o365] Token HTTPS request error: ${err.message}`))
75
+ );
76
+
77
+ req.write(body);
78
+ req.end();
79
+ });
80
+ }
81
+
82
+ /**
83
+ * Send an email via Microsoft Graph API (POST /users/{from}/sendMail).
84
+ *
85
+ * Required Azure AD app permission: Mail.Send (application, not delegated).
86
+ * The `from` address must be a licensed Exchange Online mailbox.
87
+ *
88
+ * @param {object} mailConfig - { tenantId, clientId, clientSecret, from, to }
89
+ * @param {string} subject
90
+ * @param {string} html
91
+ */
92
+ async function send(mailConfig, subject, html) {
93
+ const from = mailConfig.from || process.env.CDS_REQUIRES_ERROROUTBOX_MAIL_FROM || process.env.ERROR_OUTBOX_FROM;
94
+ const to = mailConfig.to || process.env.CDS_REQUIRES_ERROROUTBOX_MAIL_TO || process.env.ERROR_OUTBOX_TO;
95
+
96
+ if (!from || !to) {
97
+ throw new Error(
98
+ '[cds-error-outbox][o365] mail.from and mail.to are required. ' +
99
+ 'Set them in cds.requires.errorOutbox.mail or via env variables: ' +
100
+ 'CDS_REQUIRES_ERROROUTBOX_MAIL_FROM, CDS_REQUIRES_ERROROUTBOX_MAIL_TO'
101
+ );
102
+ }
103
+
104
+ const accessToken = await getAccessToken(mailConfig);
105
+
106
+ // Support comma-separated recipient list
107
+ const toRecipients = String(to)
108
+ .split(',')
109
+ .map((addr) => ({ emailAddress: { address: addr.trim() } }));
110
+
111
+ const payload = JSON.stringify({
112
+ message: {
113
+ subject,
114
+ body: {
115
+ contentType: 'HTML',
116
+ content: html
117
+ },
118
+ from: {
119
+ emailAddress: { address: from }
120
+ },
121
+ toRecipients
122
+ },
123
+ saveToSentItems: false
124
+ });
125
+
126
+ return new Promise((resolve, reject) => {
127
+ const options = {
128
+ hostname: 'graph.microsoft.com',
129
+ path: `/v1.0/users/${encodeURIComponent(from)}/sendMail`,
130
+ method: 'POST',
131
+ headers: {
132
+ 'Authorization': `Bearer ${accessToken}`,
133
+ 'Content-Type': 'application/json',
134
+ 'Content-Length': Buffer.byteLength(payload)
135
+ }
136
+ };
137
+
138
+ const req = https.request(options, (res) => {
139
+ let data = '';
140
+ res.on('data', (chunk) => { data += chunk; });
141
+ res.on('end', () => {
142
+ // Graph API returns 202 Accepted on success (no body)
143
+ if (res.statusCode >= 200 && res.statusCode < 300) {
144
+ resolve();
145
+ } else {
146
+ reject(
147
+ new Error(
148
+ `[cds-error-outbox][o365] sendMail failed — HTTP ${res.statusCode}: ${data}`
149
+ )
150
+ );
151
+ }
152
+ });
153
+ });
154
+
155
+ req.on('error', (err) =>
156
+ reject(new Error(`[cds-error-outbox][o365] sendMail HTTPS request error: ${err.message}`))
157
+ );
158
+
159
+ req.write(payload);
160
+ req.end();
161
+ });
162
+ }
163
+
164
+ module.exports = { send, getAccessToken };
@@ -0,0 +1,71 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * SMTP email provider using nodemailer.
5
+ *
6
+ * nodemailer is an optional peer dependency — install it separately:
7
+ * npm install nodemailer
8
+ *
9
+ * Required config under mail.smtp:
10
+ * host, port, secure, auth.user, auth.pass
11
+ *
12
+ * @param {object} mailConfig
13
+ * @param {string} subject
14
+ * @param {string} html
15
+ */
16
+ async function send(mailConfig, subject, html) {
17
+ let nodemailer;
18
+ try {
19
+ nodemailer = require('nodemailer');
20
+ } catch (_) {
21
+ throw new Error(
22
+ '[cds-error-outbox][smtp] nodemailer is not installed. ' +
23
+ 'Run: npm install nodemailer'
24
+ );
25
+ }
26
+
27
+ const { from, to, smtp } = mailConfig;
28
+
29
+ if (!from || !to) {
30
+ throw new Error(
31
+ '[cds-error-outbox][smtp] mail.from and mail.to must be configured.'
32
+ );
33
+ }
34
+
35
+ if (!smtp || !smtp.host) {
36
+ throw new Error(
37
+ '[cds-error-outbox][smtp] mail.smtp.host must be configured for the SMTP provider.'
38
+ );
39
+ }
40
+
41
+ const transportOptions = {
42
+ host: smtp.host,
43
+ port: smtp.port !== undefined ? smtp.port : 587,
44
+ secure: smtp.secure !== undefined ? smtp.secure : false
45
+ };
46
+
47
+ // Only set auth if credentials are provided (some internal SMTP relays don't require auth)
48
+ if (smtp.auth && smtp.auth.user) {
49
+ transportOptions.auth = {
50
+ user: smtp.auth.user,
51
+ pass: smtp.auth.pass
52
+ };
53
+ }
54
+
55
+ const transporter = nodemailer.createTransport(transportOptions);
56
+
57
+ // Normalise comma-separated recipients
58
+ const toAddresses = String(to)
59
+ .split(',')
60
+ .map((a) => a.trim())
61
+ .join(', ');
62
+
63
+ await transporter.sendMail({
64
+ from,
65
+ to: toAddresses,
66
+ subject,
67
+ html
68
+ });
69
+ }
70
+
71
+ module.exports = { send };