@wraps.dev/cli 2.2.8 → 2.3.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.
package/dist/cli.js CHANGED
@@ -4,9 +4,6 @@ var __getOwnPropNames = Object.getOwnPropertyNames;
4
4
  var __esm = (fn, res) => function __init() {
5
5
  return fn && (res = (0, fn[__getOwnPropNames(fn)[0]])(fn = 0)), res;
6
6
  };
7
- var __commonJS = (cb, mod) => function __require() {
8
- return mod || (0, cb[__getOwnPropNames(cb)[0]])((mod = { exports: {} }).exports, mod), mod.exports;
9
- };
10
7
  var __export = (target, all5) => {
11
8
  for (var name in all5)
12
9
  __defProp(target, name, { get: all5[name], enumerable: true });
@@ -142,118 +139,8 @@ var init_config = __esm({
142
139
  }
143
140
  });
144
141
 
145
- // package.json
146
- var require_package = __commonJS({
147
- "package.json"(exports, module) {
148
- module.exports = {
149
- name: "@wraps.dev/cli",
150
- version: "2.2.8",
151
- description: "CLI for deploying Wraps email infrastructure to your AWS account",
152
- type: "module",
153
- main: "./dist/cli.js",
154
- bin: {
155
- wraps: "./dist/cli.js"
156
- },
157
- files: [
158
- "dist",
159
- "README.md",
160
- "LICENSE"
161
- ],
162
- repository: {
163
- type: "git",
164
- url: "https://github.com/wraps-team/wraps.git",
165
- directory: "packages/cli"
166
- },
167
- homepage: "https://wraps.dev",
168
- bugs: {
169
- url: "https://github.com/wraps-team/wraps/issues"
170
- },
171
- publishConfig: {
172
- access: "public"
173
- },
174
- scripts: {
175
- dev: "tsup --watch",
176
- build: "pnpm build:console && pnpm build:lambda && tsup",
177
- "build:lambda": "tsx scripts/build-lambda.ts",
178
- "build:console": "pnpm --filter @wraps/console build",
179
- test: "vitest run",
180
- "test:watch": "vitest --watch",
181
- "test:ui": "vitest --ui",
182
- "test:coverage": "vitest run --coverage",
183
- typecheck: "tsc --noEmit",
184
- lint: "eslint src",
185
- prepublishOnly: "pnpm build"
186
- },
187
- keywords: [
188
- "aws",
189
- "ses",
190
- "email",
191
- "infrastructure",
192
- "cli"
193
- ],
194
- author: "Wraps",
195
- license: "AGPL-3.0-or-later",
196
- dependencies: {
197
- "@aws-sdk/client-acm": "3.933.0",
198
- "@aws-sdk/client-cloudformation": "^3.490.0",
199
- "@aws-sdk/client-cloudfront": "3.933.0",
200
- "@aws-sdk/client-cloudwatch": "^3.490.0",
201
- "@aws-sdk/client-dynamodb": "^3.490.0",
202
- "@aws-sdk/client-iam": "3.932.0",
203
- "@aws-sdk/client-lambda": "3.925.0",
204
- "@aws-sdk/client-mailmanager": "3.925.0",
205
- "@aws-sdk/client-pinpoint-sms-voice-v2": "3.955.0",
206
- "@aws-sdk/client-route-53": "3.925.0",
207
- "@aws-sdk/client-s3": "3.933.0",
208
- "@aws-sdk/client-ses": "^3.490.0",
209
- "@aws-sdk/client-sesv2": "3.925.0",
210
- "@aws-sdk/client-sns": "^3.490.0",
211
- "@aws-sdk/client-sqs": "3.933.0",
212
- "@aws-sdk/client-sts": "^3.490.0",
213
- "@aws-sdk/s3-request-presigner": "3.964.0",
214
- "@aws-sdk/util-dynamodb": "3.927.0",
215
- "@clack/prompts": "^0.11.0",
216
- "@pulumi/aws": "^7.11.1",
217
- "@pulumi/pulumi": "^3.207.0",
218
- args: "^5.0.3",
219
- conf: "^13.0.1",
220
- cosmiconfig: "^9.0.0",
221
- esbuild: "^0.25.12",
222
- express: "^4.21.2",
223
- "get-port": "^7.1.0",
224
- "http-terminator": "^3.2.0",
225
- "isomorphic-dompurify": "2.32.0",
226
- mailparser: "3.9.0",
227
- open: "^10.1.0",
228
- picocolors: "^1.1.1",
229
- tabtab: "^3.0.2",
230
- uuid: "^11.0.3"
231
- },
232
- devDependencies: {
233
- "@wraps/core": "workspace:*",
234
- "@types/args": "5.0.4",
235
- "@types/express": "^5.0.0",
236
- "@types/mailparser": "3.4.6",
237
- "@types/node": "^20.11.0",
238
- "@types/uuid": "^10.0.0",
239
- "@vitest/coverage-v8": "4.0.7",
240
- "@wraps/email-check": "workspace:*",
241
- "aws-sdk-client-mock": "4.1.0",
242
- "aws-sdk-client-mock-vitest": "7.0.1",
243
- eslint: "^8.56.0",
244
- tsup: "^8.0.1",
245
- tsx: "4.20.6",
246
- typescript: "catalog:",
247
- vitest: "^4.0.7"
248
- },
249
- engines: {
250
- node: ">=20.0.0"
251
- }
252
- };
253
- }
254
- });
255
-
256
142
  // src/telemetry/client.ts
143
+ import { createRequire } from "module";
257
144
  import pc from "picocolors";
258
145
  function getTelemetryClient() {
259
146
  if (!telemetryInstance) {
@@ -261,13 +148,14 @@ function getTelemetryClient() {
261
148
  }
262
149
  return telemetryInstance;
263
150
  }
264
- var DEFAULT_ENDPOINT, DEFAULT_TIMEOUT, TelemetryClient, telemetryInstance;
151
+ var require2, DEFAULT_ENDPOINT, DEFAULT_TIMEOUT, TelemetryClient, telemetryInstance;
265
152
  var init_client = __esm({
266
153
  "src/telemetry/client.ts"() {
267
154
  "use strict";
268
155
  init_esm_shims();
269
156
  init_ci_detection();
270
157
  init_config();
158
+ require2 = createRequire(import.meta.url);
271
159
  DEFAULT_ENDPOINT = "https://wraps.dev/api/telemetry";
272
160
  DEFAULT_TIMEOUT = 2e3;
273
161
  TelemetryClient = class {
@@ -456,7 +344,7 @@ var init_client = __esm({
456
344
  */
457
345
  getCLIVersion() {
458
346
  try {
459
- const packageJson2 = require_package();
347
+ const packageJson2 = require2("../../package.json");
460
348
  return packageJson2.version;
461
349
  } catch {
462
350
  return "unknown";
@@ -978,6 +866,18 @@ function calculateSMTPCredentialsCost(config2) {
978
866
  description: "SMTP credentials (no additional cost)"
979
867
  };
980
868
  }
869
+ function calculateAlertingCost(config2) {
870
+ if (!config2.alerts?.enabled) {
871
+ return;
872
+ }
873
+ const numAlarms = config2.alerts.dlqAlerts !== false ? 5 : 4;
874
+ const alarmCost = numAlarms * 0.1;
875
+ const snsCost = 0;
876
+ return {
877
+ monthly: alarmCost + snsCost,
878
+ description: `Reputation alerts (${numAlarms} CloudWatch alarms)`
879
+ };
880
+ }
981
881
  function calculateCosts(config2, emailsPerMonth = 1e4) {
982
882
  const tracking = calculateTrackingCost(config2);
983
883
  const reputationMetrics = calculateReputationMetricsCost(config2);
@@ -987,8 +887,9 @@ function calculateCosts(config2, emailsPerMonth = 1e4) {
987
887
  const dedicatedIp = calculateDedicatedIpCost(config2);
988
888
  const waf = calculateWafCost(config2, emailsPerMonth);
989
889
  const smtpCredentials = calculateSMTPCredentialsCost(config2);
890
+ const alerts = calculateAlertingCost(config2);
990
891
  const sesEmailCost = Math.max(0, emailsPerMonth - FREE_TIER.SES_EMAILS) * AWS_PRICING.SES_PER_EMAIL;
991
- const totalMonthlyCost = sesEmailCost + (tracking?.monthly || 0) + (reputationMetrics?.monthly || 0) + (eventTracking?.monthly || 0) + (dynamoDBHistory?.monthly || 0) + (emailArchiving?.monthly || 0) + (dedicatedIp?.monthly || 0) + (waf?.monthly || 0) + (smtpCredentials?.monthly || 0);
892
+ const totalMonthlyCost = sesEmailCost + (tracking?.monthly || 0) + (reputationMetrics?.monthly || 0) + (eventTracking?.monthly || 0) + (dynamoDBHistory?.monthly || 0) + (emailArchiving?.monthly || 0) + (dedicatedIp?.monthly || 0) + (waf?.monthly || 0) + (smtpCredentials?.monthly || 0) + (alerts?.monthly || 0);
992
893
  return {
993
894
  tracking,
994
895
  reputationMetrics,
@@ -998,6 +899,7 @@ function calculateCosts(config2, emailsPerMonth = 1e4) {
998
899
  dedicatedIp,
999
900
  waf,
1000
901
  smtpCredentials,
902
+ alerts,
1001
903
  total: {
1002
904
  monthly: totalMonthlyCost,
1003
905
  perEmail: AWS_PRICING.SES_PER_EMAIL,
@@ -1063,6 +965,11 @@ function getCostSummary(config2, emailsPerMonth = 1e4) {
1063
965
  ` - ${costs.smtpCredentials.description}: ${formatCost(costs.smtpCredentials.monthly)}`
1064
966
  );
1065
967
  }
968
+ if (costs.alerts) {
969
+ lines.push(
970
+ ` - ${costs.alerts.description}: ${formatCost(costs.alerts.monthly)}`
971
+ );
972
+ }
1066
973
  return lines.join("\n");
1067
974
  }
1068
975
  var AWS_PRICING, FREE_TIER;
@@ -1205,6 +1112,7 @@ function getPresetInfo(preset) {
1205
1112
  "Reputation tracking",
1206
1113
  "Real-time event tracking (EventBridge)",
1207
1114
  "90-day email history storage",
1115
+ "Reputation alerts (bounce/complaint rate monitoring)",
1208
1116
  "Optional: Email archiving with rendered viewer",
1209
1117
  "Complete event visibility"
1210
1118
  ]
@@ -1218,6 +1126,7 @@ function getPresetInfo(preset) {
1218
1126
  "Everything in Production",
1219
1127
  "Dedicated IP address",
1220
1128
  "1-year email history",
1129
+ "Stricter alert thresholds (catch issues earlier)",
1221
1130
  "Optional: 1-year+ email archiving",
1222
1131
  "All event types tracked",
1223
1132
  "Priority support eligibility"
@@ -1306,6 +1215,10 @@ var init_presets = __esm({
1306
1215
  enabled: false,
1307
1216
  retention: "30days"
1308
1217
  },
1218
+ // Alerting disabled for starter (no reputation metrics)
1219
+ alerts: {
1220
+ enabled: false
1221
+ },
1309
1222
  sendingEnabled: true
1310
1223
  };
1311
1224
  PRODUCTION_PRESET = {
@@ -1342,6 +1255,12 @@ var init_presets = __esm({
1342
1255
  // User can opt-in
1343
1256
  retention: "90days"
1344
1257
  },
1258
+ // Alerting enabled - warns before AWS/Gmail take action
1259
+ alerts: {
1260
+ enabled: true,
1261
+ dlqAlerts: true
1262
+ // Uses default thresholds: bounce 2%/4%, complaint 0.05%/0.08%
1263
+ },
1345
1264
  sendingEnabled: true
1346
1265
  };
1347
1266
  ENTERPRISE_PRESET = {
@@ -1380,6 +1299,22 @@ var init_presets = __esm({
1380
1299
  // User can opt-in
1381
1300
  retention: "1year"
1382
1301
  },
1302
+ // Alerting with stricter thresholds for high-volume senders
1303
+ alerts: {
1304
+ enabled: true,
1305
+ dlqAlerts: true,
1306
+ thresholds: {
1307
+ // Stricter thresholds for enterprise - catch issues earlier
1308
+ bounceRateWarning: 0.01,
1309
+ // 1% (vs 2% default)
1310
+ bounceRateCritical: 0.02,
1311
+ // 2% (vs 4% default)
1312
+ complaintRateWarning: 3e-4,
1313
+ // 0.03% (vs 0.05% default)
1314
+ complaintRateCritical: 5e-4
1315
+ // 0.05% (vs 0.08% default)
1316
+ }
1317
+ },
1383
1318
  dedicatedIp: true,
1384
1319
  sendingEnabled: true
1385
1320
  };
@@ -3223,7 +3158,7 @@ import { existsSync as existsSync4, mkdirSync } from "fs";
3223
3158
  import { tmpdir } from "os";
3224
3159
  import { dirname, join as join4 } from "path";
3225
3160
  import { fileURLToPath as fileURLToPath2 } from "url";
3226
- import * as aws7 from "@pulumi/aws";
3161
+ import * as aws8 from "@pulumi/aws";
3227
3162
  import * as pulumi11 from "@pulumi/pulumi";
3228
3163
  import { build } from "esbuild";
3229
3164
  function getPackageRoot() {
@@ -3312,7 +3247,7 @@ Try running: pnpm build`
3312
3247
  }
3313
3248
  async function deployLambdaFunctions(config2) {
3314
3249
  const eventProcessorCode = await getLambdaCode("event-processor");
3315
- const lambdaRole = new aws7.iam.Role("wraps-email-lambda-role", {
3250
+ const lambdaRole = new aws8.iam.Role("wraps-email-lambda-role", {
3316
3251
  assumeRolePolicy: JSON.stringify({
3317
3252
  Version: "2012-10-17",
3318
3253
  Statement: [
@@ -3327,11 +3262,11 @@ async function deployLambdaFunctions(config2) {
3327
3262
  ManagedBy: "wraps-cli"
3328
3263
  }
3329
3264
  });
3330
- new aws7.iam.RolePolicyAttachment("wraps-email-lambda-basic-execution", {
3265
+ new aws8.iam.RolePolicyAttachment("wraps-email-lambda-basic-execution", {
3331
3266
  role: lambdaRole.name,
3332
3267
  policyArn: "arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole"
3333
3268
  });
3334
- new aws7.iam.RolePolicy("wraps-email-lambda-policy", {
3269
+ new aws8.iam.RolePolicy("wraps-email-lambda-policy", {
3335
3270
  role: lambdaRole.name,
3336
3271
  policy: pulumi11.all([config2.tableName, config2.queueArn]).apply(
3337
3272
  ([tableName, queueArn]) => JSON.stringify({
@@ -3375,7 +3310,7 @@ async function deployLambdaFunctions(config2) {
3375
3310
  RETENTION_DAYS: config2.retentionDays.toString()
3376
3311
  }
3377
3312
  };
3378
- const eventProcessor = exists ? new aws7.lambda.Function(
3313
+ const eventProcessor = exists ? new aws8.lambda.Function(
3379
3314
  functionName,
3380
3315
  {
3381
3316
  name: functionName,
@@ -3396,7 +3331,7 @@ async function deployLambdaFunctions(config2) {
3396
3331
  import: functionName
3397
3332
  // Import existing function
3398
3333
  }
3399
- ) : new aws7.lambda.Function(functionName, {
3334
+ ) : new aws8.lambda.Function(functionName, {
3400
3335
  name: functionName,
3401
3336
  runtime: "nodejs24.x",
3402
3337
  handler: "index.handler",
@@ -3426,14 +3361,14 @@ async function deployLambdaFunctions(config2) {
3426
3361
  functionResponseTypes: ["ReportBatchItemFailures"]
3427
3362
  // Enable partial batch responses
3428
3363
  };
3429
- const eventSourceMapping = existingMappingUuid ? new aws7.lambda.EventSourceMapping(
3364
+ const eventSourceMapping = existingMappingUuid ? new aws8.lambda.EventSourceMapping(
3430
3365
  "wraps-email-event-source-mapping",
3431
3366
  mappingConfig,
3432
3367
  {
3433
3368
  import: existingMappingUuid
3434
3369
  // Import with the UUID
3435
3370
  }
3436
- ) : new aws7.lambda.EventSourceMapping(
3371
+ ) : new aws8.lambda.EventSourceMapping(
3437
3372
  "wraps-email-event-source-mapping",
3438
3373
  mappingConfig
3439
3374
  );
@@ -3454,12 +3389,12 @@ var acm_exports = {};
3454
3389
  __export(acm_exports, {
3455
3390
  createACMCertificate: () => createACMCertificate
3456
3391
  });
3457
- import * as aws11 from "@pulumi/aws";
3392
+ import * as aws12 from "@pulumi/aws";
3458
3393
  async function createACMCertificate(config2) {
3459
- const usEast1Provider = new aws11.Provider("acm-us-east-1", {
3394
+ const usEast1Provider = new aws12.Provider("acm-us-east-1", {
3460
3395
  region: "us-east-1"
3461
3396
  });
3462
- const certificate = new aws11.acm.Certificate(
3397
+ const certificate = new aws12.acm.Certificate(
3463
3398
  "wraps-email-tracking-cert",
3464
3399
  {
3465
3400
  domainName: config2.domain,
@@ -3482,7 +3417,7 @@ async function createACMCertificate(config2) {
3482
3417
  );
3483
3418
  let certificateValidation;
3484
3419
  if (config2.hostedZoneId) {
3485
- const validationRecord = new aws11.route53.Record(
3420
+ const validationRecord = new aws12.route53.Record(
3486
3421
  "wraps-email-tracking-cert-validation",
3487
3422
  {
3488
3423
  zoneId: config2.hostedZoneId,
@@ -3492,7 +3427,7 @@ async function createACMCertificate(config2) {
3492
3427
  ttl: 60
3493
3428
  }
3494
3429
  );
3495
- certificateValidation = new aws11.acm.CertificateValidation(
3430
+ certificateValidation = new aws12.acm.CertificateValidation(
3496
3431
  "wraps-email-tracking-cert-validation-waiter",
3497
3432
  {
3498
3433
  certificateArn: certificate.arn,
@@ -3521,7 +3456,7 @@ var cloudfront_exports = {};
3521
3456
  __export(cloudfront_exports, {
3522
3457
  createCloudFrontTracking: () => createCloudFrontTracking
3523
3458
  });
3524
- import * as aws12 from "@pulumi/aws";
3459
+ import * as aws13 from "@pulumi/aws";
3525
3460
  async function findDistributionByAlias(alias) {
3526
3461
  try {
3527
3462
  const { CloudFrontClient, ListDistributionsCommand } = await import("@aws-sdk/client-cloudfront");
@@ -3537,10 +3472,10 @@ async function findDistributionByAlias(alias) {
3537
3472
  }
3538
3473
  }
3539
3474
  async function createWAFWebACL() {
3540
- const usEast1Provider = new aws12.Provider("waf-us-east-1", {
3475
+ const usEast1Provider = new aws13.Provider("waf-us-east-1", {
3541
3476
  region: "us-east-1"
3542
3477
  });
3543
- const webAcl = new aws12.wafv2.WebAcl(
3478
+ const webAcl = new aws13.wafv2.WebAcl(
3544
3479
  "wraps-email-tracking-waf",
3545
3480
  {
3546
3481
  scope: "CLOUDFRONT",
@@ -3655,14 +3590,14 @@ async function createCloudFrontTracking(config2) {
3655
3590
  Description: "Wraps email tracking CloudFront distribution"
3656
3591
  }
3657
3592
  };
3658
- const distribution = existingDistributionId ? new aws12.cloudfront.Distribution(
3593
+ const distribution = existingDistributionId ? new aws13.cloudfront.Distribution(
3659
3594
  "wraps-email-tracking-cdn",
3660
3595
  distributionConfig,
3661
3596
  {
3662
3597
  import: existingDistributionId
3663
3598
  // Import existing distribution
3664
3599
  }
3665
- ) : new aws12.cloudfront.Distribution(
3600
+ ) : new aws13.cloudfront.Distribution(
3666
3601
  "wraps-email-tracking-cdn",
3667
3602
  distributionConfig
3668
3603
  );
@@ -11611,12 +11546,200 @@ import pc14 from "picocolors";
11611
11546
  // src/infrastructure/email-stack.ts
11612
11547
  init_esm_shims();
11613
11548
  init_dist();
11614
- import * as aws13 from "@pulumi/aws";
11549
+ import * as aws14 from "@pulumi/aws";
11615
11550
  import * as pulumi12 from "@pulumi/pulumi";
11616
11551
 
11617
- // src/infrastructure/resources/dynamodb.ts
11552
+ // src/infrastructure/resources/alerting.ts
11618
11553
  init_esm_shims();
11619
11554
  import * as aws4 from "@pulumi/aws";
11555
+
11556
+ // src/types/index.ts
11557
+ init_esm_shims();
11558
+
11559
+ // src/types/email.ts
11560
+ init_esm_shims();
11561
+ var DEFAULT_ALERT_THRESHOLDS = {
11562
+ bounceRateWarning: 0.02,
11563
+ // 2% - gives time before AWS 5% warning
11564
+ bounceRateCritical: 0.04,
11565
+ // 4% - urgent, approaching AWS warning
11566
+ complaintRateWarning: 5e-4,
11567
+ // 0.05% - half of AWS warning threshold
11568
+ complaintRateCritical: 8e-4,
11569
+ // 0.08% - urgent, approaching AWS 0.1% warning
11570
+ dlqMessageThreshold: 1
11571
+ // Any failed message processing
11572
+ };
11573
+
11574
+ // src/infrastructure/resources/alerting.ts
11575
+ function getThresholds(custom) {
11576
+ return {
11577
+ ...DEFAULT_ALERT_THRESHOLDS,
11578
+ ...custom
11579
+ };
11580
+ }
11581
+ async function createAlertingResources(config2) {
11582
+ const thresholds = getThresholds(config2.alertConfig.thresholds);
11583
+ const topic = new aws4.sns.Topic("wraps-email-alerts", {
11584
+ name: "wraps-email-alerts",
11585
+ displayName: "Wraps Email Alerts",
11586
+ tags: {
11587
+ ManagedBy: "wraps-cli",
11588
+ Description: "Alert notifications for email reputation and health"
11589
+ }
11590
+ });
11591
+ let emailSubscription;
11592
+ if (config2.alertConfig.notificationEmail) {
11593
+ emailSubscription = new aws4.sns.TopicSubscription(
11594
+ "wraps-email-alerts-email",
11595
+ {
11596
+ topic: topic.arn,
11597
+ protocol: "email",
11598
+ endpoint: config2.alertConfig.notificationEmail
11599
+ }
11600
+ );
11601
+ }
11602
+ let webhookSubscription;
11603
+ if (config2.alertConfig.webhookUrl) {
11604
+ webhookSubscription = new aws4.sns.TopicSubscription(
11605
+ "wraps-email-alerts-webhook",
11606
+ {
11607
+ topic: topic.arn,
11608
+ protocol: "https",
11609
+ endpoint: config2.alertConfig.webhookUrl
11610
+ }
11611
+ );
11612
+ }
11613
+ const bounceRateWarningAlarm = new aws4.cloudwatch.MetricAlarm(
11614
+ "wraps-bounce-rate-warning",
11615
+ {
11616
+ name: "wraps-email-bounce-rate-warning",
11617
+ alarmDescription: `Bounce rate exceeded ${thresholds.bounceRateWarning * 100}% - investigate before AWS takes action (warns at 5%, suspends at 10%)`,
11618
+ comparisonOperator: "GreaterThanThreshold",
11619
+ evaluationPeriods: 2,
11620
+ // Require 2 consecutive periods to reduce noise
11621
+ metricName: "Reputation.BounceRate",
11622
+ namespace: "AWS/SES",
11623
+ period: 300,
11624
+ // 5 minutes
11625
+ statistic: "Average",
11626
+ threshold: thresholds.bounceRateWarning,
11627
+ alarmActions: [topic.arn],
11628
+ okActions: [topic.arn],
11629
+ // Notify when resolved
11630
+ treatMissingData: "notBreaching",
11631
+ // Don't alarm if no data (no emails sent)
11632
+ tags: {
11633
+ ManagedBy: "wraps-cli",
11634
+ Severity: "warning"
11635
+ }
11636
+ }
11637
+ );
11638
+ const bounceRateCriticalAlarm = new aws4.cloudwatch.MetricAlarm(
11639
+ "wraps-bounce-rate-critical",
11640
+ {
11641
+ name: "wraps-email-bounce-rate-critical",
11642
+ alarmDescription: `CRITICAL: Bounce rate exceeded ${thresholds.bounceRateCritical * 100}% - approaching AWS warning threshold (5%). Immediate action required!`,
11643
+ comparisonOperator: "GreaterThanThreshold",
11644
+ evaluationPeriods: 1,
11645
+ // Alert immediately on critical
11646
+ metricName: "Reputation.BounceRate",
11647
+ namespace: "AWS/SES",
11648
+ period: 300,
11649
+ // 5 minutes
11650
+ statistic: "Average",
11651
+ threshold: thresholds.bounceRateCritical,
11652
+ alarmActions: [topic.arn],
11653
+ okActions: [topic.arn],
11654
+ treatMissingData: "notBreaching",
11655
+ tags: {
11656
+ ManagedBy: "wraps-cli",
11657
+ Severity: "critical"
11658
+ }
11659
+ }
11660
+ );
11661
+ const complaintRateWarningAlarm = new aws4.cloudwatch.MetricAlarm(
11662
+ "wraps-complaint-rate-warning",
11663
+ {
11664
+ name: "wraps-email-complaint-rate-warning",
11665
+ alarmDescription: `Complaint rate exceeded ${thresholds.complaintRateWarning * 100}% - investigate before AWS (0.1%) or Gmail (0.3%) take action`,
11666
+ comparisonOperator: "GreaterThanThreshold",
11667
+ evaluationPeriods: 2,
11668
+ metricName: "Reputation.ComplaintRate",
11669
+ namespace: "AWS/SES",
11670
+ period: 300,
11671
+ statistic: "Average",
11672
+ threshold: thresholds.complaintRateWarning,
11673
+ alarmActions: [topic.arn],
11674
+ okActions: [topic.arn],
11675
+ treatMissingData: "notBreaching",
11676
+ tags: {
11677
+ ManagedBy: "wraps-cli",
11678
+ Severity: "warning"
11679
+ }
11680
+ }
11681
+ );
11682
+ const complaintRateCriticalAlarm = new aws4.cloudwatch.MetricAlarm(
11683
+ "wraps-complaint-rate-critical",
11684
+ {
11685
+ name: "wraps-email-complaint-rate-critical",
11686
+ alarmDescription: `CRITICAL: Complaint rate exceeded ${thresholds.complaintRateCritical * 100}% - approaching AWS warning (0.1%). Immediate action required!`,
11687
+ comparisonOperator: "GreaterThanThreshold",
11688
+ evaluationPeriods: 1,
11689
+ metricName: "Reputation.ComplaintRate",
11690
+ namespace: "AWS/SES",
11691
+ period: 300,
11692
+ statistic: "Average",
11693
+ threshold: thresholds.complaintRateCritical,
11694
+ alarmActions: [topic.arn],
11695
+ okActions: [topic.arn],
11696
+ treatMissingData: "notBreaching",
11697
+ tags: {
11698
+ ManagedBy: "wraps-cli",
11699
+ Severity: "critical"
11700
+ }
11701
+ }
11702
+ );
11703
+ let dlqAlarm;
11704
+ if (config2.alertConfig.dlqAlerts !== false && config2.dlqName) {
11705
+ dlqAlarm = new aws4.cloudwatch.MetricAlarm("wraps-dlq-alarm", {
11706
+ name: "wraps-email-dlq-messages",
11707
+ alarmDescription: "Messages in dead letter queue - event processing is failing. Check Lambda logs for errors.",
11708
+ comparisonOperator: "GreaterThanOrEqualToThreshold",
11709
+ evaluationPeriods: 1,
11710
+ metricName: "ApproximateNumberOfMessagesVisible",
11711
+ namespace: "AWS/SQS",
11712
+ period: 60,
11713
+ // Check every minute
11714
+ statistic: "Sum",
11715
+ threshold: thresholds.dlqMessageThreshold,
11716
+ dimensions: {
11717
+ QueueName: config2.dlqName
11718
+ },
11719
+ alarmActions: [topic.arn],
11720
+ okActions: [topic.arn],
11721
+ treatMissingData: "notBreaching",
11722
+ tags: {
11723
+ ManagedBy: "wraps-cli",
11724
+ Severity: "warning"
11725
+ }
11726
+ });
11727
+ }
11728
+ return {
11729
+ topic,
11730
+ emailSubscription,
11731
+ webhookSubscription,
11732
+ bounceRateWarningAlarm,
11733
+ bounceRateCriticalAlarm,
11734
+ complaintRateWarningAlarm,
11735
+ complaintRateCriticalAlarm,
11736
+ dlqAlarm
11737
+ };
11738
+ }
11739
+
11740
+ // src/infrastructure/resources/dynamodb.ts
11741
+ init_esm_shims();
11742
+ import * as aws5 from "@pulumi/aws";
11620
11743
  async function tableExists(tableName) {
11621
11744
  try {
11622
11745
  const { DynamoDBClient: DynamoDBClient6, DescribeTableCommand: DescribeTableCommand2 } = await import("@aws-sdk/client-dynamodb");
@@ -11636,7 +11759,7 @@ async function tableExists(tableName) {
11636
11759
  async function createDynamoDBTables(_config) {
11637
11760
  const tableName = "wraps-email-history";
11638
11761
  const exists = await tableExists(tableName);
11639
- const emailHistory = exists ? new aws4.dynamodb.Table(
11762
+ const emailHistory = exists ? new aws5.dynamodb.Table(
11640
11763
  tableName,
11641
11764
  {
11642
11765
  name: tableName,
@@ -11668,7 +11791,7 @@ async function createDynamoDBTables(_config) {
11668
11791
  import: tableName
11669
11792
  // Import existing table
11670
11793
  }
11671
- ) : new aws4.dynamodb.Table(tableName, {
11794
+ ) : new aws5.dynamodb.Table(tableName, {
11672
11795
  name: tableName,
11673
11796
  billingMode: "PAY_PER_REQUEST",
11674
11797
  hashKey: "messageId",
@@ -11701,11 +11824,11 @@ async function createDynamoDBTables(_config) {
11701
11824
 
11702
11825
  // src/infrastructure/resources/eventbridge.ts
11703
11826
  init_esm_shims();
11704
- import * as aws5 from "@pulumi/aws";
11827
+ import * as aws6 from "@pulumi/aws";
11705
11828
  import * as pulumi9 from "@pulumi/pulumi";
11706
11829
  async function createEventBridgeResources(config2) {
11707
11830
  const eventBusName = config2.eventBusArn.apply((arn) => arn.split("/").pop());
11708
- const rule = new aws5.cloudwatch.EventRule("wraps-email-events-rule", {
11831
+ const rule = new aws6.cloudwatch.EventRule("wraps-email-events-rule", {
11709
11832
  name: "wraps-email-events-to-sqs",
11710
11833
  description: "Route all SES email events to SQS for processing",
11711
11834
  eventBusName,
@@ -11718,7 +11841,7 @@ async function createEventBridgeResources(config2) {
11718
11841
  ManagedBy: "wraps-cli"
11719
11842
  }
11720
11843
  });
11721
- new aws5.sqs.QueuePolicy("wraps-email-events-queue-policy", {
11844
+ new aws6.sqs.QueuePolicy("wraps-email-events-queue-policy", {
11722
11845
  queueUrl: config2.queueUrl,
11723
11846
  policy: pulumi9.all([config2.queueArn, rule.arn]).apply(
11724
11847
  ([queueArn, ruleArn]) => JSON.stringify({
@@ -11741,7 +11864,7 @@ async function createEventBridgeResources(config2) {
11741
11864
  })
11742
11865
  )
11743
11866
  });
11744
- const target = new aws5.cloudwatch.EventTarget("wraps-email-events-target", {
11867
+ const target = new aws6.cloudwatch.EventTarget("wraps-email-events-target", {
11745
11868
  rule: rule.name,
11746
11869
  eventBusName,
11747
11870
  arn: config2.queueArn
@@ -11752,7 +11875,7 @@ async function createEventBridgeResources(config2) {
11752
11875
  if (config2.webhook) {
11753
11876
  const { awsAccountNumber, webhookSecret, webhookUrl } = config2.webhook;
11754
11877
  const baseUrl = webhookUrl || "https://api.wraps.dev";
11755
- webhookConnection = new aws5.cloudwatch.EventConnection(
11878
+ webhookConnection = new aws6.cloudwatch.EventConnection(
11756
11879
  "wraps-webhook-connection",
11757
11880
  {
11758
11881
  name: "wraps-webhook-connection",
@@ -11766,7 +11889,7 @@ async function createEventBridgeResources(config2) {
11766
11889
  }
11767
11890
  }
11768
11891
  );
11769
- webhookApiDestination = new aws5.cloudwatch.EventApiDestination(
11892
+ webhookApiDestination = new aws6.cloudwatch.EventApiDestination(
11770
11893
  "wraps-webhook-destination",
11771
11894
  {
11772
11895
  name: "wraps-webhook-destination",
@@ -11778,7 +11901,7 @@ async function createEventBridgeResources(config2) {
11778
11901
  // Rate limit
11779
11902
  }
11780
11903
  );
11781
- const webhookRole = new aws5.iam.Role("wraps-webhook-role", {
11904
+ const webhookRole = new aws6.iam.Role("wraps-webhook-role", {
11782
11905
  name: "wraps-eventbridge-webhook-role",
11783
11906
  assumeRolePolicy: JSON.stringify({
11784
11907
  Version: "2012-10-17",
@@ -11796,7 +11919,7 @@ async function createEventBridgeResources(config2) {
11796
11919
  ManagedBy: "wraps-cli"
11797
11920
  }
11798
11921
  });
11799
- new aws5.iam.RolePolicy("wraps-webhook-policy", {
11922
+ new aws6.iam.RolePolicy("wraps-webhook-policy", {
11800
11923
  role: webhookRole.name,
11801
11924
  policy: webhookApiDestination.arn.apply(
11802
11925
  (destArn) => JSON.stringify({
@@ -11811,7 +11934,7 @@ async function createEventBridgeResources(config2) {
11811
11934
  })
11812
11935
  )
11813
11936
  });
11814
- webhookTarget = new aws5.cloudwatch.EventTarget("wraps-webhook-target", {
11937
+ webhookTarget = new aws6.cloudwatch.EventTarget("wraps-webhook-target", {
11815
11938
  rule: rule.name,
11816
11939
  eventBusName,
11817
11940
  arn: webhookApiDestination.arn,
@@ -11829,7 +11952,7 @@ async function createEventBridgeResources(config2) {
11829
11952
 
11830
11953
  // src/infrastructure/resources/iam.ts
11831
11954
  init_esm_shims();
11832
- import * as aws6 from "@pulumi/aws";
11955
+ import * as aws7 from "@pulumi/aws";
11833
11956
  import * as pulumi10 from "@pulumi/pulumi";
11834
11957
  async function roleExists2(roleName) {
11835
11958
  try {
@@ -11884,7 +12007,7 @@ async function createIAMRole(config2) {
11884
12007
  }
11885
12008
  const roleName = "wraps-email-role";
11886
12009
  const exists = await roleExists2(roleName);
11887
- const role = exists ? new aws6.iam.Role(
12010
+ const role = exists ? new aws7.iam.Role(
11888
12011
  roleName,
11889
12012
  {
11890
12013
  name: roleName,
@@ -11898,7 +12021,7 @@ async function createIAMRole(config2) {
11898
12021
  import: roleName
11899
12022
  // Import existing role (use role name, not ARN)
11900
12023
  }
11901
- ) : new aws6.iam.Role(roleName, {
12024
+ ) : new aws7.iam.Role(roleName, {
11902
12025
  name: roleName,
11903
12026
  assumeRolePolicy,
11904
12027
  tags: {
@@ -11918,6 +12041,9 @@ async function createIAMRole(config2) {
11918
12041
  // SES v2 API for listing/getting email identities (domains)
11919
12042
  "ses:ListEmailIdentities",
11920
12043
  "ses:GetEmailIdentity",
12044
+ // SES v2 API for configuration set scanning (needed by dashboard)
12045
+ "ses:GetConfigurationSet",
12046
+ "ses:GetConfigurationSetEventDestinations",
11921
12047
  "cloudwatch:GetMetricData",
11922
12048
  "cloudwatch:GetMetricStatistics"
11923
12049
  ],
@@ -11993,7 +12119,7 @@ async function createIAMRole(config2) {
11993
12119
  Resource: "arn:aws:ses:*:*:mailmanager-archive/*"
11994
12120
  });
11995
12121
  }
11996
- new aws6.iam.RolePolicy("wraps-email-policy", {
12122
+ new aws7.iam.RolePolicy("wraps-email-policy", {
11997
12123
  role: role.name,
11998
12124
  policy: JSON.stringify({
11999
12125
  Version: "2012-10-17",
@@ -12008,7 +12134,7 @@ init_lambda();
12008
12134
 
12009
12135
  // src/infrastructure/resources/ses.ts
12010
12136
  init_esm_shims();
12011
- import * as aws8 from "@pulumi/aws";
12137
+ import * as aws9 from "@pulumi/aws";
12012
12138
  async function configurationSetExists(configSetName, region) {
12013
12139
  try {
12014
12140
  const { SESv2Client: SESv2Client6, GetConfigurationSetCommand: GetConfigurationSetCommand2 } = await import("@aws-sdk/client-sesv2");
@@ -12086,16 +12212,16 @@ async function createSESResources(config2) {
12086
12212
  }
12087
12213
  const configSetName = "wraps-email-tracking";
12088
12214
  const exists = await configurationSetExists(configSetName, config2.region);
12089
- const configSet = exists ? new aws8.sesv2.ConfigurationSet(configSetName, configSetOptions, {
12215
+ const configSet = exists ? new aws9.sesv2.ConfigurationSet(configSetName, configSetOptions, {
12090
12216
  import: configSetName
12091
12217
  // Import existing configuration set
12092
- }) : new aws8.sesv2.ConfigurationSet(configSetName, configSetOptions);
12093
- const defaultEventBus = aws8.cloudwatch.getEventBusOutput({
12218
+ }) : new aws9.sesv2.ConfigurationSet(configSetName, configSetOptions);
12219
+ const defaultEventBus = aws9.cloudwatch.getEventBusOutput({
12094
12220
  name: "default"
12095
12221
  });
12096
12222
  if (config2.eventTrackingEnabled) {
12097
12223
  const eventDestName = "wraps-email-eventbridge";
12098
- new aws8.sesv2.ConfigurationSetEventDestination(
12224
+ new aws9.sesv2.ConfigurationSetEventDestination(
12099
12225
  "wraps-email-all-events",
12100
12226
  {
12101
12227
  configurationSetName: configSet.configurationSetName,
@@ -12135,7 +12261,7 @@ async function createSESResources(config2) {
12135
12261
  config2.domain,
12136
12262
  config2.region
12137
12263
  );
12138
- domainIdentity = identityExists ? new aws8.sesv2.EmailIdentity(
12264
+ domainIdentity = identityExists ? new aws9.sesv2.EmailIdentity(
12139
12265
  "wraps-email-domain",
12140
12266
  {
12141
12267
  emailIdentity: config2.domain,
@@ -12152,7 +12278,7 @@ async function createSESResources(config2) {
12152
12278
  import: config2.domain
12153
12279
  // Import existing identity
12154
12280
  }
12155
- ) : new aws8.sesv2.EmailIdentity("wraps-email-domain", {
12281
+ ) : new aws9.sesv2.EmailIdentity("wraps-email-domain", {
12156
12282
  emailIdentity: config2.domain,
12157
12283
  configurationSetName: configSet.configurationSetName,
12158
12284
  // Link configuration set to domain
@@ -12168,7 +12294,7 @@ async function createSESResources(config2) {
12168
12294
  );
12169
12295
  if (config2.mailFromDomain) {
12170
12296
  mailFromDomain = config2.mailFromDomain;
12171
- new aws8.sesv2.EmailIdentityMailFromAttributes(
12297
+ new aws9.sesv2.EmailIdentityMailFromAttributes(
12172
12298
  "wraps-email-mail-from",
12173
12299
  {
12174
12300
  emailIdentity: config2.domain,
@@ -12199,7 +12325,7 @@ async function createSESResources(config2) {
12199
12325
  // src/infrastructure/resources/smtp-credentials.ts
12200
12326
  init_esm_shims();
12201
12327
  import { createHmac as createHmac2 } from "crypto";
12202
- import * as aws9 from "@pulumi/aws";
12328
+ import * as aws10 from "@pulumi/aws";
12203
12329
  function convertToSMTPPassword2(secretAccessKey, region) {
12204
12330
  const DATE = "11111111";
12205
12331
  const SERVICE = "ses";
@@ -12235,7 +12361,7 @@ async function userExists(userName) {
12235
12361
  async function createSMTPCredentials(config2) {
12236
12362
  const userName = "wraps-email-smtp-user";
12237
12363
  const userAlreadyExists = await userExists(userName);
12238
- const iamUser = userAlreadyExists ? new aws9.iam.User(
12364
+ const iamUser = userAlreadyExists ? new aws10.iam.User(
12239
12365
  userName,
12240
12366
  {
12241
12367
  name: userName,
@@ -12245,14 +12371,14 @@ async function createSMTPCredentials(config2) {
12245
12371
  }
12246
12372
  },
12247
12373
  { import: userName }
12248
- ) : new aws9.iam.User(userName, {
12374
+ ) : new aws10.iam.User(userName, {
12249
12375
  name: userName,
12250
12376
  tags: {
12251
12377
  ManagedBy: "wraps-cli",
12252
12378
  Purpose: "SES SMTP Authentication"
12253
12379
  }
12254
12380
  });
12255
- new aws9.iam.UserPolicy("wraps-email-smtp-policy", {
12381
+ new aws10.iam.UserPolicy("wraps-email-smtp-policy", {
12256
12382
  user: iamUser.name,
12257
12383
  policy: JSON.stringify({
12258
12384
  Version: "2012-10-17",
@@ -12270,7 +12396,7 @@ async function createSMTPCredentials(config2) {
12270
12396
  ]
12271
12397
  })
12272
12398
  });
12273
- const accessKey = new aws9.iam.AccessKey("wraps-email-smtp-key", {
12399
+ const accessKey = new aws10.iam.AccessKey("wraps-email-smtp-key", {
12274
12400
  user: iamUser.name
12275
12401
  });
12276
12402
  const smtpPassword = accessKey.secret.apply(
@@ -12285,9 +12411,9 @@ async function createSMTPCredentials(config2) {
12285
12411
 
12286
12412
  // src/infrastructure/resources/sqs.ts
12287
12413
  init_esm_shims();
12288
- import * as aws10 from "@pulumi/aws";
12414
+ import * as aws11 from "@pulumi/aws";
12289
12415
  async function createSQSResources() {
12290
- const dlq = new aws10.sqs.Queue("wraps-email-events-dlq", {
12416
+ const dlq = new aws11.sqs.Queue("wraps-email-events-dlq", {
12291
12417
  name: "wraps-email-events-dlq",
12292
12418
  messageRetentionSeconds: 1209600,
12293
12419
  // 14 days
@@ -12296,7 +12422,7 @@ async function createSQSResources() {
12296
12422
  Description: "Dead letter queue for failed SES event processing"
12297
12423
  }
12298
12424
  });
12299
- const queue = new aws10.sqs.Queue("wraps-email-events", {
12425
+ const queue = new aws11.sqs.Queue("wraps-email-events", {
12300
12426
  name: "wraps-email-events",
12301
12427
  visibilityTimeoutSeconds: 60,
12302
12428
  // Must be >= Lambda timeout
@@ -12324,7 +12450,7 @@ async function createSQSResources() {
12324
12450
 
12325
12451
  // src/infrastructure/email-stack.ts
12326
12452
  async function deployEmailStack(config2) {
12327
- const identity = await aws13.getCallerIdentity();
12453
+ const identity = await aws14.getCallerIdentity();
12328
12454
  const accountId = identity.accountId;
12329
12455
  let oidcProvider;
12330
12456
  if (config2.provider === "vercel" && config2.vercel) {
@@ -12440,6 +12566,15 @@ async function deployEmailStack(config2) {
12440
12566
  region: config2.region
12441
12567
  });
12442
12568
  }
12569
+ let alertingResources;
12570
+ if (emailConfig.alerts?.enabled) {
12571
+ alertingResources = await createAlertingResources({
12572
+ alertConfig: emailConfig.alerts,
12573
+ configSetName: sesResources?.configSet.configurationSetName,
12574
+ dlqName: sqsResources ? "wraps-email-events-dlq" : void 0,
12575
+ region: config2.region
12576
+ });
12577
+ }
12443
12578
  return {
12444
12579
  roleArn: role.arn,
12445
12580
  configSetName: sesResources?.configSet.configurationSetName,
@@ -12464,7 +12599,10 @@ async function deployEmailStack(config2) {
12464
12599
  smtpUserArn: smtpResources?.iamUser.arn,
12465
12600
  smtpUsername: smtpResources?.accessKey.id,
12466
12601
  smtpPassword: smtpResources?.smtpPassword,
12467
- smtpEndpoint: smtpResources ? `email-smtp.${config2.region}.amazonaws.com` : void 0
12602
+ smtpEndpoint: smtpResources ? `email-smtp.${config2.region}.amazonaws.com` : void 0,
12603
+ // Alerting outputs
12604
+ alertsEnabled: emailConfig.alerts?.enabled,
12605
+ alertTopicArn: alertingResources?.topic.arn
12468
12606
  };
12469
12607
  }
12470
12608
 
@@ -12837,14 +12975,14 @@ async function scanSESConfigurationSets(region) {
12837
12975
  }
12838
12976
  }
12839
12977
  async function scanSNSTopics(region) {
12840
- const sns2 = new SNSClient({ region });
12978
+ const sns3 = new SNSClient({ region });
12841
12979
  const topics = [];
12842
12980
  try {
12843
- const listResponse = await sns2.send(new ListTopicsCommand({}));
12981
+ const listResponse = await sns3.send(new ListTopicsCommand({}));
12844
12982
  const topicArns = listResponse.Topics?.map((t) => t.TopicArn).filter(Boolean) || [];
12845
12983
  for (const arn of topicArns) {
12846
12984
  try {
12847
- const attrsResponse = await sns2.send(
12985
+ const attrsResponse = await sns3.send(
12848
12986
  new GetTopicAttributesCommand({ TopicArn: arn })
12849
12987
  );
12850
12988
  const name = arn.split(":").pop() || arn;
@@ -14777,6 +14915,14 @@ ${pc21.bold("Current Configuration:")}
14777
14915
  }[config2.emailArchiving.retention] || "90 days";
14778
14916
  console.log(` ${pc21.green("\u2713")} Email Archiving (${retentionLabel})`);
14779
14917
  }
14918
+ if (config2.alerts?.enabled) {
14919
+ console.log(` ${pc21.green("\u2713")} Reputation Alerts`);
14920
+ if (config2.alerts.notificationEmail) {
14921
+ console.log(
14922
+ ` ${pc21.dim("\u2514\u2500")} Email: ${pc21.cyan(config2.alerts.notificationEmail)}`
14923
+ );
14924
+ }
14925
+ }
14780
14926
  const currentCostData = calculateCosts(config2, 5e4);
14781
14927
  console.log(
14782
14928
  `
@@ -14816,6 +14962,11 @@ ${pc21.bold("Current Configuration:")}
14816
14962
  label: "Enable dedicated IP address",
14817
14963
  hint: "Requires 100k+ emails/day ($50-100/mo)"
14818
14964
  },
14965
+ {
14966
+ value: "alerts",
14967
+ label: config2.alerts?.enabled ? "Manage reputation alerts" : "Enable reputation alerts",
14968
+ hint: config2.alerts?.enabled ? "Update thresholds or notification settings" : "Get notified before AWS suspends your account"
14969
+ },
14819
14970
  {
14820
14971
  value: "custom",
14821
14972
  label: "Custom configuration",
@@ -15297,6 +15448,208 @@ ${pc21.bold("Current Configuration:")}
15297
15448
  newPreset = void 0;
15298
15449
  break;
15299
15450
  }
15451
+ case "alerts": {
15452
+ if (!config2.reputationMetrics) {
15453
+ clack20.log.warn("Reputation metrics must be enabled to use alerting.");
15454
+ clack20.log.info(
15455
+ "This requires the Production or Enterprise preset, or enabling reputation metrics manually."
15456
+ );
15457
+ const enableReputationMetrics = await clack20.confirm({
15458
+ message: "Enable reputation metrics now?",
15459
+ initialValue: true
15460
+ });
15461
+ if (clack20.isCancel(enableReputationMetrics) || !enableReputationMetrics) {
15462
+ clack20.cancel("Alerting not enabled.");
15463
+ process.exit(0);
15464
+ }
15465
+ updatedConfig = {
15466
+ ...config2,
15467
+ reputationMetrics: true
15468
+ };
15469
+ }
15470
+ if (config2.alerts?.enabled) {
15471
+ clack20.log.info(`Alerting is currently ${pc21.green("enabled")}`);
15472
+ if (config2.alerts.notificationEmail) {
15473
+ clack20.log.info(
15474
+ ` Notification email: ${pc21.cyan(config2.alerts.notificationEmail)}`
15475
+ );
15476
+ }
15477
+ const alertsAction = await clack20.select({
15478
+ message: "What would you like to do?",
15479
+ options: [
15480
+ {
15481
+ value: "change-email",
15482
+ label: "Change notification email",
15483
+ hint: config2.alerts.notificationEmail || "Not set"
15484
+ },
15485
+ {
15486
+ value: "change-thresholds",
15487
+ label: "Customize alert thresholds",
15488
+ hint: "Adjust bounce/complaint rate thresholds"
15489
+ },
15490
+ {
15491
+ value: "disable",
15492
+ label: "Disable alerting",
15493
+ hint: "Remove CloudWatch alarms and SNS topic"
15494
+ }
15495
+ ]
15496
+ });
15497
+ if (clack20.isCancel(alertsAction)) {
15498
+ clack20.cancel("Upgrade cancelled.");
15499
+ process.exit(0);
15500
+ }
15501
+ if (alertsAction === "disable") {
15502
+ const confirmDisable = await clack20.confirm({
15503
+ message: "Are you sure? You won't be notified if your reputation degrades.",
15504
+ initialValue: false
15505
+ });
15506
+ if (clack20.isCancel(confirmDisable) || !confirmDisable) {
15507
+ clack20.log.info("Alerting not disabled.");
15508
+ process.exit(0);
15509
+ }
15510
+ updatedConfig = {
15511
+ ...config2,
15512
+ alerts: { enabled: false }
15513
+ };
15514
+ } else if (alertsAction === "change-email") {
15515
+ const notificationEmail = await clack20.text({
15516
+ message: "Notification email address:",
15517
+ placeholder: "alerts@yourcompany.com",
15518
+ initialValue: config2.alerts.notificationEmail || "",
15519
+ validate: (value) => {
15520
+ if (value && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value)) {
15521
+ return "Please enter a valid email address";
15522
+ }
15523
+ }
15524
+ });
15525
+ if (clack20.isCancel(notificationEmail)) {
15526
+ clack20.cancel("Upgrade cancelled.");
15527
+ process.exit(0);
15528
+ }
15529
+ updatedConfig = {
15530
+ ...config2,
15531
+ alerts: {
15532
+ ...config2.alerts,
15533
+ enabled: true,
15534
+ notificationEmail: notificationEmail || void 0
15535
+ }
15536
+ };
15537
+ } else if (alertsAction === "change-thresholds") {
15538
+ clack20.log.info(`
15539
+ ${pc21.bold("Alert Thresholds")}`);
15540
+ clack20.log.info(
15541
+ pc21.dim("These thresholds warn you BEFORE AWS takes action:")
15542
+ );
15543
+ clack20.log.info(pc21.dim(" AWS warns at 5% bounce, 0.1% complaint"));
15544
+ clack20.log.info(pc21.dim(" Gmail blocks at 0.3% complaint rate\n"));
15545
+ const thresholdPreset = await clack20.select({
15546
+ message: "Choose threshold sensitivity:",
15547
+ options: [
15548
+ {
15549
+ value: "standard",
15550
+ label: "Standard (recommended)",
15551
+ hint: "Bounce: 2%/4%, Complaint: 0.05%/0.08%"
15552
+ },
15553
+ {
15554
+ value: "strict",
15555
+ label: "Strict (enterprise)",
15556
+ hint: "Bounce: 1%/2%, Complaint: 0.03%/0.05%"
15557
+ },
15558
+ {
15559
+ value: "relaxed",
15560
+ label: "Relaxed",
15561
+ hint: "Bounce: 3%/5%, Complaint: 0.08%/0.1%"
15562
+ }
15563
+ ]
15564
+ });
15565
+ if (clack20.isCancel(thresholdPreset)) {
15566
+ clack20.cancel("Upgrade cancelled.");
15567
+ process.exit(0);
15568
+ }
15569
+ const thresholdConfigs = {
15570
+ standard: {
15571
+ bounceRateWarning: 0.02,
15572
+ bounceRateCritical: 0.04,
15573
+ complaintRateWarning: 5e-4,
15574
+ complaintRateCritical: 8e-4
15575
+ },
15576
+ strict: {
15577
+ bounceRateWarning: 0.01,
15578
+ bounceRateCritical: 0.02,
15579
+ complaintRateWarning: 3e-4,
15580
+ complaintRateCritical: 5e-4
15581
+ },
15582
+ relaxed: {
15583
+ bounceRateWarning: 0.03,
15584
+ bounceRateCritical: 0.05,
15585
+ complaintRateWarning: 8e-4,
15586
+ complaintRateCritical: 1e-3
15587
+ }
15588
+ };
15589
+ updatedConfig = {
15590
+ ...config2,
15591
+ alerts: {
15592
+ ...config2.alerts,
15593
+ enabled: true,
15594
+ thresholds: thresholdConfigs[thresholdPreset]
15595
+ }
15596
+ };
15597
+ }
15598
+ } else {
15599
+ clack20.log.info(`
15600
+ ${pc21.bold("Reputation Alerts")}
15601
+ `);
15602
+ clack20.log.info(
15603
+ pc21.dim("Get notified when your email reputation is at risk:")
15604
+ );
15605
+ clack20.log.info(pc21.dim(" - Bounce rate warnings (before AWS review)"));
15606
+ clack20.log.info(
15607
+ pc21.dim(" - Complaint rate warnings (before Gmail blocks you)")
15608
+ );
15609
+ clack20.log.info(pc21.dim(" - DLQ alerts (event processing failures)"));
15610
+ clack20.log.info(pc21.dim("\nCost: ~$0.50/mo (5 CloudWatch alarms)\n"));
15611
+ const enableAlerts = await clack20.confirm({
15612
+ message: "Enable reputation alerts?",
15613
+ initialValue: true
15614
+ });
15615
+ if (clack20.isCancel(enableAlerts) || !enableAlerts) {
15616
+ clack20.log.info("Alerting not enabled.");
15617
+ process.exit(0);
15618
+ }
15619
+ const notificationEmail = await clack20.text({
15620
+ message: "Notification email address:",
15621
+ placeholder: "alerts@yourcompany.com",
15622
+ validate: (value) => {
15623
+ if (!value) {
15624
+ return "Email address is required for alerts";
15625
+ }
15626
+ if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value)) {
15627
+ return "Please enter a valid email address";
15628
+ }
15629
+ }
15630
+ });
15631
+ if (clack20.isCancel(notificationEmail)) {
15632
+ clack20.cancel("Upgrade cancelled.");
15633
+ process.exit(0);
15634
+ }
15635
+ clack20.log.info(
15636
+ pc21.dim("\nYou'll receive an email to confirm your subscription.")
15637
+ );
15638
+ updatedConfig = {
15639
+ ...config2,
15640
+ reputationMetrics: true,
15641
+ // Required for alerts
15642
+ alerts: {
15643
+ enabled: true,
15644
+ notificationEmail,
15645
+ dlqAlerts: true
15646
+ // Uses default thresholds
15647
+ }
15648
+ };
15649
+ }
15650
+ newPreset = void 0;
15651
+ break;
15652
+ }
15300
15653
  case "custom": {
15301
15654
  const { promptCustomConfig: promptCustomConfig2 } = await Promise.resolve().then(() => (init_prompts(), prompts_exports));
15302
15655
  const customConfig = await promptCustomConfig2(config2);
@@ -15905,6 +16258,9 @@ ${pc21.green("\u2713")} ${pc21.bold("Upgrade complete!")}
15905
16258
  if (updatedConfig.smtpCredentials?.enabled) {
15906
16259
  enabledFeatures.push("smtp_credentials");
15907
16260
  }
16261
+ if (updatedConfig.alerts?.enabled) {
16262
+ enabledFeatures.push("alerts");
16263
+ }
15908
16264
  trackServiceUpgrade("email", {
15909
16265
  from_preset: metadata.services.email?.preset,
15910
16266
  to_preset: newPreset,
@@ -16111,12 +16467,17 @@ function buildConsolePolicyDocument(emailConfig, smsConfig) {
16111
16467
  statements.push({
16112
16468
  Effect: "Allow",
16113
16469
  Action: [
16470
+ "ses:GetAccount",
16471
+ // Get SES rate limits and quotas
16114
16472
  "ses:GetSendStatistics",
16115
16473
  "ses:ListIdentities",
16116
16474
  "ses:GetIdentityVerificationAttributes",
16117
16475
  // SES v2 API for listing/getting email identities (domains)
16118
16476
  "ses:ListEmailIdentities",
16119
16477
  "ses:GetEmailIdentity",
16478
+ // SES v2 API for configuration set scanning (needed by dashboard)
16479
+ "ses:GetConfigurationSet",
16480
+ "ses:GetConfigurationSetEventDestinations",
16120
16481
  "cloudwatch:GetMetricData",
16121
16482
  "cloudwatch:GetMetricStatistics"
16122
16483
  ],
@@ -17261,7 +17622,7 @@ import {
17261
17622
  } from "@aws-sdk/client-cloudwatch";
17262
17623
  async function fetchSESMetrics(roleArn, region, timeRange, tableName) {
17263
17624
  const credentials = roleArn ? await assumeRole(roleArn, region) : void 0;
17264
- const cloudwatch3 = new CloudWatchClient({ region, credentials });
17625
+ const cloudwatch4 = new CloudWatchClient({ region, credentials });
17265
17626
  const queries = [
17266
17627
  {
17267
17628
  Id: "sends",
@@ -17309,7 +17670,7 @@ async function fetchSESMetrics(roleArn, region, timeRange, tableName) {
17309
17670
  }
17310
17671
  }
17311
17672
  ];
17312
- const response = await cloudwatch3.send(
17673
+ const response = await cloudwatch4.send(
17313
17674
  new GetMetricDataCommand({
17314
17675
  MetricDataQueries: queries,
17315
17676
  StartTime: timeRange.start,
@@ -18094,7 +18455,7 @@ import {
18094
18455
  import { unmarshall as unmarshall4 } from "@aws-sdk/util-dynamodb";
18095
18456
  async function fetchSMSSpendLimits(region) {
18096
18457
  const smsClient = new PinpointSMSVoiceV2Client({ region });
18097
- const cloudwatch3 = new CloudWatchClient2({ region });
18458
+ const cloudwatch4 = new CloudWatchClient2({ region });
18098
18459
  try {
18099
18460
  const spendLimits = await smsClient.send(
18100
18461
  new DescribeSpendLimitsCommand({})
@@ -18108,7 +18469,7 @@ async function fetchSMSSpendLimits(region) {
18108
18469
  }
18109
18470
  const now = /* @__PURE__ */ new Date();
18110
18471
  const startOfMonth = new Date(now.getFullYear(), now.getMonth(), 1);
18111
- const metricsResponse = await cloudwatch3.send(
18472
+ const metricsResponse = await cloudwatch4.send(
18112
18473
  new GetMetricDataCommand2({
18113
18474
  MetricDataQueries: [
18114
18475
  {
@@ -19095,7 +19456,7 @@ import pc28 from "picocolors";
19095
19456
 
19096
19457
  // src/infrastructure/sms-stack.ts
19097
19458
  init_esm_shims();
19098
- import * as aws14 from "@pulumi/aws";
19459
+ import * as aws15 from "@pulumi/aws";
19099
19460
  import * as pulumi22 from "@pulumi/pulumi";
19100
19461
  async function roleExists3(roleName) {
19101
19462
  try {
@@ -19173,7 +19534,7 @@ async function createSMSIAMRole(config2) {
19173
19534
  }
19174
19535
  const roleName = "wraps-sms-role";
19175
19536
  const exists = await roleExists3(roleName);
19176
- const role = exists ? new aws14.iam.Role(
19537
+ const role = exists ? new aws15.iam.Role(
19177
19538
  roleName,
19178
19539
  {
19179
19540
  name: roleName,
@@ -19188,7 +19549,7 @@ async function createSMSIAMRole(config2) {
19188
19549
  import: roleName,
19189
19550
  customTimeouts: { create: "2m", update: "2m", delete: "2m" }
19190
19551
  }
19191
- ) : new aws14.iam.Role(
19552
+ ) : new aws15.iam.Role(
19192
19553
  roleName,
19193
19554
  {
19194
19555
  name: roleName,
@@ -19283,7 +19644,7 @@ async function createSMSIAMRole(config2) {
19283
19644
  Resource: "arn:aws:logs:*:*:log-group:/aws/lambda/wraps-sms-*"
19284
19645
  });
19285
19646
  }
19286
- new aws14.iam.RolePolicy("wraps-sms-policy", {
19647
+ new aws15.iam.RolePolicy("wraps-sms-policy", {
19287
19648
  role: role.name,
19288
19649
  policy: JSON.stringify({
19289
19650
  Version: "2012-10-17",
@@ -19293,7 +19654,7 @@ async function createSMSIAMRole(config2) {
19293
19654
  return role;
19294
19655
  }
19295
19656
  function createSMSConfigurationSet() {
19296
- return new aws14.pinpoint.Smsvoicev2ConfigurationSet("wraps-sms-config", {
19657
+ return new aws15.pinpoint.Smsvoicev2ConfigurationSet("wraps-sms-config", {
19297
19658
  name: "wraps-sms-config",
19298
19659
  defaultMessageType: "TRANSACTIONAL",
19299
19660
  tags: {
@@ -19303,7 +19664,7 @@ function createSMSConfigurationSet() {
19303
19664
  });
19304
19665
  }
19305
19666
  function createSMSOptOutList() {
19306
- return new aws14.pinpoint.Smsvoicev2OptOutList("wraps-sms-optouts", {
19667
+ return new aws15.pinpoint.Smsvoicev2OptOutList("wraps-sms-optouts", {
19307
19668
  name: "wraps-sms-optouts",
19308
19669
  tags: {
19309
19670
  ManagedBy: "wraps-cli",
@@ -19367,7 +19728,7 @@ async function createSMSPhoneNumber(phoneNumberType, optOutList) {
19367
19728
  }
19368
19729
  };
19369
19730
  if (existingArn) {
19370
- return new aws14.pinpoint.Smsvoicev2PhoneNumber(
19731
+ return new aws15.pinpoint.Smsvoicev2PhoneNumber(
19371
19732
  "wraps-sms-number",
19372
19733
  phoneConfig,
19373
19734
  {
@@ -19376,7 +19737,7 @@ async function createSMSPhoneNumber(phoneNumberType, optOutList) {
19376
19737
  }
19377
19738
  );
19378
19739
  }
19379
- return new aws14.pinpoint.Smsvoicev2PhoneNumber(
19740
+ return new aws15.pinpoint.Smsvoicev2PhoneNumber(
19380
19741
  "wraps-sms-number",
19381
19742
  phoneConfig,
19382
19743
  {
@@ -19419,10 +19780,10 @@ async function createSMSSQSResources() {
19419
19780
  Description: "Dead letter queue for failed SMS event processing"
19420
19781
  }
19421
19782
  };
19422
- const dlq = dlqUrl ? new aws14.sqs.Queue(dlqName, dlqConfig, {
19783
+ const dlq = dlqUrl ? new aws15.sqs.Queue(dlqName, dlqConfig, {
19423
19784
  import: dlqUrl,
19424
19785
  customTimeouts: { create: "2m", update: "2m", delete: "2m" }
19425
- }) : new aws14.sqs.Queue(dlqName, dlqConfig, {
19786
+ }) : new aws15.sqs.Queue(dlqName, dlqConfig, {
19426
19787
  customTimeouts: { create: "2m", update: "2m", delete: "2m" }
19427
19788
  });
19428
19789
  const queueConfig = {
@@ -19445,10 +19806,10 @@ async function createSMSSQSResources() {
19445
19806
  Description: "Queue for SMS events from SNS"
19446
19807
  }
19447
19808
  };
19448
- const queue = queueUrl ? new aws14.sqs.Queue(queueName, queueConfig, {
19809
+ const queue = queueUrl ? new aws15.sqs.Queue(queueName, queueConfig, {
19449
19810
  import: queueUrl,
19450
19811
  customTimeouts: { create: "2m", update: "2m", delete: "2m" }
19451
- }) : new aws14.sqs.Queue(queueName, queueConfig, {
19812
+ }) : new aws15.sqs.Queue(queueName, queueConfig, {
19452
19813
  customTimeouts: { create: "2m", update: "2m", delete: "2m" }
19453
19814
  });
19454
19815
  return { queue, dlq };
@@ -19456,12 +19817,12 @@ async function createSMSSQSResources() {
19456
19817
  async function snsTopicExists(topicName) {
19457
19818
  try {
19458
19819
  const { SNSClient: SNSClient2, ListTopicsCommand: ListTopicsCommand2 } = await import("@aws-sdk/client-sns");
19459
- const sns2 = new SNSClient2({
19820
+ const sns3 = new SNSClient2({
19460
19821
  region: process.env.AWS_REGION || "us-east-1"
19461
19822
  });
19462
19823
  let nextToken;
19463
19824
  do {
19464
- const response = await sns2.send(
19825
+ const response = await sns3.send(
19465
19826
  new ListTopicsCommand2({ NextToken: nextToken })
19466
19827
  );
19467
19828
  const found = response.Topics?.find(
@@ -19488,13 +19849,13 @@ async function createSMSSNSResources(config2) {
19488
19849
  Description: "SNS topic for SMS delivery events"
19489
19850
  }
19490
19851
  };
19491
- const topic = topicArn ? new aws14.sns.Topic("wraps-sms-events-topic", topicConfig, {
19852
+ const topic = topicArn ? new aws15.sns.Topic("wraps-sms-events-topic", topicConfig, {
19492
19853
  import: topicArn,
19493
19854
  customTimeouts: { create: "2m", update: "2m", delete: "2m" }
19494
- }) : new aws14.sns.Topic("wraps-sms-events-topic", topicConfig, {
19855
+ }) : new aws15.sns.Topic("wraps-sms-events-topic", topicConfig, {
19495
19856
  customTimeouts: { create: "2m", update: "2m", delete: "2m" }
19496
19857
  });
19497
- new aws14.sns.TopicPolicy("wraps-sms-events-topic-policy", {
19858
+ new aws15.sns.TopicPolicy("wraps-sms-events-topic-policy", {
19498
19859
  arn: topic.arn,
19499
19860
  policy: topic.arn.apply(
19500
19861
  (topicArn2) => JSON.stringify({
@@ -19511,7 +19872,7 @@ async function createSMSSNSResources(config2) {
19511
19872
  })
19512
19873
  )
19513
19874
  });
19514
- new aws14.sqs.QueuePolicy("wraps-sms-events-queue-policy", {
19875
+ new aws15.sqs.QueuePolicy("wraps-sms-events-queue-policy", {
19515
19876
  queueUrl: config2.queueUrl,
19516
19877
  policy: pulumi22.all([config2.queueArn, topic.arn]).apply(
19517
19878
  ([queueArn, topicArn2]) => JSON.stringify({
@@ -19530,7 +19891,7 @@ async function createSMSSNSResources(config2) {
19530
19891
  })
19531
19892
  )
19532
19893
  });
19533
- const subscription = new aws14.sns.TopicSubscription(
19894
+ const subscription = new aws15.sns.TopicSubscription(
19534
19895
  "wraps-sms-events-subscription",
19535
19896
  {
19536
19897
  topic: topic.arn,
@@ -19579,17 +19940,17 @@ async function createSMSDynamoDBTable() {
19579
19940
  Service: "sms"
19580
19941
  }
19581
19942
  };
19582
- return exists ? new aws14.dynamodb.Table(tableName, tableConfig, {
19943
+ return exists ? new aws15.dynamodb.Table(tableName, tableConfig, {
19583
19944
  import: tableName,
19584
19945
  customTimeouts: { create: "5m", update: "5m", delete: "5m" }
19585
- }) : new aws14.dynamodb.Table(tableName, tableConfig, {
19946
+ }) : new aws15.dynamodb.Table(tableName, tableConfig, {
19586
19947
  customTimeouts: { create: "5m", update: "5m", delete: "5m" }
19587
19948
  });
19588
19949
  }
19589
19950
  async function deploySMSLambdaFunction(config2) {
19590
19951
  const { getLambdaCode: getLambdaCode2 } = await Promise.resolve().then(() => (init_lambda(), lambda_exports));
19591
19952
  const codeDir = await getLambdaCode2("sms-event-processor");
19592
- const lambdaRole = new aws14.iam.Role("wraps-sms-lambda-role", {
19953
+ const lambdaRole = new aws15.iam.Role("wraps-sms-lambda-role", {
19593
19954
  name: "wraps-sms-lambda-role",
19594
19955
  assumeRolePolicy: JSON.stringify({
19595
19956
  Version: "2012-10-17",
@@ -19606,11 +19967,11 @@ async function deploySMSLambdaFunction(config2) {
19606
19967
  Service: "sms"
19607
19968
  }
19608
19969
  });
19609
- new aws14.iam.RolePolicyAttachment("wraps-sms-lambda-basic-execution", {
19970
+ new aws15.iam.RolePolicyAttachment("wraps-sms-lambda-basic-execution", {
19610
19971
  role: lambdaRole.name,
19611
19972
  policyArn: "arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole"
19612
19973
  });
19613
- new aws14.iam.RolePolicy("wraps-sms-lambda-policy", {
19974
+ new aws15.iam.RolePolicy("wraps-sms-lambda-policy", {
19614
19975
  role: lambdaRole.name,
19615
19976
  policy: pulumi22.all([config2.tableName, config2.queueArn]).apply(
19616
19977
  ([tableName, queueArn]) => JSON.stringify({
@@ -19642,7 +20003,7 @@ async function deploySMSLambdaFunction(config2) {
19642
20003
  })
19643
20004
  )
19644
20005
  });
19645
- const eventProcessor = new aws14.lambda.Function(
20006
+ const eventProcessor = new aws15.lambda.Function(
19646
20007
  "wraps-sms-event-processor",
19647
20008
  {
19648
20009
  name: "wraps-sms-event-processor",
@@ -19670,7 +20031,7 @@ async function deploySMSLambdaFunction(config2) {
19670
20031
  customTimeouts: { create: "5m", update: "5m", delete: "2m" }
19671
20032
  }
19672
20033
  );
19673
- new aws14.lambda.EventSourceMapping(
20034
+ new aws15.lambda.EventSourceMapping(
19674
20035
  "wraps-sms-event-source-mapping",
19675
20036
  {
19676
20037
  eventSourceArn: config2.queueArn,
@@ -19686,7 +20047,7 @@ async function deploySMSLambdaFunction(config2) {
19686
20047
  return eventProcessor;
19687
20048
  }
19688
20049
  async function deploySMSStack(config2) {
19689
- const identity = await aws14.getCallerIdentity();
20050
+ const identity = await aws15.getCallerIdentity();
19690
20051
  const accountId = identity.accountId;
19691
20052
  let oidcProvider;
19692
20053
  if (config2.provider === "vercel" && config2.vercel) {