@wraps.dev/cli 2.2.7 → 2.3.0

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
@@ -147,7 +147,7 @@ var require_package = __commonJS({
147
147
  "package.json"(exports, module) {
148
148
  module.exports = {
149
149
  name: "@wraps.dev/cli",
150
- version: "2.2.7",
150
+ version: "2.3.0",
151
151
  description: "CLI for deploying Wraps email infrastructure to your AWS account",
152
152
  type: "module",
153
153
  main: "./dist/cli.js",
@@ -978,6 +978,18 @@ function calculateSMTPCredentialsCost(config2) {
978
978
  description: "SMTP credentials (no additional cost)"
979
979
  };
980
980
  }
981
+ function calculateAlertingCost(config2) {
982
+ if (!config2.alerts?.enabled) {
983
+ return;
984
+ }
985
+ const numAlarms = config2.alerts.dlqAlerts !== false ? 5 : 4;
986
+ const alarmCost = numAlarms * 0.1;
987
+ const snsCost = 0;
988
+ return {
989
+ monthly: alarmCost + snsCost,
990
+ description: `Reputation alerts (${numAlarms} CloudWatch alarms)`
991
+ };
992
+ }
981
993
  function calculateCosts(config2, emailsPerMonth = 1e4) {
982
994
  const tracking = calculateTrackingCost(config2);
983
995
  const reputationMetrics = calculateReputationMetricsCost(config2);
@@ -987,8 +999,9 @@ function calculateCosts(config2, emailsPerMonth = 1e4) {
987
999
  const dedicatedIp = calculateDedicatedIpCost(config2);
988
1000
  const waf = calculateWafCost(config2, emailsPerMonth);
989
1001
  const smtpCredentials = calculateSMTPCredentialsCost(config2);
1002
+ const alerts = calculateAlertingCost(config2);
990
1003
  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);
1004
+ 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
1005
  return {
993
1006
  tracking,
994
1007
  reputationMetrics,
@@ -998,6 +1011,7 @@ function calculateCosts(config2, emailsPerMonth = 1e4) {
998
1011
  dedicatedIp,
999
1012
  waf,
1000
1013
  smtpCredentials,
1014
+ alerts,
1001
1015
  total: {
1002
1016
  monthly: totalMonthlyCost,
1003
1017
  perEmail: AWS_PRICING.SES_PER_EMAIL,
@@ -1063,6 +1077,11 @@ function getCostSummary(config2, emailsPerMonth = 1e4) {
1063
1077
  ` - ${costs.smtpCredentials.description}: ${formatCost(costs.smtpCredentials.monthly)}`
1064
1078
  );
1065
1079
  }
1080
+ if (costs.alerts) {
1081
+ lines.push(
1082
+ ` - ${costs.alerts.description}: ${formatCost(costs.alerts.monthly)}`
1083
+ );
1084
+ }
1066
1085
  return lines.join("\n");
1067
1086
  }
1068
1087
  var AWS_PRICING, FREE_TIER;
@@ -1205,6 +1224,7 @@ function getPresetInfo(preset) {
1205
1224
  "Reputation tracking",
1206
1225
  "Real-time event tracking (EventBridge)",
1207
1226
  "90-day email history storage",
1227
+ "Reputation alerts (bounce/complaint rate monitoring)",
1208
1228
  "Optional: Email archiving with rendered viewer",
1209
1229
  "Complete event visibility"
1210
1230
  ]
@@ -1218,6 +1238,7 @@ function getPresetInfo(preset) {
1218
1238
  "Everything in Production",
1219
1239
  "Dedicated IP address",
1220
1240
  "1-year email history",
1241
+ "Stricter alert thresholds (catch issues earlier)",
1221
1242
  "Optional: 1-year+ email archiving",
1222
1243
  "All event types tracked",
1223
1244
  "Priority support eligibility"
@@ -1306,6 +1327,10 @@ var init_presets = __esm({
1306
1327
  enabled: false,
1307
1328
  retention: "30days"
1308
1329
  },
1330
+ // Alerting disabled for starter (no reputation metrics)
1331
+ alerts: {
1332
+ enabled: false
1333
+ },
1309
1334
  sendingEnabled: true
1310
1335
  };
1311
1336
  PRODUCTION_PRESET = {
@@ -1342,6 +1367,12 @@ var init_presets = __esm({
1342
1367
  // User can opt-in
1343
1368
  retention: "90days"
1344
1369
  },
1370
+ // Alerting enabled - warns before AWS/Gmail take action
1371
+ alerts: {
1372
+ enabled: true,
1373
+ dlqAlerts: true
1374
+ // Uses default thresholds: bounce 2%/4%, complaint 0.05%/0.08%
1375
+ },
1345
1376
  sendingEnabled: true
1346
1377
  };
1347
1378
  ENTERPRISE_PRESET = {
@@ -1380,6 +1411,22 @@ var init_presets = __esm({
1380
1411
  // User can opt-in
1381
1412
  retention: "1year"
1382
1413
  },
1414
+ // Alerting with stricter thresholds for high-volume senders
1415
+ alerts: {
1416
+ enabled: true,
1417
+ dlqAlerts: true,
1418
+ thresholds: {
1419
+ // Stricter thresholds for enterprise - catch issues earlier
1420
+ bounceRateWarning: 0.01,
1421
+ // 1% (vs 2% default)
1422
+ bounceRateCritical: 0.02,
1423
+ // 2% (vs 4% default)
1424
+ complaintRateWarning: 3e-4,
1425
+ // 0.03% (vs 0.05% default)
1426
+ complaintRateCritical: 5e-4
1427
+ // 0.05% (vs 0.08% default)
1428
+ }
1429
+ },
1383
1430
  dedicatedIp: true,
1384
1431
  sendingEnabled: true
1385
1432
  };
@@ -3223,7 +3270,7 @@ import { existsSync as existsSync4, mkdirSync } from "fs";
3223
3270
  import { tmpdir } from "os";
3224
3271
  import { dirname, join as join4 } from "path";
3225
3272
  import { fileURLToPath as fileURLToPath2 } from "url";
3226
- import * as aws7 from "@pulumi/aws";
3273
+ import * as aws8 from "@pulumi/aws";
3227
3274
  import * as pulumi11 from "@pulumi/pulumi";
3228
3275
  import { build } from "esbuild";
3229
3276
  function getPackageRoot() {
@@ -3312,7 +3359,7 @@ Try running: pnpm build`
3312
3359
  }
3313
3360
  async function deployLambdaFunctions(config2) {
3314
3361
  const eventProcessorCode = await getLambdaCode("event-processor");
3315
- const lambdaRole = new aws7.iam.Role("wraps-email-lambda-role", {
3362
+ const lambdaRole = new aws8.iam.Role("wraps-email-lambda-role", {
3316
3363
  assumeRolePolicy: JSON.stringify({
3317
3364
  Version: "2012-10-17",
3318
3365
  Statement: [
@@ -3327,11 +3374,11 @@ async function deployLambdaFunctions(config2) {
3327
3374
  ManagedBy: "wraps-cli"
3328
3375
  }
3329
3376
  });
3330
- new aws7.iam.RolePolicyAttachment("wraps-email-lambda-basic-execution", {
3377
+ new aws8.iam.RolePolicyAttachment("wraps-email-lambda-basic-execution", {
3331
3378
  role: lambdaRole.name,
3332
3379
  policyArn: "arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole"
3333
3380
  });
3334
- new aws7.iam.RolePolicy("wraps-email-lambda-policy", {
3381
+ new aws8.iam.RolePolicy("wraps-email-lambda-policy", {
3335
3382
  role: lambdaRole.name,
3336
3383
  policy: pulumi11.all([config2.tableName, config2.queueArn]).apply(
3337
3384
  ([tableName, queueArn]) => JSON.stringify({
@@ -3375,7 +3422,7 @@ async function deployLambdaFunctions(config2) {
3375
3422
  RETENTION_DAYS: config2.retentionDays.toString()
3376
3423
  }
3377
3424
  };
3378
- const eventProcessor = exists ? new aws7.lambda.Function(
3425
+ const eventProcessor = exists ? new aws8.lambda.Function(
3379
3426
  functionName,
3380
3427
  {
3381
3428
  name: functionName,
@@ -3383,8 +3430,8 @@ async function deployLambdaFunctions(config2) {
3383
3430
  handler: "index.handler",
3384
3431
  role: lambdaRole.arn,
3385
3432
  code: new pulumi11.asset.FileArchive(eventProcessorCode),
3386
- timeout: 300,
3387
- // 5 minutes (matches SQS visibility timeout)
3433
+ timeout: 30,
3434
+ // Lambda just parses JSON and writes to DynamoDB
3388
3435
  memorySize: 512,
3389
3436
  environment: lambdaEnvironment,
3390
3437
  tags: {
@@ -3396,14 +3443,14 @@ async function deployLambdaFunctions(config2) {
3396
3443
  import: functionName
3397
3444
  // Import existing function
3398
3445
  }
3399
- ) : new aws7.lambda.Function(functionName, {
3446
+ ) : new aws8.lambda.Function(functionName, {
3400
3447
  name: functionName,
3401
3448
  runtime: "nodejs24.x",
3402
3449
  handler: "index.handler",
3403
3450
  role: lambdaRole.arn,
3404
3451
  code: new pulumi11.asset.FileArchive(eventProcessorCode),
3405
- timeout: 300,
3406
- // 5 minutes (matches SQS visibility timeout)
3452
+ timeout: 30,
3453
+ // Lambda just parses JSON and writes to DynamoDB
3407
3454
  memorySize: 512,
3408
3455
  environment: lambdaEnvironment,
3409
3456
  tags: {
@@ -3426,14 +3473,14 @@ async function deployLambdaFunctions(config2) {
3426
3473
  functionResponseTypes: ["ReportBatchItemFailures"]
3427
3474
  // Enable partial batch responses
3428
3475
  };
3429
- const eventSourceMapping = existingMappingUuid ? new aws7.lambda.EventSourceMapping(
3476
+ const eventSourceMapping = existingMappingUuid ? new aws8.lambda.EventSourceMapping(
3430
3477
  "wraps-email-event-source-mapping",
3431
3478
  mappingConfig,
3432
3479
  {
3433
3480
  import: existingMappingUuid
3434
3481
  // Import with the UUID
3435
3482
  }
3436
- ) : new aws7.lambda.EventSourceMapping(
3483
+ ) : new aws8.lambda.EventSourceMapping(
3437
3484
  "wraps-email-event-source-mapping",
3438
3485
  mappingConfig
3439
3486
  );
@@ -3454,12 +3501,12 @@ var acm_exports = {};
3454
3501
  __export(acm_exports, {
3455
3502
  createACMCertificate: () => createACMCertificate
3456
3503
  });
3457
- import * as aws11 from "@pulumi/aws";
3504
+ import * as aws12 from "@pulumi/aws";
3458
3505
  async function createACMCertificate(config2) {
3459
- const usEast1Provider = new aws11.Provider("acm-us-east-1", {
3506
+ const usEast1Provider = new aws12.Provider("acm-us-east-1", {
3460
3507
  region: "us-east-1"
3461
3508
  });
3462
- const certificate = new aws11.acm.Certificate(
3509
+ const certificate = new aws12.acm.Certificate(
3463
3510
  "wraps-email-tracking-cert",
3464
3511
  {
3465
3512
  domainName: config2.domain,
@@ -3482,7 +3529,7 @@ async function createACMCertificate(config2) {
3482
3529
  );
3483
3530
  let certificateValidation;
3484
3531
  if (config2.hostedZoneId) {
3485
- const validationRecord = new aws11.route53.Record(
3532
+ const validationRecord = new aws12.route53.Record(
3486
3533
  "wraps-email-tracking-cert-validation",
3487
3534
  {
3488
3535
  zoneId: config2.hostedZoneId,
@@ -3492,7 +3539,7 @@ async function createACMCertificate(config2) {
3492
3539
  ttl: 60
3493
3540
  }
3494
3541
  );
3495
- certificateValidation = new aws11.acm.CertificateValidation(
3542
+ certificateValidation = new aws12.acm.CertificateValidation(
3496
3543
  "wraps-email-tracking-cert-validation-waiter",
3497
3544
  {
3498
3545
  certificateArn: certificate.arn,
@@ -3521,7 +3568,7 @@ var cloudfront_exports = {};
3521
3568
  __export(cloudfront_exports, {
3522
3569
  createCloudFrontTracking: () => createCloudFrontTracking
3523
3570
  });
3524
- import * as aws12 from "@pulumi/aws";
3571
+ import * as aws13 from "@pulumi/aws";
3525
3572
  async function findDistributionByAlias(alias) {
3526
3573
  try {
3527
3574
  const { CloudFrontClient, ListDistributionsCommand } = await import("@aws-sdk/client-cloudfront");
@@ -3537,10 +3584,10 @@ async function findDistributionByAlias(alias) {
3537
3584
  }
3538
3585
  }
3539
3586
  async function createWAFWebACL() {
3540
- const usEast1Provider = new aws12.Provider("waf-us-east-1", {
3587
+ const usEast1Provider = new aws13.Provider("waf-us-east-1", {
3541
3588
  region: "us-east-1"
3542
3589
  });
3543
- const webAcl = new aws12.wafv2.WebAcl(
3590
+ const webAcl = new aws13.wafv2.WebAcl(
3544
3591
  "wraps-email-tracking-waf",
3545
3592
  {
3546
3593
  scope: "CLOUDFRONT",
@@ -3655,14 +3702,14 @@ async function createCloudFrontTracking(config2) {
3655
3702
  Description: "Wraps email tracking CloudFront distribution"
3656
3703
  }
3657
3704
  };
3658
- const distribution = existingDistributionId ? new aws12.cloudfront.Distribution(
3705
+ const distribution = existingDistributionId ? new aws13.cloudfront.Distribution(
3659
3706
  "wraps-email-tracking-cdn",
3660
3707
  distributionConfig,
3661
3708
  {
3662
3709
  import: existingDistributionId
3663
3710
  // Import existing distribution
3664
3711
  }
3665
- ) : new aws12.cloudfront.Distribution(
3712
+ ) : new aws13.cloudfront.Distribution(
3666
3713
  "wraps-email-tracking-cdn",
3667
3714
  distributionConfig
3668
3715
  );
@@ -11611,12 +11658,200 @@ import pc14 from "picocolors";
11611
11658
  // src/infrastructure/email-stack.ts
11612
11659
  init_esm_shims();
11613
11660
  init_dist();
11614
- import * as aws13 from "@pulumi/aws";
11661
+ import * as aws14 from "@pulumi/aws";
11615
11662
  import * as pulumi12 from "@pulumi/pulumi";
11616
11663
 
11617
- // src/infrastructure/resources/dynamodb.ts
11664
+ // src/infrastructure/resources/alerting.ts
11618
11665
  init_esm_shims();
11619
11666
  import * as aws4 from "@pulumi/aws";
11667
+
11668
+ // src/types/index.ts
11669
+ init_esm_shims();
11670
+
11671
+ // src/types/email.ts
11672
+ init_esm_shims();
11673
+ var DEFAULT_ALERT_THRESHOLDS = {
11674
+ bounceRateWarning: 0.02,
11675
+ // 2% - gives time before AWS 5% warning
11676
+ bounceRateCritical: 0.04,
11677
+ // 4% - urgent, approaching AWS warning
11678
+ complaintRateWarning: 5e-4,
11679
+ // 0.05% - half of AWS warning threshold
11680
+ complaintRateCritical: 8e-4,
11681
+ // 0.08% - urgent, approaching AWS 0.1% warning
11682
+ dlqMessageThreshold: 1
11683
+ // Any failed message processing
11684
+ };
11685
+
11686
+ // src/infrastructure/resources/alerting.ts
11687
+ function getThresholds(custom) {
11688
+ return {
11689
+ ...DEFAULT_ALERT_THRESHOLDS,
11690
+ ...custom
11691
+ };
11692
+ }
11693
+ async function createAlertingResources(config2) {
11694
+ const thresholds = getThresholds(config2.alertConfig.thresholds);
11695
+ const topic = new aws4.sns.Topic("wraps-email-alerts", {
11696
+ name: "wraps-email-alerts",
11697
+ displayName: "Wraps Email Alerts",
11698
+ tags: {
11699
+ ManagedBy: "wraps-cli",
11700
+ Description: "Alert notifications for email reputation and health"
11701
+ }
11702
+ });
11703
+ let emailSubscription;
11704
+ if (config2.alertConfig.notificationEmail) {
11705
+ emailSubscription = new aws4.sns.TopicSubscription(
11706
+ "wraps-email-alerts-email",
11707
+ {
11708
+ topic: topic.arn,
11709
+ protocol: "email",
11710
+ endpoint: config2.alertConfig.notificationEmail
11711
+ }
11712
+ );
11713
+ }
11714
+ let webhookSubscription;
11715
+ if (config2.alertConfig.webhookUrl) {
11716
+ webhookSubscription = new aws4.sns.TopicSubscription(
11717
+ "wraps-email-alerts-webhook",
11718
+ {
11719
+ topic: topic.arn,
11720
+ protocol: "https",
11721
+ endpoint: config2.alertConfig.webhookUrl
11722
+ }
11723
+ );
11724
+ }
11725
+ const bounceRateWarningAlarm = new aws4.cloudwatch.MetricAlarm(
11726
+ "wraps-bounce-rate-warning",
11727
+ {
11728
+ name: "wraps-email-bounce-rate-warning",
11729
+ alarmDescription: `Bounce rate exceeded ${thresholds.bounceRateWarning * 100}% - investigate before AWS takes action (warns at 5%, suspends at 10%)`,
11730
+ comparisonOperator: "GreaterThanThreshold",
11731
+ evaluationPeriods: 2,
11732
+ // Require 2 consecutive periods to reduce noise
11733
+ metricName: "Reputation.BounceRate",
11734
+ namespace: "AWS/SES",
11735
+ period: 300,
11736
+ // 5 minutes
11737
+ statistic: "Average",
11738
+ threshold: thresholds.bounceRateWarning,
11739
+ alarmActions: [topic.arn],
11740
+ okActions: [topic.arn],
11741
+ // Notify when resolved
11742
+ treatMissingData: "notBreaching",
11743
+ // Don't alarm if no data (no emails sent)
11744
+ tags: {
11745
+ ManagedBy: "wraps-cli",
11746
+ Severity: "warning"
11747
+ }
11748
+ }
11749
+ );
11750
+ const bounceRateCriticalAlarm = new aws4.cloudwatch.MetricAlarm(
11751
+ "wraps-bounce-rate-critical",
11752
+ {
11753
+ name: "wraps-email-bounce-rate-critical",
11754
+ alarmDescription: `CRITICAL: Bounce rate exceeded ${thresholds.bounceRateCritical * 100}% - approaching AWS warning threshold (5%). Immediate action required!`,
11755
+ comparisonOperator: "GreaterThanThreshold",
11756
+ evaluationPeriods: 1,
11757
+ // Alert immediately on critical
11758
+ metricName: "Reputation.BounceRate",
11759
+ namespace: "AWS/SES",
11760
+ period: 300,
11761
+ // 5 minutes
11762
+ statistic: "Average",
11763
+ threshold: thresholds.bounceRateCritical,
11764
+ alarmActions: [topic.arn],
11765
+ okActions: [topic.arn],
11766
+ treatMissingData: "notBreaching",
11767
+ tags: {
11768
+ ManagedBy: "wraps-cli",
11769
+ Severity: "critical"
11770
+ }
11771
+ }
11772
+ );
11773
+ const complaintRateWarningAlarm = new aws4.cloudwatch.MetricAlarm(
11774
+ "wraps-complaint-rate-warning",
11775
+ {
11776
+ name: "wraps-email-complaint-rate-warning",
11777
+ alarmDescription: `Complaint rate exceeded ${thresholds.complaintRateWarning * 100}% - investigate before AWS (0.1%) or Gmail (0.3%) take action`,
11778
+ comparisonOperator: "GreaterThanThreshold",
11779
+ evaluationPeriods: 2,
11780
+ metricName: "Reputation.ComplaintRate",
11781
+ namespace: "AWS/SES",
11782
+ period: 300,
11783
+ statistic: "Average",
11784
+ threshold: thresholds.complaintRateWarning,
11785
+ alarmActions: [topic.arn],
11786
+ okActions: [topic.arn],
11787
+ treatMissingData: "notBreaching",
11788
+ tags: {
11789
+ ManagedBy: "wraps-cli",
11790
+ Severity: "warning"
11791
+ }
11792
+ }
11793
+ );
11794
+ const complaintRateCriticalAlarm = new aws4.cloudwatch.MetricAlarm(
11795
+ "wraps-complaint-rate-critical",
11796
+ {
11797
+ name: "wraps-email-complaint-rate-critical",
11798
+ alarmDescription: `CRITICAL: Complaint rate exceeded ${thresholds.complaintRateCritical * 100}% - approaching AWS warning (0.1%). Immediate action required!`,
11799
+ comparisonOperator: "GreaterThanThreshold",
11800
+ evaluationPeriods: 1,
11801
+ metricName: "Reputation.ComplaintRate",
11802
+ namespace: "AWS/SES",
11803
+ period: 300,
11804
+ statistic: "Average",
11805
+ threshold: thresholds.complaintRateCritical,
11806
+ alarmActions: [topic.arn],
11807
+ okActions: [topic.arn],
11808
+ treatMissingData: "notBreaching",
11809
+ tags: {
11810
+ ManagedBy: "wraps-cli",
11811
+ Severity: "critical"
11812
+ }
11813
+ }
11814
+ );
11815
+ let dlqAlarm;
11816
+ if (config2.alertConfig.dlqAlerts !== false && config2.dlqName) {
11817
+ dlqAlarm = new aws4.cloudwatch.MetricAlarm("wraps-dlq-alarm", {
11818
+ name: "wraps-email-dlq-messages",
11819
+ alarmDescription: "Messages in dead letter queue - event processing is failing. Check Lambda logs for errors.",
11820
+ comparisonOperator: "GreaterThanOrEqualToThreshold",
11821
+ evaluationPeriods: 1,
11822
+ metricName: "ApproximateNumberOfMessagesVisible",
11823
+ namespace: "AWS/SQS",
11824
+ period: 60,
11825
+ // Check every minute
11826
+ statistic: "Sum",
11827
+ threshold: thresholds.dlqMessageThreshold,
11828
+ dimensions: {
11829
+ QueueName: config2.dlqName
11830
+ },
11831
+ alarmActions: [topic.arn],
11832
+ okActions: [topic.arn],
11833
+ treatMissingData: "notBreaching",
11834
+ tags: {
11835
+ ManagedBy: "wraps-cli",
11836
+ Severity: "warning"
11837
+ }
11838
+ });
11839
+ }
11840
+ return {
11841
+ topic,
11842
+ emailSubscription,
11843
+ webhookSubscription,
11844
+ bounceRateWarningAlarm,
11845
+ bounceRateCriticalAlarm,
11846
+ complaintRateWarningAlarm,
11847
+ complaintRateCriticalAlarm,
11848
+ dlqAlarm
11849
+ };
11850
+ }
11851
+
11852
+ // src/infrastructure/resources/dynamodb.ts
11853
+ init_esm_shims();
11854
+ import * as aws5 from "@pulumi/aws";
11620
11855
  async function tableExists(tableName) {
11621
11856
  try {
11622
11857
  const { DynamoDBClient: DynamoDBClient6, DescribeTableCommand: DescribeTableCommand2 } = await import("@aws-sdk/client-dynamodb");
@@ -11636,7 +11871,7 @@ async function tableExists(tableName) {
11636
11871
  async function createDynamoDBTables(_config) {
11637
11872
  const tableName = "wraps-email-history";
11638
11873
  const exists = await tableExists(tableName);
11639
- const emailHistory = exists ? new aws4.dynamodb.Table(
11874
+ const emailHistory = exists ? new aws5.dynamodb.Table(
11640
11875
  tableName,
11641
11876
  {
11642
11877
  name: tableName,
@@ -11668,7 +11903,7 @@ async function createDynamoDBTables(_config) {
11668
11903
  import: tableName
11669
11904
  // Import existing table
11670
11905
  }
11671
- ) : new aws4.dynamodb.Table(tableName, {
11906
+ ) : new aws5.dynamodb.Table(tableName, {
11672
11907
  name: tableName,
11673
11908
  billingMode: "PAY_PER_REQUEST",
11674
11909
  hashKey: "messageId",
@@ -11701,11 +11936,11 @@ async function createDynamoDBTables(_config) {
11701
11936
 
11702
11937
  // src/infrastructure/resources/eventbridge.ts
11703
11938
  init_esm_shims();
11704
- import * as aws5 from "@pulumi/aws";
11939
+ import * as aws6 from "@pulumi/aws";
11705
11940
  import * as pulumi9 from "@pulumi/pulumi";
11706
11941
  async function createEventBridgeResources(config2) {
11707
11942
  const eventBusName = config2.eventBusArn.apply((arn) => arn.split("/").pop());
11708
- const rule = new aws5.cloudwatch.EventRule("wraps-email-events-rule", {
11943
+ const rule = new aws6.cloudwatch.EventRule("wraps-email-events-rule", {
11709
11944
  name: "wraps-email-events-to-sqs",
11710
11945
  description: "Route all SES email events to SQS for processing",
11711
11946
  eventBusName,
@@ -11718,7 +11953,7 @@ async function createEventBridgeResources(config2) {
11718
11953
  ManagedBy: "wraps-cli"
11719
11954
  }
11720
11955
  });
11721
- new aws5.sqs.QueuePolicy("wraps-email-events-queue-policy", {
11956
+ new aws6.sqs.QueuePolicy("wraps-email-events-queue-policy", {
11722
11957
  queueUrl: config2.queueUrl,
11723
11958
  policy: pulumi9.all([config2.queueArn, rule.arn]).apply(
11724
11959
  ([queueArn, ruleArn]) => JSON.stringify({
@@ -11741,7 +11976,7 @@ async function createEventBridgeResources(config2) {
11741
11976
  })
11742
11977
  )
11743
11978
  });
11744
- const target = new aws5.cloudwatch.EventTarget("wraps-email-events-target", {
11979
+ const target = new aws6.cloudwatch.EventTarget("wraps-email-events-target", {
11745
11980
  rule: rule.name,
11746
11981
  eventBusName,
11747
11982
  arn: config2.queueArn
@@ -11752,7 +11987,7 @@ async function createEventBridgeResources(config2) {
11752
11987
  if (config2.webhook) {
11753
11988
  const { awsAccountNumber, webhookSecret, webhookUrl } = config2.webhook;
11754
11989
  const baseUrl = webhookUrl || "https://api.wraps.dev";
11755
- webhookConnection = new aws5.cloudwatch.EventConnection(
11990
+ webhookConnection = new aws6.cloudwatch.EventConnection(
11756
11991
  "wraps-webhook-connection",
11757
11992
  {
11758
11993
  name: "wraps-webhook-connection",
@@ -11766,7 +12001,7 @@ async function createEventBridgeResources(config2) {
11766
12001
  }
11767
12002
  }
11768
12003
  );
11769
- webhookApiDestination = new aws5.cloudwatch.EventApiDestination(
12004
+ webhookApiDestination = new aws6.cloudwatch.EventApiDestination(
11770
12005
  "wraps-webhook-destination",
11771
12006
  {
11772
12007
  name: "wraps-webhook-destination",
@@ -11778,7 +12013,7 @@ async function createEventBridgeResources(config2) {
11778
12013
  // Rate limit
11779
12014
  }
11780
12015
  );
11781
- const webhookRole = new aws5.iam.Role("wraps-webhook-role", {
12016
+ const webhookRole = new aws6.iam.Role("wraps-webhook-role", {
11782
12017
  name: "wraps-eventbridge-webhook-role",
11783
12018
  assumeRolePolicy: JSON.stringify({
11784
12019
  Version: "2012-10-17",
@@ -11796,7 +12031,7 @@ async function createEventBridgeResources(config2) {
11796
12031
  ManagedBy: "wraps-cli"
11797
12032
  }
11798
12033
  });
11799
- new aws5.iam.RolePolicy("wraps-webhook-policy", {
12034
+ new aws6.iam.RolePolicy("wraps-webhook-policy", {
11800
12035
  role: webhookRole.name,
11801
12036
  policy: webhookApiDestination.arn.apply(
11802
12037
  (destArn) => JSON.stringify({
@@ -11811,7 +12046,7 @@ async function createEventBridgeResources(config2) {
11811
12046
  })
11812
12047
  )
11813
12048
  });
11814
- webhookTarget = new aws5.cloudwatch.EventTarget("wraps-webhook-target", {
12049
+ webhookTarget = new aws6.cloudwatch.EventTarget("wraps-webhook-target", {
11815
12050
  rule: rule.name,
11816
12051
  eventBusName,
11817
12052
  arn: webhookApiDestination.arn,
@@ -11829,7 +12064,7 @@ async function createEventBridgeResources(config2) {
11829
12064
 
11830
12065
  // src/infrastructure/resources/iam.ts
11831
12066
  init_esm_shims();
11832
- import * as aws6 from "@pulumi/aws";
12067
+ import * as aws7 from "@pulumi/aws";
11833
12068
  import * as pulumi10 from "@pulumi/pulumi";
11834
12069
  async function roleExists2(roleName) {
11835
12070
  try {
@@ -11884,7 +12119,7 @@ async function createIAMRole(config2) {
11884
12119
  }
11885
12120
  const roleName = "wraps-email-role";
11886
12121
  const exists = await roleExists2(roleName);
11887
- const role = exists ? new aws6.iam.Role(
12122
+ const role = exists ? new aws7.iam.Role(
11888
12123
  roleName,
11889
12124
  {
11890
12125
  name: roleName,
@@ -11898,7 +12133,7 @@ async function createIAMRole(config2) {
11898
12133
  import: roleName
11899
12134
  // Import existing role (use role name, not ARN)
11900
12135
  }
11901
- ) : new aws6.iam.Role(roleName, {
12136
+ ) : new aws7.iam.Role(roleName, {
11902
12137
  name: roleName,
11903
12138
  assumeRolePolicy,
11904
12139
  tags: {
@@ -11993,7 +12228,7 @@ async function createIAMRole(config2) {
11993
12228
  Resource: "arn:aws:ses:*:*:mailmanager-archive/*"
11994
12229
  });
11995
12230
  }
11996
- new aws6.iam.RolePolicy("wraps-email-policy", {
12231
+ new aws7.iam.RolePolicy("wraps-email-policy", {
11997
12232
  role: role.name,
11998
12233
  policy: JSON.stringify({
11999
12234
  Version: "2012-10-17",
@@ -12008,7 +12243,7 @@ init_lambda();
12008
12243
 
12009
12244
  // src/infrastructure/resources/ses.ts
12010
12245
  init_esm_shims();
12011
- import * as aws8 from "@pulumi/aws";
12246
+ import * as aws9 from "@pulumi/aws";
12012
12247
  async function configurationSetExists(configSetName, region) {
12013
12248
  try {
12014
12249
  const { SESv2Client: SESv2Client6, GetConfigurationSetCommand: GetConfigurationSetCommand2 } = await import("@aws-sdk/client-sesv2");
@@ -12086,16 +12321,16 @@ async function createSESResources(config2) {
12086
12321
  }
12087
12322
  const configSetName = "wraps-email-tracking";
12088
12323
  const exists = await configurationSetExists(configSetName, config2.region);
12089
- const configSet = exists ? new aws8.sesv2.ConfigurationSet(configSetName, configSetOptions, {
12324
+ const configSet = exists ? new aws9.sesv2.ConfigurationSet(configSetName, configSetOptions, {
12090
12325
  import: configSetName
12091
12326
  // Import existing configuration set
12092
- }) : new aws8.sesv2.ConfigurationSet(configSetName, configSetOptions);
12093
- const defaultEventBus = aws8.cloudwatch.getEventBusOutput({
12327
+ }) : new aws9.sesv2.ConfigurationSet(configSetName, configSetOptions);
12328
+ const defaultEventBus = aws9.cloudwatch.getEventBusOutput({
12094
12329
  name: "default"
12095
12330
  });
12096
12331
  if (config2.eventTrackingEnabled) {
12097
12332
  const eventDestName = "wraps-email-eventbridge";
12098
- new aws8.sesv2.ConfigurationSetEventDestination(
12333
+ new aws9.sesv2.ConfigurationSetEventDestination(
12099
12334
  "wraps-email-all-events",
12100
12335
  {
12101
12336
  configurationSetName: configSet.configurationSetName,
@@ -12135,7 +12370,7 @@ async function createSESResources(config2) {
12135
12370
  config2.domain,
12136
12371
  config2.region
12137
12372
  );
12138
- domainIdentity = identityExists ? new aws8.sesv2.EmailIdentity(
12373
+ domainIdentity = identityExists ? new aws9.sesv2.EmailIdentity(
12139
12374
  "wraps-email-domain",
12140
12375
  {
12141
12376
  emailIdentity: config2.domain,
@@ -12152,7 +12387,7 @@ async function createSESResources(config2) {
12152
12387
  import: config2.domain
12153
12388
  // Import existing identity
12154
12389
  }
12155
- ) : new aws8.sesv2.EmailIdentity("wraps-email-domain", {
12390
+ ) : new aws9.sesv2.EmailIdentity("wraps-email-domain", {
12156
12391
  emailIdentity: config2.domain,
12157
12392
  configurationSetName: configSet.configurationSetName,
12158
12393
  // Link configuration set to domain
@@ -12168,7 +12403,7 @@ async function createSESResources(config2) {
12168
12403
  );
12169
12404
  if (config2.mailFromDomain) {
12170
12405
  mailFromDomain = config2.mailFromDomain;
12171
- new aws8.sesv2.EmailIdentityMailFromAttributes(
12406
+ new aws9.sesv2.EmailIdentityMailFromAttributes(
12172
12407
  "wraps-email-mail-from",
12173
12408
  {
12174
12409
  emailIdentity: config2.domain,
@@ -12199,7 +12434,7 @@ async function createSESResources(config2) {
12199
12434
  // src/infrastructure/resources/smtp-credentials.ts
12200
12435
  init_esm_shims();
12201
12436
  import { createHmac as createHmac2 } from "crypto";
12202
- import * as aws9 from "@pulumi/aws";
12437
+ import * as aws10 from "@pulumi/aws";
12203
12438
  function convertToSMTPPassword2(secretAccessKey, region) {
12204
12439
  const DATE = "11111111";
12205
12440
  const SERVICE = "ses";
@@ -12235,7 +12470,7 @@ async function userExists(userName) {
12235
12470
  async function createSMTPCredentials(config2) {
12236
12471
  const userName = "wraps-email-smtp-user";
12237
12472
  const userAlreadyExists = await userExists(userName);
12238
- const iamUser = userAlreadyExists ? new aws9.iam.User(
12473
+ const iamUser = userAlreadyExists ? new aws10.iam.User(
12239
12474
  userName,
12240
12475
  {
12241
12476
  name: userName,
@@ -12245,14 +12480,14 @@ async function createSMTPCredentials(config2) {
12245
12480
  }
12246
12481
  },
12247
12482
  { import: userName }
12248
- ) : new aws9.iam.User(userName, {
12483
+ ) : new aws10.iam.User(userName, {
12249
12484
  name: userName,
12250
12485
  tags: {
12251
12486
  ManagedBy: "wraps-cli",
12252
12487
  Purpose: "SES SMTP Authentication"
12253
12488
  }
12254
12489
  });
12255
- new aws9.iam.UserPolicy("wraps-email-smtp-policy", {
12490
+ new aws10.iam.UserPolicy("wraps-email-smtp-policy", {
12256
12491
  user: iamUser.name,
12257
12492
  policy: JSON.stringify({
12258
12493
  Version: "2012-10-17",
@@ -12270,7 +12505,7 @@ async function createSMTPCredentials(config2) {
12270
12505
  ]
12271
12506
  })
12272
12507
  });
12273
- const accessKey = new aws9.iam.AccessKey("wraps-email-smtp-key", {
12508
+ const accessKey = new aws10.iam.AccessKey("wraps-email-smtp-key", {
12274
12509
  user: iamUser.name
12275
12510
  });
12276
12511
  const smtpPassword = accessKey.secret.apply(
@@ -12285,9 +12520,9 @@ async function createSMTPCredentials(config2) {
12285
12520
 
12286
12521
  // src/infrastructure/resources/sqs.ts
12287
12522
  init_esm_shims();
12288
- import * as aws10 from "@pulumi/aws";
12523
+ import * as aws11 from "@pulumi/aws";
12289
12524
  async function createSQSResources() {
12290
- const dlq = new aws10.sqs.Queue("wraps-email-events-dlq", {
12525
+ const dlq = new aws11.sqs.Queue("wraps-email-events-dlq", {
12291
12526
  name: "wraps-email-events-dlq",
12292
12527
  messageRetentionSeconds: 1209600,
12293
12528
  // 14 days
@@ -12296,10 +12531,10 @@ async function createSQSResources() {
12296
12531
  Description: "Dead letter queue for failed SES event processing"
12297
12532
  }
12298
12533
  });
12299
- const queue = new aws10.sqs.Queue("wraps-email-events", {
12534
+ const queue = new aws11.sqs.Queue("wraps-email-events", {
12300
12535
  name: "wraps-email-events",
12301
- visibilityTimeoutSeconds: 300,
12302
- // 5 minutes (Lambda timeout)
12536
+ visibilityTimeoutSeconds: 60,
12537
+ // Must be >= Lambda timeout
12303
12538
  messageRetentionSeconds: 345600,
12304
12539
  // 4 days
12305
12540
  receiveWaitTimeSeconds: 20,
@@ -12324,7 +12559,7 @@ async function createSQSResources() {
12324
12559
 
12325
12560
  // src/infrastructure/email-stack.ts
12326
12561
  async function deployEmailStack(config2) {
12327
- const identity = await aws13.getCallerIdentity();
12562
+ const identity = await aws14.getCallerIdentity();
12328
12563
  const accountId = identity.accountId;
12329
12564
  let oidcProvider;
12330
12565
  if (config2.provider === "vercel" && config2.vercel) {
@@ -12440,6 +12675,15 @@ async function deployEmailStack(config2) {
12440
12675
  region: config2.region
12441
12676
  });
12442
12677
  }
12678
+ let alertingResources;
12679
+ if (emailConfig.alerts?.enabled) {
12680
+ alertingResources = await createAlertingResources({
12681
+ alertConfig: emailConfig.alerts,
12682
+ configSetName: sesResources?.configSet.configurationSetName,
12683
+ dlqName: sqsResources ? "wraps-email-events-dlq" : void 0,
12684
+ region: config2.region
12685
+ });
12686
+ }
12443
12687
  return {
12444
12688
  roleArn: role.arn,
12445
12689
  configSetName: sesResources?.configSet.configurationSetName,
@@ -12464,7 +12708,10 @@ async function deployEmailStack(config2) {
12464
12708
  smtpUserArn: smtpResources?.iamUser.arn,
12465
12709
  smtpUsername: smtpResources?.accessKey.id,
12466
12710
  smtpPassword: smtpResources?.smtpPassword,
12467
- smtpEndpoint: smtpResources ? `email-smtp.${config2.region}.amazonaws.com` : void 0
12711
+ smtpEndpoint: smtpResources ? `email-smtp.${config2.region}.amazonaws.com` : void 0,
12712
+ // Alerting outputs
12713
+ alertsEnabled: emailConfig.alerts?.enabled,
12714
+ alertTopicArn: alertingResources?.topic.arn
12468
12715
  };
12469
12716
  }
12470
12717
 
@@ -12837,14 +13084,14 @@ async function scanSESConfigurationSets(region) {
12837
13084
  }
12838
13085
  }
12839
13086
  async function scanSNSTopics(region) {
12840
- const sns2 = new SNSClient({ region });
13087
+ const sns3 = new SNSClient({ region });
12841
13088
  const topics = [];
12842
13089
  try {
12843
- const listResponse = await sns2.send(new ListTopicsCommand({}));
13090
+ const listResponse = await sns3.send(new ListTopicsCommand({}));
12844
13091
  const topicArns = listResponse.Topics?.map((t) => t.TopicArn).filter(Boolean) || [];
12845
13092
  for (const arn of topicArns) {
12846
13093
  try {
12847
- const attrsResponse = await sns2.send(
13094
+ const attrsResponse = await sns3.send(
12848
13095
  new GetTopicAttributesCommand({ TopicArn: arn })
12849
13096
  );
12850
13097
  const name = arn.split(":").pop() || arn;
@@ -14777,6 +15024,14 @@ ${pc21.bold("Current Configuration:")}
14777
15024
  }[config2.emailArchiving.retention] || "90 days";
14778
15025
  console.log(` ${pc21.green("\u2713")} Email Archiving (${retentionLabel})`);
14779
15026
  }
15027
+ if (config2.alerts?.enabled) {
15028
+ console.log(` ${pc21.green("\u2713")} Reputation Alerts`);
15029
+ if (config2.alerts.notificationEmail) {
15030
+ console.log(
15031
+ ` ${pc21.dim("\u2514\u2500")} Email: ${pc21.cyan(config2.alerts.notificationEmail)}`
15032
+ );
15033
+ }
15034
+ }
14780
15035
  const currentCostData = calculateCosts(config2, 5e4);
14781
15036
  console.log(
14782
15037
  `
@@ -14816,6 +15071,11 @@ ${pc21.bold("Current Configuration:")}
14816
15071
  label: "Enable dedicated IP address",
14817
15072
  hint: "Requires 100k+ emails/day ($50-100/mo)"
14818
15073
  },
15074
+ {
15075
+ value: "alerts",
15076
+ label: config2.alerts?.enabled ? "Manage reputation alerts" : "Enable reputation alerts",
15077
+ hint: config2.alerts?.enabled ? "Update thresholds or notification settings" : "Get notified before AWS suspends your account"
15078
+ },
14819
15079
  {
14820
15080
  value: "custom",
14821
15081
  label: "Custom configuration",
@@ -15297,6 +15557,208 @@ ${pc21.bold("Current Configuration:")}
15297
15557
  newPreset = void 0;
15298
15558
  break;
15299
15559
  }
15560
+ case "alerts": {
15561
+ if (!config2.reputationMetrics) {
15562
+ clack20.log.warn("Reputation metrics must be enabled to use alerting.");
15563
+ clack20.log.info(
15564
+ "This requires the Production or Enterprise preset, or enabling reputation metrics manually."
15565
+ );
15566
+ const enableReputationMetrics = await clack20.confirm({
15567
+ message: "Enable reputation metrics now?",
15568
+ initialValue: true
15569
+ });
15570
+ if (clack20.isCancel(enableReputationMetrics) || !enableReputationMetrics) {
15571
+ clack20.cancel("Alerting not enabled.");
15572
+ process.exit(0);
15573
+ }
15574
+ updatedConfig = {
15575
+ ...config2,
15576
+ reputationMetrics: true
15577
+ };
15578
+ }
15579
+ if (config2.alerts?.enabled) {
15580
+ clack20.log.info(`Alerting is currently ${pc21.green("enabled")}`);
15581
+ if (config2.alerts.notificationEmail) {
15582
+ clack20.log.info(
15583
+ ` Notification email: ${pc21.cyan(config2.alerts.notificationEmail)}`
15584
+ );
15585
+ }
15586
+ const alertsAction = await clack20.select({
15587
+ message: "What would you like to do?",
15588
+ options: [
15589
+ {
15590
+ value: "change-email",
15591
+ label: "Change notification email",
15592
+ hint: config2.alerts.notificationEmail || "Not set"
15593
+ },
15594
+ {
15595
+ value: "change-thresholds",
15596
+ label: "Customize alert thresholds",
15597
+ hint: "Adjust bounce/complaint rate thresholds"
15598
+ },
15599
+ {
15600
+ value: "disable",
15601
+ label: "Disable alerting",
15602
+ hint: "Remove CloudWatch alarms and SNS topic"
15603
+ }
15604
+ ]
15605
+ });
15606
+ if (clack20.isCancel(alertsAction)) {
15607
+ clack20.cancel("Upgrade cancelled.");
15608
+ process.exit(0);
15609
+ }
15610
+ if (alertsAction === "disable") {
15611
+ const confirmDisable = await clack20.confirm({
15612
+ message: "Are you sure? You won't be notified if your reputation degrades.",
15613
+ initialValue: false
15614
+ });
15615
+ if (clack20.isCancel(confirmDisable) || !confirmDisable) {
15616
+ clack20.log.info("Alerting not disabled.");
15617
+ process.exit(0);
15618
+ }
15619
+ updatedConfig = {
15620
+ ...config2,
15621
+ alerts: { enabled: false }
15622
+ };
15623
+ } else if (alertsAction === "change-email") {
15624
+ const notificationEmail = await clack20.text({
15625
+ message: "Notification email address:",
15626
+ placeholder: "alerts@yourcompany.com",
15627
+ initialValue: config2.alerts.notificationEmail || "",
15628
+ validate: (value) => {
15629
+ if (value && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value)) {
15630
+ return "Please enter a valid email address";
15631
+ }
15632
+ }
15633
+ });
15634
+ if (clack20.isCancel(notificationEmail)) {
15635
+ clack20.cancel("Upgrade cancelled.");
15636
+ process.exit(0);
15637
+ }
15638
+ updatedConfig = {
15639
+ ...config2,
15640
+ alerts: {
15641
+ ...config2.alerts,
15642
+ enabled: true,
15643
+ notificationEmail: notificationEmail || void 0
15644
+ }
15645
+ };
15646
+ } else if (alertsAction === "change-thresholds") {
15647
+ clack20.log.info(`
15648
+ ${pc21.bold("Alert Thresholds")}`);
15649
+ clack20.log.info(
15650
+ pc21.dim("These thresholds warn you BEFORE AWS takes action:")
15651
+ );
15652
+ clack20.log.info(pc21.dim(" AWS warns at 5% bounce, 0.1% complaint"));
15653
+ clack20.log.info(pc21.dim(" Gmail blocks at 0.3% complaint rate\n"));
15654
+ const thresholdPreset = await clack20.select({
15655
+ message: "Choose threshold sensitivity:",
15656
+ options: [
15657
+ {
15658
+ value: "standard",
15659
+ label: "Standard (recommended)",
15660
+ hint: "Bounce: 2%/4%, Complaint: 0.05%/0.08%"
15661
+ },
15662
+ {
15663
+ value: "strict",
15664
+ label: "Strict (enterprise)",
15665
+ hint: "Bounce: 1%/2%, Complaint: 0.03%/0.05%"
15666
+ },
15667
+ {
15668
+ value: "relaxed",
15669
+ label: "Relaxed",
15670
+ hint: "Bounce: 3%/5%, Complaint: 0.08%/0.1%"
15671
+ }
15672
+ ]
15673
+ });
15674
+ if (clack20.isCancel(thresholdPreset)) {
15675
+ clack20.cancel("Upgrade cancelled.");
15676
+ process.exit(0);
15677
+ }
15678
+ const thresholdConfigs = {
15679
+ standard: {
15680
+ bounceRateWarning: 0.02,
15681
+ bounceRateCritical: 0.04,
15682
+ complaintRateWarning: 5e-4,
15683
+ complaintRateCritical: 8e-4
15684
+ },
15685
+ strict: {
15686
+ bounceRateWarning: 0.01,
15687
+ bounceRateCritical: 0.02,
15688
+ complaintRateWarning: 3e-4,
15689
+ complaintRateCritical: 5e-4
15690
+ },
15691
+ relaxed: {
15692
+ bounceRateWarning: 0.03,
15693
+ bounceRateCritical: 0.05,
15694
+ complaintRateWarning: 8e-4,
15695
+ complaintRateCritical: 1e-3
15696
+ }
15697
+ };
15698
+ updatedConfig = {
15699
+ ...config2,
15700
+ alerts: {
15701
+ ...config2.alerts,
15702
+ enabled: true,
15703
+ thresholds: thresholdConfigs[thresholdPreset]
15704
+ }
15705
+ };
15706
+ }
15707
+ } else {
15708
+ clack20.log.info(`
15709
+ ${pc21.bold("Reputation Alerts")}
15710
+ `);
15711
+ clack20.log.info(
15712
+ pc21.dim("Get notified when your email reputation is at risk:")
15713
+ );
15714
+ clack20.log.info(pc21.dim(" - Bounce rate warnings (before AWS review)"));
15715
+ clack20.log.info(
15716
+ pc21.dim(" - Complaint rate warnings (before Gmail blocks you)")
15717
+ );
15718
+ clack20.log.info(pc21.dim(" - DLQ alerts (event processing failures)"));
15719
+ clack20.log.info(pc21.dim("\nCost: ~$0.50/mo (5 CloudWatch alarms)\n"));
15720
+ const enableAlerts = await clack20.confirm({
15721
+ message: "Enable reputation alerts?",
15722
+ initialValue: true
15723
+ });
15724
+ if (clack20.isCancel(enableAlerts) || !enableAlerts) {
15725
+ clack20.log.info("Alerting not enabled.");
15726
+ process.exit(0);
15727
+ }
15728
+ const notificationEmail = await clack20.text({
15729
+ message: "Notification email address:",
15730
+ placeholder: "alerts@yourcompany.com",
15731
+ validate: (value) => {
15732
+ if (!value) {
15733
+ return "Email address is required for alerts";
15734
+ }
15735
+ if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value)) {
15736
+ return "Please enter a valid email address";
15737
+ }
15738
+ }
15739
+ });
15740
+ if (clack20.isCancel(notificationEmail)) {
15741
+ clack20.cancel("Upgrade cancelled.");
15742
+ process.exit(0);
15743
+ }
15744
+ clack20.log.info(
15745
+ pc21.dim("\nYou'll receive an email to confirm your subscription.")
15746
+ );
15747
+ updatedConfig = {
15748
+ ...config2,
15749
+ reputationMetrics: true,
15750
+ // Required for alerts
15751
+ alerts: {
15752
+ enabled: true,
15753
+ notificationEmail,
15754
+ dlqAlerts: true
15755
+ // Uses default thresholds
15756
+ }
15757
+ };
15758
+ }
15759
+ newPreset = void 0;
15760
+ break;
15761
+ }
15300
15762
  case "custom": {
15301
15763
  const { promptCustomConfig: promptCustomConfig2 } = await Promise.resolve().then(() => (init_prompts(), prompts_exports));
15302
15764
  const customConfig = await promptCustomConfig2(config2);
@@ -15905,6 +16367,9 @@ ${pc21.green("\u2713")} ${pc21.bold("Upgrade complete!")}
15905
16367
  if (updatedConfig.smtpCredentials?.enabled) {
15906
16368
  enabledFeatures.push("smtp_credentials");
15907
16369
  }
16370
+ if (updatedConfig.alerts?.enabled) {
16371
+ enabledFeatures.push("alerts");
16372
+ }
15908
16373
  trackServiceUpgrade("email", {
15909
16374
  from_preset: metadata.services.email?.preset,
15910
16375
  to_preset: newPreset,
@@ -17261,7 +17726,7 @@ import {
17261
17726
  } from "@aws-sdk/client-cloudwatch";
17262
17727
  async function fetchSESMetrics(roleArn, region, timeRange, tableName) {
17263
17728
  const credentials = roleArn ? await assumeRole(roleArn, region) : void 0;
17264
- const cloudwatch3 = new CloudWatchClient({ region, credentials });
17729
+ const cloudwatch4 = new CloudWatchClient({ region, credentials });
17265
17730
  const queries = [
17266
17731
  {
17267
17732
  Id: "sends",
@@ -17309,7 +17774,7 @@ async function fetchSESMetrics(roleArn, region, timeRange, tableName) {
17309
17774
  }
17310
17775
  }
17311
17776
  ];
17312
- const response = await cloudwatch3.send(
17777
+ const response = await cloudwatch4.send(
17313
17778
  new GetMetricDataCommand({
17314
17779
  MetricDataQueries: queries,
17315
17780
  StartTime: timeRange.start,
@@ -18094,7 +18559,7 @@ import {
18094
18559
  import { unmarshall as unmarshall4 } from "@aws-sdk/util-dynamodb";
18095
18560
  async function fetchSMSSpendLimits(region) {
18096
18561
  const smsClient = new PinpointSMSVoiceV2Client({ region });
18097
- const cloudwatch3 = new CloudWatchClient2({ region });
18562
+ const cloudwatch4 = new CloudWatchClient2({ region });
18098
18563
  try {
18099
18564
  const spendLimits = await smsClient.send(
18100
18565
  new DescribeSpendLimitsCommand({})
@@ -18108,7 +18573,7 @@ async function fetchSMSSpendLimits(region) {
18108
18573
  }
18109
18574
  const now = /* @__PURE__ */ new Date();
18110
18575
  const startOfMonth = new Date(now.getFullYear(), now.getMonth(), 1);
18111
- const metricsResponse = await cloudwatch3.send(
18576
+ const metricsResponse = await cloudwatch4.send(
18112
18577
  new GetMetricDataCommand2({
18113
18578
  MetricDataQueries: [
18114
18579
  {
@@ -19095,7 +19560,7 @@ import pc28 from "picocolors";
19095
19560
 
19096
19561
  // src/infrastructure/sms-stack.ts
19097
19562
  init_esm_shims();
19098
- import * as aws14 from "@pulumi/aws";
19563
+ import * as aws15 from "@pulumi/aws";
19099
19564
  import * as pulumi22 from "@pulumi/pulumi";
19100
19565
  async function roleExists3(roleName) {
19101
19566
  try {
@@ -19173,7 +19638,7 @@ async function createSMSIAMRole(config2) {
19173
19638
  }
19174
19639
  const roleName = "wraps-sms-role";
19175
19640
  const exists = await roleExists3(roleName);
19176
- const role = exists ? new aws14.iam.Role(
19641
+ const role = exists ? new aws15.iam.Role(
19177
19642
  roleName,
19178
19643
  {
19179
19644
  name: roleName,
@@ -19188,7 +19653,7 @@ async function createSMSIAMRole(config2) {
19188
19653
  import: roleName,
19189
19654
  customTimeouts: { create: "2m", update: "2m", delete: "2m" }
19190
19655
  }
19191
- ) : new aws14.iam.Role(
19656
+ ) : new aws15.iam.Role(
19192
19657
  roleName,
19193
19658
  {
19194
19659
  name: roleName,
@@ -19283,7 +19748,7 @@ async function createSMSIAMRole(config2) {
19283
19748
  Resource: "arn:aws:logs:*:*:log-group:/aws/lambda/wraps-sms-*"
19284
19749
  });
19285
19750
  }
19286
- new aws14.iam.RolePolicy("wraps-sms-policy", {
19751
+ new aws15.iam.RolePolicy("wraps-sms-policy", {
19287
19752
  role: role.name,
19288
19753
  policy: JSON.stringify({
19289
19754
  Version: "2012-10-17",
@@ -19293,7 +19758,7 @@ async function createSMSIAMRole(config2) {
19293
19758
  return role;
19294
19759
  }
19295
19760
  function createSMSConfigurationSet() {
19296
- return new aws14.pinpoint.Smsvoicev2ConfigurationSet("wraps-sms-config", {
19761
+ return new aws15.pinpoint.Smsvoicev2ConfigurationSet("wraps-sms-config", {
19297
19762
  name: "wraps-sms-config",
19298
19763
  defaultMessageType: "TRANSACTIONAL",
19299
19764
  tags: {
@@ -19303,7 +19768,7 @@ function createSMSConfigurationSet() {
19303
19768
  });
19304
19769
  }
19305
19770
  function createSMSOptOutList() {
19306
- return new aws14.pinpoint.Smsvoicev2OptOutList("wraps-sms-optouts", {
19771
+ return new aws15.pinpoint.Smsvoicev2OptOutList("wraps-sms-optouts", {
19307
19772
  name: "wraps-sms-optouts",
19308
19773
  tags: {
19309
19774
  ManagedBy: "wraps-cli",
@@ -19367,7 +19832,7 @@ async function createSMSPhoneNumber(phoneNumberType, optOutList) {
19367
19832
  }
19368
19833
  };
19369
19834
  if (existingArn) {
19370
- return new aws14.pinpoint.Smsvoicev2PhoneNumber(
19835
+ return new aws15.pinpoint.Smsvoicev2PhoneNumber(
19371
19836
  "wraps-sms-number",
19372
19837
  phoneConfig,
19373
19838
  {
@@ -19376,7 +19841,7 @@ async function createSMSPhoneNumber(phoneNumberType, optOutList) {
19376
19841
  }
19377
19842
  );
19378
19843
  }
19379
- return new aws14.pinpoint.Smsvoicev2PhoneNumber(
19844
+ return new aws15.pinpoint.Smsvoicev2PhoneNumber(
19380
19845
  "wraps-sms-number",
19381
19846
  phoneConfig,
19382
19847
  {
@@ -19419,10 +19884,10 @@ async function createSMSSQSResources() {
19419
19884
  Description: "Dead letter queue for failed SMS event processing"
19420
19885
  }
19421
19886
  };
19422
- const dlq = dlqUrl ? new aws14.sqs.Queue(dlqName, dlqConfig, {
19887
+ const dlq = dlqUrl ? new aws15.sqs.Queue(dlqName, dlqConfig, {
19423
19888
  import: dlqUrl,
19424
19889
  customTimeouts: { create: "2m", update: "2m", delete: "2m" }
19425
- }) : new aws14.sqs.Queue(dlqName, dlqConfig, {
19890
+ }) : new aws15.sqs.Queue(dlqName, dlqConfig, {
19426
19891
  customTimeouts: { create: "2m", update: "2m", delete: "2m" }
19427
19892
  });
19428
19893
  const queueConfig = {
@@ -19445,10 +19910,10 @@ async function createSMSSQSResources() {
19445
19910
  Description: "Queue for SMS events from SNS"
19446
19911
  }
19447
19912
  };
19448
- const queue = queueUrl ? new aws14.sqs.Queue(queueName, queueConfig, {
19913
+ const queue = queueUrl ? new aws15.sqs.Queue(queueName, queueConfig, {
19449
19914
  import: queueUrl,
19450
19915
  customTimeouts: { create: "2m", update: "2m", delete: "2m" }
19451
- }) : new aws14.sqs.Queue(queueName, queueConfig, {
19916
+ }) : new aws15.sqs.Queue(queueName, queueConfig, {
19452
19917
  customTimeouts: { create: "2m", update: "2m", delete: "2m" }
19453
19918
  });
19454
19919
  return { queue, dlq };
@@ -19456,12 +19921,12 @@ async function createSMSSQSResources() {
19456
19921
  async function snsTopicExists(topicName) {
19457
19922
  try {
19458
19923
  const { SNSClient: SNSClient2, ListTopicsCommand: ListTopicsCommand2 } = await import("@aws-sdk/client-sns");
19459
- const sns2 = new SNSClient2({
19924
+ const sns3 = new SNSClient2({
19460
19925
  region: process.env.AWS_REGION || "us-east-1"
19461
19926
  });
19462
19927
  let nextToken;
19463
19928
  do {
19464
- const response = await sns2.send(
19929
+ const response = await sns3.send(
19465
19930
  new ListTopicsCommand2({ NextToken: nextToken })
19466
19931
  );
19467
19932
  const found = response.Topics?.find(
@@ -19488,13 +19953,13 @@ async function createSMSSNSResources(config2) {
19488
19953
  Description: "SNS topic for SMS delivery events"
19489
19954
  }
19490
19955
  };
19491
- const topic = topicArn ? new aws14.sns.Topic("wraps-sms-events-topic", topicConfig, {
19956
+ const topic = topicArn ? new aws15.sns.Topic("wraps-sms-events-topic", topicConfig, {
19492
19957
  import: topicArn,
19493
19958
  customTimeouts: { create: "2m", update: "2m", delete: "2m" }
19494
- }) : new aws14.sns.Topic("wraps-sms-events-topic", topicConfig, {
19959
+ }) : new aws15.sns.Topic("wraps-sms-events-topic", topicConfig, {
19495
19960
  customTimeouts: { create: "2m", update: "2m", delete: "2m" }
19496
19961
  });
19497
- new aws14.sns.TopicPolicy("wraps-sms-events-topic-policy", {
19962
+ new aws15.sns.TopicPolicy("wraps-sms-events-topic-policy", {
19498
19963
  arn: topic.arn,
19499
19964
  policy: topic.arn.apply(
19500
19965
  (topicArn2) => JSON.stringify({
@@ -19511,7 +19976,7 @@ async function createSMSSNSResources(config2) {
19511
19976
  })
19512
19977
  )
19513
19978
  });
19514
- new aws14.sqs.QueuePolicy("wraps-sms-events-queue-policy", {
19979
+ new aws15.sqs.QueuePolicy("wraps-sms-events-queue-policy", {
19515
19980
  queueUrl: config2.queueUrl,
19516
19981
  policy: pulumi22.all([config2.queueArn, topic.arn]).apply(
19517
19982
  ([queueArn, topicArn2]) => JSON.stringify({
@@ -19530,7 +19995,7 @@ async function createSMSSNSResources(config2) {
19530
19995
  })
19531
19996
  )
19532
19997
  });
19533
- const subscription = new aws14.sns.TopicSubscription(
19998
+ const subscription = new aws15.sns.TopicSubscription(
19534
19999
  "wraps-sms-events-subscription",
19535
20000
  {
19536
20001
  topic: topic.arn,
@@ -19579,17 +20044,17 @@ async function createSMSDynamoDBTable() {
19579
20044
  Service: "sms"
19580
20045
  }
19581
20046
  };
19582
- return exists ? new aws14.dynamodb.Table(tableName, tableConfig, {
20047
+ return exists ? new aws15.dynamodb.Table(tableName, tableConfig, {
19583
20048
  import: tableName,
19584
20049
  customTimeouts: { create: "5m", update: "5m", delete: "5m" }
19585
- }) : new aws14.dynamodb.Table(tableName, tableConfig, {
20050
+ }) : new aws15.dynamodb.Table(tableName, tableConfig, {
19586
20051
  customTimeouts: { create: "5m", update: "5m", delete: "5m" }
19587
20052
  });
19588
20053
  }
19589
20054
  async function deploySMSLambdaFunction(config2) {
19590
20055
  const { getLambdaCode: getLambdaCode2 } = await Promise.resolve().then(() => (init_lambda(), lambda_exports));
19591
20056
  const codeDir = await getLambdaCode2("sms-event-processor");
19592
- const lambdaRole = new aws14.iam.Role("wraps-sms-lambda-role", {
20057
+ const lambdaRole = new aws15.iam.Role("wraps-sms-lambda-role", {
19593
20058
  name: "wraps-sms-lambda-role",
19594
20059
  assumeRolePolicy: JSON.stringify({
19595
20060
  Version: "2012-10-17",
@@ -19606,11 +20071,11 @@ async function deploySMSLambdaFunction(config2) {
19606
20071
  Service: "sms"
19607
20072
  }
19608
20073
  });
19609
- new aws14.iam.RolePolicyAttachment("wraps-sms-lambda-basic-execution", {
20074
+ new aws15.iam.RolePolicyAttachment("wraps-sms-lambda-basic-execution", {
19610
20075
  role: lambdaRole.name,
19611
20076
  policyArn: "arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole"
19612
20077
  });
19613
- new aws14.iam.RolePolicy("wraps-sms-lambda-policy", {
20078
+ new aws15.iam.RolePolicy("wraps-sms-lambda-policy", {
19614
20079
  role: lambdaRole.name,
19615
20080
  policy: pulumi22.all([config2.tableName, config2.queueArn]).apply(
19616
20081
  ([tableName, queueArn]) => JSON.stringify({
@@ -19642,7 +20107,7 @@ async function deploySMSLambdaFunction(config2) {
19642
20107
  })
19643
20108
  )
19644
20109
  });
19645
- const eventProcessor = new aws14.lambda.Function(
20110
+ const eventProcessor = new aws15.lambda.Function(
19646
20111
  "wraps-sms-event-processor",
19647
20112
  {
19648
20113
  name: "wraps-sms-event-processor",
@@ -19670,7 +20135,7 @@ async function deploySMSLambdaFunction(config2) {
19670
20135
  customTimeouts: { create: "5m", update: "5m", delete: "2m" }
19671
20136
  }
19672
20137
  );
19673
- new aws14.lambda.EventSourceMapping(
20138
+ new aws15.lambda.EventSourceMapping(
19674
20139
  "wraps-sms-event-source-mapping",
19675
20140
  {
19676
20141
  eventSourceArn: config2.queueArn,
@@ -19686,7 +20151,7 @@ async function deploySMSLambdaFunction(config2) {
19686
20151
  return eventProcessor;
19687
20152
  }
19688
20153
  async function deploySMSStack(config2) {
19689
- const identity = await aws14.getCallerIdentity();
20154
+ const identity = await aws15.getCallerIdentity();
19690
20155
  const accountId = identity.accountId;
19691
20156
  let oidcProvider;
19692
20157
  if (config2.provider === "vercel" && config2.vercel) {