arcane-os 0.15.2 → 0.16.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.
@@ -1,5 +1,6 @@
1
1
  import Is from 'strong-type';
2
- import {createHash,randomUUID,timingSafeEqual} from 'node:crypto';
2
+ import {randomUUID} from 'node:crypto';
3
+ import {inspect} from 'node:util';
3
4
  import {Server} from 'node-http-server';
4
5
 
5
6
  const is = new Is(false);
@@ -8,18 +9,7 @@ export const RESEND_MAIL_SERVER_PROTOCOL='arcane-resend-mail-gateway/1';
8
9
  export const RESEND_MAIL_PATH='/v1/mail';
9
10
 
10
11
  const RESEND_EMAIL_ENDPOINT='https://api.resend.com/emails';
11
- const APP_ID_PATTERN=/^[a-z0-9](?:[a-z0-9-]{0,62})$/u;
12
- 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;
13
- const IDEMPOTENCY_KEY_PATTERN=/^[a-zA-Z0-9._:-]+$/u;
14
- const PROVIDER_ID_PATTERN=/^[a-zA-Z0-9._:-]+$/u;
15
- const PROVIDER_CODE_PATTERN=/^[a-z0-9_]+$/u;
16
12
  const REQUEST_ID_PATTERN=/^[a-zA-Z0-9-]+$/u;
17
- const JSON_CONTENT_TYPE_PATTERN=/^application\/json(?:\s*;\s*charset\s*=\s*"?utf-8"?)?$/iu;
18
- const HEADER_NAME_PATTERN=/^[!#$%&'*+.^_`|~0-9a-z-]+$/iu;
19
- const MAIL_TYPES=new Set(['error','report','crisis_detected']);
20
- const PREFLIGHT_HEADERS=new Set([
21
- 'content-type','idempotency-key','x-mail-app','x-mail-key'
22
- ]);
23
13
  const PERMANENT_RATE_CODES=new Set(['daily_quota_exceeded','monthly_quota_exceeded']);
24
14
  const RETRYABLE_PROVIDER_STATUSES=new Set([408,425,429,500,502,503,504]);
25
15
  const MAX_NODE_TIMER_DELAY_MS=2_147_483_647;
@@ -31,7 +21,7 @@ class MailGatewayFault extends Error {
31
21
  this.code=code;
32
22
  this.details=details;
33
23
  this.retryable=Boolean(retryable);
34
- this.retryAfterMs=normalizeRetryAfter(retryAfterMs);
24
+ this.retryAfterMs=retryDelayOrZero(retryAfterMs);
35
25
  this.statusCode=statusCode;
36
26
  this.uncertain=Boolean(uncertain);
37
27
  }
@@ -52,17 +42,27 @@ function completeErrorDetails(error){
52
42
  ...(is.string(error.code)?{code:error.code}:{}),
53
43
  message:is.string(error.message)?error.message:String(error),
54
44
  name:is.string(error.name)?error.name:'Error',
55
- ...(is.string(error.stack)?{stack:error.stack}:{})
45
+ ...(is.string(error.stack)?{stack:error.stack}:{}),
46
+ ...(error.cause===undefined?{}:{
47
+ cause:error.cause instanceof Error?completeErrorDetails(error.cause):error.cause
48
+ }),
49
+ ...(error instanceof AggregateError?{errors:error.errors.map(completeErrorDetails)}:{})
56
50
  };
57
51
  }
58
52
 
59
- function positiveInteger(value,fallback,{label,allowZero=false}={}){
60
- const resolved=value===undefined?fallback:value;
61
- const minimum=allowZero?0:1;
62
- if(!is.safeInteger(resolved)||resolved<minimum){
63
- throw configurationError(`${label} must be ${allowZero?'a nonnegative':'a positive'} integer.`);
53
+ function reportMailError(message,error){
54
+ console.error(message,inspect(error,{
55
+ depth:null,
56
+ maxArrayLength:null,
57
+ maxStringLength:null
58
+ }));
59
+ }
60
+
61
+ function readRetryDelayMs(retryDelayMs=1_000){
62
+ if(!is.safeInteger(retryDelayMs)||retryDelayMs<1){
63
+ throw configurationError('retryableDelayMs must be a positive integer.');
64
64
  }
65
- return resolved;
65
+ return retryDelayMs;
66
66
  }
67
67
 
68
68
  function optionalTimeoutMs(value,label){
@@ -76,171 +76,17 @@ function optionalTimeoutMs(value,label){
76
76
  return value;
77
77
  }
78
78
 
79
- function normalizeRetryAfter(value){
79
+ function retryDelayOrZero(value){
80
80
  return is.safeInteger(value)&&value>0?value:0;
81
81
  }
82
82
 
83
- function portNumber(value,fallback){
84
- const resolved=value===undefined?fallback:value;
85
- if(!is.safeInteger(resolved)||resolved<0||resolved>65_535){
86
- throw configurationError('port must be an integer between 0 and 65535.');
87
- }
88
- return resolved;
89
- }
90
-
91
- function validateSignal(signal){
92
- if(signal!==undefined&&!(signal instanceof AbortSignal)){
93
- throw new TypeError('Mail server signal must be an AbortSignal.');
94
- }
95
- return signal;
96
- }
97
-
98
- function validateApiKey(value){
99
- if(!is.string(value)||value.length<1||!/^[\x21-\x7e]+$/u.test(value)){
100
- throw configurationError('Resend API key must be a nonempty printable ASCII string.');
101
- }
102
- return value;
103
- }
104
-
105
- function validateAppId(value){
106
- if(!is.string(value)||!APP_ID_PATTERN.test(value)){
107
- throw configurationError('Mail application identity is invalid.');
108
- }
109
- return value;
110
- }
111
-
112
- function appKeyDigest(value){
113
- return createHash('sha256').update(value,'utf8').digest();
114
- }
115
-
116
- function normalizeCallerAuthentication(options){
117
- const allowUnauthenticatedCaller=options.allowUnauthenticatedCaller??false;
118
- if(!is.boolean(allowUnauthenticatedCaller)){
119
- throw configurationError('allowUnauthenticatedCaller must be a boolean.');
120
- }
121
- if(allowUnauthenticatedCaller){
122
- if(options.appKey!==undefined){
123
- throw configurationError(
124
- 'appKey must be omitted when allowUnauthenticatedCaller is true.'
125
- );
126
- }
127
- return {
128
- appKeyDigest:null,
129
- callerAuthentication:'origin-app-id-only'
130
- };
131
- }
132
- if(!is.string(options.appKey)||!/^[\x21-\x7e]+$/u.test(options.appKey)){
133
- throw configurationError(
134
- 'appKey must be a nonempty printable ASCII string unless unauthenticated caller mode is explicitly enabled.'
135
- );
136
- }
137
- return {
138
- appKeyDigest:appKeyDigest(options.appKey),
139
- callerAuthentication:'app-key'
140
- };
141
- }
142
-
143
- function normalizedEmail(value,label){
144
- if(!is.string(value)){
145
- throw configurationError(`${label} must be an email address.`);
146
- }
147
- const normalized=value.trim().toLowerCase();
148
- if(normalized.length<3||normalized.length>254||!EMAIL_PATTERN.test(normalized)){
149
- throw configurationError(`${label} must be a valid email address.`);
150
- }
151
- return normalized;
152
- }
153
-
154
- function validateFrom(value){
155
- if(!is.string(value)||value!==value.trim()||value.length<3||value.length>320
156
- ||/[\u0000-\u001f\u007f]/u.test(value)){
157
- throw configurationError('Mail sender is invalid.');
158
- }
159
- if(EMAIL_PATTERN.test(value)){
160
- return value.toLowerCase();
161
- }
162
- const match=/^([^<>]{1,64}) <([^<>]+)>$/u.exec(value);
163
- if(!match||!match[1].trim()){
164
- throw configurationError('Mail sender must be an email address or Name <email> value.');
165
- }
166
- const address=normalizedEmail(match[2],'Mail sender');
167
- return `${match[1]} <${address}>`;
168
- }
169
-
170
- function normalizeEmailList(value,label,{allowEmpty=false}={}){
171
- if(!is.array(value)||(!allowEmpty&&value.length===0)){
172
- throw configurationError(`${label} must contain ${allowEmpty?'zero or more':'one or more'} addresses.`);
173
- }
174
- const result=[];
175
- for(const entry of value){
176
- const address=normalizedEmail(entry,label);
177
- result.push(address);
178
- }
179
- return result;
180
- }
181
-
182
- function normalizeOrigin(value){
183
- if(!is.string(value)||!value.trim()){
184
- throw configurationError('Every allowed mail origin must be a URL origin.');
185
- }
186
- let url;
187
- try{
188
- url=new URL(value.trim());
189
- }catch{
190
- throw configurationError('Every allowed mail origin must be a valid URL origin.');
191
- }
192
- if(!['http:','https:'].includes(url.protocol)||url.username||url.password
193
- ||url.pathname!=='/'||url.search||url.hash){
194
- throw configurationError('Every allowed mail origin must be an HTTP or HTTPS origin without credentials, path, query, or fragment.');
195
- }
196
- return url.origin;
197
- }
198
-
199
- function normalizeOrigins(value){
200
- if(!is.array(value)||value.length===0){
201
- throw configurationError('allowedOrigins must contain one or more exact origins.');
202
- }
203
- const origins=[];
204
- const seen=new Set();
205
- for(const entry of value){
206
- const origin=normalizeOrigin(entry);
207
- if(seen.has(origin)){
208
- throw configurationError('allowedOrigins must not contain duplicate origins.');
209
- }
210
- seen.add(origin);
211
- origins.push(origin);
212
- }
213
- return new Set(origins);
214
- }
215
-
216
- function validateLoopbackHost(value){
217
- if(value!=='127.0.0.1'&&value!=='::1'){
218
- throw configurationError('Mail server host must be the numeric loopback address 127.0.0.1 or ::1.');
219
- }
220
- return value;
221
- }
222
-
223
- function normalizeConfiguration(options={}){
83
+ function resolveMailServerConfiguration(options={}){
224
84
  if(!options||!is.object(options)||is.array(options)){
225
85
  throw configurationError('Mail server options must be an object.');
226
86
  }
227
- const callerAuthentication=normalizeCallerAuthentication(options);
228
- const recipientAllowlist=normalizeEmailList(
229
- options.recipientAllowlist??[],
230
- 'recipientAllowlist',
231
- {allowEmpty:true}
232
- );
233
- const errorRecipients=normalizeEmailList(
234
- options.errorRecipients??[],
235
- 'errorRecipients',
236
- {allowEmpty:true}
237
- );
87
+ const recipientAllowlist=options.recipientAllowlist??[];
88
+ const errorRecipients=options.errorRecipients??[];
238
89
  const allowedRecipients=new Set(recipientAllowlist);
239
- if(recipientAllowlist.length>0&&errorRecipients.some(function errorRecipientIsNotAllowed(address){
240
- return !allowedRecipients.has(address);
241
- })){
242
- throw configurationError('Every error recipient must also be in recipientAllowlist.');
243
- }
244
90
  const fetchImpl=options.fetchImpl??globalThis.fetch;
245
91
  if(!is.function(fetchImpl)){
246
92
  throw configurationError('A fetch implementation is required for Resend delivery.');
@@ -251,29 +97,28 @@ function normalizeConfiguration(options={}){
251
97
  if(options.requestIdFactory!==undefined&&!is.function(options.requestIdFactory)){
252
98
  throw configurationError('requestIdFactory must be a function when supplied.');
253
99
  }
100
+ if(options.verifySubscription!==undefined&&!is.function(options.verifySubscription)){
101
+ throw configurationError('verifySubscription must be a function when supplied.');
102
+ }
254
103
  return {
255
104
  allowAnyRecipient:recipientAllowlist.length===0,
256
- allowedOrigins:normalizeOrigins(options.allowedOrigins),
105
+ allowedOrigins:new Set(options.allowedOrigins??[]),
257
106
  allowedRecipients,
258
- apiKey:validateApiKey(options.apiKey),
259
- appKeyDigest:callerAuthentication.appKeyDigest,
260
- appId:validateAppId(options.appId),
107
+ apiKey:options.apiKey,
108
+ appId:options.appId,
261
109
  bodyTimeoutMs:optionalTimeoutMs(options.bodyTimeoutMs,'bodyTimeoutMs'),
262
110
  errorRecipients,
263
111
  fetchImpl,
264
- from:validateFrom(options.from),
265
- host:validateLoopbackHost(options.host??'127.0.0.1'),
266
- callerAuthentication:callerAuthentication.callerAuthentication,
112
+ from:options.from,
113
+ host:options.host??'0.0.0.0',
114
+ callerAuthentication:options.verifySubscription?'subscription':'none',
267
115
  onEvent:options.onEvent,
268
- port:portNumber(options.port,8025),
116
+ port:options.port??8025,
269
117
  providerTimeoutMs:optionalTimeoutMs(options.providerTimeoutMs,'providerTimeoutMs'),
270
118
  requestIdFactory:options.requestIdFactory??randomUUID,
271
- retryableDelayMs:positiveInteger(
272
- options.retryableDelayMs,
273
- 1_000,
274
- {label:'retryableDelayMs'}
275
- ),
276
- signal:validateSignal(options.signal)
119
+ retryableDelayMs:readRetryDelayMs(options.retryableDelayMs),
120
+ signal:options.signal,
121
+ verifySubscription:options.verifySubscription
277
122
  };
278
123
  }
279
124
 
@@ -289,148 +134,98 @@ function createRequestId(factory){
289
134
  return randomUUID();
290
135
  }
291
136
 
292
- function isNumericLoopback(value){
293
- return value==='127.0.0.1'||value==='::1'||value==='::ffff:127.0.0.1';
294
- }
295
-
296
- function headerValues(request,name){
297
- const distinct=request.headersDistinct?.[name];
298
- if(is.array(distinct)){
299
- return distinct.map(function stringifyDistinctHeader(value){return String(value);});
300
- }
301
- const values=[];
302
- for(let index=0;index<(request.rawHeaders?.length??0);index+=2){
303
- if(String(request.rawHeaders[index]).toLowerCase()===name){
304
- values.push(String(request.rawHeaders[index+1]??''));
305
- }
306
- }
307
- if(values.length>0){
308
- return values;
309
- }
310
- const fallback=request.headers?.[name];
311
- if(fallback===undefined){
312
- return [];
313
- }
314
- return is.array(fallback)?fallback.map(String):[String(fallback)];
315
- }
316
-
317
- function singleHeader(request,name,{required=true}={}){
318
- const values=headerValues(request,name);
319
- if(values.length===0&&!required){
320
- return '';
321
- }
322
- if(values.length!==1||!values[0]){
137
+ function requireRequestHeader(request,headerName){
138
+ const headerValues=request.headersDistinct[headerName]??[];
139
+ if(headerValues.length!==1||!headerValues[0]){
323
140
  throw new MailGatewayFault('mail_invalid_headers',{statusCode:400});
324
141
  }
325
- return values[0];
142
+ return headerValues[0];
326
143
  }
327
144
 
328
- function validateLoopbackRequest(request){
329
- if(!isNumericLoopback(request.socket?.remoteAddress)
330
- ||!isNumericLoopback(request.socket?.localAddress)){
331
- throw new MailGatewayFault('mail_loopback_required',{statusCode:421});
145
+ async function verifyMailSubscription(request,configuration,signal){
146
+ const appName=requireRequestHeader(request,'x-mail-app');
147
+ const authorizationHeaders=request.headersDistinct.authorization??[];
148
+ const subscriptionKey=authorizationHeaders.length===1
149
+ ?/^Bearer (.+)$/iu.exec(authorizationHeaders[0])?.[1]
150
+ :undefined;
151
+ if(!subscriptionKey){
152
+ throw new MailGatewayFault('mail_subscription_required',{statusCode:401});
332
153
  }
333
- const rawHost=singleHeader(request,'host');
334
- let parsed;
154
+ let verified;
335
155
  try{
336
- parsed=new URL(`http://${rawHost}`);
337
- }catch{
338
- throw new MailGatewayFault('mail_invalid_host',{statusCode:421});
339
- }
340
- const hostname=parsed.hostname.replace(/^\[|\]$/gu,'');
341
- const localPort=request.socket?.localPort;
342
- const statedPort=parsed.port?Number(parsed.port):80;
343
- const hostLiteral=hostname.includes(':')?`[${hostname}]`:hostname;
344
- const expectedAuthority=localPort===80?hostLiteral:`${hostLiteral}:${String(localPort)}`;
345
- if(parsed.username||parsed.password||parsed.pathname!=='/'||parsed.search||parsed.hash
346
- ||!isNumericLoopback(hostname)||!is.safeInteger(localPort)
347
- ||statedPort!==localPort||rawHost!==expectedAuthority){
348
- throw new MailGatewayFault('mail_invalid_host',{statusCode:421});
349
- }
350
- }
351
-
352
- function allowedOrigin(request,configuration){
353
- const origin=singleHeader(request,'origin');
354
- if(!configuration.allowedOrigins.has(origin)){
355
- throw new MailGatewayFault('mail_origin_not_allowed',{statusCode:403});
356
- }
357
- return origin;
358
- }
359
-
360
- function authenticateLocalCaller(request,configuration){
361
- const values=headerValues(request,'x-mail-key');
362
- if(configuration.callerAuthentication==='origin-app-id-only'){
363
- if(values.length!==0){
364
- throw new MailGatewayFault('mail_app_key_unexpected',{statusCode:403});
156
+ signal.throwIfAborted();
157
+ verified=await waitForResultOrAbort(configuration.verifySubscription({
158
+ appName,
159
+ subscriptionKey,
160
+ signal
161
+ }),signal);
162
+ }catch(error){
163
+ if(signal.aborted){
164
+ throw new MailGatewayFault('mail_request_cancelled',{
165
+ retryable:true,
166
+ statusCode:408
167
+ });
365
168
  }
366
- return;
169
+ throw new MailGatewayFault('mail_subscription_verification_failed',{
170
+ details:completeErrorDetails(error),
171
+ retryable:true,
172
+ retryAfterMs:configuration.retryableDelayMs,
173
+ statusCode:503
174
+ });
367
175
  }
368
- const candidate=values.length===1?values[0]:'';
369
- const candidateIsValid=/^[\x21-\x7e]+$/u.test(candidate);
370
- const digest=appKeyDigest(candidateIsValid?candidate:'');
371
- const authenticated=timingSafeEqual(configuration.appKeyDigest,digest);
372
- if(values.length!==1||!candidateIsValid||!authenticated){
373
- throw new MailGatewayFault('mail_app_key_invalid',{statusCode:401});
176
+ if(verified!==true){
177
+ throw new MailGatewayFault('mail_subscription_invalid',{statusCode:401});
374
178
  }
179
+ return appName;
375
180
  }
376
181
 
377
- function corsHeaders(origin,{allowPrivateNetwork=false}={}){
182
+ function createCorsResponseHeaders(origin){
378
183
  if(!origin){
379
184
  return {};
380
185
  }
381
186
  return {
382
- 'access-control-allow-headers':'Content-Type, Idempotency-Key, X-Mail-App, X-Mail-Key',
187
+ 'access-control-allow-headers':'Content-Type, Idempotency-Key, X-Mail-App, Authorization',
383
188
  'access-control-allow-methods':'POST, OPTIONS',
384
189
  'access-control-allow-origin':origin,
385
190
  'access-control-expose-headers':'Retry-After',
386
191
  'access-control-max-age':'600',
387
- ...(allowPrivateNetwork?{'access-control-allow-private-network':'true'}:{}),
388
192
  'vary':'Origin'
389
193
  };
390
194
  }
391
195
 
392
- function baseResponseHeaders(origin){
393
- return corsHeaders(origin);
394
- }
395
-
396
- function writeJson(response,statusCode,value,{origin='',retryAfterMs=0}={}){
196
+ function sendJsonResponse(response,statusCode,value,{origin='',retryAfterMs=0}={}){
397
197
  if(response.destroyed||response.writableEnded){
398
198
  return false;
399
199
  }
400
200
  const body=JSON.stringify(value);
401
201
  const headers={
402
- ...baseResponseHeaders(origin),
403
- 'content-length':String(Buffer.byteLength(body,'utf8')),
202
+ ...createCorsResponseHeaders(origin),
404
203
  'content-type':'application/json; charset=utf-8'
405
204
  };
406
- const delay=normalizeRetryAfter(retryAfterMs);
407
- if(delay){
408
- headers['retry-after']=String(Math.max(1,Math.ceil(delay/1000)));
205
+ if(statusCode===401)headers['www-authenticate']='Bearer';
206
+ if(retryAfterMs){
207
+ headers['retry-after']=String(Math.ceil(retryAfterMs/1000));
409
208
  }
410
209
  response.writeHead(statusCode,headers);
411
210
  response.end(body);
412
211
  return true;
413
212
  }
414
213
 
415
- function writePreflight(response,origin,{allowPrivateNetwork=false}={}){
214
+ function sendCorsPreflightResponse(response,origin){
416
215
  if(response.destroyed||response.writableEnded){
417
216
  return false;
418
217
  }
419
- response.writeHead(204,{
420
- ...baseResponseHeaders(origin),
421
- ...corsHeaders(origin,{allowPrivateNetwork}),
422
- 'content-length':'0'
423
- });
218
+ response.writeHead(204,createCorsResponseHeaders(origin));
424
219
  response.end();
425
220
  return true;
426
221
  }
427
222
 
428
- function writeFault(response,requestId,fault,origin=''){
429
- const retryAfterMs=normalizeRetryAfter(fault.retryAfterMs);
430
- return writeJson(response,fault.statusCode,{
223
+ function sendMailFailureResponse(response,requestId,fault,origin=''){
224
+ const retryAfterMs=fault.retryAfterMs;
225
+ return sendJsonResponse(response,fault.statusCode,{
431
226
  requestId,
432
227
  error:{
433
- code:PROVIDER_CODE_PATTERN.test(fault.code)?fault.code:'mail_gateway_error',
228
+ code:fault.code,
434
229
  message:fault.message,
435
230
  details:fault.details,
436
231
  retryable:Boolean(fault.retryable),
@@ -440,7 +235,7 @@ function writeFault(response,requestId,fault,origin=''){
440
235
  },{origin,retryAfterMs});
441
236
  }
442
237
 
443
- function normalizeFault(error){
238
+ function mailFaultFromError(error){
444
239
  if(error instanceof MailGatewayFault){
445
240
  return error;
446
241
  }
@@ -450,85 +245,62 @@ function normalizeFault(error){
450
245
  });
451
246
  }
452
247
 
453
- function validatePreflight(request){
454
- if(singleHeader(request,'access-control-request-method')!=='POST'){
455
- throw new MailGatewayFault('mail_preflight_denied',{statusCode:403});
248
+ function createMailEventObserver(onEvent){
249
+ const pendingObserverTasks=new Set();
250
+ function reportObserverFailure(error){
251
+ reportMailError('Mail event observer failed.',error);
456
252
  }
457
- const rawHeaders=singleHeader(request,'access-control-request-headers');
458
- const requestedHeaders=rawHeaders.split(',').map(function normalizeRequestedHeader(value){
459
- return value.trim().toLowerCase();
460
- });
461
- if(requestedHeaders.length===0||requestedHeaders.some(function headerIsNotAllowed(value){
462
- return !value||!HEADER_NAME_PATTERN.test(value)||!PREFLIGHT_HEADERS.has(value);
463
- })){
464
- throw new MailGatewayFault('mail_preflight_denied',{statusCode:403});
465
- }
466
- const privateNetwork=singleHeader(
467
- request,
468
- 'access-control-request-private-network',
469
- {required:false}
470
- );
471
- if(privateNetwork&&privateNetwork!=='true'){
472
- throw new MailGatewayFault('mail_preflight_denied',{statusCode:403});
473
- }
474
- return {allowPrivateNetwork:privateNetwork==='true'};
475
- }
476
-
477
- function createObserver(onEvent){
478
- const pending=new Set();
479
253
  function observe(event){
480
- if(!onEvent){
481
- return;
482
- }
483
- let result;
254
+ let observerResult;
484
255
  try{
485
- result=onEvent({...event});
486
- }catch{
256
+ observerResult=onEvent(event);
257
+ }catch(error){
258
+ reportObserverFailure(error);
487
259
  return;
488
260
  }
489
- if(!result||!is.function(result.then)){
261
+ if(!observerResult||!is.function(observerResult.then)){
490
262
  return;
491
263
  }
492
- const task=Promise.resolve(result);
493
- pending.add(task);
494
- task.catch(function ignoreObserverFailure(){})
495
- .finally(function releaseObserverTask(){pending.delete(task);});
264
+ const observerTask=Promise.resolve(observerResult);
265
+ pendingObserverTasks.add(observerTask);
266
+ observerTask.catch(reportObserverFailure)
267
+ .finally(function releaseObserverTask(){pendingObserverTasks.delete(observerTask);});
496
268
  }
497
- async function drain(){
498
- await Promise.allSettled([...pending]);
269
+ async function drainObserverTasks(){
270
+ await Promise.allSettled([...pendingObserverTasks]);
499
271
  }
500
- return {drain,observe};
272
+ return {drain:drainObserverTasks,observe};
501
273
  }
502
274
 
503
- function readRequestBody(request,{timeoutMs,signal}){
504
- return new Promise(function collectRequestBody(resolve,reject){
505
- const chunks=[];
506
- let settled=false;
507
- const timer=timeoutMs==null
275
+ function readRequestBodyText(request,{timeoutMs,signal}){
276
+ return new Promise(function collectRequestBodyText(resolve,reject){
277
+ const bodyChunks=[];
278
+ let bodyReadSettled=false;
279
+ const bodyTimeout=timeoutMs==null
508
280
  ?null
509
281
  :setTimeout(function expireRequestBody(){
510
- finish(new MailGatewayFault('mail_body_timeout',{
282
+ settleBodyRead(new MailGatewayFault('mail_body_timeout',{
511
283
  retryable:true,
512
284
  statusCode:408
513
285
  }));
514
286
  request.resume();
515
287
  },timeoutMs);
516
288
 
517
- function cleanup(){
518
- if(timer!==null) clearTimeout(timer);
519
- request.removeListener('data',onData);
520
- request.removeListener('end',onEnd);
521
- request.removeListener('error',onError);
522
- request.removeListener('aborted',onAborted);
523
- signal?.removeEventListener('abort',onSignalAbort);
289
+ function releaseBodyReadResources(){
290
+ if(bodyTimeout!==null) clearTimeout(bodyTimeout);
291
+ request.removeListener('data',collectBodyChunk);
292
+ request.removeListener('end',completeBodyRead);
293
+ request.removeListener('error',rejectFailedBodyRead);
294
+ request.removeListener('aborted',rejectAbortedBodyRead);
295
+ signal?.removeEventListener('abort',cancelBodyReadFromSignal);
524
296
  }
525
297
 
526
- function finish(error,value){
527
- if(settled){
298
+ function settleBodyRead(error,value){
299
+ if(bodyReadSettled){
528
300
  return;
529
301
  }
530
- settled=true;
531
- cleanup();
302
+ bodyReadSettled=true;
303
+ releaseBodyReadResources();
532
304
  if(error){
533
305
  reject(error);
534
306
  }else{
@@ -536,154 +308,106 @@ function readRequestBody(request,{timeoutMs,signal}){
536
308
  }
537
309
  }
538
310
 
539
- function onData(chunk){
540
- const bytes=Buffer.isBuffer(chunk)?chunk:Buffer.from(chunk);
541
- chunks.push(bytes);
311
+ function collectBodyChunk(chunk){
312
+ const bodyChunk=Buffer.isBuffer(chunk)?chunk:Buffer.from(chunk);
313
+ bodyChunks.push(bodyChunk);
542
314
  }
543
315
 
544
- function onEnd(){
545
- finish(null,Buffer.concat(chunks).toString('utf8'));
316
+ function completeBodyRead(){
317
+ settleBodyRead(null,Buffer.concat(bodyChunks).toString('utf8'));
546
318
  }
547
319
 
548
- function onError(error){
549
- finish(new MailGatewayFault('mail_request_stream_failed',{
320
+ function rejectFailedBodyRead(error){
321
+ settleBodyRead(new MailGatewayFault('mail_request_stream_failed',{
550
322
  details:completeErrorDetails(error),
551
323
  retryable:true,
552
324
  statusCode:400
553
325
  }));
554
326
  }
555
327
 
556
- function onAborted(){
557
- finish(new MailGatewayFault('mail_request_cancelled',{
328
+ function rejectAbortedBodyRead(){
329
+ settleBodyRead(new MailGatewayFault('mail_request_cancelled',{
558
330
  retryable:true,
559
331
  statusCode:408
560
332
  }));
561
333
  }
562
334
 
563
- function onSignalAbort(){
564
- finish(new MailGatewayFault('mail_request_cancelled',{
335
+ function cancelBodyReadFromSignal(){
336
+ settleBodyRead(new MailGatewayFault('mail_request_cancelled',{
565
337
  retryable:true,
566
338
  statusCode:408
567
339
  }));
568
340
  request.resume();
569
341
  }
570
342
 
571
- request.on('data',onData);
572
- request.once('end',onEnd);
573
- request.once('error',onError);
574
- request.once('aborted',onAborted);
575
- signal?.addEventListener('abort',onSignalAbort,{once:true});
343
+ request.on('data',collectBodyChunk);
344
+ request.once('end',completeBodyRead);
345
+ request.once('error',rejectFailedBodyRead);
346
+ request.once('aborted',rejectAbortedBodyRead);
347
+ signal?.addEventListener('abort',cancelBodyReadFromSignal,{once:true});
576
348
  if(signal?.aborted){
577
- onSignalAbort();
349
+ cancelBodyReadFromSignal();
578
350
  }
579
351
  });
580
352
  }
581
353
 
582
- function normalizedReportEmail(value){
583
- if(!is.string(value)){
584
- throw new MailGatewayFault('mail_invalid_recipient',{statusCode:422});
585
- }
586
- const address=value.trim().toLowerCase();
587
- if(address.length<3||address.length>254||!EMAIL_PATTERN.test(address)){
588
- throw new MailGatewayFault('mail_invalid_recipient',{statusCode:422});
589
- }
590
- return address;
591
- }
592
-
593
- function normalizeReportRecipients(report,configuration){
594
- if(!is.array(report.to)){
595
- throw new MailGatewayFault('mail_invalid_recipients',{statusCode:422});
596
- }
597
- const recipients=[];
598
- for(const value of report.to){
599
- const address=normalizedReportEmail(value);
600
- if(!configuration.allowAnyRecipient&&!configuration.allowedRecipients.has(address)){
601
- throw new MailGatewayFault('mail_recipient_not_allowed',{statusCode:403});
354
+ function resolveReportRecipients(report,configuration){
355
+ const recipients=is.array(report.to)&&report.to.length===0&&report.type==='error'
356
+ ?configuration.errorRecipients
357
+ :report.to;
358
+ if(!configuration.allowAnyRecipient){
359
+ for(const recipientGroup of [recipients,report.cc,report.bcc]){
360
+ if(recipientGroup===undefined)continue;
361
+ for(const recipient of is.array(recipientGroup)?recipientGroup:[recipientGroup]){
362
+ if(configuration.allowedRecipients.has(recipient))continue;
363
+ throw new MailGatewayFault('mail_recipient_not_allowed',{statusCode:403});
364
+ }
602
365
  }
603
- recipients.push(address);
604
- }
605
- if(recipients.length===0&&report.type==='error'){
606
- recipients.push(...configuration.errorRecipients);
607
- }
608
- if(recipients.length===0){
609
- throw new MailGatewayFault('mail_recipients_required',{statusCode:422});
610
366
  }
611
367
  return recipients;
612
368
  }
613
369
 
614
- function normalizeReport(value,configuration){
615
- if(!value||!is.object(value)||is.array(value)){
370
+ function prepareProviderDelivery(report,configuration){
371
+ if(!report||!is.object(report)||is.array(report)){
616
372
  throw new MailGatewayFault('mail_invalid_report',{statusCode:422});
617
373
  }
618
- if(!Object.hasOwn(value,'subject')||!Object.hasOwn(value,'to')
619
- ||!Object.hasOwn(value,'type')){
620
- throw new MailGatewayFault('mail_invalid_report_shape',{statusCode:422});
621
- }
622
- if(!is.string(value.type)||!MAIL_TYPES.has(value.type)){
623
- throw new MailGatewayFault('mail_invalid_type',{statusCode:422});
624
- }
625
- if(!is.string(value.subject)){
626
- throw new MailGatewayFault('mail_invalid_subject',{statusCode:422});
627
- }
628
- const hasText=Object.hasOwn(value,'text');
629
- const hasHtml=Object.hasOwn(value,'html');
630
- if(!hasText&&!hasHtml||(hasText&&!is.string(value.text))
631
- ||(hasHtml&&!is.string(value.html))){
632
- throw new MailGatewayFault('mail_content_required',{statusCode:422});
633
- }
634
- const recipients=normalizeReportRecipients(value,configuration);
635
- const providerFields={...value};
636
- delete providerFields.type;
637
- const providerBody={
638
- ...providerFields,
639
- from:configuration.from,
640
- to:recipients,
641
- subject:value.subject,
642
- ...(hasText?{text:value.text}:{}),
643
- ...(hasHtml?{html:value.html}:{})
644
- };
645
- const serializedProviderBody=JSON.stringify(providerBody);
374
+ const recipients=resolveReportRecipients(report,configuration);
375
+ const providerRequest={...report,to:recipients};
376
+ delete providerRequest.type;
377
+ if(configuration.from!==undefined)providerRequest.from=configuration.from;
646
378
  return {
647
- report:{...value},
648
- providerBody:serializedProviderBody,
649
- recipientCount:recipients.length
379
+ report,
380
+ serializedProviderRequest:JSON.stringify(providerRequest),
381
+ recipientCount:is.array(recipients)?recipients.length:recipients?1:0
650
382
  };
651
383
  }
652
384
 
653
- function parseReport(serialized,configuration){
654
- let value;
385
+ function parseMailRequest(requestText,configuration){
386
+ let report;
655
387
  try{
656
- value=JSON.parse(serialized);
388
+ report=JSON.parse(requestText);
657
389
  }catch{
658
390
  throw new MailGatewayFault('mail_invalid_json',{statusCode:400});
659
391
  }
660
- return normalizeReport(value,configuration);
392
+ return prepareProviderDelivery(report,configuration);
661
393
  }
662
394
 
663
- function parseRetryAfter(value,now=Date.now()){
664
- if(!is.string(value)||!value.trim()){
395
+ function parseRetryAfterMilliseconds(value,now=Date.now()){
396
+ if(!is.string(value)){
665
397
  return 0;
666
398
  }
667
- const trimmed=value.trim();
668
- if(/^\d+(?:\.\d+)?$/u.test(trimmed)){
669
- return normalizeRetryAfter(Math.ceil(Number(trimmed)*1000));
399
+ const retryAfterValue=value.trim();
400
+ if(!retryAfterValue)return 0;
401
+ if(/^\d+(?:\.\d+)?$/u.test(retryAfterValue)){
402
+ return retryDelayOrZero(Math.ceil(Number(retryAfterValue)*1000));
670
403
  }
671
- const timestamp=Date.parse(trimmed);
404
+ const timestamp=Date.parse(retryAfterValue);
672
405
  return is.finite(timestamp)
673
- ? normalizeRetryAfter(Math.max(0,timestamp-now))
406
+ ? retryDelayOrZero(Math.max(0,timestamp-now))
674
407
  : 0;
675
408
  }
676
409
 
677
- function responseHeader(response,name){
678
- try{
679
- const value=response.headers?.get?.(name);
680
- return value===null||value===undefined?'':String(value);
681
- }catch{
682
- return '';
683
- }
684
- }
685
-
686
- function awaitAbortable(value,signal){
410
+ function waitForResultOrAbort(value,signal){
687
411
  return new Promise(function waitForAbortable(resolve,reject){
688
412
  let settled=false;
689
413
  function cleanup(){
@@ -736,7 +460,7 @@ function cancelProviderReader(reader){
736
460
  }
737
461
  }
738
462
 
739
- async function readProviderBody(response,signal){
463
+ async function readProviderResponseText(response,signal){
740
464
  if(response.body===null||response.body===undefined){
741
465
  return '';
742
466
  }
@@ -749,24 +473,28 @@ async function readProviderBody(response,signal){
749
473
  }
750
474
  const reader=response.body.getReader();
751
475
  const decoder=new TextDecoder();
752
- let text='';
476
+ let providerResponseText='';
753
477
  let fullyRead=false;
754
478
  try{
755
479
  while(true){
756
- const result=await awaitAbortable(reader.read(),signal);
480
+ const result=await waitForResultOrAbort(reader.read(),signal);
757
481
  if(result.done){
758
482
  fullyRead=true;
759
483
  break;
760
484
  }
761
- if(!(result.value instanceof Uint8Array)){
762
- throw new MailGatewayFault('resend_unreadable_response',{
763
- statusCode:502,
764
- uncertain:true
765
- });
766
- }
767
- text+=decoder.decode(result.value,{stream:true});
485
+ providerResponseText+=decoder.decode(result.value,{stream:true});
768
486
  }
769
- return text+decoder.decode();
487
+ return providerResponseText+decoder.decode();
488
+ }catch(error){
489
+ throw new MailGatewayFault('resend_unreadable_response',{
490
+ details:{
491
+ error:completeErrorDetails(error),
492
+ responseText:providerResponseText+decoder.decode(),
493
+ responseComplete:false
494
+ },
495
+ statusCode:502,
496
+ uncertain:true
497
+ });
770
498
  }finally{
771
499
  if(!fullyRead){
772
500
  cancelProviderReader(reader);
@@ -774,40 +502,35 @@ async function readProviderBody(response,signal){
774
502
  try{
775
503
  reader.releaseLock();
776
504
  }catch{
777
- // An untrusted stream implementation cannot replace the provider classification.
505
+ // Stream cleanup cannot replace the provider outcome.
778
506
  }
779
507
  }
780
508
  }
781
509
 
782
- function parseProviderObject(text){
783
- if(!text){
784
- return null;
785
- }
510
+ function parseProviderResponse(providerResponseText){
786
511
  try{
787
- const value=JSON.parse(text);
788
- return value&&is.object(value)&&!is.array(value)?value:null;
512
+ return JSON.parse(providerResponseText);
789
513
  }catch{
790
- return null;
514
+ return providerResponseText;
791
515
  }
792
516
  }
793
517
 
794
- function providerCode(value,statusCode){
795
- const candidate=value?.name;
796
- if(is.string(candidate)&&PROVIDER_CODE_PATTERN.test(candidate)){
797
- return candidate;
518
+ function resolveProviderErrorCode(providerResponse,statusCode){
519
+ if(is.string(providerResponse?.name)&&providerResponse.name){
520
+ return providerResponse.name;
798
521
  }
799
522
  return `resend_http_${String(statusCode)}`;
800
523
  }
801
524
 
802
- function providerRejection(statusCode,value,retryAfterMs,defaultRetryAfterMs){
803
- const code=providerCode(value,statusCode);
525
+ function classifyProviderRejection(statusCode,value,retryAfterMs,defaultRetryAfterMs){
526
+ const code=resolveProviderErrorCode(value,statusCode);
804
527
  const permanentRateLimit=PERMANENT_RATE_CODES.has(code);
805
528
  const retryable=code==='concurrent_idempotent_requests'
806
529
  ||(statusCode===409&&code!=='invalid_idempotent_request')
807
530
  ||(!permanentRateLimit&&code!=='invalid_idempotent_request'
808
531
  &&RETRYABLE_PROVIDER_STATUSES.has(statusCode));
809
532
  const resolvedDelay=retryable
810
- ? normalizeRetryAfter(retryAfterMs||defaultRetryAfterMs)
533
+ ? retryAfterMs||defaultRetryAfterMs
811
534
  : 0;
812
535
  return {
813
536
  kind:'rejected',
@@ -821,50 +544,50 @@ function providerRejection(statusCode,value,retryAfterMs,defaultRetryAfterMs){
821
544
  };
822
545
  }
823
546
 
824
- function ambiguousResult(code,retryAfterMs,providerStatus=0,details=null){
547
+ function createUncertainProviderResult(code,retryAfterMs,providerStatus=0,details=null){
825
548
  return {
826
549
  code,
827
550
  details,
828
551
  kind:'ambiguous',
829
552
  providerStatus,
830
- retryAfterMs:normalizeRetryAfter(retryAfterMs)
553
+ retryAfterMs:retryDelayOrZero(retryAfterMs)
831
554
  };
832
555
  }
833
556
 
834
- async function performResendAttempt(configuration,delivery,idempotencyKey,signal,requestId,observe){
557
+ async function attemptResendDelivery(configuration,delivery,idempotencyKey,signal,requestId,observe,appId=configuration.appId){
835
558
  const controller=new AbortController();
836
559
  let outcome=null;
837
560
  let timedOut=false;
838
- function completeAttempt(result){
561
+ function recordAttemptOutcome(result){
839
562
  outcome=result;
840
563
  return result;
841
564
  }
842
- function forwardAbort(){
565
+ function abortProviderRequest(){
843
566
  controller.abort(signal?.reason??new Error('Mail request cancelled.'));
844
567
  }
845
- signal?.addEventListener('abort',forwardAbort,{once:true});
568
+ signal?.addEventListener('abort',abortProviderRequest,{once:true});
846
569
  if(signal?.aborted){
847
- forwardAbort();
570
+ abortProviderRequest();
848
571
  }
849
572
  const timeout=configuration.providerTimeoutMs==null
850
573
  ?null
851
- :setTimeout(function expireResendAttempt(){
574
+ :setTimeout(function abortTimedOutProviderRequest(){
852
575
  timedOut=true;
853
576
  controller.abort(new Error('Resend request timed out.'));
854
577
  },configuration.providerTimeoutMs);
855
- const startedAt=Date.now();
856
- observe({
578
+ const startedAt=configuration.onEvent?Date.now():0;
579
+ if(configuration.onEvent)observe({
857
580
  type:'mail.provider.started',
858
- appId:configuration.appId,
581
+ appId,
859
582
  idempotencyKey,
860
- providerRequest:JSON.parse(delivery.providerBody),
583
+ providerRequest:JSON.parse(delivery.serializedProviderRequest),
861
584
  report:delivery.report,
862
585
  requestId
863
586
  });
864
587
  let response;
865
588
  try{
866
589
  try{
867
- response=await awaitAbortable(configuration.fetchImpl(RESEND_EMAIL_ENDPOINT,{
590
+ response=await waitForResultOrAbort(configuration.fetchImpl(RESEND_EMAIL_ENDPOINT,{
868
591
  method:'POST',
869
592
  headers:{
870
593
  'Authorization':`Bearer ${configuration.apiKey}`,
@@ -872,13 +595,12 @@ async function performResendAttempt(configuration,delivery,idempotencyKey,signal
872
595
  'Idempotency-Key':idempotencyKey,
873
596
  'User-Agent':'arcane-os-sdk-mail/1'
874
597
  },
875
- body:delivery.providerBody,
598
+ body:delivery.serializedProviderRequest,
876
599
  redirect:'error',
877
- referrerPolicy:'no-referrer',
878
600
  signal:controller.signal
879
601
  }),controller.signal);
880
602
  }catch(error){
881
- return completeAttempt(ambiguousResult(
603
+ return recordAttemptOutcome(createUncertainProviderResult(
882
604
  timedOut?'resend_timeout':'resend_transport_uncertain',
883
605
  configuration.retryableDelayMs,
884
606
  0,
@@ -887,64 +609,64 @@ async function performResendAttempt(configuration,delivery,idempotencyKey,signal
887
609
  }
888
610
  const statusCode=Number(response?.status);
889
611
  if(!is.safeInteger(statusCode)||statusCode<100||statusCode>599){
890
- return completeAttempt(ambiguousResult(
612
+ return recordAttemptOutcome(createUncertainProviderResult(
891
613
  'resend_invalid_response',
892
614
  configuration.retryableDelayMs,
893
615
  0,
894
616
  {status:response?.status??null}
895
617
  ));
896
618
  }
897
- let text='';
619
+ let providerResponseText='';
898
620
  try{
899
- text=await readProviderBody(response,controller.signal);
621
+ providerResponseText=await readProviderResponseText(response,controller.signal);
900
622
  }catch(error){
901
623
  if(statusCode>=200&&statusCode<300||controller.signal.aborted){
902
- return completeAttempt(ambiguousResult(
624
+ return recordAttemptOutcome(createUncertainProviderResult(
903
625
  error instanceof MailGatewayFault?error.code:'resend_transport_uncertain',
904
626
  configuration.retryableDelayMs,
905
627
  statusCode,
906
628
  completeErrorDetails(error)
907
629
  ));
908
630
  }
909
- return completeAttempt(providerRejection(
631
+ return recordAttemptOutcome(classifyProviderRejection(
910
632
  statusCode,
911
- null,
912
- parseRetryAfter(responseHeader(response,'retry-after')),
633
+ completeErrorDetails(error),
634
+ parseRetryAfterMilliseconds(response.headers.get('retry-after')),
913
635
  configuration.retryableDelayMs
914
636
  ));
915
637
  }
916
- const value=parseProviderObject(text);
638
+ const providerResponse=parseProviderResponse(providerResponseText);
917
639
  if(statusCode>=200&&statusCode<300){
918
- if(!value||!is.string(value.id)||!PROVIDER_ID_PATTERN.test(value.id)){
919
- return completeAttempt(ambiguousResult(
640
+ if(!is.string(providerResponse?.id)||!providerResponse.id){
641
+ return recordAttemptOutcome(createUncertainProviderResult(
920
642
  'resend_invalid_success_response',
921
643
  configuration.retryableDelayMs,
922
644
  statusCode,
923
- value??text
645
+ providerResponse
924
646
  ));
925
647
  }
926
- return completeAttempt({
648
+ return recordAttemptOutcome({
927
649
  kind:'accepted',
928
- providerId:value.id,
929
- providerResponse:value,
650
+ providerId:providerResponse.id,
651
+ providerResponse,
930
652
  providerStatus:statusCode
931
653
  });
932
654
  }
933
- return completeAttempt(providerRejection(
655
+ return recordAttemptOutcome(classifyProviderRejection(
934
656
  statusCode,
935
- value,
936
- parseRetryAfter(responseHeader(response,'retry-after')),
657
+ providerResponse,
658
+ parseRetryAfterMilliseconds(response.headers.get('retry-after')),
937
659
  configuration.retryableDelayMs
938
660
  ));
939
661
  }finally{
940
662
  if(timeout!==null) clearTimeout(timeout);
941
- signal?.removeEventListener('abort',forwardAbort);
942
- observe({
663
+ signal?.removeEventListener('abort',abortProviderRequest);
664
+ if(configuration.onEvent)observe({
943
665
  type:'mail.provider.completed',
944
- appId:configuration.appId,
666
+ appId,
945
667
  idempotencyKey,
946
668
  outcome,
947
- providerRequest:JSON.parse(delivery.providerBody),
669
+ providerRequest:JSON.parse(delivery.serializedProviderRequest),
948
670
  report:delivery.report,
949
671
  durationMs:Math.max(0,Date.now()-startedAt),
950
672
  requestId,
@@ -953,12 +675,12 @@ async function performResendAttempt(configuration,delivery,idempotencyKey,signal
953
675
  }
954
676
  }
955
677
 
956
- function normalizeDirectSendOptions(options){
678
+ function resolveDirectSendConfiguration(options){
957
679
  if(!options||!is.object(options)||is.array(options)){
958
680
  throw configurationError('Mail send options must be an object.');
959
681
  }
960
- if(!is.string(options.reportKey)||!IDEMPOTENCY_KEY_PATTERN.test(options.reportKey)){
961
- throw configurationError('reportKey must contain safe identifier characters.');
682
+ if(!is.string(options.reportKey)||!options.reportKey){
683
+ throw configurationError('reportKey is required to identify the mail attempt.');
962
684
  }
963
685
  const fetchImpl=options.fetchImpl??globalThis.fetch;
964
686
  if(!is.function(fetchImpl)){
@@ -973,26 +695,22 @@ function normalizeDirectSendOptions(options){
973
695
  return {
974
696
  allowAnyRecipient:true,
975
697
  allowedRecipients:null,
976
- apiKey:validateApiKey(options.apiKey),
977
- appId:validateAppId(options.appId),
698
+ apiKey:options.apiKey,
699
+ appId:options.appId,
978
700
  errorRecipients:[],
979
701
  fetchImpl,
980
- from:validateFrom(options.from),
702
+ from:options.from,
981
703
  providerTimeoutMs:optionalTimeoutMs(options.providerTimeoutMs,'providerTimeoutMs'),
982
704
  requestIdFactory:options.requestIdFactory??randomUUID,
983
- retryableDelayMs:positiveInteger(
984
- options.retryableDelayMs,
985
- 1_000,
986
- {label:'retryableDelayMs'}
987
- ),
988
- signal:validateSignal(options.signal),
705
+ retryableDelayMs:readRetryDelayMs(options.retryableDelayMs),
706
+ signal:options.signal,
989
707
  report:options.report,
990
708
  reportKey:options.reportKey,
991
709
  onEvent:options.onEvent
992
710
  };
993
711
  }
994
712
 
995
- function directSendResult(result,{delivery,requestId}){
713
+ function createDirectSendResult(result,{delivery,requestId}){
996
714
  const common={
997
715
  ...result,
998
716
  provider:'resend',
@@ -1003,20 +721,16 @@ function directSendResult(result,{delivery,requestId}){
1003
721
  ?result.fault.retryable?'retryable':'permanent'
1004
722
  :result.kind,
1005
723
  requestId,
1006
- providerStatus:result.providerStatus,
1007
- providerRequest:JSON.parse(delivery.providerBody),
724
+ providerRequest:JSON.parse(delivery.serializedProviderRequest),
1008
725
  report:delivery.report,
1009
726
  recipientCount:delivery.recipientCount
1010
727
  };
1011
728
  if(result.kind==='accepted'){
1012
- return {...common,providerId:result.providerId};
729
+ return common;
1013
730
  }
1014
731
  if(result.kind==='ambiguous'){
1015
732
  return {
1016
733
  ...common,
1017
- code:result.code,
1018
- details:result.details,
1019
- ...(result.retryAfterMs?{retryAfterMs:result.retryAfterMs}:{}),
1020
734
  retryable:true,
1021
735
  uncertain:true
1022
736
  };
@@ -1033,8 +747,7 @@ function directSendResult(result,{delivery,requestId}){
1033
747
  }
1034
748
 
1035
749
  export async function sendResendMail(options={}){
1036
- const configuration=normalizeDirectSendOptions(options);
1037
- const delivery=normalizeReport(configuration.report,configuration);
750
+ const configuration=resolveDirectSendConfiguration(options);
1038
751
  if(configuration.signal?.aborted){
1039
752
  const error=new Error('Mail send cancelled before provider attempt.',{
1040
753
  cause:configuration.signal.reason
@@ -1042,29 +755,30 @@ export async function sendResendMail(options={}){
1042
755
  error.code='ARCANE_CANCELLED';
1043
756
  throw error;
1044
757
  }
758
+ const delivery=prepareProviderDelivery(configuration.report,configuration);
1045
759
  const requestId=createRequestId(configuration.requestIdFactory);
1046
- const observer=createObserver(configuration.onEvent);
760
+ const observer=configuration.onEvent?createMailEventObserver(configuration.onEvent):null;
1047
761
  try{
1048
- const result=await performResendAttempt(
762
+ const result=await attemptResendDelivery(
1049
763
  configuration,
1050
764
  delivery,
1051
765
  configuration.reportKey,
1052
766
  configuration.signal,
1053
767
  requestId,
1054
- observer.observe
768
+ observer?.observe
1055
769
  );
1056
- return directSendResult(result,{
770
+ return createDirectSendResult(result,{
1057
771
  delivery,
1058
772
  requestId
1059
773
  });
1060
774
  }finally{
1061
- await observer.drain();
775
+ if(observer)await observer.drain();
1062
776
  }
1063
777
  }
1064
778
 
1065
- function sendProviderResult(response,result,{origin,requestId,recipientCount}){
779
+ function writeProviderDeliveryResponse(response,result,{origin,requestId,recipientCount}){
1066
780
  if(result.kind==='accepted'){
1067
- return writeJson(response,202,{
781
+ return sendJsonResponse(response,202,{
1068
782
  requestId,
1069
783
  status:'accepted',
1070
784
  accepted:recipientCount,
@@ -1074,7 +788,7 @@ function sendProviderResult(response,result,{origin,requestId,recipientCount}){
1074
788
  },{origin});
1075
789
  }
1076
790
  if(result.kind==='ambiguous'){
1077
- return writeJson(response,207,{
791
+ return sendJsonResponse(response,207,{
1078
792
  requestId,
1079
793
  status:'delivery_uncertain',
1080
794
  accepted:0,
@@ -1083,120 +797,121 @@ function sendProviderResult(response,result,{origin,requestId,recipientCount}){
1083
797
  ...(result.retryAfterMs?{retryAfterMs:result.retryAfterMs}:{})
1084
798
  },{origin,retryAfterMs:result.retryAfterMs});
1085
799
  }
1086
- return writeFault(response,requestId,result.fault,origin);
800
+ return sendMailFailureResponse(response,requestId,result.fault,origin);
1087
801
  }
1088
802
 
1089
803
  export function createResendMailRequestHandler(options={}){
1090
- const configuration=normalizeConfiguration(options);
804
+ return createConfiguredMailHandler(resolveMailServerConfiguration(options));
805
+ }
806
+
807
+ function createConfiguredMailHandler(configuration){
1091
808
  const ownerController=new AbortController();
1092
809
  const activeRequests=new Set();
1093
- const observer=createObserver(configuration.onEvent);
810
+ const observer=configuration.onEvent?createMailEventObserver(configuration.onEvent):null;
1094
811
  let closePromise=null;
1095
812
 
1096
- function forwardOwnerAbort(){
813
+ function abortHandlerFromOwner(){
1097
814
  ownerController.abort(configuration.signal?.reason??new Error('Mail server cancelled.'));
1098
815
  }
1099
- configuration.signal?.addEventListener('abort',forwardOwnerAbort,{once:true});
816
+ configuration.signal?.addEventListener('abort',abortHandlerFromOwner,{once:true});
1100
817
  if(configuration.signal?.aborted){
1101
- forwardOwnerAbort();
818
+ abortHandlerFromOwner();
1102
819
  }
1103
820
 
1104
- async function handleOwnedRequest(request,response){
821
+ async function handleMailRequest(request,response){
1105
822
  const requestId=createRequestId(configuration.requestIdFactory);
1106
- const startedAt=Date.now();
823
+ let appId=request.headers['x-mail-app']??configuration.appId;
824
+ const startedAt=configuration.onEvent?Date.now():0;
1107
825
  const requestController=new AbortController();
1108
826
  let delivery=null;
1109
827
  let idempotencyKey=null;
1110
828
  let origin='';
1111
829
  let providerAttempted=false;
1112
830
  let result=null;
1113
- let serialized=null;
1114
831
 
1115
- function abortFromOwner(){
832
+ function abortRequestFromHandler(){
1116
833
  requestController.abort(ownerController.signal.reason);
1117
834
  }
1118
- function abortFromRequest(){
835
+ function abortDisconnectedRequest(){
1119
836
  requestController.abort(new Error('Mail client disconnected.'));
1120
837
  }
1121
- function abortFromResponseClose(){
838
+ function abortRequestOnPrematureResponseClose(){
1122
839
  if(!response.writableEnded){
1123
- abortFromRequest();
840
+ abortDisconnectedRequest();
1124
841
  }
1125
842
  }
1126
- function absorbResponseError(){
1127
- abortFromRequest();
1128
- }
1129
843
  function releaseRequestListeners(){
1130
- request.removeListener('aborted',abortFromRequest);
1131
- request.removeListener('error',abortFromRequest);
844
+ request.removeListener('aborted',abortDisconnectedRequest);
845
+ request.removeListener('error',abortDisconnectedRequest);
1132
846
  }
1133
847
  function releaseResponseListeners(){
1134
- response.removeListener('close',abortFromResponseClose);
1135
- response.removeListener('error',absorbResponseError);
848
+ response.removeListener('close',abortRequestOnPrematureResponseClose);
849
+ response.removeListener('error',abortDisconnectedRequest);
1136
850
  }
1137
851
 
1138
- ownerController.signal.addEventListener('abort',abortFromOwner,{once:true});
1139
- request.once('aborted',abortFromRequest);
1140
- request.once('error',abortFromRequest);
1141
- response.once('close',abortFromResponseClose);
1142
- response.once('error',absorbResponseError);
852
+ ownerController.signal.addEventListener('abort',abortRequestFromHandler,{once:true});
853
+ request.once('aborted',abortDisconnectedRequest);
854
+ request.once('error',abortDisconnectedRequest);
855
+ response.once('close',abortRequestOnPrematureResponseClose);
856
+ response.once('error',abortDisconnectedRequest);
1143
857
  if(ownerController.signal.aborted){
1144
- abortFromOwner();
858
+ abortRequestFromHandler();
1145
859
  }
1146
- observer.observe({
860
+ if(configuration.onEvent)observer.observe({
1147
861
  type:'mail.request.received',
1148
- appId:configuration.appId,
862
+ appId,
1149
863
  requestId
1150
864
  });
1151
865
 
1152
866
  try{
1153
- validateLoopbackRequest(request);
1154
- origin=allowedOrigin(request,configuration);
1155
- if(request.url!==RESEND_MAIL_PATH){
867
+ if(request.url!==RESEND_MAIL_PATH&&!request.url?.startsWith(`${RESEND_MAIL_PATH}?`)){
1156
868
  throw new MailGatewayFault('mail_route_not_found',{statusCode:404});
1157
869
  }
870
+ const requestOrigin=request.headers.origin;
871
+ if(requestOrigin){
872
+ const originAllowed=configuration.allowedOrigins.size>0
873
+ ?configuration.allowedOrigins.has(requestOrigin)
874
+ :requestOrigin===`https://${request.headers.host}`
875
+ ||requestOrigin===`http://${request.headers.host}`;
876
+ if(!originAllowed){
877
+ throw new MailGatewayFault('mail_origin_not_allowed',{statusCode:403});
878
+ }
879
+ origin=requestOrigin;
880
+ }
1158
881
  if(request.method==='OPTIONS'){
1159
- const preflight=validatePreflight(request);
1160
- writePreflight(response,origin,preflight);
882
+ sendCorsPreflightResponse(response,origin);
1161
883
  return;
1162
884
  }
1163
885
  if(request.method!=='POST'){
1164
886
  throw new MailGatewayFault('mail_method_not_allowed',{statusCode:405});
1165
887
  }
1166
- if(singleHeader(request,'x-mail-app')!==configuration.appId){
1167
- throw new MailGatewayFault('mail_app_not_allowed',{statusCode:403});
1168
- }
1169
- authenticateLocalCaller(request,configuration);
1170
- idempotencyKey=singleHeader(request,'idempotency-key');
1171
- if(!IDEMPOTENCY_KEY_PATTERN.test(idempotencyKey)){
1172
- throw new MailGatewayFault('invalid_idempotency_key',{statusCode:400});
1173
- }
1174
- const contentType=singleHeader(request,'content-type');
1175
- if(!JSON_CONTENT_TYPE_PATTERN.test(contentType)){
1176
- throw new MailGatewayFault('mail_unsupported_content_type',{statusCode:415});
888
+ if(configuration.verifySubscription){
889
+ appId=await verifyMailSubscription(request,configuration,requestController.signal);
1177
890
  }
1178
- serialized=await readRequestBody(request,{
891
+ idempotencyKey=requireRequestHeader(request,'idempotency-key');
892
+ const requestText=await readRequestBodyText(request,{
1179
893
  signal:requestController.signal,
1180
894
  timeoutMs:configuration.bodyTimeoutMs
1181
895
  });
1182
- delivery=parseReport(serialized,configuration);
896
+ delivery=parseMailRequest(requestText,configuration);
1183
897
  providerAttempted=true;
1184
- result=await performResendAttempt(
898
+ result=await attemptResendDelivery(
1185
899
  configuration,
1186
900
  delivery,
1187
901
  idempotencyKey,
1188
902
  requestController.signal,
1189
903
  requestId,
1190
- observer.observe
904
+ observer?.observe,
905
+ appId
1191
906
  );
1192
- sendProviderResult(response,result,{
907
+ writeProviderDeliveryResponse(response,result,{
1193
908
  origin,
1194
909
  recipientCount:delivery.recipientCount,
1195
910
  requestId
1196
911
  });
1197
- observer.observe({
912
+ if(configuration.onEvent)observer.observe({
1198
913
  type:'mail.request.completed',
1199
- appId:configuration.appId,
914
+ appId,
1200
915
  classification:result.kind,
1201
916
  delivery,
1202
917
  durationMs:Math.max(0,Date.now()-startedAt),
@@ -1206,14 +921,14 @@ export function createResendMailRequestHandler(options={}){
1206
921
  requestId
1207
922
  });
1208
923
  }catch(error){
1209
- const fault=normalizeFault(error);
1210
- writeFault(response,requestId,fault,origin);
924
+ const fault=mailFaultFromError(error);
925
+ sendMailFailureResponse(response,requestId,fault,origin);
1211
926
  if(!request.readableEnded&&!request.destroyed){
1212
927
  request.resume();
1213
928
  }
1214
- observer.observe({
929
+ if(configuration.onEvent)observer.observe({
1215
930
  type:'mail.request.completed',
1216
- appId:configuration.appId,
931
+ appId,
1217
932
  classification:fault.uncertain?'ambiguous':fault.retryable?'retryable':'permanent',
1218
933
  delivery,
1219
934
  durationMs:Math.max(0,Date.now()-startedAt),
@@ -1231,7 +946,7 @@ export function createResendMailRequestHandler(options={}){
1231
946
  requestId
1232
947
  });
1233
948
  }finally{
1234
- ownerController.signal.removeEventListener('abort',abortFromOwner);
949
+ ownerController.signal.removeEventListener('abort',abortRequestFromHandler);
1235
950
  if(request.readableEnded||request.destroyed){
1236
951
  releaseRequestListeners();
1237
952
  }else{
@@ -1245,30 +960,31 @@ export function createResendMailRequestHandler(options={}){
1245
960
  }
1246
961
  }
1247
962
 
1248
- function handle(request,response){
1249
- const operation=handleOwnedRequest(request,response);
963
+ function dispatchMailRequest(request,response){
964
+ const operation=handleMailRequest(request,response);
1250
965
  activeRequests.add(operation);
1251
- operation.catch(function closeFailedRequest(){
966
+ operation.catch(function closeResponseAfterHandlerFailure(error){
967
+ reportMailError('Mail request handler failed.',error);
1252
968
  if(!response.destroyed){
1253
- response.destroy();
969
+ response.destroy(error);
1254
970
  }
1255
971
  }).finally(function releaseActiveRequest(){
1256
972
  activeRequests.delete(operation);
1257
973
  });
1258
974
  }
1259
975
 
1260
- async function closeOwnedHandler(){
1261
- configuration.signal?.removeEventListener('abort',forwardOwnerAbort);
976
+ async function closeMailRequestHandler(){
977
+ configuration.signal?.removeEventListener('abort',abortHandlerFromOwner);
1262
978
  if(!ownerController.signal.aborted){
1263
979
  ownerController.abort(new Error('Mail server closed.'));
1264
980
  }
1265
981
  await Promise.allSettled([...activeRequests]);
1266
- await observer.drain();
982
+ if(observer)await observer.drain();
1267
983
  }
1268
984
 
1269
985
  function close(){
1270
986
  if(!closePromise){
1271
- closePromise=closeOwnedHandler();
987
+ closePromise=closeMailRequestHandler();
1272
988
  }
1273
989
  return closePromise;
1274
990
  }
@@ -1277,13 +993,13 @@ export function createResendMailRequestHandler(options={}){
1277
993
  appId:configuration.appId,
1278
994
  callerAuthentication:configuration.callerAuthentication,
1279
995
  close,
1280
- handle,
996
+ handle:dispatchMailRequest,
1281
997
  path:RESEND_MAIL_PATH,
1282
998
  protocol:RESEND_MAIL_SERVER_PROTOCOL
1283
999
  };
1284
1000
  }
1285
1001
 
1286
- function deployMailServer(mailServer){
1002
+ function listenForMailRequests(mailServer){
1287
1003
  return new Promise(function waitForMailListener(resolve,reject){
1288
1004
  function onError(error){
1289
1005
  reject(error);
@@ -1297,7 +1013,7 @@ function deployMailServer(mailServer){
1297
1013
  }
1298
1014
 
1299
1015
  export async function startResendMailServer(options={}){
1300
- const configuration=normalizeConfiguration(options);
1016
+ const configuration=resolveMailServerConfiguration(options);
1301
1017
  if(configuration.signal?.aborted){
1302
1018
  throw configuration.signal.reason??new Error('Mail server start was cancelled.');
1303
1019
  }
@@ -1306,23 +1022,23 @@ export async function startResendMailServer(options={}){
1306
1022
  port:configuration.port,
1307
1023
  server:{timeout:0}
1308
1024
  });
1309
- const requestHandler=createResendMailRequestHandler(options);
1310
- mailServer.onRawRequest=function handleMailRequest(request,response){
1025
+ const requestHandler=createConfiguredMailHandler(configuration);
1026
+ mailServer.onRawRequest=function routeRawMailRequest(request,response){
1311
1027
  requestHandler.handle(request,response);
1312
1028
  return true;
1313
1029
  };
1314
1030
 
1315
1031
  let server;
1316
1032
  try{
1317
- server=await deployMailServer(mailServer);
1033
+ server=await listenForMailRequests(mailServer);
1318
1034
  }catch(error){
1319
1035
  await Promise.allSettled([mailServer.close(),requestHandler.close()]);
1320
1036
  throw error;
1321
1037
  }
1322
1038
  const address=server.address();
1323
- if(!address||is.string(address)||!isNumericLoopback(address.address)){
1039
+ if(!address||is.string(address)){
1324
1040
  await Promise.allSettled([mailServer.close(),requestHandler.close()]);
1325
- throw configurationError('Mail server did not bind to a numeric loopback address.');
1041
+ throw configurationError('Mail server has no TCP listener address.');
1326
1042
  }
1327
1043
  const displayHost=address.address.includes(':')?`[${address.address}]`:address.address;
1328
1044
  const origin=`http://${displayHost}:${String(address.port)}`;
@@ -1335,8 +1051,8 @@ export async function startResendMailServer(options={}){
1335
1051
  });
1336
1052
  lifecycle.catch(function observeMailLifecycleFailure(){});
1337
1053
 
1338
- async function closeOwnedServer(){
1339
- configuration.signal?.removeEventListener('abort',closeFromSignal);
1054
+ async function closeMailServer(){
1055
+ configuration.signal?.removeEventListener('abort',closeServerOnAbort);
1340
1056
  const handlerClosing=requestHandler.close();
1341
1057
  try{
1342
1058
  await Promise.all([mailServer.close(),handlerClosing]);
@@ -1349,21 +1065,21 @@ export async function startResendMailServer(options={}){
1349
1065
 
1350
1066
  function close(){
1351
1067
  if(!closePromise){
1352
- closePromise=closeOwnedServer();
1068
+ closePromise=closeMailServer();
1353
1069
  }
1354
1070
  return closePromise;
1355
1071
  }
1356
1072
 
1357
- function closeFromSignal(){
1073
+ function closeServerOnAbort(){
1358
1074
  close().catch(function ignoreSignalCloseFailure(){});
1359
1075
  }
1360
1076
 
1361
- function closeFromServerError(error){
1077
+ function closeServerAfterError(error){
1362
1078
  rejectLifecycle(error);
1363
1079
  close().catch(function observeOperationalCloseFailure(){});
1364
1080
  }
1365
1081
 
1366
- server.once('close',function finishExternallyClosedServer(){
1082
+ server.once('close',function finishMailServerAfterExternalClose(){
1367
1083
  if(!closePromise){
1368
1084
  closePromise=requestHandler.close().then(
1369
1085
  function resolveExternalClose(){resolveLifecycle();},
@@ -1372,10 +1088,10 @@ export async function startResendMailServer(options={}){
1372
1088
  closePromise.catch(function observeExternalCloseFailure(){});
1373
1089
  }
1374
1090
  });
1375
- server.on('error',closeFromServerError);
1376
- configuration.signal?.addEventListener('abort',closeFromSignal,{once:true});
1091
+ server.on('error',closeServerAfterError);
1092
+ configuration.signal?.addEventListener('abort',closeServerOnAbort,{once:true});
1377
1093
  if(configuration.signal?.aborted){
1378
- closeFromSignal();
1094
+ closeServerOnAbort();
1379
1095
  }
1380
1096
 
1381
1097
  return {