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 +283 -0
- package/config/defaults.js +46 -0
- package/db/model.cds +18 -0
- package/index.cds +1 -0
- package/index.js +14 -0
- package/lib/bootstrap.js +71 -0
- package/lib/config.js +67 -0
- package/lib/dedup.js +91 -0
- package/lib/formatter.js +214 -0
- package/lib/interceptor.js +48 -0
- package/lib/scheduler.js +125 -0
- package/package.json +34 -0
- package/providers/index.js +40 -0
- package/providers/mock.js +23 -0
- package/providers/o365.js +164 -0
- package/providers/smtp.js +71 -0
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();
|
package/lib/bootstrap.js
ADDED
|
@@ -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 };
|
package/lib/formatter.js
ADDED
|
@@ -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, '&')
|
|
12
|
+
.replace(/</g, '<')
|
|
13
|
+
.replace(/>/g, '>')
|
|
14
|
+
.replace(/"/g, '"')
|
|
15
|
+
.replace(/'/g, ''');
|
|
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> — 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 };
|
package/lib/scheduler.js
ADDED
|
@@ -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 };
|