Skip to content

feat(ses): sending-side builders — ConfigurationSet + event destinations, send grants, account reputation alarms #303

Description

@laazyj

Problem / use case

@composurecdk/ses today covers only the receiving path (email identities,
receipt rule sets, filters, actions — #279). The README says as much: "Sending-side
builders (configuration sets, dedicated IP pools, VDM, reputation alarms) will
follow."
This issue is that follow-up.

A production sender needs three things the package doesn't yet give you, all of
which are currently raw aws-cdk-lib:

  1. A configuration set — the unit that tracks every send: TLS enforcement,
    reputation-metric publishing, suppression, event routing. Without one you fly
    blind and can't segment transactional vs marketing reputation.
  2. Event routing for Bounce/Complaint/Reject/… to SNS or EventBridge so a
    downstream workflow can suppress bad addresses. AWS requires you to track
    bounces and complaints.
  3. Least-privilege send grants to the Lambda/task role that calls
    SendEmail, and an account-level reputation safety net — the Versioning strategy #1 SES footgun
    is an account paused for a bounce/complaint rate over threshold.

Research

CDK L2 support for the sending path (verified against aws-cdk-lib source + the 2.216.0 floor tarball)

All sending constructs live in the AWS::SES::* CFN namespace (there is no
AWS::SESv2 CFN namespace
— "v2" is only the API). Present and confirmed at our
ses floor (2.216.0, gated by route53):

  • ConfigurationSet / ConfigurationSetPropstlsPolicy
    (ConfigurationSetTlsPolicy.REQUIRE|OPTIONAL), reputationMetrics,
    sendingEnabled, suppressionReasons
    (BOUNCES_AND_COMPLAINTS|BOUNCES_ONLY|COMPLAINTS_ONLY), disableSuppressionList,
    dedicatedIpPool, vdmOptions (engagementMetrics/optimizedSharedDelivery),
    customTrackingRedirectDomain, maxDeliveryDuration. Method
    addEventDestination(id, options). No grant*, no metric*, no Tags.
  • ConfigurationSetEventDestination / EventDestination — static destination
    factories snsTopic(ITopic), eventBus(IEventBus), cloudWatchDimensions(...),
    firehoseDeliveryStream(...); event filter enum EmailSendingEvent
    (SEND, REJECT, BOUNCE, COMPLAINT, DELIVERY, OPEN, CLICK, RENDERING_FAILURE, DELIVERY_DELAY, SUBSCRIPTION).
  • DedicatedIpPool / ScalingMode (STANDARD|MANAGED) and account-level
    VdmAttributes — both present (deferred, see Scope).
  • EmailIdentity — already has configurationSet?: IConfigurationSet prop, and
    grantSendEmail(grantee) (grants ses:SendEmail + ses:SendRawEmail on the
    identity ARN) plus generic grant(grantee, ...actions). Both present at 2.216.0.
  • No L2 for email templates (AWS::SES::Template is L1-only) — out of scope.
  • Account-level sandbox→production access, sending quota, account suppression
    list
    have no CFN resource (SDK-only) — out of scope, future domain actions
    (ADR-0016).

Sending is available in all commercial regions (unlike receiving), so no
region-gating is needed for these builders.

AWS-recommended alarms & Well-Architected (SES Developer Guide — not the CloudWatch recommended-alarms doc, which omits SES)

SES is not in the CloudWatch "Best Practice Alarm Recommendations" doc. The
authoritative source is the SES Developer Guide,
Creating reputation monitoring alarms using CloudWatch:

Metric (AWS/SES) Threshold Comparison Stat Period Missing data
Reputation.BounceRate 0.05 (5%) >= Average 1 hr IGNORE (maintain state)
Reputation.ComplaintRate 0.001 (0.1%) >= Average 1 hr IGNORE

Enforcement thresholds SES itself uses (sender reputation /
account pausing):
bounce ≥5% → under review, ≥10% → may pause; complaint ≥0.1% → under
review
, ≥0.5% → may pause. The recommended alarm thresholds sit at the
review boundary, giving headroom before a pause.

Critical: Reputation.BounceRate / Reputation.ComplaintRate are
account-level, dimensionless metrics. An alarm on them is account-scoped
attaching a configuration-set dimension yields an alarm that never receives data.
This drives design decision #1 below.

Well-Architected mapping: Operational Excellence (publish + alarm on reputation
metrics, route events), Reliability (bounce/complaint handling + suppression
keep you able to send), Security (least-privilege ses:SendEmail scoped by
ses:FromAddress; DKIM/SPF/DMARC — DKIM already handled by the identity builder).

Exemplary AWS examples & idiomatic OSS
  • SESv2 SendEmail is the recommended send API; least-privilege sender policy is
    ses:SendEmail on the identity ARN, optionally scoped with a ses:FromAddress
    StringLike condition (policy examples).
  • The dominant real-world shape: EmailIdentity (+DKIM) → ConfigurationSet
    (TLS require, reputation metrics, VDM) → event destination (SNS/EventBridge) →
    bounce/complaint Lambda → suppression list
    , exposing a grantSendEmail-style
    method to the app role. Community constructs
    (@seeebiii/ses-verify-identities,
    mkrn/cdk-ses-template-mailer,
    markilott/aws-cdk-configure-ses)
    converge on this. Separate config sets (and subdomains) per stream so one
    stream's reputation can't sink another; tag messages to split CloudWatch metrics.
  • Idioms worth mirroring: configuration-set association as a prop on the identity;
    event destinations via a chainable addEventDestination(id, {destination, events})
    with a destination-factory union; suppression/reputation/VDM as config-set
    booleans
    , not separate resources.

Recommended design (for @composurecdk/ses)

import {
  createConfigurationSetBuilder,
  snsDestination, eventBusDestination,
  createReputationAlarmBuilder,
  identityGrants,
  createEmailIdentityBuilder,
} from "@composurecdk/ses";
import { EmailSendingEvent } from "aws-cdk-lib/aws-ses";
import { compose, ref } from "@composurecdk/core";

compose(
  {
    // Where bounces/complaints go for suppression processing.
    events: createTopicBuilder(),

    // The sending configuration set — TLS + reputation metrics on by default.
    mailConfig: createConfigurationSetBuilder().addEventDestination("feedback", {
      destination: snsDestination(ref("events", (r) => r.topic)),
      events: [EmailSendingEvent.BOUNCE, EmailSendingEvent.COMPLAINT, EmailSendingEvent.REJECT],
    }),

    // Identity associated with the config set so every send is tracked.
    identity: createEmailIdentityBuilder()
      .domain("mail.example.com")
      .configurationSet(ref("mailConfig", (r) => r.configurationSet)),

    // The app that sends — least-privilege ses:SendEmail on the identity.
    sender: createFunctionBuilder()
      .code(Code.fromAsset("sender"))
      .grant(identityGrants.send(ref("identity", (r) => r.emailIdentity))),

    // Account-level reputation safety net (bounce 5% / complaint 0.1%).
    reputation: createReputationAlarmBuilder(),
  },
  {
    events: [], mailConfig: ["events"], identity: ["mailConfig"],
    sender: ["identity"], reputation: [],
  },
);

Scope for this PR: configuration-set builder + event-destination helpers +
consumer-side send grants + account reputation-alarm builder + identity↔config-set
wiring. Deferred to follow-ups: dedicated IP pool builder, account VdmAttributes
builder, SES templates (L1-only), production-access/quota/suppression-list domain
actions (SDK-only, ADR-0016). An example stack under packages/examples/ will follow
once the surface lands.

The detailed implementation plan and the key design decisions (each compared to two
alternatives) are in the comment below — those are the points I'd value your input on
before/while the PR is in review.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

enhancementNew feature or request

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions