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/.github/workflows/publish.yml +29 -0
- package/README.md +311 -283
- package/config/defaults.js +46 -46
- package/db/model.cds +18 -18
- package/index.cds +1 -1
- package/index.js +14 -14
- package/lib/bootstrap.js +71 -71
- package/lib/config.js +67 -67
- package/lib/dedup.js +91 -91
- package/lib/formatter.js +214 -214
- package/lib/interceptor.js +59 -48
- package/lib/scheduler.js +125 -125
- package/package.json +40 -34
- package/providers/index.js +40 -40
- package/providers/mock.js +23 -23
- package/providers/o365.js +164 -164
- package/providers/smtp.js +71 -71
- package/tests/config.test.js +92 -0
- package/tests/dedup.test.js +159 -0
- package/tests/formatter.test.js +90 -0
- package/tests/scheduler.test.js +147 -0
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
|
+
});
|