@remit/mailbox-service 0.0.63 → 0.0.65

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.
Files changed (52) hide show
  1. package/package.json +1 -4
  2. package/src/adversarial-mime.e2e.test.ts +2 -2
  3. package/src/mailbox-sync.test.ts +146 -0
  4. package/src/mailbox-sync.ts +43 -26
  5. package/test/fixtures/mime/01-flat-text-plain.bodyparts.json +0 -8
  6. package/test/fixtures/mime/01-flat-text-plain.eml +0 -10
  7. package/test/fixtures/mime/01-flat-text-plain.expected.json +0 -10
  8. package/test/fixtures/mime/02-flat-text-html.bodyparts.json +0 -8
  9. package/test/fixtures/mime/02-flat-text-html.eml +0 -10
  10. package/test/fixtures/mime/02-flat-text-html.expected.json +0 -10
  11. package/test/fixtures/mime/03-alternative-text-html.bodyparts.json +0 -20
  12. package/test/fixtures/mime/03-alternative-text-html.eml +0 -19
  13. package/test/fixtures/mime/03-alternative-text-html.expected.json +0 -16
  14. package/test/fixtures/mime/04-mixed-html-pdf-attachment.bodyparts.json +0 -22
  15. package/test/fixtures/mime/04-mixed-html-pdf-attachment.eml +0 -20
  16. package/test/fixtures/mime/04-mixed-html-pdf-attachment.expected.json +0 -16
  17. package/test/fixtures/mime/05-mixed-html-octet-stream-pdf.bodyparts.json +0 -22
  18. package/test/fixtures/mime/05-mixed-html-octet-stream-pdf.eml +0 -21
  19. package/test/fixtures/mime/05-mixed-html-octet-stream-pdf.expected.json +0 -16
  20. package/test/fixtures/mime/06-related-html-cid-image.bodyparts.json +0 -23
  21. package/test/fixtures/mime/06-related-html-cid-image.eml +0 -21
  22. package/test/fixtures/mime/06-related-html-cid-image.expected.json +0 -16
  23. package/test/fixtures/mime/07-mixed-alternative-related-attachment.bodyparts.json +0 -55
  24. package/test/fixtures/mime/07-mixed-alternative-related-attachment.eml +0 -47
  25. package/test/fixtures/mime/07-mixed-alternative-related-attachment.expected.json +0 -34
  26. package/test/fixtures/mime/08-two-pdfs-same-filename.bodyparts.json +0 -30
  27. package/test/fixtures/mime/08-two-pdfs-same-filename.eml +0 -26
  28. package/test/fixtures/mime/08-two-pdfs-same-filename.expected.json +0 -22
  29. package/test/fixtures/mime/09-attachment-no-filename.bodyparts.json +0 -21
  30. package/test/fixtures/mime/09-attachment-no-filename.eml +0 -20
  31. package/test/fixtures/mime/09-attachment-no-filename.expected.json +0 -16
  32. package/test/fixtures/mime/10-calendar-invite.bodyparts.json +0 -22
  33. package/test/fixtures/mime/10-calendar-invite.eml +0 -33
  34. package/test/fixtures/mime/10-calendar-invite.expected.json +0 -16
  35. package/test/fixtures/mime/11-quoted-printable-text.bodyparts.json +0 -8
  36. package/test/fixtures/mime/11-quoted-printable-text.eml +0 -10
  37. package/test/fixtures/mime/11-quoted-printable-text.expected.json +0 -10
  38. package/test/fixtures/mime/12-base64-binary.bodyparts.json +0 -22
  39. package/test/fixtures/mime/12-base64-binary.eml +0 -20
  40. package/test/fixtures/mime/12-base64-binary.expected.json +0 -16
  41. package/test/fixtures/mime/13-message-rfc822-forward.bodyparts.json +0 -22
  42. package/test/fixtures/mime/13-message-rfc822-forward.eml +0 -28
  43. package/test/fixtures/mime/13-message-rfc822-forward.expected.json +0 -16
  44. package/test/fixtures/mime/14-deeply-nested-5-levels.bodyparts.json +0 -38
  45. package/test/fixtures/mime/14-deeply-nested-5-levels.eml +0 -34
  46. package/test/fixtures/mime/14-deeply-nested-5-levels.expected.json +0 -10
  47. package/test/fixtures/mime/15-empty-text-part.bodyparts.json +0 -21
  48. package/test/fixtures/mime/15-empty-text-part.eml +0 -18
  49. package/test/fixtures/mime/15-empty-text-part.expected.json +0 -16
  50. package/test/fixtures/mime/16-empty-non-text-part.bodyparts.json +0 -30
  51. package/test/fixtures/mime/16-empty-non-text-part.eml +0 -24
  52. package/test/fixtures/mime/16-empty-non-text-part.expected.json +0 -22
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@remit/mailbox-service",
3
- "version": "0.0.63",
3
+ "version": "0.0.65",
4
4
  "type": "module",
5
5
  "main": "src/index.ts",
6
6
  "types": "src/index.ts",
@@ -25,9 +25,6 @@
25
25
  "test:e2e": "RUN_E2E_TESTS=1 node --env-file=../../localhost-test-e2e.env --import tsx --test --test-concurrency=1 'src/**/*.e2e.test.ts'",
26
26
  "test": "npm run test:typecheck && npm run test:run"
27
27
  },
28
- "devDependencies": {
29
- "@types/uuid": "^10.0.0"
30
- },
31
28
  "dependencies": {
32
29
  "imapflow": "^1.3.3",
33
30
  "mailparser": "^3.9.14",
@@ -16,8 +16,8 @@
16
16
  * 8-bit UTF-8 bodies, empty text/plain alternatives) — the same class of shape
17
17
  * that caused the body-sync regression in #394.
18
18
  *
19
- * Mapper unit-level defence lives in `test/fixtures/mime/`; this suite is the
20
- * integration-layer belt-and-braces against the same regression class.
19
+ * Mapper unit-level defence lives in `body-part-mapper.property.test.ts`; this
20
+ * suite is the integration-layer belt-and-braces against the same class.
21
21
  */
22
22
 
23
23
  import assert from "node:assert/strict";
@@ -342,6 +342,152 @@ describe("MailboxSyncService.syncMailboxes — reconcile does not delete pending
342
342
  });
343
343
  });
344
344
 
345
+ describe("MailboxSyncService.syncMailboxes — a lookalike is not a folder the user keeps mail in (#837)", () => {
346
+ const namespaces: ImapNamespaces = {
347
+ personal: [{ prefix: "", delimiter: "/" }],
348
+ other: [],
349
+ shared: [],
350
+ };
351
+
352
+ const serverConnection = (
353
+ folders: Array<{ fullPath: string; attributes: string[] }>,
354
+ ): IImapConnection =>
355
+ ({
356
+ getNamespaces: async () => namespaces,
357
+ listMailboxes: async () =>
358
+ folders.map((folder) => ({
359
+ fullPath: folder.fullPath,
360
+ name: folder.fullPath.split("/").pop() ?? folder.fullPath,
361
+ delimiter: "/",
362
+ attributes: folder.attributes,
363
+ parentPath: null,
364
+ })),
365
+ getMailboxStatus: async () => ({
366
+ messages: 0,
367
+ recent: 0,
368
+ unseen: 0,
369
+ uidNext: 1,
370
+ uidValidity: 1,
371
+ highestModseq: "0",
372
+ deletedCount: 0,
373
+ }),
374
+ }) as unknown as IImapConnection;
375
+
376
+ const buildServices = (
377
+ existing: Array<{ mailboxId: string; fullPath: string }>,
378
+ ) => {
379
+ const deleted: string[] = [];
380
+ const mailboxService = {
381
+ listByAccount: async () => ({
382
+ items: existing.map((m) => ({
383
+ mailboxId: m.mailboxId,
384
+ fullPath: m.fullPath,
385
+ uidNext: 1,
386
+ uidValidity: 1,
387
+ messageCount: 0,
388
+ unseenCount: 0,
389
+ deletedCount: 0,
390
+ highestModseq: "0",
391
+ specialUse: undefined,
392
+ syncStatus: MailboxSyncStatus.synced,
393
+ })),
394
+ continuationToken: undefined,
395
+ }),
396
+ update: async () => ({}),
397
+ delete: async (_accountId: string, mailboxId: string) => {
398
+ deleted.push(mailboxId);
399
+ },
400
+ create: async () => ({}),
401
+ } as unknown as IMailboxRepository;
402
+
403
+ const specialUseService = {
404
+ listByMailboxId: async () => [],
405
+ deleteByMailboxId: async () => undefined,
406
+ createMany: async () => undefined,
407
+ } as unknown as IMailboxSpecialUseRepository;
408
+
409
+ return { mailboxService, specialUseService, deleted };
410
+ };
411
+
412
+ const syncWith = async (
413
+ folders: Array<{ fullPath: string; attributes: string[] }>,
414
+ existing: Array<{ mailboxId: string; fullPath: string }>,
415
+ ): Promise<string[]> => {
416
+ const { mailboxService, specialUseService, deleted } =
417
+ buildServices(existing);
418
+ const service = new MailboxSyncService(
419
+ mailboxService,
420
+ specialUseService,
421
+ silentLogger,
422
+ );
423
+ await service.syncMailboxes(
424
+ { accountId: "acc-1" },
425
+ serverConnection(folders),
426
+ );
427
+ return deleted;
428
+ };
429
+
430
+ it("keeps a folder called Deleted beside the folder the server flagged", async () => {
431
+ // `deleted` and `bin` were Trash names in this file's own copy of the hint
432
+ // table long after #843 dropped them from the shared one. Every sync of an
433
+ // account holding a flagged Trash and a user folder called `Deleted` took
434
+ // the user's folder, and its mail, out of the client.
435
+ const deleted = await syncWith(
436
+ [
437
+ { fullPath: "INBOX", attributes: [] },
438
+ { fullPath: "Trash", attributes: ["\\Trash"] },
439
+ { fullPath: "Deleted", attributes: [] },
440
+ { fullPath: "Bin", attributes: [] },
441
+ ],
442
+ [
443
+ { mailboxId: "inbox", fullPath: "INBOX" },
444
+ { mailboxId: "trash", fullPath: "Trash" },
445
+ { mailboxId: "keepsakes", fullPath: "Deleted" },
446
+ { mailboxId: "bin", fullPath: "Bin" },
447
+ ],
448
+ );
449
+
450
+ assert.deepEqual(deleted, []);
451
+ });
452
+
453
+ it("still hides the unflagged twin of a folder the server flagged", async () => {
454
+ const deleted = await syncWith(
455
+ [
456
+ { fullPath: "INBOX", attributes: [] },
457
+ { fullPath: "[Gmail]/Trash", attributes: ["\\Trash"] },
458
+ { fullPath: "Trash", attributes: [] },
459
+ ],
460
+ [
461
+ { mailboxId: "inbox", fullPath: "INBOX" },
462
+ { mailboxId: "gmail-trash", fullPath: "[Gmail]/Trash" },
463
+ { mailboxId: "lookalike", fullPath: "Trash" },
464
+ ],
465
+ );
466
+
467
+ assert.deepEqual(deleted, ["lookalike"]);
468
+ });
469
+
470
+ it("keeps a folder that holds a role of its own", async () => {
471
+ // `all mail` is a conventional name for Archive as well as for All, and the
472
+ // account already has a flagged Archive. The folder holds the All role, so
473
+ // it is a folder in its own right rather than a lookalike of Archive.
474
+ const deleted = await syncWith(
475
+ [
476
+ { fullPath: "INBOX", attributes: [] },
477
+ { fullPath: "Archive", attributes: ["\\Archive"] },
478
+ { fullPath: "INBOX/All Mail", attributes: [] },
479
+ ],
480
+ [
481
+ { mailboxId: "inbox", fullPath: "INBOX" },
482
+ { mailboxId: "archive", fullPath: "Archive" },
483
+ { mailboxId: "all-mail", fullPath: "INBOX/All Mail" },
484
+ ],
485
+ );
486
+
487
+ assert.deepEqual(deleted, []);
488
+ });
489
+ });
490
+
345
491
  /**
346
492
  * The folder set can also change under a running sweep: a delete asked for while
347
493
  * the account is enumerating lands between the LIST and one folder's STATUS.
@@ -10,9 +10,15 @@ import type {
10
10
  IMailboxSpecialUseRepository,
11
11
  MailboxItem,
12
12
  } from "@remit/data-ports";
13
+ import {
14
+ CANONICAL_ROLES,
15
+ type CanonicalMailboxRoleValue,
16
+ ROLE_NAME_HINTS,
17
+ ROLE_SPECIAL_USE,
18
+ } from "@remit/data-ports/folder-role";
13
19
  import {
14
20
  MailboxCursorState,
15
- MailboxSpecialUse,
21
+ type MailboxSpecialUse,
16
22
  MailboxSyncStatus,
17
23
  NamespaceType,
18
24
  } from "@remit/domain-enums";
@@ -51,23 +57,27 @@ const areSpecialUseSetsEqual = (
51
57
  };
52
58
 
53
59
  /**
54
- * Map common folder names to their expected special-use designation.
55
- * Used to detect duplicate folders (e.g., "Trash" vs "[Gmail]/Trash").
60
+ * The role a folder's own leaf name is most conventionally for, read from the
61
+ * one hint table every special-folder lookup shares (`@remit/data-ports`,
62
+ * #837). A name that several roles list goes to the role that ranks it highest,
63
+ * so `All Mail` is an All folder rather than a lookalike of Archive.
64
+ *
65
+ * A second copy of these names lived here, and it is the one that could destroy
66
+ * something: it still called `Deleted` and `Bin` Trash names, which #843 dropped
67
+ * precisely because they are ordinary folders a user keeps mail in — and this
68
+ * lookup does not merely skip a folder, it deletes the row and everything the
69
+ * client can see in it.
56
70
  */
57
- const FOLDER_NAME_TO_SPECIAL_USE: Record<string, MailboxSpecialUseValue> = {
58
- trash: MailboxSpecialUse.Trash,
59
- "deleted items": MailboxSpecialUse.Trash,
60
- deleted: MailboxSpecialUse.Trash,
61
- bin: MailboxSpecialUse.Trash,
62
- drafts: MailboxSpecialUse.Drafts,
63
- draft: MailboxSpecialUse.Drafts,
64
- sent: MailboxSpecialUse.Sent,
65
- "sent items": MailboxSpecialUse.Sent,
66
- "sent mail": MailboxSpecialUse.Sent,
67
- junk: MailboxSpecialUse.Junk,
68
- spam: MailboxSpecialUse.Junk,
69
- archive: MailboxSpecialUse.Archive,
70
- archives: MailboxSpecialUse.Archive,
71
+ const roleForFolderName = (
72
+ name: string,
73
+ ): CanonicalMailboxRoleValue | undefined => {
74
+ let best: { role: CanonicalMailboxRoleValue; rank: number } | undefined;
75
+ for (const role of CANONICAL_ROLES) {
76
+ const rank = ROLE_NAME_HINTS[role]?.indexOf(name) ?? -1;
77
+ if (rank < 0) continue;
78
+ if (!best || rank < best.rank) best = { role, rank };
79
+ }
80
+ return best?.role;
71
81
  };
72
82
 
73
83
  /**
@@ -451,8 +461,8 @@ export class MailboxSyncService {
451
461
 
452
462
  const mailbox = await this.mailboxService.create(input);
453
463
 
454
- // Keep the MailboxSpecialUseEntry table in sync — other services (e.g.
455
- // MessageMoveService.findTrashMailbox) still query by entry. Denormalized
464
+ // Keep the MailboxSpecialUseEntry table in sync — every backend and worker
465
+ // special-folder lookup reads it through MailboxSpecialUseRepo. Denormalized
456
466
  // copy on Mailbox is the read-side optimization, the entries remain the
457
467
  // authoritative join source for cross-mailbox lookups.
458
468
  if (parsed.specialUse.length > 0) {
@@ -653,21 +663,28 @@ export class MailboxSyncService {
653
663
  const folderName = mailbox.fullPath.split(mailbox.delimiter).pop() ?? "";
654
664
  const normalizedName = folderName.toLowerCase();
655
665
 
656
- // Check if this folder name maps to a special-use designation
657
- const expectedSpecialUse = FOLDER_NAME_TO_SPECIAL_USE[normalizedName];
658
- if (!expectedSpecialUse) {
666
+ // Check if this folder name is a conventional name for a role
667
+ const role = roleForFolderName(normalizedName);
668
+ if (!role) {
659
669
  return false; // Not a special-use folder name
660
670
  }
661
671
 
672
+ const expectedSpecialUse = ROLE_SPECIAL_USE[role];
673
+ if (!expectedSpecialUse) {
674
+ return false; // The role has no SPECIAL-USE flag to be a duplicate of
675
+ }
676
+
662
677
  // Check if this mailbox has the special-use attribute
663
678
  const parsed = parseImapAttributes(mailbox.attributes);
664
- if (parsed.specialUse.includes(expectedSpecialUse)) {
679
+ if (parsed.specialUse.some((use) => use === expectedSpecialUse)) {
665
680
  return false; // This IS the canonical folder
666
681
  }
667
682
 
668
683
  // Check if another folder already claimed this special-use
669
- const claimedPath = claimedSpecialUse.get(expectedSpecialUse);
670
- if (!claimedPath) {
684
+ const claimed = [...claimedSpecialUse].find(
685
+ ([specialUse]) => specialUse === expectedSpecialUse,
686
+ );
687
+ if (!claimed) {
671
688
  return false; // No other folder has this special-use
672
689
  }
673
690
 
@@ -675,7 +692,7 @@ export class MailboxSyncService {
675
692
  this.log.debug(
676
693
  {
677
694
  fullPath: mailbox.fullPath,
678
- claimedPath,
695
+ claimedPath: claimed[1],
679
696
  specialUse: expectedSpecialUse,
680
697
  },
681
698
  "Skipping duplicate folder",
@@ -1,8 +0,0 @@
1
- [
2
- {
3
- "partPath": "0",
4
- "isMultipart": false,
5
- "mediaType": "TEXT",
6
- "mediaSubtype": "plain"
7
- }
8
- ]
@@ -1,10 +0,0 @@
1
- From: alice@example.org
2
- To: bob@example.org
3
- Subject: Flat text/plain fixture 01
4
- Date: Thu, 28 May 2026 09:00:00 +0000
5
- Message-Id: <fixture-01@example.org>
6
- MIME-Version: 1.0
7
- Content-Type: text/plain; charset=us-ascii
8
- Content-Transfer-Encoding: 7bit
9
-
10
- Hello bob, this is a flat text/plain message.
@@ -1,10 +0,0 @@
1
- {
2
- "pairs": [
3
- {
4
- "partPath": "0",
5
- "contentType": "text/plain",
6
- "contentSha256": "55ac4ab155dd2b05e3d4ffb422c6fc2539484dfb07c7b5b5fac1d0035ac1f6db",
7
- "contentLength": 46
8
- }
9
- ]
10
- }
@@ -1,8 +0,0 @@
1
- [
2
- {
3
- "partPath": "0",
4
- "isMultipart": false,
5
- "mediaType": "TEXT",
6
- "mediaSubtype": "html"
7
- }
8
- ]
@@ -1,10 +0,0 @@
1
- From: alice@example.org
2
- To: bob@example.org
3
- Subject: Flat text/html fixture 02
4
- Date: Thu, 28 May 2026 09:00:00 +0000
5
- Message-Id: <fixture-02@example.org>
6
- MIME-Version: 1.0
7
- Content-Type: text/html; charset=us-ascii
8
- Content-Transfer-Encoding: 7bit
9
-
10
- <html><body><p>Hello bob, this is a flat <b>text/html</b> message.</p></body></html>
@@ -1,10 +0,0 @@
1
- {
2
- "pairs": [
3
- {
4
- "partPath": "0",
5
- "contentType": "text/html",
6
- "contentSha256": "4484f960dbbb61e252b8ac3dd516a3c651a90ed666781befe67c6e13d30f3b4e",
7
- "contentLength": 85
8
- }
9
- ]
10
- }
@@ -1,20 +0,0 @@
1
- [
2
- {
3
- "partPath": "0",
4
- "isMultipart": true,
5
- "mediaType": "MULTIPART",
6
- "mediaSubtype": "alternative"
7
- },
8
- {
9
- "partPath": "1",
10
- "isMultipart": false,
11
- "mediaType": "TEXT",
12
- "mediaSubtype": "plain"
13
- },
14
- {
15
- "partPath": "2",
16
- "isMultipart": false,
17
- "mediaType": "TEXT",
18
- "mediaSubtype": "html"
19
- }
20
- ]
@@ -1,19 +0,0 @@
1
- From: alice@example.org
2
- To: bob@example.org
3
- Subject: Alternative text+html fixture 03
4
- Date: Thu, 28 May 2026 09:00:00 +0000
5
- Message-Id: <fixture-03@example.org>
6
- MIME-Version: 1.0
7
- Content-Type: multipart/alternative; boundary="=_boundary_03_="
8
-
9
- --=_boundary_03_=
10
- Content-Type: text/plain; charset=us-ascii
11
- Content-Transfer-Encoding: 7bit
12
-
13
- Hello bob, plain text body.
14
- --=_boundary_03_=
15
- Content-Type: text/html; charset=us-ascii
16
- Content-Transfer-Encoding: 7bit
17
-
18
- <html><body><p>Hello bob, html body.</p></body></html>
19
- --=_boundary_03_=--
@@ -1,16 +0,0 @@
1
- {
2
- "pairs": [
3
- {
4
- "partPath": "1",
5
- "contentType": "text/plain",
6
- "contentSha256": "54178e683cea517723189e2851efd8449bac73f2b57c50bd81a0c8a6df4ce7c8",
7
- "contentLength": 27
8
- },
9
- {
10
- "partPath": "2",
11
- "contentType": "text/html",
12
- "contentSha256": "71744f8d83087de2acde32d5b9ec93ecfe1b5ba677f760655643dabfd0ce661d",
13
- "contentLength": 54
14
- }
15
- ]
16
- }
@@ -1,22 +0,0 @@
1
- [
2
- {
3
- "partPath": "0",
4
- "isMultipart": true,
5
- "mediaType": "MULTIPART",
6
- "mediaSubtype": "mixed"
7
- },
8
- {
9
- "partPath": "1",
10
- "isMultipart": false,
11
- "mediaType": "TEXT",
12
- "mediaSubtype": "html"
13
- },
14
- {
15
- "partPath": "2",
16
- "isMultipart": false,
17
- "mediaType": "APPLICATION",
18
- "mediaSubtype": "pdf",
19
- "dispositionFilename": "invoice.pdf",
20
- "disposition": "attachment"
21
- }
22
- ]
@@ -1,20 +0,0 @@
1
- From: alice@example.org
2
- To: bob@example.org
3
- Subject: Mixed html + pdf fixture 04
4
- Date: Thu, 28 May 2026 09:00:00 +0000
5
- Message-Id: <fixture-04@example.org>
6
- MIME-Version: 1.0
7
- Content-Type: multipart/mixed; boundary="=_boundary_04_="
8
-
9
- --=_boundary_04_=
10
- Content-Type: text/html; charset=us-ascii
11
- Content-Transfer-Encoding: 7bit
12
-
13
- <html><body><p>See attached invoice.</p></body></html>
14
- --=_boundary_04_=
15
- Content-Type: application/pdf; name="invoice.pdf"
16
- Content-Transfer-Encoding: base64
17
- Content-Disposition: attachment; filename="invoice.pdf"
18
-
19
- JVBERi0xLjQKJWZha2UgcGRmIGZvciBmaXh0dXJlIDA0CiVFT0YK
20
- --=_boundary_04_=--
@@ -1,16 +0,0 @@
1
- {
2
- "pairs": [
3
- {
4
- "partPath": "1",
5
- "contentType": "text/html",
6
- "contentSha256": "d8d4d207e4f8d121a1d60fe74922da8b562ebada6a4a58b2d0f2b1bcac2ae5d7",
7
- "contentLength": 54
8
- },
9
- {
10
- "partPath": "2",
11
- "contentType": "application/pdf",
12
- "contentSha256": "1e82c5ee13ccddf36b4abc368c682a08ed1bc5671101c76062183c8e9914fb41",
13
- "contentLength": 39
14
- }
15
- ]
16
- }
@@ -1,22 +0,0 @@
1
- [
2
- {
3
- "partPath": "0",
4
- "isMultipart": true,
5
- "mediaType": "MULTIPART",
6
- "mediaSubtype": "mixed"
7
- },
8
- {
9
- "partPath": "1",
10
- "isMultipart": false,
11
- "mediaType": "TEXT",
12
- "mediaSubtype": "html"
13
- },
14
- {
15
- "partPath": "2",
16
- "isMultipart": false,
17
- "mediaType": "APPLICATION",
18
- "mediaSubtype": "octet-stream",
19
- "dispositionFilename": "contract.pdf",
20
- "disposition": "attachment"
21
- }
22
- ]
@@ -1,21 +0,0 @@
1
- From: alice@example.org
2
- To: bob@example.org
3
- Subject: Mixed html + octet-stream PDF fixture 05 (Odido bug)
4
- Date: Thu, 28 May 2026 09:00:00 +0000
5
- Message-Id: <fixture-05@example.org>
6
- MIME-Version: 1.0
7
- Content-Type: multipart/mixed; boundary="=_boundary_05_="
8
-
9
- --=_boundary_05_=
10
- Content-Type: text/html; charset=us-ascii
11
- Content-Transfer-Encoding: 7bit
12
-
13
- <html><body><p>Your contract PDF is attached.</p></body></html>
14
- --=_boundary_05_=
15
- Content-Type: application/octet-stream; name="contract.pdf"
16
- Content-Transfer-Encoding: base64
17
- Content-Disposition: attachment; filename="contract.pdf"
18
-
19
- JVBERi0xLjQKJWZha2UgcGRmIGZvciBmaXh0dXJlIDA1IG9jdGV0LXN0cmVhbQol
20
- RU9GCg==
21
- --=_boundary_05_=--
@@ -1,16 +0,0 @@
1
- {
2
- "pairs": [
3
- {
4
- "partPath": "1",
5
- "contentType": "text/html",
6
- "contentSha256": "9fd8608290b5e5c88848e336235fbcf48d4797290c631b9f73bb9f13c7ce3515",
7
- "contentLength": 63
8
- },
9
- {
10
- "partPath": "2",
11
- "contentType": "application/octet-stream",
12
- "contentSha256": "649331fa0bcf7487ba5529a34be1b2629f76e7906548321283d9255ad4ef53eb",
13
- "contentLength": 52
14
- }
15
- ]
16
- }
@@ -1,23 +0,0 @@
1
- [
2
- {
3
- "partPath": "0",
4
- "isMultipart": true,
5
- "mediaType": "MULTIPART",
6
- "mediaSubtype": "related"
7
- },
8
- {
9
- "partPath": "1",
10
- "isMultipart": false,
11
- "mediaType": "TEXT",
12
- "mediaSubtype": "html"
13
- },
14
- {
15
- "partPath": "2",
16
- "isMultipart": false,
17
- "mediaType": "IMAGE",
18
- "mediaSubtype": "png",
19
- "contentId": "logo@example.org",
20
- "dispositionFilename": "logo.png",
21
- "disposition": "inline"
22
- }
23
- ]
@@ -1,21 +0,0 @@
1
- From: alice@example.org
2
- To: bob@example.org
3
- Subject: Related html + cid image fixture 06
4
- Date: Thu, 28 May 2026 09:00:00 +0000
5
- Message-Id: <fixture-06@example.org>
6
- MIME-Version: 1.0
7
- Content-Type: multipart/related; boundary="=_boundary_06_="; type="text/html"
8
-
9
- --=_boundary_06_=
10
- Content-Type: text/html; charset=us-ascii
11
- Content-Transfer-Encoding: 7bit
12
-
13
- <html><body><p>Inline image: <img src="cid:logo@example.org"></p></body></html>
14
- --=_boundary_06_=
15
- Content-Type: image/png; name="logo.png"
16
- Content-Transfer-Encoding: base64
17
- Content-Disposition: inline; filename="logo.png"
18
- Content-ID: <logo@example.org>
19
-
20
- iVBORw0KGgpmaXh0dXJlMDZwbmdieXRlcw==
21
- --=_boundary_06_=--
@@ -1,16 +0,0 @@
1
- {
2
- "pairs": [
3
- {
4
- "partPath": "1",
5
- "contentType": "text/html",
6
- "contentSha256": "212c7f36413be9efaf441c588da97719f947a2210f76c98d05f21ad7480f1c8a",
7
- "contentLength": 117
8
- },
9
- {
10
- "partPath": "2",
11
- "contentType": "image/png",
12
- "contentSha256": "c5a79659b71cb6866a73f688ef63a29e163065f6dbb6aba3da932bc872e475d6",
13
- "contentLength": 25
14
- }
15
- ]
16
- }
@@ -1,55 +0,0 @@
1
- [
2
- {
3
- "partPath": "0",
4
- "isMultipart": true,
5
- "mediaType": "MULTIPART",
6
- "mediaSubtype": "mixed"
7
- },
8
- {
9
- "partPath": "1",
10
- "isMultipart": true,
11
- "mediaType": "MULTIPART",
12
- "mediaSubtype": "alternative"
13
- },
14
- {
15
- "partPath": "1.1",
16
- "isMultipart": false,
17
- "mediaType": "TEXT",
18
- "mediaSubtype": "plain"
19
- },
20
- {
21
- "partPath": "1.2",
22
- "isMultipart": false,
23
- "mediaType": "TEXT",
24
- "mediaSubtype": "html"
25
- },
26
- {
27
- "partPath": "2",
28
- "isMultipart": true,
29
- "mediaType": "MULTIPART",
30
- "mediaSubtype": "related"
31
- },
32
- {
33
- "partPath": "2.1",
34
- "isMultipart": false,
35
- "mediaType": "TEXT",
36
- "mediaSubtype": "html"
37
- },
38
- {
39
- "partPath": "2.2",
40
- "isMultipart": false,
41
- "mediaType": "IMAGE",
42
- "mediaSubtype": "png",
43
- "contentId": "hero@example.org",
44
- "dispositionFilename": "hero.png",
45
- "disposition": "inline"
46
- },
47
- {
48
- "partPath": "3",
49
- "isMultipart": false,
50
- "mediaType": "APPLICATION",
51
- "mediaSubtype": "pdf",
52
- "dispositionFilename": "report.pdf",
53
- "disposition": "attachment"
54
- }
55
- ]