@kensio/yulin 1.21.1 → 1.21.2

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 (36) hide show
  1. package/dist/service/ses/command/configuration-set/sim-ses-configuration-set-commands.js +13 -5
  2. package/dist/service/ses/command/configuration-set/sim-ses-configuration-set-options.d.ts +1 -1
  3. package/dist/service/ses/command/configuration-set/sim-ses-configuration-set-options.js +9 -9
  4. package/dist/service/ses/command/send/send.command.d.ts +10 -1
  5. package/dist/service/ses/command/send/sim-ses-read-content.d.ts +2 -1
  6. package/dist/service/ses/command/send/sim-ses-send-email.js +1 -0
  7. package/dist/service/ses/command/send/sim-ses-service-send.js +1 -0
  8. package/dist/service/ses/command/send/sim-ses-simple-content.js +26 -5
  9. package/dist/service/ses/command/send/sim-ses-template-send.js +1 -0
  10. package/dist/service/ses/command/suppression/suppression.command.d.ts +3 -2
  11. package/dist/service/ses/configuration-set/sim-ses-configuration-set.d.ts +9 -10
  12. package/dist/service/ses/configuration-set/sim-ses-configuration-set.js +7 -8
  13. package/dist/service/ses/email/sim-ses-sent-email-store.d.ts +8 -0
  14. package/dist/service/ses/email/sim-ses-sent-email-store.js +17 -0
  15. package/dist/service/ses/email/sim-ses-sent-email.d.ts +18 -0
  16. package/dist/service/ses/email/sim-ses-sent-email.js +3 -0
  17. package/dist/service/ses/error/sim-ses.error.d.ts +10 -0
  18. package/dist/service/ses/error/sim-ses.error.js +10 -0
  19. package/dist/service/ses/feedback/sim-ses-feedback-recorder.d.ts +34 -0
  20. package/dist/service/ses/feedback/sim-ses-feedback-recorder.js +55 -0
  21. package/dist/service/ses/index.d.ts +3 -2
  22. package/dist/service/ses/index.js +2 -1
  23. package/dist/service/ses/sim-ses-commands.d.ts +2 -0
  24. package/dist/service/ses/sim-ses-commands.js +9 -0
  25. package/dist/service/ses/sim-ses-configuration-sets.d.ts +4 -4
  26. package/dist/service/ses/sim-ses-configuration-sets.js +4 -4
  27. package/dist/service/ses/sim-ses-suppression.d.ts +3 -2
  28. package/dist/service/ses/sim-ses-suppression.js +3 -2
  29. package/dist/service/ses/sim-ses-v2.d.ts +13 -0
  30. package/dist/service/ses/sim-ses-v2.js +13 -0
  31. package/dist/service/ses/suppression/sim-ses-suppressed-destination.d.ts +3 -4
  32. package/dist/service/ses/suppression/sim-ses-suppressed-destination.js +3 -4
  33. package/dist/service/ses/suppression/sim-ses-suppression-list.d.ts +4 -4
  34. package/dist/service/ses/suppression/sim-ses-suppression-list.js +4 -4
  35. package/docs/services/ses/README.md +164 -41
  36. package/package.json +1 -1
@@ -82,15 +82,13 @@ export class SimSesConfigurationSetCommands {
82
82
  /**
83
83
  * What GetConfigurationSet answers with.
84
84
  *
85
- * Every group is reported, including the ones the set never declared, because
86
- * real SES answers with the defaults it applied rather than leaving them out.
85
+ * Sending, delivery and reputation options report their applied defaults.
86
+ * Suppression options stay absent unless the set declared an override.
87
87
  */
88
88
  function reported(configurationSet) {
89
89
  return {
90
90
  ConfigurationSetName: configurationSet.configurationSetName,
91
- SuppressionOptions: {
92
- SuppressedReasons: configurationSet.suppressedReasons,
93
- },
91
+ ...reportedSuppressionOptions(configurationSet),
94
92
  SendingOptions: { SendingEnabled: configurationSet.sendingEnabled },
95
93
  DeliveryOptions: {
96
94
  TlsPolicy: configurationSet.deliveryOptions.tlsPolicy,
@@ -102,3 +100,13 @@ function reported(configurationSet) {
102
100
  },
103
101
  };
104
102
  }
103
+ function reportedSuppressionOptions(configurationSet) {
104
+ if (configurationSet.suppressedReasons === undefined) {
105
+ return {};
106
+ }
107
+ return {
108
+ SuppressionOptions: {
109
+ SuppressedReasons: configurationSet.suppressedReasons,
110
+ },
111
+ };
112
+ }
@@ -5,6 +5,6 @@ import type { SimCreateConfigurationSetCommandInput } from "./configuration-set.
5
5
  * refuses.
6
6
  *
7
7
  * A set declaring none of these gets the defaults real SES applies: sending
8
- * on, no suppression reasons, optional TLS and no reputation metrics.
8
+ * on, account-level suppression, optional TLS and no reputation metrics.
9
9
  */
10
10
  export declare function readSimSesConfigurationSetOptions(input: SimCreateConfigurationSetCommandInput): SimSesConfigurationSetOptions;
@@ -11,11 +11,11 @@ const tlsPolicies = new Set(["REQUIRE", "OPTIONAL"]);
11
11
  * refuses.
12
12
  *
13
13
  * A set declaring none of these gets the defaults real SES applies: sending
14
- * on, no suppression reasons, optional TLS and no reputation metrics.
14
+ * on, account-level suppression, optional TLS and no reputation metrics.
15
15
  */
16
16
  export function readSimSesConfigurationSetOptions(input) {
17
17
  return {
18
- suppressedReasons: readSuppressedReasons(input.SuppressionOptions?.SuppressedReasons),
18
+ suppressedReasons: readSuppressedReasons(input.SuppressionOptions),
19
19
  sendingEnabled: input.SendingOptions?.SendingEnabled ?? true,
20
20
  deliveryOptions: readDeliveryOptions(input.DeliveryOptions),
21
21
  reputationOptions: {
@@ -26,15 +26,15 @@ export function readSimSesConfigurationSetOptions(input) {
26
26
  /**
27
27
  * The suppression reasons a set names, refusing one SES has no meaning for.
28
28
  *
29
- * An absent `SuppressionOptions` leaves this empty. Real SES falls back to the
30
- * account-level setting there, and this simulation has no account suppression
31
- * list to fall back to.
29
+ * An absent `SuppressionOptions` returns undefined, which preserves the
30
+ * account-level fallback. A present group with no reasons returns an empty
31
+ * override and disables suppression for messages sent through the set.
32
32
  */
33
- function readSuppressedReasons(reasons) {
34
- if (reasons === undefined) {
35
- return [];
33
+ function readSuppressedReasons(options) {
34
+ if (options === undefined) {
35
+ return undefined;
36
36
  }
37
- return reasons.map((reason) => {
37
+ return (options.SuppressedReasons ?? []).map((reason) => {
38
38
  if (!isSuppressedReason(reason)) {
39
39
  throw new SimSesBadRequestException(`1 validation error detected: Value '${reason}' at ` +
40
40
  `'suppressionOptions.suppressedReasons' failed to satisfy ` +
@@ -11,11 +11,20 @@ export interface SimSesBody {
11
11
  readonly Text?: SimSesContent | undefined;
12
12
  readonly Html?: SimSesContent | undefined;
13
13
  }
14
+ export interface SimSesAttachment {
15
+ readonly RawContent?: Uint8Array | undefined;
16
+ readonly ContentDisposition?: "ATTACHMENT" | "INLINE" | undefined;
17
+ readonly FileName?: string | undefined;
18
+ readonly ContentDescription?: string | undefined;
19
+ readonly ContentId?: string | undefined;
20
+ readonly ContentTransferEncoding?: "BASE64" | "QUOTED_PRINTABLE" | "SEVEN_BIT" | undefined;
21
+ readonly ContentType?: string | undefined;
22
+ }
14
23
  export interface SimSesMessage {
15
24
  readonly Subject?: SimSesContent | undefined;
16
25
  readonly Body?: SimSesBody | undefined;
17
26
  readonly Headers?: readonly unknown[] | undefined;
18
- readonly Attachments?: readonly unknown[] | undefined;
27
+ readonly Attachments?: readonly SimSesAttachment[] | undefined;
19
28
  }
20
29
  export interface SimSesRawMessage {
21
30
  readonly Data?: Uint8Array | string | undefined;
@@ -1,4 +1,4 @@
1
- import type { SimSesSentEmailBody } from "../../email/sim-ses-sent-email.js";
1
+ import type { SimSesSentEmailAttachment, SimSesSentEmailBody } from "../../email/sim-ses-sent-email.js";
2
2
  /**
3
3
  * What a message says, read out of the content of a send.
4
4
  *
@@ -11,6 +11,7 @@ import type { SimSesSentEmailBody } from "../../email/sim-ses-sent-email.js";
11
11
  export interface SimSesReadContent {
12
12
  readonly subject: string;
13
13
  readonly body: SimSesSentEmailBody;
14
+ readonly attachments: readonly SimSesSentEmailAttachment[];
14
15
  /** The template this was rendered from, if it was rendered from a stored one. */
15
16
  readonly templateName: string | undefined;
16
17
  /** The data the placeholders were filled from, parsed out of the JSON. */
@@ -65,6 +65,7 @@ export class SimSesSendEmail {
65
65
  replyToAddresses: [...(input.ReplyToAddresses ?? [])],
66
66
  subject: content.subject,
67
67
  body: content.body,
68
+ attachments: content.attachments,
68
69
  templateName: content.templateName,
69
70
  templateData: content.templateData,
70
71
  configurationSetName,
@@ -62,6 +62,7 @@ export class SimSesServiceSend {
62
62
  replyToAddresses: [...request.replyToAddresses],
63
63
  subject: request.subject,
64
64
  body: { text: request.body, html: undefined },
65
+ attachments: [],
65
66
  templateName: undefined,
66
67
  templateData: undefined,
67
68
  configurationSetName,
@@ -1,4 +1,4 @@
1
- import { SimSesBadRequestException, SimSesUnsupportedOperationException, } from "../../error/sim-ses.error.js";
1
+ import { SimSesBadRequestException } from "../../error/sim-ses.error.js";
2
2
  /**
3
3
  * Read a message a send wrote out in full, refusing what real SES refuses and
4
4
  * what this simulation does not model.
@@ -8,10 +8,6 @@ import { SimSesBadRequestException, SimSesUnsupportedOperationException, } from
8
8
  * not have accepted.
9
9
  */
10
10
  export function readSimSesSimpleMessage(message) {
11
- if (message.Attachments !== undefined) {
12
- throw new SimSesUnsupportedOperationException("Attachments are not simulated, so SendEmail refuses them rather than " +
13
- "recording a message without them");
14
- }
15
11
  const subject = message.Subject?.Data;
16
12
  if (subject === undefined) {
17
13
  throw new SimSesBadRequestException("1 validation error detected: Value at 'content.simple.subject' " +
@@ -26,7 +22,32 @@ export function readSimSesSimpleMessage(message) {
26
22
  return {
27
23
  subject,
28
24
  body: { text, html },
25
+ attachments: (message.Attachments ?? []).map((attachment, index) => readAttachment(attachment, index)),
29
26
  templateName: undefined,
30
27
  templateData: undefined,
31
28
  };
32
29
  }
30
+ /** Read and validate one attachment from a simple message. */
31
+ function readAttachment(attachment, index) {
32
+ if (attachment.RawContent === undefined) {
33
+ throw missingAttachmentMember(index, "rawContent");
34
+ }
35
+ if (attachment.FileName === undefined) {
36
+ throw missingAttachmentMember(index, "fileName");
37
+ }
38
+ return {
39
+ rawContent: Uint8Array.from(attachment.RawContent),
40
+ fileName: attachment.FileName,
41
+ contentType: attachment.ContentType,
42
+ contentDisposition: attachment.ContentDisposition,
43
+ contentDescription: attachment.ContentDescription,
44
+ contentId: attachment.ContentId,
45
+ contentTransferEncoding: attachment.ContentTransferEncoding,
46
+ };
47
+ }
48
+ /** Build the SES validation error for a missing attachment member. */
49
+ function missingAttachmentMember(index, member) {
50
+ return new SimSesBadRequestException("1 validation error detected: Value at " +
51
+ `'content.simple.attachments.${String(index)}.${member}' failed to ` +
52
+ "satisfy constraint: Member must not be null");
53
+ }
@@ -35,6 +35,7 @@ export class SimSesTemplateSendReader {
35
35
  text: renderOptional(content.text, data),
36
36
  html: renderOptional(content.html, data),
37
37
  },
38
+ attachments: [],
38
39
  templateName: template.TemplateName,
39
40
  templateData: data,
40
41
  };
@@ -2,8 +2,9 @@ import type { SimResponseMetadata } from "../../../aws/metadata/response-metadat
2
2
  /**
3
3
  * What a suppression list entry says, in the shape the SES v2 API reports it.
4
4
  *
5
- * `Attributes` is left out. It describes the bounce or complaint SES put the
6
- * address on the list for, and nothing bounces here.
5
+ * `Attributes` is left out. Real SES uses it for details of the feedback event
6
+ * that put the address on the list. Explicit simulator feedback records the
7
+ * reason and time only.
7
8
  */
8
9
  export interface SimSesSuppressedDestinationDetail {
9
10
  readonly EmailAddress: string;
@@ -32,7 +32,7 @@ export interface SimSesConfigurationSetReputationOptions {
32
32
  * Everything a configuration set holds apart from its name.
33
33
  */
34
34
  export interface SimSesConfigurationSetOptions {
35
- readonly suppressedReasons: readonly SimSesSuppressedReason[];
35
+ readonly suppressedReasons: readonly SimSesSuppressedReason[] | undefined;
36
36
  readonly sendingEnabled: boolean;
37
37
  readonly deliveryOptions: SimSesConfigurationSetDeliveryOptions;
38
38
  readonly reputationOptions: SimSesConfigurationSetReputationOptions;
@@ -48,11 +48,9 @@ interface SimSesConfigurationSetProperties {
48
48
  * A configuration set is a named group of settings a send can be made under,
49
49
  * covering suppression, whether sending is on, how the message is handed on
50
50
  * and whether reputation metrics are published. None of it changes what a
51
- * message says, which is why this is state to read back rather than behaviour
52
- * to reproduce.
53
- *
54
- * Sending is the exception, and it is left to the work that attaches a set to
55
- * a send. Here the switch is recorded and acted on by nothing.
51
+ * message says. The sending switch controls acceptance, and the suppression
52
+ * reasons control feedback. Delivery and reputation options remain state for
53
+ * a test to read back.
56
54
  */
57
55
  export declare class SimSesConfigurationSet {
58
56
  readonly configurationSetName: string;
@@ -60,11 +58,12 @@ export declare class SimSesConfigurationSet {
60
58
  /**
61
59
  * The reasons that would put a recipient on the account suppression list.
62
60
  *
63
- * Empty where the set declares no `SuppressionOptions`. Real SES falls back
64
- * to the account setting there, and this simulation has no account
65
- * suppression list to fall back to.
61
+ * Undefined where the set declares no `SuppressionOptions`, which makes a
62
+ * send fall back to the account setting. An empty list is an explicit
63
+ * override that disables suppression for feedback recorded for a message
64
+ * sent through this set.
66
65
  */
67
- readonly suppressedReasons: readonly SimSesSuppressedReason[];
66
+ readonly suppressedReasons: readonly SimSesSuppressedReason[] | undefined;
68
67
  /** Whether SES would accept a send made through this set. */
69
68
  readonly sendingEnabled: boolean;
70
69
  readonly deliveryOptions: SimSesConfigurationSetDeliveryOptions;
@@ -10,11 +10,9 @@ export const simSesSuppressedReasons = [
10
10
  * A configuration set is a named group of settings a send can be made under,
11
11
  * covering suppression, whether sending is on, how the message is handed on
12
12
  * and whether reputation metrics are published. None of it changes what a
13
- * message says, which is why this is state to read back rather than behaviour
14
- * to reproduce.
15
- *
16
- * Sending is the exception, and it is left to the work that attaches a set to
17
- * a send. Here the switch is recorded and acted on by nothing.
13
+ * message says. The sending switch controls acceptance, and the suppression
14
+ * reasons control feedback. Delivery and reputation options remain state for
15
+ * a test to read back.
18
16
  */
19
17
  export class SimSesConfigurationSet {
20
18
  configurationSetName;
@@ -22,9 +20,10 @@ export class SimSesConfigurationSet {
22
20
  /**
23
21
  * The reasons that would put a recipient on the account suppression list.
24
22
  *
25
- * Empty where the set declares no `SuppressionOptions`. Real SES falls back
26
- * to the account setting there, and this simulation has no account
27
- * suppression list to fall back to.
23
+ * Undefined where the set declares no `SuppressionOptions`, which makes a
24
+ * send fall back to the account setting. An empty list is an explicit
25
+ * override that disables suppression for feedback recorded for a message
26
+ * sent through this set.
28
27
  */
29
28
  suppressedReasons;
30
29
  /** Whether SES would accept a send made through this set. */
@@ -26,6 +26,14 @@ export declare class SimSesSentEmailStore {
26
26
  * Every message this scope has accepted, oldest first.
27
27
  */
28
28
  get all(): readonly SimSesSentEmail[];
29
+ /**
30
+ * Find an accepted message by the id SES returned for it.
31
+ */
32
+ find(messageId: string): SimSesSentEmail | undefined;
33
+ /**
34
+ * Get an accepted message by id, refusing an id this scope never issued.
35
+ */
36
+ require(messageId: string): SimSesSentEmail;
29
37
  /**
30
38
  * Keep a message SES has accepted.
31
39
  *
@@ -1,4 +1,5 @@
1
1
  import { SimAwsMessageLog } from "../../aws/message/sim-aws-message-log.js";
2
+ import { SimSesFeedbackError } from "../error/sim-ses.error.js";
2
3
  const hoursInADay = 24;
3
4
  const millisecondsInAnHour = 60 * 60 * 1000;
4
5
  /**
@@ -25,6 +26,22 @@ export class SimSesSentEmailStore {
25
26
  get all() {
26
27
  return [...this.#sent];
27
28
  }
29
+ /**
30
+ * Find an accepted message by the id SES returned for it.
31
+ */
32
+ find(messageId) {
33
+ return this.#sent.find((email) => email.messageId === messageId);
34
+ }
35
+ /**
36
+ * Get an accepted message by id, refusing an id this scope never issued.
37
+ */
38
+ require(messageId) {
39
+ const email = this.find(messageId);
40
+ if (email === undefined) {
41
+ throw new SimSesFeedbackError(`No accepted sim SES message has id ${messageId}.`);
42
+ }
43
+ return email;
44
+ }
28
45
  /**
29
46
  * Keep a message SES has accepted.
30
47
  *
@@ -19,6 +19,21 @@ export interface SimSesSentEmailBody {
19
19
  readonly text: string | undefined;
20
20
  readonly html: string | undefined;
21
21
  }
22
+ /**
23
+ * One structured attachment on a simple message.
24
+ *
25
+ * The bytes and metadata use the values from the SendEmail request. SES would
26
+ * use them to assemble MIME content after accepting the request.
27
+ */
28
+ export interface SimSesSentEmailAttachment {
29
+ readonly rawContent: Uint8Array;
30
+ readonly fileName: string;
31
+ readonly contentType: string | undefined;
32
+ readonly contentDisposition: "ATTACHMENT" | "INLINE" | undefined;
33
+ readonly contentDescription: string | undefined;
34
+ readonly contentId: string | undefined;
35
+ readonly contentTransferEncoding: "BASE64" | "QUOTED_PRINTABLE" | "SEVEN_BIT" | undefined;
36
+ }
22
37
  /**
23
38
  * A recipient the account's suppression list held back.
24
39
  *
@@ -37,6 +52,7 @@ interface SimSesSentEmailProperties {
37
52
  readonly replyToAddresses: readonly string[];
38
53
  readonly subject: string;
39
54
  readonly body: SimSesSentEmailBody;
55
+ readonly attachments: readonly SimSesSentEmailAttachment[];
40
56
  readonly templateName: string | undefined;
41
57
  readonly templateData: Readonly<Record<string, unknown>> | undefined;
42
58
  readonly configurationSetName: string | undefined;
@@ -64,6 +80,8 @@ export declare class SimSesSentEmail {
64
80
  readonly replyToAddresses: readonly string[];
65
81
  readonly subject: string;
66
82
  readonly body: SimSesSentEmailBody;
83
+ /** Structured attachments in request order. */
84
+ readonly attachments: readonly SimSesSentEmailAttachment[];
67
85
  /**
68
86
  * The template this message was rendered from, if it was rendered from a
69
87
  * stored one. A message written out in full has none, and so does one
@@ -19,6 +19,8 @@ export class SimSesSentEmail {
19
19
  replyToAddresses;
20
20
  subject;
21
21
  body;
22
+ /** Structured attachments in request order. */
23
+ attachments;
22
24
  /**
23
25
  * The template this message was rendered from, if it was rendered from a
24
26
  * stored one. A message written out in full has none, and so does one
@@ -51,6 +53,7 @@ export class SimSesSentEmail {
51
53
  this.replyToAddresses = properties.replyToAddresses;
52
54
  this.subject = properties.subject;
53
55
  this.body = properties.body;
56
+ this.attachments = properties.attachments;
54
57
  this.templateName = properties.templateName;
55
58
  this.templateData = properties.templateData;
56
59
  this.configurationSetName = properties.configurationSetName;
@@ -79,3 +79,13 @@ export declare class SimSesSendingPausedException extends SimSesError {
79
79
  readonly name = "SendingPausedException";
80
80
  constructor(message: string);
81
81
  }
82
+ /**
83
+ * A feedback event that cannot belong to the recorded message it names.
84
+ *
85
+ * This is a simulator diagnostic rather than an AWS error. A test supplies
86
+ * feedback through `recordFeedback`, and the error is raised at that call so
87
+ * an unrelated message or recipient cannot alter the suppression list.
88
+ */
89
+ export declare class SimSesFeedbackError extends SimSesError {
90
+ readonly name = "SimSesFeedbackError";
91
+ }
@@ -88,3 +88,13 @@ export class SimSesSendingPausedException extends SimSesError {
88
88
  super(message, { httpStatusCode: 400 });
89
89
  }
90
90
  }
91
+ /**
92
+ * A feedback event that cannot belong to the recorded message it names.
93
+ *
94
+ * This is a simulator diagnostic rather than an AWS error. A test supplies
95
+ * feedback through `recordFeedback`, and the error is raised at that call so
96
+ * an unrelated message or recipient cannot alter the suppression list.
97
+ */
98
+ export class SimSesFeedbackError extends SimSesError {
99
+ name = "SimSesFeedbackError";
100
+ }
@@ -0,0 +1,34 @@
1
+ import type { SimClock } from "../../../util/clock/sim-clock.js";
2
+ import type { SimSesAccount } from "../account/sim-ses-account.js";
3
+ import type { SimSesConfigurationSetStore } from "../configuration-set/sim-ses-configuration-set-store.js";
4
+ import type { SimSesSentEmailStore } from "../email/sim-ses-sent-email-store.js";
5
+ import type { SimSesSuppressionList } from "../suppression/sim-ses-suppression-list.js";
6
+ import { type SimSesSuppressionReason } from "../suppression/sim-ses-suppression-reason.js";
7
+ import type { SimSesSuppressedDestination } from "../suppression/sim-ses-suppressed-destination.js";
8
+ /** A hard bounce or complaint reported for one accepted message recipient. */
9
+ export interface SimSesFeedback {
10
+ readonly messageId: string;
11
+ readonly emailAddress: string;
12
+ readonly reason: SimSesSuppressionReason;
13
+ }
14
+ interface SimSesFeedbackRecorderProperties {
15
+ readonly sent: SimSesSentEmailStore;
16
+ readonly configurationSets: SimSesConfigurationSetStore;
17
+ readonly account: SimSesAccount;
18
+ readonly suppression: SimSesSuppressionList;
19
+ readonly clock: SimClock;
20
+ }
21
+ /** Applies explicit delivery feedback to the account suppression list. */
22
+ export declare class SimSesFeedbackRecorder {
23
+ #private;
24
+ constructor(properties: SimSesFeedbackRecorderProperties);
25
+ /**
26
+ * Record a hard bounce or complaint for one recipient of an accepted
27
+ * message.
28
+ *
29
+ * The returned entry is undefined when suppression for the feedback reason
30
+ * was inactive for this send.
31
+ */
32
+ record(feedback: SimSesFeedback): SimSesSuppressedDestination | undefined;
33
+ }
34
+ export {};
@@ -0,0 +1,55 @@
1
+ import { simSesBareAddress } from "../email/sim-ses-address.js";
2
+ import { SimSesFeedbackError } from "../error/sim-ses.error.js";
3
+ import { requiredSimSesSuppressionAddress } from "../suppression/sim-ses-suppression-address.js";
4
+ import { requiredSimSesSuppressionReason, } from "../suppression/sim-ses-suppression-reason.js";
5
+ /** Applies explicit delivery feedback to the account suppression list. */
6
+ export class SimSesFeedbackRecorder {
7
+ #sent;
8
+ #configurationSets;
9
+ #account;
10
+ #suppression;
11
+ #clock;
12
+ constructor(properties) {
13
+ this.#sent = properties.sent;
14
+ this.#configurationSets = properties.configurationSets;
15
+ this.#account = properties.account;
16
+ this.#suppression = properties.suppression;
17
+ this.#clock = properties.clock;
18
+ }
19
+ /**
20
+ * Record a hard bounce or complaint for one recipient of an accepted
21
+ * message.
22
+ *
23
+ * The returned entry is undefined when suppression for the feedback reason
24
+ * was inactive for this send.
25
+ */
26
+ record(feedback) {
27
+ const email = this.#sent.require(feedback.messageId);
28
+ const emailAddress = requiredSimSesSuppressionAddress(feedback.emailAddress);
29
+ const reason = requiredSimSesSuppressionReason(feedback.reason);
30
+ const recipient = this.#recipient(email, emailAddress);
31
+ if (!this.#isActive(email, reason)) {
32
+ return undefined;
33
+ }
34
+ return this.#suppression.put(recipient, reason, this.#clock.now());
35
+ }
36
+ #recipient(email, emailAddress) {
37
+ const wanted = emailAddress.toLowerCase();
38
+ const recipient = email.recipients
39
+ .map((address) => simSesBareAddress(address))
40
+ .find((address) => address.toLowerCase() === wanted);
41
+ if (recipient === undefined) {
42
+ throw new SimSesFeedbackError(`${emailAddress} is not a recipient of sim SES message ${email.messageId}.`);
43
+ }
44
+ return recipient;
45
+ }
46
+ #isActive(email, reason) {
47
+ if (email.configurationSetName !== undefined) {
48
+ const configurationSet = this.#configurationSets.find(email.configurationSetName);
49
+ if (configurationSet?.suppressedReasons !== undefined) {
50
+ return configurationSet.suppressedReasons.includes(reason);
51
+ }
52
+ }
53
+ return this.#account.isSuppressedFor(reason);
54
+ }
55
+ }
@@ -9,11 +9,12 @@ export { requiredSimSesIdentityName, simSesIdentityDomain, simSesIdentityKey, ty
9
9
  export { simSesArnPrefix, simSesConfigurationSetArn, simSesIdentityArn, } from "./sim-ses-arn.js";
10
10
  export { SimSesConfigurationSet, type SimSesConfigurationSetDeliveryOptions, type SimSesConfigurationSetOptions, type SimSesConfigurationSetReputationOptions, type SimSesSuppressedReason, } from "./configuration-set/sim-ses-configuration-set.js";
11
11
  export { SimSesConfigurationSetStore } from "./configuration-set/sim-ses-configuration-set-store.js";
12
- export { SimSesSentEmail, type SimSesSentEmailBody, type SimSesSentEmailDestination, type SimSesSuppressedRecipient, } from "./email/sim-ses-sent-email.js";
12
+ export { SimSesSentEmail, type SimSesSentEmailAttachment, type SimSesSentEmailBody, type SimSesSentEmailDestination, type SimSesSuppressedRecipient, } from "./email/sim-ses-sent-email.js";
13
13
  export { SimSesSentEmailStore } from "./email/sim-ses-sent-email-store.js";
14
+ export { type SimSesFeedback, SimSesFeedbackRecorder, } from "./feedback/sim-ses-feedback-recorder.js";
14
15
  export { requiredSimSesFromAddress, simSesBareAddress, } from "./email/sim-ses-address.js";
15
16
  export { SimSesSuppressedDestination } from "./suppression/sim-ses-suppressed-destination.js";
16
17
  export { SimSesSuppressionList } from "./suppression/sim-ses-suppression-list.js";
17
18
  export { requiredSimSesSuppressionReason, type SimSesSuppressionReason, } from "./suppression/sim-ses-suppression-reason.js";
18
19
  export { SimSesAccount, type SimSesAccountContactDetails, type SimSesSendQuota, } from "./account/sim-ses-account.js";
19
- export { SimSesAlreadyExistsException, SimSesBadRequestException, SimSesError, type SimSesErrorMetadata, SimSesMessageRejected, SimSesNotFoundException, SimSesSendingPausedException, SimSesUnsupportedOperationException, } from "./error/sim-ses.error.js";
20
+ export { SimSesAlreadyExistsException, SimSesBadRequestException, SimSesError, SimSesFeedbackError, type SimSesErrorMetadata, SimSesMessageRejected, SimSesNotFoundException, SimSesSendingPausedException, SimSesUnsupportedOperationException, } from "./error/sim-ses.error.js";
@@ -8,9 +8,10 @@ export { SimSesConfigurationSet, } from "./configuration-set/sim-ses-configurati
8
8
  export { SimSesConfigurationSetStore } from "./configuration-set/sim-ses-configuration-set-store.js";
9
9
  export { SimSesSentEmail, } from "./email/sim-ses-sent-email.js";
10
10
  export { SimSesSentEmailStore } from "./email/sim-ses-sent-email-store.js";
11
+ export { SimSesFeedbackRecorder, } from "./feedback/sim-ses-feedback-recorder.js";
11
12
  export { requiredSimSesFromAddress, simSesBareAddress, } from "./email/sim-ses-address.js";
12
13
  export { SimSesSuppressedDestination } from "./suppression/sim-ses-suppressed-destination.js";
13
14
  export { SimSesSuppressionList } from "./suppression/sim-ses-suppression-list.js";
14
15
  export { requiredSimSesSuppressionReason, } from "./suppression/sim-ses-suppression-reason.js";
15
16
  export { SimSesAccount, } from "./account/sim-ses-account.js";
16
- export { SimSesAlreadyExistsException, SimSesBadRequestException, SimSesError, SimSesMessageRejected, SimSesNotFoundException, SimSesSendingPausedException, SimSesUnsupportedOperationException, } from "./error/sim-ses.error.js";
17
+ export { SimSesAlreadyExistsException, SimSesBadRequestException, SimSesError, SimSesFeedbackError, SimSesMessageRejected, SimSesNotFoundException, SimSesSendingPausedException, SimSesUnsupportedOperationException, } from "./error/sim-ses.error.js";
@@ -11,6 +11,7 @@ import { SimSesServiceSend } from "./command/send/sim-ses-service-send.js";
11
11
  import { SimSesSuppressionCommands } from "./command/suppression/sim-ses-suppression-commands.js";
12
12
  import { SimSesSuppressionList } from "./suppression/sim-ses-suppression-list.js";
13
13
  import { SimSesSentEmailStore } from "./email/sim-ses-sent-email-store.js";
14
+ import { SimSesFeedbackRecorder } from "./feedback/sim-ses-feedback-recorder.js";
14
15
  import { SimSesConfigurationSetStore } from "./configuration-set/sim-ses-configuration-set-store.js";
15
16
  import { SimSesIdentityStore } from "./identity/sim-ses-identity-store.js";
16
17
  import { SimSesTemplateCommands } from "./command/template/sim-ses-template-commands.js";
@@ -43,6 +44,7 @@ export declare class SimSesCommands {
43
44
  readonly templateCommands: SimSesTemplateCommands;
44
45
  readonly configurationSetCommands: SimSesConfigurationSetCommands;
45
46
  readonly sendEmail: SimSesSendEmail;
47
+ readonly feedback: SimSesFeedbackRecorder;
46
48
  /**
47
49
  * The way another simulated service reaches this SES, which is the send
48
50
  * without the IAM authorization a caller's request goes through.
@@ -16,6 +16,7 @@ import { SimSesVerifiedIdentityCheck } from "./command/send/sim-ses-verified-ide
16
16
  import { SimSesSuppressionCommands } from "./command/suppression/sim-ses-suppression-commands.js";
17
17
  import { SimSesSuppressionList } from "./suppression/sim-ses-suppression-list.js";
18
18
  import { SimSesSentEmailStore } from "./email/sim-ses-sent-email-store.js";
19
+ import { SimSesFeedbackRecorder } from "./feedback/sim-ses-feedback-recorder.js";
19
20
  import { SimSesConfigurationSetStore } from "./configuration-set/sim-ses-configuration-set-store.js";
20
21
  import { SimSesIdentityStore } from "./identity/sim-ses-identity-store.js";
21
22
  import { SimSesTemplateCommands } from "./command/template/sim-ses-template-commands.js";
@@ -39,6 +40,7 @@ export class SimSesCommands {
39
40
  templateCommands;
40
41
  configurationSetCommands;
41
42
  sendEmail;
43
+ feedback;
42
44
  /**
43
45
  * The way another simulated service reaches this SES, which is the send
44
46
  * without the IAM authorization a caller's request goes through.
@@ -103,6 +105,13 @@ export class SimSesCommands {
103
105
  authorizer,
104
106
  clock: background,
105
107
  });
108
+ this.feedback = new SimSesFeedbackRecorder({
109
+ sent,
110
+ configurationSets,
111
+ account,
112
+ suppression,
113
+ clock: background,
114
+ });
106
115
  this.serviceSend = new SimSesServiceSend({
107
116
  sent,
108
117
  identityCheck,
@@ -10,10 +10,10 @@ import { SimSesSuppression } from "./sim-ses-suppression.js";
10
10
  * separate classes because one class holding every SES operation grows by a
11
11
  * method with each one added.
12
12
  *
13
- * A configuration set here is state and no behaviour. Its suppression reasons,
14
- * sending switch, delivery options and reputation switch are all held and read
15
- * back, and nothing acts on any of them. A send naming a set keeps the name on
16
- * its record and goes no further.
13
+ * A configuration set holds suppression reasons, the sending switch, delivery
14
+ * options and the reputation switch. The sending switch acts on acceptance.
15
+ * Suppression reasons act when feedback is recorded for an accepted message.
16
+ * The remaining options are held for a test to read back.
17
17
  */
18
18
  export declare abstract class SimSesConfigurationSets extends SimSesSuppression {
19
19
  /**
@@ -7,10 +7,10 @@ import { SimSesSuppression } from "./sim-ses-suppression.js";
7
7
  * separate classes because one class holding every SES operation grows by a
8
8
  * method with each one added.
9
9
  *
10
- * A configuration set here is state and no behaviour. Its suppression reasons,
11
- * sending switch, delivery options and reputation switch are all held and read
12
- * back, and nothing acts on any of them. A send naming a set keeps the name on
13
- * its record and goes no further.
10
+ * A configuration set holds suppression reasons, the sending switch, delivery
11
+ * options and the reputation switch. The sending switch acts on acceptance.
12
+ * Suppression reasons act when feedback is recorded for an accepted message.
13
+ * The remaining options are held for a test to read back.
14
14
  */
15
15
  export class SimSesConfigurationSets extends SimSesSuppression {
16
16
  /**
@@ -13,8 +13,9 @@ interface SimSesSuppressionProperties {
13
13
  * SES operation grows by a method with each one added, and the suppression
14
14
  * commands are the part of the API a reader can take in on their own.
15
15
  *
16
- * Real SES fills this list from hard bounces and complaints. Nothing bounces
17
- * here, so every address on it was put there by a caller.
16
+ * Real SES fills this list from hard bounces and complaints. A test supplies
17
+ * that feedback explicitly, and callers may also manage entries through the
18
+ * suppression commands.
18
19
  */
19
20
  export declare abstract class SimSesSuppression {
20
21
  protected readonly commands: SimSesCommands;
@@ -6,8 +6,9 @@
6
6
  * SES operation grows by a method with each one added, and the suppression
7
7
  * commands are the part of the API a reader can take in on their own.
8
8
  *
9
- * Real SES fills this list from hard bounces and complaints. Nothing bounces
10
- * here, so every address on it was put there by a caller.
9
+ * Real SES fills this list from hard bounces and complaints. A test supplies
10
+ * that feedback explicitly, and callers may also manage entries through the
11
+ * suppression commands.
11
12
  */
12
13
  export class SimSesSuppression {
13
14
  commands;
@@ -3,6 +3,8 @@ import type * as simSesCommands from "./command/sim-ses-command.types.js";
3
3
  import type { SimSesRequestOptions } from "./command/sim-ses-request-options.js";
4
4
  import type * as simSesServiceSend from "./command/send/sim-ses-service-send.js";
5
5
  import type { SimSesSentEmail } from "./email/sim-ses-sent-email.js";
6
+ import type { SimSesFeedback } from "./feedback/sim-ses-feedback-recorder.js";
7
+ import type { SimSesSuppressedDestination } from "./suppression/sim-ses-suppressed-destination.js";
6
8
  import type { SimSesIdentity } from "./identity/sim-ses-identity.js";
7
9
  import type { SimSesTemplate } from "./template/sim-ses-template.js";
8
10
  import { SimSesCfnResourceFactory } from "./cfn/sim-ses-cfn-resource-factory.js";
@@ -39,6 +41,17 @@ export declare class SimSesV2 extends SimSesConfigurationSets {
39
41
  * account keeps no such record, which is exactly why a test needs one.
40
42
  */
41
43
  sentEmails(): readonly SimSesSentEmail[];
44
+ /**
45
+ * Record a hard bounce or complaint for one recipient of an accepted
46
+ * message.
47
+ *
48
+ * Real feedback happens after SES hands a message to a mail system. A test
49
+ * process cannot observe that exchange, so this simulator-side operation
50
+ * supplies the feedback explicitly. Active feedback adds or updates the
51
+ * account suppression entry at the current simulated time. Inactive
52
+ * feedback returns undefined and leaves the list unchanged.
53
+ */
54
+ recordFeedback(feedback: SimSesFeedback): SimSesSuppressedDestination | undefined;
42
55
  /**
43
56
  * Find an identity by the address or domain it names.
44
57
  *
@@ -38,6 +38,19 @@ export class SimSesV2 extends SimSesConfigurationSets {
38
38
  sentEmails() {
39
39
  return this.commands.sent.all;
40
40
  }
41
+ /**
42
+ * Record a hard bounce or complaint for one recipient of an accepted
43
+ * message.
44
+ *
45
+ * Real feedback happens after SES hands a message to a mail system. A test
46
+ * process cannot observe that exchange, so this simulator-side operation
47
+ * supplies the feedback explicitly. Active feedback adds or updates the
48
+ * account suppression entry at the current simulated time. Inactive
49
+ * feedback returns undefined and leaves the list unchanged.
50
+ */
51
+ recordFeedback(feedback) {
52
+ return this.commands.feedback.record(feedback);
53
+ }
41
54
  /**
42
55
  * Find an identity by the address or domain it names.
43
56
  *
@@ -7,10 +7,9 @@ interface SimSesSuppressedDestinationProperties {
7
7
  /**
8
8
  * One address on an account's suppression list.
9
9
  *
10
- * Real SES also reports a `MessageId` and a `FeedbackId` on the address it put
11
- * there itself, describing the bounce or complaint that did it. Nothing
12
- * bounces here, so every address on this list was put there by hand and there
13
- * is no feedback event to point at.
10
+ * Real SES also reports a `MessageId` and a `FeedbackId` on an address added by
11
+ * delivery feedback. This simulation records the reason and time only. It
12
+ * does not publish or retain a feedback event.
14
13
  */
15
14
  export declare class SimSesSuppressedDestination {
16
15
  /** The address as it was given, case and all. */
@@ -1,10 +1,9 @@
1
1
  /**
2
2
  * One address on an account's suppression list.
3
3
  *
4
- * Real SES also reports a `MessageId` and a `FeedbackId` on the address it put
5
- * there itself, describing the bounce or complaint that did it. Nothing
6
- * bounces here, so every address on this list was put there by hand and there
7
- * is no feedback event to point at.
4
+ * Real SES also reports a `MessageId` and a `FeedbackId` on an address added by
5
+ * delivery feedback. This simulation records the reason and time only. It
6
+ * does not publish or retain a feedback event.
8
7
  */
9
8
  export class SimSesSuppressedDestination {
10
9
  /** The address as it was given, case and all. */
@@ -3,10 +3,10 @@ import type { SimSesSuppressionReason } from "./sim-ses-suppression-reason.js";
3
3
  /**
4
4
  * The account-level suppression list of one simulated SES scope.
5
5
  *
6
- * Real SES fills this from hard bounces and complaints. Nothing bounces here,
7
- * so every address on it was put there by a caller, which is the point: an
8
- * application's own support tooling and operations scripts have somewhere to
9
- * run.
6
+ * Real SES fills this from hard bounces and complaints. A test supplies those
7
+ * events through the simulator's feedback operation. The ordinary suppression
8
+ * commands also manage the same entries, giving application support tooling
9
+ * and operations scripts somewhere to run.
10
10
  *
11
11
  * The list is region scoped, as identities and sends are. An address
12
12
  * suppressed in one region says nothing about another.
@@ -3,10 +3,10 @@ import { SimSesSuppressedDestination } from "./sim-ses-suppressed-destination.js
3
3
  /**
4
4
  * The account-level suppression list of one simulated SES scope.
5
5
  *
6
- * Real SES fills this from hard bounces and complaints. Nothing bounces here,
7
- * so every address on it was put there by a caller, which is the point: an
8
- * application's own support tooling and operations scripts have somewhere to
9
- * run.
6
+ * Real SES fills this from hard bounces and complaints. A test supplies those
7
+ * events through the simulator's feedback operation. The ordinary suppression
8
+ * commands also manage the same entries, giving application support tooling
9
+ * and operations scripts somewhere to run.
10
10
  *
11
11
  * The list is region scoped, as identities and sends are. An address
12
12
  * suppressed in one region says nothing about another.
@@ -14,7 +14,7 @@ SES specific types are imported from the `@kensio/yulin/ses` subpath.
14
14
  ## Asserting on a message that was sent
15
15
 
16
16
  `sentEmails()` hands over the record. Each message carries who it was from, the three recipient
17
- lists, the subject, the body and the message id SES answered with.
17
+ lists, the subject, the body, its attachments and the message id SES answered with.
18
18
 
19
19
  ```typescript sim-ses-send-and-assert
20
20
  /**
@@ -59,6 +59,60 @@ read. The three recipient lists stay apart, leaving a test free to assert that a
59
59
  `body` keeps `text` and `html` apart too. A message sent with only an HTML body reports `undefined`
60
60
  for its text, and never the markup.
61
61
 
62
+ ### Asserting on an attachment
63
+
64
+ A simple message may carry structured attachments. The record keeps them in request order.
65
+ `rawContent` contains a copy of the bytes from the request, and the other fields contain the supplied
66
+ SES attachment metadata.
67
+
68
+ ```typescript sim-ses-attachments
69
+ /**
70
+ * Sending a generated CSV and reading it from the simulated SES record.
71
+ */
72
+
73
+ import { SendEmailCommand } from "@aws-sdk/client-sesv2";
74
+
75
+ import { SimAws } from "@kensio/yulin";
76
+
77
+ const simAws = new SimAws();
78
+ const ses = simAws.sesV2();
79
+
80
+ ses.verifyIdentity("hello@example.com");
81
+ ses.verifyIdentity("someone@example.org");
82
+
83
+ const csv = new TextEncoder().encode("word,meaning\n你好,hello\n");
84
+
85
+ await ses.sendEmail(
86
+ new SendEmailCommand({
87
+ FromEmailAddress: "hello@example.com",
88
+ Destination: { ToAddresses: ["someone@example.org"] },
89
+ Content: {
90
+ Simple: {
91
+ Subject: { Data: "Your vocabulary backup" },
92
+ Body: { Text: { Data: "Your backup is attached." } },
93
+ Attachments: [
94
+ {
95
+ RawContent: csv,
96
+ FileName: "vocabulary.csv",
97
+ ContentType: "text/csv; charset=utf-8",
98
+ ContentDisposition: "ATTACHMENT",
99
+ },
100
+ ],
101
+ },
102
+ },
103
+ }),
104
+ );
105
+
106
+ const [email] = ses.sentEmails();
107
+ const [attachment] = email?.attachments ?? [];
108
+
109
+ // vocabulary.csv "word,meaning\n你好,hello\n"
110
+ console.log(
111
+ attachment?.fileName,
112
+ new TextDecoder().decode(attachment?.rawContent),
113
+ );
114
+ ```
115
+
62
116
  ## Verifying identities
63
117
 
64
118
  Real SES verifies an email address by emailing it a link and a domain by looking for DNS records.
@@ -303,8 +357,9 @@ sending switch, the delivery options and the reputation switch are all declared
303
357
  SES holds a set as state. A test can then assert what a stack declared, with no AWS account to read
304
358
  it back from.
305
359
 
306
- A set is attached to an identity, or named on a send. `SendingEnabled` is the one option a send acts
307
- on. The rest are held for a test to read back.
360
+ A set is attached to an identity, or named on a send. `SendingEnabled` acts during acceptance.
361
+ Suppression options decide whether explicit bounce or complaint feedback adds the recipient to the
362
+ account suppression list. The rest are held for a test to read back.
308
363
 
309
364
  ```typescript sim-ses-configuration-sets
310
365
  /**
@@ -350,8 +405,13 @@ console.log(ses.allConfigurationSets().map((set) => set.configurationSetName));
350
405
  ```
351
406
 
352
407
  A set declaring only its name gets the defaults real SES applies. Sending is on, TLS is optional,
353
- reputation metrics are off and no reason is suppressed. `GetConfigurationSet` reports those defaults
354
- back rather than leaving the groups out of the answer.
408
+ reputation metrics are off and suppression falls back to the account reasons.
409
+ `GetConfigurationSet` reports the sending, delivery and reputation defaults. It leaves suppression
410
+ options absent where the set has no override.
411
+
412
+ An explicit empty override is different. `SuppressionOptions: { SuppressedReasons: [] }` disables
413
+ suppression for feedback on messages sent through that set. Simulated SES retains the empty list so
414
+ it does not fall back to the account reasons.
355
415
 
356
416
  `findConfigurationSet` reaches one set by name and `allConfigurationSets` hands over every set in
357
417
  the scope, oldest first.
@@ -436,9 +496,9 @@ set at a time.
436
496
  domain and the events that report a click. The Virtual Deliverability Manager reports on engagement,
437
497
  which this simulation never measures.
438
498
 
439
- A set's suppression reasons are held and read back, and that is all they do. On real SES they decide
440
- what a bounce or a complaint would add to the account suppression list. Both are absent here. The
441
- list itself is a separate thing, filled by hand.
499
+ A set's suppression reasons override the account reasons when `recordFeedback` records a hard bounce
500
+ or complaint for a message sent through it. A set with no suppression options falls back to the
501
+ account reasons. The feedback entry goes onto the account suppression list.
442
502
 
443
503
  `TlsPolicy` accepts `REQUIRE` and `OPTIONAL` and refuses anything else, and a `SuppressedReasons`
444
504
  entry that is neither `BOUNCE` nor `COMPLAINT` is refused too. `MaxDeliverySeconds` takes whole
@@ -644,9 +704,9 @@ review is beyond what a test can assert on anyway.
644
704
  ## The suppression list
645
705
 
646
706
  Real SES holds an account-level suppression list and fills it from hard bounces and complaints.
647
- Nothing bounces here, so every address on this one was put there by a caller. That is what makes it
648
- worth having in a test. The support tool that lists suppressed addresses, the form that removes one
649
- and the script that seeds the list all have somewhere to run.
707
+ Tests supply that feedback explicitly with `recordFeedback`. Suppression commands manage the same
708
+ list. The support tool that lists suppressed addresses, the form that removes one and the script
709
+ that seeds the list all have somewhere to run.
650
710
 
651
711
  `PutSuppressedDestination`, `GetSuppressedDestination`, `ListSuppressedDestinations` and
652
712
  `DeleteSuppressedDestination` manage it.
@@ -705,6 +765,70 @@ carries the answer. `suppressedRecipients` names who was held back and why,
705
765
  and `isFullySuppressed` is the narrower question of whether the message reached nobody. A message to
706
766
  two recipients with one of them suppressed went to the other.
707
767
 
768
+ ### Recording a hard bounce or complaint
769
+
770
+ `recordFeedback` takes the message id of an accepted message, one of its recipient addresses and a
771
+ `BOUNCE` or `COMPLAINT` reason. Active feedback adds or updates the account suppression entry at the
772
+ current simulated time. The existing suppression commands and `suppressedDestinations()` read the
773
+ result.
774
+
775
+ ```typescript sim-ses-feedback-suppression
776
+ /**
777
+ * Recording a hard bounce and observing the next send being suppressed.
778
+ */
779
+
780
+ import { SendEmailCommand } from "@aws-sdk/client-sesv2";
781
+
782
+ import { SimAws, SimFixedClock } from "@kensio/yulin";
783
+
784
+ const simAws = new SimAws({
785
+ clock: new SimFixedClock(new Date("2026-08-31T09:00:00.000Z")),
786
+ });
787
+ const ses = simAws.sesV2();
788
+
789
+ ses.verifyIdentity("hello@example.com");
790
+ ses.verifyIdentity("someone@example.org");
791
+
792
+ const message = new SendEmailCommand({
793
+ FromEmailAddress: "hello@example.com",
794
+ Destination: { ToAddresses: ["someone@example.org"] },
795
+ Content: {
796
+ Simple: {
797
+ Subject: { Data: "Welcome" },
798
+ Body: { Text: { Data: "Hi there" } },
799
+ },
800
+ },
801
+ });
802
+
803
+ const accepted = await ses.sendEmail(message);
804
+
805
+ ses.recordFeedback({
806
+ messageId: accepted.MessageId!,
807
+ emailAddress: "someone@example.org",
808
+ reason: "BOUNCE",
809
+ });
810
+
811
+ await ses.sendEmail(message);
812
+
813
+ const suppressed = ses.suppressedDestinations()[0];
814
+ const later = ses.sentEmails()[1];
815
+
816
+ // "BOUNCE" "2026-08-31T09:00:00.000Z" true
817
+ console.log(
818
+ suppressed?.reason,
819
+ suppressed?.lastUpdateTime.toISOString(),
820
+ later?.isFullySuppressed,
821
+ );
822
+ ```
823
+
824
+ The operation refuses a message id this SES scope did not issue and an address that was not a
825
+ recipient of that message. Recipient matching ignores case and a display name, while the suppression
826
+ entry keeps the address spelling from the accepted message.
827
+
828
+ Without a configuration-set override, the account's `SuppressedReasons` decide whether feedback is
829
+ active. A configuration set with suppression options replaces those reasons for messages sent
830
+ through it. An empty override leaves the suppression list unchanged.
831
+
708
832
  `ListSuppressedDestinations` pages with `PageSize` and `NextToken`, and narrows with `Reasons`,
709
833
  `StartDate` and `EndDate`. Removing an address that was never on the list succeeds, so a form that
710
834
  removes one twice has no failure to handle.
@@ -938,29 +1062,29 @@ time forward past the window sees the count fall the way an account's would.
938
1062
 
939
1063
  ## Simulated commands
940
1064
 
941
- | Command | Notes |
942
- | --------------------------------- | --------------------------------------------------------------------------------------------------------- |
943
- | `SendEmail` | `Content.Simple` and `Content.Template`. Recorded rather than delivered. |
944
- | `CreateEmailIdentity` | Starts unverified. `Tags`, `DkimSigningAttributes` and `ConfigurationSetName` are held and reported back. |
945
- | `GetEmailIdentity` | Reports the DKIM, MAIL FROM, feedback, configuration set and tag settings the identity holds. |
946
- | `ListEmailIdentities` | Paged with `PageSize` and `NextToken`. |
947
- | `DeleteEmailIdentity` | |
948
- | `CreateEmailTemplate` | Substitution only. `Tags` are refused. |
949
- | `GetEmailTemplate` | Reports the wording with its placeholders unrendered. |
950
- | `UpdateEmailTemplate` | Replaces the wording outright, keeping the creation time. |
951
- | `ListEmailTemplates` | Names and creation times only, paged. |
952
- | `DeleteEmailTemplate` | |
953
- | `CreateConfigurationSet` | `TrackingOptions`, `VdmOptions` and `Tags` are refused. |
954
- | `GetConfigurationSet` | Reports the defaults it applied as well as what was declared. |
955
- | `ListConfigurationSets` | Names only, paged with `PageSize` and `NextToken`. |
956
- | `DeleteConfigurationSet` | |
957
- | `GetAccount` | Reports `SuppressionAttributes` alongside the quota. |
958
- | `PutAccountDetails` | `MailType` and `WebsiteURL` are required, as on real SES. |
959
- | `PutAccountSuppressionAttributes` | No reasons at all turns the suppression list off. |
960
- | `PutSuppressedDestination` | Accepted in the sandbox, which real SES refuses. |
961
- | `GetSuppressedDestination` | |
962
- | `ListSuppressedDestinations` | Paged, and narrowed by `Reasons`, `StartDate` and `EndDate`. |
963
- | `DeleteSuppressedDestination` | Removing an address that is not on the list succeeds. |
1065
+ | Command | Notes |
1066
+ | --------------------------------- | ----------------------------------------------------------------------------------------------------------- |
1067
+ | `SendEmail` | `Content.Simple`, including structured attachments, and `Content.Template`. Recorded rather than delivered. |
1068
+ | `CreateEmailIdentity` | Starts unverified. `Tags`, `DkimSigningAttributes` and `ConfigurationSetName` are held and reported back. |
1069
+ | `GetEmailIdentity` | Reports the DKIM, MAIL FROM, feedback, configuration set and tag settings the identity holds. |
1070
+ | `ListEmailIdentities` | Paged with `PageSize` and `NextToken`. |
1071
+ | `DeleteEmailIdentity` | |
1072
+ | `CreateEmailTemplate` | Substitution only. `Tags` are refused. |
1073
+ | `GetEmailTemplate` | Reports the wording with its placeholders unrendered. |
1074
+ | `UpdateEmailTemplate` | Replaces the wording outright, keeping the creation time. |
1075
+ | `ListEmailTemplates` | Names and creation times only, paged. |
1076
+ | `DeleteEmailTemplate` | |
1077
+ | `CreateConfigurationSet` | `TrackingOptions`, `VdmOptions` and `Tags` are refused. |
1078
+ | `GetConfigurationSet` | Reports applied defaults and preserves whether suppression options were absent. |
1079
+ | `ListConfigurationSets` | Names only, paged with `PageSize` and `NextToken`. |
1080
+ | `DeleteConfigurationSet` | |
1081
+ | `GetAccount` | Reports `SuppressionAttributes` alongside the quota. |
1082
+ | `PutAccountDetails` | `MailType` and `WebsiteURL` are required, as on real SES. |
1083
+ | `PutAccountSuppressionAttributes` | No reasons at all turns the suppression list off. |
1084
+ | `PutSuppressedDestination` | Accepted in the sandbox, which real SES refuses. |
1085
+ | `GetSuppressedDestination` | |
1086
+ | `ListSuppressedDestinations` | Paged, and narrowed by `Reasons`, `StartDate` and `EndDate`. |
1087
+ | `DeleteSuppressedDestination` | Removing an address that is not on the list succeeds. |
964
1088
 
965
1089
  Anything else refuses on send with `SimSdkUnsupportedCommandError`.
966
1090
 
@@ -978,19 +1102,18 @@ Anything else refuses on send with `SimSdkUnsupportedCommandError`.
978
1102
  the template. Template data holding an object where the template wants a value is refused too,
979
1103
  where real Handlebars would render `[object Object]`.
980
1104
  - **`SendBulkEmail` is absent**, along with its per-recipient replacement data.
981
- - **Nothing is delivered, and nothing bounces.** There are no bounce or complaint events and no
982
- event destinations.
1105
+ - **Nothing is delivered.** A test supplies a hard bounce or complaint through `recordFeedback`.
1106
+ Feedback is never generated automatically, and there are no event destinations.
983
1107
  - **A configuration set acts on one send.** `SendingEnabled` refuses a send made through the set.
984
- The suppression reasons, delivery options and reputation switch are held and read back, and
985
- nothing acts on those.
1108
+ Suppression reasons act on explicit feedback. Delivery options and the reputation switch are held
1109
+ and read back.
986
1110
  - **A set name nothing created is still accepted.** Real SES refuses one on an identity and on a
987
1111
  send. Both stand here and the name is recorded, because a test failing over a set missing from a
988
1112
  local setup fails for a reason unrelated to what it asserts.
989
1113
  - **A configuration set holds what it was created with.** The `Put` commands that change one group
990
1114
  of options are absent. A set cannot be changed once it exists.
991
- - **The suppression list fills only by hand.** Every address on it was put there by a caller,
992
- because no message here ever bounces. A configuration set's `SuppressedReasons` name what a bounce
993
- or a complaint would suppress for, and both are absent.
1115
+ - **Feedback is explicit.** Hard bounces and complaints update the suppression list only when a test
1116
+ calls `recordFeedback`. Soft bounces are absent.
994
1117
  - **Tenant-level suppression lists are left out.** A suppression command carrying `TenantName` is
995
1118
  refused rather than answered from the account-level list.
996
1119
  - **`PutSuppressedDestination` works in the sandbox.** Real SES refuses it until an account has
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kensio/yulin",
3
- "version": "1.21.1",
3
+ "version": "1.21.2",
4
4
  "description": "AWS system behaviour simulation for isolated unit testing",
5
5
  "repository": "https://github.com/KensioSoftware/yulin",
6
6
  "homepage": "https://yulinsim.dev/",