@wraps.dev/cli 3.17.3 → 3.17.4

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.
@@ -18,6 +18,7 @@ const ebMock = mockClient(EventBridgeClient);
18
18
  const ssmMock = mockClient(SSMClient);
19
19
 
20
20
  const PARSED_BUCKET = "parsed-bucket";
21
+ const INTERNAL_FAILURE = /InternalFailure/;
21
22
 
22
23
  type EmailFixture = {
23
24
  to?: Array<{ address: string }>;
@@ -25,6 +26,8 @@ type EmailFixture = {
25
26
  from?: { address: string; name?: string };
26
27
  headers?: [string, unknown][];
27
28
  subject?: string;
29
+ html?: string;
30
+ text?: string;
28
31
  };
29
32
 
30
33
  const parsedState: { queue: EmailFixture[] } = { queue: [] };
@@ -48,8 +51,8 @@ function parseFromFixture(fx: EmailFixture) {
48
51
  ccValue.length > 0
49
52
  ? { value: ccValue, text: ccValue.map((t) => t.address).join(", ") }
50
53
  : undefined,
51
- html: "<p>hi</p>",
52
- text: "hi",
54
+ html: fx.html ?? "<p>hi</p>",
55
+ text: fx.text ?? "hi",
53
56
  headers,
54
57
  attachments: [],
55
58
  date: new Date("2026-01-01T00:00:00Z"),
@@ -63,9 +66,14 @@ vi.mock("mailparser", () => ({
63
66
  }),
64
67
  }));
65
68
 
66
- function singleS3Event(key = "raw/abc"): S3Event {
69
+ function singleS3Event(key = "raw/abc", eventTime?: string): S3Event {
67
70
  return {
68
- Records: [{ s3: { bucket: { name: "raw-bucket" }, object: { key } } }],
71
+ Records: [
72
+ {
73
+ ...(eventTime ? { eventTime } : {}),
74
+ s3: { bucket: { name: "raw-bucket" }, object: { key } },
75
+ },
76
+ ],
69
77
  } as unknown as S3Event;
70
78
  }
71
79
 
@@ -83,6 +91,8 @@ function makeCtx(): Context {
83
91
 
84
92
  async function capturedPut(idx = 0): Promise<{
85
93
  source?: string;
94
+ detailType?: string;
95
+ rawDetail: string;
86
96
  detail: Record<string, unknown>;
87
97
  }> {
88
98
  const calls = ebMock.commandCalls(PutEventsCommand);
@@ -92,10 +102,25 @@ async function capturedPut(idx = 0): Promise<{
92
102
  const entry = calls[idx].args[0].input.Entries?.[0];
93
103
  return {
94
104
  source: entry?.Source,
105
+ detailType: entry?.DetailType,
106
+ rawDetail: entry?.Detail ?? "",
95
107
  detail: JSON.parse(entry?.Detail ?? "{}") as Record<string, unknown>,
96
108
  };
97
109
  }
98
110
 
111
+ function capturedParsedObject(): { key: string; body: string } {
112
+ const call = s3Mock
113
+ .commandCalls(PutObjectCommand)
114
+ .find((c) => c.args[0].input.Key?.startsWith("parsed/"));
115
+ if (!call) {
116
+ throw new Error("no parsed/ PutObject call");
117
+ }
118
+ return {
119
+ key: call.args[0].input.Key as string,
120
+ body: call.args[0].input.Body as string,
121
+ };
122
+ }
123
+
99
124
  async function runHandler(event: S3Event = singleS3Event()) {
100
125
  const { handler } = await import("./index.ts");
101
126
  await handler(event, makeCtx());
@@ -118,6 +143,8 @@ beforeEach(() => {
118
143
  process.env.BUCKET_NAME = PARSED_BUCKET;
119
144
  // biome-ignore lint/performance/noDelete: process.env coerces `= undefined` to the string "undefined" (truthy). Actual key removal is required so `Boolean(process.env.REPLY_SECRET_PARAMETER_PREFIX)` is false in the feature-disabled tests.
120
145
  delete process.env.REPLY_SECRET_PARAMETER_PREFIX;
146
+ // A 1 MB-limit Region; tests that need the 256 KB limit override it.
147
+ process.env.AWS_REGION = "eu-west-1";
121
148
 
122
149
  // Fresh module — re-initializes the module-scope domainSecretCache per test.
123
150
  vi.resetModules();
@@ -574,3 +601,336 @@ describe("inbound-processor — feature-disabled path", () => {
574
601
  expect(ssmMock.commandCalls(GetParameterCommand).length).toBe(0);
575
602
  });
576
603
  });
604
+
605
+ describe("inbound-processor — EventBridge entry size", () => {
606
+ const MB = 1024 * 1024;
607
+
608
+ async function entryBytes(): Promise<number> {
609
+ const { putEventsEntrySize } = await import("./index.ts");
610
+ const ev = await capturedPut();
611
+ return putEventsEntrySize({
612
+ Source: ev.source ?? "",
613
+ DetailType: ev.detailType ?? "",
614
+ Detail: ev.rawDetail,
615
+ });
616
+ }
617
+
618
+ it("sends a small mail unchanged: detail equals the parsed JSON in S3", async () => {
619
+ parsedState.queue = [
620
+ {
621
+ from: { address: "sender@external.com" },
622
+ to: [{ address: "inbox@support.foo.com" }],
623
+ subject: "hello",
624
+ },
625
+ ];
626
+
627
+ await runHandler();
628
+
629
+ const ev = await capturedPut();
630
+ expect(ev.rawDetail).toBe(capturedParsedObject().body);
631
+ expect(ev.detail.truncated).toBeUndefined();
632
+ expect(ev.detail.parsedS3Key).toBeUndefined();
633
+ expect(ev.detail.html).toBe("<p>hi</p>");
634
+ expect(ev.detail.text).toBe("hi");
635
+ });
636
+
637
+ it("trims a huge text body under the limit and points at the parsed JSON", async () => {
638
+ const hugeText = "x".repeat(2 * MB);
639
+ parsedState.queue = [
640
+ {
641
+ from: { address: "sender@external.com" },
642
+ to: [{ address: "inbox@support.foo.com" }],
643
+ cc: [{ address: "cc@support.foo.com" }],
644
+ subject: "big one",
645
+ text: hugeText,
646
+ headers: [["x-ses-spam-verdict", "PASS"]],
647
+ },
648
+ ];
649
+
650
+ await runHandler(singleS3Event("raw/big"));
651
+
652
+ const { maxEventEntryBytes } = await import("./index.ts");
653
+ const ev = await capturedPut();
654
+ const parsedObject = capturedParsedObject();
655
+ expect(await entryBytes()).toBeLessThanOrEqual(maxEventEntryBytes());
656
+ expect(maxEventEntryBytes()).toBeLessThan(MB);
657
+ expect(ev.detail.truncated).toBe(true);
658
+ expect(ev.detail.parsedS3Key).toBe(parsedObject.key);
659
+ expect(ev.detail.parsedS3Key).toBe(`parsed/${ev.detail.emailId}.json`);
660
+ // Only the oversized field goes; the small html stays inline.
661
+ expect(ev.detail.truncatedFields).toEqual(["text"]);
662
+ expect(ev.detail.text).toBeNull();
663
+ expect(ev.detail.html).toBe("<p>hi</p>");
664
+ expect(ev.detail.htmlTruncated).toBe(false);
665
+ // Identifying fields survive.
666
+ expect(ev.detail.rawS3Key).toBe("raw/big");
667
+ expect(ev.detail.subject).toBe("big one");
668
+ expect(ev.detail.from).toEqual({
669
+ address: "sender@external.com",
670
+ name: "",
671
+ });
672
+ expect(ev.detail.to).toEqual([
673
+ { address: "inbox@support.foo.com", name: "" },
674
+ ]);
675
+ expect(ev.detail.cc).toEqual([{ address: "cc@support.foo.com", name: "" }]);
676
+ expect(ev.detail.spamVerdict).toBe("PASS");
677
+ expect(ev.detail.headers).toEqual({ "x-ses-spam-verdict": "PASS" });
678
+ expect(ev.detail.replyToken).toBeNull();
679
+ expect(ev.detail.autoReply).toBe(false);
680
+ // S3 keeps the full email.
681
+ expect(JSON.parse(parsedObject.body).text).toBe(hugeText);
682
+ });
683
+
684
+ it("counts UTF-8 bytes, not characters", async () => {
685
+ // 400k CJK chars: under 1 MB in characters, 1.2 MB in UTF-8 bytes.
686
+ const cjkText = "中".repeat(400_000);
687
+ parsedState.queue = [
688
+ {
689
+ from: { address: "sender@external.com" },
690
+ to: [{ address: "inbox@support.foo.com" }],
691
+ text: cjkText,
692
+ },
693
+ ];
694
+
695
+ await runHandler();
696
+
697
+ const { maxEventEntryBytes } = await import("./index.ts");
698
+ const ev = await capturedPut();
699
+ expect(cjkText.length).toBeLessThan(maxEventEntryBytes());
700
+ expect(ev.detail.truncated).toBe(true);
701
+ expect(ev.detail.text).toBeNull();
702
+ expect(await entryBytes()).toBeLessThanOrEqual(maxEventEntryBytes());
703
+ });
704
+
705
+ it("uses the 256 KB limit in Regions without the 1 MB increase, dropping the largest body field", async () => {
706
+ process.env.AWS_REGION = "mx-central-1";
707
+ parsedState.queue = [
708
+ {
709
+ from: { address: "sender@external.com" },
710
+ to: [{ address: "inbox@support.foo.com" }],
711
+ html: `<p>${"y".repeat(160 * 1024)}</p>`,
712
+ text: "z".repeat(150 * 1024),
713
+ },
714
+ ];
715
+
716
+ await runHandler();
717
+
718
+ const { maxEventEntryBytes } = await import("./index.ts");
719
+ const ev = await capturedPut();
720
+ expect(maxEventEntryBytes()).toBeLessThan(256 * 1024);
721
+ expect(await entryBytes()).toBeLessThanOrEqual(maxEventEntryBytes());
722
+ expect(ev.detail.truncatedFields).toEqual(["html"]);
723
+ expect(ev.detail.html).toBeNull();
724
+ expect(ev.detail.text).toBe("z".repeat(150 * 1024));
725
+ });
726
+
727
+ it("keeps a ~300 KB mail inline in a 1 MB Region", async () => {
728
+ parsedState.queue = [
729
+ {
730
+ from: { address: "sender@external.com" },
731
+ to: [{ address: "inbox@support.foo.com" }],
732
+ html: `<p>${"y".repeat(150 * 1024)}</p>`,
733
+ text: "z".repeat(150 * 1024),
734
+ },
735
+ ];
736
+
737
+ await runHandler();
738
+
739
+ const ev = await capturedPut();
740
+ expect(ev.detail.truncated).toBeUndefined();
741
+ expect(ev.rawDetail).toBe(capturedParsedObject().body);
742
+ });
743
+
744
+ it("throws when PutEvents reports a failed entry, so the Lambda retries", async () => {
745
+ ebMock.reset();
746
+ ebMock.on(PutEventsCommand).resolves({
747
+ FailedEntryCount: 1,
748
+ Entries: [
749
+ {
750
+ ErrorCode: "InternalFailure",
751
+ ErrorMessage: "Internal Service Failure",
752
+ },
753
+ ],
754
+ });
755
+ parsedState.queue = [
756
+ {
757
+ from: { address: "sender@external.com" },
758
+ to: [{ address: "inbox@support.foo.com" }],
759
+ },
760
+ ];
761
+
762
+ await expect(runHandler()).rejects.toThrow(INTERNAL_FAILURE);
763
+ });
764
+ });
765
+
766
+ describe("buildEventDetail", () => {
767
+ const base = {
768
+ emailId: "inb_1",
769
+ messageId: "<m@x>",
770
+ receivingDomain: "x.com",
771
+ from: { address: "a@x.com", name: "" },
772
+ to: [{ address: "b@x.com", name: "" }],
773
+ cc: [],
774
+ subject: "s",
775
+ date: "2026-01-01T00:00:00.000Z",
776
+ html: null,
777
+ htmlTruncated: false,
778
+ text: null,
779
+ headers: {},
780
+ attachments: [],
781
+ spamVerdict: null,
782
+ virusVerdict: null,
783
+ rawS3Key: "raw/1",
784
+ receivedAt: "2026-01-01T00:00:00.000Z",
785
+ replyToken: null,
786
+ autoReply: false,
787
+ };
788
+
789
+ it("drops attachments after the body fields, keeping their count", async () => {
790
+ const { buildEventDetail } = await import("./index.ts");
791
+ const attachments = Array.from({ length: 2000 }, (_, i) => ({
792
+ id: `att_${i}`,
793
+ filename: `file-${i}-${"n".repeat(100)}.pdf`,
794
+ contentType: "application/pdf",
795
+ size: 1,
796
+ s3Key: `attachments/inb_1/att_${i}-file.pdf`,
797
+ contentDisposition: "attachment",
798
+ cid: null,
799
+ }));
800
+ const { detail, truncatedFields } = buildEventDetail(
801
+ { ...base, text: "t".repeat(1000), attachments },
802
+ "parsed/inb_1.json",
803
+ "wraps.inbound",
804
+ 64 * 1024
805
+ );
806
+ const parsed = JSON.parse(detail);
807
+ // html and headers were already empty, so they are not listed.
808
+ expect(truncatedFields).toEqual(["text", "attachments"]);
809
+ expect(parsed.htmlTruncated).toBe(false);
810
+ expect(parsed.attachments).toEqual([]);
811
+ expect(parsed.attachmentCount).toBe(2000);
812
+ expect(parsed.to).toEqual(base.to);
813
+ expect(parsed.parsedS3Key).toBe("parsed/inb_1.json");
814
+ });
815
+ });
816
+
817
+ describe("inbound-processor — envelope fallback", () => {
818
+ it("sends a bounded envelope when an unbounded field like from.name is huge", async () => {
819
+ process.env.AWS_REGION = "eu-west-1";
820
+ parsedState.queue = [
821
+ {
822
+ from: {
823
+ address: "sender@external.com",
824
+ name: "n".repeat(2 * 1024 * 1024),
825
+ },
826
+ to: [{ address: "inbox@support.foo.com" }],
827
+ },
828
+ ];
829
+
830
+ await runHandler(singleS3Event("raw/huge-name"));
831
+
832
+ const { maxEventEntryBytes, putEventsEntrySize } = await import(
833
+ "./index.ts"
834
+ );
835
+ const ev = await capturedPut();
836
+ expect(
837
+ putEventsEntrySize({
838
+ Source: ev.source ?? "",
839
+ DetailType: ev.detailType ?? "",
840
+ Detail: ev.rawDetail,
841
+ })
842
+ ).toBeLessThanOrEqual(maxEventEntryBytes());
843
+ expect(ev.detail.truncated).toBe(true);
844
+ expect(ev.detail.rawS3Key).toBe("raw/huge-name");
845
+ expect(ev.detail.parsedS3Key).toBe(capturedParsedObject().key);
846
+ expect(ev.detail.from).toBeUndefined();
847
+ expect(ev.detail.replyToken).toBeNull();
848
+ expect((ev.detail.truncatedFields as string[]).at(-1)).toBe("envelope");
849
+ });
850
+ });
851
+
852
+ describe("inbound-processor — retry-stable ids and timestamps", () => {
853
+ const FIXTURE: EmailFixture = {
854
+ from: { address: "sender@external.com" },
855
+ to: [{ address: "inbox@support.foo.com" }],
856
+ headers: [],
857
+ };
858
+ const EMAIL_ID_SHAPE = /^inb_[0-9a-f]{16}$/;
859
+ const ISO_UTC = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/;
860
+
861
+ // Runs the handler once and returns what it wrote, then clears call history
862
+ // so the next invocation (a simulated retry) is read in isolation.
863
+ async function invokeAndCapture(event: S3Event) {
864
+ parsedState.queue = [{ ...FIXTURE }];
865
+ await runHandler(event);
866
+ const parsedObject = capturedParsedObject();
867
+ const ev = await capturedPut();
868
+ s3Mock.resetHistory();
869
+ ebMock.resetHistory();
870
+ return {
871
+ parsedKey: parsedObject.key,
872
+ parsedBody: JSON.parse(parsedObject.body) as Record<string, unknown>,
873
+ eventDetail: ev.detail,
874
+ };
875
+ }
876
+
877
+ it("derives the same emailId and parsed key when the same raw object is retried", async () => {
878
+ const first = await invokeAndCapture(singleS3Event("raw/retry-me"));
879
+ const second = await invokeAndCapture(singleS3Event("raw/retry-me"));
880
+
881
+ expect(second.parsedBody.emailId).toBe(first.parsedBody.emailId);
882
+ expect(second.parsedKey).toBe(first.parsedKey);
883
+ expect(first.parsedKey).toBe(`parsed/${first.parsedBody.emailId}.json`);
884
+ expect(second.eventDetail.emailId).toBe(first.eventDetail.emailId);
885
+ expect(first.eventDetail.emailId).toBe(first.parsedBody.emailId);
886
+ });
887
+
888
+ it("derives different emailIds for different raw objects", async () => {
889
+ const a = await invokeAndCapture(singleS3Event("raw/one"));
890
+ const b = await invokeAndCapture(singleS3Event("raw/two"));
891
+
892
+ expect(a.parsedBody.emailId).not.toBe(b.parsedBody.emailId);
893
+ expect(a.parsedKey).not.toBe(b.parsedKey);
894
+ });
895
+
896
+ it("emits an emailId of the form inb_ plus 16 hex chars", async () => {
897
+ const { parsedBody, eventDetail } = await invokeAndCapture(
898
+ singleS3Event("raw/shape")
899
+ );
900
+
901
+ expect(parsedBody.emailId).toMatch(EMAIL_ID_SHAPE);
902
+ expect(eventDetail.emailId).toMatch(EMAIL_ID_SHAPE);
903
+ });
904
+
905
+ it("uses the S3 record eventTime as receivedAt on every attempt", async () => {
906
+ const eventTime = "2026-01-02T03:04:05.678Z";
907
+ const first = await invokeAndCapture(singleS3Event("raw/timed", eventTime));
908
+ const second = await invokeAndCapture(
909
+ singleS3Event("raw/timed", eventTime)
910
+ );
911
+
912
+ expect(first.parsedBody.receivedAt).toBe(eventTime);
913
+ expect(second.parsedBody.receivedAt).toBe(eventTime);
914
+ expect(first.eventDetail.receivedAt).toBe(eventTime);
915
+ expect(second.eventDetail.receivedAt).toBe(eventTime);
916
+ });
917
+
918
+ it("normalises a non-canonical eventTime to UTC ISO-8601", async () => {
919
+ const { parsedBody } = await invokeAndCapture(
920
+ singleS3Event("raw/offset", "2026-01-02T04:04:05.678+01:00")
921
+ );
922
+
923
+ expect(parsedBody.receivedAt).toBe("2026-01-02T03:04:05.678Z");
924
+ });
925
+
926
+ it("falls back to a valid ISO receivedAt when the record has no eventTime", async () => {
927
+ const before = Date.now();
928
+ const { parsedBody } = await invokeAndCapture(singleS3Event("raw/untimed"));
929
+ const after = Date.now();
930
+
931
+ const receivedAt = parsedBody.receivedAt as string;
932
+ expect(receivedAt).toMatch(ISO_UTC);
933
+ expect(Date.parse(receivedAt)).toBeGreaterThanOrEqual(before);
934
+ expect(Date.parse(receivedAt)).toBeLessThanOrEqual(after);
935
+ });
936
+ });