@wraps.dev/cli 2.19.8 → 2.20.1

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
@@ -6057,7 +6057,9 @@ function getAllTrackedDomains(metadata) {
6057
6057
  managed: true,
6058
6058
  purpose: d.purpose,
6059
6059
  mailFromDomain: d.mailFromDomain,
6060
- addedAt: d.addedAt
6060
+ addedAt: d.addedAt,
6061
+ configSetName: d.configSetName,
6062
+ trackingConfig: d.trackingConfig
6061
6063
  });
6062
6064
  }
6063
6065
  return result;
@@ -7255,6 +7257,35 @@ var init_dist = __esm({
7255
7257
  }
7256
7258
  });
7257
7259
 
7260
+ // src/utils/email/config-set-slug.ts
7261
+ var config_set_slug_exports = {};
7262
+ __export(config_set_slug_exports, {
7263
+ domainToConfigSetName: () => domainToConfigSetName
7264
+ });
7265
+ import { createHash } from "crypto";
7266
+ function domainToConfigSetName(domain) {
7267
+ const slug = domain.toLowerCase().replace(/\./g, "-");
7268
+ const needsHash = domain.includes("-") || slug.length > MAX_SLUG;
7269
+ if (!needsHash) {
7270
+ return `${PREFIX}${slug}`;
7271
+ }
7272
+ const hash = createHash("sha256").update(domain).digest("hex").slice(0, 8);
7273
+ const rawSlugPart = slug.length > MAX_SLUG_WITH_HASH ? slug.slice(0, MAX_SLUG_WITH_HASH) : slug;
7274
+ const slugPart = rawSlugPart.replace(/-+$/, "");
7275
+ return `${PREFIX}${slugPart}-${hash}`;
7276
+ }
7277
+ var PREFIX, MAX_LENGTH, MAX_SLUG, MAX_SLUG_WITH_HASH;
7278
+ var init_config_set_slug = __esm({
7279
+ "src/utils/email/config-set-slug.ts"() {
7280
+ "use strict";
7281
+ init_esm_shims();
7282
+ PREFIX = "wraps-email-";
7283
+ MAX_LENGTH = 64;
7284
+ MAX_SLUG = MAX_LENGTH - PREFIX.length;
7285
+ MAX_SLUG_WITH_HASH = MAX_SLUG - 9;
7286
+ }
7287
+ });
7288
+
7258
7289
  // src/infrastructure/resources/eventbridge-user-webhook.ts
7259
7290
  var eventbridge_user_webhook_exports = {};
7260
7291
  __export(eventbridge_user_webhook_exports, {
@@ -7929,20 +7960,20 @@ async function createMailManagerArchive(config2) {
7929
7960
  let archiveId;
7930
7961
  let archiveArn;
7931
7962
  try {
7932
- const listResult = await mailManagerClient.send(new ListArchivesCommand({}));
7963
+ const listResult = await mailManagerClient.send(
7964
+ new ListArchivesCommand({})
7965
+ );
7933
7966
  const existing = listResult.Archives?.find(
7934
7967
  (a) => a.ArchiveState === ArchiveState.ACTIVE && a.ArchiveName !== void 0 && namePattern.test(a.ArchiveName)
7935
7968
  );
7936
7969
  if (existing?.ArchiveId) {
7937
- console.log(`Using existing Mail Manager archive: ${existing.ArchiveName}`);
7938
7970
  archiveId = existing.ArchiveId;
7939
7971
  const getResult = await mailManagerClient.send(
7940
7972
  new GetArchiveCommand({ ArchiveId: archiveId })
7941
7973
  );
7942
7974
  archiveArn = getResult.ArchiveArn;
7943
7975
  }
7944
- } catch (error) {
7945
- console.log("Error checking for existing archive:", error);
7976
+ } catch {
7946
7977
  }
7947
7978
  if (!archiveId) {
7948
7979
  for (let attempt = 1; attempt <= MAX_NAME_ATTEMPTS; attempt++) {
@@ -7966,13 +7997,9 @@ async function createMailManagerArchive(config2) {
7966
7997
  "Failed to create Mail Manager Archive: No ArchiveId returned"
7967
7998
  );
7968
7999
  }
7969
- console.log(`Created new Mail Manager archive: ${archiveName}`);
7970
8000
  break;
7971
8001
  } catch (error) {
7972
8002
  if (error instanceof Error && error.name === "ConflictException" && attempt < MAX_NAME_ATTEMPTS) {
7973
- console.log(
7974
- `Archive '${archiveName}' is unavailable, trying '${baseArchiveName}-${attempt + 1}'...`
7975
- );
7976
8003
  continue;
7977
8004
  }
7978
8005
  throw error;
@@ -9572,7 +9599,7 @@ Run ${pc27.cyan(`wraps email domains verify --domain ${domain} --wait`)} to keep
9572
9599
  }
9573
9600
  }
9574
9601
  },
9575
- ConfigurationSetName: "wraps-email-tracking"
9602
+ ConfigurationSetName: domainToConfigSetName(domain)
9576
9603
  })
9577
9604
  );
9578
9605
  return response.MessageId;
@@ -9684,6 +9711,7 @@ var init_test = __esm({
9684
9711
  "use strict";
9685
9712
  init_esm_shims();
9686
9713
  init_events();
9714
+ init_config_set_slug();
9687
9715
  init_ses_simulator();
9688
9716
  init_verification();
9689
9717
  init_aws();
@@ -17403,6 +17431,7 @@ import pc18 from "picocolors";
17403
17431
  // src/infrastructure/email-stack.ts
17404
17432
  init_esm_shims();
17405
17433
  init_dist();
17434
+ init_config_set_slug();
17406
17435
  import * as aws19 from "@pulumi/aws";
17407
17436
 
17408
17437
  // src/infrastructure/resources/alerting.ts
@@ -17963,6 +17992,7 @@ init_lambda();
17963
17992
 
17964
17993
  // src/infrastructure/resources/ses.ts
17965
17994
  init_esm_shims();
17995
+ init_config_set_slug();
17966
17996
  import * as aws9 from "@pulumi/aws";
17967
17997
  async function configurationSetExists(configSetName, region) {
17968
17998
  try {
@@ -18014,8 +18044,9 @@ async function emailIdentityExists(emailIdentity, region) {
18014
18044
  }
18015
18045
  }
18016
18046
  async function createSESResources(config2) {
18047
+ const configSetName = config2.domain ? domainToConfigSetName(config2.domain) : "wraps-email-tracking";
18017
18048
  const configSetOptions = {
18018
- configurationSetName: "wraps-email-tracking",
18049
+ configurationSetName: configSetName,
18019
18050
  deliveryOptions: config2.tlsRequired ? {
18020
18051
  tlsPolicy: "REQUIRE"
18021
18052
  // Require TLS 1.2+ for all emails
@@ -18040,7 +18071,6 @@ async function createSESResources(config2) {
18040
18071
  httpsPolicy: config2.trackingConfig.httpsEnabled ? "REQUIRE" : "OPTIONAL"
18041
18072
  };
18042
18073
  }
18043
- const configSetName = "wraps-email-tracking";
18044
18074
  const exists = await configurationSetExists(configSetName, config2.region);
18045
18075
  const configSet = exists && !config2.skipResourceImports ? new aws9.sesv2.ConfigurationSet(configSetName, configSetOptions, {
18046
18076
  import: configSetName
@@ -18050,6 +18080,20 @@ async function createSESResources(config2) {
18050
18080
  });
18051
18081
  if (config2.eventTrackingEnabled) {
18052
18082
  const eventDestName = "wraps-email-eventbridge";
18083
+ const opensEnabled = config2.trackingConfig?.opens ?? true;
18084
+ const clicksEnabled = config2.trackingConfig?.clicks ?? true;
18085
+ const matchingEventTypes = [
18086
+ "SEND",
18087
+ "DELIVERY",
18088
+ ...opensEnabled ? ["OPEN"] : [],
18089
+ ...clicksEnabled ? ["CLICK"] : [],
18090
+ "BOUNCE",
18091
+ "COMPLAINT",
18092
+ "REJECT",
18093
+ "RENDERING_FAILURE",
18094
+ "DELIVERY_DELAY",
18095
+ "SUBSCRIPTION"
18096
+ ];
18053
18097
  new aws9.sesv2.ConfigurationSetEventDestination(
18054
18098
  "wraps-email-all-events",
18055
18099
  {
@@ -18057,18 +18101,7 @@ async function createSESResources(config2) {
18057
18101
  eventDestinationName: eventDestName,
18058
18102
  eventDestination: {
18059
18103
  enabled: true,
18060
- matchingEventTypes: [
18061
- "SEND",
18062
- "DELIVERY",
18063
- "OPEN",
18064
- "CLICK",
18065
- "BOUNCE",
18066
- "COMPLAINT",
18067
- "REJECT",
18068
- "RENDERING_FAILURE",
18069
- "DELIVERY_DELAY",
18070
- "SUBSCRIPTION"
18071
- ],
18104
+ matchingEventTypes,
18072
18105
  eventBridgeDestination: {
18073
18106
  // SES requires default bus - cannot use custom bus
18074
18107
  eventBusArn: defaultEventBus.arn
@@ -18078,7 +18111,7 @@ async function createSESResources(config2) {
18078
18111
  {
18079
18112
  // Import existing resource if it already exists in AWS but not in Pulumi state.
18080
18113
  // Skip when skipResourceImports is true (resource already tracked in state).
18081
- import: config2.importExistingEventDestination && !config2.skipResourceImports ? `wraps-email-tracking|${eventDestName}` : void 0
18114
+ import: config2.importExistingEventDestination && !config2.skipResourceImports ? `${configSetName}|${eventDestName}` : void 0
18082
18115
  }
18083
18116
  );
18084
18117
  }
@@ -18349,7 +18382,7 @@ async function deployEmailStack(config2) {
18349
18382
  let sesResources;
18350
18383
  if (emailConfig.tracking?.enabled || emailConfig.eventTracking?.enabled) {
18351
18384
  const shouldImportEventDest = !config2.skipResourceImports && emailConfig.eventTracking?.enabled && await eventDestinationExists(
18352
- "wraps-email-tracking",
18385
+ domainToConfigSetName(emailConfig.domain ?? ""),
18353
18386
  "wraps-email-eventbridge",
18354
18387
  config2.region
18355
18388
  );
@@ -18433,7 +18466,7 @@ async function deployEmailStack(config2) {
18433
18466
  let smtpResources;
18434
18467
  if (emailConfig.smtpCredentials?.enabled && sesResources) {
18435
18468
  smtpResources = await createSMTPCredentials({
18436
- configSetName: "wraps-email-tracking",
18469
+ configSetName: domainToConfigSetName(emailConfig.domain ?? ""),
18437
18470
  region: config2.region
18438
18471
  });
18439
18472
  }
@@ -19222,17 +19255,18 @@ ${pc19.dim("\u2500\u2500\u2500 end Pulumi output \u2500\u2500\u2500")}
19222
19255
  tableName: outputs.tableName
19223
19256
  });
19224
19257
  if (selectedIdentities.length > 0 && emailConfig.tracking?.enabled) {
19258
+ const displayConfigSetName = outputs.configSetName ?? "wraps-email-<your-domain>";
19225
19259
  console.log(`
19226
19260
  ${pc19.bold("Next Steps:")}
19227
19261
  `);
19228
19262
  console.log(
19229
- `Update your code to use configuration set: ${pc19.cyan("wraps-email-tracking")}`
19263
+ `Update your code to use configuration set: ${pc19.cyan(displayConfigSetName)}`
19230
19264
  );
19231
19265
  console.log(`
19232
19266
  ${pc19.dim("Example:")}`);
19233
19267
  console.log(
19234
19268
  pc19.gray(` await ses.sendEmail({
19235
- ConfigurationSetName: 'wraps-email-tracking',
19269
+ ConfigurationSetName: '${displayConfigSetName}',
19236
19270
  // ... other parameters
19237
19271
  });`)
19238
19272
  );
@@ -19550,6 +19584,42 @@ async function emailDestroy(options) {
19550
19584
  "Some resources may not have been fully removed. You can re-run this command or clean up manually in the AWS console."
19551
19585
  );
19552
19586
  }
19587
+ if (destroyFailed) {
19588
+ try {
19589
+ const { SQSClient, GetQueueUrlCommand, DeleteQueueCommand } = await import("@aws-sdk/client-sqs");
19590
+ const sqsClient = new SQSClient({ region });
19591
+ for (const queueName of [
19592
+ "wraps-email-events",
19593
+ "wraps-email-events-dlq"
19594
+ ]) {
19595
+ try {
19596
+ const { QueueUrl } = await sqsClient.send(
19597
+ new GetQueueUrlCommand({ QueueName: queueName })
19598
+ );
19599
+ if (QueueUrl) {
19600
+ await sqsClient.send(new DeleteQueueCommand({ QueueUrl }));
19601
+ }
19602
+ } catch {
19603
+ }
19604
+ }
19605
+ } catch {
19606
+ }
19607
+ }
19608
+ try {
19609
+ const { SESv2Client: SESv2Client9, DeleteConfigurationSetCommand: DeleteConfigSet } = await import("@aws-sdk/client-sesv2");
19610
+ const sesv22 = new SESv2Client9({ region });
19611
+ const additionalDomains = emailConfig?.additionalDomains ?? [];
19612
+ for (const d of additionalDomains) {
19613
+ if (!d.configSetName) continue;
19614
+ try {
19615
+ await sesv22.send(
19616
+ new DeleteConfigSet({ ConfigurationSetName: d.configSetName })
19617
+ );
19618
+ } catch {
19619
+ }
19620
+ }
19621
+ } catch {
19622
+ }
19553
19623
  await deleteConnectionMetadata(identity.accountId, region);
19554
19624
  progress.stop();
19555
19625
  if (isJsonMode()) {
@@ -19917,6 +19987,7 @@ init_esm_shims();
19917
19987
  init_client();
19918
19988
  init_events();
19919
19989
  init_dns();
19990
+ init_config_set_slug();
19920
19991
  init_aws();
19921
19992
  init_errors();
19922
19993
  init_json_output();
@@ -19924,7 +19995,12 @@ init_metadata();
19924
19995
  init_output();
19925
19996
  init_prompts();
19926
19997
  import { Resolver as Resolver2 } from "dns/promises";
19927
- import { GetEmailIdentityCommand as GetEmailIdentityCommand2, SESv2Client as SESv2Client4 } from "@aws-sdk/client-sesv2";
19998
+ import {
19999
+ CreateConfigurationSetCommand,
20000
+ CreateConfigurationSetEventDestinationCommand,
20001
+ GetEmailIdentityCommand as GetEmailIdentityCommand2,
20002
+ SESv2Client as SESv2Client4
20003
+ } from "@aws-sdk/client-sesv2";
19928
20004
  import * as clack21 from "@clack/prompts";
19929
20005
  import pc23 from "picocolors";
19930
20006
  async function checkVerification(domain, sesClient, region) {
@@ -20382,13 +20458,59 @@ Run ${pc23.cyan("wraps email init")} first to deploy email infrastructure.
20382
20458
  if (!options.yes) {
20383
20459
  purpose = await promptDomainPurpose();
20384
20460
  }
20461
+ const configSetName = domainToConfigSetName(domain);
20462
+ const trackingConfig = {
20463
+ opens: purpose === "marketing" || purpose === "notifications",
20464
+ clicks: purpose === "marketing" || purpose === "notifications"
20465
+ };
20466
+ const baseEventTypes = [
20467
+ "SEND",
20468
+ "DELIVERY",
20469
+ "BOUNCE",
20470
+ "COMPLAINT",
20471
+ "REJECT",
20472
+ "RENDERING_FAILURE",
20473
+ "DELIVERY_DELAY",
20474
+ "SUBSCRIPTION"
20475
+ ];
20476
+ const matchingEventTypes = trackingConfig.opens ? [...baseEventTypes, "OPEN", "CLICK"] : [...baseEventTypes];
20477
+ const eventBusArn = `arn:aws:events:${region}:${identity.accountId}:event-bus/default`;
20478
+ await progress.execute("Creating tracking configuration", async () => {
20479
+ try {
20480
+ await sesClient.send(
20481
+ new CreateConfigurationSetCommand({
20482
+ ConfigurationSetName: configSetName,
20483
+ SuppressionOptions: { SuppressedReasons: ["BOUNCE", "COMPLAINT"] }
20484
+ })
20485
+ );
20486
+ } catch (err) {
20487
+ if (err.name !== "AlreadyExistsException")
20488
+ throw err;
20489
+ }
20490
+ try {
20491
+ await sesClient.send(
20492
+ new CreateConfigurationSetEventDestinationCommand({
20493
+ ConfigurationSetName: configSetName,
20494
+ EventDestinationName: "wraps-email-eventbridge",
20495
+ EventDestination: {
20496
+ Enabled: true,
20497
+ MatchingEventTypes: matchingEventTypes,
20498
+ EventBridgeDestination: { EventBusArn: eventBusArn }
20499
+ }
20500
+ })
20501
+ );
20502
+ } catch (err) {
20503
+ if (err.name !== "AlreadyExistsException")
20504
+ throw err;
20505
+ }
20506
+ });
20385
20507
  if (domainAlreadyExists) {
20386
20508
  const { PutEmailIdentityConfigurationSetAttributesCommand } = await import("@aws-sdk/client-sesv2");
20387
20509
  await progress.execute("Associating tracking configuration", async () => {
20388
20510
  await sesClient.send(
20389
20511
  new PutEmailIdentityConfigurationSetAttributesCommand({
20390
20512
  EmailIdentity: domain,
20391
- ConfigurationSetName: "wraps-email-tracking"
20513
+ ConfigurationSetName: configSetName
20392
20514
  })
20393
20515
  );
20394
20516
  });
@@ -20398,7 +20520,7 @@ Run ${pc23.cyan("wraps email init")} first to deploy email infrastructure.
20398
20520
  await sesClient.send(
20399
20521
  new CreateEmailIdentityCommand({
20400
20522
  EmailIdentity: domain,
20401
- ConfigurationSetName: "wraps-email-tracking",
20523
+ ConfigurationSetName: configSetName,
20402
20524
  DkimSigningAttributes: {
20403
20525
  NextSigningKeyLength: "RSA_2048_BIT"
20404
20526
  }
@@ -20511,6 +20633,8 @@ Run ${pc23.cyan("wraps email init")} first to deploy email infrastructure.
20511
20633
  domain,
20512
20634
  mailFromDomain,
20513
20635
  purpose,
20636
+ configSetName,
20637
+ trackingConfig,
20514
20638
  addedAt: (/* @__PURE__ */ new Date()).toISOString()
20515
20639
  };
20516
20640
  addDomainToMetadata(metadata, entry);
@@ -21424,7 +21548,7 @@ Deploy first: ${pc24.cyan("wraps email inbound init")}
21424
21548
  }
21425
21549
  const emailService = metadata.services.email;
21426
21550
  const inboundConfig = emailService.config.inbound;
21427
- if (!options.force) {
21551
+ if (!(options.force || options.preview)) {
21428
21552
  clack22.log.warn(
21429
21553
  `This will remove inbound email for ${pc24.cyan(inboundConfig.receivingDomain || "")}`
21430
21554
  );
@@ -21437,17 +21561,6 @@ Deploy first: ${pc24.cyan("wraps email inbound init")}
21437
21561
  process.exit(0);
21438
21562
  }
21439
21563
  }
21440
- await progress.execute("Removing SES receipt rules", async () => {
21441
- await deleteReceiptRule(region);
21442
- await deleteReceiptRuleSet(region);
21443
- });
21444
- await progress.execute(
21445
- "Preparing workspace",
21446
- async () => ensurePulumiWorkDir({
21447
- accountId: identity.accountId,
21448
- region
21449
- })
21450
- );
21451
21564
  const pulumiWorkDir = getPulumiWorkDir();
21452
21565
  const stackName = emailService.pulumiStackName || `wraps-${identity.accountId}-${region}`;
21453
21566
  const updatedEmailConfig = {
@@ -21458,7 +21571,8 @@ Deploy first: ${pc24.cyan("wraps email inbound init")}
21458
21571
  const stackConfig = buildEmailStackConfig(metadata, region, {
21459
21572
  emailConfig: updatedEmailConfig
21460
21573
  });
21461
- await progress.execute("Removing inbound infrastructure", async () => {
21574
+ const createStack = async () => {
21575
+ await ensurePulumiWorkDir({ accountId: identity.accountId, region });
21462
21576
  const stack = await pulumi18.automation.LocalWorkspace.createOrSelectStack(
21463
21577
  {
21464
21578
  stackName,
@@ -21473,6 +21587,39 @@ Deploy first: ${pc24.cyan("wraps email inbound init")}
21473
21587
  }
21474
21588
  );
21475
21589
  await stack.setConfig("aws:region", { value: region });
21590
+ return stack;
21591
+ };
21592
+ if (options.preview) {
21593
+ const previewResult = await progress.execute(
21594
+ "Generating infrastructure preview",
21595
+ async () => {
21596
+ const stack = await createStack();
21597
+ return previewWithResourceChanges(stack, { diff: true });
21598
+ }
21599
+ );
21600
+ displayPreview({
21601
+ changeSummary: previewResult.changeSummary,
21602
+ resourceChanges: previewResult.resourceChanges,
21603
+ commandName: "wraps email inbound destroy"
21604
+ });
21605
+ clack22.outro(
21606
+ pc24.green("Preview complete. Run without --preview to destroy.")
21607
+ );
21608
+ return;
21609
+ }
21610
+ await progress.execute("Removing SES receipt rules", async () => {
21611
+ await deleteReceiptRule(region);
21612
+ await deleteReceiptRuleSet(region);
21613
+ });
21614
+ await progress.execute(
21615
+ "Preparing workspace",
21616
+ async () => ensurePulumiWorkDir({
21617
+ accountId: identity.accountId,
21618
+ region
21619
+ })
21620
+ );
21621
+ await progress.execute("Removing inbound infrastructure", async () => {
21622
+ const stack = await createStack();
21476
21623
  await withLockRetry(
21477
21624
  () => withTimeout(
21478
21625
  stack.up({ onOutput: () => {
@@ -22502,6 +22649,44 @@ async function init2(options) {
22502
22649
  emailConfig.mailFromSubdomain = mailFromFull.endsWith(suffix) ? mailFromFull.slice(0, -suffix.length) || "mail" : "mail";
22503
22650
  }
22504
22651
  }
22652
+ if (!options.quick && preset !== "custom" && emailConfig.tracking?.enabled) {
22653
+ const purpose = await promptDomainPurpose();
22654
+ if (purpose === "transactional") {
22655
+ emailConfig.tracking = {
22656
+ ...emailConfig.tracking,
22657
+ opens: false,
22658
+ clicks: false
22659
+ };
22660
+ } else if (purpose === "marketing" || purpose === "notifications") {
22661
+ emailConfig.tracking = {
22662
+ ...emailConfig.tracking,
22663
+ opens: true,
22664
+ clicks: true
22665
+ };
22666
+ } else {
22667
+ const trackOpens = await clack26.confirm({
22668
+ message: "Track email opens?",
22669
+ initialValue: emailConfig.tracking.opens ?? true
22670
+ });
22671
+ if (clack26.isCancel(trackOpens)) {
22672
+ clack26.cancel("Operation cancelled.");
22673
+ process.exit(0);
22674
+ }
22675
+ const trackClicks = await clack26.confirm({
22676
+ message: "Track link clicks?",
22677
+ initialValue: emailConfig.tracking.clicks ?? true
22678
+ });
22679
+ if (clack26.isCancel(trackClicks)) {
22680
+ clack26.cancel("Operation cancelled.");
22681
+ process.exit(0);
22682
+ }
22683
+ emailConfig.tracking = {
22684
+ ...emailConfig.tracking,
22685
+ opens: trackOpens,
22686
+ clicks: trackClicks
22687
+ };
22688
+ }
22689
+ }
22505
22690
  let costSummary;
22506
22691
  if (!options.quick) {
22507
22692
  const estimatedVolume = await promptEstimatedVolume();
@@ -23372,6 +23557,59 @@ async function replyInit(options) {
23372
23557
  );
23373
23558
  }
23374
23559
  const stackName = emailService.pulumiStackName || `wraps-${identity.accountId}-${region}`;
23560
+ if (options.preview) {
23561
+ const previewResult = await progress.execute(
23562
+ "Generating infrastructure preview",
23563
+ async () => {
23564
+ const previewMetadata = JSON.parse(
23565
+ JSON.stringify(metadata)
23566
+ );
23567
+ const previewEmailConfig = previewMetadata.services.email.config;
23568
+ const rt = previewEmailConfig.replyThreading ?? {
23569
+ enabled: false,
23570
+ domains: []
23571
+ };
23572
+ for (const domain of targetDomains) {
23573
+ const filtered = rt.domains.filter((d) => d.domain !== domain);
23574
+ filtered.push({
23575
+ domain,
23576
+ initialSecret: randomBytes5(32).toString("base64"),
23577
+ currentKid: 1,
23578
+ createdAt: (/* @__PURE__ */ new Date()).toISOString()
23579
+ });
23580
+ rt.domains = filtered;
23581
+ }
23582
+ previewEmailConfig.replyThreading = {
23583
+ enabled: true,
23584
+ domains: rt.domains
23585
+ };
23586
+ const stackConfig = buildEmailStackConfig(previewMetadata, region);
23587
+ await ensurePulumiWorkDir({ accountId: identity.accountId, region });
23588
+ const stack = await pulumi20.automation.LocalWorkspace.createOrSelectStack(
23589
+ {
23590
+ stackName,
23591
+ projectName: "wraps-email",
23592
+ program: async () => {
23593
+ const result = await deployEmailStack(stackConfig);
23594
+ return result;
23595
+ }
23596
+ },
23597
+ {
23598
+ workDir: getPulumiWorkDir()
23599
+ }
23600
+ );
23601
+ await stack.setConfig("aws:region", { value: region });
23602
+ return previewWithResourceChanges(stack, { diff: true });
23603
+ }
23604
+ );
23605
+ displayPreview({
23606
+ changeSummary: previewResult.changeSummary,
23607
+ resourceChanges: previewResult.resourceChanges,
23608
+ commandName: "wraps email reply init"
23609
+ });
23610
+ clack27.outro(pc29.green("Preview complete. Run without --preview to deploy."));
23611
+ return;
23612
+ }
23375
23613
  const results = [];
23376
23614
  for (const domain of targetDomains) {
23377
23615
  const fresh = await loadConnectionMetadata(identity.accountId, region);
@@ -23713,7 +23951,7 @@ async function replyDestroy(options) {
23713
23951
  "https://wraps.dev/docs/guides/reply-threading"
23714
23952
  );
23715
23953
  }
23716
- if (!(options.force || isJsonMode())) {
23954
+ if (!(options.force || options.preview || isJsonMode())) {
23717
23955
  const confirmed = await clack27.confirm({
23718
23956
  message: `Remove reply threading for ${targets.join(", ")}?`,
23719
23957
  initialValue: false
@@ -23723,10 +23961,55 @@ async function replyDestroy(options) {
23723
23961
  return;
23724
23962
  }
23725
23963
  }
23964
+ const emailService = metadata.services.email;
23965
+ if (options.preview) {
23966
+ if (emailService) {
23967
+ const previewMetadata = JSON.parse(
23968
+ JSON.stringify(metadata)
23969
+ );
23970
+ for (const domain of targets) {
23971
+ stripDomainFromReplyThreadingMetadata({
23972
+ domain,
23973
+ metadata: previewMetadata
23974
+ });
23975
+ }
23976
+ const stackName = emailService.pulumiStackName || `wraps-${identity.accountId}-${region}`;
23977
+ const stackConfig = buildEmailStackConfig(previewMetadata, region);
23978
+ const previewResult = await progress.execute(
23979
+ "Generating infrastructure preview",
23980
+ async () => {
23981
+ await ensurePulumiWorkDir({ accountId: identity.accountId, region });
23982
+ const stack = await pulumi20.automation.LocalWorkspace.createOrSelectStack(
23983
+ {
23984
+ stackName,
23985
+ projectName: "wraps-email",
23986
+ program: async () => {
23987
+ const result = await deployEmailStack(stackConfig);
23988
+ return result;
23989
+ }
23990
+ },
23991
+ {
23992
+ workDir: getPulumiWorkDir()
23993
+ }
23994
+ );
23995
+ await stack.setConfig("aws:region", { value: region });
23996
+ return previewWithResourceChanges(stack, { diff: true });
23997
+ }
23998
+ );
23999
+ displayPreview({
24000
+ changeSummary: previewResult.changeSummary,
24001
+ resourceChanges: previewResult.resourceChanges,
24002
+ commandName: "wraps email reply destroy"
24003
+ });
24004
+ }
24005
+ clack27.outro(
24006
+ pc29.green("Preview complete. Run without --preview to destroy.")
24007
+ );
24008
+ return;
24009
+ }
23726
24010
  for (const domain of targets) {
23727
24011
  stripDomainFromReplyThreadingMetadata({ domain, metadata });
23728
24012
  }
23729
- const emailService = metadata.services.email;
23730
24013
  if (emailService) {
23731
24014
  const stackName = emailService.pulumiStackName || `wraps-${identity.accountId}-${region}`;
23732
24015
  const stackConfig = buildEmailStackConfig(metadata, region);
@@ -23819,6 +24102,7 @@ async function replyDecode(addressInput, options) {
23819
24102
  // src/commands/email/restore.ts
23820
24103
  init_esm_shims();
23821
24104
  init_events();
24105
+ init_config_set_slug();
23822
24106
  init_aws();
23823
24107
  init_errors();
23824
24108
  init_fs();
@@ -23882,7 +24166,10 @@ ${pc30.bold("The following Wraps resources will be removed:")}
23882
24166
  `
23883
24167
  );
23884
24168
  if (metadata.services.email?.config.tracking?.enabled) {
23885
- console.log(` ${pc30.cyan("\u2713")} Configuration Set (wraps-email-tracking)`);
24169
+ const configSetName = domainToConfigSetName(
24170
+ metadata.services.email.config.domain ?? ""
24171
+ );
24172
+ console.log(` ${pc30.cyan("\u2713")} Configuration Set (${configSetName})`);
23886
24173
  }
23887
24174
  if (metadata.services.email?.config.eventTracking?.dynamoDBHistory) {
23888
24175
  console.log(` ${pc30.cyan("\u2713")} DynamoDB Table (wraps-email-history)`);
@@ -25440,7 +25727,7 @@ function renderErrorPage(err) {
25440
25727
  // src/commands/email/templates/push.ts
25441
25728
  init_esm_shims();
25442
25729
  init_events();
25443
- import { createHash } from "crypto";
25730
+ import { createHash as createHash2 } from "crypto";
25444
25731
  import { existsSync as existsSync13 } from "fs";
25445
25732
  import { mkdir as mkdir6, readFile as readFile6, writeFile as writeFile8 } from "fs/promises";
25446
25733
  import { join as join15 } from "path";
@@ -26077,7 +26364,7 @@ async function pushToAPI(templates, token, progress, force) {
26077
26364
  return results;
26078
26365
  }
26079
26366
  function sha256(content) {
26080
- return createHash("sha256").update(content).digest("hex");
26367
+ return createHash2("sha256").update(content).digest("hex");
26081
26368
  }
26082
26369
 
26083
26370
  // src/cli.ts
@@ -26301,6 +26588,11 @@ ${pc35.bold("Current Configuration:")}
26301
26588
  value: "hosting-provider",
26302
26589
  label: "Change hosting provider",
26303
26590
  hint: metadata.provider === "vercel" ? `Currently: Vercel (${metadata.vercel?.teamSlug || "configured"})` : `Currently: ${metadata.provider} \u2192 Switch to Vercel OIDC, etc.`
26591
+ },
26592
+ {
26593
+ value: "per-domain-config-sets",
26594
+ label: "Per-domain configuration sets",
26595
+ hint: "Create dedicated SES config sets for each additional domain"
26304
26596
  }
26305
26597
  );
26306
26598
  if (options.action) {
@@ -27433,6 +27725,141 @@ ${pc35.bold("SMTP Credentials for Legacy Systems")}
27433
27725
  }
27434
27726
  break;
27435
27727
  }
27728
+ case "per-domain-config-sets": {
27729
+ const {
27730
+ SESv2Client: SESv2Client9,
27731
+ CreateConfigurationSetCommand: CreateConfigurationSetCommand2,
27732
+ CreateConfigurationSetEventDestinationCommand: CreateConfigurationSetEventDestinationCommand2,
27733
+ PutEmailIdentityConfigurationSetAttributesCommand,
27734
+ EventType
27735
+ } = await import("@aws-sdk/client-sesv2");
27736
+ const { domainToConfigSetName: domainToConfigSetName2 } = await Promise.resolve().then(() => (init_config_set_slug(), config_set_slug_exports));
27737
+ const sesClient = new SESv2Client9({ region });
27738
+ const accountId = identity.accountId;
27739
+ const additionalDomains = metadata.services.email?.config.additionalDomains ?? [];
27740
+ const unmigratedDomains = additionalDomains.filter(
27741
+ (d) => !d.configSetName
27742
+ );
27743
+ if (unmigratedDomains.length === 0) {
27744
+ clack34.log.info(
27745
+ "All additional domains are already migrated to per-domain config sets."
27746
+ );
27747
+ clack34.outro(pc35.green("\u2713 Nothing to migrate"));
27748
+ trackServiceUpgrade("email", {
27749
+ action: "per-domain-config-sets",
27750
+ duration_ms: Date.now() - startTime
27751
+ });
27752
+ return;
27753
+ }
27754
+ clack34.log.info(
27755
+ `Migrating ${unmigratedDomains.length} domain(s) to per-domain configuration sets...`
27756
+ );
27757
+ const eventBusArn = `arn:aws:events:${region}:${accountId}:event-bus/default`;
27758
+ for (let i = 0; i < unmigratedDomains.length; i++) {
27759
+ const d = unmigratedDomains[i];
27760
+ const configSetName = domainToConfigSetName2(d.domain);
27761
+ clack34.log.step(
27762
+ `Migrating ${pc35.cyan(d.domain)} \u2192 ${pc35.dim(configSetName)}`
27763
+ );
27764
+ await progress.execute(
27765
+ `Creating config set for ${d.domain}`,
27766
+ async () => {
27767
+ try {
27768
+ await sesClient.send(
27769
+ new CreateConfigurationSetCommand2({
27770
+ ConfigurationSetName: configSetName,
27771
+ SuppressionOptions: {
27772
+ SuppressedReasons: ["BOUNCE", "COMPLAINT"]
27773
+ }
27774
+ })
27775
+ );
27776
+ } catch (err) {
27777
+ if (err.name !== "AlreadyExistsException")
27778
+ throw err;
27779
+ }
27780
+ }
27781
+ );
27782
+ const allEvents = [
27783
+ EventType.SEND,
27784
+ EventType.DELIVERY,
27785
+ EventType.OPEN,
27786
+ EventType.CLICK,
27787
+ EventType.BOUNCE,
27788
+ EventType.COMPLAINT,
27789
+ EventType.REJECT,
27790
+ EventType.RENDERING_FAILURE,
27791
+ EventType.DELIVERY_DELAY,
27792
+ EventType.SUBSCRIPTION
27793
+ ];
27794
+ const matchingEventTypes = d.trackingConfig != null ? allEvents.filter((evt) => {
27795
+ if (evt === EventType.OPEN) return d.trackingConfig.opens;
27796
+ if (evt === EventType.CLICK) return d.trackingConfig.clicks;
27797
+ return true;
27798
+ }) : [...allEvents];
27799
+ await progress.execute(
27800
+ `Adding EventBridge destination for ${d.domain}`,
27801
+ async () => {
27802
+ try {
27803
+ await sesClient.send(
27804
+ new CreateConfigurationSetEventDestinationCommand2({
27805
+ ConfigurationSetName: configSetName,
27806
+ EventDestinationName: "wraps-email-eventbridge",
27807
+ EventDestination: {
27808
+ Enabled: true,
27809
+ MatchingEventTypes: matchingEventTypes,
27810
+ EventBridgeDestination: { EventBusArn: eventBusArn }
27811
+ }
27812
+ })
27813
+ );
27814
+ } catch (err) {
27815
+ if (err.name !== "AlreadyExistsException")
27816
+ throw err;
27817
+ }
27818
+ }
27819
+ );
27820
+ d.configSetName = configSetName;
27821
+ await saveConnectionMetadata(metadata);
27822
+ try {
27823
+ await progress.execute(
27824
+ `Reassigning identity ${d.domain}`,
27825
+ async () => {
27826
+ await sesClient.send(
27827
+ new PutEmailIdentityConfigurationSetAttributesCommand({
27828
+ EmailIdentity: d.domain,
27829
+ ConfigurationSetName: configSetName
27830
+ })
27831
+ );
27832
+ }
27833
+ );
27834
+ } catch (err) {
27835
+ const msg = err instanceof Error ? err.message : String(err);
27836
+ clack34.log.warn(
27837
+ `Failed to reassign identity for ${pc35.cyan(d.domain)}: ${msg}`
27838
+ );
27839
+ clack34.log.info(
27840
+ pc35.dim(
27841
+ `Config set was saved \u2014 re-run to retry identity reassignment for ${d.domain}`
27842
+ )
27843
+ );
27844
+ }
27845
+ if (i < unmigratedDomains.length - 1) {
27846
+ await new Promise((resolve) => setTimeout(resolve, 1e3));
27847
+ }
27848
+ }
27849
+ clack34.log.info(
27850
+ `For the primary domain (${pc35.cyan(config2.domain ?? "")}) config set rename: re-run ${pc35.cyan("wraps email upgrade")} with a Pulumi stack update.`
27851
+ );
27852
+ clack34.outro(
27853
+ pc35.green(
27854
+ `\u2713 Per-domain config sets migration complete (${unmigratedDomains.length} domain(s))`
27855
+ )
27856
+ );
27857
+ trackServiceUpgrade("email", {
27858
+ action: "per-domain-config-sets",
27859
+ duration_ms: Date.now() - startTime
27860
+ });
27861
+ return;
27862
+ }
27436
27863
  }
27437
27864
  const newCostData = calculateCosts(updatedConfig, 5e4);
27438
27865
  const costDiff = newCostData.total.monthly - currentCostData.total.monthly;
@@ -28813,7 +29240,7 @@ function assignPositions(steps, transitions) {
28813
29240
 
28814
29241
  // src/utils/email/workflow-ts.ts
28815
29242
  init_esm_shims();
28816
- import { createHash as createHash2 } from "crypto";
29243
+ import { createHash as createHash3 } from "crypto";
28817
29244
  import { existsSync as existsSync15 } from "fs";
28818
29245
  import { mkdir as mkdir8, readdir as readdir4, readFile as readFile8, writeFile as writeFile10 } from "fs/promises";
28819
29246
  import { basename, join as join17 } from "path";
@@ -28839,7 +29266,7 @@ async function discoverWorkflows(dir, filter) {
28839
29266
  async function parseWorkflowTs(filePath, wrapsDir) {
28840
29267
  const { build: build2 } = await import("esbuild");
28841
29268
  const source = await readFile8(filePath, "utf-8");
28842
- const sourceHash = createHash2("sha256").update(source).digest("hex");
29269
+ const sourceHash = createHash3("sha256").update(source).digest("hex");
28843
29270
  const slug = basename(filePath, ".ts");
28844
29271
  const shimDir = join17(wrapsDir, ".wraps", "_shims");
28845
29272
  await mkdir8(shimDir, { recursive: true });
@@ -30494,7 +30921,7 @@ import {
30494
30921
  IAMClient as IAMClient3,
30495
30922
  PutRolePolicyCommand
30496
30923
  } from "@aws-sdk/client-iam";
30497
- import { confirm as confirm18, intro as intro34, isCancel as isCancel25, log as log36, outro as outro20, select as select17 } from "@clack/prompts";
30924
+ import { confirm as confirm18, intro as intro34, isCancel as isCancel25, log as log36, outro as outro22, select as select17 } from "@clack/prompts";
30498
30925
  import * as pulumi24 from "@pulumi/pulumi";
30499
30926
  import pc41 from "picocolors";
30500
30927
  init_events();
@@ -30856,7 +31283,7 @@ async function resolveOrganization() {
30856
31283
  }))
30857
31284
  });
30858
31285
  if (isCancel25(selected)) {
30859
- outro20("Operation cancelled");
31286
+ outro22("Operation cancelled");
30860
31287
  process.exit(0);
30861
31288
  }
30862
31289
  return orgs.find((o) => o.id === selected) || null;
@@ -30922,7 +31349,7 @@ async function authenticatedConnect(token, options) {
30922
31349
  initialValue: true
30923
31350
  });
30924
31351
  if (isCancel25(enableTracking) || !enableTracking) {
30925
- outro20("Platform connection cancelled.");
31352
+ outro22("Platform connection cancelled.");
30926
31353
  process.exit(0);
30927
31354
  }
30928
31355
  metadata.services.email.config = {
@@ -31013,7 +31440,7 @@ You can try the manual flow: ${pc41.cyan("wraps auth logout")} then ${pc41.cyan(
31013
31440
  webhookConnected: true
31014
31441
  });
31015
31442
  } else {
31016
- outro20(pc41.green("Platform connection complete!"));
31443
+ outro22(pc41.green("Platform connection complete!"));
31017
31444
  console.log();
31018
31445
  console.log(
31019
31446
  pc41.dim(
@@ -31117,7 +31544,7 @@ Run ${pc41.cyan("wraps email init")} or ${pc41.cyan("wraps sms init")} first.
31117
31544
  initialValue: true
31118
31545
  });
31119
31546
  if (isCancel25(enableEventTracking) || !enableEventTracking) {
31120
- outro20("Platform connection cancelled.");
31547
+ outro22("Platform connection cancelled.");
31121
31548
  process.exit(0);
31122
31549
  }
31123
31550
  metadata.services.email.config = {
@@ -31165,7 +31592,7 @@ Run ${pc41.cyan("wraps email init")} or ${pc41.cyan("wraps sms init")} first.
31165
31592
  ]
31166
31593
  });
31167
31594
  if (isCancel25(action)) {
31168
- outro20("Operation cancelled");
31595
+ outro22("Operation cancelled");
31169
31596
  process.exit(0);
31170
31597
  }
31171
31598
  if (action === "keep") {
@@ -31176,7 +31603,7 @@ Run ${pc41.cyan("wraps email init")} or ${pc41.cyan("wraps sms init")} first.
31176
31603
  initialValue: false
31177
31604
  });
31178
31605
  if (isCancel25(confirmDisconnect) || !confirmDisconnect) {
31179
- outro20("Disconnect cancelled");
31606
+ outro22("Disconnect cancelled");
31180
31607
  process.exit(0);
31181
31608
  }
31182
31609
  metadata.services.email.webhookSecret = void 0;
@@ -31274,7 +31701,7 @@ Run ${pc41.cyan("wraps email init")} or ${pc41.cyan("wraps sms init")} first.
31274
31701
  }
31275
31702
  await saveConnectionMetadata(metadata);
31276
31703
  progress.stop();
31277
- outro20(pc41.green("Platform connection complete!"));
31704
+ outro22(pc41.green("Platform connection complete!"));
31278
31705
  if (webhookSecret && needsDeployment) {
31279
31706
  console.log(`
31280
31707
  ${pc41.bold("Webhook Secret")} ${pc41.dim("(save this!)")}`);
@@ -31387,7 +31814,7 @@ import {
31387
31814
  GetRoleCommand as GetRoleCommand2,
31388
31815
  IAMClient as IAMClient4
31389
31816
  } from "@aws-sdk/client-iam";
31390
- import { confirm as confirm19, intro as intro36, isCancel as isCancel26, log as log37, outro as outro21 } from "@clack/prompts";
31817
+ import { confirm as confirm19, intro as intro36, isCancel as isCancel26, log as log37, outro as outro23 } from "@clack/prompts";
31391
31818
  import pc43 from "picocolors";
31392
31819
  async function updateRole(options) {
31393
31820
  const startTime = Date.now();
@@ -31457,7 +31884,7 @@ Run ${pc43.cyan("wraps email init")} to deploy infrastructure first.
31457
31884
  initialValue: true
31458
31885
  });
31459
31886
  if (isCancel26(shouldContinue) || !shouldContinue) {
31460
- outro21(`${actionLabel} cancelled`);
31887
+ outro23(`${actionLabel} cancelled`);
31461
31888
  process.exit(0);
31462
31889
  }
31463
31890
  }
@@ -31537,7 +31964,7 @@ Run ${pc43.cyan("wraps email init")} to deploy infrastructure first.
31537
31964
  });
31538
31965
  return;
31539
31966
  }
31540
- outro21(pc43.green(`\u2713 Platform access role ${actionVerb} successfully`));
31967
+ outro23(pc43.green(`\u2713 Platform access role ${actionVerb} successfully`));
31541
31968
  console.log(`
31542
31969
  ${pc43.bold("Permissions:")}`);
31543
31970
  console.log(`
@@ -33096,6 +33523,7 @@ function createMetricsRouter(config2) {
33096
33523
 
33097
33524
  // src/console/routes/settings.ts
33098
33525
  init_esm_shims();
33526
+ init_config_set_slug();
33099
33527
  init_metadata();
33100
33528
  import dns4 from "dns/promises";
33101
33529
  import { Router as createRouter6 } from "express";
@@ -33232,8 +33660,8 @@ function createSettingsRouter(config2) {
33232
33660
  error: "No Wraps infrastructure found for this account and region"
33233
33661
  });
33234
33662
  }
33235
- const configSetName = "wraps-email-tracking";
33236
33663
  const domain = metadata.services.email?.config.domain;
33664
+ const configSetName = domainToConfigSetName(domain ?? "");
33237
33665
  const settings = await fetchEmailSettings(
33238
33666
  config2.roleArn,
33239
33667
  config2.region,
@@ -33335,7 +33763,9 @@ function createSettingsRouter(config2) {
33335
33763
  error: "No Wraps infrastructure found for this account and region"
33336
33764
  });
33337
33765
  }
33338
- const configSetName = "wraps-email-tracking";
33766
+ const configSetName = domainToConfigSetName(
33767
+ metadata.services.email?.config.domain ?? ""
33768
+ );
33339
33769
  console.log(
33340
33770
  `[Settings] Updating sending options for ${configSetName}: ${enabled}`
33341
33771
  );
@@ -33372,7 +33802,9 @@ function createSettingsRouter(config2) {
33372
33802
  error: "No Wraps infrastructure found for this account and region"
33373
33803
  });
33374
33804
  }
33375
- const configSetName = "wraps-email-tracking";
33805
+ const configSetName = domainToConfigSetName(
33806
+ metadata.services.email?.config.domain ?? ""
33807
+ );
33376
33808
  console.log(
33377
33809
  `[Settings] Updating reputation options for ${configSetName}: ${enabled}`
33378
33810
  );
@@ -33415,7 +33847,9 @@ function createSettingsRouter(config2) {
33415
33847
  error: "No Wraps infrastructure found for this account and region"
33416
33848
  });
33417
33849
  }
33418
- const configSetName = "wraps-email-tracking";
33850
+ const configSetName = domainToConfigSetName(
33851
+ metadata.services.email?.config.domain ?? ""
33852
+ );
33419
33853
  console.log(
33420
33854
  `[Settings] Updating tracking domain for ${configSetName}: ${domain}`
33421
33855
  );
@@ -36464,7 +36898,7 @@ ${pc48.yellow(pc48.bold("Important Notes:"))}`);
36464
36898
  clack45.log.warn(warning);
36465
36899
  }
36466
36900
  }
36467
- if (!options.yes) {
36901
+ if (!(options.yes || options.preview)) {
36468
36902
  const confirmed = await confirmDeploy();
36469
36903
  if (!confirmed) {
36470
36904
  clack45.cancel("Deployment cancelled.");
@@ -36477,43 +36911,77 @@ ${pc48.yellow(pc48.bold("Important Notes:"))}`);
36477
36911
  vercel: vercelConfig,
36478
36912
  smsConfig
36479
36913
  };
36914
+ const createStack = async () => {
36915
+ await ensurePulumiWorkDir({ accountId: identity.accountId, region });
36916
+ const stack = await pulumi29.automation.LocalWorkspace.createOrSelectStack(
36917
+ {
36918
+ stackName: `wraps-sms-${identity.accountId}-${region}`,
36919
+ projectName: "wraps-sms",
36920
+ program: async () => {
36921
+ const result = await deploySMSStack(stackConfig);
36922
+ return {
36923
+ roleArn: result.roleArn,
36924
+ phoneNumber: result.phoneNumber,
36925
+ phoneNumberArn: result.phoneNumberArn,
36926
+ configSetName: result.configSetName,
36927
+ tableName: result.tableName,
36928
+ region: result.region,
36929
+ lambdaFunctions: result.lambdaFunctions,
36930
+ snsTopicArn: result.snsTopicArn,
36931
+ queueUrl: result.queueUrl,
36932
+ dlqUrl: result.dlqUrl,
36933
+ optOutListArn: result.optOutListArn
36934
+ };
36935
+ }
36936
+ },
36937
+ {
36938
+ workDir: getPulumiWorkDir(),
36939
+ envVars: {
36940
+ PULUMI_CONFIG_PASSPHRASE: "",
36941
+ AWS_REGION: region
36942
+ },
36943
+ secretsProvider: "passphrase"
36944
+ }
36945
+ );
36946
+ await stack.setConfig("aws:region", { value: region });
36947
+ return stack;
36948
+ };
36949
+ if (options.preview) {
36950
+ try {
36951
+ const previewResult = await progress.execute(
36952
+ "Generating infrastructure preview",
36953
+ async () => {
36954
+ const stack = await createStack();
36955
+ return previewWithResourceChanges(stack, { diff: true });
36956
+ }
36957
+ );
36958
+ displayPreview({
36959
+ changeSummary: previewResult.changeSummary,
36960
+ resourceChanges: previewResult.resourceChanges,
36961
+ costEstimate: getSMSCostSummary(smsConfig, 0),
36962
+ commandName: "wraps sms init"
36963
+ });
36964
+ clack45.outro(
36965
+ pc48.green("Preview complete. Run without --preview to deploy.")
36966
+ );
36967
+ trackServiceInit("sms", true, {
36968
+ provider,
36969
+ region,
36970
+ preview: true,
36971
+ duration_ms: Date.now() - startTime
36972
+ });
36973
+ } catch (error) {
36974
+ trackError("PREVIEW_FAILED", "sms:init", { step: "preview" });
36975
+ throw error;
36976
+ }
36977
+ return;
36978
+ }
36480
36979
  let outputs;
36481
36980
  try {
36482
36981
  outputs = await progress.execute(
36483
36982
  "Deploying SMS infrastructure (this may take 2-3 minutes)",
36484
36983
  async () => {
36485
- await ensurePulumiWorkDir({ accountId: identity.accountId, region });
36486
- const stack = await pulumi29.automation.LocalWorkspace.createOrSelectStack(
36487
- {
36488
- stackName: `wraps-sms-${identity.accountId}-${region}`,
36489
- projectName: "wraps-sms",
36490
- program: async () => {
36491
- const result = await deploySMSStack(stackConfig);
36492
- return {
36493
- roleArn: result.roleArn,
36494
- phoneNumber: result.phoneNumber,
36495
- phoneNumberArn: result.phoneNumberArn,
36496
- configSetName: result.configSetName,
36497
- tableName: result.tableName,
36498
- region: result.region,
36499
- lambdaFunctions: result.lambdaFunctions,
36500
- snsTopicArn: result.snsTopicArn,
36501
- queueUrl: result.queueUrl,
36502
- dlqUrl: result.dlqUrl,
36503
- optOutListArn: result.optOutListArn
36504
- };
36505
- }
36506
- },
36507
- {
36508
- workDir: getPulumiWorkDir(),
36509
- envVars: {
36510
- PULUMI_CONFIG_PASSPHRASE: "",
36511
- AWS_REGION: region
36512
- },
36513
- secretsProvider: "passphrase"
36514
- }
36515
- );
36516
- await stack.setConfig("aws:region", { value: region });
36984
+ const stack = await createStack();
36517
36985
  const upResult = await withLockRetry(
36518
36986
  () => stack.up({ onOutput: console.log }),
36519
36987
  { accountId: identity.accountId, region, autoConfirm: options.yes }
@@ -38180,7 +38648,7 @@ ${pc53.bold("Cost Impact:")}`);
38180
38648
  );
38181
38649
  }
38182
38650
  console.log("");
38183
- if (!options.yes) {
38651
+ if (!(options.yes || options.preview)) {
38184
38652
  const confirmed = await clack50.confirm({
38185
38653
  message: "Proceed with upgrade?",
38186
38654
  initialValue: true
@@ -38202,46 +38670,79 @@ ${pc53.bold("Cost Impact:")}`);
38202
38670
  vercel: vercelConfig,
38203
38671
  smsConfig: updatedConfig
38204
38672
  };
38673
+ const stackName = metadata.services.sms?.pulumiStackName || `wraps-sms-${identity.accountId}-${region}`;
38674
+ const createStack = async () => {
38675
+ await ensurePulumiWorkDir({ accountId: identity.accountId, region });
38676
+ const stack = await pulumi32.automation.LocalWorkspace.createOrSelectStack(
38677
+ {
38678
+ stackName,
38679
+ projectName: "wraps-sms",
38680
+ program: async () => {
38681
+ const result = await deploySMSStack(stackConfig);
38682
+ return {
38683
+ roleArn: result.roleArn,
38684
+ phoneNumber: result.phoneNumber,
38685
+ phoneNumberArn: result.phoneNumberArn,
38686
+ configSetName: result.configSetName,
38687
+ tableName: result.tableName,
38688
+ region: result.region,
38689
+ lambdaFunctions: result.lambdaFunctions,
38690
+ snsTopicArn: result.snsTopicArn,
38691
+ queueUrl: result.queueUrl,
38692
+ dlqUrl: result.dlqUrl,
38693
+ optOutListArn: result.optOutListArn
38694
+ };
38695
+ }
38696
+ },
38697
+ {
38698
+ workDir: getPulumiWorkDir(),
38699
+ envVars: {
38700
+ PULUMI_CONFIG_PASSPHRASE: "",
38701
+ AWS_REGION: region
38702
+ },
38703
+ secretsProvider: "passphrase"
38704
+ }
38705
+ );
38706
+ await stack.workspace.selectStack(stackName);
38707
+ await stack.setConfig("aws:region", { value: region });
38708
+ return stack;
38709
+ };
38710
+ if (options.preview) {
38711
+ try {
38712
+ const previewResult = await progress.execute(
38713
+ "Generating infrastructure preview",
38714
+ async () => {
38715
+ const stack = await createStack();
38716
+ await stack.refresh({ onOutput: () => {
38717
+ } });
38718
+ return previewWithResourceChanges(stack, { diff: true });
38719
+ }
38720
+ );
38721
+ displayPreview({
38722
+ changeSummary: previewResult.changeSummary,
38723
+ resourceChanges: previewResult.resourceChanges,
38724
+ commandName: "wraps sms upgrade"
38725
+ });
38726
+ clack50.outro(
38727
+ pc53.green("Preview complete. Run without --preview to upgrade.")
38728
+ );
38729
+ trackServiceUpgrade("sms", {
38730
+ region,
38731
+ preview: true,
38732
+ duration_ms: Date.now() - startTime
38733
+ });
38734
+ } catch (error) {
38735
+ trackError("PREVIEW_FAILED", "sms:upgrade", { step: "preview" });
38736
+ throw error;
38737
+ }
38738
+ return;
38739
+ }
38205
38740
  let outputs;
38206
38741
  try {
38207
38742
  outputs = await progress.execute(
38208
38743
  "Updating SMS infrastructure (this may take 2-3 minutes)",
38209
38744
  async () => {
38210
- await ensurePulumiWorkDir({ accountId: identity.accountId, region });
38211
- const stack = await pulumi32.automation.LocalWorkspace.createOrSelectStack(
38212
- {
38213
- stackName: metadata.services.sms?.pulumiStackName || `wraps-sms-${identity.accountId}-${region}`,
38214
- projectName: "wraps-sms",
38215
- program: async () => {
38216
- const result = await deploySMSStack(stackConfig);
38217
- return {
38218
- roleArn: result.roleArn,
38219
- phoneNumber: result.phoneNumber,
38220
- phoneNumberArn: result.phoneNumberArn,
38221
- configSetName: result.configSetName,
38222
- tableName: result.tableName,
38223
- region: result.region,
38224
- lambdaFunctions: result.lambdaFunctions,
38225
- snsTopicArn: result.snsTopicArn,
38226
- queueUrl: result.queueUrl,
38227
- dlqUrl: result.dlqUrl,
38228
- optOutListArn: result.optOutListArn
38229
- };
38230
- }
38231
- },
38232
- {
38233
- workDir: getPulumiWorkDir(),
38234
- envVars: {
38235
- PULUMI_CONFIG_PASSPHRASE: "",
38236
- AWS_REGION: region
38237
- },
38238
- secretsProvider: "passphrase"
38239
- }
38240
- );
38241
- await stack.workspace.selectStack(
38242
- metadata.services.sms?.pulumiStackName || `wraps-sms-${identity.accountId}-${region}`
38243
- );
38244
- await stack.setConfig("aws:region", { value: region });
38745
+ const stack = await createStack();
38245
38746
  await stack.refresh({ onOutput: () => {
38246
38747
  } });
38247
38748
  const upResult = await stack.up({ onOutput: () => {
@@ -39567,7 +40068,8 @@ if (!primaryCommand) {
39567
40068
  provider: flags.provider,
39568
40069
  region: flags.region,
39569
40070
  preset: flags.preset,
39570
- yes: flags.yes
40071
+ yes: flags.yes,
40072
+ preview: flags.preview
39571
40073
  });
39572
40074
  break;
39573
40075
  case "cdn-init":
@@ -39752,6 +40254,7 @@ Usage: ${pc59.cyan("wraps email verify --domain yourapp.com")}
39752
40254
  await inboundDestroy({
39753
40255
  region: flags.region,
39754
40256
  force: flags.force,
40257
+ preview: flags.preview,
39755
40258
  json: flags.json
39756
40259
  });
39757
40260
  break;
@@ -39816,6 +40319,7 @@ Available commands: ${pc59.cyan("init")}, ${pc59.cyan("destroy")}, ${pc59.cyan("
39816
40319
  domain: flags.domain,
39817
40320
  all: flags.all,
39818
40321
  yes: flags.yes,
40322
+ preview: flags.preview,
39819
40323
  json: flags.json
39820
40324
  });
39821
40325
  break;
@@ -39839,6 +40343,7 @@ Available commands: ${pc59.cyan("init")}, ${pc59.cyan("destroy")}, ${pc59.cyan("
39839
40343
  domain: flags.domain,
39840
40344
  all: flags.all,
39841
40345
  force: flags.force,
40346
+ preview: flags.preview,
39842
40347
  json: flags.json
39843
40348
  });
39844
40349
  break;
@@ -40065,6 +40570,7 @@ Run ${pc59.cyan("wraps --help")} for available commands.
40065
40570
  region: flags.region,
40066
40571
  preset: flags.preset,
40067
40572
  yes: flags.yes,
40573
+ preview: flags.preview,
40068
40574
  json: flags.json
40069
40575
  });
40070
40576
  break;
@@ -40087,6 +40593,7 @@ Run ${pc59.cyan("wraps --help")} for available commands.
40087
40593
  await smsUpgrade({
40088
40594
  region: flags.region,
40089
40595
  yes: flags.yes,
40596
+ preview: flags.preview,
40090
40597
  json: flags.json
40091
40598
  });
40092
40599
  break;