arcane-os 0.2.2 → 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.
Files changed (117) hide show
  1. package/CHANGELOG.md +29 -0
  2. package/README.md +8 -8
  3. package/browser-runtime/ARCANE_SDK_BROWSER_RELEASE.json +29 -22
  4. package/browser-runtime/ai/ARCANE_AI_BROWSER_SPEECH_COMPONENTS.json +203 -0
  5. package/browser-runtime/ai/browser-kokoro-worker.mjs +11 -2
  6. package/browser-runtime/ai/browser-speech-artifacts.mjs +3230 -397
  7. package/browser-runtime/ai/browser-speech-providers.mjs +1141 -157
  8. package/browser-runtime/ai/browser-speech.mjs +2 -0
  9. package/browser-runtime/ai/browser-whisper-worker.mjs +11 -2
  10. package/browser-runtime/ai/model-controller.mjs +285 -95
  11. package/browser-runtime/ai/speech-worker-client.mjs +247 -32
  12. package/browser-runtime/ai/speech-worker-runtime.mjs +2310 -167
  13. package/browser-runtime/event-manager.mjs +1097 -1
  14. package/docs/architecture.md +2 -2
  15. package/docs/event-manager.md +155 -27
  16. package/docs/reference/README.md +27 -27
  17. package/docs/reference/ai/browser-speech-package-authority.json +835 -0
  18. package/docs/reference/ai/browser-speech.md +1162 -246
  19. package/docs/reference/ai/browser-wasm.md +18 -7
  20. package/docs/reference/availability-and-normalization.md +6 -3
  21. package/docs/reference/behavioral-testing.md +29 -6
  22. package/docs/reference/cli.md +117 -9
  23. package/docs/reference/core/arcane-ai-contracts.md +1 -1
  24. package/docs/reference/event-manager.md +577 -32
  25. package/docs/reference/inventory/package-api.json +478 -2
  26. package/docs/reference/inventory/runtime-components.json +108 -44
  27. package/docs/reference/inventory/runtime-modules.json +131 -53
  28. package/docs/reference/mail.md +316 -0
  29. package/docs/reference/protocols.md +157 -43
  30. package/docs/reference/runtime-components.md +258 -83
  31. package/docs/reference/runtime-modules.md +613 -77
  32. package/docs/reference/sdk-api.md +1014 -25
  33. package/package.json +5 -4
  34. package/runtime/ARCANE_RUNTIME_RELEASE.json +145 -140
  35. package/runtime/arcane/components/app-bar.html +34 -13
  36. package/runtime/arcane/components/assistant-panel.html +110 -57
  37. package/runtime/arcane/components/calculator.html +7 -4
  38. package/runtime/arcane/components/chart.html +58 -17
  39. package/runtime/arcane/components/chat.html +606 -136
  40. package/runtime/arcane/components/conversation-view.html +13 -6
  41. package/runtime/arcane/components/dashboard-config.html +96 -59
  42. package/runtime/arcane/components/data-maintenance.html +69 -14
  43. package/runtime/arcane/components/data-view.html +53 -7
  44. package/runtime/arcane/components/directory-picker.html +118 -32
  45. package/runtime/arcane/components/document-inspector.html +47 -10
  46. package/runtime/arcane/components/file-drop.html +81 -35
  47. package/runtime/arcane/components/file-inspector.html +72 -22
  48. package/runtime/arcane/components/file-manager.html +374 -79
  49. package/runtime/arcane/components/integration-settings.html +12 -5
  50. package/runtime/arcane/components/local-ai-status.html +48 -19
  51. package/runtime/arcane/components/markdown-document.html +161 -68
  52. package/runtime/arcane/components/markdown-editor.html +110 -33
  53. package/runtime/arcane/components/media-embed.html +8 -5
  54. package/runtime/arcane/components/modal.html +15 -5
  55. package/runtime/arcane/components/output-panel.html +28 -23
  56. package/runtime/arcane/components/preferences-form.html +22 -4
  57. package/runtime/arcane/components/record-timeline.html +18 -2
  58. package/runtime/arcane/components/relationship-board.html +23 -3
  59. package/runtime/arcane/components/screen-capture.html +10 -4
  60. package/runtime/arcane/components/source-code-viewer.html +76 -9
  61. package/runtime/arcane/components/source-explanation.html +23 -3
  62. package/runtime/arcane/components/speech.html +462 -384
  63. package/runtime/arcane/components/summary-strip.html +22 -11
  64. package/runtime/arcane/components/table.html +39 -21
  65. package/runtime/arcane/components/task-progress.html +79 -21
  66. package/runtime/arcane/components/terminal-workspace.html +7 -4
  67. package/runtime/arcane/components/theme-editor.html +7 -3
  68. package/runtime/arcane/components/unified-inbox.html +9 -4
  69. package/runtime/arcane/components/voice-transcription.html +639 -98
  70. package/runtime/arcane/components/weather-widget.html +5 -3
  71. package/runtime/arcane/components/web-navigator.html +48 -8
  72. package/runtime/arcane/entities/Chat.js +1 -1
  73. package/runtime/arcane/entities/User.js +110 -23
  74. package/runtime/arcane/modules/AI.js +2109 -130
  75. package/runtime/arcane/modules/AIProviderRuntime.js +720 -13
  76. package/runtime/arcane/modules/AIRuntimeState.js +109 -52
  77. package/runtime/arcane/modules/ApiModelDatabase.js +390 -17
  78. package/runtime/arcane/modules/BrowserTestSuite.js +205 -28
  79. package/runtime/arcane/modules/CalculatorEngine.js +63 -3
  80. package/runtime/arcane/modules/CommunicationAppController.js +588 -28
  81. package/runtime/arcane/modules/CommunicationHub.js +590 -10
  82. package/runtime/arcane/modules/ComponentContracts.js +470 -0
  83. package/runtime/arcane/modules/ConversationTimebox.js +152 -33
  84. package/runtime/arcane/modules/DBLS.js +40 -7
  85. package/runtime/arcane/modules/DBOPFS.js +35 -11
  86. package/runtime/arcane/modules/DataMaintenance.js +12 -2
  87. package/runtime/arcane/modules/Errors.js +65 -7
  88. package/runtime/arcane/modules/HTMLImport.js +198 -14
  89. package/runtime/arcane/modules/LocalAIReadinessController.js +208 -29
  90. package/runtime/arcane/modules/Mail.js +738 -115
  91. package/runtime/arcane/modules/MailOutbox.mjs +1395 -0
  92. package/runtime/arcane/modules/MailTransport.mjs +197 -39
  93. package/runtime/arcane/modules/Ollama.js +36 -1
  94. package/runtime/arcane/modules/OpenMeteoWeatherProvider.js +583 -7
  95. package/runtime/arcane/modules/PreferenceStore.js +367 -33
  96. package/runtime/arcane/modules/RecordReviewStore.js +322 -23
  97. package/runtime/arcane/modules/ScreenCapture.js +1397 -15
  98. package/runtime/arcane/modules/SpeechPlayback.js +438 -41
  99. package/runtime/arcane/modules/TerminalClient.js +277 -12
  100. package/runtime/arcane/modules/ThemeBootstrap.js +80 -6
  101. package/runtime/arcane/modules/ThemeManager.js +39 -7
  102. package/runtime/arcane/modules/TimeGuard.js +131 -20
  103. package/runtime/arcane/modules/WaitForComponent.js +386 -33
  104. package/schemas/arcane-lock.schema.json +2 -2
  105. package/src/cli/main.mjs +435 -9
  106. package/src/event-manager.mjs +1097 -1
  107. package/src/import-map.mjs +21 -3
  108. package/src/index.mjs +13 -0
  109. package/src/installed-sdk-runtime.mjs +112 -0
  110. package/src/mail-api.mjs +22 -0
  111. package/src/mail-credentials.mjs +667 -0
  112. package/src/mail-server.mjs +1769 -0
  113. package/src/mail.mjs +261 -0
  114. package/src/sdk-browser-runtime.mjs +85 -41
  115. package/src/testing-loader.mjs +7 -0
  116. package/src/toolchain.mjs +3 -0
  117. package/src/workspace.mjs +1 -1
@@ -1,3 +1,5 @@
1
+ import {createArcaneEventSource} from 'arcane-os/event-manager';
2
+ import MailOutbox from './MailOutbox.mjs';
1
3
  import {
2
4
  DEFAULT_MAIL_REQUEST_TIMEOUT_MS,
3
5
  sendMailReport,
@@ -5,25 +7,199 @@ import {
5
7
 
6
8
  let userInstance=null;
7
9
 
8
- async function loadOptionalMailDependencies(){
9
- await import('./DBOPFS.js');
10
- if(!userInstance){
11
- const { default:UserEntity }=await import('../entities/User.js');
12
- userInstance=new UserEntity();
13
- }
14
- return {
15
- dbopfs:globalThis.dbopfs,
16
- user:userInstance,
17
- };
18
- }
19
-
20
10
  const MAX_SUBJECT_LENGTH=160;
21
11
  const MAX_MESSAGE_BYTES=25*1024*1024;
22
12
  const MAX_NATIVE_REPORT_BYTES=768*1024;
23
13
  const MAIL_TYPES=new Set(['error','report','crisis_detected']);
14
+ const MAIL_OUTBOX_EVENTS=Object.freeze([
15
+ 'mail-outbox-state',
16
+ 'mail-outbox-delivery',
17
+ 'mail-outbox-drain'
18
+ ]);
19
+ const PENDING_OUTBOX_STATES=new Set(['queued','sending','retry_wait']);
20
+ const NATIVE_MAIL_RESPONSE_STATUS_CODES=Object.freeze({
21
+ accepted:202,
22
+ delivery_uncertain:207,
23
+ partially_accepted:207
24
+ });
25
+ const NATIVE_MAIL_RESULT_KEYS=Object.freeze([
26
+ 'partial','requestId','sent','status','statusCode','uncertain'
27
+ ]);
28
+ const NATIVE_MAIL_REQUEST_ID_PATTERN=/^[A-Za-z0-9-]{8,128}$/;
29
+ const NATIVE_MAIL_UNCERTAIN_ERROR_CODES=new Set([
30
+ 'ARCANE_REQUEST_TIMEOUT',
31
+ 'MAIL_GATEWAY_RESPONSE_INVALID',
32
+ 'MAIL_GATEWAY_RESPONSE_TOO_LARGE',
33
+ 'MAIL_GATEWAY_UNAVAILABLE',
34
+ 'MAIL_NATIVE_RESULT_INVALID',
35
+ 'MAIL_SEND_TIMEOUT',
36
+ 'NATIVE_BRIDGE_ACK_TIMEOUT'
37
+ ]);
38
+ const NATIVE_MAIL_RETRYABLE_ERROR_CODES=new Set([
39
+ 'ARCANE_BRIDGE_CALL_FAILED',
40
+ 'ARCANE_TRANSPORT_UNAVAILABLE'
41
+ ]);
24
42
  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])?)+$/i;
25
43
  const ARCANE_APP_ID_PATTERN=/^[a-z0-9](?:[a-z0-9-]{0,62})$/;
26
44
 
45
+ function codedError(message,code,ErrorType=Error){
46
+ const error=new ErrorType(message);
47
+ error.code=code;
48
+ return error;
49
+ }
50
+
51
+ function validateMailSignal(signal){
52
+ if(signal===null||signal===undefined) return null;
53
+ if(typeof signal!=='object'
54
+ ||typeof signal.aborted!=='boolean'
55
+ ||typeof signal.addEventListener!=='function'
56
+ ||typeof signal.removeEventListener!=='function'){
57
+ throw new TypeError('Mail signal must be an AbortSignal');
58
+ }
59
+ return signal;
60
+ }
61
+
62
+ function throwIfMailAborted(signal){
63
+ if(!signal?.aborted) return;
64
+ const error=codedError('Mail operation was aborted.','MAIL_OUTBOX_ABORTED');
65
+ error.name='AbortError';
66
+ if(signal.reason!==undefined) error.cause=signal.reason;
67
+ throw error;
68
+ }
69
+
70
+ function linkedMailSignal(primary,secondary){
71
+ const signals=[];
72
+ for(const candidate of [primary,secondary]){
73
+ const signal=validateMailSignal(candidate);
74
+ if(signal&&!signals.includes(signal)) signals.push(signal);
75
+ }
76
+ if(signals.length===0){
77
+ return Object.freeze({signal:null,dispose:function disposeEmptyMailSignal(){}});
78
+ }
79
+ if(signals.length===1){
80
+ return Object.freeze({signal:signals[0],dispose:function disposeSingleMailSignal(){}});
81
+ }
82
+ const controller=new AbortController();
83
+ const listeners=[];
84
+ for(const signal of signals){
85
+ const listener=function forwardLinkedMailAbort(){
86
+ if(!controller.signal.aborted) controller.abort(signal.reason);
87
+ };
88
+ listeners.push({signal,listener});
89
+ if(signal.aborted) listener();
90
+ else signal.addEventListener('abort',listener,{once:true});
91
+ }
92
+ return Object.freeze({
93
+ signal:controller.signal,
94
+ dispose:function disposeLinkedMailSignal(){
95
+ for(const entry of listeners){
96
+ entry.signal.removeEventListener('abort',entry.listener);
97
+ }
98
+ }
99
+ });
100
+ }
101
+
102
+ function waitForMailOperation(operation,signal){
103
+ const normalizedSignal=validateMailSignal(signal);
104
+ throwIfMailAborted(normalizedSignal);
105
+ if(!normalizedSignal) return operation;
106
+ return new Promise(function observeMailCallerAbort(resolve,reject){
107
+ let settled=false;
108
+ function finish(callback,value){
109
+ if(settled) return;
110
+ settled=true;
111
+ normalizedSignal.removeEventListener('abort',handleAbort);
112
+ callback(value);
113
+ }
114
+ function handleAbort(){
115
+ try{
116
+ throwIfMailAborted(normalizedSignal);
117
+ }catch(error){
118
+ finish(reject,error);
119
+ }
120
+ }
121
+ normalizedSignal.addEventListener('abort',handleAbort,{once:true});
122
+ Promise.resolve(operation).then(
123
+ finish.bind(null,resolve),
124
+ finish.bind(null,reject)
125
+ );
126
+ if(normalizedSignal.aborted) handleAbort();
127
+ });
128
+ }
129
+
130
+ function exactNativeMailResult(value){
131
+ if(!value||typeof value!=='object'||Array.isArray(value)
132
+ ||Object.keys(value).sort().join(',')!==[...NATIVE_MAIL_RESULT_KEYS].sort().join(',')
133
+ ||typeof value.requestId!=='string'
134
+ ||!NATIVE_MAIL_REQUEST_ID_PATTERN.test(value.requestId)
135
+ ||!Object.hasOwn(NATIVE_MAIL_RESPONSE_STATUS_CODES,value.status)
136
+ ||value.statusCode!==NATIVE_MAIL_RESPONSE_STATUS_CODES[value.status]
137
+ ||typeof value.sent!=='boolean'
138
+ ||typeof value.partial!=='boolean'
139
+ ||typeof value.uncertain!=='boolean'
140
+ ||value.sent!==(value.status==='accepted')
141
+ ||value.partial!==(value.status==='partially_accepted')
142
+ ||value.uncertain!==(value.status==='delivery_uncertain')){
143
+ throw codedError(
144
+ 'Native Arcane mail returned an invalid result.',
145
+ 'MAIL_NATIVE_RESULT_INVALID'
146
+ );
147
+ }
148
+ if(value.status!=='accepted') return value;
149
+ return {
150
+ ...value,
151
+ acceptanceAuthority:'arcane-core-mail-send-v1'
152
+ };
153
+ }
154
+
155
+ function normalizedNativeMailError(error){
156
+ const uncertain=NATIVE_MAIL_UNCERTAIN_ERROR_CODES.has(error?.code);
157
+ const retryable=uncertain||NATIVE_MAIL_RETRYABLE_ERROR_CODES.has(error?.code);
158
+ if(!retryable) return error;
159
+ const normalized=new Error(
160
+ uncertain
161
+ ?'Native Arcane mail delivery has an uncertain outcome.'
162
+ :'Native Arcane mail transport is temporarily unavailable.',
163
+ {cause:error}
164
+ );
165
+ normalized.code=error.code;
166
+ normalized.retryable=true;
167
+ normalized.uncertain=uncertain;
168
+ const statusCode=Number(error?.statusCode??error?.status);
169
+ if(Number.isSafeInteger(statusCode)&&statusCode>=100&&statusCode<=599){
170
+ normalized.statusCode=statusCode;
171
+ }
172
+ return normalized;
173
+ }
174
+
175
+ async function loadRequiredMailStorage(){
176
+ try{
177
+ await import('./DBOPFS.js');
178
+ }catch{
179
+ throw codedError(
180
+ 'Mail outbox storage is unavailable.',
181
+ 'MAIL_OUTBOX_STORAGE_UNAVAILABLE'
182
+ );
183
+ }
184
+ const storage=globalThis.dbopfs;
185
+ if(!storage||typeof storage!=='object'){
186
+ throw codedError(
187
+ 'Mail outbox storage is unavailable.',
188
+ 'MAIL_OUTBOX_STORAGE_UNAVAILABLE'
189
+ );
190
+ }
191
+ return storage;
192
+ }
193
+
194
+ async function loadOptionalMailUser(injectedUser){
195
+ if(injectedUser!==undefined) return injectedUser;
196
+ if(!userInstance){
197
+ const {default:UserEntity}=await import('../entities/User.js');
198
+ userInstance=new UserEntity();
199
+ }
200
+ return userInstance;
201
+ }
202
+
27
203
  function declaredApplicationId(document=globalThis.document){
28
204
  const value=document?.querySelector?.('meta[name="arcane-app-id"]')?.content?.trim();
29
205
  return ARCANE_APP_ID_PATTERN.test(value||'') ? value:'';
@@ -119,7 +295,9 @@ function serializePayload(value){
119
295
 
120
296
  function assertMessageSize(report){
121
297
  const messageBytes=['text','html'].reduce(
122
- (total,key)=>total+utf8ByteLength(report[key]||''),
298
+ function sumMessageBytes(total,key){
299
+ return total+utf8ByteLength(report[key]||'');
300
+ },
123
301
  0
124
302
  );
125
303
  if(messageBytes>MAX_MESSAGE_BYTES){
@@ -127,29 +305,38 @@ function assertMessageSize(report){
127
305
  }
128
306
  }
129
307
 
130
- function assertNativeReportSize(report){
131
- const serialized=JSON.stringify(report);
132
- if(utf8ByteLength(serialized)>MAX_NATIVE_REPORT_BYTES){
308
+ function assertNativeReportSize(serializedReport){
309
+ if(utf8ByteLength(serializedReport)>MAX_NATIVE_REPORT_BYTES){
133
310
  throw new RangeError('Native Arcane mail reports cannot exceed 786,432 serialized bytes');
134
311
  }
135
312
  }
136
313
 
137
- function createReportKey(subject){
138
- const safeSubject=subject
139
- .normalize('NFKD')
140
- .replace(/[^a-z0-9_-]+/gi,'_')
141
- .replace(/^_+|_+$/g,'')
142
- .slice(0,48)||'notification';
143
- let nonce;
144
- if(typeof globalThis.crypto?.randomUUID==='function'){
145
- nonce=globalThis.crypto.randomUUID();
146
- }else if(typeof globalThis.crypto?.getRandomValues==='function'){
147
- const bytes=globalThis.crypto.getRandomValues(new Uint8Array(16));
148
- nonce=[...bytes].map(value=>value.toString(16).padStart(2,'0')).join('');
149
- }else{
150
- nonce=Math.random().toString(36).slice(2).padEnd(16,'0').slice(0,16);
151
- }
152
- return `${Date.now()}-${nonce}-email-${safeSubject}.json`;
314
+ function randomReportNonce(cryptoProvider){
315
+ if(typeof cryptoProvider?.randomUUID==='function'){
316
+ try{
317
+ const value=cryptoProvider.randomUUID();
318
+ if(/^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(value)){
319
+ return value;
320
+ }
321
+ }catch{}
322
+ }
323
+ if(typeof cryptoProvider?.getRandomValues==='function'){
324
+ try{
325
+ const bytes=new Uint8Array(16);
326
+ cryptoProvider.getRandomValues(bytes);
327
+ return [...bytes].map(function reportNonceByte(value){
328
+ return value.toString(16).padStart(2,'0');
329
+ }).join('');
330
+ }catch{}
331
+ }
332
+ throw codedError(
333
+ 'Mail requires Web Crypto to create a durable idempotency key.',
334
+ 'MAIL_CRYPTO_UNAVAILABLE'
335
+ );
336
+ }
337
+
338
+ function createReportKey(timestamp,cryptoProvider){
339
+ return `${timestamp.toString(36)}-${randomReportNonce(cryptoProvider)}-mail`;
153
340
  }
154
341
 
155
342
  function normalizeRecipients(values){
@@ -162,62 +349,514 @@ function normalizeRecipients(values){
162
349
 
163
350
  const recipients=[];
164
351
  for(const value of values){
165
- if(!value){
166
- continue;
167
- }
168
-
352
+ if(!value) continue;
169
353
  if(typeof value!=='string'){
170
354
  throw new TypeError('Every mail recipient must be an email address');
171
355
  }
172
-
173
356
  const address=value.trim().toLowerCase();
174
357
  if(address.length>254||!EMAIL_PATTERN.test(address)){
175
358
  throw new TypeError('Mail contains an invalid recipient address');
176
359
  }
360
+ if(!recipients.includes(address)) recipients.push(address);
361
+ }
362
+ return recipients;
363
+ }
177
364
 
178
- if(!recipients.includes(address)){
179
- recipients.push(address);
180
- }
365
+ function profileValue(user,key){
366
+ try{
367
+ const value=user?.[key];
368
+ return typeof value==='string'?value:'';
369
+ }catch{
370
+ return '';
181
371
  }
372
+ }
182
373
 
183
- return recipients;
374
+ function normalizedDeliveryResult(value){
375
+ if(!value||typeof value!=='object'||Array.isArray(value)) return value;
376
+ let status=typeof value.status==='string'?value.status:'';
377
+ if(!status&&value.sent===true) status='accepted';
378
+ let classification=typeof value.classification==='string'?value.classification:'';
379
+ if(!classification){
380
+ classification={
381
+ accepted:'accepted',
382
+ delivery_uncertain:'ambiguous',
383
+ retryable:'retryable',
384
+ partially_accepted:'permanent',
385
+ permanently_rejected:'permanent'
386
+ }[status]||'';
387
+ }
388
+ const retryAfterMilliseconds=Number(value.retryAfterMs);
389
+ const retryAfterSeconds=Number.isSafeInteger(value.retryAfterSeconds)
390
+ ? value.retryAfterSeconds
391
+ : Number.isFinite(retryAfterMilliseconds)&&retryAfterMilliseconds>0
392
+ ? Math.ceil(retryAfterMilliseconds/1000)
393
+ : null;
394
+ return {
395
+ ...value,
396
+ ...(status?{status}:{}),
397
+ ...(classification?{classification}:{}),
398
+ retryAfterSeconds,
399
+ };
400
+ }
401
+
402
+ function publicSendResult(record){
403
+ return {
404
+ ...(record.result||{}),
405
+ reportKey:record.reportKey,
406
+ state:record.state,
407
+ status:record.result?.status||record.state,
408
+ queued:PENDING_OUTBOX_STATES.has(record.state),
409
+ sent:record.state==='accepted',
410
+ uncertain:record.failure?.uncertain===true,
411
+ attempts:record.attempts,
412
+ };
413
+ }
414
+
415
+ function safeDrainDetail(summary){
416
+ return Object.freeze({
417
+ reason:summary.reason,
418
+ online:summary.online,
419
+ considered:summary.considered,
420
+ attempted:summary.attempted,
421
+ invalidRecords:summary.invalidRecords,
422
+ bounded:summary.bounded,
423
+ pending:summary.pending,
424
+ states:Object.freeze({...summary.states})
425
+ });
426
+ }
427
+
428
+ function normalizeMailOptions(options){
429
+ if(!options||typeof options!=='object'||Array.isArray(options)){
430
+ throw new TypeError('Mail options must be an object');
431
+ }
432
+ const deliver=options.deliver;
433
+ const clock=options.clock??Date.now;
434
+ const isOnline=options.isOnline??function defaultMailOnlineStatus(){
435
+ return globalThis.navigator?.onLine!==false;
436
+ };
437
+ const onlineTarget=options.onlineTarget===undefined
438
+ ?typeof globalThis.addEventListener==='function'?globalThis:null
439
+ :options.onlineTarget;
440
+ const includeContext=options.includeContext??false;
441
+ const cryptoProvider=Object.hasOwn(options,'crypto')
442
+ ?options.crypto
443
+ :globalThis.crypto;
444
+ if(deliver!==undefined&&typeof deliver!=='function'){
445
+ throw new TypeError('Mail deliver must be a function');
446
+ }
447
+ if(typeof clock!=='function'||typeof isOnline!=='function'){
448
+ throw new TypeError('Mail clock and isOnline hooks must be functions');
449
+ }
450
+ if(onlineTarget!==null&&(
451
+ typeof onlineTarget?.addEventListener!=='function'
452
+ ||typeof onlineTarget?.removeEventListener!=='function'
453
+ )){
454
+ throw new TypeError('Mail onlineTarget must be an EventTarget');
455
+ }
456
+ if(typeof includeContext!=='boolean'){
457
+ throw new TypeError('Mail includeContext must be boolean');
458
+ }
459
+ if(cryptoProvider!==null&&cryptoProvider!==undefined
460
+ &&typeof cryptoProvider!=='object'){
461
+ throw new TypeError('Mail crypto must be a Web Crypto provider');
462
+ }
463
+ return {
464
+ clock,
465
+ cryptoProvider,
466
+ deliver,
467
+ includeContext,
468
+ isOnline,
469
+ onlineTarget,
470
+ outboxOptions:{
471
+ ...(options.maxAttemptsPerDrain===undefined
472
+ ?{}:{maxAttemptsPerDrain:options.maxAttemptsPerDrain}),
473
+ ...(options.maxInvalidRecords===undefined
474
+ ?{}:{maxInvalidRecords:options.maxInvalidRecords}),
475
+ ...(options.maxRecords===undefined?{}:{maxRecords:options.maxRecords}),
476
+ ...(options.lockManager===undefined?{}:{lockManager:options.lockManager}),
477
+ maxReportBytes:options.maxReportBytes??MAX_MESSAGE_BYTES,
478
+ },
479
+ storage:options.storage,
480
+ storageInjected:Object.hasOwn(options,'storage'),
481
+ user:Object.hasOwn(options,'user')?options.user:undefined
482
+ };
184
483
  }
185
484
 
186
485
  class Mail {
187
- constructor(config=globalThis.arcane?.config?.mail||{}) {
188
- if(globalThis.window?.mail){
189
- return globalThis.window.mail;
486
+ #backgroundController=null;
487
+ #backgroundDrain=null;
488
+ #backgroundError=null;
489
+ #clock;
490
+ #cryptoProvider;
491
+ #deliver;
492
+ #disposed=false;
493
+ #events;
494
+ #includeContext=false;
495
+ #isOnline;
496
+ #lifecycleController=new AbortController();
497
+ #lifecycleRevision=0;
498
+ #onlineHandler;
499
+ #onlineTarget;
500
+ #outbox=null;
501
+ #outboxOptions;
502
+ #outboxPromise=null;
503
+ #startPromise=null;
504
+ #startRevision=null;
505
+ #startSummary=null;
506
+ #started=false;
507
+ #storage;
508
+ #storageInjected;
509
+ #user;
510
+
511
+ constructor(config=globalThis.arcane?.config?.mail||{},options={}) {
512
+ const resolved=resolveMailConfig(config);
513
+ const normalizedOptions=normalizeMailOptions(options);
514
+ const existing=globalThis.window?.mail;
515
+ if(existing instanceof Mail){
516
+ if(!existing.disposed){
517
+ if(arguments.length>0){
518
+ existing.#applyConfiguration(resolved,normalizedOptions,{reconfigure:true});
519
+ }
520
+ return existing;
521
+ }
522
+ if(globalThis.window.mail===existing) globalThis.window.mail=null;
523
+ }else if(existing){
524
+ throw codedError(
525
+ 'window.mail is already owned by an incompatible implementation.',
526
+ 'MAIL_SINGLETON_CONFLICT'
527
+ );
190
528
  }
191
529
 
192
- const resolved=resolveMailConfig(config);
530
+ this.#applyConfiguration(resolved,normalizedOptions);
531
+ this.#onlineHandler=this.#handleOnline.bind(this);
532
+ this.#events=createArcaneEventSource(this,{
533
+ source:'mail-outbox',
534
+ eventTypes:MAIL_OUTBOX_EVENTS
535
+ });
536
+ if(globalThis.window&&!globalThis.window.mail) globalThis.window.mail=this;
537
+ }
538
+
539
+ #applyConfiguration(resolved,options,{reconfigure=false}={}){
540
+ if(reconfigure&&(this.#started||this.#startPromise||this.#outbox||this.#outboxPromise)){
541
+ throw codedError(
542
+ 'Mail configuration is locked after its durable lifecycle begins.',
543
+ 'MAIL_CONFIGURATION_LOCKED'
544
+ );
545
+ }
193
546
  this.appName=resolved.appName;
194
547
  this.appKey=resolved.appKey;
195
548
  this.endpoint=resolved.endpoint;
196
549
  this.requestTimeout=resolved.requestTimeout;
550
+ this.#storageInjected=options.storageInjected;
551
+ this.#storage=options.storage;
552
+ this.#deliver=options.deliver;
553
+ this.#clock=options.clock;
554
+ this.#cryptoProvider=options.cryptoProvider;
555
+ this.#includeContext=options.includeContext;
556
+ this.#isOnline=options.isOnline;
557
+ this.#onlineTarget=options.onlineTarget;
558
+ this.#outboxOptions=options.outboxOptions;
559
+ this.#user=options.user;
560
+ }
561
+
562
+ get started(){return this.#started;}
563
+ get disposed(){return this.#disposed;}
564
+ get events(){return this.#events;}
565
+ get invalidOutboxRecords(){
566
+ return this.#outbox?.invalidRecords??Object.freeze([]);
567
+ }
568
+ get lastBackgroundError(){return this.#backgroundError;}
569
+
570
+ #assertActive(){
571
+ if(this.#disposed){
572
+ throw codedError('Mail has been disposed.','MAIL_DISPOSED');
573
+ }
197
574
  }
198
575
 
199
576
  #assertConfigured(){
200
- if(!this.appName||(!this.endpoint&&typeof globalThis.Arcane?.mail?.send!=='function')){
201
- throw new Error('Mail transport is not configured');
577
+ const hasTransport=this.#deliver!==undefined
578
+ ||typeof globalThis.Arcane?.mail?.send==='function'
579
+ ||Boolean(this.endpoint);
580
+ if(!this.appName||!hasTransport){
581
+ throw codedError('Mail transport is not configured.','MAIL_NOT_CONFIGURED');
202
582
  }
203
583
  }
204
584
 
205
- async #deliver(report,reportKey){
206
- if(typeof globalThis.Arcane?.mail?.send==='function'){
207
- assertNativeReportSize(report);
208
- return globalThis.Arcane.mail.send({report,reportKey});
585
+ #activeLifecycleSignal(){
586
+ if(this.#lifecycleController.signal.aborted){
587
+ this.#lifecycleController=new AbortController();
209
588
  }
210
- return sendMailReport({
211
- appKey:this.appKey,
212
- appName:this.appName,
213
- endpoint:this.endpoint,
214
- report,
215
- reportKey,
216
- requestTimeout:this.requestTimeout,
589
+ return this.#lifecycleController.signal;
590
+ }
591
+
592
+ #time(){
593
+ const value=this.#clock();
594
+ const milliseconds=value instanceof Date?value.getTime():Number(value);
595
+ if(!Number.isSafeInteger(milliseconds)||milliseconds<0
596
+ ||Number.isNaN(new Date(milliseconds).getTime())){
597
+ throw codedError('Mail clock returned an invalid time.','MAIL_CLOCK_INVALID');
598
+ }
599
+ return milliseconds;
600
+ }
601
+
602
+ #publish(type,detail,operationId=null){
603
+ if(this.#disposed||this.#events.disposed) return;
604
+ try{
605
+ this.#events.dispatch(type,detail,{operationId,publicDetail:detail});
606
+ }catch{}
607
+ }
608
+
609
+ #publishState(detail,operationId=null){
610
+ this.#publish('mail-outbox-state',Object.freeze({...detail}),operationId);
611
+ }
612
+
613
+ #publishRecord(record){
614
+ const stateDetail=Object.freeze({
615
+ reportKey:record.reportKey,
616
+ state:record.state,
617
+ attempts:record.attempts,
618
+ pending:PENDING_OUTBOX_STATES.has(record.state)
217
619
  });
620
+ this.#publish('mail-outbox-state',stateDetail,record.reportKey);
621
+ if(record.attempts<1||record.state==='queued'||record.state==='sending') return;
622
+ const outcome={
623
+ accepted:'accepted',
624
+ retry_wait:'retry',
625
+ failed:'failed',
626
+ reconciliation_required:'reconciliation_required'
627
+ }[record.state];
628
+ if(!outcome) return;
629
+ this.#publish('mail-outbox-delivery',Object.freeze({
630
+ reportKey:record.reportKey,
631
+ state:record.state,
632
+ outcome,
633
+ uncertain:record.failure?.uncertain===true
634
+ }),record.reportKey);
635
+ }
636
+
637
+ #handleOutboxRecord(record){
638
+ this.#publishRecord(record);
639
+ }
640
+
641
+ async #transportDelivery(request){
642
+ if(this.#deliver) return this.#deliver(request);
643
+ if(this.endpoint){
644
+ return sendMailReport({
645
+ appKey:this.appKey,
646
+ appName:this.appName,
647
+ endpoint:this.endpoint,
648
+ report:request.report,
649
+ reportKey:request.reportKey,
650
+ requestTimeout:this.requestTimeout,
651
+ serializedReport:request.serializedReport,
652
+ ...(request.signal?{signal:request.signal}:{})
653
+ });
654
+ }
655
+ assertNativeReportSize(request.serializedReport);
656
+ try{
657
+ const result=await globalThis.Arcane.mail.send({
658
+ report:request.report,
659
+ reportKey:request.reportKey
660
+ });
661
+ return exactNativeMailResult(result);
662
+ }catch(error){
663
+ throw normalizedNativeMailError(error);
664
+ }
665
+ }
666
+
667
+ async #deliverOutboxRecord(request){
668
+ return normalizedDeliveryResult(await this.#transportDelivery(request));
669
+ }
670
+
671
+ async #createOutbox(){
672
+ let storage=this.#storage;
673
+ if(!this.#storageInjected) storage=await loadRequiredMailStorage();
674
+ const outbox=new MailOutbox({
675
+ storage,
676
+ deliver:this.#deliverOutboxRecord.bind(this),
677
+ clock:this.#clock,
678
+ isOnline:this.#isOnline,
679
+ onRecordCommitted:this.#handleOutboxRecord.bind(this),
680
+ onlineTarget:null,
681
+ ...this.#outboxOptions
682
+ });
683
+ this.#outbox=outbox;
684
+ return outbox;
685
+ }
686
+
687
+ async #getOutbox(){
688
+ this.#assertActive();
689
+ if(this.#outbox) return this.#outbox;
690
+ if(!this.#outboxPromise) this.#outboxPromise=this.#createOutbox();
691
+ try{
692
+ return await this.#outboxPromise;
693
+ }catch(error){
694
+ this.#outboxPromise=null;
695
+ throw error;
696
+ }
697
+ }
698
+
699
+ async #performStart(revision,signal){
700
+ const outbox=await this.#getOutbox();
701
+ let summary;
702
+ try{
703
+ summary=await outbox.start({signal});
704
+ }catch(error){
705
+ outbox.stop();
706
+ throw error;
707
+ }
708
+ if(this.#disposed||revision!==this.#lifecycleRevision){
709
+ outbox.stop();
710
+ throw codedError('Mail start was cancelled.','MAIL_START_CANCELLED');
711
+ }
712
+ this.#onlineTarget?.addEventListener('online',this.#onlineHandler);
713
+ this.#started=true;
714
+ this.#startSummary=summary;
715
+ this.#publish('mail-outbox-drain',safeDrainDetail(summary));
716
+ this.#publishState({lifecycle:'started',started:true});
717
+ return summary;
718
+ }
719
+
720
+ async #handleOnline(){
721
+ if(!this.#started||this.#disposed||this.#backgroundDrain) return;
722
+ const controller=new AbortController();
723
+ this.#backgroundController=controller;
724
+ const operation=this.drain({reason:'online',signal:controller.signal});
725
+ this.#backgroundDrain=operation;
726
+ try{
727
+ await operation;
728
+ this.#backgroundError=null;
729
+ }catch(error){
730
+ if(error?.name!=='AbortError'&&error?.code!=='MAIL_OUTBOX_ABORTED'){
731
+ this.#backgroundError=error;
732
+ this.#publishState({lifecycle:'background-drain-failed',started:this.#started});
733
+ }
734
+ }finally{
735
+ if(this.#backgroundDrain===operation) this.#backgroundDrain=null;
736
+ if(this.#backgroundController===controller) this.#backgroundController=null;
737
+ }
738
+ }
739
+
740
+ async #optionalProfile(){
741
+ const fallback={email:'',language:'',phone:'',username:''};
742
+ try{
743
+ const user=await loadOptionalMailUser(this.#user);
744
+ if(!user) return fallback;
745
+ try{
746
+ await user.load?.();
747
+ }catch{
748
+ return fallback;
749
+ }
750
+ return {
751
+ email:profileValue(user,'email'),
752
+ language:profileValue(user,'language'),
753
+ phone:profileValue(user,'phone'),
754
+ username:profileValue(user,'username')
755
+ };
756
+ }catch{
757
+ return fallback;
758
+ }
759
+ }
760
+
761
+ async start({signal=null}={}){
762
+ this.#assertActive();
763
+ this.#assertConfigured();
764
+ const callerSignal=validateMailSignal(signal);
765
+ throwIfMailAborted(callerSignal);
766
+ if(this.#started) return this.#startSummary;
767
+ const revision=this.#lifecycleRevision;
768
+ if(this.#startPromise&&this.#startRevision===revision){
769
+ return waitForMailOperation(this.#startPromise,callerSignal);
770
+ }
771
+ const previous=this.#startPromise;
772
+ const linked=linkedMailSignal(this.#activeLifecycleSignal(),callerSignal);
773
+ const operation=(previous
774
+ ?previous.catch(function ignoreCancelledStartGeneration(){})
775
+ :Promise.resolve()
776
+ ).then(this.#performStart.bind(this,revision,linked.signal));
777
+ this.#startPromise=operation;
778
+ this.#startRevision=revision;
779
+ try{
780
+ return await operation;
781
+ }finally{
782
+ linked.dispose();
783
+ if(this.#startPromise===operation){
784
+ this.#startPromise=null;
785
+ this.#startRevision=null;
786
+ }
787
+ }
788
+ }
789
+
790
+ async drain({reason='manual',signal=null}={}){
791
+ this.#assertActive();
792
+ this.#assertConfigured();
793
+ const linked=linkedMailSignal(this.#activeLifecycleSignal(),signal);
794
+ try{
795
+ const outbox=await this.#getOutbox();
796
+ const summary=await outbox.drain({reason,signal:linked.signal});
797
+ this.#publish('mail-outbox-drain',safeDrainDetail(summary));
798
+ return summary;
799
+ }finally{
800
+ linked.dispose();
801
+ }
802
+ }
803
+
804
+ async listOutbox(){
805
+ return (await this.#getOutbox()).list();
806
+ }
807
+
808
+ async auditOutbox(){
809
+ return (await this.#getOutbox()).audit();
810
+ }
811
+
812
+ async getOutboxRecord(reportKey){
813
+ return (await this.#getOutbox()).get(reportKey);
814
+ }
815
+
816
+ async deleteInvalidOutbox(fileName){
817
+ return (await this.#getOutbox()).deleteInvalid(fileName);
818
+ }
819
+
820
+ async repairInvalidOutbox(fileName,replacement){
821
+ return (await this.#getOutbox()).repairInvalid(fileName,replacement);
822
+ }
823
+
824
+ async quarantineInvalidOutbox(options){
825
+ return (await this.#getOutbox()).quarantineInvalid(options);
826
+ }
827
+
828
+ stop(){
829
+ if(this.#disposed) return this;
830
+ this.#lifecycleRevision+=1;
831
+ this.#lifecycleController.abort(
832
+ codedError('Mail lifecycle was stopped.','MAIL_LIFECYCLE_STOPPED')
833
+ );
834
+ this.#backgroundController?.abort(
835
+ codedError('Mail background drain was stopped.','MAIL_BACKGROUND_DRAIN_STOPPED')
836
+ );
837
+ this.#outbox?.stop();
838
+ if(this.#started){
839
+ this.#onlineTarget?.removeEventListener('online',this.#onlineHandler);
840
+ this.#started=false;
841
+ this.#startSummary=null;
842
+ this.#publishState({lifecycle:'stopped',started:false});
843
+ }
844
+ return this;
845
+ }
846
+
847
+ dispose(){
848
+ if(this.#disposed) return false;
849
+ this.stop();
850
+ this.#disposed=true;
851
+ this.#events.dispose();
852
+ try{
853
+ if(globalThis.window?.mail===this) globalThis.window.mail=null;
854
+ }catch{}
855
+ return true;
218
856
  }
219
857
 
220
858
  async send(to=[], subject='', payload={}, messageStyle='', messageType='') {
859
+ this.#assertActive();
221
860
  const normalizedSubject=typeof subject==='string' ? subject.trim():'';
222
861
  if(!normalizedSubject||normalizedSubject.length>MAX_SUBJECT_LENGTH||/[\u0000-\u001f\u007f]/.test(normalizedSubject)){
223
862
  throw new TypeError(`Mail subject must contain 1-${MAX_SUBJECT_LENGTH} characters without line breaks`);
@@ -236,17 +875,24 @@ class Mail {
236
875
  }
237
876
 
238
877
  this.#assertConfigured();
239
-
240
878
  const recipients=normalizeRecipients(to);
241
879
  if(messageType!=='error'&&recipients.length===0){
242
880
  throw new TypeError('Report and crisis mail require at least one recipient');
243
881
  }
244
882
 
245
- const reportKey=createReportKey(normalizedSubject);
883
+ await this.start();
884
+ this.#assertActive();
885
+ const signal=this.#lifecycleController.signal;
886
+ throwIfMailAborted(signal);
887
+ const outbox=await this.#getOutbox();
888
+ const timestamp=this.#time();
889
+ const reportKey=createReportKey(timestamp,this.#cryptoProvider);
246
890
  const reportPayload={
247
891
  ...clonePayload(payload),
248
- source_at:new Date().toISOString(),
249
- source_path:globalThis.location?.pathname||'',
892
+ source_at:new Date(timestamp).toISOString(),
893
+ ...(this.#includeContext
894
+ ?{source_path:globalThis.location?.pathname||''}
895
+ :{}),
250
896
  subject:normalizedSubject,
251
897
  type:messageType,
252
898
  };
@@ -259,38 +905,25 @@ class Mail {
259
905
  type:messageType,
260
906
  };
261
907
  assertMessageSize(reportPayload.report);
262
-
263
- const delivery=await this.#deliver(reportPayload.report,reportKey);
264
- return { ...delivery,reportKey };
265
- }
266
-
267
- let reportStorage=null;
268
- let profile={ email:'',language:'',phone:'',username:'' };
269
- try{
270
- const dependencies=await loadOptionalMailDependencies();
271
- reportStorage=dependencies.dbopfs;
272
- try{
273
- await dependencies.user.load();
274
- }catch(error){
275
- console.warn('Unable to load the user profile for mail; continuing with available values.',error);
276
- }
277
- profile={
278
- email:dependencies.user.email,
279
- language:dependencies.user.language,
280
- phone:dependencies.user.phone,
281
- username:dependencies.user.username,
282
- };
283
- }catch(error){
284
- console.warn('Optional mail formatting dependencies are unavailable; using a deterministic fallback.',error);
908
+ const record=await outbox.enqueue(
909
+ {report:reportPayload.report,reportKey},
910
+ {signal}
911
+ );
912
+ return publicSendResult(record);
285
913
  }
286
914
 
915
+ const profile=this.#includeContext
916
+ ?await this.#optionalProfile()
917
+ :{email:'',language:'',phone:'',username:''};
287
918
  const wantsHtml=/\bhtml\b/i.test(messageStyle);
288
- Object.assign(reportPayload,{
289
- email:profile.email,
290
- language:profile.language,
291
- phone:profile.phone,
292
- source_user:profile.username || 'username not specified',
293
- });
919
+ if(this.#includeContext){
920
+ Object.assign(reportPayload,{
921
+ email:profile.email,
922
+ language:profile.language,
923
+ phone:profile.phone,
924
+ source_user:profile.username || 'username not specified',
925
+ });
926
+ }
294
927
 
295
928
  // Email formatting stays local and deterministic. The supplied style
296
929
  // is presentation intent only; it is never sent to an AI provider and
@@ -300,7 +933,7 @@ class Mail {
300
933
  ? `<pre>${escapeHtml(serialized)}</pre>`
301
934
  : serialized;
302
935
 
303
- const sourceUser=String(reportPayload.source_user)
936
+ const sourceUser=String(reportPayload.source_user||'')
304
937
  .replace(/[\u0000-\u001f\u007f]/g,' ')
305
938
  .trim()
306
939
  .slice(0,80);
@@ -315,38 +948,28 @@ class Mail {
315
948
  type:messageType,
316
949
  };
317
950
  if(wantsHtml){
318
- reportPayload.report.html=`${generatedMessage}
951
+ reportPayload.report.html=this.#includeContext?`${generatedMessage}
319
952
  <hr>
320
- <p>Phone: ${escapeHtml(profile.phone || 'not provided')}<br>Email: ${escapeHtml(profile.email || 'not provided')}</p>`;
953
+ <p>Phone: ${escapeHtml(profile.phone || 'not provided')}<br>Email: ${escapeHtml(profile.email || 'not provided')}</p>`
954
+ :generatedMessage;
321
955
  }else{
322
- reportPayload.report.text=`${generatedMessage}
956
+ reportPayload.report.text=this.#includeContext?`${generatedMessage}
323
957
 
324
958
  Phone: ${profile.phone || 'not provided'}
325
- Email: ${profile.email || 'not provided'}`;
959
+ Email: ${profile.email || 'not provided'}`
960
+ :generatedMessage;
326
961
  }
327
962
 
328
963
  assertMessageSize(reportPayload.report);
329
-
330
- try{
331
- if(!reportStorage?.set){
332
- throw new Error('Report storage is unavailable');
333
- }
334
- await reportStorage.set('reports',reportKey,reportPayload);
335
- }catch(error){
336
- console.warn('Unable to store the generated mail report; continuing with delivery.',error);
337
- }
338
-
339
- const delivery=await this.#deliver(reportPayload.report,reportKey);
340
-
341
- return {
342
- ...delivery,
343
- reportKey,
344
- };
964
+ throwIfMailAborted(signal);
965
+ const record=await outbox.enqueue(
966
+ {report:reportPayload.report,reportKey},
967
+ {signal}
968
+ );
969
+ return publicSendResult(record);
345
970
  }
346
971
  }
347
972
 
348
- if(globalThis.window&&!globalThis.window.mail){
349
- globalThis.window.mail=new Mail();
350
- }
973
+ if(globalThis.window) new Mail();
351
974
 
352
975
  export default Mail;