cds-error-outbox 1.0.0 → 1.0.2

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/providers/o365.js CHANGED
@@ -1,164 +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 };
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 };
package/providers/smtp.js CHANGED
@@ -1,71 +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 };
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 };
@@ -0,0 +1,92 @@
1
+ 'use strict';
2
+
3
+ const { describe, it, beforeEach, afterEach } = require('node:test');
4
+ const assert = require('node:assert/strict');
5
+
6
+ // Mock cds before requiring config
7
+ const cds = require('@sap/cds');
8
+
9
+ const { deepMerge, loadConfig, resetConfig } = require('../lib/config');
10
+
11
+ describe('deepMerge', () => {
12
+ it('merges flat objects', () => {
13
+ const result = deepMerge({ a: 1, b: 2 }, { b: 99, c: 3 });
14
+ assert.deepEqual(result, { a: 1, b: 99, c: 3 });
15
+ });
16
+
17
+ it('merges nested objects recursively', () => {
18
+ const result = deepMerge(
19
+ { mail: { provider: 'mock', port: 587 } },
20
+ { mail: { provider: 'smtp' } }
21
+ );
22
+ assert.deepEqual(result, { mail: { provider: 'smtp', port: 587 } });
23
+ });
24
+
25
+ it('replaces arrays (no element-wise merge)', () => {
26
+ const result = deepMerge({ arr: [1, 2, 3] }, { arr: [4, 5] });
27
+ assert.deepEqual(result, { arr: [4, 5] });
28
+ });
29
+
30
+ it('returns target unchanged when source is null', () => {
31
+ const target = { a: 1 };
32
+ const result = deepMerge(target, null);
33
+ assert.deepEqual(result, { a: 1 });
34
+ });
35
+
36
+ it('returns target unchanged when source is undefined', () => {
37
+ const target = { a: 1 };
38
+ const result = deepMerge(target, undefined);
39
+ assert.deepEqual(result, { a: 1 });
40
+ });
41
+
42
+ it('does not mutate the original target', () => {
43
+ const target = { a: 1 };
44
+ deepMerge(target, { a: 2 });
45
+ assert.equal(target.a, 1);
46
+ });
47
+ });
48
+
49
+ describe('loadConfig / resetConfig', () => {
50
+ beforeEach(() => {
51
+ resetConfig();
52
+ });
53
+
54
+ afterEach(() => {
55
+ resetConfig();
56
+ // Clean up any cds.env overrides
57
+ if (cds.env.requires) delete cds.env.requires.errorOutbox;
58
+ });
59
+
60
+ it('returns defaults when no user config is set', () => {
61
+ const config = loadConfig();
62
+ assert.equal(config.enabled, true);
63
+ assert.equal(config.interval, 300000);
64
+ assert.equal(config.batchSize, 50);
65
+ assert.equal(config.dedup.enabled, true);
66
+ assert.equal(config.dedup.windowMinutes, 10);
67
+ assert.equal(config.mail.provider, 'mock');
68
+ });
69
+
70
+ it('overrides defaults with user config', () => {
71
+ cds.env.requires = cds.env.requires || {};
72
+ cds.env.requires.errorOutbox = { interval: 60000, mail: { provider: 'smtp' } };
73
+ const config = loadConfig();
74
+ assert.equal(config.interval, 60000);
75
+ assert.equal(config.mail.provider, 'smtp');
76
+ // Defaults for untouched keys remain
77
+ assert.equal(config.batchSize, 50);
78
+ });
79
+
80
+ it('caches the result — second call returns same object', () => {
81
+ const a = loadConfig();
82
+ const b = loadConfig();
83
+ assert.equal(a, b);
84
+ });
85
+
86
+ it('resetConfig causes loadConfig to return a new instance', () => {
87
+ const a = loadConfig();
88
+ resetConfig();
89
+ const b = loadConfig();
90
+ assert.notEqual(a, b);
91
+ });
92
+ });