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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.cjs CHANGED
@@ -143,7 +143,22 @@ var JmapHttpClient = class {
143
143
  throw new JmapApiError(lastError.message, void 0, void 0, void 0, void 0, attempt + 1);
144
144
  }
145
145
  const delay = Math.pow(2, attempt) * 1e3;
146
- await new Promise((resolve) => setTimeout(resolve, delay));
146
+ await new Promise((resolve, reject) => {
147
+ const cleanup = () => {
148
+ clearTimeout(timer);
149
+ signal?.removeEventListener("abort", onAbort);
150
+ };
151
+ const onAbort = () => {
152
+ cleanup();
153
+ reject(signal?.reason);
154
+ };
155
+ const timer = setTimeout(() => {
156
+ cleanup();
157
+ resolve();
158
+ }, delay);
159
+ signal?.addEventListener("abort", onAbort, { once: true });
160
+ if (signal?.aborted) onAbort();
161
+ });
147
162
  }
148
163
  }
149
164
  if (lastError != null) throw lastError;
@@ -925,6 +940,60 @@ var JmapTransport = class {
925
940
  this.httpClient = new JmapHttpClient(this.config);
926
941
  }
927
942
  /**
943
+ * Checks a fresh Session, the account used by send(), its drafts mailbox,
944
+ * and the configured or available identity without creating or sending mail.
945
+ * Does not read or update the session cache. Each discovery operation has
946
+ * config.timeout as its total budget, including response bodies and retries.
947
+ * Success does not guarantee acceptance of a particular sender or message.
948
+ * @param options Optional cancellation signal.
949
+ * @returns A promise that resolves when live verification succeeds.
950
+ * @throws {JmapApiError} If discovery, authentication, or validation fails.
951
+ * @throws The caller's abort reason if cancelled.
952
+ * @since 0.6.0
953
+ */
954
+ async verify(options) {
955
+ const signal = options?.signal;
956
+ signal?.throwIfAborted();
957
+ try {
958
+ let session = await verificationOperation(this.config.timeout, (owned) => this.httpClient.fetchSession(owned), signal);
959
+ validateVerificationSession(session);
960
+ if (this.config.baseUrl) session = this.rewriteSessionUrls(session);
961
+ validateVerificationApiUrl(session.apiUrl);
962
+ if (!Object.values(JMAP_CAPABILITIES).every((cap) => cap in session.capabilities)) throw new JmapApiError("JMAP session lacks core, mail, or submission capability.");
963
+ const accountId = this.config.accountId ?? findMailAccount(session);
964
+ const account = accountId == null ? void 0 : session.accounts[accountId];
965
+ if (accountId == null || account == null) throw new JmapApiError(`No JMAP mail account found: ${accountId ?? "automatic selection"}`);
966
+ if (account.isReadOnly || !(JMAP_CAPABILITIES.mail in account.accountCapabilities) || !(JMAP_CAPABILITIES.submission in account.accountCapabilities)) throw new JmapApiError(`JMAP account must be writable with mail and submission capability: ${accountId}`);
967
+ const get = async (method, args, signal$1) => {
968
+ const response = await verificationOperation(this.config.timeout, (owned) => this.httpClient.executeRequest(session.apiUrl, {
969
+ using: method === "Mailbox/get" ? [JMAP_CAPABILITIES.core, JMAP_CAPABILITIES.mail] : [JMAP_CAPABILITIES.core, JMAP_CAPABILITIES.submission],
970
+ methodCalls: [[
971
+ method,
972
+ {
973
+ accountId,
974
+ ...args
975
+ },
976
+ "verify"
977
+ ]]
978
+ }, owned), signal$1);
979
+ return verificationResult(response, method, accountId);
980
+ };
981
+ const mailboxes = await get("Mailbox/get", { properties: ["id", "role"] }, signal);
982
+ if (!mailboxes.list.some((mailbox) => mailbox.role === "drafts")) throw new JmapApiError(`No drafts mailbox found in JMAP account: ${accountId}`);
983
+ const identityId = this.config.identityId;
984
+ const identities = await get("Identity/get", {
985
+ ids: identityId == null ? null : [identityId],
986
+ properties: ["id", "email"]
987
+ }, signal);
988
+ if (identities.notFound.length > 0 || identities.list.length === 0 || !identities.list.every((identity) => typeof identity.email === "string" && identity.email.length > 0) || identityId != null && (identities.list.length !== 1 || identities.list[0].id !== identityId)) throw new JmapApiError(`No valid JMAP identity found: ${identityId ?? accountId}`);
989
+ signal?.throwIfAborted();
990
+ } catch (error) {
991
+ signal?.throwIfAborted();
992
+ if (error instanceof JmapApiError) throw error;
993
+ throw new JmapApiError(`JMAP verification failed: ${error instanceof Error ? error.message : String(error)}`);
994
+ }
995
+ }
996
+ /**
928
997
  * Sends a single email message.
929
998
  * @param message The message to send.
930
999
  * @param options Optional transport options.
@@ -1370,6 +1439,76 @@ function getAbortReason(signal, fallback) {
1370
1439
  function isAbortError(error) {
1371
1440
  return error instanceof Error && error.name === "AbortError";
1372
1441
  }
1442
+ /** Bounds verification through body consumption, not just receipt of headers. */
1443
+ async function verificationOperation(timeout, operation, signal) {
1444
+ const controller = new AbortController();
1445
+ const combined = (0, __upyo_core.combineSignals)(controller.signal, signal);
1446
+ let onAbort;
1447
+ const timer = setTimeout(() => controller.abort(new JmapApiError("JMAP verification timed out.")), timeout);
1448
+ try {
1449
+ combined.signal.throwIfAborted();
1450
+ return await new Promise((resolve, reject) => {
1451
+ onAbort = () => reject(combined.signal.reason);
1452
+ combined.signal.addEventListener("abort", onAbort, { once: true });
1453
+ Promise.resolve().then(() => {
1454
+ combined.signal.throwIfAborted();
1455
+ return operation(combined.signal);
1456
+ }).then(resolve, reject);
1457
+ if (combined.signal.aborted) onAbort();
1458
+ });
1459
+ } finally {
1460
+ clearTimeout(timer);
1461
+ if (onAbort) combined.signal.removeEventListener("abort", onAbort);
1462
+ controller.abort();
1463
+ combined.cleanup();
1464
+ }
1465
+ }
1466
+ function validateVerificationApiUrl(value) {
1467
+ if (typeof value === "string" && URL.canParse(value)) {
1468
+ const url = new URL(value);
1469
+ if (url.protocol === "https:" || url.protocol === "http:") return;
1470
+ }
1471
+ throw new JmapApiError("Invalid JMAP Session field: apiUrl");
1472
+ }
1473
+ function validateVerificationSession(session) {
1474
+ if (!isRecord(session)) throw new JmapApiError("Invalid JMAP Session response.");
1475
+ if (!isRecord(session.capabilities)) throw new JmapApiError("Invalid JMAP Session field: capabilities");
1476
+ if (!isRecord(session.accounts)) throw new JmapApiError("Invalid JMAP Session field: accounts");
1477
+ for (const [id, account] of Object.entries(session.accounts)) if (!isRecord(account) || typeof account.isReadOnly !== "boolean" || !isRecord(account.accountCapabilities)) throw new JmapApiError(`Invalid JMAP Session account: ${id}`);
1478
+ validateVerificationApiUrl(session.apiUrl);
1479
+ }
1480
+ /** Validates only the discovery fields used by verification. */
1481
+ function verificationResult(response, method, accountId) {
1482
+ if (!isRecord(response) || !Array.isArray(response.methodResponses)) throw new JmapApiError("Invalid JMAP verification response.");
1483
+ const matches = response.methodResponses.filter((entry$1) => Array.isArray(entry$1) && entry$1[2] === "verify");
1484
+ if (matches.length !== 1) throw new JmapApiError("Missing or duplicate JMAP verification response.");
1485
+ const entry = matches[0];
1486
+ if (!Array.isArray(entry) || entry.length !== 3 || !isRecord(entry[1])) throw new JmapApiError("Invalid JMAP verification method response.");
1487
+ const result = entry[1];
1488
+ if (entry[0] === "error") {
1489
+ if (typeof result.type !== "string" || !result.type) throw new JmapApiError("Invalid JMAP method error.");
1490
+ throw new JmapApiError(typeof result.description === "string" ? result.description : `JMAP method failed: ${result.type}`, void 0, void 0, result.type);
1491
+ }
1492
+ if (entry[0] !== method || result.accountId !== accountId || !Array.isArray(result.list) || !Array.isArray(result.notFound)) throw new JmapApiError(`Invalid JMAP discovery result: ${method}`);
1493
+ const list = [];
1494
+ const ids = /* @__PURE__ */ new Set();
1495
+ for (const item of result.list) {
1496
+ if (!isRecord(item) || typeof item.id !== "string" || !item.id || ids.has(item.id)) throw new JmapApiError(`Invalid JMAP discovery identifiers: ${method}`);
1497
+ ids.add(item.id);
1498
+ list.push(item);
1499
+ }
1500
+ const notFound = [];
1501
+ for (const id of result.notFound) {
1502
+ if (typeof id !== "string" || !id || ids.has(id)) throw new JmapApiError(`Contradictory JMAP discovery identifiers: ${method}`);
1503
+ ids.add(id);
1504
+ notFound.push(id);
1505
+ }
1506
+ if (method === "Mailbox/get" && (notFound.length > 0 || !list.every((item) => item.role == null || typeof item.role === "string"))) throw new JmapApiError("Invalid JMAP mailbox discovery result.");
1507
+ return {
1508
+ list,
1509
+ notFound
1510
+ };
1511
+ }
1373
1512
 
1374
1513
  //#endregion
1375
1514
  exports.JMAP_ERROR_TYPES = JMAP_ERROR_TYPES;
package/dist/index.d.cts CHANGED
@@ -1,4 +1,4 @@
1
- import { Message, RawMessage, RawTransport, Receipt, TransportOptions } from "@upyo/core";
1
+ import { Message, RawMessage, RawTransport, Receipt, TransportOptions, VerifiableTransport } 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 RawTransport<"jmap">, VerifiableTransport<"jmap"> {
107
107
  readonly id = "jmap";
108
108
  readonly config: ResolvedJmapConfig;
109
109
  private readonly httpClient;
@@ -114,6 +114,19 @@ declare class JmapTransport implements RawTransport<"jmap"> {
114
114
  * @since 0.4.0
115
115
  */
116
116
  constructor(config: JmapConfig);
117
+ /**
118
+ * Checks a fresh Session, the account used by send(), its drafts mailbox,
119
+ * and the configured or available identity without creating or sending mail.
120
+ * Does not read or update the session cache. Each discovery operation has
121
+ * config.timeout as its total budget, including response bodies and retries.
122
+ * Success does not guarantee acceptance of a particular sender or message.
123
+ * @param options Optional cancellation signal.
124
+ * @returns A promise that resolves when live verification succeeds.
125
+ * @throws {JmapApiError} If discovery, authentication, or validation fails.
126
+ * @throws The caller's abort reason if cancelled.
127
+ * @since 0.6.0
128
+ */
129
+ verify(options?: TransportOptions): Promise<void>;
117
130
  /**
118
131
  * Sends a single email message.
119
132
  * @param message The message 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, RawMessage, RawTransport, Receipt, TransportOptions, VerifiableTransport } 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 RawTransport<"jmap">, VerifiableTransport<"jmap"> {
107
107
  readonly id = "jmap";
108
108
  readonly config: ResolvedJmapConfig;
109
109
  private readonly httpClient;
@@ -114,6 +114,19 @@ declare class JmapTransport implements RawTransport<"jmap"> {
114
114
  * @since 0.4.0
115
115
  */
116
116
  constructor(config: JmapConfig);
117
+ /**
118
+ * Checks a fresh Session, the account used by send(), its drafts mailbox,
119
+ * and the configured or available identity without creating or sending mail.
120
+ * Does not read or update the session cache. Each discovery operation has
121
+ * config.timeout as its total budget, including response bodies and retries.
122
+ * Success does not guarantee acceptance of a particular sender or message.
123
+ * @param options Optional cancellation signal.
124
+ * @returns A promise that resolves when live verification succeeds.
125
+ * @throws {JmapApiError} If discovery, authentication, or validation fails.
126
+ * @throws The caller's abort reason if cancelled.
127
+ * @since 0.6.0
128
+ */
129
+ verify(options?: TransportOptions): Promise<void>;
117
130
  /**
118
131
  * Sends a single email message.
119
132
  * @param message The message to send.
package/dist/index.js CHANGED
@@ -120,7 +120,22 @@ var JmapHttpClient = class {
120
120
  throw new JmapApiError(lastError.message, void 0, void 0, void 0, void 0, attempt + 1);
121
121
  }
122
122
  const delay = Math.pow(2, attempt) * 1e3;
123
- await new Promise((resolve) => setTimeout(resolve, delay));
123
+ await new Promise((resolve, reject) => {
124
+ const cleanup = () => {
125
+ clearTimeout(timer);
126
+ signal?.removeEventListener("abort", onAbort);
127
+ };
128
+ const onAbort = () => {
129
+ cleanup();
130
+ reject(signal?.reason);
131
+ };
132
+ const timer = setTimeout(() => {
133
+ cleanup();
134
+ resolve();
135
+ }, delay);
136
+ signal?.addEventListener("abort", onAbort, { once: true });
137
+ if (signal?.aborted) onAbort();
138
+ });
124
139
  }
125
140
  }
126
141
  if (lastError != null) throw lastError;
@@ -902,6 +917,60 @@ var JmapTransport = class {
902
917
  this.httpClient = new JmapHttpClient(this.config);
903
918
  }
904
919
  /**
920
+ * Checks a fresh Session, the account used by send(), its drafts mailbox,
921
+ * and the configured or available identity without creating or sending mail.
922
+ * Does not read or update the session cache. Each discovery operation has
923
+ * config.timeout as its total budget, including response bodies and retries.
924
+ * Success does not guarantee acceptance of a particular sender or message.
925
+ * @param options Optional cancellation signal.
926
+ * @returns A promise that resolves when live verification succeeds.
927
+ * @throws {JmapApiError} If discovery, authentication, or validation fails.
928
+ * @throws The caller's abort reason if cancelled.
929
+ * @since 0.6.0
930
+ */
931
+ async verify(options) {
932
+ const signal = options?.signal;
933
+ signal?.throwIfAborted();
934
+ try {
935
+ let session = await verificationOperation(this.config.timeout, (owned) => this.httpClient.fetchSession(owned), signal);
936
+ validateVerificationSession(session);
937
+ if (this.config.baseUrl) session = this.rewriteSessionUrls(session);
938
+ validateVerificationApiUrl(session.apiUrl);
939
+ if (!Object.values(JMAP_CAPABILITIES).every((cap) => cap in session.capabilities)) throw new JmapApiError("JMAP session lacks core, mail, or submission capability.");
940
+ const accountId = this.config.accountId ?? findMailAccount(session);
941
+ const account = accountId == null ? void 0 : session.accounts[accountId];
942
+ if (accountId == null || account == null) throw new JmapApiError(`No JMAP mail account found: ${accountId ?? "automatic selection"}`);
943
+ if (account.isReadOnly || !(JMAP_CAPABILITIES.mail in account.accountCapabilities) || !(JMAP_CAPABILITIES.submission in account.accountCapabilities)) throw new JmapApiError(`JMAP account must be writable with mail and submission capability: ${accountId}`);
944
+ const get = async (method, args, signal$1) => {
945
+ const response = await verificationOperation(this.config.timeout, (owned) => this.httpClient.executeRequest(session.apiUrl, {
946
+ using: method === "Mailbox/get" ? [JMAP_CAPABILITIES.core, JMAP_CAPABILITIES.mail] : [JMAP_CAPABILITIES.core, JMAP_CAPABILITIES.submission],
947
+ methodCalls: [[
948
+ method,
949
+ {
950
+ accountId,
951
+ ...args
952
+ },
953
+ "verify"
954
+ ]]
955
+ }, owned), signal$1);
956
+ return verificationResult(response, method, accountId);
957
+ };
958
+ const mailboxes = await get("Mailbox/get", { properties: ["id", "role"] }, signal);
959
+ if (!mailboxes.list.some((mailbox) => mailbox.role === "drafts")) throw new JmapApiError(`No drafts mailbox found in JMAP account: ${accountId}`);
960
+ const identityId = this.config.identityId;
961
+ const identities = await get("Identity/get", {
962
+ ids: identityId == null ? null : [identityId],
963
+ properties: ["id", "email"]
964
+ }, signal);
965
+ if (identities.notFound.length > 0 || identities.list.length === 0 || !identities.list.every((identity) => typeof identity.email === "string" && identity.email.length > 0) || identityId != null && (identities.list.length !== 1 || identities.list[0].id !== identityId)) throw new JmapApiError(`No valid JMAP identity found: ${identityId ?? accountId}`);
966
+ signal?.throwIfAborted();
967
+ } catch (error) {
968
+ signal?.throwIfAborted();
969
+ if (error instanceof JmapApiError) throw error;
970
+ throw new JmapApiError(`JMAP verification failed: ${error instanceof Error ? error.message : String(error)}`);
971
+ }
972
+ }
973
+ /**
905
974
  * Sends a single email message.
906
975
  * @param message The message to send.
907
976
  * @param options Optional transport options.
@@ -1347,6 +1416,76 @@ function getAbortReason(signal, fallback) {
1347
1416
  function isAbortError(error) {
1348
1417
  return error instanceof Error && error.name === "AbortError";
1349
1418
  }
1419
+ /** Bounds verification through body consumption, not just receipt of headers. */
1420
+ async function verificationOperation(timeout, operation, signal) {
1421
+ const controller = new AbortController();
1422
+ const combined = combineSignals(controller.signal, signal);
1423
+ let onAbort;
1424
+ const timer = setTimeout(() => controller.abort(new JmapApiError("JMAP verification timed out.")), timeout);
1425
+ try {
1426
+ combined.signal.throwIfAborted();
1427
+ return await new Promise((resolve, reject) => {
1428
+ onAbort = () => reject(combined.signal.reason);
1429
+ combined.signal.addEventListener("abort", onAbort, { once: true });
1430
+ Promise.resolve().then(() => {
1431
+ combined.signal.throwIfAborted();
1432
+ return operation(combined.signal);
1433
+ }).then(resolve, reject);
1434
+ if (combined.signal.aborted) onAbort();
1435
+ });
1436
+ } finally {
1437
+ clearTimeout(timer);
1438
+ if (onAbort) combined.signal.removeEventListener("abort", onAbort);
1439
+ controller.abort();
1440
+ combined.cleanup();
1441
+ }
1442
+ }
1443
+ function validateVerificationApiUrl(value) {
1444
+ if (typeof value === "string" && URL.canParse(value)) {
1445
+ const url = new URL(value);
1446
+ if (url.protocol === "https:" || url.protocol === "http:") return;
1447
+ }
1448
+ throw new JmapApiError("Invalid JMAP Session field: apiUrl");
1449
+ }
1450
+ function validateVerificationSession(session) {
1451
+ if (!isRecord(session)) throw new JmapApiError("Invalid JMAP Session response.");
1452
+ if (!isRecord(session.capabilities)) throw new JmapApiError("Invalid JMAP Session field: capabilities");
1453
+ if (!isRecord(session.accounts)) throw new JmapApiError("Invalid JMAP Session field: accounts");
1454
+ for (const [id, account] of Object.entries(session.accounts)) if (!isRecord(account) || typeof account.isReadOnly !== "boolean" || !isRecord(account.accountCapabilities)) throw new JmapApiError(`Invalid JMAP Session account: ${id}`);
1455
+ validateVerificationApiUrl(session.apiUrl);
1456
+ }
1457
+ /** Validates only the discovery fields used by verification. */
1458
+ function verificationResult(response, method, accountId) {
1459
+ if (!isRecord(response) || !Array.isArray(response.methodResponses)) throw new JmapApiError("Invalid JMAP verification response.");
1460
+ const matches = response.methodResponses.filter((entry$1) => Array.isArray(entry$1) && entry$1[2] === "verify");
1461
+ if (matches.length !== 1) throw new JmapApiError("Missing or duplicate JMAP verification response.");
1462
+ const entry = matches[0];
1463
+ if (!Array.isArray(entry) || entry.length !== 3 || !isRecord(entry[1])) throw new JmapApiError("Invalid JMAP verification method response.");
1464
+ const result = entry[1];
1465
+ if (entry[0] === "error") {
1466
+ if (typeof result.type !== "string" || !result.type) throw new JmapApiError("Invalid JMAP method error.");
1467
+ throw new JmapApiError(typeof result.description === "string" ? result.description : `JMAP method failed: ${result.type}`, void 0, void 0, result.type);
1468
+ }
1469
+ if (entry[0] !== method || result.accountId !== accountId || !Array.isArray(result.list) || !Array.isArray(result.notFound)) throw new JmapApiError(`Invalid JMAP discovery result: ${method}`);
1470
+ const list = [];
1471
+ const ids = /* @__PURE__ */ new Set();
1472
+ for (const item of result.list) {
1473
+ if (!isRecord(item) || typeof item.id !== "string" || !item.id || ids.has(item.id)) throw new JmapApiError(`Invalid JMAP discovery identifiers: ${method}`);
1474
+ ids.add(item.id);
1475
+ list.push(item);
1476
+ }
1477
+ const notFound = [];
1478
+ for (const id of result.notFound) {
1479
+ if (typeof id !== "string" || !id || ids.has(id)) throw new JmapApiError(`Contradictory JMAP discovery identifiers: ${method}`);
1480
+ ids.add(id);
1481
+ notFound.push(id);
1482
+ }
1483
+ if (method === "Mailbox/get" && (notFound.length > 0 || !list.every((item) => item.role == null || typeof item.role === "string"))) throw new JmapApiError("Invalid JMAP mailbox discovery result.");
1484
+ return {
1485
+ list,
1486
+ notFound
1487
+ };
1488
+ }
1350
1489
 
1351
1490
  //#endregion
1352
1491
  export { JMAP_ERROR_TYPES, JmapApiError, JmapTransport, createJmapConfig, isCapabilityError, uploadBlob };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@upyo/jmap",
3
- "version": "0.6.0-dev.354",
3
+ "version": "0.6.0-dev.357",
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.354+3493c572"
56
+ "@upyo/core": "0.6.0-dev.357+6aaf02d1"
57
57
  },
58
58
  "devDependencies": {
59
59
  "jmap-rfc-types": "^0.1.2",