@upyo/jmap 0.6.0-dev.353 → 0.6.0-dev.356

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -133,3 +133,68 @@ try {
133
133
  }
134
134
  }
135
135
  ~~~~
136
+
137
+
138
+ Sending raw MIME
139
+ ----------------
140
+
141
+ `JmapTransport` implements the optional `RawTransport` interface. It uploads
142
+ serialized MIME, imports the uploaded blob into Drafts, and submits that Email
143
+ with an explicit delivery envelope:
144
+
145
+ ~~~~ typescript
146
+ import { JmapTransport } from "@upyo/jmap";
147
+
148
+ const transport = new JmapTransport({
149
+ sessionUrl: "https://mail.example.com/.well-known/jmap",
150
+ bearerToken: "your-token",
151
+ });
152
+ const receipt = await transport.sendRaw({
153
+ envelope: { from: "sender@example.com", to: ["recipient@example.net"] },
154
+ content: new TextEncoder().encode(
155
+ "From: sender@example.com\r\nSubject: Hello\r\n\r\nHello!\r\n"
156
+ ),
157
+ encoding: "7bit",
158
+ });
159
+ ~~~~
160
+
161
+ Content accepts bytes, promised bytes, Blob, or replayable attachment-style
162
+ factories. Declaring `encoding` reads the source once per successful send;
163
+ omitting it adds an analysis pass before upload. Every reader must reproduce
164
+ the same bytes. `7bit` requires ASCII, `8bit` asserts ASCII MIME headers with an
165
+ 8-bit body, and `utf8` permits internationalized headers. Automatic analysis
166
+ conservatively selects `utf8` for any non-ASCII byte. With `8bit`, the caller
167
+ must ensure nested MIME headers are ASCII; Upyo checks only top-level headers.
168
+ Use `utf8` or omit `encoding` if unsure. No transcoding occurs.
169
+
170
+ Sources must have CRLF line endings including the final CRLF, nonempty headers,
171
+ no NUL, and no line longer than 998 bytes excluding CRLF. Uploads stream without
172
+ collecting the message in memory. Progress refreshes the inactivity timeout;
173
+ cancellation stops source reads and requests source cleanup. Response parsing
174
+ also remains subject to timeout. Pass an `AbortSignal` through `signal` in the
175
+ second argument to `sendRaw()`.
176
+
177
+ The envelope is independent of MIME headers. A configured `identityId` takes
178
+ precedence; otherwise Upyo selects the identity matching the envelope sender,
179
+ or falls back to the first available identity. A null sender also uses that
180
+ fallback. The server may reject the chosen identity, envelope, or null sender.
181
+
182
+ Upyo does not compose or repair the uploaded bytes. JMAP servers may repair
183
+ imported MIME and modify messages during submission; RFC 8621 requires removal
184
+ of Bcc during submission. Raw JMAP delivery therefore does not guarantee that
185
+ an existing signature or byte-for-byte representation reaches the recipient.
186
+
187
+ Import and submission are each attempted once. `jmap.raw_import_failed` with
188
+ `retryable: true` means no submission was issued, although an imported Email
189
+ may remain after a lost response. A definite server rejection is non-retryable.
190
+ `jmap.raw_submission_unknown` means submission may have succeeded: inspect the
191
+ server state before attempting another send. It is marked non-retryable to
192
+ avoid duplicate delivery. Cancellation cannot recall a submitted message.
193
+
194
+ An `alreadyExists` import result is reused only when the existing Email refers
195
+ to the exact uploaded blob; matching Message-ID alone is insufficient. Upyo
196
+ leaves imported Emails in Drafts and does not delete them after success or
197
+ failure. The server expires unreferenced uploaded blobs according to its policy.
198
+
199
+ See [RFC 8620] for uploads and request errors, and [RFC 8621] for import and
200
+ submission behavior.
package/dist/index.cjs CHANGED
@@ -66,79 +66,6 @@ function isCapabilityError(error) {
66
66
  return error instanceof JmapApiError && error.jmapErrorType === JMAP_ERROR_TYPES.unknownCapability;
67
67
  }
68
68
 
69
- //#endregion
70
- //#region src/blob-uploader.ts
71
- /**
72
- * Upload a blob to the JMAP server.
73
- *
74
- * @param config - The resolved JMAP configuration
75
- * @param uploadUrl - The upload URL template from the session (e.g., "https://server/upload/{accountId}")
76
- * @param accountId - The account ID to upload to
77
- * @param blob - The blob or file to upload
78
- * @param signal - Optional abort signal
79
- * @returns The upload response containing the blobId
80
- */
81
- async function uploadBlob(config, uploadUrl, accountId, blob, signal) {
82
- signal?.throwIfAborted();
83
- const url = uploadUrl.replace("{accountId}", accountId);
84
- let authHeader;
85
- if (config.bearerToken) authHeader = `Bearer ${config.bearerToken}`;
86
- else if (config.basicAuth) {
87
- const credentials = btoa(`${config.basicAuth.username}:${config.basicAuth.password}`);
88
- authHeader = `Basic ${credentials}`;
89
- } else throw new Error("No authentication method configured");
90
- const headers = {
91
- Authorization: authHeader,
92
- "Content-Type": blob.type || "application/octet-stream"
93
- };
94
- for (const [key, value] of Object.entries(config.headers)) headers[key] = value;
95
- const controller = new AbortController();
96
- const timeoutId = setTimeout(() => controller.abort(), config.timeout);
97
- const combinedSignal = (0, __upyo_core.combineSignals)(controller.signal, signal);
98
- try {
99
- const response = await fetch(url, {
100
- method: "POST",
101
- headers,
102
- body: blob,
103
- signal: combinedSignal.signal
104
- });
105
- if (!response.ok) {
106
- const body = await response.text();
107
- throw new JmapApiError(`Blob upload failed: ${response.status} ${response.statusText}`, response.status, body);
108
- }
109
- const result = await response.json();
110
- return result;
111
- } finally {
112
- combinedSignal.cleanup();
113
- clearTimeout(timeoutId);
114
- }
115
- }
116
-
117
- //#endregion
118
- //#region src/config.ts
119
- /**
120
- * Creates a resolved JMAP configuration with default values applied.
121
- * @param config The user-provided configuration.
122
- * @returns The resolved configuration with all fields populated.
123
- * @throws Error if neither bearerToken nor basicAuth is provided.
124
- * @since 0.4.0
125
- */
126
- function createJmapConfig(config) {
127
- if (!config.bearerToken && !config.basicAuth) throw new Error("Either bearerToken or basicAuth must be provided");
128
- return {
129
- sessionUrl: config.sessionUrl,
130
- bearerToken: config.bearerToken ?? null,
131
- basicAuth: config.basicAuth ?? null,
132
- accountId: config.accountId ?? null,
133
- identityId: config.identityId ?? null,
134
- timeout: config.timeout ?? 3e4,
135
- retries: config.retries ?? 3,
136
- headers: config.headers ?? {},
137
- sessionCacheTtl: config.sessionCacheTtl ?? 3e5,
138
- baseUrl: config.baseUrl ?? null
139
- };
140
- }
141
-
142
69
  //#endregion
143
70
  //#region src/http-client.ts
144
71
  /**
@@ -147,7 +74,12 @@ function createJmapConfig(config) {
147
74
  */
148
75
  var JmapHttpClient = class {
149
76
  config;
150
- constructor(config) {
77
+ /**
78
+ * @param config HTTP and authentication settings.
79
+ * @param replayableRequests Whether request bodies and failed requests may be replayed.
80
+ */
81
+ constructor(config, replayableRequests = true) {
82
+ this.replayableRequests = replayableRequests;
151
83
  this.config = config;
152
84
  }
153
85
  /**
@@ -176,13 +108,21 @@ var JmapHttpClient = class {
176
108
  async executeRequest(apiUrl, request, signal) {
177
109
  signal?.throwIfAborted();
178
110
  let lastError = null;
179
- for (let attempt = 0; attempt <= this.config.retries; attempt++) {
111
+ const retries = this.replayableRequests ? this.config.retries : 0;
112
+ for (let attempt = 0; attempt <= retries; attempt++) {
180
113
  signal?.throwIfAborted();
181
114
  try {
182
115
  const response = await this.fetchWithAuth(apiUrl, {
183
116
  method: "POST",
184
117
  headers: { "Content-Type": "application/json" },
185
- body: JSON.stringify(request)
118
+ body: this.replayableRequests ? JSON.stringify(request) : new ReadableStream({ start(controller) {
119
+ controller.enqueue(new TextEncoder().encode(JSON.stringify(request)));
120
+ controller.close();
121
+ } }),
122
+ ...this.replayableRequests ? {} : {
123
+ duplex: "half",
124
+ redirect: "error"
125
+ }
186
126
  }, signal);
187
127
  if (!response.ok) {
188
128
  const text = await response.text();
@@ -197,7 +137,7 @@ var JmapHttpClient = class {
197
137
  if (error.statusCode >= 400 && error.statusCode < 500) throw error;
198
138
  }
199
139
  lastError = error instanceof Error ? error : new Error(String(error));
200
- if (attempt === this.config.retries) {
140
+ if (attempt === retries) {
201
141
  if (error instanceof JmapApiError) throw error;
202
142
  if (isAbortError$1(error)) throw new JmapApiError("JMAP request timed out.", void 0, void 0, void 0, void 0, attempt + 1);
203
143
  throw new JmapApiError(lastError.message, void 0, void 0, void 0, void 0, attempt + 1);
@@ -260,6 +200,384 @@ function isAbortError$1(error) {
260
200
  return error instanceof Error && error.name === "AbortError";
261
201
  }
262
202
 
203
+ //#endregion
204
+ //#region src/raw-upload.ts
205
+ /** Runs a raw operation with a progress-resettable deadline through response parsing. @internal */
206
+ async function rawOperation(timeout, operation, signal) {
207
+ const controller = new AbortController();
208
+ const combined = (0, __upyo_core.combineSignals)(controller.signal, signal);
209
+ let timer;
210
+ let finished = false;
211
+ const progress = () => {
212
+ if (finished) return;
213
+ clearTimeout(timer);
214
+ timer = setTimeout(() => controller.abort(new JmapApiError("Raw JMAP operation timed out.")), timeout);
215
+ };
216
+ let abort;
217
+ try {
218
+ combined.signal.throwIfAborted();
219
+ progress();
220
+ return await new Promise((resolve, reject) => {
221
+ abort = () => reject(combined.signal.reason);
222
+ combined.signal.addEventListener("abort", abort, { once: true });
223
+ Promise.resolve().then(() => {
224
+ combined.signal.throwIfAborted();
225
+ return operation(combined.signal, progress);
226
+ }).then(resolve, reject);
227
+ if (combined.signal.aborted) abort();
228
+ });
229
+ } finally {
230
+ finished = true;
231
+ clearTimeout(timer);
232
+ if (abort) combined.signal.removeEventListener("abort", abort);
233
+ controller.abort();
234
+ combined.cleanup();
235
+ }
236
+ }
237
+ /** @internal */
238
+ function isRecord(value) {
239
+ return typeof value === "object" && value !== null && !Array.isArray(value);
240
+ }
241
+ /** Streams validated MIME bytes without relying on fetch to cancel its body. @internal */
242
+ async function uploadRawMessage(config, uploadUrl, accountId, plan, signal) {
243
+ return await rawOperation(config.timeout, async (outerSignal, progress) => {
244
+ const analysis = plan.encoding === void 0 ? await (0, __upyo_core.analyzeRawMessage)(plan, outerSignal, progress) : {
245
+ encoding: plan.encoding,
246
+ size: plan.size
247
+ };
248
+ outerSignal.throwIfAborted();
249
+ const controller = new AbortController();
250
+ const combined = (0, __upyo_core.combineSignals)(controller.signal, outerSignal);
251
+ const owned = combined.signal;
252
+ const iterator = (0, __upyo_core.iterateRawMessage)(plan, {
253
+ signal: owned,
254
+ encoding: analysis.encoding,
255
+ expectedSize: analysis.size,
256
+ onProgress: progress
257
+ })[Symbol.asyncIterator]();
258
+ let closed = false;
259
+ let eof = false;
260
+ let size = 0;
261
+ let sourceError;
262
+ let hasSourceError = false;
263
+ let bodyController;
264
+ const close = (reason) => {
265
+ if (closed) return;
266
+ closed = true;
267
+ controller.abort(reason);
268
+ if (!eof) try {
269
+ Promise.resolve(iterator.return?.()).catch(() => {});
270
+ } catch {}
271
+ };
272
+ const abort = () => {
273
+ try {
274
+ bodyController?.error(owned.reason);
275
+ } catch {}
276
+ close(owned.reason);
277
+ };
278
+ const body = new ReadableStream({
279
+ start(stream) {
280
+ bodyController = stream;
281
+ owned.addEventListener("abort", abort, { once: true });
282
+ if (owned.aborted) abort();
283
+ },
284
+ async pull(stream) {
285
+ try {
286
+ owned.throwIfAborted();
287
+ const item = await iterator.next();
288
+ owned.throwIfAborted();
289
+ if (item.done) {
290
+ eof = true;
291
+ progress();
292
+ stream.close();
293
+ } else {
294
+ size += item.value.length;
295
+ stream.enqueue(new Uint8Array(item.value));
296
+ }
297
+ } catch (error) {
298
+ if (!owned.aborted && !hasSourceError) {
299
+ hasSourceError = true;
300
+ sourceError = error;
301
+ }
302
+ try {
303
+ stream.error(error);
304
+ } catch {}
305
+ close(error);
306
+ }
307
+ },
308
+ cancel: close
309
+ }, { highWaterMark: 0 });
310
+ const headers = new Headers(config.headers);
311
+ if (!headers.has("Authorization")) {
312
+ if (config.bearerToken) headers.set("Authorization", `Bearer ${config.bearerToken}`);
313
+ else if (config.basicAuth) headers.set("Authorization", `Basic ${btoa(`${config.basicAuth.username}:${config.basicAuth.password}`)}`);
314
+ }
315
+ headers.set("Content-Type", "message/rfc822");
316
+ const request = {
317
+ method: "POST",
318
+ headers,
319
+ body,
320
+ duplex: "half",
321
+ redirect: "error",
322
+ signal: outerSignal
323
+ };
324
+ try {
325
+ const response = await fetch(uploadUrl.replace("{accountId}", encodeURIComponent(accountId)), request);
326
+ outerSignal.throwIfAborted();
327
+ if (!response.ok) throw new JmapApiError(`Raw upload failed: ${response.status}`, response.status, await response.text(), void 0, (0, __upyo_core.parseRetryAfter)(response.headers.get("Retry-After")));
328
+ if (!eof) throw new JmapApiError("Raw upload responded before validated EOF.");
329
+ const result = await response.json();
330
+ outerSignal.throwIfAborted();
331
+ if (!isRecord(result) || result.accountId !== accountId || typeof result.blobId !== "string" || !result.blobId || result.size !== size) throw new JmapApiError("Invalid raw upload response.");
332
+ return {
333
+ accountId,
334
+ blobId: result.blobId,
335
+ size,
336
+ type: typeof result.type === "string" ? result.type : "message/rfc822"
337
+ };
338
+ } catch (error) {
339
+ outerSignal.throwIfAborted();
340
+ if (hasSourceError) throw sourceError;
341
+ throw error;
342
+ } finally {
343
+ owned.removeEventListener("abort", abort);
344
+ close();
345
+ combined.cleanup();
346
+ }
347
+ }, signal);
348
+ }
349
+
350
+ //#endregion
351
+ //#region src/raw-delivery.ts
352
+ const using = [
353
+ "urn:ietf:params:jmap:core",
354
+ "urn:ietf:params:jmap:mail",
355
+ "urn:ietf:params:jmap:submission"
356
+ ];
357
+ function failure(message, stage, retryable, unknown = false, error) {
358
+ return (0, __upyo_core.createFailedReceipt)(message, {
359
+ provider: "jmap",
360
+ code: `jmap.raw_${stage}_${unknown ? "unknown" : "failed"}`,
361
+ category: unknown ? "unknown" : error?.statusCode !== void 0 || retryable ? void 0 : "rejected",
362
+ statusCode: error?.statusCode,
363
+ retryAfterMilliseconds: error?.retryAfterMilliseconds,
364
+ providerDetails: error ? {
365
+ responseBody: error.responseBody,
366
+ jmapErrorType: error.jmapErrorType
367
+ } : void 0,
368
+ retryable,
369
+ attempts: 1
370
+ });
371
+ }
372
+ function methodResult(response, name, id, accountId) {
373
+ if (!isRecord(response) || !Array.isArray(response.methodResponses)) throw new JmapApiError("Invalid raw JMAP method response.");
374
+ const matches = response.methodResponses.filter((entry) => Array.isArray(entry) && entry[2] === id);
375
+ if (matches.length !== 1) throw new JmapApiError("Missing or contradictory raw JMAP method response.");
376
+ const result = matches[0];
377
+ if (!Array.isArray(result) || result.length !== 3 || !isRecord(result[1]) || result[0] !== name && result[0] !== "error") throw new JmapApiError("Invalid raw JMAP method result.");
378
+ if (result[0] === "error") {
379
+ if (typeof result[1].type !== "string" || !result[1].type) throw new JmapApiError("Invalid raw JMAP method error.");
380
+ } else if (result[1].accountId !== accountId) throw new JmapApiError("Wrong account in raw JMAP response.");
381
+ return {
382
+ name: result[0],
383
+ args: result[1]
384
+ };
385
+ }
386
+ function creation(args) {
387
+ const created = isRecord(args.created) ? args.created.raw : void 0;
388
+ const rejected = isRecord(args.notCreated) ? args.notCreated.raw : void 0;
389
+ if (created !== void 0 && rejected !== void 0) throw new JmapApiError("Contradictory raw JMAP creation result.");
390
+ if (isRecord(created) && typeof created.id === "string" && created.id) return { created };
391
+ if (isRecord(rejected) && typeof rejected.type === "string" && rejected.type) return { rejected };
392
+ throw new JmapApiError("Missing raw JMAP creation result.");
393
+ }
394
+ function errorDescription(error) {
395
+ return `Raw JMAP operation rejected: ${String(error.type)}${typeof error.description === "string" ? `: ${error.description}` : ""}`;
396
+ }
397
+ function isRequestRejection(error) {
398
+ if (!(error instanceof JmapApiError)) return false;
399
+ if (error.statusCode === 401 || error.statusCode === 403) return true;
400
+ if (!error.responseBody || error.statusCode === void 0 || error.statusCode < 400 || error.statusCode > 599) return false;
401
+ try {
402
+ const body = JSON.parse(error.responseBody);
403
+ if (!isRecord(body) || body.status !== void 0 && body.status !== error.statusCode) return false;
404
+ if (body.type === JMAP_ERROR_TYPES.limit) return typeof body.limit === "string" && body.limit.length > 0;
405
+ return body.type === JMAP_ERROR_TYPES.notJSON || body.type === JMAP_ERROR_TYPES.notRequest || body.type === JMAP_ERROR_TYPES.unknownCapability;
406
+ } catch {
407
+ return false;
408
+ }
409
+ }
410
+ /** Uploads, imports, then submits exactly once; uncertain submission must not be retried. @internal */
411
+ async function deliverRawMessage(config, session, accountId, draftsMailboxId, identityId, plan, signal) {
412
+ const client = new JmapHttpClient({
413
+ ...config,
414
+ retries: 0
415
+ }, false);
416
+ const execute = (request) => rawOperation(config.timeout, (owned) => client.executeRequest(session.apiUrl, request, owned), signal);
417
+ let stage = "upload";
418
+ try {
419
+ const uploaded = await uploadRawMessage(config, session.uploadUrl, accountId, plan, signal);
420
+ signal?.throwIfAborted();
421
+ stage = "import";
422
+ const imported = methodResult(await execute({
423
+ using,
424
+ methodCalls: [[
425
+ "Email/import",
426
+ {
427
+ accountId,
428
+ emails: { raw: {
429
+ blobId: uploaded.blobId,
430
+ mailboxIds: { [draftsMailboxId]: true }
431
+ } }
432
+ },
433
+ "raw-import"
434
+ ]]
435
+ }), "Email/import", "raw-import", accountId);
436
+ if (imported.name === "error") return failure(errorDescription(imported.args), "import", imported.args.type === "serverPartialFail");
437
+ const { created, rejected } = creation(imported.args);
438
+ let emailId;
439
+ if (created && typeof created.id === "string") emailId = created.id;
440
+ else if (rejected?.type === "alreadyExists" && typeof rejected.existingId === "string" && rejected.existingId) {
441
+ const existing = methodResult(await execute({
442
+ using,
443
+ methodCalls: [[
444
+ "Email/get",
445
+ {
446
+ accountId,
447
+ ids: [rejected.existingId],
448
+ properties: ["id", "blobId"]
449
+ },
450
+ "raw-existing"
451
+ ]]
452
+ }), "Email/get", "raw-existing", accountId);
453
+ const list = existing.args.list;
454
+ if (existing.name === "error" || !Array.isArray(list) || list.length !== 1 || !isRecord(list[0]) || list[0].id !== rejected.existingId || list[0].blobId !== uploaded.blobId) return failure("Existing Email does not match the uploaded raw blob.", "import", false);
455
+ emailId = rejected.existingId;
456
+ } else return failure(errorDescription(rejected ?? { type: "invalidResult" }), "import", false);
457
+ signal?.throwIfAborted();
458
+ stage = "submission";
459
+ const submitted = methodResult(await execute({
460
+ using,
461
+ methodCalls: [[
462
+ "EmailSubmission/set",
463
+ {
464
+ accountId,
465
+ create: { raw: {
466
+ emailId,
467
+ identityId,
468
+ envelope: {
469
+ mailFrom: { email: plan.envelope.from ?? "" },
470
+ rcptTo: plan.envelope.to.map((email) => ({ email }))
471
+ }
472
+ } }
473
+ },
474
+ "raw-submit"
475
+ ]]
476
+ }), "EmailSubmission/set", "raw-submit", accountId);
477
+ if (submitted.name === "error") return failure(errorDescription(submitted.args), "submission", false, submitted.args.type === "serverPartialFail");
478
+ const result = creation(submitted.args);
479
+ if (result.rejected) return failure(errorDescription(result.rejected), "submission", false);
480
+ if (!result.created || typeof result.created.id !== "string") throw new JmapApiError("Missing raw submission ID.");
481
+ signal?.throwIfAborted();
482
+ return {
483
+ successful: true,
484
+ provider: "jmap",
485
+ messageId: result.created.id
486
+ };
487
+ } catch (error) {
488
+ signal?.throwIfAborted();
489
+ const message = error instanceof Error ? error.message : String(error);
490
+ if (error instanceof __upyo_core.RawMessageValidationError) return (0, __upyo_core.createFailedReceipt)(message, {
491
+ provider: "jmap",
492
+ code: "jmap.raw_message_invalid",
493
+ category: "validation",
494
+ retryable: false,
495
+ attempts: 1
496
+ });
497
+ const definite = isRequestRejection(error);
498
+ return failure(message, stage, stage !== "submission" && !definite, stage === "submission" && !definite, error instanceof JmapApiError ? error : void 0);
499
+ }
500
+ }
501
+ /** Validates raw discovery responses before accepting delivery identifiers. @internal */
502
+ function rawDiscoveryResult(response, method, accountId) {
503
+ const result = methodResult(response, method, "c0", accountId);
504
+ if (result.name === "error") throw new JmapApiError(errorDescription(result.args));
505
+ return result.args;
506
+ }
507
+
508
+ //#endregion
509
+ //#region src/blob-uploader.ts
510
+ /**
511
+ * Upload a blob to the JMAP server.
512
+ *
513
+ * @param config - The resolved JMAP configuration
514
+ * @param uploadUrl - The upload URL template from the session (e.g., "https://server/upload/{accountId}")
515
+ * @param accountId - The account ID to upload to
516
+ * @param blob - The blob or file to upload
517
+ * @param signal - Optional abort signal
518
+ * @returns The upload response containing the blobId
519
+ */
520
+ async function uploadBlob(config, uploadUrl, accountId, blob, signal) {
521
+ signal?.throwIfAborted();
522
+ const url = uploadUrl.replace("{accountId}", accountId);
523
+ let authHeader;
524
+ if (config.bearerToken) authHeader = `Bearer ${config.bearerToken}`;
525
+ else if (config.basicAuth) {
526
+ const credentials = btoa(`${config.basicAuth.username}:${config.basicAuth.password}`);
527
+ authHeader = `Basic ${credentials}`;
528
+ } else throw new Error("No authentication method configured");
529
+ const headers = {
530
+ Authorization: authHeader,
531
+ "Content-Type": blob.type || "application/octet-stream"
532
+ };
533
+ for (const [key, value] of Object.entries(config.headers)) headers[key] = value;
534
+ const controller = new AbortController();
535
+ const timeoutId = setTimeout(() => controller.abort(), config.timeout);
536
+ const combinedSignal = (0, __upyo_core.combineSignals)(controller.signal, signal);
537
+ try {
538
+ const response = await fetch(url, {
539
+ method: "POST",
540
+ headers,
541
+ body: blob,
542
+ signal: combinedSignal.signal
543
+ });
544
+ if (!response.ok) {
545
+ const body = await response.text();
546
+ throw new JmapApiError(`Blob upload failed: ${response.status} ${response.statusText}`, response.status, body);
547
+ }
548
+ const result = await response.json();
549
+ return result;
550
+ } finally {
551
+ combinedSignal.cleanup();
552
+ clearTimeout(timeoutId);
553
+ }
554
+ }
555
+
556
+ //#endregion
557
+ //#region src/config.ts
558
+ /**
559
+ * Creates a resolved JMAP configuration with default values applied.
560
+ * @param config The user-provided configuration.
561
+ * @returns The resolved configuration with all fields populated.
562
+ * @throws Error if neither bearerToken nor basicAuth is provided.
563
+ * @since 0.4.0
564
+ */
565
+ function createJmapConfig(config) {
566
+ if (!config.bearerToken && !config.basicAuth) throw new Error("Either bearerToken or basicAuth must be provided");
567
+ return {
568
+ sessionUrl: config.sessionUrl,
569
+ bearerToken: config.bearerToken ?? null,
570
+ basicAuth: config.basicAuth ?? null,
571
+ accountId: config.accountId ?? null,
572
+ identityId: config.identityId ?? null,
573
+ timeout: config.timeout ?? 3e4,
574
+ retries: config.retries ?? 3,
575
+ headers: config.headers ?? {},
576
+ sessionCacheTtl: config.sessionCacheTtl ?? 3e5,
577
+ baseUrl: config.baseUrl ?? null
578
+ };
579
+ }
580
+
263
581
  //#endregion
264
582
  //#region src/message-converter.ts
265
583
  /**
@@ -672,6 +990,45 @@ var JmapTransport = class {
672
990
  }
673
991
  }
674
992
  /**
993
+ * Uploads serialized MIME, imports it, and submits it with an explicit envelope.
994
+ * JMAP servers may modify imported or submitted messages, including removing
995
+ * Bcc. Import and submission are never retried automatically.
996
+ * @param message Original MIME and delivery envelope.
997
+ * @param options Optional cancellation signal.
998
+ * @returns A receipt; uncertain submission outcomes are non-retryable.
999
+ * @throws {Error} If cancellation is requested.
1000
+ * @since 0.6.0
1001
+ */
1002
+ async sendRaw(message, options) {
1003
+ if (message?.content instanceof Promise) message.content.catch(() => {});
1004
+ const signal = options?.signal;
1005
+ signal?.throwIfAborted();
1006
+ try {
1007
+ const plan = (0, __upyo_core.createRawMessagePlan)(message);
1008
+ const session = await rawOperation(this.config.timeout, (owned) => this.getSession(owned), signal);
1009
+ const capable = (id) => {
1010
+ const account = session.accounts[id];
1011
+ return account != null && !account.isReadOnly && JMAP_CAPABILITIES.mail in account.accountCapabilities && JMAP_CAPABILITIES.submission in account.accountCapabilities;
1012
+ };
1013
+ const accountId = this.config.accountId ?? Object.keys(session.accounts).find(capable);
1014
+ if (accountId == null || !capable(accountId) || !Object.values(JMAP_CAPABILITIES).every((capability) => capability in session.capabilities)) return createJmapFailure("No writable mail and submission account found.", void 0, {
1015
+ category: "configuration",
1016
+ code: "jmap.no_mail_account",
1017
+ retryable: false
1018
+ });
1019
+ const drafts = await rawOperation(this.config.timeout, (owned) => this.getDraftsMailboxId(session, accountId, owned, true), signal);
1020
+ const identity = await rawOperation(this.config.timeout, (owned) => this.getIdentityId(session, accountId, plan.envelope.from ?? "", owned, true), signal);
1021
+ return await deliverRawMessage(this.config, session, accountId, drafts, identity, plan, signal);
1022
+ } catch (error) {
1023
+ signal?.throwIfAborted();
1024
+ return createJmapFailure(error instanceof Error ? error.message : String(error), error, error instanceof __upyo_core.RawMessageValidationError ? {
1025
+ category: "validation",
1026
+ code: "jmap.raw_message_invalid",
1027
+ retryable: false
1028
+ } : void 0);
1029
+ }
1030
+ }
1031
+ /**
675
1032
  * Sends multiple messages in a single batched JMAP request.
676
1033
  * @param messages The messages to send.
677
1034
  * @param options Optional transport options.
@@ -818,7 +1175,7 @@ var JmapTransport = class {
818
1175
  * @returns The drafts mailbox ID.
819
1176
  * @since 0.4.0
820
1177
  */
821
- async getDraftsMailboxId(session, accountId, signal) {
1178
+ async getDraftsMailboxId(session, accountId, signal, strict = false) {
822
1179
  const response = await this.httpClient.executeRequest(session.apiUrl, {
823
1180
  using: [JMAP_CAPABILITIES.core, JMAP_CAPABILITIES.mail],
824
1181
  methodCalls: [[
@@ -834,10 +1191,11 @@ var JmapTransport = class {
834
1191
  "c0"
835
1192
  ]]
836
1193
  }, signal);
837
- const mailboxResponse = response.methodResponses.find((r) => r[0] === "Mailbox/get");
1194
+ const mailboxResponse = strict ? rawDiscoveryResult(response, "Mailbox/get", accountId) : response.methodResponses.find((r) => r[0] === "Mailbox/get")?.[1];
838
1195
  if (!mailboxResponse) throw new JmapApiError("No Mailbox/get response received");
839
- const mailboxes = mailboxResponse[1].list;
1196
+ const mailboxes = mailboxResponse.list;
840
1197
  if (!mailboxes) throw new JmapApiError("No mailboxes found");
1198
+ if (strict && (!Array.isArray(mailboxes) || !mailboxes.every((mailbox) => isRecord(mailbox) && typeof mailbox.id === "string" && mailbox.id.length > 0))) throw new JmapApiError("Invalid mailbox identifiers in raw discovery.");
841
1199
  const drafts = mailboxes.find((m) => m.role === "drafts");
842
1200
  if (!drafts) throw new JmapApiError("No drafts mailbox found");
843
1201
  return drafts.id;
@@ -851,9 +1209,9 @@ var JmapTransport = class {
851
1209
  * @returns The identity ID.
852
1210
  * @since 0.4.0
853
1211
  */
854
- async getIdentityId(session, accountId, senderEmail, signal) {
1212
+ async getIdentityId(session, accountId, senderEmail, signal, strict = false) {
855
1213
  if (this.config.identityId) return this.config.identityId;
856
- const identityMap = await this.getIdentityMap(session, accountId, signal);
1214
+ const identityMap = await this.getIdentityMap(session, accountId, signal, strict);
857
1215
  const matching = identityMap.get(senderEmail.toLowerCase());
858
1216
  if (matching) return matching;
859
1217
  return identityMap.values().next().value;
@@ -866,7 +1224,7 @@ var JmapTransport = class {
866
1224
  * @returns Map of lowercase email to identity ID.
867
1225
  * @since 0.4.0
868
1226
  */
869
- async getIdentityMap(session, accountId, signal) {
1227
+ async getIdentityMap(session, accountId, signal, strict = false) {
870
1228
  if (this.config.identityId) return new Map([["*", this.config.identityId]]);
871
1229
  const response = await this.httpClient.executeRequest(session.apiUrl, {
872
1230
  using: [JMAP_CAPABILITIES.core, JMAP_CAPABILITIES.submission],
@@ -876,10 +1234,11 @@ var JmapTransport = class {
876
1234
  "c0"
877
1235
  ]]
878
1236
  }, signal);
879
- const identityResponse = response.methodResponses.find((r) => r[0] === "Identity/get");
1237
+ const identityResponse = strict ? rawDiscoveryResult(response, "Identity/get", accountId) : response.methodResponses.find((r) => r[0] === "Identity/get")?.[1];
880
1238
  if (!identityResponse) throw new JmapApiError("No Identity/get response received");
881
- const identities = identityResponse[1].list;
1239
+ const identities = identityResponse.list;
882
1240
  if (!identities || identities.length === 0) throw new JmapApiError("No identities found");
1241
+ if (strict && (!Array.isArray(identities) || !identities.every((identity) => isRecord(identity) && typeof identity.id === "string" && identity.id.length > 0 && typeof identity.email === "string"))) throw new JmapApiError("Invalid identity identifiers in raw discovery.");
883
1242
  const identityMap = /* @__PURE__ */ new Map();
884
1243
  for (const identity of identities) identityMap.set(identity.email.toLowerCase(), identity.id);
885
1244
  return identityMap;