@upyo/jmap 0.6.0-dev.349 → 0.6.0-dev.351
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 +63 -0
- package/dist/index.cjs +437 -85
- package/dist/index.d.cts +13 -2
- package/dist/index.d.ts +13 -2
- package/dist/index.js +438 -86
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -133,3 +133,66 @@ 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,79 +66,6 @@ function isCapabilityError(error) {
|
|
|
66
66
|
return error instanceof JmapApiError && error.jmapErrorType === JMAP_ERROR_TYPES.unknownCapability;
|
|
67
67
|
}
|
|
68
68
|
|
|
69
|
-
//#endregion
|
|
70
|
-
//#region src/blob-uploader.ts
|
|
71
|
-
/**
|
|
72
|
-
* Upload a blob to the JMAP server.
|
|
73
|
-
*
|
|
74
|
-
* @param config - The resolved JMAP configuration
|
|
75
|
-
* @param uploadUrl - The upload URL template from the session (e.g., "https://server/upload/{accountId}")
|
|
76
|
-
* @param accountId - The account ID to upload to
|
|
77
|
-
* @param blob - The blob or file to upload
|
|
78
|
-
* @param signal - Optional abort signal
|
|
79
|
-
* @returns The upload response containing the blobId
|
|
80
|
-
*/
|
|
81
|
-
async function uploadBlob(config, uploadUrl, accountId, blob, signal) {
|
|
82
|
-
signal?.throwIfAborted();
|
|
83
|
-
const url = uploadUrl.replace("{accountId}", accountId);
|
|
84
|
-
let authHeader;
|
|
85
|
-
if (config.bearerToken) authHeader = `Bearer ${config.bearerToken}`;
|
|
86
|
-
else if (config.basicAuth) {
|
|
87
|
-
const credentials = btoa(`${config.basicAuth.username}:${config.basicAuth.password}`);
|
|
88
|
-
authHeader = `Basic ${credentials}`;
|
|
89
|
-
} else throw new Error("No authentication method configured");
|
|
90
|
-
const headers = {
|
|
91
|
-
Authorization: authHeader,
|
|
92
|
-
"Content-Type": blob.type || "application/octet-stream"
|
|
93
|
-
};
|
|
94
|
-
for (const [key, value] of Object.entries(config.headers)) headers[key] = value;
|
|
95
|
-
const controller = new AbortController();
|
|
96
|
-
const timeoutId = setTimeout(() => controller.abort(), config.timeout);
|
|
97
|
-
const combinedSignal = (0, __upyo_core.combineSignals)(controller.signal, signal);
|
|
98
|
-
try {
|
|
99
|
-
const response = await fetch(url, {
|
|
100
|
-
method: "POST",
|
|
101
|
-
headers,
|
|
102
|
-
body: blob,
|
|
103
|
-
signal: combinedSignal.signal
|
|
104
|
-
});
|
|
105
|
-
if (!response.ok) {
|
|
106
|
-
const body = await response.text();
|
|
107
|
-
throw new JmapApiError(`Blob upload failed: ${response.status} ${response.statusText}`, response.status, body);
|
|
108
|
-
}
|
|
109
|
-
const result = await response.json();
|
|
110
|
-
return result;
|
|
111
|
-
} finally {
|
|
112
|
-
combinedSignal.cleanup();
|
|
113
|
-
clearTimeout(timeoutId);
|
|
114
|
-
}
|
|
115
|
-
}
|
|
116
|
-
|
|
117
|
-
//#endregion
|
|
118
|
-
//#region src/config.ts
|
|
119
|
-
/**
|
|
120
|
-
* Creates a resolved JMAP configuration with default values applied.
|
|
121
|
-
* @param config The user-provided configuration.
|
|
122
|
-
* @returns The resolved configuration with all fields populated.
|
|
123
|
-
* @throws Error if neither bearerToken nor basicAuth is provided.
|
|
124
|
-
* @since 0.4.0
|
|
125
|
-
*/
|
|
126
|
-
function createJmapConfig(config) {
|
|
127
|
-
if (!config.bearerToken && !config.basicAuth) throw new Error("Either bearerToken or basicAuth must be provided");
|
|
128
|
-
return {
|
|
129
|
-
sessionUrl: config.sessionUrl,
|
|
130
|
-
bearerToken: config.bearerToken ?? null,
|
|
131
|
-
basicAuth: config.basicAuth ?? null,
|
|
132
|
-
accountId: config.accountId ?? null,
|
|
133
|
-
identityId: config.identityId ?? null,
|
|
134
|
-
timeout: config.timeout ?? 3e4,
|
|
135
|
-
retries: config.retries ?? 3,
|
|
136
|
-
headers: config.headers ?? {},
|
|
137
|
-
sessionCacheTtl: config.sessionCacheTtl ?? 3e5,
|
|
138
|
-
baseUrl: config.baseUrl ?? null
|
|
139
|
-
};
|
|
140
|
-
}
|
|
141
|
-
|
|
142
69
|
//#endregion
|
|
143
70
|
//#region src/http-client.ts
|
|
144
71
|
/**
|
|
@@ -147,7 +74,12 @@ function createJmapConfig(config) {
|
|
|
147
74
|
*/
|
|
148
75
|
var JmapHttpClient = class {
|
|
149
76
|
config;
|
|
150
|
-
|
|
77
|
+
/**
|
|
78
|
+
* @param config HTTP and authentication settings.
|
|
79
|
+
* @param replayableRequests Whether request bodies and failed requests may be replayed.
|
|
80
|
+
*/
|
|
81
|
+
constructor(config, replayableRequests = true) {
|
|
82
|
+
this.replayableRequests = replayableRequests;
|
|
151
83
|
this.config = config;
|
|
152
84
|
}
|
|
153
85
|
/**
|
|
@@ -176,13 +108,21 @@ var JmapHttpClient = class {
|
|
|
176
108
|
async executeRequest(apiUrl, request, signal) {
|
|
177
109
|
signal?.throwIfAborted();
|
|
178
110
|
let lastError = null;
|
|
179
|
-
|
|
111
|
+
const retries = this.replayableRequests ? this.config.retries : 0;
|
|
112
|
+
for (let attempt = 0; attempt <= retries; attempt++) {
|
|
180
113
|
signal?.throwIfAborted();
|
|
181
114
|
try {
|
|
182
115
|
const response = await this.fetchWithAuth(apiUrl, {
|
|
183
116
|
method: "POST",
|
|
184
117
|
headers: { "Content-Type": "application/json" },
|
|
185
|
-
body: JSON.stringify(request)
|
|
118
|
+
body: this.replayableRequests ? JSON.stringify(request) : new ReadableStream({ start(controller) {
|
|
119
|
+
controller.enqueue(new TextEncoder().encode(JSON.stringify(request)));
|
|
120
|
+
controller.close();
|
|
121
|
+
} }),
|
|
122
|
+
...this.replayableRequests ? {} : {
|
|
123
|
+
duplex: "half",
|
|
124
|
+
redirect: "error"
|
|
125
|
+
}
|
|
186
126
|
}, signal);
|
|
187
127
|
if (!response.ok) {
|
|
188
128
|
const text = await response.text();
|
|
@@ -197,7 +137,7 @@ var JmapHttpClient = class {
|
|
|
197
137
|
if (error.statusCode >= 400 && error.statusCode < 500) throw error;
|
|
198
138
|
}
|
|
199
139
|
lastError = error instanceof Error ? error : new Error(String(error));
|
|
200
|
-
if (attempt ===
|
|
140
|
+
if (attempt === retries) {
|
|
201
141
|
if (error instanceof JmapApiError) throw error;
|
|
202
142
|
if (isAbortError$1(error)) throw new JmapApiError("JMAP request timed out.", void 0, void 0, void 0, void 0, attempt + 1);
|
|
203
143
|
throw new JmapApiError(lastError.message, void 0, void 0, void 0, void 0, attempt + 1);
|
|
@@ -260,6 +200,377 @@ function isAbortError$1(error) {
|
|
|
260
200
|
return error instanceof Error && error.name === "AbortError";
|
|
261
201
|
}
|
|
262
202
|
|
|
203
|
+
//#endregion
|
|
204
|
+
//#region src/raw-upload.ts
|
|
205
|
+
/** Runs a raw operation with a progress-resettable deadline through response parsing. @internal */
|
|
206
|
+
async function rawOperation(timeout, operation, signal) {
|
|
207
|
+
const controller = new AbortController();
|
|
208
|
+
const combined = (0, __upyo_core.combineSignals)(controller.signal, signal);
|
|
209
|
+
let timer;
|
|
210
|
+
let finished = false;
|
|
211
|
+
const progress = () => {
|
|
212
|
+
if (finished) return;
|
|
213
|
+
clearTimeout(timer);
|
|
214
|
+
timer = setTimeout(() => controller.abort(new JmapApiError("Raw JMAP operation timed out.")), timeout);
|
|
215
|
+
};
|
|
216
|
+
let abort;
|
|
217
|
+
try {
|
|
218
|
+
combined.signal.throwIfAborted();
|
|
219
|
+
progress();
|
|
220
|
+
return await new Promise((resolve, reject) => {
|
|
221
|
+
abort = () => reject(combined.signal.reason);
|
|
222
|
+
combined.signal.addEventListener("abort", abort, { once: true });
|
|
223
|
+
Promise.resolve().then(() => {
|
|
224
|
+
combined.signal.throwIfAborted();
|
|
225
|
+
return operation(combined.signal, progress);
|
|
226
|
+
}).then(resolve, reject);
|
|
227
|
+
if (combined.signal.aborted) abort();
|
|
228
|
+
});
|
|
229
|
+
} finally {
|
|
230
|
+
finished = true;
|
|
231
|
+
clearTimeout(timer);
|
|
232
|
+
if (abort) combined.signal.removeEventListener("abort", abort);
|
|
233
|
+
controller.abort();
|
|
234
|
+
combined.cleanup();
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
/** @internal */
|
|
238
|
+
function isRecord(value) {
|
|
239
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
240
|
+
}
|
|
241
|
+
/** Streams validated MIME bytes without relying on fetch to cancel its body. @internal */
|
|
242
|
+
async function uploadRawMessage(config, uploadUrl, accountId, plan, signal) {
|
|
243
|
+
return await rawOperation(config.timeout, async (outerSignal, progress) => {
|
|
244
|
+
const analysis = plan.encoding === void 0 ? await (0, __upyo_core.analyzeRawMessage)(plan, outerSignal, progress) : {
|
|
245
|
+
encoding: plan.encoding,
|
|
246
|
+
size: plan.size
|
|
247
|
+
};
|
|
248
|
+
outerSignal.throwIfAborted();
|
|
249
|
+
const controller = new AbortController();
|
|
250
|
+
const combined = (0, __upyo_core.combineSignals)(controller.signal, outerSignal);
|
|
251
|
+
const owned = combined.signal;
|
|
252
|
+
const iterator = (0, __upyo_core.iterateRawMessage)(plan, {
|
|
253
|
+
signal: owned,
|
|
254
|
+
encoding: analysis.encoding,
|
|
255
|
+
expectedSize: analysis.size,
|
|
256
|
+
onProgress: progress
|
|
257
|
+
})[Symbol.asyncIterator]();
|
|
258
|
+
let closed = false;
|
|
259
|
+
let eof = false;
|
|
260
|
+
let size = 0;
|
|
261
|
+
let sourceError;
|
|
262
|
+
let hasSourceError = false;
|
|
263
|
+
let bodyController;
|
|
264
|
+
const close = (reason) => {
|
|
265
|
+
if (closed) return;
|
|
266
|
+
closed = true;
|
|
267
|
+
controller.abort(reason);
|
|
268
|
+
if (!eof) try {
|
|
269
|
+
Promise.resolve(iterator.return?.()).catch(() => {});
|
|
270
|
+
} catch {}
|
|
271
|
+
};
|
|
272
|
+
const abort = () => {
|
|
273
|
+
try {
|
|
274
|
+
bodyController?.error(owned.reason);
|
|
275
|
+
} catch {}
|
|
276
|
+
close(owned.reason);
|
|
277
|
+
};
|
|
278
|
+
const body = new ReadableStream({
|
|
279
|
+
start(stream) {
|
|
280
|
+
bodyController = stream;
|
|
281
|
+
owned.addEventListener("abort", abort, { once: true });
|
|
282
|
+
if (owned.aborted) abort();
|
|
283
|
+
},
|
|
284
|
+
async pull(stream) {
|
|
285
|
+
try {
|
|
286
|
+
owned.throwIfAborted();
|
|
287
|
+
const item = await iterator.next();
|
|
288
|
+
owned.throwIfAborted();
|
|
289
|
+
if (item.done) {
|
|
290
|
+
eof = true;
|
|
291
|
+
progress();
|
|
292
|
+
stream.close();
|
|
293
|
+
} else {
|
|
294
|
+
size += item.value.length;
|
|
295
|
+
stream.enqueue(new Uint8Array(item.value));
|
|
296
|
+
}
|
|
297
|
+
} catch (error) {
|
|
298
|
+
if (!owned.aborted && !hasSourceError) {
|
|
299
|
+
hasSourceError = true;
|
|
300
|
+
sourceError = error;
|
|
301
|
+
}
|
|
302
|
+
try {
|
|
303
|
+
stream.error(error);
|
|
304
|
+
} catch {}
|
|
305
|
+
close(error);
|
|
306
|
+
}
|
|
307
|
+
},
|
|
308
|
+
cancel: close
|
|
309
|
+
}, { highWaterMark: 0 });
|
|
310
|
+
const headers = new Headers(config.headers);
|
|
311
|
+
if (!headers.has("Authorization")) {
|
|
312
|
+
if (config.bearerToken) headers.set("Authorization", `Bearer ${config.bearerToken}`);
|
|
313
|
+
else if (config.basicAuth) headers.set("Authorization", `Basic ${btoa(`${config.basicAuth.username}:${config.basicAuth.password}`)}`);
|
|
314
|
+
}
|
|
315
|
+
headers.set("Content-Type", "message/rfc822");
|
|
316
|
+
const request = {
|
|
317
|
+
method: "POST",
|
|
318
|
+
headers,
|
|
319
|
+
body,
|
|
320
|
+
duplex: "half",
|
|
321
|
+
redirect: "error",
|
|
322
|
+
signal: 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
|
+
|
|
263
574
|
//#endregion
|
|
264
575
|
//#region src/message-converter.ts
|
|
265
576
|
/**
|
|
@@ -672,6 +983,45 @@ var JmapTransport = class {
|
|
|
672
983
|
}
|
|
673
984
|
}
|
|
674
985
|
/**
|
|
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
|
+
/**
|
|
675
1025
|
* Sends multiple messages in a single batched JMAP request.
|
|
676
1026
|
* @param messages The messages to send.
|
|
677
1027
|
* @param options Optional transport options.
|
|
@@ -818,7 +1168,7 @@ var JmapTransport = class {
|
|
|
818
1168
|
* @returns The drafts mailbox ID.
|
|
819
1169
|
* @since 0.4.0
|
|
820
1170
|
*/
|
|
821
|
-
async getDraftsMailboxId(session, accountId, signal) {
|
|
1171
|
+
async getDraftsMailboxId(session, accountId, signal, strict = false) {
|
|
822
1172
|
const response = await this.httpClient.executeRequest(session.apiUrl, {
|
|
823
1173
|
using: [JMAP_CAPABILITIES.core, JMAP_CAPABILITIES.mail],
|
|
824
1174
|
methodCalls: [[
|
|
@@ -834,10 +1184,11 @@ var JmapTransport = class {
|
|
|
834
1184
|
"c0"
|
|
835
1185
|
]]
|
|
836
1186
|
}, signal);
|
|
837
|
-
const mailboxResponse = response.methodResponses.find((r) => r[0] === "Mailbox/get");
|
|
1187
|
+
const mailboxResponse = strict ? rawDiscoveryResult(response, "Mailbox/get", accountId) : response.methodResponses.find((r) => r[0] === "Mailbox/get")?.[1];
|
|
838
1188
|
if (!mailboxResponse) throw new JmapApiError("No Mailbox/get response received");
|
|
839
|
-
const mailboxes = mailboxResponse
|
|
1189
|
+
const mailboxes = mailboxResponse.list;
|
|
840
1190
|
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.");
|
|
841
1192
|
const drafts = mailboxes.find((m) => m.role === "drafts");
|
|
842
1193
|
if (!drafts) throw new JmapApiError("No drafts mailbox found");
|
|
843
1194
|
return drafts.id;
|
|
@@ -851,9 +1202,9 @@ var JmapTransport = class {
|
|
|
851
1202
|
* @returns The identity ID.
|
|
852
1203
|
* @since 0.4.0
|
|
853
1204
|
*/
|
|
854
|
-
async getIdentityId(session, accountId, senderEmail, signal) {
|
|
1205
|
+
async getIdentityId(session, accountId, senderEmail, signal, strict = false) {
|
|
855
1206
|
if (this.config.identityId) return this.config.identityId;
|
|
856
|
-
const identityMap = await this.getIdentityMap(session, accountId, signal);
|
|
1207
|
+
const identityMap = await this.getIdentityMap(session, accountId, signal, strict);
|
|
857
1208
|
const matching = identityMap.get(senderEmail.toLowerCase());
|
|
858
1209
|
if (matching) return matching;
|
|
859
1210
|
return identityMap.values().next().value;
|
|
@@ -866,7 +1217,7 @@ var JmapTransport = class {
|
|
|
866
1217
|
* @returns Map of lowercase email to identity ID.
|
|
867
1218
|
* @since 0.4.0
|
|
868
1219
|
*/
|
|
869
|
-
async getIdentityMap(session, accountId, signal) {
|
|
1220
|
+
async getIdentityMap(session, accountId, signal, strict = false) {
|
|
870
1221
|
if (this.config.identityId) return new Map([["*", this.config.identityId]]);
|
|
871
1222
|
const response = await this.httpClient.executeRequest(session.apiUrl, {
|
|
872
1223
|
using: [JMAP_CAPABILITIES.core, JMAP_CAPABILITIES.submission],
|
|
@@ -876,10 +1227,11 @@ var JmapTransport = class {
|
|
|
876
1227
|
"c0"
|
|
877
1228
|
]]
|
|
878
1229
|
}, signal);
|
|
879
|
-
const identityResponse = response.methodResponses.find((r) => r[0] === "Identity/get");
|
|
1230
|
+
const identityResponse = strict ? rawDiscoveryResult(response, "Identity/get", accountId) : response.methodResponses.find((r) => r[0] === "Identity/get")?.[1];
|
|
880
1231
|
if (!identityResponse) throw new JmapApiError("No Identity/get response received");
|
|
881
|
-
const identities = identityResponse
|
|
1232
|
+
const identities = identityResponse.list;
|
|
882
1233
|
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.");
|
|
883
1235
|
const identityMap = /* @__PURE__ */ new Map();
|
|
884
1236
|
for (const identity of identities) identityMap.set(identity.email.toLowerCase(), identity.id);
|
|
885
1237
|
return identityMap;
|
package/dist/index.d.cts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { Message,
|
|
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
|
|
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,
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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 ===
|
|
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,377 @@ 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: 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
|
+
|
|
240
551
|
//#endregion
|
|
241
552
|
//#region src/message-converter.ts
|
|
242
553
|
/**
|
|
@@ -649,6 +960,45 @@ var JmapTransport = class {
|
|
|
649
960
|
}
|
|
650
961
|
}
|
|
651
962
|
/**
|
|
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
|
+
/**
|
|
652
1002
|
* Sends multiple messages in a single batched JMAP request.
|
|
653
1003
|
* @param messages The messages to send.
|
|
654
1004
|
* @param options Optional transport options.
|
|
@@ -795,7 +1145,7 @@ var JmapTransport = class {
|
|
|
795
1145
|
* @returns The drafts mailbox ID.
|
|
796
1146
|
* @since 0.4.0
|
|
797
1147
|
*/
|
|
798
|
-
async getDraftsMailboxId(session, accountId, signal) {
|
|
1148
|
+
async getDraftsMailboxId(session, accountId, signal, strict = false) {
|
|
799
1149
|
const response = await this.httpClient.executeRequest(session.apiUrl, {
|
|
800
1150
|
using: [JMAP_CAPABILITIES.core, JMAP_CAPABILITIES.mail],
|
|
801
1151
|
methodCalls: [[
|
|
@@ -811,10 +1161,11 @@ var JmapTransport = class {
|
|
|
811
1161
|
"c0"
|
|
812
1162
|
]]
|
|
813
1163
|
}, signal);
|
|
814
|
-
const mailboxResponse = response.methodResponses.find((r) => r[0] === "Mailbox/get");
|
|
1164
|
+
const mailboxResponse = strict ? rawDiscoveryResult(response, "Mailbox/get", accountId) : response.methodResponses.find((r) => r[0] === "Mailbox/get")?.[1];
|
|
815
1165
|
if (!mailboxResponse) throw new JmapApiError("No Mailbox/get response received");
|
|
816
|
-
const mailboxes = mailboxResponse
|
|
1166
|
+
const mailboxes = mailboxResponse.list;
|
|
817
1167
|
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.");
|
|
818
1169
|
const drafts = mailboxes.find((m) => m.role === "drafts");
|
|
819
1170
|
if (!drafts) throw new JmapApiError("No drafts mailbox found");
|
|
820
1171
|
return drafts.id;
|
|
@@ -828,9 +1179,9 @@ var JmapTransport = class {
|
|
|
828
1179
|
* @returns The identity ID.
|
|
829
1180
|
* @since 0.4.0
|
|
830
1181
|
*/
|
|
831
|
-
async getIdentityId(session, accountId, senderEmail, signal) {
|
|
1182
|
+
async getIdentityId(session, accountId, senderEmail, signal, strict = false) {
|
|
832
1183
|
if (this.config.identityId) return this.config.identityId;
|
|
833
|
-
const identityMap = await this.getIdentityMap(session, accountId, signal);
|
|
1184
|
+
const identityMap = await this.getIdentityMap(session, accountId, signal, strict);
|
|
834
1185
|
const matching = identityMap.get(senderEmail.toLowerCase());
|
|
835
1186
|
if (matching) return matching;
|
|
836
1187
|
return identityMap.values().next().value;
|
|
@@ -843,7 +1194,7 @@ var JmapTransport = class {
|
|
|
843
1194
|
* @returns Map of lowercase email to identity ID.
|
|
844
1195
|
* @since 0.4.0
|
|
845
1196
|
*/
|
|
846
|
-
async getIdentityMap(session, accountId, signal) {
|
|
1197
|
+
async getIdentityMap(session, accountId, signal, strict = false) {
|
|
847
1198
|
if (this.config.identityId) return new Map([["*", this.config.identityId]]);
|
|
848
1199
|
const response = await this.httpClient.executeRequest(session.apiUrl, {
|
|
849
1200
|
using: [JMAP_CAPABILITIES.core, JMAP_CAPABILITIES.submission],
|
|
@@ -853,10 +1204,11 @@ var JmapTransport = class {
|
|
|
853
1204
|
"c0"
|
|
854
1205
|
]]
|
|
855
1206
|
}, signal);
|
|
856
|
-
const identityResponse = response.methodResponses.find((r) => r[0] === "Identity/get");
|
|
1207
|
+
const identityResponse = strict ? rawDiscoveryResult(response, "Identity/get", accountId) : response.methodResponses.find((r) => r[0] === "Identity/get")?.[1];
|
|
857
1208
|
if (!identityResponse) throw new JmapApiError("No Identity/get response received");
|
|
858
|
-
const identities = identityResponse
|
|
1209
|
+
const identities = identityResponse.list;
|
|
859
1210
|
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.");
|
|
860
1212
|
const identityMap = /* @__PURE__ */ new Map();
|
|
861
1213
|
for (const identity of identities) identityMap.set(identity.email.toLowerCase(), identity.id);
|
|
862
1214
|
return identityMap;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@upyo/jmap",
|
|
3
|
-
"version": "0.6.0-dev.
|
|
3
|
+
"version": "0.6.0-dev.351",
|
|
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.
|
|
56
|
+
"@upyo/core": "0.6.0-dev.351+b257f1a6"
|
|
57
57
|
},
|
|
58
58
|
"devDependencies": {
|
|
59
59
|
"jmap-rfc-types": "^0.1.2",
|