@upyo/jmap 0.6.0-dev.316 → 0.6.0-dev.319

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
@@ -22,6 +22,7 @@ var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__ge
22
22
 
23
23
  //#endregion
24
24
  const __upyo_core = __toESM(require("@upyo/core"));
25
+ const __upyo_core_message_id = __toESM(require("@upyo/core/message-id"));
25
26
 
26
27
  //#region src/errors.ts
27
28
  /**
@@ -300,17 +301,44 @@ function getPriorityHeaders(priority) {
300
301
  }
301
302
  }
302
303
  /**
303
- * Extracts custom headers from message headers.
304
+ * Header field names that must not be written as a raw header property.
305
+ *
306
+ * RFC 8621 §4.6 forbids two properties representing the same header field, and
307
+ * the `Email` here already carries a structured property for each of these.
308
+ * `Bcc` matters most: it travels in the submission envelope, so writing it into
309
+ * the message would disclose recipients meant to stay hidden. `Content-*`
310
+ * belongs on an `EmailBodyPart` rather than on the `Email`.
311
+ */
312
+ const structuredHeaders = new Set([
313
+ "bcc",
314
+ "cc",
315
+ "from",
316
+ "mime-version",
317
+ "reply-to",
318
+ "subject",
319
+ "to"
320
+ ]);
321
+ /**
322
+ * Extracts the custom headers of a message, dropping those the `Email` object
323
+ * expresses through a structured property of its own.
324
+ *
304
325
  * @param message The message to extract headers from.
305
326
  * @returns Array of JMAP headers.
327
+ * @throws {TypeError} If a header value contains a carriage return or line
328
+ * feed, which would forge further header fields.
306
329
  * @since 0.4.0
307
330
  */
308
331
  function extractCustomHeaders(message) {
309
332
  const headers = [];
310
- for (const [name, value] of message.headers.entries()) headers.push({
311
- name,
312
- value
313
- });
333
+ for (const [name, value] of message.headers.entries()) {
334
+ const lowered = name.toLowerCase();
335
+ if (structuredHeaders.has(lowered) || lowered.startsWith("content-")) continue;
336
+ if (/[\r\n]/.test(value)) throw new TypeError(`Header field ${name} must not contain a carriage return or line feed.`);
337
+ headers.push({
338
+ name,
339
+ value
340
+ });
341
+ }
314
342
  return headers;
315
343
  }
316
344
  /**
@@ -418,13 +446,21 @@ function buildAttachmentParts(attachments, uploadedBlobs) {
418
446
  * @param draftMailboxId The mailbox ID to store the draft in.
419
447
  * @param uploadedBlobs Map of contentId to blobId for attachments.
420
448
  * @returns The JMAP Email/set create object.
449
+ * @throws {TypeError} If the message carries an invalid message identifier or
450
+ * date, or a header value containing a carriage return or line feed.
451
+ * @throws {RangeError} If the year of the date is outside what an RFC 3339
452
+ * date-time can express.
421
453
  * @since 0.4.0
422
454
  */
423
455
  function convertMessage(message, draftMailboxId, uploadedBlobs) {
424
456
  const { bodyStructure, bodyValues } = buildBodyStructure(message, uploadedBlobs);
425
- const headers = [];
426
- if (message.priority !== "normal") headers.push(...getPriorityHeaders(message.priority));
427
- headers.push(...extractCustomHeaders(message));
457
+ const identity = resolveIdentity(message);
458
+ const headerProperties = {};
459
+ for (const { name, value } of getPriorityHeaders(message.priority)) headerProperties[`header:${name}`] = value;
460
+ for (const { name, value } of extractCustomHeaders(message)) {
461
+ if (identity.owned.has(name.toLowerCase())) continue;
462
+ headerProperties[`header:${name}`] = value;
463
+ }
428
464
  const email = {
429
465
  mailboxIds: { [draftMailboxId]: true },
430
466
  from: [formatAddress(message.sender)],
@@ -435,10 +471,89 @@ function convertMessage(message, draftMailboxId, uploadedBlobs) {
435
471
  ...message.ccRecipients.length > 0 && { cc: message.ccRecipients.map(formatAddress) },
436
472
  ...message.bccRecipients.length > 0 && { bcc: message.bccRecipients.map(formatAddress) },
437
473
  ...message.replyRecipients.length > 0 && { replyTo: message.replyRecipients.map(formatAddress) },
438
- ...headers.length > 0 && { headers }
474
+ ...identity.properties,
475
+ ...headerProperties
439
476
  };
440
477
  return email;
441
478
  }
479
+ /**
480
+ * Works out the identity and threading properties of an `Email` from the typed
481
+ * fields of a message.
482
+ *
483
+ * A field the message leaves unset is not owned, so a header supplied through
484
+ * {@link Message.headers} still reaches the server, and the server falls back
485
+ * to generating an identifier and a date of its own. A threading field set to
486
+ * an empty list is owned but writes no property, which suppresses such a
487
+ * header.
488
+ *
489
+ * @param message The message being converted.
490
+ * @returns The properties to set, and the field names the message owns.
491
+ * @throws {TypeError} If a message identifier is invalid, or the date is not a
492
+ * valid one.
493
+ * @throws {RangeError} If the year of the date is outside what an RFC 3339
494
+ * date-time can express.
495
+ */
496
+ function resolveIdentity(message) {
497
+ const owned = /* @__PURE__ */ new Set();
498
+ const properties = {};
499
+ if (message.priority !== "normal") {
500
+ owned.add("x-priority");
501
+ owned.add("importance");
502
+ }
503
+ if (message.messageId != null) {
504
+ owned.add("message-id");
505
+ properties.messageId = [checkMessageId(message.messageId)];
506
+ }
507
+ if (message.date != null) {
508
+ owned.add("date");
509
+ properties.sentAt = formatSentAt(message.date);
510
+ }
511
+ if (message.inReplyTo != null) {
512
+ owned.add("in-reply-to");
513
+ if (message.inReplyTo.length > 0) properties.inReplyTo = message.inReplyTo.map(checkMessageId);
514
+ }
515
+ if (message.references != null) {
516
+ owned.add("references");
517
+ if (message.references.length > 0) properties.references = message.references.map(checkMessageId);
518
+ }
519
+ return {
520
+ properties,
521
+ owned
522
+ };
523
+ }
524
+ /**
525
+ * Checks a message identifier a message carries.
526
+ *
527
+ * `createMessage()` validates these, but `Message` is a structural type that a
528
+ * caller can build without it.
529
+ *
530
+ * @param id The identifier to check.
531
+ * @returns The identifier, unchanged.
532
+ * @throws {TypeError} If the value is not a valid message identifier.
533
+ */
534
+ function checkMessageId(id) {
535
+ const parsed = (0, __upyo_core_message_id.parseMessageId)(id);
536
+ if (parsed == null) throw new TypeError(`Invalid message ID: ${JSON.stringify(id)}`);
537
+ return parsed;
538
+ }
539
+ /**
540
+ * Formats a date as the RFC 3339 date-time JMAP calls a `Date`.
541
+ *
542
+ * RFC 8620 §1.4 omits a fractional-seconds part that is zero, which
543
+ * `Date.toISOString()` always writes.
544
+ *
545
+ * @param date The date to format.
546
+ * @returns The formatted date.
547
+ * @throws {TypeError} If the date is not a valid one.
548
+ * @throws {RangeError} If its year cannot be written as four digits.
549
+ */
550
+ function formatSentAt(date) {
551
+ const time = date instanceof Date ? date.getTime() : NaN;
552
+ if (Number.isNaN(time)) throw new TypeError(`Invalid date: ${JSON.stringify(date)}`);
553
+ const year = date.getUTCFullYear();
554
+ if (year < 1 || year > 9999) throw new RangeError(`Year out of range for an RFC 3339 date: ${year}`);
555
+ return date.toISOString().replace(".000Z", "Z");
556
+ }
442
557
 
443
558
  //#endregion
444
559
  //#region src/session.ts
package/dist/index.js CHANGED
@@ -1,4 +1,5 @@
1
1
  import { combineSignals, createFailedReceipt, parseRetryAfter, readAttachmentContent } from "@upyo/core";
2
+ import { parseMessageId } from "@upyo/core/message-id";
2
3
 
3
4
  //#region src/errors.ts
4
5
  /**
@@ -277,17 +278,44 @@ function getPriorityHeaders(priority) {
277
278
  }
278
279
  }
279
280
  /**
280
- * Extracts custom headers from message headers.
281
+ * Header field names that must not be written as a raw header property.
282
+ *
283
+ * RFC 8621 §4.6 forbids two properties representing the same header field, and
284
+ * the `Email` here already carries a structured property for each of these.
285
+ * `Bcc` matters most: it travels in the submission envelope, so writing it into
286
+ * the message would disclose recipients meant to stay hidden. `Content-*`
287
+ * belongs on an `EmailBodyPart` rather than on the `Email`.
288
+ */
289
+ const structuredHeaders = new Set([
290
+ "bcc",
291
+ "cc",
292
+ "from",
293
+ "mime-version",
294
+ "reply-to",
295
+ "subject",
296
+ "to"
297
+ ]);
298
+ /**
299
+ * Extracts the custom headers of a message, dropping those the `Email` object
300
+ * expresses through a structured property of its own.
301
+ *
281
302
  * @param message The message to extract headers from.
282
303
  * @returns Array of JMAP headers.
304
+ * @throws {TypeError} If a header value contains a carriage return or line
305
+ * feed, which would forge further header fields.
283
306
  * @since 0.4.0
284
307
  */
285
308
  function extractCustomHeaders(message) {
286
309
  const headers = [];
287
- for (const [name, value] of message.headers.entries()) headers.push({
288
- name,
289
- value
290
- });
310
+ for (const [name, value] of message.headers.entries()) {
311
+ const lowered = name.toLowerCase();
312
+ if (structuredHeaders.has(lowered) || lowered.startsWith("content-")) continue;
313
+ if (/[\r\n]/.test(value)) throw new TypeError(`Header field ${name} must not contain a carriage return or line feed.`);
314
+ headers.push({
315
+ name,
316
+ value
317
+ });
318
+ }
291
319
  return headers;
292
320
  }
293
321
  /**
@@ -395,13 +423,21 @@ function buildAttachmentParts(attachments, uploadedBlobs) {
395
423
  * @param draftMailboxId The mailbox ID to store the draft in.
396
424
  * @param uploadedBlobs Map of contentId to blobId for attachments.
397
425
  * @returns The JMAP Email/set create object.
426
+ * @throws {TypeError} If the message carries an invalid message identifier or
427
+ * date, or a header value containing a carriage return or line feed.
428
+ * @throws {RangeError} If the year of the date is outside what an RFC 3339
429
+ * date-time can express.
398
430
  * @since 0.4.0
399
431
  */
400
432
  function convertMessage(message, draftMailboxId, uploadedBlobs) {
401
433
  const { bodyStructure, bodyValues } = buildBodyStructure(message, uploadedBlobs);
402
- const headers = [];
403
- if (message.priority !== "normal") headers.push(...getPriorityHeaders(message.priority));
404
- headers.push(...extractCustomHeaders(message));
434
+ const identity = resolveIdentity(message);
435
+ const headerProperties = {};
436
+ for (const { name, value } of getPriorityHeaders(message.priority)) headerProperties[`header:${name}`] = value;
437
+ for (const { name, value } of extractCustomHeaders(message)) {
438
+ if (identity.owned.has(name.toLowerCase())) continue;
439
+ headerProperties[`header:${name}`] = value;
440
+ }
405
441
  const email = {
406
442
  mailboxIds: { [draftMailboxId]: true },
407
443
  from: [formatAddress(message.sender)],
@@ -412,10 +448,89 @@ function convertMessage(message, draftMailboxId, uploadedBlobs) {
412
448
  ...message.ccRecipients.length > 0 && { cc: message.ccRecipients.map(formatAddress) },
413
449
  ...message.bccRecipients.length > 0 && { bcc: message.bccRecipients.map(formatAddress) },
414
450
  ...message.replyRecipients.length > 0 && { replyTo: message.replyRecipients.map(formatAddress) },
415
- ...headers.length > 0 && { headers }
451
+ ...identity.properties,
452
+ ...headerProperties
416
453
  };
417
454
  return email;
418
455
  }
456
+ /**
457
+ * Works out the identity and threading properties of an `Email` from the typed
458
+ * fields of a message.
459
+ *
460
+ * A field the message leaves unset is not owned, so a header supplied through
461
+ * {@link Message.headers} still reaches the server, and the server falls back
462
+ * to generating an identifier and a date of its own. A threading field set to
463
+ * an empty list is owned but writes no property, which suppresses such a
464
+ * header.
465
+ *
466
+ * @param message The message being converted.
467
+ * @returns The properties to set, and the field names the message owns.
468
+ * @throws {TypeError} If a message identifier is invalid, or the date is not a
469
+ * valid one.
470
+ * @throws {RangeError} If the year of the date is outside what an RFC 3339
471
+ * date-time can express.
472
+ */
473
+ function resolveIdentity(message) {
474
+ const owned = /* @__PURE__ */ new Set();
475
+ const properties = {};
476
+ if (message.priority !== "normal") {
477
+ owned.add("x-priority");
478
+ owned.add("importance");
479
+ }
480
+ if (message.messageId != null) {
481
+ owned.add("message-id");
482
+ properties.messageId = [checkMessageId(message.messageId)];
483
+ }
484
+ if (message.date != null) {
485
+ owned.add("date");
486
+ properties.sentAt = formatSentAt(message.date);
487
+ }
488
+ if (message.inReplyTo != null) {
489
+ owned.add("in-reply-to");
490
+ if (message.inReplyTo.length > 0) properties.inReplyTo = message.inReplyTo.map(checkMessageId);
491
+ }
492
+ if (message.references != null) {
493
+ owned.add("references");
494
+ if (message.references.length > 0) properties.references = message.references.map(checkMessageId);
495
+ }
496
+ return {
497
+ properties,
498
+ owned
499
+ };
500
+ }
501
+ /**
502
+ * Checks a message identifier a message carries.
503
+ *
504
+ * `createMessage()` validates these, but `Message` is a structural type that a
505
+ * caller can build without it.
506
+ *
507
+ * @param id The identifier to check.
508
+ * @returns The identifier, unchanged.
509
+ * @throws {TypeError} If the value is not a valid message identifier.
510
+ */
511
+ function checkMessageId(id) {
512
+ const parsed = parseMessageId(id);
513
+ if (parsed == null) throw new TypeError(`Invalid message ID: ${JSON.stringify(id)}`);
514
+ return parsed;
515
+ }
516
+ /**
517
+ * Formats a date as the RFC 3339 date-time JMAP calls a `Date`.
518
+ *
519
+ * RFC 8620 §1.4 omits a fractional-seconds part that is zero, which
520
+ * `Date.toISOString()` always writes.
521
+ *
522
+ * @param date The date to format.
523
+ * @returns The formatted date.
524
+ * @throws {TypeError} If the date is not a valid one.
525
+ * @throws {RangeError} If its year cannot be written as four digits.
526
+ */
527
+ function formatSentAt(date) {
528
+ const time = date instanceof Date ? date.getTime() : NaN;
529
+ if (Number.isNaN(time)) throw new TypeError(`Invalid date: ${JSON.stringify(date)}`);
530
+ const year = date.getUTCFullYear();
531
+ if (year < 1 || year > 9999) throw new RangeError(`Year out of range for an RFC 3339 date: ${year}`);
532
+ return date.toISOString().replace(".000Z", "Z");
533
+ }
419
534
 
420
535
  //#endregion
421
536
  //#region src/session.ts
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@upyo/jmap",
3
- "version": "0.6.0-dev.316",
3
+ "version": "0.6.0-dev.319",
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.316+a131652d"
56
+ "@upyo/core": "0.6.0-dev.319+2324fa8c"
57
57
  },
58
58
  "devDependencies": {
59
59
  "jmap-rfc-types": "^0.1.2",