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

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,66 +133,3 @@ 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. No transcoding occurs.
167
-
168
- Sources must have CRLF line endings including the final CRLF, nonempty headers,
169
- no NUL, and no line longer than 998 bytes excluding CRLF. Uploads stream without
170
- collecting the message in memory. Progress refreshes the inactivity timeout;
171
- cancellation stops source reads and requests source cleanup. Response parsing
172
- also remains subject to timeout. Pass an `AbortSignal` through `signal` in the
173
- second argument to `sendRaw()`.
174
-
175
- The envelope is independent of MIME headers. A configured `identityId` takes
176
- precedence; otherwise Upyo selects the identity matching the envelope sender,
177
- or falls back to the first available identity. A null sender also uses that
178
- fallback. The server may reject the chosen identity, envelope, or null sender.
179
-
180
- Upyo does not compose or repair the uploaded bytes. JMAP servers may repair
181
- imported MIME and modify messages during submission; RFC 8621 requires removal
182
- of Bcc during submission. Raw JMAP delivery therefore does not guarantee that
183
- an existing signature or byte-for-byte representation reaches the recipient.
184
-
185
- Import and submission are each attempted once. `jmap.raw_import_failed` with
186
- `retryable: true` means no submission was issued, although an imported Email
187
- may remain after a lost response. A definite server rejection is non-retryable.
188
- `jmap.raw_submission_unknown` means submission may have succeeded: inspect the
189
- server state before attempting another send. It is marked non-retryable to
190
- avoid duplicate delivery. Cancellation cannot recall a submitted message.
191
-
192
- An `alreadyExists` import result is reused only when the existing Email refers
193
- to the exact uploaded blob; matching Message-ID alone is insufficient. Upyo
194
- leaves imported Emails in Drafts and does not delete them after success or
195
- failure. The server expires unreferenced uploaded blobs according to its policy.
196
-
197
- See [RFC 8620] for uploads and request errors, and [RFC 8621] for import and
198
- submission behavior.
package/dist/index.cjs CHANGED
@@ -66,6 +66,79 @@ 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
+
69
142
  //#endregion
70
143
  //#region src/http-client.ts
71
144
  /**
@@ -74,12 +147,7 @@ function isCapabilityError(error) {
74
147
  */
75
148
  var JmapHttpClient = class {
76
149
  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;
150
+ constructor(config) {
83
151
  this.config = config;
84
152
  }
85
153
  /**
@@ -108,21 +176,13 @@ var JmapHttpClient = class {
108
176
  async executeRequest(apiUrl, request, signal) {
109
177
  signal?.throwIfAborted();
110
178
  let lastError = null;
111
- const retries = this.replayableRequests ? this.config.retries : 0;
112
- for (let attempt = 0; attempt <= retries; attempt++) {
179
+ for (let attempt = 0; attempt <= this.config.retries; attempt++) {
113
180
  signal?.throwIfAborted();
114
181
  try {
115
182
  const response = await this.fetchWithAuth(apiUrl, {
116
183
  method: "POST",
117
184
  headers: { "Content-Type": "application/json" },
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
- }
185
+ body: JSON.stringify(request)
126
186
  }, signal);
127
187
  if (!response.ok) {
128
188
  const text = await response.text();
@@ -137,7 +197,7 @@ var JmapHttpClient = class {
137
197
  if (error.statusCode >= 400 && error.statusCode < 500) throw error;
138
198
  }
139
199
  lastError = error instanceof Error ? error : new Error(String(error));
140
- if (attempt === retries) {
200
+ if (attempt === this.config.retries) {
141
201
  if (error instanceof JmapApiError) throw error;
142
202
  if (isAbortError$1(error)) throw new JmapApiError("JMAP request timed out.", void 0, void 0, void 0, void 0, attempt + 1);
143
203
  throw new JmapApiError(lastError.message, void 0, void 0, void 0, void 0, attempt + 1);
@@ -200,377 +260,6 @@ function isAbortError$1(error) {
200
260
  return error instanceof Error && error.name === "AbortError";
201
261
  }
202
262
 
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: owned
323
- };
324
- try {
325
- const response = await fetch(uploadUrl.replace("{accountId}", encodeURIComponent(accountId)), request);
326
- owned.throwIfAborted();
327
- if (!eof) throw new JmapApiError("Raw upload responded before validated EOF.");
328
- if (!response.ok) throw new JmapApiError(`Raw upload failed: ${response.status}`, response.status, await response.text());
329
- const result = await response.json();
330
- owned.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
- owned.throwIfAborted();
342
- throw error;
343
- } finally {
344
- owned.removeEventListener("abort", abort);
345
- close();
346
- combined.cleanup();
347
- }
348
- }, signal);
349
- }
350
-
351
- //#endregion
352
- //#region src/raw-delivery.ts
353
- const using = [
354
- "urn:ietf:params:jmap:core",
355
- "urn:ietf:params:jmap:mail",
356
- "urn:ietf:params:jmap:submission"
357
- ];
358
- function failure(message, stage, retryable, unknown = false) {
359
- return (0, __upyo_core.createFailedReceipt)(message, {
360
- provider: "jmap",
361
- code: `jmap.raw_${stage}_${unknown ? "unknown" : "failed"}`,
362
- category: unknown ? "unknown" : retryable ? void 0 : "rejected",
363
- retryable,
364
- attempts: 1
365
- });
366
- }
367
- function methodResult(response, name, id, accountId) {
368
- if (!isRecord(response) || !Array.isArray(response.methodResponses)) throw new JmapApiError("Invalid raw JMAP method response.");
369
- const matches = response.methodResponses.filter((entry) => Array.isArray(entry) && entry[2] === id);
370
- if (matches.length !== 1) throw new JmapApiError("Missing or contradictory raw JMAP method response.");
371
- const result = matches[0];
372
- if (!Array.isArray(result) || result.length !== 3 || !isRecord(result[1]) || result[0] !== name && result[0] !== "error") throw new JmapApiError("Invalid raw JMAP method result.");
373
- if (result[0] === "error") {
374
- if (typeof result[1].type !== "string" || !result[1].type) throw new JmapApiError("Invalid raw JMAP method error.");
375
- } else if (result[1].accountId !== accountId) throw new JmapApiError("Wrong account in raw JMAP response.");
376
- return {
377
- name: result[0],
378
- args: result[1]
379
- };
380
- }
381
- function creation(args) {
382
- const created = isRecord(args.created) ? args.created.raw : void 0;
383
- const rejected = isRecord(args.notCreated) ? args.notCreated.raw : void 0;
384
- if (created !== void 0 && rejected !== void 0) throw new JmapApiError("Contradictory raw JMAP creation result.");
385
- if (isRecord(created) && typeof created.id === "string" && created.id) return { created };
386
- if (isRecord(rejected) && typeof rejected.type === "string" && rejected.type) return { rejected };
387
- throw new JmapApiError("Missing raw JMAP creation result.");
388
- }
389
- function errorDescription(error) {
390
- return `Raw JMAP operation rejected: ${String(error.type)}${typeof error.description === "string" ? `: ${error.description}` : ""}`;
391
- }
392
- function isRequestRejection(error) {
393
- if (!(error instanceof JmapApiError) || !error.responseBody || error.statusCode === void 0 || error.statusCode < 400 || error.statusCode > 599) return false;
394
- try {
395
- const body = JSON.parse(error.responseBody);
396
- if (!isRecord(body) || body.status !== void 0 && body.status !== error.statusCode) return false;
397
- if (body.type === JMAP_ERROR_TYPES.limit) return typeof body.limit === "string" && body.limit.length > 0;
398
- return body.type === JMAP_ERROR_TYPES.notJSON || body.type === JMAP_ERROR_TYPES.notRequest || body.type === JMAP_ERROR_TYPES.unknownCapability;
399
- } catch {
400
- return false;
401
- }
402
- }
403
- /** Uploads, imports, then submits exactly once; uncertain submission must not be retried. @internal */
404
- async function deliverRawMessage(config, session, accountId, draftsMailboxId, identityId, plan, signal) {
405
- const client = new JmapHttpClient({
406
- ...config,
407
- retries: 0
408
- }, false);
409
- const execute = (request) => rawOperation(config.timeout, (owned) => client.executeRequest(session.apiUrl, request, owned), signal);
410
- let stage = "upload";
411
- try {
412
- const uploaded = await uploadRawMessage(config, session.uploadUrl, accountId, plan, signal);
413
- signal?.throwIfAborted();
414
- stage = "import";
415
- const imported = methodResult(await execute({
416
- using,
417
- methodCalls: [[
418
- "Email/import",
419
- {
420
- accountId,
421
- emails: { raw: {
422
- blobId: uploaded.blobId,
423
- mailboxIds: { [draftsMailboxId]: true }
424
- } }
425
- },
426
- "raw-import"
427
- ]]
428
- }), "Email/import", "raw-import", accountId);
429
- if (imported.name === "error") return failure(errorDescription(imported.args), "import", imported.args.type === "serverPartialFail");
430
- const { created, rejected } = creation(imported.args);
431
- let emailId;
432
- if (created && typeof created.id === "string") emailId = created.id;
433
- else if (rejected?.type === "alreadyExists" && typeof rejected.existingId === "string" && rejected.existingId) {
434
- const existing = methodResult(await execute({
435
- using,
436
- methodCalls: [[
437
- "Email/get",
438
- {
439
- accountId,
440
- ids: [rejected.existingId],
441
- properties: ["id", "blobId"]
442
- },
443
- "raw-existing"
444
- ]]
445
- }), "Email/get", "raw-existing", accountId);
446
- const list = existing.args.list;
447
- 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);
448
- emailId = rejected.existingId;
449
- } else return failure(errorDescription(rejected ?? { type: "invalidResult" }), "import", false);
450
- signal?.throwIfAborted();
451
- stage = "submission";
452
- const submitted = methodResult(await execute({
453
- using,
454
- methodCalls: [[
455
- "EmailSubmission/set",
456
- {
457
- accountId,
458
- create: { raw: {
459
- emailId,
460
- identityId,
461
- envelope: {
462
- mailFrom: { email: plan.envelope.from ?? "" },
463
- rcptTo: plan.envelope.to.map((email) => ({ email }))
464
- }
465
- } }
466
- },
467
- "raw-submit"
468
- ]]
469
- }), "EmailSubmission/set", "raw-submit", accountId);
470
- if (submitted.name === "error") return failure(errorDescription(submitted.args), "submission", false, submitted.args.type === "serverPartialFail");
471
- const result = creation(submitted.args);
472
- if (result.rejected) return failure(errorDescription(result.rejected), "submission", false);
473
- if (!result.created || typeof result.created.id !== "string") throw new JmapApiError("Missing raw submission ID.");
474
- signal?.throwIfAborted();
475
- return {
476
- successful: true,
477
- provider: "jmap",
478
- messageId: result.created.id
479
- };
480
- } catch (error) {
481
- signal?.throwIfAborted();
482
- const message = error instanceof Error ? error.message : String(error);
483
- if (error instanceof __upyo_core.RawMessageValidationError) return (0, __upyo_core.createFailedReceipt)(message, {
484
- provider: "jmap",
485
- code: "jmap.raw_message_invalid",
486
- category: "validation",
487
- retryable: false,
488
- attempts: 1
489
- });
490
- const definite = isRequestRejection(error);
491
- return failure(message, stage, stage !== "submission" && !definite, stage === "submission" && !definite);
492
- }
493
- }
494
- /** Validates raw discovery responses before accepting delivery identifiers. @internal */
495
- function rawDiscoveryResult(response, method, accountId) {
496
- const result = methodResult(response, method, "c0", accountId);
497
- if (result.name === "error") throw new JmapApiError(errorDescription(result.args));
498
- return result.args;
499
- }
500
-
501
- //#endregion
502
- //#region src/blob-uploader.ts
503
- /**
504
- * Upload a blob to the JMAP server.
505
- *
506
- * @param config - The resolved JMAP configuration
507
- * @param uploadUrl - The upload URL template from the session (e.g., "https://server/upload/{accountId}")
508
- * @param accountId - The account ID to upload to
509
- * @param blob - The blob or file to upload
510
- * @param signal - Optional abort signal
511
- * @returns The upload response containing the blobId
512
- */
513
- async function uploadBlob(config, uploadUrl, accountId, blob, signal) {
514
- signal?.throwIfAborted();
515
- const url = uploadUrl.replace("{accountId}", accountId);
516
- let authHeader;
517
- if (config.bearerToken) authHeader = `Bearer ${config.bearerToken}`;
518
- else if (config.basicAuth) {
519
- const credentials = btoa(`${config.basicAuth.username}:${config.basicAuth.password}`);
520
- authHeader = `Basic ${credentials}`;
521
- } else throw new Error("No authentication method configured");
522
- const headers = {
523
- Authorization: authHeader,
524
- "Content-Type": blob.type || "application/octet-stream"
525
- };
526
- for (const [key, value] of Object.entries(config.headers)) headers[key] = value;
527
- const controller = new AbortController();
528
- const timeoutId = setTimeout(() => controller.abort(), config.timeout);
529
- const combinedSignal = (0, __upyo_core.combineSignals)(controller.signal, signal);
530
- try {
531
- const response = await fetch(url, {
532
- method: "POST",
533
- headers,
534
- body: blob,
535
- signal: combinedSignal.signal
536
- });
537
- if (!response.ok) {
538
- const body = await response.text();
539
- throw new JmapApiError(`Blob upload failed: ${response.status} ${response.statusText}`, response.status, body);
540
- }
541
- const result = await response.json();
542
- return result;
543
- } finally {
544
- combinedSignal.cleanup();
545
- clearTimeout(timeoutId);
546
- }
547
- }
548
-
549
- //#endregion
550
- //#region src/config.ts
551
- /**
552
- * Creates a resolved JMAP configuration with default values applied.
553
- * @param config The user-provided configuration.
554
- * @returns The resolved configuration with all fields populated.
555
- * @throws Error if neither bearerToken nor basicAuth is provided.
556
- * @since 0.4.0
557
- */
558
- function createJmapConfig(config) {
559
- if (!config.bearerToken && !config.basicAuth) throw new Error("Either bearerToken or basicAuth must be provided");
560
- return {
561
- sessionUrl: config.sessionUrl,
562
- bearerToken: config.bearerToken ?? null,
563
- basicAuth: config.basicAuth ?? null,
564
- accountId: config.accountId ?? null,
565
- identityId: config.identityId ?? null,
566
- timeout: config.timeout ?? 3e4,
567
- retries: config.retries ?? 3,
568
- headers: config.headers ?? {},
569
- sessionCacheTtl: config.sessionCacheTtl ?? 3e5,
570
- baseUrl: config.baseUrl ?? null
571
- };
572
- }
573
-
574
263
  //#endregion
575
264
  //#region src/message-converter.ts
576
265
  /**
@@ -983,45 +672,6 @@ var JmapTransport = class {
983
672
  }
984
673
  }
985
674
  /**
986
- * Uploads serialized MIME, imports it, and submits it with an explicit envelope.
987
- * JMAP servers may modify imported or submitted messages, including removing
988
- * Bcc. Import and submission are never retried automatically.
989
- * @param message Original MIME and delivery envelope.
990
- * @param options Optional cancellation signal.
991
- * @returns A receipt; uncertain submission outcomes are non-retryable.
992
- * @throws {Error} If cancellation is requested.
993
- * @since 0.6.0
994
- */
995
- async sendRaw(message, options) {
996
- if (message?.content instanceof Promise) message.content.catch(() => {});
997
- const signal = options?.signal;
998
- signal?.throwIfAborted();
999
- try {
1000
- const plan = (0, __upyo_core.createRawMessagePlan)(message);
1001
- const session = await rawOperation(this.config.timeout, (owned) => this.getSession(owned), signal);
1002
- const capable = (id) => {
1003
- const account = session.accounts[id];
1004
- return account != null && !account.isReadOnly && JMAP_CAPABILITIES.mail in account.accountCapabilities && JMAP_CAPABILITIES.submission in account.accountCapabilities;
1005
- };
1006
- const accountId = this.config.accountId ?? Object.keys(session.accounts).find(capable);
1007
- 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, {
1008
- category: "configuration",
1009
- code: "jmap.no_mail_account",
1010
- retryable: false
1011
- });
1012
- const drafts = await rawOperation(this.config.timeout, (owned) => this.getDraftsMailboxId(session, accountId, owned, true), signal);
1013
- const identity = await rawOperation(this.config.timeout, (owned) => this.getIdentityId(session, accountId, plan.envelope.from ?? "", owned, true), signal);
1014
- return await deliverRawMessage(this.config, session, accountId, drafts, identity, plan, signal);
1015
- } catch (error) {
1016
- signal?.throwIfAborted();
1017
- return createJmapFailure(error instanceof Error ? error.message : String(error), error, error instanceof __upyo_core.RawMessageValidationError ? {
1018
- category: "validation",
1019
- code: "jmap.raw_message_invalid",
1020
- retryable: false
1021
- } : void 0);
1022
- }
1023
- }
1024
- /**
1025
675
  * Sends multiple messages in a single batched JMAP request.
1026
676
  * @param messages The messages to send.
1027
677
  * @param options Optional transport options.
@@ -1168,7 +818,7 @@ var JmapTransport = class {
1168
818
  * @returns The drafts mailbox ID.
1169
819
  * @since 0.4.0
1170
820
  */
1171
- async getDraftsMailboxId(session, accountId, signal, strict = false) {
821
+ async getDraftsMailboxId(session, accountId, signal) {
1172
822
  const response = await this.httpClient.executeRequest(session.apiUrl, {
1173
823
  using: [JMAP_CAPABILITIES.core, JMAP_CAPABILITIES.mail],
1174
824
  methodCalls: [[
@@ -1184,11 +834,10 @@ var JmapTransport = class {
1184
834
  "c0"
1185
835
  ]]
1186
836
  }, signal);
1187
- const mailboxResponse = strict ? rawDiscoveryResult(response, "Mailbox/get", accountId) : response.methodResponses.find((r) => r[0] === "Mailbox/get")?.[1];
837
+ const mailboxResponse = response.methodResponses.find((r) => r[0] === "Mailbox/get");
1188
838
  if (!mailboxResponse) throw new JmapApiError("No Mailbox/get response received");
1189
- const mailboxes = mailboxResponse.list;
839
+ const mailboxes = mailboxResponse[1].list;
1190
840
  if (!mailboxes) throw new JmapApiError("No mailboxes found");
1191
- 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.");
1192
841
  const drafts = mailboxes.find((m) => m.role === "drafts");
1193
842
  if (!drafts) throw new JmapApiError("No drafts mailbox found");
1194
843
  return drafts.id;
@@ -1202,9 +851,9 @@ var JmapTransport = class {
1202
851
  * @returns The identity ID.
1203
852
  * @since 0.4.0
1204
853
  */
1205
- async getIdentityId(session, accountId, senderEmail, signal, strict = false) {
854
+ async getIdentityId(session, accountId, senderEmail, signal) {
1206
855
  if (this.config.identityId) return this.config.identityId;
1207
- const identityMap = await this.getIdentityMap(session, accountId, signal, strict);
856
+ const identityMap = await this.getIdentityMap(session, accountId, signal);
1208
857
  const matching = identityMap.get(senderEmail.toLowerCase());
1209
858
  if (matching) return matching;
1210
859
  return identityMap.values().next().value;
@@ -1217,7 +866,7 @@ var JmapTransport = class {
1217
866
  * @returns Map of lowercase email to identity ID.
1218
867
  * @since 0.4.0
1219
868
  */
1220
- async getIdentityMap(session, accountId, signal, strict = false) {
869
+ async getIdentityMap(session, accountId, signal) {
1221
870
  if (this.config.identityId) return new Map([["*", this.config.identityId]]);
1222
871
  const response = await this.httpClient.executeRequest(session.apiUrl, {
1223
872
  using: [JMAP_CAPABILITIES.core, JMAP_CAPABILITIES.submission],
@@ -1227,11 +876,10 @@ var JmapTransport = class {
1227
876
  "c0"
1228
877
  ]]
1229
878
  }, signal);
1230
- const identityResponse = strict ? rawDiscoveryResult(response, "Identity/get", accountId) : response.methodResponses.find((r) => r[0] === "Identity/get")?.[1];
879
+ const identityResponse = response.methodResponses.find((r) => r[0] === "Identity/get");
1231
880
  if (!identityResponse) throw new JmapApiError("No Identity/get response received");
1232
- const identities = identityResponse.list;
881
+ const identities = identityResponse[1].list;
1233
882
  if (!identities || identities.length === 0) throw new JmapApiError("No identities found");
1234
- 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.");
1235
883
  const identityMap = /* @__PURE__ */ new Map();
1236
884
  for (const identity of identities) identityMap.set(identity.email.toLowerCase(), identity.id);
1237
885
  return identityMap;
package/dist/index.d.cts CHANGED
@@ -1,4 +1,4 @@
1
- import { Message, RawMessage, RawTransport, Receipt, TransportOptions } from "@upyo/core";
1
+ import { Message, Receipt, Transport, 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 RawTransport<"jmap"> {
106
+ declare class JmapTransport implements Transport<"jmap"> {
107
107
  readonly id = "jmap";
108
108
  readonly config: ResolvedJmapConfig;
109
109
  private readonly httpClient;
@@ -124,17 +124,6 @@ declare class JmapTransport implements RawTransport<"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">>;
138
127
  /**
139
128
  * Sends multiple messages in a single batched JMAP request.
140
129
  * @param messages The messages to send.
package/dist/index.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { Message, RawMessage, RawTransport, Receipt, TransportOptions } from "@upyo/core";
1
+ import { Message, Receipt, Transport, 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 RawTransport<"jmap"> {
106
+ declare class JmapTransport implements Transport<"jmap"> {
107
107
  readonly id = "jmap";
108
108
  readonly config: ResolvedJmapConfig;
109
109
  private readonly httpClient;
@@ -124,17 +124,6 @@ declare class JmapTransport implements RawTransport<"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">>;
138
127
  /**
139
128
  * Sends multiple messages in a single batched JMAP request.
140
129
  * @param messages The messages to send.
package/dist/index.js CHANGED
@@ -1,4 +1,4 @@
1
- import { RawMessageValidationError, analyzeRawMessage, combineSignals, createFailedReceipt, createRawMessagePlan, iterateRawMessage, parseRetryAfter, readAttachmentContent } from "@upyo/core";
1
+ import { combineSignals, createFailedReceipt, 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,6 +43,79 @@ 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
+
46
119
  //#endregion
47
120
  //#region src/http-client.ts
48
121
  /**
@@ -51,12 +124,7 @@ function isCapabilityError(error) {
51
124
  */
52
125
  var JmapHttpClient = class {
53
126
  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;
127
+ constructor(config) {
60
128
  this.config = config;
61
129
  }
62
130
  /**
@@ -85,21 +153,13 @@ var JmapHttpClient = class {
85
153
  async executeRequest(apiUrl, request, signal) {
86
154
  signal?.throwIfAborted();
87
155
  let lastError = null;
88
- const retries = this.replayableRequests ? this.config.retries : 0;
89
- for (let attempt = 0; attempt <= retries; attempt++) {
156
+ for (let attempt = 0; attempt <= this.config.retries; attempt++) {
90
157
  signal?.throwIfAborted();
91
158
  try {
92
159
  const response = await this.fetchWithAuth(apiUrl, {
93
160
  method: "POST",
94
161
  headers: { "Content-Type": "application/json" },
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
- }
162
+ body: JSON.stringify(request)
103
163
  }, signal);
104
164
  if (!response.ok) {
105
165
  const text = await response.text();
@@ -114,7 +174,7 @@ var JmapHttpClient = class {
114
174
  if (error.statusCode >= 400 && error.statusCode < 500) throw error;
115
175
  }
116
176
  lastError = error instanceof Error ? error : new Error(String(error));
117
- if (attempt === retries) {
177
+ if (attempt === this.config.retries) {
118
178
  if (error instanceof JmapApiError) throw error;
119
179
  if (isAbortError$1(error)) throw new JmapApiError("JMAP request timed out.", void 0, void 0, void 0, void 0, attempt + 1);
120
180
  throw new JmapApiError(lastError.message, void 0, void 0, void 0, void 0, attempt + 1);
@@ -177,377 +237,6 @@ function isAbortError$1(error) {
177
237
  return error instanceof Error && error.name === "AbortError";
178
238
  }
179
239
 
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: owned
300
- };
301
- try {
302
- const response = await fetch(uploadUrl.replace("{accountId}", encodeURIComponent(accountId)), request);
303
- owned.throwIfAborted();
304
- if (!eof) throw new JmapApiError("Raw upload responded before validated EOF.");
305
- if (!response.ok) throw new JmapApiError(`Raw upload failed: ${response.status}`, response.status, await response.text());
306
- const result = await response.json();
307
- owned.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
- owned.throwIfAborted();
319
- throw error;
320
- } finally {
321
- owned.removeEventListener("abort", abort);
322
- close();
323
- combined.cleanup();
324
- }
325
- }, signal);
326
- }
327
-
328
- //#endregion
329
- //#region src/raw-delivery.ts
330
- const using = [
331
- "urn:ietf:params:jmap:core",
332
- "urn:ietf:params:jmap:mail",
333
- "urn:ietf:params:jmap:submission"
334
- ];
335
- function failure(message, stage, retryable, unknown = false) {
336
- return createFailedReceipt(message, {
337
- provider: "jmap",
338
- code: `jmap.raw_${stage}_${unknown ? "unknown" : "failed"}`,
339
- category: unknown ? "unknown" : retryable ? void 0 : "rejected",
340
- retryable,
341
- attempts: 1
342
- });
343
- }
344
- function methodResult(response, name, id, accountId) {
345
- if (!isRecord(response) || !Array.isArray(response.methodResponses)) throw new JmapApiError("Invalid raw JMAP method response.");
346
- const matches = response.methodResponses.filter((entry) => Array.isArray(entry) && entry[2] === id);
347
- if (matches.length !== 1) throw new JmapApiError("Missing or contradictory raw JMAP method response.");
348
- const result = matches[0];
349
- if (!Array.isArray(result) || result.length !== 3 || !isRecord(result[1]) || result[0] !== name && result[0] !== "error") throw new JmapApiError("Invalid raw JMAP method result.");
350
- if (result[0] === "error") {
351
- if (typeof result[1].type !== "string" || !result[1].type) throw new JmapApiError("Invalid raw JMAP method error.");
352
- } else if (result[1].accountId !== accountId) throw new JmapApiError("Wrong account in raw JMAP response.");
353
- return {
354
- name: result[0],
355
- args: result[1]
356
- };
357
- }
358
- function creation(args) {
359
- const created = isRecord(args.created) ? args.created.raw : void 0;
360
- const rejected = isRecord(args.notCreated) ? args.notCreated.raw : void 0;
361
- if (created !== void 0 && rejected !== void 0) throw new JmapApiError("Contradictory raw JMAP creation result.");
362
- if (isRecord(created) && typeof created.id === "string" && created.id) return { created };
363
- if (isRecord(rejected) && typeof rejected.type === "string" && rejected.type) return { rejected };
364
- throw new JmapApiError("Missing raw JMAP creation result.");
365
- }
366
- function errorDescription(error) {
367
- return `Raw JMAP operation rejected: ${String(error.type)}${typeof error.description === "string" ? `: ${error.description}` : ""}`;
368
- }
369
- function isRequestRejection(error) {
370
- if (!(error instanceof JmapApiError) || !error.responseBody || error.statusCode === void 0 || error.statusCode < 400 || error.statusCode > 599) return false;
371
- try {
372
- const body = JSON.parse(error.responseBody);
373
- if (!isRecord(body) || body.status !== void 0 && body.status !== error.statusCode) return false;
374
- if (body.type === JMAP_ERROR_TYPES.limit) return typeof body.limit === "string" && body.limit.length > 0;
375
- return body.type === JMAP_ERROR_TYPES.notJSON || body.type === JMAP_ERROR_TYPES.notRequest || body.type === JMAP_ERROR_TYPES.unknownCapability;
376
- } catch {
377
- return false;
378
- }
379
- }
380
- /** Uploads, imports, then submits exactly once; uncertain submission must not be retried. @internal */
381
- async function deliverRawMessage(config, session, accountId, draftsMailboxId, identityId, plan, signal) {
382
- const client = new JmapHttpClient({
383
- ...config,
384
- retries: 0
385
- }, false);
386
- const execute = (request) => rawOperation(config.timeout, (owned) => client.executeRequest(session.apiUrl, request, owned), signal);
387
- let stage = "upload";
388
- try {
389
- const uploaded = await uploadRawMessage(config, session.uploadUrl, accountId, plan, signal);
390
- signal?.throwIfAborted();
391
- stage = "import";
392
- const imported = methodResult(await execute({
393
- using,
394
- methodCalls: [[
395
- "Email/import",
396
- {
397
- accountId,
398
- emails: { raw: {
399
- blobId: uploaded.blobId,
400
- mailboxIds: { [draftsMailboxId]: true }
401
- } }
402
- },
403
- "raw-import"
404
- ]]
405
- }), "Email/import", "raw-import", accountId);
406
- if (imported.name === "error") return failure(errorDescription(imported.args), "import", imported.args.type === "serverPartialFail");
407
- const { created, rejected } = creation(imported.args);
408
- let emailId;
409
- if (created && typeof created.id === "string") emailId = created.id;
410
- else if (rejected?.type === "alreadyExists" && typeof rejected.existingId === "string" && rejected.existingId) {
411
- const existing = methodResult(await execute({
412
- using,
413
- methodCalls: [[
414
- "Email/get",
415
- {
416
- accountId,
417
- ids: [rejected.existingId],
418
- properties: ["id", "blobId"]
419
- },
420
- "raw-existing"
421
- ]]
422
- }), "Email/get", "raw-existing", accountId);
423
- const list = existing.args.list;
424
- 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);
425
- emailId = rejected.existingId;
426
- } else return failure(errorDescription(rejected ?? { type: "invalidResult" }), "import", false);
427
- signal?.throwIfAborted();
428
- stage = "submission";
429
- const submitted = methodResult(await execute({
430
- using,
431
- methodCalls: [[
432
- "EmailSubmission/set",
433
- {
434
- accountId,
435
- create: { raw: {
436
- emailId,
437
- identityId,
438
- envelope: {
439
- mailFrom: { email: plan.envelope.from ?? "" },
440
- rcptTo: plan.envelope.to.map((email) => ({ email }))
441
- }
442
- } }
443
- },
444
- "raw-submit"
445
- ]]
446
- }), "EmailSubmission/set", "raw-submit", accountId);
447
- if (submitted.name === "error") return failure(errorDescription(submitted.args), "submission", false, submitted.args.type === "serverPartialFail");
448
- const result = creation(submitted.args);
449
- if (result.rejected) return failure(errorDescription(result.rejected), "submission", false);
450
- if (!result.created || typeof result.created.id !== "string") throw new JmapApiError("Missing raw submission ID.");
451
- signal?.throwIfAborted();
452
- return {
453
- successful: true,
454
- provider: "jmap",
455
- messageId: result.created.id
456
- };
457
- } catch (error) {
458
- signal?.throwIfAborted();
459
- const message = error instanceof Error ? error.message : String(error);
460
- if (error instanceof RawMessageValidationError) return createFailedReceipt(message, {
461
- provider: "jmap",
462
- code: "jmap.raw_message_invalid",
463
- category: "validation",
464
- retryable: false,
465
- attempts: 1
466
- });
467
- const definite = isRequestRejection(error);
468
- return failure(message, stage, stage !== "submission" && !definite, stage === "submission" && !definite);
469
- }
470
- }
471
- /** Validates raw discovery responses before accepting delivery identifiers. @internal */
472
- function rawDiscoveryResult(response, method, accountId) {
473
- const result = methodResult(response, method, "c0", accountId);
474
- if (result.name === "error") throw new JmapApiError(errorDescription(result.args));
475
- return result.args;
476
- }
477
-
478
- //#endregion
479
- //#region src/blob-uploader.ts
480
- /**
481
- * Upload a blob to the JMAP server.
482
- *
483
- * @param config - The resolved JMAP configuration
484
- * @param uploadUrl - The upload URL template from the session (e.g., "https://server/upload/{accountId}")
485
- * @param accountId - The account ID to upload to
486
- * @param blob - The blob or file to upload
487
- * @param signal - Optional abort signal
488
- * @returns The upload response containing the blobId
489
- */
490
- async function uploadBlob(config, uploadUrl, accountId, blob, signal) {
491
- signal?.throwIfAborted();
492
- const url = uploadUrl.replace("{accountId}", accountId);
493
- let authHeader;
494
- if (config.bearerToken) authHeader = `Bearer ${config.bearerToken}`;
495
- else if (config.basicAuth) {
496
- const credentials = btoa(`${config.basicAuth.username}:${config.basicAuth.password}`);
497
- authHeader = `Basic ${credentials}`;
498
- } else throw new Error("No authentication method configured");
499
- const headers = {
500
- Authorization: authHeader,
501
- "Content-Type": blob.type || "application/octet-stream"
502
- };
503
- for (const [key, value] of Object.entries(config.headers)) headers[key] = value;
504
- const controller = new AbortController();
505
- const timeoutId = setTimeout(() => controller.abort(), config.timeout);
506
- const combinedSignal = combineSignals(controller.signal, signal);
507
- try {
508
- const response = await fetch(url, {
509
- method: "POST",
510
- headers,
511
- body: blob,
512
- signal: combinedSignal.signal
513
- });
514
- if (!response.ok) {
515
- const body = await response.text();
516
- throw new JmapApiError(`Blob upload failed: ${response.status} ${response.statusText}`, response.status, body);
517
- }
518
- const result = await response.json();
519
- return result;
520
- } finally {
521
- combinedSignal.cleanup();
522
- clearTimeout(timeoutId);
523
- }
524
- }
525
-
526
- //#endregion
527
- //#region src/config.ts
528
- /**
529
- * Creates a resolved JMAP configuration with default values applied.
530
- * @param config The user-provided configuration.
531
- * @returns The resolved configuration with all fields populated.
532
- * @throws Error if neither bearerToken nor basicAuth is provided.
533
- * @since 0.4.0
534
- */
535
- function createJmapConfig(config) {
536
- if (!config.bearerToken && !config.basicAuth) throw new Error("Either bearerToken or basicAuth must be provided");
537
- return {
538
- sessionUrl: config.sessionUrl,
539
- bearerToken: config.bearerToken ?? null,
540
- basicAuth: config.basicAuth ?? null,
541
- accountId: config.accountId ?? null,
542
- identityId: config.identityId ?? null,
543
- timeout: config.timeout ?? 3e4,
544
- retries: config.retries ?? 3,
545
- headers: config.headers ?? {},
546
- sessionCacheTtl: config.sessionCacheTtl ?? 3e5,
547
- baseUrl: config.baseUrl ?? null
548
- };
549
- }
550
-
551
240
  //#endregion
552
241
  //#region src/message-converter.ts
553
242
  /**
@@ -960,45 +649,6 @@ var JmapTransport = class {
960
649
  }
961
650
  }
962
651
  /**
963
- * Uploads serialized MIME, imports it, and submits it with an explicit envelope.
964
- * JMAP servers may modify imported or submitted messages, including removing
965
- * Bcc. Import and submission are never retried automatically.
966
- * @param message Original MIME and delivery envelope.
967
- * @param options Optional cancellation signal.
968
- * @returns A receipt; uncertain submission outcomes are non-retryable.
969
- * @throws {Error} If cancellation is requested.
970
- * @since 0.6.0
971
- */
972
- async sendRaw(message, options) {
973
- if (message?.content instanceof Promise) message.content.catch(() => {});
974
- const signal = options?.signal;
975
- signal?.throwIfAborted();
976
- try {
977
- const plan = createRawMessagePlan(message);
978
- const session = await rawOperation(this.config.timeout, (owned) => this.getSession(owned), signal);
979
- const capable = (id) => {
980
- const account = session.accounts[id];
981
- return account != null && !account.isReadOnly && JMAP_CAPABILITIES.mail in account.accountCapabilities && JMAP_CAPABILITIES.submission in account.accountCapabilities;
982
- };
983
- const accountId = this.config.accountId ?? Object.keys(session.accounts).find(capable);
984
- 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, {
985
- category: "configuration",
986
- code: "jmap.no_mail_account",
987
- retryable: false
988
- });
989
- const drafts = await rawOperation(this.config.timeout, (owned) => this.getDraftsMailboxId(session, accountId, owned, true), signal);
990
- const identity = await rawOperation(this.config.timeout, (owned) => this.getIdentityId(session, accountId, plan.envelope.from ?? "", owned, true), signal);
991
- return await deliverRawMessage(this.config, session, accountId, drafts, identity, plan, signal);
992
- } catch (error) {
993
- signal?.throwIfAborted();
994
- return createJmapFailure(error instanceof Error ? error.message : String(error), error, error instanceof RawMessageValidationError ? {
995
- category: "validation",
996
- code: "jmap.raw_message_invalid",
997
- retryable: false
998
- } : void 0);
999
- }
1000
- }
1001
- /**
1002
652
  * Sends multiple messages in a single batched JMAP request.
1003
653
  * @param messages The messages to send.
1004
654
  * @param options Optional transport options.
@@ -1145,7 +795,7 @@ var JmapTransport = class {
1145
795
  * @returns The drafts mailbox ID.
1146
796
  * @since 0.4.0
1147
797
  */
1148
- async getDraftsMailboxId(session, accountId, signal, strict = false) {
798
+ async getDraftsMailboxId(session, accountId, signal) {
1149
799
  const response = await this.httpClient.executeRequest(session.apiUrl, {
1150
800
  using: [JMAP_CAPABILITIES.core, JMAP_CAPABILITIES.mail],
1151
801
  methodCalls: [[
@@ -1161,11 +811,10 @@ var JmapTransport = class {
1161
811
  "c0"
1162
812
  ]]
1163
813
  }, signal);
1164
- const mailboxResponse = strict ? rawDiscoveryResult(response, "Mailbox/get", accountId) : response.methodResponses.find((r) => r[0] === "Mailbox/get")?.[1];
814
+ const mailboxResponse = response.methodResponses.find((r) => r[0] === "Mailbox/get");
1165
815
  if (!mailboxResponse) throw new JmapApiError("No Mailbox/get response received");
1166
- const mailboxes = mailboxResponse.list;
816
+ const mailboxes = mailboxResponse[1].list;
1167
817
  if (!mailboxes) throw new JmapApiError("No mailboxes found");
1168
- 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.");
1169
818
  const drafts = mailboxes.find((m) => m.role === "drafts");
1170
819
  if (!drafts) throw new JmapApiError("No drafts mailbox found");
1171
820
  return drafts.id;
@@ -1179,9 +828,9 @@ var JmapTransport = class {
1179
828
  * @returns The identity ID.
1180
829
  * @since 0.4.0
1181
830
  */
1182
- async getIdentityId(session, accountId, senderEmail, signal, strict = false) {
831
+ async getIdentityId(session, accountId, senderEmail, signal) {
1183
832
  if (this.config.identityId) return this.config.identityId;
1184
- const identityMap = await this.getIdentityMap(session, accountId, signal, strict);
833
+ const identityMap = await this.getIdentityMap(session, accountId, signal);
1185
834
  const matching = identityMap.get(senderEmail.toLowerCase());
1186
835
  if (matching) return matching;
1187
836
  return identityMap.values().next().value;
@@ -1194,7 +843,7 @@ var JmapTransport = class {
1194
843
  * @returns Map of lowercase email to identity ID.
1195
844
  * @since 0.4.0
1196
845
  */
1197
- async getIdentityMap(session, accountId, signal, strict = false) {
846
+ async getIdentityMap(session, accountId, signal) {
1198
847
  if (this.config.identityId) return new Map([["*", this.config.identityId]]);
1199
848
  const response = await this.httpClient.executeRequest(session.apiUrl, {
1200
849
  using: [JMAP_CAPABILITIES.core, JMAP_CAPABILITIES.submission],
@@ -1204,11 +853,10 @@ var JmapTransport = class {
1204
853
  "c0"
1205
854
  ]]
1206
855
  }, signal);
1207
- const identityResponse = strict ? rawDiscoveryResult(response, "Identity/get", accountId) : response.methodResponses.find((r) => r[0] === "Identity/get")?.[1];
856
+ const identityResponse = response.methodResponses.find((r) => r[0] === "Identity/get");
1208
857
  if (!identityResponse) throw new JmapApiError("No Identity/get response received");
1209
- const identities = identityResponse.list;
858
+ const identities = identityResponse[1].list;
1210
859
  if (!identities || identities.length === 0) throw new JmapApiError("No identities found");
1211
- 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.");
1212
860
  const identityMap = /* @__PURE__ */ new Map();
1213
861
  for (const identity of identities) identityMap.set(identity.email.toLowerCase(), identity.id);
1214
862
  return identityMap;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@upyo/jmap",
3
- "version": "0.6.0-dev.351",
3
+ "version": "0.6.0-dev.353",
4
4
  "description": "JMAP transport for Upyo email library",
5
5
  "keywords": [
6
6
  "email",
@@ -53,7 +53,7 @@
53
53
  },
54
54
  "sideEffects": false,
55
55
  "peerDependencies": {
56
- "@upyo/core": "0.6.0-dev.351+b257f1a6"
56
+ "@upyo/core": "0.6.0-dev.353+671a798b"
57
57
  },
58
58
  "devDependencies": {
59
59
  "jmap-rfc-types": "^0.1.2",