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

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/dist/index.d.cts CHANGED
@@ -1,4 +1,4 @@
1
- import { Message, Receipt, Transport, TransportOptions } from "@upyo/core";
1
+ import { Message, RawMessage, RawTransport, Receipt, TransportOptions } from "@upyo/core";
2
2
 
3
3
  //#region src/config.d.ts
4
4
 
@@ -103,7 +103,7 @@ declare function createJmapConfig(config: JmapConfig): ResolvedJmapConfig;
103
103
  * JMAP transport for sending emails via JMAP protocol (RFC 8620/8621).
104
104
  * @since 0.4.0
105
105
  */
106
- declare class JmapTransport implements Transport<"jmap"> {
106
+ declare class JmapTransport implements RawTransport<"jmap"> {
107
107
  readonly id = "jmap";
108
108
  readonly config: ResolvedJmapConfig;
109
109
  private readonly httpClient;
@@ -124,6 +124,17 @@ declare class JmapTransport implements Transport<"jmap"> {
124
124
  * @since 0.4.0
125
125
  */
126
126
  send(message: Message, options?: TransportOptions): Promise<Receipt<"jmap">>;
127
+ /**
128
+ * Uploads serialized MIME, imports it, and submits it with an explicit envelope.
129
+ * JMAP servers may modify imported or submitted messages, including removing
130
+ * Bcc. Import and submission are never retried automatically.
131
+ * @param message Original MIME and delivery envelope.
132
+ * @param options Optional cancellation signal.
133
+ * @returns A receipt; uncertain submission outcomes are non-retryable.
134
+ * @throws {Error} If cancellation is requested.
135
+ * @since 0.6.0
136
+ */
137
+ sendRaw(message: RawMessage, options?: TransportOptions): Promise<Receipt<"jmap">>;
127
138
  /**
128
139
  * Sends multiple messages in a single batched JMAP request.
129
140
  * @param messages The messages to send.
package/dist/index.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { Message, Receipt, Transport, TransportOptions } from "@upyo/core";
1
+ import { Message, RawMessage, RawTransport, Receipt, TransportOptions } from "@upyo/core";
2
2
 
3
3
  //#region src/config.d.ts
4
4
 
@@ -103,7 +103,7 @@ declare function createJmapConfig(config: JmapConfig): ResolvedJmapConfig;
103
103
  * JMAP transport for sending emails via JMAP protocol (RFC 8620/8621).
104
104
  * @since 0.4.0
105
105
  */
106
- declare class JmapTransport implements Transport<"jmap"> {
106
+ declare class JmapTransport implements RawTransport<"jmap"> {
107
107
  readonly id = "jmap";
108
108
  readonly config: ResolvedJmapConfig;
109
109
  private readonly httpClient;
@@ -124,6 +124,17 @@ declare class JmapTransport implements Transport<"jmap"> {
124
124
  * @since 0.4.0
125
125
  */
126
126
  send(message: Message, options?: TransportOptions): Promise<Receipt<"jmap">>;
127
+ /**
128
+ * Uploads serialized MIME, imports it, and submits it with an explicit envelope.
129
+ * JMAP servers may modify imported or submitted messages, including removing
130
+ * Bcc. Import and submission are never retried automatically.
131
+ * @param message Original MIME and delivery envelope.
132
+ * @param options Optional cancellation signal.
133
+ * @returns A receipt; uncertain submission outcomes are non-retryable.
134
+ * @throws {Error} If cancellation is requested.
135
+ * @since 0.6.0
136
+ */
137
+ sendRaw(message: RawMessage, options?: TransportOptions): Promise<Receipt<"jmap">>;
127
138
  /**
128
139
  * Sends multiple messages in a single batched JMAP request.
129
140
  * @param messages The messages to send.
package/dist/index.js CHANGED
@@ -1,4 +1,4 @@
1
- import { combineSignals, createFailedReceipt, parseRetryAfter, readAttachmentContent } from "@upyo/core";
1
+ import { RawMessageValidationError, analyzeRawMessage, combineSignals, createFailedReceipt, createRawMessagePlan, iterateRawMessage, parseRetryAfter, readAttachmentContent } from "@upyo/core";
2
2
  import { resolveCalendarContent } from "@upyo/core/calendar";
3
3
  import { parseMessageId } from "@upyo/core/message-id";
4
4
 
@@ -43,79 +43,6 @@ function isCapabilityError(error) {
43
43
  return error instanceof JmapApiError && error.jmapErrorType === JMAP_ERROR_TYPES.unknownCapability;
44
44
  }
45
45
 
46
- //#endregion
47
- //#region src/blob-uploader.ts
48
- /**
49
- * Upload a blob to the JMAP server.
50
- *
51
- * @param config - The resolved JMAP configuration
52
- * @param uploadUrl - The upload URL template from the session (e.g., "https://server/upload/{accountId}")
53
- * @param accountId - The account ID to upload to
54
- * @param blob - The blob or file to upload
55
- * @param signal - Optional abort signal
56
- * @returns The upload response containing the blobId
57
- */
58
- async function uploadBlob(config, uploadUrl, accountId, blob, signal) {
59
- signal?.throwIfAborted();
60
- const url = uploadUrl.replace("{accountId}", accountId);
61
- let authHeader;
62
- if (config.bearerToken) authHeader = `Bearer ${config.bearerToken}`;
63
- else if (config.basicAuth) {
64
- const credentials = btoa(`${config.basicAuth.username}:${config.basicAuth.password}`);
65
- authHeader = `Basic ${credentials}`;
66
- } else throw new Error("No authentication method configured");
67
- const headers = {
68
- Authorization: authHeader,
69
- "Content-Type": blob.type || "application/octet-stream"
70
- };
71
- for (const [key, value] of Object.entries(config.headers)) headers[key] = value;
72
- const controller = new AbortController();
73
- const timeoutId = setTimeout(() => controller.abort(), config.timeout);
74
- const combinedSignal = combineSignals(controller.signal, signal);
75
- try {
76
- const response = await fetch(url, {
77
- method: "POST",
78
- headers,
79
- body: blob,
80
- signal: combinedSignal.signal
81
- });
82
- if (!response.ok) {
83
- const body = await response.text();
84
- throw new JmapApiError(`Blob upload failed: ${response.status} ${response.statusText}`, response.status, body);
85
- }
86
- const result = await response.json();
87
- return result;
88
- } finally {
89
- combinedSignal.cleanup();
90
- clearTimeout(timeoutId);
91
- }
92
- }
93
-
94
- //#endregion
95
- //#region src/config.ts
96
- /**
97
- * Creates a resolved JMAP configuration with default values applied.
98
- * @param config The user-provided configuration.
99
- * @returns The resolved configuration with all fields populated.
100
- * @throws Error if neither bearerToken nor basicAuth is provided.
101
- * @since 0.4.0
102
- */
103
- function createJmapConfig(config) {
104
- if (!config.bearerToken && !config.basicAuth) throw new Error("Either bearerToken or basicAuth must be provided");
105
- return {
106
- sessionUrl: config.sessionUrl,
107
- bearerToken: config.bearerToken ?? null,
108
- basicAuth: config.basicAuth ?? null,
109
- accountId: config.accountId ?? null,
110
- identityId: config.identityId ?? null,
111
- timeout: config.timeout ?? 3e4,
112
- retries: config.retries ?? 3,
113
- headers: config.headers ?? {},
114
- sessionCacheTtl: config.sessionCacheTtl ?? 3e5,
115
- baseUrl: config.baseUrl ?? null
116
- };
117
- }
118
-
119
46
  //#endregion
120
47
  //#region src/http-client.ts
121
48
  /**
@@ -124,7 +51,12 @@ function createJmapConfig(config) {
124
51
  */
125
52
  var JmapHttpClient = class {
126
53
  config;
127
- constructor(config) {
54
+ /**
55
+ * @param config HTTP and authentication settings.
56
+ * @param replayableRequests Whether request bodies and failed requests may be replayed.
57
+ */
58
+ constructor(config, replayableRequests = true) {
59
+ this.replayableRequests = replayableRequests;
128
60
  this.config = config;
129
61
  }
130
62
  /**
@@ -153,13 +85,21 @@ var JmapHttpClient = class {
153
85
  async executeRequest(apiUrl, request, signal) {
154
86
  signal?.throwIfAborted();
155
87
  let lastError = null;
156
- for (let attempt = 0; attempt <= this.config.retries; attempt++) {
88
+ const retries = this.replayableRequests ? this.config.retries : 0;
89
+ for (let attempt = 0; attempt <= retries; attempt++) {
157
90
  signal?.throwIfAborted();
158
91
  try {
159
92
  const response = await this.fetchWithAuth(apiUrl, {
160
93
  method: "POST",
161
94
  headers: { "Content-Type": "application/json" },
162
- body: JSON.stringify(request)
95
+ body: this.replayableRequests ? JSON.stringify(request) : new ReadableStream({ start(controller) {
96
+ controller.enqueue(new TextEncoder().encode(JSON.stringify(request)));
97
+ controller.close();
98
+ } }),
99
+ ...this.replayableRequests ? {} : {
100
+ duplex: "half",
101
+ redirect: "error"
102
+ }
163
103
  }, signal);
164
104
  if (!response.ok) {
165
105
  const text = await response.text();
@@ -174,7 +114,7 @@ var JmapHttpClient = class {
174
114
  if (error.statusCode >= 400 && error.statusCode < 500) throw error;
175
115
  }
176
116
  lastError = error instanceof Error ? error : new Error(String(error));
177
- if (attempt === this.config.retries) {
117
+ if (attempt === retries) {
178
118
  if (error instanceof JmapApiError) throw error;
179
119
  if (isAbortError$1(error)) throw new JmapApiError("JMAP request timed out.", void 0, void 0, void 0, void 0, attempt + 1);
180
120
  throw new JmapApiError(lastError.message, void 0, void 0, void 0, void 0, attempt + 1);
@@ -237,6 +177,384 @@ function isAbortError$1(error) {
237
177
  return error instanceof Error && error.name === "AbortError";
238
178
  }
239
179
 
180
+ //#endregion
181
+ //#region src/raw-upload.ts
182
+ /** Runs a raw operation with a progress-resettable deadline through response parsing. @internal */
183
+ async function rawOperation(timeout, operation, signal) {
184
+ const controller = new AbortController();
185
+ const combined = combineSignals(controller.signal, signal);
186
+ let timer;
187
+ let finished = false;
188
+ const progress = () => {
189
+ if (finished) return;
190
+ clearTimeout(timer);
191
+ timer = setTimeout(() => controller.abort(new JmapApiError("Raw JMAP operation timed out.")), timeout);
192
+ };
193
+ let abort;
194
+ try {
195
+ combined.signal.throwIfAborted();
196
+ progress();
197
+ return await new Promise((resolve, reject) => {
198
+ abort = () => reject(combined.signal.reason);
199
+ combined.signal.addEventListener("abort", abort, { once: true });
200
+ Promise.resolve().then(() => {
201
+ combined.signal.throwIfAborted();
202
+ return operation(combined.signal, progress);
203
+ }).then(resolve, reject);
204
+ if (combined.signal.aborted) abort();
205
+ });
206
+ } finally {
207
+ finished = true;
208
+ clearTimeout(timer);
209
+ if (abort) combined.signal.removeEventListener("abort", abort);
210
+ controller.abort();
211
+ combined.cleanup();
212
+ }
213
+ }
214
+ /** @internal */
215
+ function isRecord(value) {
216
+ return typeof value === "object" && value !== null && !Array.isArray(value);
217
+ }
218
+ /** Streams validated MIME bytes without relying on fetch to cancel its body. @internal */
219
+ async function uploadRawMessage(config, uploadUrl, accountId, plan, signal) {
220
+ return await rawOperation(config.timeout, async (outerSignal, progress) => {
221
+ const analysis = plan.encoding === void 0 ? await analyzeRawMessage(plan, outerSignal, progress) : {
222
+ encoding: plan.encoding,
223
+ size: plan.size
224
+ };
225
+ outerSignal.throwIfAborted();
226
+ const controller = new AbortController();
227
+ const combined = combineSignals(controller.signal, outerSignal);
228
+ const owned = combined.signal;
229
+ const iterator = iterateRawMessage(plan, {
230
+ signal: owned,
231
+ encoding: analysis.encoding,
232
+ expectedSize: analysis.size,
233
+ onProgress: progress
234
+ })[Symbol.asyncIterator]();
235
+ let closed = false;
236
+ let eof = false;
237
+ let size = 0;
238
+ let sourceError;
239
+ let hasSourceError = false;
240
+ let bodyController;
241
+ const close = (reason) => {
242
+ if (closed) return;
243
+ closed = true;
244
+ controller.abort(reason);
245
+ if (!eof) try {
246
+ Promise.resolve(iterator.return?.()).catch(() => {});
247
+ } catch {}
248
+ };
249
+ const abort = () => {
250
+ try {
251
+ bodyController?.error(owned.reason);
252
+ } catch {}
253
+ close(owned.reason);
254
+ };
255
+ const body = new ReadableStream({
256
+ start(stream) {
257
+ bodyController = stream;
258
+ owned.addEventListener("abort", abort, { once: true });
259
+ if (owned.aborted) abort();
260
+ },
261
+ async pull(stream) {
262
+ try {
263
+ owned.throwIfAborted();
264
+ const item = await iterator.next();
265
+ owned.throwIfAborted();
266
+ if (item.done) {
267
+ eof = true;
268
+ progress();
269
+ stream.close();
270
+ } else {
271
+ size += item.value.length;
272
+ stream.enqueue(new Uint8Array(item.value));
273
+ }
274
+ } catch (error) {
275
+ if (!owned.aborted && !hasSourceError) {
276
+ hasSourceError = true;
277
+ sourceError = error;
278
+ }
279
+ try {
280
+ stream.error(error);
281
+ } catch {}
282
+ close(error);
283
+ }
284
+ },
285
+ cancel: close
286
+ }, { highWaterMark: 0 });
287
+ const headers = new Headers(config.headers);
288
+ if (!headers.has("Authorization")) {
289
+ if (config.bearerToken) headers.set("Authorization", `Bearer ${config.bearerToken}`);
290
+ else if (config.basicAuth) headers.set("Authorization", `Basic ${btoa(`${config.basicAuth.username}:${config.basicAuth.password}`)}`);
291
+ }
292
+ headers.set("Content-Type", "message/rfc822");
293
+ const request = {
294
+ method: "POST",
295
+ headers,
296
+ body,
297
+ duplex: "half",
298
+ redirect: "error",
299
+ signal: outerSignal
300
+ };
301
+ try {
302
+ const response = await fetch(uploadUrl.replace("{accountId}", encodeURIComponent(accountId)), request);
303
+ outerSignal.throwIfAborted();
304
+ if (!response.ok) throw new JmapApiError(`Raw upload failed: ${response.status}`, response.status, await response.text(), void 0, parseRetryAfter(response.headers.get("Retry-After")));
305
+ if (!eof) throw new JmapApiError("Raw upload responded before validated EOF.");
306
+ const result = await response.json();
307
+ outerSignal.throwIfAborted();
308
+ if (!isRecord(result) || result.accountId !== accountId || typeof result.blobId !== "string" || !result.blobId || result.size !== size) throw new JmapApiError("Invalid raw upload response.");
309
+ return {
310
+ accountId,
311
+ blobId: result.blobId,
312
+ size,
313
+ type: typeof result.type === "string" ? result.type : "message/rfc822"
314
+ };
315
+ } catch (error) {
316
+ outerSignal.throwIfAborted();
317
+ if (hasSourceError) throw sourceError;
318
+ throw error;
319
+ } finally {
320
+ owned.removeEventListener("abort", abort);
321
+ close();
322
+ combined.cleanup();
323
+ }
324
+ }, signal);
325
+ }
326
+
327
+ //#endregion
328
+ //#region src/raw-delivery.ts
329
+ const using = [
330
+ "urn:ietf:params:jmap:core",
331
+ "urn:ietf:params:jmap:mail",
332
+ "urn:ietf:params:jmap:submission"
333
+ ];
334
+ function failure(message, stage, retryable, unknown = false, error) {
335
+ return createFailedReceipt(message, {
336
+ provider: "jmap",
337
+ code: `jmap.raw_${stage}_${unknown ? "unknown" : "failed"}`,
338
+ category: unknown ? "unknown" : error?.statusCode !== void 0 || retryable ? void 0 : "rejected",
339
+ statusCode: error?.statusCode,
340
+ retryAfterMilliseconds: error?.retryAfterMilliseconds,
341
+ providerDetails: error ? {
342
+ responseBody: error.responseBody,
343
+ jmapErrorType: error.jmapErrorType
344
+ } : void 0,
345
+ retryable,
346
+ attempts: 1
347
+ });
348
+ }
349
+ function methodResult(response, name, id, accountId) {
350
+ if (!isRecord(response) || !Array.isArray(response.methodResponses)) throw new JmapApiError("Invalid raw JMAP method response.");
351
+ const matches = response.methodResponses.filter((entry) => Array.isArray(entry) && entry[2] === id);
352
+ if (matches.length !== 1) throw new JmapApiError("Missing or contradictory raw JMAP method response.");
353
+ const result = matches[0];
354
+ if (!Array.isArray(result) || result.length !== 3 || !isRecord(result[1]) || result[0] !== name && result[0] !== "error") throw new JmapApiError("Invalid raw JMAP method result.");
355
+ if (result[0] === "error") {
356
+ if (typeof result[1].type !== "string" || !result[1].type) throw new JmapApiError("Invalid raw JMAP method error.");
357
+ } else if (result[1].accountId !== accountId) throw new JmapApiError("Wrong account in raw JMAP response.");
358
+ return {
359
+ name: result[0],
360
+ args: result[1]
361
+ };
362
+ }
363
+ function creation(args) {
364
+ const created = isRecord(args.created) ? args.created.raw : void 0;
365
+ const rejected = isRecord(args.notCreated) ? args.notCreated.raw : void 0;
366
+ if (created !== void 0 && rejected !== void 0) throw new JmapApiError("Contradictory raw JMAP creation result.");
367
+ if (isRecord(created) && typeof created.id === "string" && created.id) return { created };
368
+ if (isRecord(rejected) && typeof rejected.type === "string" && rejected.type) return { rejected };
369
+ throw new JmapApiError("Missing raw JMAP creation result.");
370
+ }
371
+ function errorDescription(error) {
372
+ return `Raw JMAP operation rejected: ${String(error.type)}${typeof error.description === "string" ? `: ${error.description}` : ""}`;
373
+ }
374
+ function isRequestRejection(error) {
375
+ if (!(error instanceof JmapApiError)) return false;
376
+ if (error.statusCode === 401 || error.statusCode === 403) return true;
377
+ if (!error.responseBody || error.statusCode === void 0 || error.statusCode < 400 || error.statusCode > 599) return false;
378
+ try {
379
+ const body = JSON.parse(error.responseBody);
380
+ if (!isRecord(body) || body.status !== void 0 && body.status !== error.statusCode) return false;
381
+ if (body.type === JMAP_ERROR_TYPES.limit) return typeof body.limit === "string" && body.limit.length > 0;
382
+ return body.type === JMAP_ERROR_TYPES.notJSON || body.type === JMAP_ERROR_TYPES.notRequest || body.type === JMAP_ERROR_TYPES.unknownCapability;
383
+ } catch {
384
+ return false;
385
+ }
386
+ }
387
+ /** Uploads, imports, then submits exactly once; uncertain submission must not be retried. @internal */
388
+ async function deliverRawMessage(config, session, accountId, draftsMailboxId, identityId, plan, signal) {
389
+ const client = new JmapHttpClient({
390
+ ...config,
391
+ retries: 0
392
+ }, false);
393
+ const execute = (request) => rawOperation(config.timeout, (owned) => client.executeRequest(session.apiUrl, request, owned), signal);
394
+ let stage = "upload";
395
+ try {
396
+ const uploaded = await uploadRawMessage(config, session.uploadUrl, accountId, plan, signal);
397
+ signal?.throwIfAborted();
398
+ stage = "import";
399
+ const imported = methodResult(await execute({
400
+ using,
401
+ methodCalls: [[
402
+ "Email/import",
403
+ {
404
+ accountId,
405
+ emails: { raw: {
406
+ blobId: uploaded.blobId,
407
+ mailboxIds: { [draftsMailboxId]: true }
408
+ } }
409
+ },
410
+ "raw-import"
411
+ ]]
412
+ }), "Email/import", "raw-import", accountId);
413
+ if (imported.name === "error") return failure(errorDescription(imported.args), "import", imported.args.type === "serverPartialFail");
414
+ const { created, rejected } = creation(imported.args);
415
+ let emailId;
416
+ if (created && typeof created.id === "string") emailId = created.id;
417
+ else if (rejected?.type === "alreadyExists" && typeof rejected.existingId === "string" && rejected.existingId) {
418
+ const existing = methodResult(await execute({
419
+ using,
420
+ methodCalls: [[
421
+ "Email/get",
422
+ {
423
+ accountId,
424
+ ids: [rejected.existingId],
425
+ properties: ["id", "blobId"]
426
+ },
427
+ "raw-existing"
428
+ ]]
429
+ }), "Email/get", "raw-existing", accountId);
430
+ const list = existing.args.list;
431
+ 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);
432
+ emailId = rejected.existingId;
433
+ } else return failure(errorDescription(rejected ?? { type: "invalidResult" }), "import", false);
434
+ signal?.throwIfAborted();
435
+ stage = "submission";
436
+ const submitted = methodResult(await execute({
437
+ using,
438
+ methodCalls: [[
439
+ "EmailSubmission/set",
440
+ {
441
+ accountId,
442
+ create: { raw: {
443
+ emailId,
444
+ identityId,
445
+ envelope: {
446
+ mailFrom: { email: plan.envelope.from ?? "" },
447
+ rcptTo: plan.envelope.to.map((email) => ({ email }))
448
+ }
449
+ } }
450
+ },
451
+ "raw-submit"
452
+ ]]
453
+ }), "EmailSubmission/set", "raw-submit", accountId);
454
+ if (submitted.name === "error") return failure(errorDescription(submitted.args), "submission", false, submitted.args.type === "serverPartialFail");
455
+ const result = creation(submitted.args);
456
+ if (result.rejected) return failure(errorDescription(result.rejected), "submission", false);
457
+ if (!result.created || typeof result.created.id !== "string") throw new JmapApiError("Missing raw submission ID.");
458
+ signal?.throwIfAborted();
459
+ return {
460
+ successful: true,
461
+ provider: "jmap",
462
+ messageId: result.created.id
463
+ };
464
+ } catch (error) {
465
+ signal?.throwIfAborted();
466
+ const message = error instanceof Error ? error.message : String(error);
467
+ if (error instanceof RawMessageValidationError) return createFailedReceipt(message, {
468
+ provider: "jmap",
469
+ code: "jmap.raw_message_invalid",
470
+ category: "validation",
471
+ retryable: false,
472
+ attempts: 1
473
+ });
474
+ const definite = isRequestRejection(error);
475
+ return failure(message, stage, stage !== "submission" && !definite, stage === "submission" && !definite, error instanceof JmapApiError ? error : void 0);
476
+ }
477
+ }
478
+ /** Validates raw discovery responses before accepting delivery identifiers. @internal */
479
+ function rawDiscoveryResult(response, method, accountId) {
480
+ const result = methodResult(response, method, "c0", accountId);
481
+ if (result.name === "error") throw new JmapApiError(errorDescription(result.args));
482
+ return result.args;
483
+ }
484
+
485
+ //#endregion
486
+ //#region src/blob-uploader.ts
487
+ /**
488
+ * Upload a blob to the JMAP server.
489
+ *
490
+ * @param config - The resolved JMAP configuration
491
+ * @param uploadUrl - The upload URL template from the session (e.g., "https://server/upload/{accountId}")
492
+ * @param accountId - The account ID to upload to
493
+ * @param blob - The blob or file to upload
494
+ * @param signal - Optional abort signal
495
+ * @returns The upload response containing the blobId
496
+ */
497
+ async function uploadBlob(config, uploadUrl, accountId, blob, signal) {
498
+ signal?.throwIfAborted();
499
+ const url = uploadUrl.replace("{accountId}", accountId);
500
+ let authHeader;
501
+ if (config.bearerToken) authHeader = `Bearer ${config.bearerToken}`;
502
+ else if (config.basicAuth) {
503
+ const credentials = btoa(`${config.basicAuth.username}:${config.basicAuth.password}`);
504
+ authHeader = `Basic ${credentials}`;
505
+ } else throw new Error("No authentication method configured");
506
+ const headers = {
507
+ Authorization: authHeader,
508
+ "Content-Type": blob.type || "application/octet-stream"
509
+ };
510
+ for (const [key, value] of Object.entries(config.headers)) headers[key] = value;
511
+ const controller = new AbortController();
512
+ const timeoutId = setTimeout(() => controller.abort(), config.timeout);
513
+ const combinedSignal = combineSignals(controller.signal, signal);
514
+ try {
515
+ const response = await fetch(url, {
516
+ method: "POST",
517
+ headers,
518
+ body: blob,
519
+ signal: combinedSignal.signal
520
+ });
521
+ if (!response.ok) {
522
+ const body = await response.text();
523
+ throw new JmapApiError(`Blob upload failed: ${response.status} ${response.statusText}`, response.status, body);
524
+ }
525
+ const result = await response.json();
526
+ return result;
527
+ } finally {
528
+ combinedSignal.cleanup();
529
+ clearTimeout(timeoutId);
530
+ }
531
+ }
532
+
533
+ //#endregion
534
+ //#region src/config.ts
535
+ /**
536
+ * Creates a resolved JMAP configuration with default values applied.
537
+ * @param config The user-provided configuration.
538
+ * @returns The resolved configuration with all fields populated.
539
+ * @throws Error if neither bearerToken nor basicAuth is provided.
540
+ * @since 0.4.0
541
+ */
542
+ function createJmapConfig(config) {
543
+ if (!config.bearerToken && !config.basicAuth) throw new Error("Either bearerToken or basicAuth must be provided");
544
+ return {
545
+ sessionUrl: config.sessionUrl,
546
+ bearerToken: config.bearerToken ?? null,
547
+ basicAuth: config.basicAuth ?? null,
548
+ accountId: config.accountId ?? null,
549
+ identityId: config.identityId ?? null,
550
+ timeout: config.timeout ?? 3e4,
551
+ retries: config.retries ?? 3,
552
+ headers: config.headers ?? {},
553
+ sessionCacheTtl: config.sessionCacheTtl ?? 3e5,
554
+ baseUrl: config.baseUrl ?? null
555
+ };
556
+ }
557
+
240
558
  //#endregion
241
559
  //#region src/message-converter.ts
242
560
  /**
@@ -649,6 +967,45 @@ var JmapTransport = class {
649
967
  }
650
968
  }
651
969
  /**
970
+ * Uploads serialized MIME, imports it, and submits it with an explicit envelope.
971
+ * JMAP servers may modify imported or submitted messages, including removing
972
+ * Bcc. Import and submission are never retried automatically.
973
+ * @param message Original MIME and delivery envelope.
974
+ * @param options Optional cancellation signal.
975
+ * @returns A receipt; uncertain submission outcomes are non-retryable.
976
+ * @throws {Error} If cancellation is requested.
977
+ * @since 0.6.0
978
+ */
979
+ async sendRaw(message, options) {
980
+ if (message?.content instanceof Promise) message.content.catch(() => {});
981
+ const signal = options?.signal;
982
+ signal?.throwIfAborted();
983
+ try {
984
+ const plan = createRawMessagePlan(message);
985
+ const session = await rawOperation(this.config.timeout, (owned) => this.getSession(owned), signal);
986
+ const capable = (id) => {
987
+ const account = session.accounts[id];
988
+ return account != null && !account.isReadOnly && JMAP_CAPABILITIES.mail in account.accountCapabilities && JMAP_CAPABILITIES.submission in account.accountCapabilities;
989
+ };
990
+ const accountId = this.config.accountId ?? Object.keys(session.accounts).find(capable);
991
+ 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, {
992
+ category: "configuration",
993
+ code: "jmap.no_mail_account",
994
+ retryable: false
995
+ });
996
+ const drafts = await rawOperation(this.config.timeout, (owned) => this.getDraftsMailboxId(session, accountId, owned, true), signal);
997
+ const identity = await rawOperation(this.config.timeout, (owned) => this.getIdentityId(session, accountId, plan.envelope.from ?? "", owned, true), signal);
998
+ return await deliverRawMessage(this.config, session, accountId, drafts, identity, plan, signal);
999
+ } catch (error) {
1000
+ signal?.throwIfAborted();
1001
+ return createJmapFailure(error instanceof Error ? error.message : String(error), error, error instanceof RawMessageValidationError ? {
1002
+ category: "validation",
1003
+ code: "jmap.raw_message_invalid",
1004
+ retryable: false
1005
+ } : void 0);
1006
+ }
1007
+ }
1008
+ /**
652
1009
  * Sends multiple messages in a single batched JMAP request.
653
1010
  * @param messages The messages to send.
654
1011
  * @param options Optional transport options.
@@ -795,7 +1152,7 @@ var JmapTransport = class {
795
1152
  * @returns The drafts mailbox ID.
796
1153
  * @since 0.4.0
797
1154
  */
798
- async getDraftsMailboxId(session, accountId, signal) {
1155
+ async getDraftsMailboxId(session, accountId, signal, strict = false) {
799
1156
  const response = await this.httpClient.executeRequest(session.apiUrl, {
800
1157
  using: [JMAP_CAPABILITIES.core, JMAP_CAPABILITIES.mail],
801
1158
  methodCalls: [[
@@ -811,10 +1168,11 @@ var JmapTransport = class {
811
1168
  "c0"
812
1169
  ]]
813
1170
  }, signal);
814
- const mailboxResponse = response.methodResponses.find((r) => r[0] === "Mailbox/get");
1171
+ const mailboxResponse = strict ? rawDiscoveryResult(response, "Mailbox/get", accountId) : response.methodResponses.find((r) => r[0] === "Mailbox/get")?.[1];
815
1172
  if (!mailboxResponse) throw new JmapApiError("No Mailbox/get response received");
816
- const mailboxes = mailboxResponse[1].list;
1173
+ const mailboxes = mailboxResponse.list;
817
1174
  if (!mailboxes) throw new JmapApiError("No mailboxes found");
1175
+ 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.");
818
1176
  const drafts = mailboxes.find((m) => m.role === "drafts");
819
1177
  if (!drafts) throw new JmapApiError("No drafts mailbox found");
820
1178
  return drafts.id;
@@ -828,9 +1186,9 @@ var JmapTransport = class {
828
1186
  * @returns The identity ID.
829
1187
  * @since 0.4.0
830
1188
  */
831
- async getIdentityId(session, accountId, senderEmail, signal) {
1189
+ async getIdentityId(session, accountId, senderEmail, signal, strict = false) {
832
1190
  if (this.config.identityId) return this.config.identityId;
833
- const identityMap = await this.getIdentityMap(session, accountId, signal);
1191
+ const identityMap = await this.getIdentityMap(session, accountId, signal, strict);
834
1192
  const matching = identityMap.get(senderEmail.toLowerCase());
835
1193
  if (matching) return matching;
836
1194
  return identityMap.values().next().value;
@@ -843,7 +1201,7 @@ var JmapTransport = class {
843
1201
  * @returns Map of lowercase email to identity ID.
844
1202
  * @since 0.4.0
845
1203
  */
846
- async getIdentityMap(session, accountId, signal) {
1204
+ async getIdentityMap(session, accountId, signal, strict = false) {
847
1205
  if (this.config.identityId) return new Map([["*", this.config.identityId]]);
848
1206
  const response = await this.httpClient.executeRequest(session.apiUrl, {
849
1207
  using: [JMAP_CAPABILITIES.core, JMAP_CAPABILITIES.submission],
@@ -853,10 +1211,11 @@ var JmapTransport = class {
853
1211
  "c0"
854
1212
  ]]
855
1213
  }, signal);
856
- const identityResponse = response.methodResponses.find((r) => r[0] === "Identity/get");
1214
+ const identityResponse = strict ? rawDiscoveryResult(response, "Identity/get", accountId) : response.methodResponses.find((r) => r[0] === "Identity/get")?.[1];
857
1215
  if (!identityResponse) throw new JmapApiError("No Identity/get response received");
858
- const identities = identityResponse[1].list;
1216
+ const identities = identityResponse.list;
859
1217
  if (!identities || identities.length === 0) throw new JmapApiError("No identities found");
1218
+ 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.");
860
1219
  const identityMap = /* @__PURE__ */ new Map();
861
1220
  for (const identity of identities) identityMap.set(identity.email.toLowerCase(), identity.id);
862
1221
  return identityMap;