arcane-os 0.2.3 → 0.3.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/CHANGELOG.md +17 -0
- package/README.md +8 -8
- package/browser-runtime/ARCANE_SDK_BROWSER_RELEASE.json +29 -22
- package/browser-runtime/ai/ARCANE_AI_BROWSER_SPEECH_COMPONENTS.json +203 -0
- package/browser-runtime/ai/browser-kokoro-worker.mjs +11 -2
- package/browser-runtime/ai/browser-speech-artifacts.mjs +3230 -397
- package/browser-runtime/ai/browser-speech-providers.mjs +1141 -157
- package/browser-runtime/ai/browser-speech.mjs +2 -0
- package/browser-runtime/ai/browser-whisper-worker.mjs +11 -2
- package/browser-runtime/ai/model-controller.mjs +285 -95
- package/browser-runtime/ai/speech-worker-client.mjs +247 -32
- package/browser-runtime/ai/speech-worker-runtime.mjs +2310 -167
- package/browser-runtime/event-manager.mjs +1097 -1
- package/docs/architecture.md +2 -2
- package/docs/event-manager.md +155 -27
- package/docs/reference/README.md +20 -20
- package/docs/reference/ai/browser-speech-package-authority.json +835 -0
- package/docs/reference/ai/browser-speech.md +1162 -246
- package/docs/reference/ai/browser-wasm.md +18 -7
- package/docs/reference/availability-and-normalization.md +2 -2
- package/docs/reference/behavioral-testing.md +28 -5
- package/docs/reference/cli.md +115 -7
- package/docs/reference/core/arcane-ai-contracts.md +1 -1
- package/docs/reference/event-manager.md +577 -32
- package/docs/reference/inventory/package-api.json +478 -2
- package/docs/reference/inventory/runtime-components.json +86 -37
- package/docs/reference/inventory/runtime-modules.json +126 -49
- package/docs/reference/mail.md +316 -0
- package/docs/reference/protocols.md +143 -33
- package/docs/reference/runtime-components.md +192 -80
- package/docs/reference/runtime-modules.md +584 -70
- package/docs/reference/sdk-api.md +1014 -25
- package/package.json +5 -4
- package/runtime/ARCANE_RUNTIME_RELEASE.json +145 -140
- package/runtime/arcane/components/app-bar.html +34 -13
- package/runtime/arcane/components/assistant-panel.html +110 -57
- package/runtime/arcane/components/calculator.html +7 -4
- package/runtime/arcane/components/chart.html +58 -17
- package/runtime/arcane/components/chat.html +606 -136
- package/runtime/arcane/components/conversation-view.html +13 -6
- package/runtime/arcane/components/dashboard-config.html +96 -59
- package/runtime/arcane/components/data-maintenance.html +69 -14
- package/runtime/arcane/components/data-view.html +53 -7
- package/runtime/arcane/components/directory-picker.html +118 -32
- package/runtime/arcane/components/document-inspector.html +47 -10
- package/runtime/arcane/components/file-drop.html +81 -35
- package/runtime/arcane/components/file-inspector.html +72 -22
- package/runtime/arcane/components/file-manager.html +374 -79
- package/runtime/arcane/components/integration-settings.html +12 -5
- package/runtime/arcane/components/local-ai-status.html +48 -19
- package/runtime/arcane/components/markdown-document.html +161 -68
- package/runtime/arcane/components/markdown-editor.html +110 -33
- package/runtime/arcane/components/media-embed.html +8 -5
- package/runtime/arcane/components/modal.html +15 -5
- package/runtime/arcane/components/output-panel.html +28 -23
- package/runtime/arcane/components/preferences-form.html +22 -4
- package/runtime/arcane/components/record-timeline.html +18 -2
- package/runtime/arcane/components/relationship-board.html +23 -3
- package/runtime/arcane/components/screen-capture.html +10 -4
- package/runtime/arcane/components/source-code-viewer.html +76 -9
- package/runtime/arcane/components/source-explanation.html +23 -3
- package/runtime/arcane/components/speech.html +453 -131
- package/runtime/arcane/components/summary-strip.html +22 -11
- package/runtime/arcane/components/table.html +39 -21
- package/runtime/arcane/components/task-progress.html +79 -21
- package/runtime/arcane/components/terminal-workspace.html +7 -4
- package/runtime/arcane/components/theme-editor.html +7 -3
- package/runtime/arcane/components/unified-inbox.html +9 -4
- package/runtime/arcane/components/voice-transcription.html +244 -72
- package/runtime/arcane/components/weather-widget.html +5 -3
- package/runtime/arcane/components/web-navigator.html +48 -8
- package/runtime/arcane/entities/Chat.js +1 -1
- package/runtime/arcane/entities/User.js +110 -23
- package/runtime/arcane/modules/AI.js +2109 -130
- package/runtime/arcane/modules/AIProviderRuntime.js +720 -13
- package/runtime/arcane/modules/AIRuntimeState.js +109 -52
- package/runtime/arcane/modules/ApiModelDatabase.js +390 -17
- package/runtime/arcane/modules/BrowserTestSuite.js +205 -28
- package/runtime/arcane/modules/CalculatorEngine.js +63 -3
- package/runtime/arcane/modules/CommunicationAppController.js +588 -28
- package/runtime/arcane/modules/CommunicationHub.js +590 -10
- package/runtime/arcane/modules/ComponentContracts.js +235 -37
- package/runtime/arcane/modules/ConversationTimebox.js +152 -33
- package/runtime/arcane/modules/DBLS.js +40 -7
- package/runtime/arcane/modules/DBOPFS.js +35 -11
- package/runtime/arcane/modules/DataMaintenance.js +12 -2
- package/runtime/arcane/modules/Errors.js +65 -7
- package/runtime/arcane/modules/HTMLImport.js +198 -14
- package/runtime/arcane/modules/LocalAIReadinessController.js +208 -29
- package/runtime/arcane/modules/Mail.js +738 -115
- package/runtime/arcane/modules/MailOutbox.mjs +1395 -0
- package/runtime/arcane/modules/MailTransport.mjs +197 -39
- package/runtime/arcane/modules/Ollama.js +36 -1
- package/runtime/arcane/modules/OpenMeteoWeatherProvider.js +583 -7
- package/runtime/arcane/modules/PreferenceStore.js +367 -33
- package/runtime/arcane/modules/RecordReviewStore.js +322 -23
- package/runtime/arcane/modules/ScreenCapture.js +1397 -15
- package/runtime/arcane/modules/SpeechPlayback.js +438 -41
- package/runtime/arcane/modules/TerminalClient.js +277 -12
- package/runtime/arcane/modules/ThemeBootstrap.js +80 -6
- package/runtime/arcane/modules/ThemeManager.js +39 -7
- package/runtime/arcane/modules/TimeGuard.js +131 -20
- package/runtime/arcane/modules/WaitForComponent.js +386 -33
- package/schemas/arcane-lock.schema.json +2 -2
- package/src/cli/main.mjs +435 -9
- package/src/event-manager.mjs +1097 -1
- package/src/import-map.mjs +21 -3
- package/src/index.mjs +13 -0
- package/src/installed-sdk-runtime.mjs +112 -0
- package/src/mail-api.mjs +22 -0
- package/src/mail-credentials.mjs +667 -0
- package/src/mail-server.mjs +1769 -0
- package/src/mail.mjs +261 -0
- package/src/sdk-browser-runtime.mjs +85 -41
- package/src/testing-loader.mjs +7 -0
- package/src/toolchain.mjs +3 -0
- package/src/workspace.mjs +1 -1
|
@@ -0,0 +1,1769 @@
|
|
|
1
|
+
import {createHash,randomUUID,timingSafeEqual} from 'node:crypto';
|
|
2
|
+
import http from 'node:http';
|
|
3
|
+
|
|
4
|
+
export const RESEND_MAIL_SERVER_PROTOCOL='arcane-resend-mail-gateway/1';
|
|
5
|
+
export const RESEND_MAIL_PATH='/v1/mail';
|
|
6
|
+
|
|
7
|
+
const RESEND_EMAIL_ENDPOINT='https://api.resend.com/emails';
|
|
8
|
+
const APP_ID_PATTERN=/^[a-z0-9](?:[a-z0-9-]{0,62})$/u;
|
|
9
|
+
const EMAIL_PATTERN=/^[a-z0-9.!#$%&'*+/=?^_`{|}~-]+@[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?(?:\.[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?)+$/iu;
|
|
10
|
+
const IDEMPOTENCY_KEY_PATTERN=/^[a-zA-Z0-9._:-]{8,128}$/u;
|
|
11
|
+
const PROVIDER_ID_PATTERN=/^[a-zA-Z0-9._:-]{1,256}$/u;
|
|
12
|
+
const PROVIDER_CODE_PATTERN=/^[a-z0-9_]{1,80}$/u;
|
|
13
|
+
const REQUEST_ID_PATTERN=/^[a-zA-Z0-9-]{8,128}$/u;
|
|
14
|
+
const JSON_CONTENT_TYPE_PATTERN=/^application\/json(?:\s*;\s*charset\s*=\s*"?utf-8"?)?$/iu;
|
|
15
|
+
const HEADER_NAME_PATTERN=/^[!#$%&'*+.^_`|~0-9a-z-]+$/iu;
|
|
16
|
+
const MAIL_TYPES=new Set(['error','report','crisis_detected']);
|
|
17
|
+
const REPORT_KEYS=new Set(['html','subject','text','to','type']);
|
|
18
|
+
const PREFLIGHT_HEADERS=new Set([
|
|
19
|
+
'content-type','idempotency-key','x-mail-app','x-mail-key'
|
|
20
|
+
]);
|
|
21
|
+
const PERMANENT_RATE_CODES=new Set(['daily_quota_exceeded','monthly_quota_exceeded']);
|
|
22
|
+
const RETRYABLE_PROVIDER_STATUSES=new Set([408,425,429,500,502,503,504]);
|
|
23
|
+
const DEFAULT_MAX_MESSAGE_BYTES=25*1024*1024;
|
|
24
|
+
const DEFAULT_MAX_REQUEST_BYTES=52*1024*1024;
|
|
25
|
+
const DEFAULT_MAX_PROVIDER_RESPONSE_BYTES=64*1024;
|
|
26
|
+
const DEFAULT_MAX_QUEUED_MESSAGE_BYTES=64*1024*1024;
|
|
27
|
+
const MAX_RETRY_AFTER_MS=24*60*60*1000;
|
|
28
|
+
|
|
29
|
+
class MailGatewayFault extends Error {
|
|
30
|
+
constructor(code,{retryable=false,retryAfterMs=0,statusCode=400,uncertain=false}={}){
|
|
31
|
+
super(code);
|
|
32
|
+
this.name='MailGatewayFault';
|
|
33
|
+
this.code=code;
|
|
34
|
+
this.retryable=Boolean(retryable);
|
|
35
|
+
this.retryAfterMs=boundedRetryAfter(retryAfterMs);
|
|
36
|
+
this.statusCode=statusCode;
|
|
37
|
+
this.uncertain=Boolean(uncertain);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function configurationError(message){
|
|
42
|
+
const error=new Error(message);
|
|
43
|
+
error.code='ARCANE_MAIL_CONFIG_INVALID';
|
|
44
|
+
return error;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function boundedInteger(value,fallback,{label,min,max}){
|
|
48
|
+
const resolved=value===undefined?fallback:value;
|
|
49
|
+
if(!Number.isSafeInteger(resolved)||resolved<min||resolved>max){
|
|
50
|
+
throw configurationError(`${label} must be an integer between ${min} and ${max}.`);
|
|
51
|
+
}
|
|
52
|
+
return resolved;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function boundedRetryAfter(value){
|
|
56
|
+
return Number.isSafeInteger(value)&&value>0
|
|
57
|
+
? Math.min(MAX_RETRY_AFTER_MS,value)
|
|
58
|
+
: 0;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function validateSignal(signal){
|
|
62
|
+
if(signal!==undefined&&!(signal instanceof AbortSignal)){
|
|
63
|
+
throw new TypeError('Mail server signal must be an AbortSignal.');
|
|
64
|
+
}
|
|
65
|
+
return signal;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function validateApiKey(value){
|
|
69
|
+
if(typeof value!=='string'||value.length<1||value.length>4096
|
|
70
|
+
||!/^[\x21-\x7e]+$/u.test(value)){
|
|
71
|
+
throw configurationError('Resend API key must be a nonempty printable ASCII string.');
|
|
72
|
+
}
|
|
73
|
+
return value;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function validateAppId(value){
|
|
77
|
+
if(typeof value!=='string'||!APP_ID_PATTERN.test(value)){
|
|
78
|
+
throw configurationError('Mail application identity is invalid.');
|
|
79
|
+
}
|
|
80
|
+
return value;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function appKeyDigest(value){
|
|
84
|
+
return createHash('sha256').update(value,'utf8').digest();
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function normalizeCallerAuthentication(options){
|
|
88
|
+
const allowUnauthenticatedCaller=options.allowUnauthenticatedCaller??false;
|
|
89
|
+
if(typeof allowUnauthenticatedCaller!=='boolean'){
|
|
90
|
+
throw configurationError('allowUnauthenticatedCaller must be a boolean.');
|
|
91
|
+
}
|
|
92
|
+
if(allowUnauthenticatedCaller){
|
|
93
|
+
if(options.appKey!==undefined){
|
|
94
|
+
throw configurationError(
|
|
95
|
+
'appKey must be omitted when allowUnauthenticatedCaller is true.'
|
|
96
|
+
);
|
|
97
|
+
}
|
|
98
|
+
return Object.freeze({
|
|
99
|
+
appKeyDigest:null,
|
|
100
|
+
callerAuthentication:'origin-app-id-only'
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
if(typeof options.appKey!=='string'||options.appKey.length<16
|
|
104
|
+
||options.appKey.length>512||!/^[\x21-\x7e]+$/u.test(options.appKey)){
|
|
105
|
+
throw configurationError(
|
|
106
|
+
'appKey must contain 16-512 printable ASCII characters unless unauthenticated caller mode is explicitly enabled.'
|
|
107
|
+
);
|
|
108
|
+
}
|
|
109
|
+
return Object.freeze({
|
|
110
|
+
appKeyDigest:appKeyDigest(options.appKey),
|
|
111
|
+
callerAuthentication:'app-key'
|
|
112
|
+
});
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function normalizedEmail(value,label){
|
|
116
|
+
if(typeof value!=='string'){
|
|
117
|
+
throw configurationError(`${label} must be an email address.`);
|
|
118
|
+
}
|
|
119
|
+
const normalized=value.trim().toLowerCase();
|
|
120
|
+
if(normalized.length<3||normalized.length>254||!EMAIL_PATTERN.test(normalized)){
|
|
121
|
+
throw configurationError(`${label} must be a valid email address.`);
|
|
122
|
+
}
|
|
123
|
+
return normalized;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function validateFrom(value){
|
|
127
|
+
if(typeof value!=='string'||value!==value.trim()||value.length<3||value.length>320
|
|
128
|
+
||/[\u0000-\u001f\u007f]/u.test(value)){
|
|
129
|
+
throw configurationError('Mail sender is invalid.');
|
|
130
|
+
}
|
|
131
|
+
if(EMAIL_PATTERN.test(value)){
|
|
132
|
+
return value.toLowerCase();
|
|
133
|
+
}
|
|
134
|
+
const match=/^([^<>]{1,64}) <([^<>]+)>$/u.exec(value);
|
|
135
|
+
if(!match||!match[1].trim()){
|
|
136
|
+
throw configurationError('Mail sender must be an email address or Name <email> value.');
|
|
137
|
+
}
|
|
138
|
+
const address=normalizedEmail(match[2],'Mail sender');
|
|
139
|
+
return `${match[1]} <${address}>`;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function normalizeEmailList(value,label,{allowEmpty=false}={}){
|
|
143
|
+
if(!Array.isArray(value)||value.length>50||(!allowEmpty&&value.length===0)){
|
|
144
|
+
throw configurationError(`${label} must contain ${allowEmpty?'zero to ':'one to '}50 addresses.`);
|
|
145
|
+
}
|
|
146
|
+
const result=[];
|
|
147
|
+
const seen=new Set();
|
|
148
|
+
for(const entry of value){
|
|
149
|
+
const address=normalizedEmail(entry,label);
|
|
150
|
+
if(seen.has(address)){
|
|
151
|
+
throw configurationError(`${label} must not contain duplicate addresses.`);
|
|
152
|
+
}
|
|
153
|
+
seen.add(address);
|
|
154
|
+
result.push(address);
|
|
155
|
+
}
|
|
156
|
+
return result;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function normalizeOrigin(value){
|
|
160
|
+
if(typeof value!=='string'||!value.trim()){
|
|
161
|
+
throw configurationError('Every allowed mail origin must be a URL origin.');
|
|
162
|
+
}
|
|
163
|
+
let url;
|
|
164
|
+
try{
|
|
165
|
+
url=new URL(value.trim());
|
|
166
|
+
}catch{
|
|
167
|
+
throw configurationError('Every allowed mail origin must be a valid URL origin.');
|
|
168
|
+
}
|
|
169
|
+
if(!['http:','https:'].includes(url.protocol)||url.username||url.password
|
|
170
|
+
||url.pathname!=='/'||url.search||url.hash){
|
|
171
|
+
throw configurationError('Every allowed mail origin must be an HTTP or HTTPS origin without credentials, path, query, or fragment.');
|
|
172
|
+
}
|
|
173
|
+
return url.origin;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function normalizeOrigins(value){
|
|
177
|
+
if(!Array.isArray(value)||value.length===0||value.length>64){
|
|
178
|
+
throw configurationError('allowedOrigins must contain one to 64 exact origins.');
|
|
179
|
+
}
|
|
180
|
+
const origins=[];
|
|
181
|
+
const seen=new Set();
|
|
182
|
+
for(const entry of value){
|
|
183
|
+
const origin=normalizeOrigin(entry);
|
|
184
|
+
if(seen.has(origin)){
|
|
185
|
+
throw configurationError('allowedOrigins must not contain duplicate origins.');
|
|
186
|
+
}
|
|
187
|
+
seen.add(origin);
|
|
188
|
+
origins.push(origin);
|
|
189
|
+
}
|
|
190
|
+
return new Set(origins);
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function validateLoopbackHost(value){
|
|
194
|
+
if(value!=='127.0.0.1'&&value!=='::1'){
|
|
195
|
+
throw configurationError('Mail server host must be the numeric loopback address 127.0.0.1 or ::1.');
|
|
196
|
+
}
|
|
197
|
+
return value;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
function normalizeConfiguration(options={}){
|
|
201
|
+
if(!options||typeof options!=='object'||Array.isArray(options)){
|
|
202
|
+
throw configurationError('Mail server options must be an object.');
|
|
203
|
+
}
|
|
204
|
+
const callerAuthentication=normalizeCallerAuthentication(options);
|
|
205
|
+
const recipientAllowlist=normalizeEmailList(
|
|
206
|
+
options.recipientAllowlist,
|
|
207
|
+
'recipientAllowlist'
|
|
208
|
+
);
|
|
209
|
+
const errorRecipients=normalizeEmailList(
|
|
210
|
+
options.errorRecipients??[],
|
|
211
|
+
'errorRecipients',
|
|
212
|
+
{allowEmpty:true}
|
|
213
|
+
);
|
|
214
|
+
const allowedRecipients=new Set(recipientAllowlist);
|
|
215
|
+
if(errorRecipients.some(function errorRecipientIsNotAllowed(address){
|
|
216
|
+
return !allowedRecipients.has(address);
|
|
217
|
+
})){
|
|
218
|
+
throw configurationError('Every error recipient must also be in recipientAllowlist.');
|
|
219
|
+
}
|
|
220
|
+
const maxMessageBytes=boundedInteger(
|
|
221
|
+
options.maxMessageBytes,
|
|
222
|
+
DEFAULT_MAX_MESSAGE_BYTES,
|
|
223
|
+
{label:'maxMessageBytes',min:1,max:DEFAULT_MAX_MESSAGE_BYTES}
|
|
224
|
+
);
|
|
225
|
+
const maxRequestBytes=boundedInteger(
|
|
226
|
+
options.maxRequestBytes,
|
|
227
|
+
DEFAULT_MAX_REQUEST_BYTES,
|
|
228
|
+
{label:'maxRequestBytes',min:256,max:64*1024*1024}
|
|
229
|
+
);
|
|
230
|
+
if(maxRequestBytes<maxMessageBytes+256){
|
|
231
|
+
throw configurationError('maxRequestBytes must leave at least 256 bytes beyond maxMessageBytes.');
|
|
232
|
+
}
|
|
233
|
+
const fetchImpl=options.fetchImpl??globalThis.fetch;
|
|
234
|
+
if(typeof fetchImpl!=='function'){
|
|
235
|
+
throw configurationError('A fetch implementation is required for Resend delivery.');
|
|
236
|
+
}
|
|
237
|
+
if(options.onEvent!==undefined&&typeof options.onEvent!=='function'){
|
|
238
|
+
throw configurationError('onEvent must be a function when supplied.');
|
|
239
|
+
}
|
|
240
|
+
if(options.requestIdFactory!==undefined&&typeof options.requestIdFactory!=='function'){
|
|
241
|
+
throw configurationError('requestIdFactory must be a function when supplied.');
|
|
242
|
+
}
|
|
243
|
+
return Object.freeze({
|
|
244
|
+
allowAnyRecipient:false,
|
|
245
|
+
allowedOrigins:normalizeOrigins(options.allowedOrigins),
|
|
246
|
+
allowedRecipients,
|
|
247
|
+
apiKey:validateApiKey(options.apiKey),
|
|
248
|
+
appKeyDigest:callerAuthentication.appKeyDigest,
|
|
249
|
+
appId:validateAppId(options.appId),
|
|
250
|
+
bodyQueueTimeoutMs:boundedInteger(
|
|
251
|
+
options.bodyQueueTimeoutMs,
|
|
252
|
+
5_000,
|
|
253
|
+
{label:'bodyQueueTimeoutMs',min:1,max:60_000}
|
|
254
|
+
),
|
|
255
|
+
bodyTimeoutMs:boundedInteger(
|
|
256
|
+
options.bodyTimeoutMs,
|
|
257
|
+
10_000,
|
|
258
|
+
{label:'bodyTimeoutMs',min:100,max:120_000}
|
|
259
|
+
),
|
|
260
|
+
errorRecipients,
|
|
261
|
+
fetchImpl,
|
|
262
|
+
from:validateFrom(options.from),
|
|
263
|
+
host:validateLoopbackHost(options.host??'127.0.0.1'),
|
|
264
|
+
callerAuthentication:callerAuthentication.callerAuthentication,
|
|
265
|
+
maxConcurrentBodyReads:boundedInteger(
|
|
266
|
+
options.maxConcurrentBodyReads,
|
|
267
|
+
8,
|
|
268
|
+
{label:'maxConcurrentBodyReads',min:1,max:64}
|
|
269
|
+
),
|
|
270
|
+
maxConcurrentSends:boundedInteger(
|
|
271
|
+
options.maxConcurrentSends,
|
|
272
|
+
2,
|
|
273
|
+
{label:'maxConcurrentSends',min:1,max:16}
|
|
274
|
+
),
|
|
275
|
+
maxMessageBytes,
|
|
276
|
+
maxProviderResponseBytes:boundedInteger(
|
|
277
|
+
options.maxProviderResponseBytes,
|
|
278
|
+
DEFAULT_MAX_PROVIDER_RESPONSE_BYTES,
|
|
279
|
+
{label:'maxProviderResponseBytes',min:64,max:1024*1024}
|
|
280
|
+
),
|
|
281
|
+
maxQueuedBodyReads:boundedInteger(
|
|
282
|
+
options.maxQueuedBodyReads,
|
|
283
|
+
64,
|
|
284
|
+
{label:'maxQueuedBodyReads',min:0,max:1024}
|
|
285
|
+
),
|
|
286
|
+
maxQueuedSends:boundedInteger(
|
|
287
|
+
options.maxQueuedSends,
|
|
288
|
+
32,
|
|
289
|
+
{label:'maxQueuedSends',min:0,max:1024}
|
|
290
|
+
),
|
|
291
|
+
maxQueuedMessageBytes:boundedInteger(
|
|
292
|
+
options.maxQueuedMessageBytes,
|
|
293
|
+
DEFAULT_MAX_QUEUED_MESSAGE_BYTES,
|
|
294
|
+
{label:'maxQueuedMessageBytes',min:0,max:512*1024*1024}
|
|
295
|
+
),
|
|
296
|
+
maxRequestBytes,
|
|
297
|
+
onEvent:options.onEvent,
|
|
298
|
+
observerDrainTimeoutMs:boundedInteger(
|
|
299
|
+
options.observerDrainTimeoutMs,
|
|
300
|
+
1_000,
|
|
301
|
+
{label:'observerDrainTimeoutMs',min:1,max:10_000}
|
|
302
|
+
),
|
|
303
|
+
port:boundedInteger(options.port,8025,{label:'port',min:0,max:65_535}),
|
|
304
|
+
providerTimeoutMs:boundedInteger(
|
|
305
|
+
options.providerTimeoutMs,
|
|
306
|
+
120_000,
|
|
307
|
+
{label:'providerTimeoutMs',min:100,max:600_000}
|
|
308
|
+
),
|
|
309
|
+
requestIdFactory:options.requestIdFactory??randomUUID,
|
|
310
|
+
retryableDelayMs:boundedInteger(
|
|
311
|
+
options.retryableDelayMs,
|
|
312
|
+
1_000,
|
|
313
|
+
{label:'retryableDelayMs',min:1,max:60_000}
|
|
314
|
+
),
|
|
315
|
+
sendQueueTimeoutMs:boundedInteger(
|
|
316
|
+
options.sendQueueTimeoutMs,
|
|
317
|
+
10_000,
|
|
318
|
+
{label:'sendQueueTimeoutMs',min:1,max:120_000}
|
|
319
|
+
),
|
|
320
|
+
signal:validateSignal(options.signal)
|
|
321
|
+
});
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
function createRequestId(factory){
|
|
325
|
+
try{
|
|
326
|
+
const candidate=factory();
|
|
327
|
+
if(typeof candidate==='string'&&REQUEST_ID_PATTERN.test(candidate)){
|
|
328
|
+
return candidate;
|
|
329
|
+
}
|
|
330
|
+
}catch{
|
|
331
|
+
// A diagnostic identifier must never prevent a bounded error response.
|
|
332
|
+
}
|
|
333
|
+
return randomUUID();
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
function isNumericLoopback(value){
|
|
337
|
+
return value==='127.0.0.1'||value==='::1'||value==='::ffff:127.0.0.1';
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
function headerValues(request,name){
|
|
341
|
+
const distinct=request.headersDistinct?.[name];
|
|
342
|
+
if(Array.isArray(distinct)){
|
|
343
|
+
return distinct.map(function stringifyDistinctHeader(value){return String(value);});
|
|
344
|
+
}
|
|
345
|
+
const values=[];
|
|
346
|
+
for(let index=0;index<(request.rawHeaders?.length??0);index+=2){
|
|
347
|
+
if(String(request.rawHeaders[index]).toLowerCase()===name){
|
|
348
|
+
values.push(String(request.rawHeaders[index+1]??''));
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
if(values.length>0){
|
|
352
|
+
return values;
|
|
353
|
+
}
|
|
354
|
+
const fallback=request.headers?.[name];
|
|
355
|
+
if(fallback===undefined){
|
|
356
|
+
return [];
|
|
357
|
+
}
|
|
358
|
+
return Array.isArray(fallback)?fallback.map(String):[String(fallback)];
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
function singleHeader(request,name,{required=true}={}){
|
|
362
|
+
const values=headerValues(request,name);
|
|
363
|
+
if(values.length===0&&!required){
|
|
364
|
+
return '';
|
|
365
|
+
}
|
|
366
|
+
if(values.length!==1||!values[0]){
|
|
367
|
+
throw new MailGatewayFault('mail_invalid_headers',{statusCode:400});
|
|
368
|
+
}
|
|
369
|
+
return values[0];
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
function validateLoopbackRequest(request){
|
|
373
|
+
if(!isNumericLoopback(request.socket?.remoteAddress)
|
|
374
|
+
||!isNumericLoopback(request.socket?.localAddress)){
|
|
375
|
+
throw new MailGatewayFault('mail_loopback_required',{statusCode:421});
|
|
376
|
+
}
|
|
377
|
+
const rawHost=singleHeader(request,'host');
|
|
378
|
+
let parsed;
|
|
379
|
+
try{
|
|
380
|
+
parsed=new URL(`http://${rawHost}`);
|
|
381
|
+
}catch{
|
|
382
|
+
throw new MailGatewayFault('mail_invalid_host',{statusCode:421});
|
|
383
|
+
}
|
|
384
|
+
const hostname=parsed.hostname.replace(/^\[|\]$/gu,'');
|
|
385
|
+
const localPort=request.socket?.localPort;
|
|
386
|
+
const statedPort=parsed.port?Number(parsed.port):80;
|
|
387
|
+
const hostLiteral=hostname.includes(':')?`[${hostname}]`:hostname;
|
|
388
|
+
const expectedAuthority=localPort===80?hostLiteral:`${hostLiteral}:${String(localPort)}`;
|
|
389
|
+
if(parsed.username||parsed.password||parsed.pathname!=='/'||parsed.search||parsed.hash
|
|
390
|
+
||!isNumericLoopback(hostname)||!Number.isSafeInteger(localPort)
|
|
391
|
+
||statedPort!==localPort||rawHost!==expectedAuthority){
|
|
392
|
+
throw new MailGatewayFault('mail_invalid_host',{statusCode:421});
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
function allowedOrigin(request,configuration){
|
|
397
|
+
const origin=singleHeader(request,'origin');
|
|
398
|
+
if(!configuration.allowedOrigins.has(origin)){
|
|
399
|
+
throw new MailGatewayFault('mail_origin_not_allowed',{statusCode:403});
|
|
400
|
+
}
|
|
401
|
+
return origin;
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
function authenticateLocalCaller(request,configuration){
|
|
405
|
+
const values=headerValues(request,'x-mail-key');
|
|
406
|
+
if(configuration.callerAuthentication==='origin-app-id-only'){
|
|
407
|
+
if(values.length!==0){
|
|
408
|
+
throw new MailGatewayFault('mail_app_key_unexpected',{statusCode:403});
|
|
409
|
+
}
|
|
410
|
+
return;
|
|
411
|
+
}
|
|
412
|
+
const candidate=values.length===1?values[0]:'';
|
|
413
|
+
const candidateIsValid=candidate.length>=16&&candidate.length<=512
|
|
414
|
+
&&/^[\x21-\x7e]+$/u.test(candidate);
|
|
415
|
+
const digest=appKeyDigest(candidateIsValid?candidate:'');
|
|
416
|
+
const authenticated=timingSafeEqual(configuration.appKeyDigest,digest);
|
|
417
|
+
if(values.length!==1||!candidateIsValid||!authenticated){
|
|
418
|
+
throw new MailGatewayFault('mail_app_key_invalid',{statusCode:401});
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
function corsHeaders(origin,{allowPrivateNetwork=false}={}){
|
|
423
|
+
if(!origin){
|
|
424
|
+
return {};
|
|
425
|
+
}
|
|
426
|
+
return {
|
|
427
|
+
'access-control-allow-headers':'Content-Type, Idempotency-Key, X-Mail-App, X-Mail-Key',
|
|
428
|
+
'access-control-allow-methods':'POST, OPTIONS',
|
|
429
|
+
'access-control-allow-origin':origin,
|
|
430
|
+
'access-control-expose-headers':'Retry-After',
|
|
431
|
+
'access-control-max-age':'600',
|
|
432
|
+
...(allowPrivateNetwork?{'access-control-allow-private-network':'true'}:{}),
|
|
433
|
+
'vary':'Origin'
|
|
434
|
+
};
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
function baseResponseHeaders(origin){
|
|
438
|
+
return {
|
|
439
|
+
'cache-control':'no-store',
|
|
440
|
+
'content-security-policy':"default-src 'none'; frame-ancestors 'none'; base-uri 'none'",
|
|
441
|
+
'cross-origin-resource-policy':'cross-origin',
|
|
442
|
+
'referrer-policy':'no-referrer',
|
|
443
|
+
'x-content-type-options':'nosniff',
|
|
444
|
+
...corsHeaders(origin)
|
|
445
|
+
};
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
function writeJson(response,statusCode,value,{origin='',retryAfterMs=0}={}){
|
|
449
|
+
if(response.destroyed||response.writableEnded){
|
|
450
|
+
return false;
|
|
451
|
+
}
|
|
452
|
+
const body=JSON.stringify(value);
|
|
453
|
+
const headers={
|
|
454
|
+
...baseResponseHeaders(origin),
|
|
455
|
+
'content-length':String(Buffer.byteLength(body,'utf8')),
|
|
456
|
+
'content-type':'application/json; charset=utf-8'
|
|
457
|
+
};
|
|
458
|
+
const boundedDelay=boundedRetryAfter(retryAfterMs);
|
|
459
|
+
if(boundedDelay){
|
|
460
|
+
headers['retry-after']=String(Math.max(1,Math.ceil(boundedDelay/1000)));
|
|
461
|
+
}
|
|
462
|
+
response.writeHead(statusCode,headers);
|
|
463
|
+
response.end(body);
|
|
464
|
+
return true;
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
function writePreflight(response,origin,{allowPrivateNetwork=false}={}){
|
|
468
|
+
if(response.destroyed||response.writableEnded){
|
|
469
|
+
return false;
|
|
470
|
+
}
|
|
471
|
+
response.writeHead(204,{
|
|
472
|
+
...baseResponseHeaders(origin),
|
|
473
|
+
...corsHeaders(origin,{allowPrivateNetwork}),
|
|
474
|
+
'content-length':'0'
|
|
475
|
+
});
|
|
476
|
+
response.end();
|
|
477
|
+
return true;
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
function writeFault(response,requestId,fault,origin=''){
|
|
481
|
+
const retryAfterMs=boundedRetryAfter(fault.retryAfterMs);
|
|
482
|
+
return writeJson(response,fault.statusCode,{
|
|
483
|
+
requestId,
|
|
484
|
+
error:{
|
|
485
|
+
code:PROVIDER_CODE_PATTERN.test(fault.code)?fault.code:'mail_gateway_error',
|
|
486
|
+
retryable:Boolean(fault.retryable),
|
|
487
|
+
uncertain:Boolean(fault.uncertain),
|
|
488
|
+
...(retryAfterMs?{retryAfterMs}:{})
|
|
489
|
+
}
|
|
490
|
+
},{origin,retryAfterMs});
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
function normalizeFault(error){
|
|
494
|
+
if(error instanceof MailGatewayFault){
|
|
495
|
+
return error;
|
|
496
|
+
}
|
|
497
|
+
return new MailGatewayFault('mail_gateway_error',{statusCode:500});
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
function validatePreflight(request){
|
|
501
|
+
if(singleHeader(request,'access-control-request-method')!=='POST'){
|
|
502
|
+
throw new MailGatewayFault('mail_preflight_denied',{statusCode:403});
|
|
503
|
+
}
|
|
504
|
+
const rawHeaders=singleHeader(request,'access-control-request-headers');
|
|
505
|
+
const requestedHeaders=rawHeaders.split(',').map(function normalizeRequestedHeader(value){
|
|
506
|
+
return value.trim().toLowerCase();
|
|
507
|
+
});
|
|
508
|
+
if(requestedHeaders.length===0||requestedHeaders.some(function headerIsNotAllowed(value){
|
|
509
|
+
return !value||!HEADER_NAME_PATTERN.test(value)||!PREFLIGHT_HEADERS.has(value);
|
|
510
|
+
})){
|
|
511
|
+
throw new MailGatewayFault('mail_preflight_denied',{statusCode:403});
|
|
512
|
+
}
|
|
513
|
+
const privateNetwork=singleHeader(
|
|
514
|
+
request,
|
|
515
|
+
'access-control-request-private-network',
|
|
516
|
+
{required:false}
|
|
517
|
+
);
|
|
518
|
+
if(privateNetwork&&privateNetwork!=='true'){
|
|
519
|
+
throw new MailGatewayFault('mail_preflight_denied',{statusCode:403});
|
|
520
|
+
}
|
|
521
|
+
return {allowPrivateNetwork:privateNetwork==='true'};
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
function createObserver(onEvent,drainTimeoutMs){
|
|
525
|
+
const pending=new Set();
|
|
526
|
+
function observe(event){
|
|
527
|
+
if(!onEvent){
|
|
528
|
+
return;
|
|
529
|
+
}
|
|
530
|
+
let result;
|
|
531
|
+
try{
|
|
532
|
+
result=onEvent(Object.freeze({...event}));
|
|
533
|
+
}catch{
|
|
534
|
+
return;
|
|
535
|
+
}
|
|
536
|
+
if(!result||typeof result.then!=='function'){
|
|
537
|
+
return;
|
|
538
|
+
}
|
|
539
|
+
const task=Promise.resolve(result);
|
|
540
|
+
pending.add(task);
|
|
541
|
+
task.catch(function ignoreObserverFailure(){})
|
|
542
|
+
.finally(function releaseObserverTask(){pending.delete(task);});
|
|
543
|
+
}
|
|
544
|
+
async function drain(){
|
|
545
|
+
if(pending.size===0){
|
|
546
|
+
return;
|
|
547
|
+
}
|
|
548
|
+
let timer;
|
|
549
|
+
const timeout=new Promise(function boundObserverDrain(resolve){
|
|
550
|
+
timer=setTimeout(resolve,drainTimeoutMs);
|
|
551
|
+
});
|
|
552
|
+
await Promise.race([Promise.allSettled([...pending]),timeout]);
|
|
553
|
+
clearTimeout(timer);
|
|
554
|
+
pending.clear();
|
|
555
|
+
}
|
|
556
|
+
return {drain,observe};
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
function createScheduler({concurrency,maxQueued,queueTimeoutMs,retryAfterMs,code,
|
|
560
|
+
maxQueuedWeight=Number.MAX_SAFE_INTEGER,weightCode=code}){
|
|
561
|
+
let active=0;
|
|
562
|
+
let closed=false;
|
|
563
|
+
let queuedWeight=0;
|
|
564
|
+
const pending=[];
|
|
565
|
+
const idleWaiters=[];
|
|
566
|
+
|
|
567
|
+
function notifyIdle(){
|
|
568
|
+
if(active!==0||pending.length!==0){
|
|
569
|
+
return;
|
|
570
|
+
}
|
|
571
|
+
while(idleWaiters.length){
|
|
572
|
+
idleWaiters.shift()();
|
|
573
|
+
}
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
function cleanupItem(item){
|
|
577
|
+
if(item.timeout){
|
|
578
|
+
clearTimeout(item.timeout);
|
|
579
|
+
}
|
|
580
|
+
item.signal?.removeEventListener('abort',item.onAbort);
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
function releaseQueuedWeight(item){
|
|
584
|
+
if(!item.queued){
|
|
585
|
+
return;
|
|
586
|
+
}
|
|
587
|
+
item.queued=false;
|
|
588
|
+
queuedWeight-=item.weight;
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
function removePending(item){
|
|
592
|
+
const index=pending.indexOf(item);
|
|
593
|
+
if(index>=0){
|
|
594
|
+
pending.splice(index,1);
|
|
595
|
+
releaseQueuedWeight(item);
|
|
596
|
+
return true;
|
|
597
|
+
}
|
|
598
|
+
return false;
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
function rejectQueuedItem(item,fault){
|
|
602
|
+
if(!removePending(item)){
|
|
603
|
+
return;
|
|
604
|
+
}
|
|
605
|
+
cleanupItem(item);
|
|
606
|
+
item.reject(fault);
|
|
607
|
+
notifyIdle();
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
function dispatch(){
|
|
611
|
+
while(!closed&&active<concurrency&&pending.length){
|
|
612
|
+
const item=pending.shift();
|
|
613
|
+
startItem(item);
|
|
614
|
+
}
|
|
615
|
+
notifyIdle();
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
async function executeItem(item){
|
|
619
|
+
try{
|
|
620
|
+
item.resolve(await item.work());
|
|
621
|
+
}catch(error){
|
|
622
|
+
item.reject(error);
|
|
623
|
+
}finally{
|
|
624
|
+
active-=1;
|
|
625
|
+
dispatch();
|
|
626
|
+
}
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
function startItem(item){
|
|
630
|
+
releaseQueuedWeight(item);
|
|
631
|
+
cleanupItem(item);
|
|
632
|
+
if(item.signal?.aborted){
|
|
633
|
+
item.reject(new MailGatewayFault('mail_request_cancelled',{
|
|
634
|
+
retryable:true,
|
|
635
|
+
statusCode:408
|
|
636
|
+
}));
|
|
637
|
+
dispatch();
|
|
638
|
+
return;
|
|
639
|
+
}
|
|
640
|
+
active+=1;
|
|
641
|
+
void executeItem(item);
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
function schedule(work,{signal,weight=0}={}){
|
|
645
|
+
if(closed){
|
|
646
|
+
return Promise.reject(new MailGatewayFault('mail_server_stopping',{
|
|
647
|
+
retryable:true,
|
|
648
|
+
retryAfterMs,
|
|
649
|
+
statusCode:503
|
|
650
|
+
}));
|
|
651
|
+
}
|
|
652
|
+
if(signal?.aborted){
|
|
653
|
+
return Promise.reject(new MailGatewayFault('mail_request_cancelled',{
|
|
654
|
+
retryable:true,
|
|
655
|
+
statusCode:408
|
|
656
|
+
}));
|
|
657
|
+
}
|
|
658
|
+
if(!Number.isSafeInteger(weight)||weight<0){
|
|
659
|
+
return Promise.reject(new MailGatewayFault('mail_invalid_queue_weight',{
|
|
660
|
+
statusCode:500
|
|
661
|
+
}));
|
|
662
|
+
}
|
|
663
|
+
return new Promise(function createScheduledWork(resolve,reject){
|
|
664
|
+
const item={
|
|
665
|
+
onAbort:null,
|
|
666
|
+
queued:false,
|
|
667
|
+
reject,
|
|
668
|
+
resolve,
|
|
669
|
+
signal,
|
|
670
|
+
timeout:null,
|
|
671
|
+
weight,
|
|
672
|
+
work
|
|
673
|
+
};
|
|
674
|
+
item.onAbort=function cancelQueuedWork(){
|
|
675
|
+
rejectQueuedItem(item,new MailGatewayFault('mail_request_cancelled',{
|
|
676
|
+
retryable:true,
|
|
677
|
+
statusCode:408
|
|
678
|
+
}));
|
|
679
|
+
};
|
|
680
|
+
if(active<concurrency){
|
|
681
|
+
startItem(item);
|
|
682
|
+
return;
|
|
683
|
+
}
|
|
684
|
+
if(pending.length>=maxQueued){
|
|
685
|
+
reject(new MailGatewayFault(code,{
|
|
686
|
+
retryable:true,
|
|
687
|
+
retryAfterMs,
|
|
688
|
+
statusCode:503
|
|
689
|
+
}));
|
|
690
|
+
return;
|
|
691
|
+
}
|
|
692
|
+
if(weight>maxQueuedWeight||queuedWeight>maxQueuedWeight-weight){
|
|
693
|
+
reject(new MailGatewayFault(weightCode,{
|
|
694
|
+
retryable:true,
|
|
695
|
+
retryAfterMs,
|
|
696
|
+
statusCode:503
|
|
697
|
+
}));
|
|
698
|
+
return;
|
|
699
|
+
}
|
|
700
|
+
item.queued=true;
|
|
701
|
+
queuedWeight+=weight;
|
|
702
|
+
pending.push(item);
|
|
703
|
+
item.signal?.addEventListener('abort',item.onAbort,{once:true});
|
|
704
|
+
if(item.signal?.aborted){
|
|
705
|
+
item.onAbort();
|
|
706
|
+
return;
|
|
707
|
+
}
|
|
708
|
+
item.timeout=setTimeout(function expireQueuedWork(){
|
|
709
|
+
rejectQueuedItem(item,new MailGatewayFault(`${code}_timeout`,{
|
|
710
|
+
retryable:true,
|
|
711
|
+
retryAfterMs,
|
|
712
|
+
statusCode:503
|
|
713
|
+
}));
|
|
714
|
+
},queueTimeoutMs);
|
|
715
|
+
});
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
function close(){
|
|
719
|
+
if(closed){
|
|
720
|
+
return;
|
|
721
|
+
}
|
|
722
|
+
closed=true;
|
|
723
|
+
while(pending.length){
|
|
724
|
+
const item=pending.shift();
|
|
725
|
+
releaseQueuedWeight(item);
|
|
726
|
+
cleanupItem(item);
|
|
727
|
+
item.reject(new MailGatewayFault('mail_server_stopping',{
|
|
728
|
+
retryable:true,
|
|
729
|
+
retryAfterMs,
|
|
730
|
+
statusCode:503
|
|
731
|
+
}));
|
|
732
|
+
}
|
|
733
|
+
notifyIdle();
|
|
734
|
+
}
|
|
735
|
+
|
|
736
|
+
function idle(){
|
|
737
|
+
if(active===0&&pending.length===0){
|
|
738
|
+
return Promise.resolve();
|
|
739
|
+
}
|
|
740
|
+
return new Promise(function waitForSchedulerIdle(resolve){
|
|
741
|
+
idleWaiters.push(resolve);
|
|
742
|
+
});
|
|
743
|
+
}
|
|
744
|
+
|
|
745
|
+
return {close,idle,schedule};
|
|
746
|
+
}
|
|
747
|
+
|
|
748
|
+
function requestContentLength(request,maxRequestBytes){
|
|
749
|
+
const raw=singleHeader(request,'content-length',{required:false});
|
|
750
|
+
if(!raw){
|
|
751
|
+
return null;
|
|
752
|
+
}
|
|
753
|
+
if(!/^\d+$/u.test(raw)){
|
|
754
|
+
throw new MailGatewayFault('mail_invalid_content_length',{statusCode:400});
|
|
755
|
+
}
|
|
756
|
+
const length=Number(raw);
|
|
757
|
+
if(!Number.isSafeInteger(length)){
|
|
758
|
+
throw new MailGatewayFault('mail_invalid_content_length',{statusCode:400});
|
|
759
|
+
}
|
|
760
|
+
if(length>maxRequestBytes){
|
|
761
|
+
throw new MailGatewayFault('mail_request_too_large',{statusCode:413});
|
|
762
|
+
}
|
|
763
|
+
return length;
|
|
764
|
+
}
|
|
765
|
+
|
|
766
|
+
function readRequestBody(request,{maxRequestBytes,timeoutMs,signal}){
|
|
767
|
+
return new Promise(function collectRequestBody(resolve,reject){
|
|
768
|
+
const chunks=[];
|
|
769
|
+
let byteLength=0;
|
|
770
|
+
let settled=false;
|
|
771
|
+
const timer=setTimeout(function expireRequestBody(){
|
|
772
|
+
finish(new MailGatewayFault('mail_body_timeout',{
|
|
773
|
+
retryable:true,
|
|
774
|
+
statusCode:408
|
|
775
|
+
}));
|
|
776
|
+
request.resume();
|
|
777
|
+
},timeoutMs);
|
|
778
|
+
|
|
779
|
+
function cleanup(){
|
|
780
|
+
clearTimeout(timer);
|
|
781
|
+
request.removeListener('data',onData);
|
|
782
|
+
request.removeListener('end',onEnd);
|
|
783
|
+
request.removeListener('error',onError);
|
|
784
|
+
request.removeListener('aborted',onAborted);
|
|
785
|
+
signal?.removeEventListener('abort',onSignalAbort);
|
|
786
|
+
}
|
|
787
|
+
|
|
788
|
+
function finish(error,value){
|
|
789
|
+
if(settled){
|
|
790
|
+
return;
|
|
791
|
+
}
|
|
792
|
+
settled=true;
|
|
793
|
+
cleanup();
|
|
794
|
+
if(error){
|
|
795
|
+
reject(error);
|
|
796
|
+
}else{
|
|
797
|
+
resolve(value);
|
|
798
|
+
}
|
|
799
|
+
}
|
|
800
|
+
|
|
801
|
+
function onData(chunk){
|
|
802
|
+
const bytes=Buffer.isBuffer(chunk)?chunk:Buffer.from(chunk);
|
|
803
|
+
byteLength+=bytes.length;
|
|
804
|
+
if(byteLength>maxRequestBytes){
|
|
805
|
+
finish(new MailGatewayFault('mail_request_too_large',{statusCode:413}));
|
|
806
|
+
request.resume();
|
|
807
|
+
return;
|
|
808
|
+
}
|
|
809
|
+
chunks.push(bytes);
|
|
810
|
+
}
|
|
811
|
+
|
|
812
|
+
function onEnd(){
|
|
813
|
+
finish(null,Buffer.concat(chunks,byteLength).toString('utf8'));
|
|
814
|
+
}
|
|
815
|
+
|
|
816
|
+
function onError(){
|
|
817
|
+
finish(new MailGatewayFault('mail_request_stream_failed',{
|
|
818
|
+
retryable:true,
|
|
819
|
+
statusCode:400
|
|
820
|
+
}));
|
|
821
|
+
}
|
|
822
|
+
|
|
823
|
+
function onAborted(){
|
|
824
|
+
finish(new MailGatewayFault('mail_request_cancelled',{
|
|
825
|
+
retryable:true,
|
|
826
|
+
statusCode:408
|
|
827
|
+
}));
|
|
828
|
+
}
|
|
829
|
+
|
|
830
|
+
function onSignalAbort(){
|
|
831
|
+
finish(new MailGatewayFault('mail_request_cancelled',{
|
|
832
|
+
retryable:true,
|
|
833
|
+
statusCode:408
|
|
834
|
+
}));
|
|
835
|
+
request.resume();
|
|
836
|
+
}
|
|
837
|
+
|
|
838
|
+
request.on('data',onData);
|
|
839
|
+
request.once('end',onEnd);
|
|
840
|
+
request.once('error',onError);
|
|
841
|
+
request.once('aborted',onAborted);
|
|
842
|
+
signal?.addEventListener('abort',onSignalAbort,{once:true});
|
|
843
|
+
if(signal?.aborted){
|
|
844
|
+
onSignalAbort();
|
|
845
|
+
}
|
|
846
|
+
});
|
|
847
|
+
}
|
|
848
|
+
|
|
849
|
+
function utf8Bytes(value){
|
|
850
|
+
return Buffer.byteLength(value,'utf8');
|
|
851
|
+
}
|
|
852
|
+
|
|
853
|
+
function normalizedReportEmail(value){
|
|
854
|
+
if(typeof value!=='string'){
|
|
855
|
+
throw new MailGatewayFault('mail_invalid_recipient',{statusCode:422});
|
|
856
|
+
}
|
|
857
|
+
const address=value.trim().toLowerCase();
|
|
858
|
+
if(address.length<3||address.length>254||!EMAIL_PATTERN.test(address)){
|
|
859
|
+
throw new MailGatewayFault('mail_invalid_recipient',{statusCode:422});
|
|
860
|
+
}
|
|
861
|
+
return address;
|
|
862
|
+
}
|
|
863
|
+
|
|
864
|
+
function normalizeReportRecipients(report,configuration){
|
|
865
|
+
if(!Array.isArray(report.to)||report.to.length>50){
|
|
866
|
+
throw new MailGatewayFault('mail_invalid_recipients',{statusCode:422});
|
|
867
|
+
}
|
|
868
|
+
const recipients=[];
|
|
869
|
+
const seen=new Set();
|
|
870
|
+
for(const value of report.to){
|
|
871
|
+
const address=normalizedReportEmail(value);
|
|
872
|
+
if(seen.has(address)){
|
|
873
|
+
throw new MailGatewayFault('mail_duplicate_recipient',{statusCode:422});
|
|
874
|
+
}
|
|
875
|
+
if(!configuration.allowAnyRecipient&&!configuration.allowedRecipients.has(address)){
|
|
876
|
+
throw new MailGatewayFault('mail_recipient_not_allowed',{statusCode:403});
|
|
877
|
+
}
|
|
878
|
+
seen.add(address);
|
|
879
|
+
recipients.push(address);
|
|
880
|
+
}
|
|
881
|
+
if(recipients.length===0&&report.type==='error'){
|
|
882
|
+
recipients.push(...configuration.errorRecipients);
|
|
883
|
+
}
|
|
884
|
+
if(recipients.length===0){
|
|
885
|
+
throw new MailGatewayFault('mail_recipients_required',{statusCode:422});
|
|
886
|
+
}
|
|
887
|
+
return recipients;
|
|
888
|
+
}
|
|
889
|
+
|
|
890
|
+
function normalizeReport(value,configuration){
|
|
891
|
+
if(!value||typeof value!=='object'||Array.isArray(value)){
|
|
892
|
+
throw new MailGatewayFault('mail_invalid_report',{statusCode:422});
|
|
893
|
+
}
|
|
894
|
+
const keys=Object.keys(value);
|
|
895
|
+
if(keys.some(function reportKeyIsUnknown(key){return !REPORT_KEYS.has(key);})
|
|
896
|
+
||!Object.hasOwn(value,'subject')||!Object.hasOwn(value,'to')
|
|
897
|
+
||!Object.hasOwn(value,'type')){
|
|
898
|
+
throw new MailGatewayFault('mail_invalid_report_shape',{statusCode:422});
|
|
899
|
+
}
|
|
900
|
+
if(typeof value.type!=='string'||!MAIL_TYPES.has(value.type)){
|
|
901
|
+
throw new MailGatewayFault('mail_invalid_type',{statusCode:422});
|
|
902
|
+
}
|
|
903
|
+
if(typeof value.subject!=='string'||value.subject!==value.subject.trim()
|
|
904
|
+
||value.subject.length<1||value.subject.length>160
|
|
905
|
+
||/[\u0000-\u001f\u007f]/u.test(value.subject)){
|
|
906
|
+
throw new MailGatewayFault('mail_invalid_subject',{statusCode:422});
|
|
907
|
+
}
|
|
908
|
+
const hasText=Object.hasOwn(value,'text');
|
|
909
|
+
const hasHtml=Object.hasOwn(value,'html');
|
|
910
|
+
if(!hasText&&!hasHtml||(hasText&&typeof value.text!=='string')
|
|
911
|
+
||(hasHtml&&typeof value.html!=='string')){
|
|
912
|
+
throw new MailGatewayFault('mail_content_required',{statusCode:422});
|
|
913
|
+
}
|
|
914
|
+
const text=hasText?value.text:'';
|
|
915
|
+
const html=hasHtml?value.html:'';
|
|
916
|
+
if(!/\S/u.test(text)&&!/\S/u.test(html)){
|
|
917
|
+
throw new MailGatewayFault('mail_content_required',{statusCode:422});
|
|
918
|
+
}
|
|
919
|
+
const unsupportedControl=/[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f]/u;
|
|
920
|
+
if(unsupportedControl.test(text)||unsupportedControl.test(html)){
|
|
921
|
+
throw new MailGatewayFault('mail_unsupported_content',{statusCode:422});
|
|
922
|
+
}
|
|
923
|
+
const messageBytes=utf8Bytes(text)+utf8Bytes(html);
|
|
924
|
+
if(messageBytes>configuration.maxMessageBytes){
|
|
925
|
+
throw new MailGatewayFault('mail_message_too_large',{statusCode:413});
|
|
926
|
+
}
|
|
927
|
+
const recipients=normalizeReportRecipients(value,configuration);
|
|
928
|
+
const providerBody={
|
|
929
|
+
from:configuration.from,
|
|
930
|
+
to:recipients,
|
|
931
|
+
subject:value.subject,
|
|
932
|
+
...(hasText?{text:value.text}:{}),
|
|
933
|
+
...(hasHtml?{html:value.html}:{})
|
|
934
|
+
};
|
|
935
|
+
const serializedProviderBody=JSON.stringify(providerBody);
|
|
936
|
+
if(utf8Bytes(serializedProviderBody)>configuration.maxRequestBytes){
|
|
937
|
+
throw new MailGatewayFault('mail_request_too_large',{statusCode:413});
|
|
938
|
+
}
|
|
939
|
+
return {
|
|
940
|
+
providerBody:serializedProviderBody,
|
|
941
|
+
providerBodyBytes:utf8Bytes(serializedProviderBody),
|
|
942
|
+
recipientCount:recipients.length
|
|
943
|
+
};
|
|
944
|
+
}
|
|
945
|
+
|
|
946
|
+
function parseReport(serialized,configuration){
|
|
947
|
+
let value;
|
|
948
|
+
try{
|
|
949
|
+
value=JSON.parse(serialized);
|
|
950
|
+
}catch{
|
|
951
|
+
throw new MailGatewayFault('mail_invalid_json',{statusCode:400});
|
|
952
|
+
}
|
|
953
|
+
return normalizeReport(value,configuration);
|
|
954
|
+
}
|
|
955
|
+
|
|
956
|
+
function parseRetryAfter(value,now=Date.now()){
|
|
957
|
+
if(typeof value!=='string'||!value.trim()){
|
|
958
|
+
return 0;
|
|
959
|
+
}
|
|
960
|
+
const trimmed=value.trim();
|
|
961
|
+
if(/^\d+(?:\.\d+)?$/u.test(trimmed)){
|
|
962
|
+
return boundedRetryAfter(Math.ceil(Number(trimmed)*1000));
|
|
963
|
+
}
|
|
964
|
+
const timestamp=Date.parse(trimmed);
|
|
965
|
+
return Number.isFinite(timestamp)
|
|
966
|
+
? boundedRetryAfter(Math.max(0,timestamp-now))
|
|
967
|
+
: 0;
|
|
968
|
+
}
|
|
969
|
+
|
|
970
|
+
function responseHeader(response,name){
|
|
971
|
+
try{
|
|
972
|
+
const value=response.headers?.get?.(name);
|
|
973
|
+
return value===null||value===undefined?'':String(value);
|
|
974
|
+
}catch{
|
|
975
|
+
return '';
|
|
976
|
+
}
|
|
977
|
+
}
|
|
978
|
+
|
|
979
|
+
function awaitAbortable(value,signal){
|
|
980
|
+
return new Promise(function waitForAbortable(resolve,reject){
|
|
981
|
+
let settled=false;
|
|
982
|
+
function cleanup(){
|
|
983
|
+
signal?.removeEventListener('abort',onAbort);
|
|
984
|
+
}
|
|
985
|
+
function settleResolved(result){
|
|
986
|
+
if(settled)return;
|
|
987
|
+
settled=true;
|
|
988
|
+
cleanup();
|
|
989
|
+
resolve(result);
|
|
990
|
+
}
|
|
991
|
+
function settleRejected(error){
|
|
992
|
+
if(settled)return;
|
|
993
|
+
settled=true;
|
|
994
|
+
cleanup();
|
|
995
|
+
reject(error);
|
|
996
|
+
}
|
|
997
|
+
function onAbort(){
|
|
998
|
+
settleRejected(signal.reason??new Error('Operation cancelled.'));
|
|
999
|
+
}
|
|
1000
|
+
signal?.addEventListener('abort',onAbort,{once:true});
|
|
1001
|
+
Promise.resolve(value).then(settleResolved,settleRejected);
|
|
1002
|
+
if(signal?.aborted){
|
|
1003
|
+
onAbort();
|
|
1004
|
+
}
|
|
1005
|
+
});
|
|
1006
|
+
}
|
|
1007
|
+
|
|
1008
|
+
function ignoreCancellationFailure(){}
|
|
1009
|
+
|
|
1010
|
+
function cancelProviderBody(body){
|
|
1011
|
+
if(!body||typeof body.cancel!=='function'){
|
|
1012
|
+
return;
|
|
1013
|
+
}
|
|
1014
|
+
try{
|
|
1015
|
+
Promise.resolve(body.cancel()).catch(ignoreCancellationFailure);
|
|
1016
|
+
}catch{
|
|
1017
|
+
// Cancellation is best-effort after the response boundary is classified.
|
|
1018
|
+
}
|
|
1019
|
+
}
|
|
1020
|
+
|
|
1021
|
+
function cancelProviderReader(reader){
|
|
1022
|
+
if(!reader||typeof reader.cancel!=='function'){
|
|
1023
|
+
return;
|
|
1024
|
+
}
|
|
1025
|
+
try{
|
|
1026
|
+
Promise.resolve(reader.cancel()).catch(ignoreCancellationFailure);
|
|
1027
|
+
}catch{
|
|
1028
|
+
// Cancellation is best-effort after the response boundary is classified.
|
|
1029
|
+
}
|
|
1030
|
+
}
|
|
1031
|
+
|
|
1032
|
+
async function readProviderBody(response,maxBytes,signal){
|
|
1033
|
+
const declared=responseHeader(response,'content-length');
|
|
1034
|
+
if(declared){
|
|
1035
|
+
if(!/^\d+$/u.test(declared)||!Number.isSafeInteger(Number(declared))
|
|
1036
|
+
||Number(declared)>maxBytes){
|
|
1037
|
+
cancelProviderBody(response.body);
|
|
1038
|
+
throw new MailGatewayFault('resend_response_too_large',{
|
|
1039
|
+
statusCode:502,
|
|
1040
|
+
uncertain:true
|
|
1041
|
+
});
|
|
1042
|
+
}
|
|
1043
|
+
}
|
|
1044
|
+
if(response.body===null||response.body===undefined){
|
|
1045
|
+
return '';
|
|
1046
|
+
}
|
|
1047
|
+
if(typeof response.body.getReader!=='function'){
|
|
1048
|
+
cancelProviderBody(response.body);
|
|
1049
|
+
throw new MailGatewayFault('resend_unreadable_response',{
|
|
1050
|
+
statusCode:502,
|
|
1051
|
+
uncertain:true
|
|
1052
|
+
});
|
|
1053
|
+
}
|
|
1054
|
+
const reader=response.body.getReader();
|
|
1055
|
+
const chunks=[];
|
|
1056
|
+
let byteLength=0;
|
|
1057
|
+
let fullyRead=false;
|
|
1058
|
+
try{
|
|
1059
|
+
while(true){
|
|
1060
|
+
const result=await awaitAbortable(reader.read(),signal);
|
|
1061
|
+
if(result.done){
|
|
1062
|
+
fullyRead=true;
|
|
1063
|
+
break;
|
|
1064
|
+
}
|
|
1065
|
+
if(!(result.value instanceof Uint8Array)){
|
|
1066
|
+
throw new MailGatewayFault('resend_unreadable_response',{
|
|
1067
|
+
statusCode:502,
|
|
1068
|
+
uncertain:true
|
|
1069
|
+
});
|
|
1070
|
+
}
|
|
1071
|
+
byteLength+=result.value.byteLength;
|
|
1072
|
+
if(byteLength>maxBytes){
|
|
1073
|
+
throw new MailGatewayFault('resend_response_too_large',{
|
|
1074
|
+
statusCode:502,
|
|
1075
|
+
uncertain:true
|
|
1076
|
+
});
|
|
1077
|
+
}
|
|
1078
|
+
chunks.push(Buffer.from(result.value));
|
|
1079
|
+
}
|
|
1080
|
+
return Buffer.concat(chunks,byteLength).toString('utf8');
|
|
1081
|
+
}finally{
|
|
1082
|
+
if(!fullyRead){
|
|
1083
|
+
cancelProviderReader(reader);
|
|
1084
|
+
}
|
|
1085
|
+
try{
|
|
1086
|
+
reader.releaseLock();
|
|
1087
|
+
}catch{
|
|
1088
|
+
// An untrusted stream implementation cannot replace the provider classification.
|
|
1089
|
+
}
|
|
1090
|
+
}
|
|
1091
|
+
}
|
|
1092
|
+
|
|
1093
|
+
function parseProviderObject(text){
|
|
1094
|
+
if(!text){
|
|
1095
|
+
return null;
|
|
1096
|
+
}
|
|
1097
|
+
try{
|
|
1098
|
+
const value=JSON.parse(text);
|
|
1099
|
+
return value&&typeof value==='object'&&!Array.isArray(value)?value:null;
|
|
1100
|
+
}catch{
|
|
1101
|
+
return null;
|
|
1102
|
+
}
|
|
1103
|
+
}
|
|
1104
|
+
|
|
1105
|
+
function providerCode(value,statusCode){
|
|
1106
|
+
const candidate=value?.name;
|
|
1107
|
+
if(typeof candidate==='string'&&PROVIDER_CODE_PATTERN.test(candidate)){
|
|
1108
|
+
return candidate;
|
|
1109
|
+
}
|
|
1110
|
+
return `resend_http_${String(statusCode)}`;
|
|
1111
|
+
}
|
|
1112
|
+
|
|
1113
|
+
function providerRejection(statusCode,value,retryAfterMs,defaultRetryAfterMs){
|
|
1114
|
+
const code=providerCode(value,statusCode);
|
|
1115
|
+
const permanentRateLimit=PERMANENT_RATE_CODES.has(code);
|
|
1116
|
+
const retryable=code==='concurrent_idempotent_requests'
|
|
1117
|
+
||(statusCode===409&&code!=='invalid_idempotent_request')
|
|
1118
|
+
||(!permanentRateLimit&&code!=='invalid_idempotent_request'
|
|
1119
|
+
&&RETRYABLE_PROVIDER_STATUSES.has(statusCode));
|
|
1120
|
+
const resolvedDelay=retryable
|
|
1121
|
+
? boundedRetryAfter(retryAfterMs||defaultRetryAfterMs)
|
|
1122
|
+
: 0;
|
|
1123
|
+
return {
|
|
1124
|
+
kind:'rejected',
|
|
1125
|
+
fault:new MailGatewayFault(code,{
|
|
1126
|
+
retryable,
|
|
1127
|
+
retryAfterMs:resolvedDelay,
|
|
1128
|
+
statusCode:retryable?(statusCode===429?429:503):422
|
|
1129
|
+
}),
|
|
1130
|
+
providerStatus:statusCode
|
|
1131
|
+
};
|
|
1132
|
+
}
|
|
1133
|
+
|
|
1134
|
+
function ambiguousResult(code,retryAfterMs,providerStatus=0){
|
|
1135
|
+
return {
|
|
1136
|
+
code,
|
|
1137
|
+
kind:'ambiguous',
|
|
1138
|
+
providerStatus,
|
|
1139
|
+
retryAfterMs:boundedRetryAfter(retryAfterMs)
|
|
1140
|
+
};
|
|
1141
|
+
}
|
|
1142
|
+
|
|
1143
|
+
async function performResendAttempt(configuration,delivery,idempotencyKey,signal,requestId,observe){
|
|
1144
|
+
const controller=new AbortController();
|
|
1145
|
+
let timedOut=false;
|
|
1146
|
+
function forwardAbort(){
|
|
1147
|
+
controller.abort(signal?.reason??new Error('Mail request cancelled.'));
|
|
1148
|
+
}
|
|
1149
|
+
signal?.addEventListener('abort',forwardAbort,{once:true});
|
|
1150
|
+
if(signal?.aborted){
|
|
1151
|
+
forwardAbort();
|
|
1152
|
+
}
|
|
1153
|
+
const timeout=setTimeout(function expireResendAttempt(){
|
|
1154
|
+
timedOut=true;
|
|
1155
|
+
controller.abort(new Error('Resend request timed out.'));
|
|
1156
|
+
},configuration.providerTimeoutMs);
|
|
1157
|
+
const startedAt=Date.now();
|
|
1158
|
+
observe({
|
|
1159
|
+
type:'mail.provider.started',
|
|
1160
|
+
appId:configuration.appId,
|
|
1161
|
+
requestId
|
|
1162
|
+
});
|
|
1163
|
+
let response;
|
|
1164
|
+
try{
|
|
1165
|
+
try{
|
|
1166
|
+
response=await awaitAbortable(configuration.fetchImpl(RESEND_EMAIL_ENDPOINT,{
|
|
1167
|
+
method:'POST',
|
|
1168
|
+
headers:{
|
|
1169
|
+
'Authorization':`Bearer ${configuration.apiKey}`,
|
|
1170
|
+
'Content-Type':'application/json',
|
|
1171
|
+
'Idempotency-Key':idempotencyKey,
|
|
1172
|
+
'User-Agent':'arcane-os-sdk-mail/1'
|
|
1173
|
+
},
|
|
1174
|
+
body:delivery.providerBody,
|
|
1175
|
+
redirect:'error',
|
|
1176
|
+
referrerPolicy:'no-referrer',
|
|
1177
|
+
signal:controller.signal
|
|
1178
|
+
}),controller.signal);
|
|
1179
|
+
}catch{
|
|
1180
|
+
return ambiguousResult(
|
|
1181
|
+
timedOut?'resend_timeout':'resend_transport_uncertain',
|
|
1182
|
+
configuration.retryableDelayMs
|
|
1183
|
+
);
|
|
1184
|
+
}
|
|
1185
|
+
const statusCode=Number(response?.status);
|
|
1186
|
+
if(!Number.isSafeInteger(statusCode)||statusCode<100||statusCode>599){
|
|
1187
|
+
return ambiguousResult('resend_invalid_response',configuration.retryableDelayMs);
|
|
1188
|
+
}
|
|
1189
|
+
let text='';
|
|
1190
|
+
try{
|
|
1191
|
+
text=await readProviderBody(
|
|
1192
|
+
response,
|
|
1193
|
+
configuration.maxProviderResponseBytes,
|
|
1194
|
+
controller.signal
|
|
1195
|
+
);
|
|
1196
|
+
}catch(error){
|
|
1197
|
+
if(statusCode>=200&&statusCode<300||controller.signal.aborted){
|
|
1198
|
+
return ambiguousResult(
|
|
1199
|
+
error instanceof MailGatewayFault?error.code:'resend_transport_uncertain',
|
|
1200
|
+
configuration.retryableDelayMs,
|
|
1201
|
+
statusCode
|
|
1202
|
+
);
|
|
1203
|
+
}
|
|
1204
|
+
return providerRejection(
|
|
1205
|
+
statusCode,
|
|
1206
|
+
null,
|
|
1207
|
+
parseRetryAfter(responseHeader(response,'retry-after')),
|
|
1208
|
+
configuration.retryableDelayMs
|
|
1209
|
+
);
|
|
1210
|
+
}
|
|
1211
|
+
const value=parseProviderObject(text);
|
|
1212
|
+
if(statusCode>=200&&statusCode<300){
|
|
1213
|
+
if(!value||typeof value.id!=='string'||!PROVIDER_ID_PATTERN.test(value.id)){
|
|
1214
|
+
return ambiguousResult(
|
|
1215
|
+
'resend_invalid_success_response',
|
|
1216
|
+
configuration.retryableDelayMs,
|
|
1217
|
+
statusCode
|
|
1218
|
+
);
|
|
1219
|
+
}
|
|
1220
|
+
return {
|
|
1221
|
+
kind:'accepted',
|
|
1222
|
+
providerId:value.id,
|
|
1223
|
+
providerStatus:statusCode
|
|
1224
|
+
};
|
|
1225
|
+
}
|
|
1226
|
+
return providerRejection(
|
|
1227
|
+
statusCode,
|
|
1228
|
+
value,
|
|
1229
|
+
parseRetryAfter(responseHeader(response,'retry-after')),
|
|
1230
|
+
configuration.retryableDelayMs
|
|
1231
|
+
);
|
|
1232
|
+
}finally{
|
|
1233
|
+
clearTimeout(timeout);
|
|
1234
|
+
signal?.removeEventListener('abort',forwardAbort);
|
|
1235
|
+
observe({
|
|
1236
|
+
type:'mail.provider.completed',
|
|
1237
|
+
appId:configuration.appId,
|
|
1238
|
+
durationMs:Math.max(0,Date.now()-startedAt),
|
|
1239
|
+
requestId,
|
|
1240
|
+
providerStatus:Number.isSafeInteger(Number(response?.status))?Number(response.status):0
|
|
1241
|
+
});
|
|
1242
|
+
}
|
|
1243
|
+
}
|
|
1244
|
+
|
|
1245
|
+
function normalizeDirectSendOptions(options){
|
|
1246
|
+
if(!options||typeof options!=='object'||Array.isArray(options)){
|
|
1247
|
+
throw configurationError('Mail send options must be an object.');
|
|
1248
|
+
}
|
|
1249
|
+
if(typeof options.reportKey!=='string'||!IDEMPOTENCY_KEY_PATTERN.test(options.reportKey)){
|
|
1250
|
+
throw configurationError('reportKey must contain 8-128 safe identifier characters.');
|
|
1251
|
+
}
|
|
1252
|
+
const maxMessageBytes=boundedInteger(
|
|
1253
|
+
options.maxMessageBytes,
|
|
1254
|
+
DEFAULT_MAX_MESSAGE_BYTES,
|
|
1255
|
+
{label:'maxMessageBytes',min:1,max:DEFAULT_MAX_MESSAGE_BYTES}
|
|
1256
|
+
);
|
|
1257
|
+
const maxRequestBytes=boundedInteger(
|
|
1258
|
+
options.maxRequestBytes,
|
|
1259
|
+
DEFAULT_MAX_REQUEST_BYTES,
|
|
1260
|
+
{label:'maxRequestBytes',min:256,max:64*1024*1024}
|
|
1261
|
+
);
|
|
1262
|
+
if(maxRequestBytes<maxMessageBytes+256){
|
|
1263
|
+
throw configurationError('maxRequestBytes must leave at least 256 bytes beyond maxMessageBytes.');
|
|
1264
|
+
}
|
|
1265
|
+
const fetchImpl=options.fetchImpl??globalThis.fetch;
|
|
1266
|
+
if(typeof fetchImpl!=='function'){
|
|
1267
|
+
throw configurationError('A fetch implementation is required for Resend delivery.');
|
|
1268
|
+
}
|
|
1269
|
+
if(options.onEvent!==undefined&&typeof options.onEvent!=='function'){
|
|
1270
|
+
throw configurationError('onEvent must be a function when supplied.');
|
|
1271
|
+
}
|
|
1272
|
+
if(options.requestIdFactory!==undefined&&typeof options.requestIdFactory!=='function'){
|
|
1273
|
+
throw configurationError('requestIdFactory must be a function when supplied.');
|
|
1274
|
+
}
|
|
1275
|
+
return Object.freeze({
|
|
1276
|
+
allowAnyRecipient:true,
|
|
1277
|
+
allowedRecipients:null,
|
|
1278
|
+
apiKey:validateApiKey(options.apiKey),
|
|
1279
|
+
appId:validateAppId(options.appId),
|
|
1280
|
+
errorRecipients:[],
|
|
1281
|
+
fetchImpl,
|
|
1282
|
+
from:validateFrom(options.from),
|
|
1283
|
+
maxMessageBytes,
|
|
1284
|
+
maxProviderResponseBytes:boundedInteger(
|
|
1285
|
+
options.maxProviderResponseBytes,
|
|
1286
|
+
DEFAULT_MAX_PROVIDER_RESPONSE_BYTES,
|
|
1287
|
+
{label:'maxProviderResponseBytes',min:64,max:1024*1024}
|
|
1288
|
+
),
|
|
1289
|
+
maxRequestBytes,
|
|
1290
|
+
observerDrainTimeoutMs:boundedInteger(
|
|
1291
|
+
options.observerDrainTimeoutMs,
|
|
1292
|
+
1_000,
|
|
1293
|
+
{label:'observerDrainTimeoutMs',min:1,max:10_000}
|
|
1294
|
+
),
|
|
1295
|
+
providerTimeoutMs:boundedInteger(
|
|
1296
|
+
options.providerTimeoutMs,
|
|
1297
|
+
120_000,
|
|
1298
|
+
{label:'providerTimeoutMs',min:100,max:600_000}
|
|
1299
|
+
),
|
|
1300
|
+
requestIdFactory:options.requestIdFactory??randomUUID,
|
|
1301
|
+
retryableDelayMs:boundedInteger(
|
|
1302
|
+
options.retryableDelayMs,
|
|
1303
|
+
1_000,
|
|
1304
|
+
{label:'retryableDelayMs',min:1,max:60_000}
|
|
1305
|
+
),
|
|
1306
|
+
signal:validateSignal(options.signal),
|
|
1307
|
+
report:options.report,
|
|
1308
|
+
reportKey:options.reportKey,
|
|
1309
|
+
onEvent:options.onEvent
|
|
1310
|
+
});
|
|
1311
|
+
}
|
|
1312
|
+
|
|
1313
|
+
function directSendResult(result,{recipientCount,requestId}){
|
|
1314
|
+
const common={
|
|
1315
|
+
provider:'resend',
|
|
1316
|
+
status:result.kind==='accepted'
|
|
1317
|
+
?'accepted'
|
|
1318
|
+
:result.kind==='ambiguous'?'delivery_uncertain':'rejected',
|
|
1319
|
+
classification:result.kind==='rejected'
|
|
1320
|
+
?result.fault.retryable?'retryable':'permanent'
|
|
1321
|
+
:result.kind,
|
|
1322
|
+
requestId,
|
|
1323
|
+
providerStatus:result.providerStatus,
|
|
1324
|
+
recipientCount
|
|
1325
|
+
};
|
|
1326
|
+
if(result.kind==='accepted'){
|
|
1327
|
+
return Object.freeze({...common,providerId:result.providerId});
|
|
1328
|
+
}
|
|
1329
|
+
if(result.kind==='ambiguous'){
|
|
1330
|
+
return Object.freeze({
|
|
1331
|
+
...common,
|
|
1332
|
+
code:result.code,
|
|
1333
|
+
...(result.retryAfterMs?{retryAfterMs:result.retryAfterMs}:{}),
|
|
1334
|
+
retryable:true,
|
|
1335
|
+
uncertain:true
|
|
1336
|
+
});
|
|
1337
|
+
}
|
|
1338
|
+
return Object.freeze({
|
|
1339
|
+
...common,
|
|
1340
|
+
code:result.fault.code,
|
|
1341
|
+
...(result.fault.retryAfterMs?{retryAfterMs:result.fault.retryAfterMs}:{}),
|
|
1342
|
+
retryable:result.fault.retryable,
|
|
1343
|
+
uncertain:false
|
|
1344
|
+
});
|
|
1345
|
+
}
|
|
1346
|
+
|
|
1347
|
+
export async function sendResendMail(options={}){
|
|
1348
|
+
const configuration=normalizeDirectSendOptions(options);
|
|
1349
|
+
const delivery=normalizeReport(configuration.report,configuration);
|
|
1350
|
+
if(configuration.signal?.aborted){
|
|
1351
|
+
const error=new Error('Mail send cancelled before provider attempt.');
|
|
1352
|
+
error.code='ARCANE_CANCELLED';
|
|
1353
|
+
throw error;
|
|
1354
|
+
}
|
|
1355
|
+
const requestId=createRequestId(configuration.requestIdFactory);
|
|
1356
|
+
const observer=createObserver(
|
|
1357
|
+
configuration.onEvent,
|
|
1358
|
+
configuration.observerDrainTimeoutMs
|
|
1359
|
+
);
|
|
1360
|
+
try{
|
|
1361
|
+
const result=await performResendAttempt(
|
|
1362
|
+
configuration,
|
|
1363
|
+
delivery,
|
|
1364
|
+
configuration.reportKey,
|
|
1365
|
+
configuration.signal,
|
|
1366
|
+
requestId,
|
|
1367
|
+
observer.observe
|
|
1368
|
+
);
|
|
1369
|
+
return directSendResult(result,{
|
|
1370
|
+
recipientCount:delivery.recipientCount,
|
|
1371
|
+
requestId
|
|
1372
|
+
});
|
|
1373
|
+
}finally{
|
|
1374
|
+
await observer.drain();
|
|
1375
|
+
}
|
|
1376
|
+
}
|
|
1377
|
+
|
|
1378
|
+
function sendProviderResult(response,result,{origin,requestId,recipientCount}){
|
|
1379
|
+
if(result.kind==='accepted'){
|
|
1380
|
+
return writeJson(response,202,{
|
|
1381
|
+
requestId,
|
|
1382
|
+
status:'accepted',
|
|
1383
|
+
accepted:recipientCount,
|
|
1384
|
+
rejected:0,
|
|
1385
|
+
providerId:result.providerId
|
|
1386
|
+
},{origin});
|
|
1387
|
+
}
|
|
1388
|
+
if(result.kind==='ambiguous'){
|
|
1389
|
+
return writeJson(response,207,{
|
|
1390
|
+
requestId,
|
|
1391
|
+
status:'delivery_uncertain',
|
|
1392
|
+
accepted:0,
|
|
1393
|
+
rejected:0,
|
|
1394
|
+
...(result.retryAfterMs?{retryAfterMs:result.retryAfterMs}:{})
|
|
1395
|
+
},{origin,retryAfterMs:result.retryAfterMs});
|
|
1396
|
+
}
|
|
1397
|
+
return writeFault(response,requestId,result.fault,origin);
|
|
1398
|
+
}
|
|
1399
|
+
|
|
1400
|
+
export function createResendMailRequestHandler(options={}){
|
|
1401
|
+
const configuration=normalizeConfiguration(options);
|
|
1402
|
+
const ownerController=new AbortController();
|
|
1403
|
+
const activeRequests=new Set();
|
|
1404
|
+
const observer=createObserver(
|
|
1405
|
+
configuration.onEvent,
|
|
1406
|
+
configuration.observerDrainTimeoutMs
|
|
1407
|
+
);
|
|
1408
|
+
const bodyScheduler=createScheduler({
|
|
1409
|
+
code:'mail_body_backpressure',
|
|
1410
|
+
concurrency:configuration.maxConcurrentBodyReads,
|
|
1411
|
+
maxQueued:configuration.maxQueuedBodyReads,
|
|
1412
|
+
queueTimeoutMs:configuration.bodyQueueTimeoutMs,
|
|
1413
|
+
retryAfterMs:250
|
|
1414
|
+
});
|
|
1415
|
+
const sendScheduler=createScheduler({
|
|
1416
|
+
code:'mail_send_backpressure',
|
|
1417
|
+
concurrency:configuration.maxConcurrentSends,
|
|
1418
|
+
maxQueued:configuration.maxQueuedSends,
|
|
1419
|
+
maxQueuedWeight:configuration.maxQueuedMessageBytes,
|
|
1420
|
+
queueTimeoutMs:configuration.sendQueueTimeoutMs,
|
|
1421
|
+
retryAfterMs:configuration.retryableDelayMs,
|
|
1422
|
+
weightCode:'mail_send_byte_backpressure'
|
|
1423
|
+
});
|
|
1424
|
+
let closePromise=null;
|
|
1425
|
+
|
|
1426
|
+
function forwardOwnerAbort(){
|
|
1427
|
+
ownerController.abort(configuration.signal?.reason??new Error('Mail server cancelled.'));
|
|
1428
|
+
}
|
|
1429
|
+
configuration.signal?.addEventListener('abort',forwardOwnerAbort,{once:true});
|
|
1430
|
+
if(configuration.signal?.aborted){
|
|
1431
|
+
forwardOwnerAbort();
|
|
1432
|
+
}
|
|
1433
|
+
|
|
1434
|
+
async function handleOwnedRequest(request,response){
|
|
1435
|
+
const requestId=createRequestId(configuration.requestIdFactory);
|
|
1436
|
+
const startedAt=Date.now();
|
|
1437
|
+
const requestController=new AbortController();
|
|
1438
|
+
let origin='';
|
|
1439
|
+
let providerAttempted=false;
|
|
1440
|
+
|
|
1441
|
+
function abortFromOwner(){
|
|
1442
|
+
requestController.abort(ownerController.signal.reason);
|
|
1443
|
+
}
|
|
1444
|
+
function abortFromRequest(){
|
|
1445
|
+
requestController.abort(new Error('Mail client disconnected.'));
|
|
1446
|
+
}
|
|
1447
|
+
function abortFromResponseClose(){
|
|
1448
|
+
if(!response.writableEnded){
|
|
1449
|
+
abortFromRequest();
|
|
1450
|
+
}
|
|
1451
|
+
}
|
|
1452
|
+
function absorbResponseError(){
|
|
1453
|
+
abortFromRequest();
|
|
1454
|
+
}
|
|
1455
|
+
function releaseRequestListeners(){
|
|
1456
|
+
request.removeListener('aborted',abortFromRequest);
|
|
1457
|
+
request.removeListener('error',abortFromRequest);
|
|
1458
|
+
}
|
|
1459
|
+
function releaseResponseListeners(){
|
|
1460
|
+
response.removeListener('close',abortFromResponseClose);
|
|
1461
|
+
response.removeListener('error',absorbResponseError);
|
|
1462
|
+
}
|
|
1463
|
+
|
|
1464
|
+
ownerController.signal.addEventListener('abort',abortFromOwner,{once:true});
|
|
1465
|
+
request.once('aborted',abortFromRequest);
|
|
1466
|
+
request.once('error',abortFromRequest);
|
|
1467
|
+
response.once('close',abortFromResponseClose);
|
|
1468
|
+
response.once('error',absorbResponseError);
|
|
1469
|
+
if(ownerController.signal.aborted){
|
|
1470
|
+
abortFromOwner();
|
|
1471
|
+
}
|
|
1472
|
+
observer.observe({
|
|
1473
|
+
type:'mail.request.received',
|
|
1474
|
+
appId:configuration.appId,
|
|
1475
|
+
requestId
|
|
1476
|
+
});
|
|
1477
|
+
|
|
1478
|
+
try{
|
|
1479
|
+
validateLoopbackRequest(request);
|
|
1480
|
+
origin=allowedOrigin(request,configuration);
|
|
1481
|
+
if(request.url!==RESEND_MAIL_PATH){
|
|
1482
|
+
throw new MailGatewayFault('mail_route_not_found',{statusCode:404});
|
|
1483
|
+
}
|
|
1484
|
+
if(request.method==='OPTIONS'){
|
|
1485
|
+
const preflight=validatePreflight(request);
|
|
1486
|
+
writePreflight(response,origin,preflight);
|
|
1487
|
+
return;
|
|
1488
|
+
}
|
|
1489
|
+
if(request.method!=='POST'){
|
|
1490
|
+
throw new MailGatewayFault('mail_method_not_allowed',{statusCode:405});
|
|
1491
|
+
}
|
|
1492
|
+
if(singleHeader(request,'x-mail-app')!==configuration.appId){
|
|
1493
|
+
throw new MailGatewayFault('mail_app_not_allowed',{statusCode:403});
|
|
1494
|
+
}
|
|
1495
|
+
authenticateLocalCaller(request,configuration);
|
|
1496
|
+
const idempotencyKey=singleHeader(request,'idempotency-key');
|
|
1497
|
+
if(!IDEMPOTENCY_KEY_PATTERN.test(idempotencyKey)){
|
|
1498
|
+
throw new MailGatewayFault('invalid_idempotency_key',{statusCode:400});
|
|
1499
|
+
}
|
|
1500
|
+
const contentType=singleHeader(request,'content-type');
|
|
1501
|
+
if(!JSON_CONTENT_TYPE_PATTERN.test(contentType)){
|
|
1502
|
+
throw new MailGatewayFault('mail_unsupported_content_type',{statusCode:415});
|
|
1503
|
+
}
|
|
1504
|
+
requestContentLength(request,configuration.maxRequestBytes);
|
|
1505
|
+
const serialized=await bodyScheduler.schedule(
|
|
1506
|
+
function readAdmittedMailBody(){
|
|
1507
|
+
return readRequestBody(request,{
|
|
1508
|
+
maxRequestBytes:configuration.maxRequestBytes,
|
|
1509
|
+
signal:requestController.signal,
|
|
1510
|
+
timeoutMs:configuration.bodyTimeoutMs
|
|
1511
|
+
});
|
|
1512
|
+
},
|
|
1513
|
+
{signal:requestController.signal}
|
|
1514
|
+
);
|
|
1515
|
+
const delivery=parseReport(serialized,configuration);
|
|
1516
|
+
const result=await sendScheduler.schedule(
|
|
1517
|
+
function makeSingleResendAttempt(){
|
|
1518
|
+
providerAttempted=true;
|
|
1519
|
+
return performResendAttempt(
|
|
1520
|
+
configuration,
|
|
1521
|
+
delivery,
|
|
1522
|
+
idempotencyKey,
|
|
1523
|
+
requestController.signal,
|
|
1524
|
+
requestId,
|
|
1525
|
+
observer.observe
|
|
1526
|
+
);
|
|
1527
|
+
},
|
|
1528
|
+
{
|
|
1529
|
+
signal:requestController.signal,
|
|
1530
|
+
weight:delivery.providerBodyBytes
|
|
1531
|
+
}
|
|
1532
|
+
);
|
|
1533
|
+
sendProviderResult(response,result,{
|
|
1534
|
+
origin,
|
|
1535
|
+
recipientCount:delivery.recipientCount,
|
|
1536
|
+
requestId
|
|
1537
|
+
});
|
|
1538
|
+
observer.observe({
|
|
1539
|
+
type:'mail.request.completed',
|
|
1540
|
+
appId:configuration.appId,
|
|
1541
|
+
classification:result.kind,
|
|
1542
|
+
durationMs:Math.max(0,Date.now()-startedAt),
|
|
1543
|
+
providerAttempted,
|
|
1544
|
+
requestId
|
|
1545
|
+
});
|
|
1546
|
+
}catch(error){
|
|
1547
|
+
const fault=normalizeFault(error);
|
|
1548
|
+
writeFault(response,requestId,fault,origin);
|
|
1549
|
+
if(!request.readableEnded&&!request.destroyed){
|
|
1550
|
+
request.resume();
|
|
1551
|
+
}
|
|
1552
|
+
observer.observe({
|
|
1553
|
+
type:'mail.request.completed',
|
|
1554
|
+
appId:configuration.appId,
|
|
1555
|
+
classification:fault.uncertain?'ambiguous':fault.retryable?'retryable':'permanent',
|
|
1556
|
+
durationMs:Math.max(0,Date.now()-startedAt),
|
|
1557
|
+
providerAttempted,
|
|
1558
|
+
requestId
|
|
1559
|
+
});
|
|
1560
|
+
}finally{
|
|
1561
|
+
ownerController.signal.removeEventListener('abort',abortFromOwner);
|
|
1562
|
+
if(request.readableEnded||request.destroyed){
|
|
1563
|
+
releaseRequestListeners();
|
|
1564
|
+
}else{
|
|
1565
|
+
request.once('end',releaseRequestListeners);
|
|
1566
|
+
}
|
|
1567
|
+
if(response.writableFinished||response.destroyed){
|
|
1568
|
+
releaseResponseListeners();
|
|
1569
|
+
}else{
|
|
1570
|
+
response.once('finish',releaseResponseListeners);
|
|
1571
|
+
}
|
|
1572
|
+
}
|
|
1573
|
+
}
|
|
1574
|
+
|
|
1575
|
+
function handle(request,response){
|
|
1576
|
+
const operation=handleOwnedRequest(request,response);
|
|
1577
|
+
activeRequests.add(operation);
|
|
1578
|
+
operation.catch(function closeFailedRequest(){
|
|
1579
|
+
if(!response.destroyed){
|
|
1580
|
+
response.destroy();
|
|
1581
|
+
}
|
|
1582
|
+
}).finally(function releaseActiveRequest(){
|
|
1583
|
+
activeRequests.delete(operation);
|
|
1584
|
+
});
|
|
1585
|
+
}
|
|
1586
|
+
|
|
1587
|
+
async function closeOwnedHandler(){
|
|
1588
|
+
configuration.signal?.removeEventListener('abort',forwardOwnerAbort);
|
|
1589
|
+
if(!ownerController.signal.aborted){
|
|
1590
|
+
ownerController.abort(new Error('Mail server closed.'));
|
|
1591
|
+
}
|
|
1592
|
+
bodyScheduler.close();
|
|
1593
|
+
sendScheduler.close();
|
|
1594
|
+
await Promise.all([
|
|
1595
|
+
bodyScheduler.idle(),
|
|
1596
|
+
sendScheduler.idle(),
|
|
1597
|
+
Promise.allSettled([...activeRequests])
|
|
1598
|
+
]);
|
|
1599
|
+
await observer.drain();
|
|
1600
|
+
}
|
|
1601
|
+
|
|
1602
|
+
function close(){
|
|
1603
|
+
if(!closePromise){
|
|
1604
|
+
closePromise=closeOwnedHandler();
|
|
1605
|
+
}
|
|
1606
|
+
return closePromise;
|
|
1607
|
+
}
|
|
1608
|
+
|
|
1609
|
+
return Object.freeze({
|
|
1610
|
+
appId:configuration.appId,
|
|
1611
|
+
callerAuthentication:configuration.callerAuthentication,
|
|
1612
|
+
close,
|
|
1613
|
+
handle,
|
|
1614
|
+
path:RESEND_MAIL_PATH,
|
|
1615
|
+
protocol:RESEND_MAIL_SERVER_PROTOCOL
|
|
1616
|
+
});
|
|
1617
|
+
}
|
|
1618
|
+
|
|
1619
|
+
function listen(server,{host,port}){
|
|
1620
|
+
return new Promise(function waitForMailListener(resolve,reject){
|
|
1621
|
+
function cleanup(){
|
|
1622
|
+
server.removeListener('error',onError);
|
|
1623
|
+
server.removeListener('listening',onListening);
|
|
1624
|
+
}
|
|
1625
|
+
function onError(error){
|
|
1626
|
+
cleanup();
|
|
1627
|
+
reject(error);
|
|
1628
|
+
}
|
|
1629
|
+
function onListening(){
|
|
1630
|
+
cleanup();
|
|
1631
|
+
resolve();
|
|
1632
|
+
}
|
|
1633
|
+
server.once('error',onError);
|
|
1634
|
+
server.once('listening',onListening);
|
|
1635
|
+
server.listen({exclusive:true,host,port});
|
|
1636
|
+
});
|
|
1637
|
+
}
|
|
1638
|
+
|
|
1639
|
+
function closeHttpServer(server){
|
|
1640
|
+
return new Promise(function waitForHttpServerClose(resolve,reject){
|
|
1641
|
+
if(!server.listening){
|
|
1642
|
+
resolve();
|
|
1643
|
+
return;
|
|
1644
|
+
}
|
|
1645
|
+
server.close(function finishHttpServerClose(error){
|
|
1646
|
+
if(error){
|
|
1647
|
+
reject(error);
|
|
1648
|
+
}else{
|
|
1649
|
+
resolve();
|
|
1650
|
+
}
|
|
1651
|
+
});
|
|
1652
|
+
server.closeIdleConnections?.();
|
|
1653
|
+
});
|
|
1654
|
+
}
|
|
1655
|
+
|
|
1656
|
+
export async function startResendMailServer(options={}){
|
|
1657
|
+
const configuration=normalizeConfiguration(options);
|
|
1658
|
+
if(configuration.signal?.aborted){
|
|
1659
|
+
throw configuration.signal.reason??new Error('Mail server start was cancelled.');
|
|
1660
|
+
}
|
|
1661
|
+
const requestHandler=createResendMailRequestHandler(options);
|
|
1662
|
+
const server=http.createServer({
|
|
1663
|
+
headersTimeout:5_000,
|
|
1664
|
+
keepAlive:true,
|
|
1665
|
+
maxHeaderSize:16_384,
|
|
1666
|
+
requestTimeout:configuration.bodyQueueTimeoutMs+configuration.bodyTimeoutMs+1_000
|
|
1667
|
+
},requestHandler.handle);
|
|
1668
|
+
server.keepAliveTimeout=5_000;
|
|
1669
|
+
server.maxConnections=configuration.maxConcurrentBodyReads
|
|
1670
|
+
+configuration.maxQueuedBodyReads
|
|
1671
|
+
+configuration.maxConcurrentSends
|
|
1672
|
+
+configuration.maxQueuedSends
|
|
1673
|
+
+16;
|
|
1674
|
+
server.maxHeadersCount=32;
|
|
1675
|
+
server.maxRequestsPerSocket=20;
|
|
1676
|
+
server.on('clientError',function rejectMalformedClient(error,socket){
|
|
1677
|
+
if(!socket.writable){
|
|
1678
|
+
return;
|
|
1679
|
+
}
|
|
1680
|
+
socket.end(
|
|
1681
|
+
'HTTP/1.1 400 Bad Request\r\n'
|
|
1682
|
+
+'Connection: close\r\n'
|
|
1683
|
+
+'Content-Length: 0\r\n'
|
|
1684
|
+
+'\r\n'
|
|
1685
|
+
);
|
|
1686
|
+
});
|
|
1687
|
+
|
|
1688
|
+
try{
|
|
1689
|
+
await listen(server,{host:configuration.host,port:configuration.port});
|
|
1690
|
+
}catch(error){
|
|
1691
|
+
await requestHandler.close();
|
|
1692
|
+
throw error;
|
|
1693
|
+
}
|
|
1694
|
+
const address=server.address();
|
|
1695
|
+
if(!address||typeof address==='string'||!isNumericLoopback(address.address)){
|
|
1696
|
+
await Promise.allSettled([closeHttpServer(server),requestHandler.close()]);
|
|
1697
|
+
throw configurationError('Mail server did not bind to a numeric loopback address.');
|
|
1698
|
+
}
|
|
1699
|
+
const displayHost=address.address.includes(':')?`[${address.address}]`:address.address;
|
|
1700
|
+
const origin=`http://${displayHost}:${String(address.port)}`;
|
|
1701
|
+
let closePromise=null;
|
|
1702
|
+
let resolveLifecycle;
|
|
1703
|
+
let rejectLifecycle;
|
|
1704
|
+
const lifecycle=new Promise(function createMailLifecycle(resolve,reject){
|
|
1705
|
+
resolveLifecycle=resolve;
|
|
1706
|
+
rejectLifecycle=reject;
|
|
1707
|
+
});
|
|
1708
|
+
lifecycle.catch(function observeMailLifecycleFailure(){});
|
|
1709
|
+
|
|
1710
|
+
async function closeOwnedServer(){
|
|
1711
|
+
configuration.signal?.removeEventListener('abort',closeFromSignal);
|
|
1712
|
+
const handlerClosing=requestHandler.close();
|
|
1713
|
+
try{
|
|
1714
|
+
await Promise.all([closeHttpServer(server),handlerClosing]);
|
|
1715
|
+
resolveLifecycle();
|
|
1716
|
+
}catch(error){
|
|
1717
|
+
rejectLifecycle(error);
|
|
1718
|
+
throw error;
|
|
1719
|
+
}
|
|
1720
|
+
}
|
|
1721
|
+
|
|
1722
|
+
function close(){
|
|
1723
|
+
if(!closePromise){
|
|
1724
|
+
closePromise=closeOwnedServer();
|
|
1725
|
+
}
|
|
1726
|
+
return closePromise;
|
|
1727
|
+
}
|
|
1728
|
+
|
|
1729
|
+
function closeFromSignal(){
|
|
1730
|
+
close().catch(function ignoreSignalCloseFailure(){});
|
|
1731
|
+
}
|
|
1732
|
+
|
|
1733
|
+
function closeFromServerError(error){
|
|
1734
|
+
rejectLifecycle(error);
|
|
1735
|
+
close().catch(function observeOperationalCloseFailure(){});
|
|
1736
|
+
}
|
|
1737
|
+
|
|
1738
|
+
server.once('close',function finishExternallyClosedServer(){
|
|
1739
|
+
if(!closePromise){
|
|
1740
|
+
closePromise=requestHandler.close().then(
|
|
1741
|
+
function resolveExternalClose(){resolveLifecycle();},
|
|
1742
|
+
function rejectExternalClose(error){rejectLifecycle(error);throw error;}
|
|
1743
|
+
);
|
|
1744
|
+
closePromise.catch(function observeExternalCloseFailure(){});
|
|
1745
|
+
}
|
|
1746
|
+
});
|
|
1747
|
+
server.on('error',closeFromServerError);
|
|
1748
|
+
configuration.signal?.addEventListener('abort',closeFromSignal,{once:true});
|
|
1749
|
+
if(configuration.signal?.aborted){
|
|
1750
|
+
closeFromSignal();
|
|
1751
|
+
}
|
|
1752
|
+
|
|
1753
|
+
return Object.freeze({
|
|
1754
|
+
appId:configuration.appId,
|
|
1755
|
+
callerAuthentication:configuration.callerAuthentication,
|
|
1756
|
+
close,
|
|
1757
|
+
closed:lifecycle,
|
|
1758
|
+
host:address.address,
|
|
1759
|
+
lifecycle,
|
|
1760
|
+
mode:'mail',
|
|
1761
|
+
origin,
|
|
1762
|
+
path:RESEND_MAIL_PATH,
|
|
1763
|
+
port:address.port,
|
|
1764
|
+
protocol:RESEND_MAIL_SERVER_PROTOCOL,
|
|
1765
|
+
server,
|
|
1766
|
+
target:'mail',
|
|
1767
|
+
url:`${origin}${RESEND_MAIL_PATH}`
|
|
1768
|
+
});
|
|
1769
|
+
}
|