@wraps.dev/cli 2.23.2 → 2.24.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.
Files changed (28) hide show
  1. package/dist/api-lambda.zip +0 -0
  2. package/dist/cli.js +633 -45
  3. package/dist/cli.js.map +1 -1
  4. package/dist/console/assets/{index-e_Wzcxes.js → index-5HGEQ1Ul.js} +58 -58
  5. package/dist/console/assets/index-BP4FpEpn.css +1 -0
  6. package/dist/console/index.html +2 -2
  7. package/dist/lambda/event-processor/.bundled +1 -1
  8. package/dist/lambda/event-processor/index.js +3 -2
  9. package/dist/lambda/inbound-processor/.bundled +1 -1
  10. package/dist/lambda/inbound-processor/index.js +60 -67
  11. package/dist/lambda/sms-event-processor/.bundled +1 -1
  12. package/dist/lambda/sms-event-processor/index.js +3 -2
  13. package/dist/selfhost-migrations/0059_message_send_claimed_at.sql +1 -0
  14. package/dist/selfhost-migrations/0060_harsh_nehzno.sql +1 -0
  15. package/dist/selfhost-migrations/0061_flashy_spirit.sql +1 -0
  16. package/dist/selfhost-migrations/0062_next_timeslip.sql +2 -0
  17. package/dist/selfhost-migrations/CLAUDE.md +49 -0
  18. package/dist/selfhost-migrations/meta/0055_snapshot.json +39 -3
  19. package/dist/selfhost-migrations/meta/0056_snapshot.json +39 -3
  20. package/dist/selfhost-migrations/meta/0057_snapshot.json +34 -2
  21. package/dist/selfhost-migrations/meta/0059_snapshot.json +6764 -0
  22. package/dist/selfhost-migrations/meta/0060_snapshot.json +7125 -0
  23. package/dist/selfhost-migrations/meta/0061_snapshot.json +7131 -0
  24. package/dist/selfhost-migrations/meta/0062_snapshot.json +7143 -0
  25. package/dist/selfhost-migrations/meta/_journal.json +29 -1
  26. package/package.json +35 -34
  27. package/dist/cli.d.ts +0 -1
  28. package/dist/console/assets/index-CXXcMD9d.css +0 -1
package/dist/cli.js CHANGED
@@ -9,12 +9,12 @@ var __export = (target, all6) => {
9
9
  __defProp(target, name, { get: all6[name], enumerable: true });
10
10
  };
11
11
 
12
- // ../../node_modules/.pnpm/tsup@8.5.1_jiti@2.6.1_postcss@8.5.14_tsx@4.20.6_typescript@5.9.3_yaml@2.8.4/node_modules/tsup/assets/esm_shims.js
12
+ // ../../node_modules/.pnpm/tsup@8.5.1_jiti@2.7.0_postcss@8.5.15_tsx@4.20.6_typescript@5.9.3_yaml@2.8.4/node_modules/tsup/assets/esm_shims.js
13
13
  import path from "path";
14
14
  import { fileURLToPath } from "url";
15
15
  var getFilename, getDirname, __dirname;
16
16
  var init_esm_shims = __esm({
17
- "../../node_modules/.pnpm/tsup@8.5.1_jiti@2.6.1_postcss@8.5.14_tsx@4.20.6_typescript@5.9.3_yaml@2.8.4/node_modules/tsup/assets/esm_shims.js"() {
17
+ "../../node_modules/.pnpm/tsup@8.5.1_jiti@2.7.0_postcss@8.5.15_tsx@4.20.6_typescript@5.9.3_yaml@2.8.4/node_modules/tsup/assets/esm_shims.js"() {
18
18
  "use strict";
19
19
  getFilename = () => fileURLToPath(import.meta.url);
20
20
  getDirname = () => path.dirname(getFilename());
@@ -5583,7 +5583,7 @@ __export(metadata_exports, {
5583
5583
  });
5584
5584
  import { randomBytes } from "crypto";
5585
5585
  import { existsSync as existsSync5 } from "fs";
5586
- import { readFile as readFile2, writeFile as writeFile3 } from "fs/promises";
5586
+ import { chmod as chmod2, readFile as readFile2, writeFile as writeFile3 } from "fs/promises";
5587
5587
  import { join as join6 } from "path";
5588
5588
  function getConnectionsDir() {
5589
5589
  return join6(getWrapsDir(), "connections");
@@ -5695,6 +5695,7 @@ async function saveConnectionMetadataLocal(metadata) {
5695
5695
  const metadataPath = getMetadataPath(metadata.accountId, metadata.region);
5696
5696
  const content = JSON.stringify(metadata, null, 2);
5697
5697
  await writeFile3(metadataPath, content, "utf-8");
5698
+ await chmod2(metadataPath, 384);
5698
5699
  }
5699
5700
  async function saveConnectionMetadata(metadata) {
5700
5701
  await ensureConnectionsDir();
@@ -5702,6 +5703,7 @@ async function saveConnectionMetadata(metadata) {
5702
5703
  try {
5703
5704
  const content = JSON.stringify(metadata, null, 2);
5704
5705
  await writeFile3(metadataPath, content, "utf-8");
5706
+ await chmod2(metadataPath, 384);
5705
5707
  } catch (error) {
5706
5708
  console.error(
5707
5709
  "Error saving connection metadata:",
@@ -8038,7 +8040,11 @@ async function createMailManagerArchive(config2) {
8038
8040
  );
8039
8041
  archiveArn = getResult.ArchiveArn;
8040
8042
  }
8041
- } catch {
8043
+ } catch (error) {
8044
+ const detail = error instanceof Error ? error.message : String(error);
8045
+ throw new Error(
8046
+ `Failed to list existing Mail Manager archives in ${region}: ${detail}`
8047
+ );
8042
8048
  }
8043
8049
  if (!archiveId) {
8044
8050
  for (let attempt = 1; attempt <= MAX_NAME_ATTEMPTS; attempt++) {
@@ -8064,7 +8070,8 @@ async function createMailManagerArchive(config2) {
8064
8070
  }
8065
8071
  break;
8066
8072
  } catch (error) {
8067
- if (error instanceof Error && error.name === "ConflictException" && attempt < MAX_NAME_ATTEMPTS) {
8073
+ const isConflict = error instanceof Error && (error.name === "ConflictException" || error.message.includes("ConflictException"));
8074
+ if (isConflict && attempt < MAX_NAME_ATTEMPTS) {
8068
8075
  continue;
8069
8076
  }
8070
8077
  throw error;
@@ -17832,8 +17839,19 @@ async function createEventBridgeResources(config2) {
17832
17839
  const target = new aws7.cloudwatch.EventTarget("wraps-email-events-target", {
17833
17840
  rule: rule.name,
17834
17841
  eventBusName,
17835
- arn: config2.queueArn
17842
+ arn: config2.queueArn,
17843
+ deadLetterConfig: { arn: config2.dlqArn }
17836
17844
  });
17845
+ const archive = new aws7.cloudwatch.EventArchive(
17846
+ "wraps-email-events-archive",
17847
+ {
17848
+ name: "wraps-email-events-archive",
17849
+ eventSourceArn: config2.eventBusArn,
17850
+ description: "Wraps: 30-day archive of SES events for outage replay (see wraps email doctor)",
17851
+ retentionDays: 30,
17852
+ eventPattern: JSON.stringify({ source: ["aws.ses"] })
17853
+ }
17854
+ );
17837
17855
  let webhookConnection;
17838
17856
  let webhookApiDestination;
17839
17857
  let webhookTarget;
@@ -17904,7 +17922,8 @@ async function createEventBridgeResources(config2) {
17904
17922
  rule: rule.name,
17905
17923
  eventBusName,
17906
17924
  arn: webhookApiDestination.arn,
17907
- roleArn: webhookRole.arn
17925
+ roleArn: webhookRole.arn,
17926
+ deadLetterConfig: { arn: config2.dlqArn }
17908
17927
  });
17909
17928
  }
17910
17929
  let userWebhookConnection;
@@ -17922,9 +17941,33 @@ async function createEventBridgeResources(config2) {
17922
17941
  userWebhookApiDestination = userWebhookResources.apiDestination;
17923
17942
  userWebhookTarget = userWebhookResources.target;
17924
17943
  }
17944
+ const alarmActions = config2.alertTopicArn ? [config2.alertTopicArn] : void 0;
17945
+ const failedInvocationsAlarm = new aws7.cloudwatch.MetricAlarm(
17946
+ "wraps-email-events-delivery-failures",
17947
+ {
17948
+ name: "wraps-email-events-delivery-failures",
17949
+ namespace: "AWS/Events",
17950
+ metricName: "FailedInvocations",
17951
+ dimensions: { RuleName: "wraps-email-events-to-sqs" },
17952
+ statistic: "Sum",
17953
+ period: 300,
17954
+ evaluationPeriods: 3,
17955
+ threshold: 1,
17956
+ comparisonOperator: "GreaterThanOrEqualToThreshold",
17957
+ treatMissingData: "notBreaching",
17958
+ alarmDescription: "SES event delivery from EventBridge is failing (dead target or deauthorized webhook). Event history is being lost \u2014 run `wraps email doctor`.",
17959
+ ...alarmActions ? { alarmActions, okActions: alarmActions } : {},
17960
+ tags: {
17961
+ ManagedBy: "wraps-cli",
17962
+ Service: "email"
17963
+ }
17964
+ }
17965
+ );
17925
17966
  return {
17926
17967
  rule,
17927
17968
  target,
17969
+ failedInvocationsAlarm,
17970
+ archive,
17928
17971
  webhookConnection,
17929
17972
  webhookApiDestination,
17930
17973
  webhookTarget,
@@ -18369,6 +18412,30 @@ async function createSQSResources(config2) {
18369
18412
  ...SQS_TIMEOUTS,
18370
18413
  ...dlqUrl ? { import: dlqUrl } : {}
18371
18414
  });
18415
+ const ruleArn = `arn:aws:events:${config2.region}:${config2.accountId}:rule/wraps-email-events-to-sqs`;
18416
+ new aws11.sqs.QueuePolicy("wraps-email-events-dlq-policy", {
18417
+ queueUrl: dlq.url,
18418
+ policy: dlq.arn.apply(
18419
+ (dlqArn) => JSON.stringify({
18420
+ Version: "2012-10-17",
18421
+ Statement: [
18422
+ {
18423
+ Effect: "Allow",
18424
+ Principal: {
18425
+ Service: "events.amazonaws.com"
18426
+ },
18427
+ Action: "sqs:SendMessage",
18428
+ Resource: dlqArn,
18429
+ Condition: {
18430
+ ArnEquals: {
18431
+ "aws:SourceArn": ruleArn
18432
+ }
18433
+ }
18434
+ }
18435
+ ]
18436
+ })
18437
+ )
18438
+ });
18372
18439
  const queueConfig = {
18373
18440
  name: queueName,
18374
18441
  visibilityTimeoutSeconds: 300,
@@ -18498,13 +18565,27 @@ async function deployEmailStack(config2) {
18498
18565
  }
18499
18566
  let sqsResources;
18500
18567
  if (emailConfig.eventTracking?.enabled) {
18501
- sqsResources = await createSQSResources({ region: config2.region });
18568
+ sqsResources = await createSQSResources({
18569
+ region: config2.region,
18570
+ accountId
18571
+ });
18572
+ }
18573
+ let alertingResources;
18574
+ if (emailConfig.alerts?.enabled) {
18575
+ alertingResources = await createAlertingResources({
18576
+ alertConfig: emailConfig.alerts,
18577
+ configSetName: sesResources?.configSet.configurationSetName,
18578
+ dlqName: sqsResources ? "wraps-email-events-dlq" : void 0,
18579
+ region: config2.region
18580
+ });
18502
18581
  }
18503
18582
  if (emailConfig.eventTracking?.enabled && sesResources && sqsResources) {
18504
18583
  await createEventBridgeResources({
18505
18584
  eventBusArn: sesResources.eventBus.arn,
18506
18585
  queueArn: sqsResources.queue.arn,
18507
18586
  queueUrl: sqsResources.queue.url,
18587
+ dlqArn: sqsResources.dlq.arn,
18588
+ alertTopicArn: alertingResources?.topic.arn,
18508
18589
  // Include webhook config if provided (for Wraps platform integration)
18509
18590
  webhook: config2.webhook,
18510
18591
  // Include user webhook config if provided
@@ -18545,15 +18626,6 @@ async function deployEmailStack(config2) {
18545
18626
  region: config2.region
18546
18627
  });
18547
18628
  }
18548
- let alertingResources;
18549
- if (emailConfig.alerts?.enabled) {
18550
- alertingResources = await createAlertingResources({
18551
- alertConfig: emailConfig.alerts,
18552
- configSetName: sesResources?.configSet.configurationSetName,
18553
- dlqName: sqsResources ? "wraps-email-events-dlq" : void 0,
18554
- region: config2.region
18555
- });
18556
- }
18557
18629
  let inboundResources;
18558
18630
  let replySecretsOutput;
18559
18631
  if (emailConfig.inbound?.enabled) {
@@ -19502,6 +19574,26 @@ async function emailDestroy(options) {
19502
19574
  const domain = emailConfig?.domain;
19503
19575
  const storedStackName = emailService?.pulumiStackName;
19504
19576
  if (!(options.force || options.preview)) {
19577
+ const additionalDomainNames = (emailConfig?.additionalDomains ?? []).map((d) => d.domain).filter(Boolean);
19578
+ const domainList = [domain, ...additionalDomainNames].filter(Boolean);
19579
+ const summaryLines = [
19580
+ domainList.length > 0 ? `Domain(s): ${domainList.join(", ")}` : "Domain(s): none configured",
19581
+ "SES configuration sets and event destinations for the above domain(s)",
19582
+ "Event pipeline: wraps-email-events / wraps-email-events-dlq queues, wraps-email-event-processor Lambda, EventBridge rule wraps-email-events-to-sqs",
19583
+ "",
19584
+ pc20.yellow(
19585
+ "Event streaming stops: the Wraps dashboard email timeline and event history for THIS ENTIRE AWS ACCOUNT stop receiving events. Historical events in DynamoDB are destroyed with the table. This cannot be undone."
19586
+ )
19587
+ ];
19588
+ if (emailService?.webhookSecret) {
19589
+ summaryLines.push(
19590
+ "",
19591
+ pc20.yellow(
19592
+ "This account is connected to the Wraps platform \u2014 the dashboard will stop receiving events for it."
19593
+ )
19594
+ );
19595
+ }
19596
+ clack18.note(summaryLines.join("\n"), "What will be destroyed");
19505
19597
  const confirmed = await clack18.confirm({
19506
19598
  message: pc20.red(
19507
19599
  "Are you sure you want to destroy all email infrastructure?"
@@ -19512,6 +19604,19 @@ async function emailDestroy(options) {
19512
19604
  clack18.cancel("Destruction cancelled.");
19513
19605
  process.exit(0);
19514
19606
  }
19607
+ if (emailService?.webhookSecret) {
19608
+ const typed = await clack18.text({
19609
+ message: `Type the AWS account id (${identity.accountId}) to confirm destruction of a platform-connected account:`
19610
+ });
19611
+ if (clack18.isCancel(typed) || typed !== identity.accountId) {
19612
+ clack18.cancel("Destruction cancelled \u2014 account id did not match.");
19613
+ process.exit(0);
19614
+ }
19615
+ }
19616
+ } else if (options.force && emailService?.webhookSecret) {
19617
+ clack18.log.warn(
19618
+ `--force skipped the typed account-id confirmation for platform-connected account ${identity.accountId}.`
19619
+ );
19515
19620
  }
19516
19621
  let shouldCleanDNS = false;
19517
19622
  let hostedZone = null;
@@ -19757,12 +19862,6 @@ Run ${pc20.cyan("wraps email init")} to deploy infrastructure again.
19757
19862
  // src/commands/email/doctor.ts
19758
19863
  init_esm_shims();
19759
19864
  init_events();
19760
- init_aws();
19761
- init_fs();
19762
- init_json_output();
19763
- init_metadata();
19764
- init_output();
19765
- init_scanner();
19766
19865
  import { DeleteTableCommand, DynamoDBClient as DynamoDBClient2 } from "@aws-sdk/client-dynamodb";
19767
19866
  import {
19768
19867
  DeleteRoleCommand,
@@ -19778,6 +19877,458 @@ import { DeleteTopicCommand, SNSClient as SNSClient2 } from "@aws-sdk/client-sns
19778
19877
  import * as clack19 from "@clack/prompts";
19779
19878
  import * as pulumi17 from "@pulumi/pulumi";
19780
19879
  import pc21 from "picocolors";
19880
+
19881
+ // src/utils/email/event-pipeline-check.ts
19882
+ init_esm_shims();
19883
+ init_errors();
19884
+ init_config_set_slug();
19885
+ var EVENT_DESTINATION_NAME = "wraps-email-eventbridge";
19886
+ var CONFIG_SET_FALLBACK = "wraps-email-tracking";
19887
+ var RULE_NAME = "wraps-email-events-to-sqs";
19888
+ var QUEUE_NAME = "wraps-email-events";
19889
+ var DLQ_NAME = "wraps-email-events-dlq";
19890
+ var LAMBDA_FUNCTION_NAME = "wraps-email-event-processor";
19891
+ var WEBHOOK_DESTINATION_NAME = "wraps-webhook-destination";
19892
+ var WEBHOOK_CONNECTION_NAME = "wraps-webhook-connection";
19893
+ var HISTORY_TABLE_NAME = "wraps-email-history";
19894
+ var UPGRADE_HINT = "wraps email upgrade";
19895
+ function summarizeError(error) {
19896
+ return error instanceof Error ? error.message : String(error);
19897
+ }
19898
+ function isQueueNotFoundError(error) {
19899
+ return isAWSNotFoundError(error) || error instanceof Error && error.name === "QueueDoesNotExist";
19900
+ }
19901
+ async function checkConfigSet(configSetName, region, optional) {
19902
+ const hop = `SES config set ${configSetName}`;
19903
+ try {
19904
+ const { SESv2Client: SESv2Client10, GetConfigurationSetEventDestinationsCommand: GetConfigurationSetEventDestinationsCommand2 } = await import("@aws-sdk/client-sesv2");
19905
+ const client = new SESv2Client10({ region });
19906
+ const response = await client.send(
19907
+ new GetConfigurationSetEventDestinationsCommand2({
19908
+ ConfigurationSetName: configSetName
19909
+ })
19910
+ );
19911
+ const destination = response.EventDestinations?.find(
19912
+ (d) => d.Name === EVENT_DESTINATION_NAME
19913
+ );
19914
+ if (destination?.Enabled) {
19915
+ return {
19916
+ hop,
19917
+ status: "pass",
19918
+ details: `${EVENT_DESTINATION_NAME} enabled`
19919
+ };
19920
+ }
19921
+ return {
19922
+ hop,
19923
+ status: "fail",
19924
+ details: `SES emits no events for ${configSetName} \u2014 redeploy with \`${UPGRADE_HINT}\``
19925
+ };
19926
+ } catch (error) {
19927
+ if (isAWSNotFoundError(error)) {
19928
+ if (optional) {
19929
+ return null;
19930
+ }
19931
+ return {
19932
+ hop,
19933
+ status: "fail",
19934
+ details: `Configuration set not found \u2014 redeploy with \`${UPGRADE_HINT}\``
19935
+ };
19936
+ }
19937
+ return {
19938
+ hop,
19939
+ status: "warn",
19940
+ details: `Could not check event destinations: ${summarizeError(error)}`
19941
+ };
19942
+ }
19943
+ }
19944
+ async function checkRule(region) {
19945
+ const hop = `EventBridge rule ${RULE_NAME}`;
19946
+ try {
19947
+ const { EventBridgeClient, DescribeRuleCommand } = await import("@aws-sdk/client-eventbridge");
19948
+ const client = new EventBridgeClient({ region });
19949
+ const response = await client.send(
19950
+ new DescribeRuleCommand({ Name: RULE_NAME })
19951
+ );
19952
+ if (response.State === "ENABLED") {
19953
+ return {
19954
+ exists: true,
19955
+ check: { hop, status: "pass", details: "Rule enabled" }
19956
+ };
19957
+ }
19958
+ return {
19959
+ exists: true,
19960
+ check: {
19961
+ hop,
19962
+ status: "fail",
19963
+ details: `Rule is ${response.State ?? "in an unknown state"} \u2014 run \`${UPGRADE_HINT}\` to re-enable`
19964
+ }
19965
+ };
19966
+ } catch (error) {
19967
+ if (isAWSNotFoundError(error)) {
19968
+ return {
19969
+ exists: false,
19970
+ check: {
19971
+ hop,
19972
+ status: "fail",
19973
+ details: `Rule not found \u2014 SES events have nowhere to go; run \`${UPGRADE_HINT}\``
19974
+ }
19975
+ };
19976
+ }
19977
+ return {
19978
+ exists: false,
19979
+ check: {
19980
+ hop,
19981
+ status: "warn",
19982
+ details: `Could not check rule: ${summarizeError(error)}`
19983
+ }
19984
+ };
19985
+ }
19986
+ }
19987
+ async function checkQueueExists(queueName, region, failDetails) {
19988
+ const hop = `SQS queue ${queueName}`;
19989
+ try {
19990
+ const { SQSClient, GetQueueUrlCommand } = await import("@aws-sdk/client-sqs");
19991
+ const client = new SQSClient({ region });
19992
+ await client.send(new GetQueueUrlCommand({ QueueName: queueName }));
19993
+ return { hop, status: "pass", details: "Queue exists" };
19994
+ } catch (error) {
19995
+ if (isQueueNotFoundError(error)) {
19996
+ return { hop, status: "fail", details: failDetails };
19997
+ }
19998
+ return {
19999
+ hop,
20000
+ status: "warn",
20001
+ details: `Could not check queue: ${summarizeError(error)}`
20002
+ };
20003
+ }
20004
+ }
20005
+ function findDuplicateTargetChecks(targets) {
20006
+ const idsByArn = /* @__PURE__ */ new Map();
20007
+ for (const target of targets) {
20008
+ if (!target.Arn) {
20009
+ continue;
20010
+ }
20011
+ const ids = idsByArn.get(target.Arn) ?? [];
20012
+ ids.push(target.Id ?? "unknown");
20013
+ idsByArn.set(target.Arn, ids);
20014
+ }
20015
+ const checks = [];
20016
+ for (const [arn, ids] of idsByArn) {
20017
+ if (ids.length > 1) {
20018
+ checks.push({
20019
+ hop: "EventBridge rule targets",
20020
+ status: "warn",
20021
+ details: `Duplicate targets for ${arn}: ${ids.join(", ")}`
20022
+ });
20023
+ }
20024
+ }
20025
+ return checks;
20026
+ }
20027
+ function checkSqsTargetOnRule(targets, region) {
20028
+ const failDetails = `events are being dropped \u2014 run \`${UPGRADE_HINT}\` to recreate the queue`;
20029
+ const sqsTarget = targets.find((t) => t.Arn?.split(":")[2] === "sqs");
20030
+ if (!sqsTarget) {
20031
+ return Promise.resolve({
20032
+ hop: `SQS queue ${QUEUE_NAME}`,
20033
+ status: "fail",
20034
+ details: `No SQS target on rule \u2014 ${failDetails}`
20035
+ });
20036
+ }
20037
+ return checkQueueExists(QUEUE_NAME, region, failDetails);
20038
+ }
20039
+ function checkPlatformWebhookTargetPresence(webhookTargetPresent, expectPlatformWebhook) {
20040
+ if (expectPlatformWebhook && !webhookTargetPresent) {
20041
+ return {
20042
+ hop: "Platform webhook target",
20043
+ status: "fail",
20044
+ details: `platform webhook target missing but metadata says connected \u2014 dashboard receives no events; run \`${UPGRADE_HINT}\``
20045
+ };
20046
+ }
20047
+ if (!expectPlatformWebhook && webhookTargetPresent) {
20048
+ return {
20049
+ hop: "Platform webhook target",
20050
+ status: "warn",
20051
+ details: "Webhook target exists on the rule but metadata has no webhookSecret \u2014 stack/metadata mismatch, possibly a lost metadata file"
20052
+ };
20053
+ }
20054
+ if (expectPlatformWebhook && webhookTargetPresent) {
20055
+ return {
20056
+ hop: "Platform webhook target",
20057
+ status: "pass",
20058
+ details: "Platform webhook target present"
20059
+ };
20060
+ }
20061
+ return null;
20062
+ }
20063
+ async function checkRuleTargets(region, expectPlatformWebhook) {
20064
+ try {
20065
+ const { EventBridgeClient, ListTargetsByRuleCommand } = await import("@aws-sdk/client-eventbridge");
20066
+ const client = new EventBridgeClient({ region });
20067
+ const response = await client.send(
20068
+ new ListTargetsByRuleCommand({ Rule: RULE_NAME })
20069
+ );
20070
+ const targets = response.Targets ?? [];
20071
+ const checks = [...findDuplicateTargetChecks(targets)];
20072
+ checks.push(await checkSqsTargetOnRule(targets, region));
20073
+ const webhookTargetPresent = targets.some(
20074
+ (t) => t.Arn?.includes(WEBHOOK_DESTINATION_NAME)
20075
+ );
20076
+ const webhookCheck = checkPlatformWebhookTargetPresence(
20077
+ webhookTargetPresent,
20078
+ expectPlatformWebhook
20079
+ );
20080
+ if (webhookCheck) {
20081
+ checks.push(webhookCheck);
20082
+ }
20083
+ return { checks, webhookTargetPresent };
20084
+ } catch (error) {
20085
+ return {
20086
+ checks: [
20087
+ {
20088
+ hop: "EventBridge rule targets",
20089
+ status: "warn",
20090
+ details: `Could not list rule targets: ${summarizeError(error)}`
20091
+ }
20092
+ ],
20093
+ webhookTargetPresent: false
20094
+ };
20095
+ }
20096
+ }
20097
+ async function checkWebhookDestination(region) {
20098
+ const hop = `EventBridge API destination ${WEBHOOK_DESTINATION_NAME}`;
20099
+ try {
20100
+ const { EventBridgeClient, DescribeApiDestinationCommand } = await import("@aws-sdk/client-eventbridge");
20101
+ const client = new EventBridgeClient({ region });
20102
+ const response = await client.send(
20103
+ new DescribeApiDestinationCommand({ Name: WEBHOOK_DESTINATION_NAME })
20104
+ );
20105
+ if (response.ApiDestinationState === "ACTIVE") {
20106
+ return { hop, status: "pass", details: "Active" };
20107
+ }
20108
+ return {
20109
+ hop,
20110
+ status: "fail",
20111
+ details: `API destination is ${response.ApiDestinationState ?? "in an unknown state"} \u2014 re-run \`${UPGRADE_HINT}\` to re-authorize`
20112
+ };
20113
+ } catch (error) {
20114
+ if (isAWSNotFoundError(error)) {
20115
+ return {
20116
+ hop,
20117
+ status: "fail",
20118
+ details: `API destination not found \u2014 re-run \`${UPGRADE_HINT}\``
20119
+ };
20120
+ }
20121
+ return {
20122
+ hop,
20123
+ status: "warn",
20124
+ details: `Could not check API destination: ${summarizeError(error)}`
20125
+ };
20126
+ }
20127
+ }
20128
+ async function checkWebhookConnection(region) {
20129
+ const hop = `EventBridge connection ${WEBHOOK_CONNECTION_NAME}`;
20130
+ try {
20131
+ const { EventBridgeClient, DescribeConnectionCommand } = await import("@aws-sdk/client-eventbridge");
20132
+ const client = new EventBridgeClient({ region });
20133
+ const response = await client.send(
20134
+ new DescribeConnectionCommand({ Name: WEBHOOK_CONNECTION_NAME })
20135
+ );
20136
+ if (response.ConnectionState === "AUTHORIZED") {
20137
+ return { hop, status: "pass", details: "Authorized" };
20138
+ }
20139
+ return {
20140
+ hop,
20141
+ status: "fail",
20142
+ details: `Connection is ${response.ConnectionState ?? "in an unknown state"} \u2014 re-run \`${UPGRADE_HINT}\` to re-authorize`
20143
+ };
20144
+ } catch (error) {
20145
+ if (isAWSNotFoundError(error)) {
20146
+ return {
20147
+ hop,
20148
+ status: "fail",
20149
+ details: `Connection not found \u2014 re-run \`${UPGRADE_HINT}\``
20150
+ };
20151
+ }
20152
+ return {
20153
+ hop,
20154
+ status: "warn",
20155
+ details: `Could not check connection: ${summarizeError(error)}`
20156
+ };
20157
+ }
20158
+ }
20159
+ async function checkDLQ(region) {
20160
+ const hop = `SQS DLQ ${DLQ_NAME}`;
20161
+ try {
20162
+ const { SQSClient, GetQueueUrlCommand, GetQueueAttributesCommand } = await import("@aws-sdk/client-sqs");
20163
+ const client = new SQSClient({ region });
20164
+ const urlResponse = await client.send(
20165
+ new GetQueueUrlCommand({ QueueName: DLQ_NAME })
20166
+ );
20167
+ const attrResponse = await client.send(
20168
+ new GetQueueAttributesCommand({
20169
+ QueueUrl: urlResponse.QueueUrl,
20170
+ AttributeNames: ["ApproximateNumberOfMessages"]
20171
+ })
20172
+ );
20173
+ const count = Number(
20174
+ attrResponse.Attributes?.ApproximateNumberOfMessages ?? "0"
20175
+ );
20176
+ if (count > 0) {
20177
+ return {
20178
+ hop,
20179
+ status: "warn",
20180
+ details: `${count} dead-lettered event(s) \u2014 investigate before they age out`
20181
+ };
20182
+ }
20183
+ return { hop, status: "pass", details: "Empty" };
20184
+ } catch (error) {
20185
+ if (isQueueNotFoundError(error)) {
20186
+ return {
20187
+ hop,
20188
+ status: "fail",
20189
+ details: `Dead-letter queue missing \u2014 run \`${UPGRADE_HINT}\` to recreate it`
20190
+ };
20191
+ }
20192
+ return {
20193
+ hop,
20194
+ status: "warn",
20195
+ details: `Could not check DLQ: ${summarizeError(error)}`
20196
+ };
20197
+ }
20198
+ }
20199
+ async function checkEventSourceMapping(region) {
20200
+ const hop = `Lambda event source mapping ${LAMBDA_FUNCTION_NAME}`;
20201
+ try {
20202
+ const { LambdaClient: LambdaClient3, ListEventSourceMappingsCommand } = await import("@aws-sdk/client-lambda");
20203
+ const client = new LambdaClient3({ region });
20204
+ const response = await client.send(
20205
+ new ListEventSourceMappingsCommand({ FunctionName: LAMBDA_FUNCTION_NAME })
20206
+ );
20207
+ const mapping = response.EventSourceMappings?.find(
20208
+ (m) => m.EventSourceArn?.endsWith(`:${QUEUE_NAME}`)
20209
+ );
20210
+ if (!mapping) {
20211
+ return {
20212
+ hop,
20213
+ status: "fail",
20214
+ details: `No event source mapping from ${QUEUE_NAME} \u2014 Lambda never runs; run \`${UPGRADE_HINT}\``
20215
+ };
20216
+ }
20217
+ if (mapping.State === "Enabled") {
20218
+ return { hop, status: "pass", details: "Mapping enabled" };
20219
+ }
20220
+ return {
20221
+ hop,
20222
+ status: "fail",
20223
+ details: `Mapping is ${mapping.State ?? "in an unknown state"} \u2014 run \`${UPGRADE_HINT}\` to re-enable`
20224
+ };
20225
+ } catch (error) {
20226
+ if (isAWSNotFoundError(error)) {
20227
+ return {
20228
+ hop,
20229
+ status: "fail",
20230
+ details: `Lambda function not found \u2014 run \`${UPGRADE_HINT}\``
20231
+ };
20232
+ }
20233
+ return {
20234
+ hop,
20235
+ status: "warn",
20236
+ details: `Could not check event source mapping: ${summarizeError(error)}`
20237
+ };
20238
+ }
20239
+ }
20240
+ async function checkHistoryTable(region) {
20241
+ const hop = `DynamoDB table ${HISTORY_TABLE_NAME}`;
20242
+ try {
20243
+ const { DynamoDBClient: DynamoDBClient7, DescribeTableCommand: DescribeTableCommand2 } = await import("@aws-sdk/client-dynamodb");
20244
+ const client = new DynamoDBClient7({ region });
20245
+ const response = await client.send(
20246
+ new DescribeTableCommand2({ TableName: HISTORY_TABLE_NAME })
20247
+ );
20248
+ const status2 = response.Table?.TableStatus;
20249
+ if (status2 === "ACTIVE") {
20250
+ return { hop, status: "pass", details: "Active" };
20251
+ }
20252
+ return {
20253
+ hop,
20254
+ status: "fail",
20255
+ details: `Table status is ${status2 ?? "unknown"} \u2014 event log may be unavailable; run \`${UPGRADE_HINT}\``
20256
+ };
20257
+ } catch (error) {
20258
+ if (isAWSNotFoundError(error)) {
20259
+ return {
20260
+ hop,
20261
+ status: "fail",
20262
+ details: `Table not found \u2014 event history is not being recorded; run \`${UPGRADE_HINT}\``
20263
+ };
20264
+ }
20265
+ return {
20266
+ hop,
20267
+ status: "warn",
20268
+ details: `Could not check table: ${summarizeError(error)}`
20269
+ };
20270
+ }
20271
+ }
20272
+ async function checkEventPipeline(params) {
20273
+ const { region, domains, expectPlatformWebhook } = params;
20274
+ const checks = [];
20275
+ const uniqueConfigSetNames = Array.from(
20276
+ new Set(domains.map((d) => domainToConfigSetName(d)))
20277
+ );
20278
+ if (uniqueConfigSetNames.length === 0) {
20279
+ const check2 = await checkConfigSet(CONFIG_SET_FALLBACK, region, false);
20280
+ if (check2) {
20281
+ checks.push(check2);
20282
+ }
20283
+ } else {
20284
+ for (const name of uniqueConfigSetNames) {
20285
+ const check2 = await checkConfigSet(name, region, false);
20286
+ if (check2) {
20287
+ checks.push(check2);
20288
+ }
20289
+ }
20290
+ const fallbackCheck = await checkConfigSet(
20291
+ CONFIG_SET_FALLBACK,
20292
+ region,
20293
+ true
20294
+ );
20295
+ if (fallbackCheck) {
20296
+ checks.push(fallbackCheck);
20297
+ }
20298
+ }
20299
+ const ruleResult = await checkRule(region);
20300
+ checks.push(ruleResult.check);
20301
+ let webhookTargetPresent = false;
20302
+ if (ruleResult.exists) {
20303
+ const targetsResult = await checkRuleTargets(region, expectPlatformWebhook);
20304
+ checks.push(...targetsResult.checks);
20305
+ webhookTargetPresent = targetsResult.webhookTargetPresent;
20306
+ }
20307
+ if (webhookTargetPresent) {
20308
+ checks.push(await checkWebhookDestination(region));
20309
+ checks.push(await checkWebhookConnection(region));
20310
+ }
20311
+ checks.push(await checkDLQ(region));
20312
+ checks.push(await checkEventSourceMapping(region));
20313
+ checks.push(await checkHistoryTable(region));
20314
+ return checks;
20315
+ }
20316
+
20317
+ // src/commands/email/doctor.ts
20318
+ init_aws();
20319
+ init_fs();
20320
+ init_json_output();
20321
+ init_metadata();
20322
+ init_output();
20323
+ init_scanner();
20324
+ function pipelineChecksToDoctorResults(pipelineChecks) {
20325
+ return pipelineChecks.map((check2) => ({
20326
+ status: check2.status,
20327
+ category: "Event Pipeline",
20328
+ name: check2.hop,
20329
+ details: check2.details
20330
+ }));
20331
+ }
19781
20332
  function runResourceDiagnostics(wrapsResources, hasStack) {
19782
20333
  const results = [];
19783
20334
  const orphanSuffix = hasStack ? void 0 : " (orphan \u2014 no Pulumi state)";
@@ -19873,11 +20424,11 @@ async function emailDoctor(options) {
19873
20424
  async () => validateAWSCredentials()
19874
20425
  );
19875
20426
  let region = options.region || await getAWSRegion();
20427
+ const emailConnections = await findConnectionsWithService(
20428
+ identity.accountId,
20429
+ "email"
20430
+ );
19876
20431
  if (!(options.region || process.env.AWS_REGION || process.env.AWS_DEFAULT_REGION)) {
19877
- const emailConnections = await findConnectionsWithService(
19878
- identity.accountId,
19879
- "email"
19880
- );
19881
20432
  if (emailConnections.length === 1) {
19882
20433
  region = emailConnections[0].region;
19883
20434
  } else if (emailConnections.length > 1 && !isJsonMode()) {
@@ -19912,8 +20463,23 @@ async function emailDoctor(options) {
19912
20463
  } catch (_error) {
19913
20464
  hasStack = false;
19914
20465
  }
20466
+ const emailConnection = emailConnections.find((c) => c.region === region);
20467
+ let pipelineResults = [];
20468
+ if (emailConnection) {
20469
+ const emailService = emailConnection.services.email;
20470
+ const domains = emailService?.config ? getAllTrackedDomains(emailConnection).map((d) => d.domain) : [];
20471
+ const expectPlatformWebhook = Boolean(emailService?.webhookSecret);
20472
+ const pipelineChecks = await progress.execute(
20473
+ "Checking event pipeline",
20474
+ async () => checkEventPipeline({ region, domains, expectPlatformWebhook })
20475
+ );
20476
+ pipelineResults = pipelineChecksToDoctorResults(pipelineChecks);
20477
+ }
19915
20478
  progress.stop();
19916
- const results = runResourceDiagnostics(wrapsResources, hasStack);
20479
+ const results = [
20480
+ ...runResourceDiagnostics(wrapsResources, hasStack),
20481
+ ...pipelineResults
20482
+ ];
19917
20483
  if (isJsonMode()) {
19918
20484
  jsonSuccess("email.doctor", {
19919
20485
  region,
@@ -19928,7 +20494,7 @@ async function emailDoctor(options) {
19928
20494
  });
19929
20495
  return;
19930
20496
  }
19931
- if (totalResources === 0) {
20497
+ if (results.length === 0) {
19932
20498
  clack19.log.info("No wraps-* resources found in this region.");
19933
20499
  clack19.outro(pc21.dim("Your AWS account is clean."));
19934
20500
  } else {
@@ -21915,7 +22481,7 @@ import {
21915
22481
  UpdateReceiptRuleCommand
21916
22482
  } from "@aws-sdk/client-ses";
21917
22483
  var RULE_SET_NAME = "wraps-inbound-rules";
21918
- var RULE_NAME = "wraps-inbound-catch-all";
22484
+ var RULE_NAME2 = "wraps-inbound-catch-all";
21919
22485
  function createSESClient(region) {
21920
22486
  return new SESClient4({ region });
21921
22487
  }
@@ -21940,13 +22506,13 @@ async function createReceiptRule(region, receivingDomain, s3BucketName) {
21940
22506
  await ses.send(
21941
22507
  new DescribeReceiptRuleCommand({
21942
22508
  RuleSetName: RULE_SET_NAME,
21943
- RuleName: RULE_NAME
22509
+ RuleName: RULE_NAME2
21944
22510
  })
21945
22511
  );
21946
22512
  await ses.send(
21947
22513
  new DeleteReceiptRuleCommand({
21948
22514
  RuleSetName: RULE_SET_NAME,
21949
- RuleName: RULE_NAME
22515
+ RuleName: RULE_NAME2
21950
22516
  })
21951
22517
  );
21952
22518
  } catch (error) {
@@ -21958,7 +22524,7 @@ async function createReceiptRule(region, receivingDomain, s3BucketName) {
21958
22524
  new CreateReceiptRuleCommand({
21959
22525
  RuleSetName: RULE_SET_NAME,
21960
22526
  Rule: {
21961
- Name: RULE_NAME,
22527
+ Name: RULE_NAME2,
21962
22528
  Enabled: true,
21963
22529
  ScanEnabled: true,
21964
22530
  // Enable spam and virus scanning
@@ -22006,7 +22572,7 @@ async function deleteReceiptRule(region) {
22006
22572
  await ses.send(
22007
22573
  new DeleteReceiptRuleCommand({
22008
22574
  RuleSetName: RULE_SET_NAME,
22009
- RuleName: RULE_NAME
22575
+ RuleName: RULE_NAME2
22010
22576
  })
22011
22577
  );
22012
22578
  } catch (error) {
@@ -22041,7 +22607,7 @@ async function addDomainToReceiptRule(region, domain, s3BucketName) {
22041
22607
  const response = await ses.send(
22042
22608
  new DescribeReceiptRuleCommand({
22043
22609
  RuleSetName: RULE_SET_NAME,
22044
- RuleName: RULE_NAME
22610
+ RuleName: RULE_NAME2
22045
22611
  })
22046
22612
  );
22047
22613
  const existingRecipients = response.Rule?.Recipients ?? [];
@@ -22052,7 +22618,7 @@ async function addDomainToReceiptRule(region, domain, s3BucketName) {
22052
22618
  new UpdateReceiptRuleCommand({
22053
22619
  RuleSetName: RULE_SET_NAME,
22054
22620
  Rule: {
22055
- Name: RULE_NAME,
22621
+ Name: RULE_NAME2,
22056
22622
  Enabled: response.Rule?.Enabled,
22057
22623
  ScanEnabled: response.Rule?.ScanEnabled,
22058
22624
  TlsPolicy: response.Rule?.TlsPolicy,
@@ -22077,7 +22643,7 @@ async function removeDomainFromReceiptRule(region, domain) {
22077
22643
  const response = await ses.send(
22078
22644
  new DescribeReceiptRuleCommand({
22079
22645
  RuleSetName: RULE_SET_NAME,
22080
- RuleName: RULE_NAME
22646
+ RuleName: RULE_NAME2
22081
22647
  })
22082
22648
  );
22083
22649
  const existingRecipients = response.Rule?.Recipients ?? [];
@@ -22090,7 +22656,7 @@ async function removeDomainFromReceiptRule(region, domain) {
22090
22656
  new UpdateReceiptRuleCommand({
22091
22657
  RuleSetName: RULE_SET_NAME,
22092
22658
  Rule: {
22093
- Name: RULE_NAME,
22659
+ Name: RULE_NAME2,
22094
22660
  Enabled: response.Rule?.Enabled,
22095
22661
  ScanEnabled: response.Rule?.ScanEnabled,
22096
22662
  TlsPolicy: response.Rule?.TlsPolicy,
@@ -22112,7 +22678,7 @@ async function getReceiptRuleDomains(region) {
22112
22678
  const response = await ses.send(
22113
22679
  new DescribeReceiptRuleCommand({
22114
22680
  RuleSetName: RULE_SET_NAME,
22115
- RuleName: RULE_NAME
22681
+ RuleName: RULE_NAME2
22116
22682
  })
22117
22683
  );
22118
22684
  return response.Rule?.Recipients ?? [];
@@ -26833,7 +27399,7 @@ init_esm_shims();
26833
27399
  // src/utils/email/template-mustache-case.ts
26834
27400
  init_esm_shims();
26835
27401
  var HANDLEBARS_BLOCK_HELPERS = /* @__PURE__ */ new Set(["if", "unless", "each", "with"]);
26836
- function normalizePlainTextMustaches(text12, canonicalVars) {
27402
+ function normalizePlainTextMustaches(text13, canonicalVars) {
26837
27403
  const canonicalByLower = /* @__PURE__ */ new Map();
26838
27404
  for (const name of canonicalVars) {
26839
27405
  canonicalByLower.set(name.toLowerCase(), name);
@@ -26841,7 +27407,7 @@ function normalizePlainTextMustaches(text12, canonicalVars) {
26841
27407
  function restoreIdentifier(name) {
26842
27408
  return canonicalByLower.get(name.toLowerCase()) ?? name;
26843
27409
  }
26844
- return text12.replace(
27410
+ return text13.replace(
26845
27411
  /\{\{([#/]?)([A-Z][A-Z0-9_]*(?:\.[A-Z0-9_]+)*)((?:\s+[A-Z0-9_.]+)*)\s*\}\}/g,
26846
27412
  (match, sigil, name, args) => {
26847
27413
  const lower = name.toLowerCase();
@@ -26891,8 +27457,8 @@ async function renderTemplateWithProxy(Component) {
26891
27457
  match = regex.exec(html);
26892
27458
  }
26893
27459
  const canonicalVars = /* @__PURE__ */ new Set([...htmlVarNames, ...accessedProps]);
26894
- const text12 = normalizePlainTextMustaches(rawText, canonicalVars);
26895
- return { html, text: text12, accessedProps };
27460
+ const text13 = normalizePlainTextMustaches(rawText, canonicalVars);
27461
+ return { html, text: text13, accessedProps };
26896
27462
  }
26897
27463
 
26898
27464
  // src/commands/email/templates/push.ts
@@ -27131,11 +27697,11 @@ async function compileTemplate(filePath, slug, source, sourceHash, wrapsDir) {
27131
27697
  "Template must have a default export (React component function)"
27132
27698
  );
27133
27699
  }
27134
- const { html, text: text12, accessedProps } = await renderTemplateWithProxy(Component);
27700
+ const { html, text: text13, accessedProps } = await renderTemplateWithProxy(Component);
27135
27701
  const variables = mergeVariables(extractVariables(html), accessedProps);
27136
27702
  const sesSubject = transformVariablesForSes(subject);
27137
27703
  const sesHtml = transformVariablesForSes(html);
27138
- const sesText = transformVariablesForSes(text12);
27704
+ const sesText = transformVariablesForSes(text13);
27139
27705
  const sesTemplateName = slug.replace(/[^a-zA-Z0-9-_]/g, "-").replace(/-+/g, "-").replace(/^-|-$/g, "").substring(0, 64);
27140
27706
  return {
27141
27707
  slug,
@@ -29538,6 +30104,28 @@ ${pc37.green("\u2713")} ${pc37.bold("Upgrade complete!")}
29538
30104
  }
29539
30105
  }
29540
30106
  await saveConnectionMetadata(metadata);
30107
+ try {
30108
+ const domains = getAllTrackedDomains(metadata).map((d) => d.domain);
30109
+ const pipelineChecks = await checkEventPipeline({
30110
+ region: outputs.region ?? region,
30111
+ domains,
30112
+ expectPlatformWebhook: Boolean(metadata.services.email?.webhookSecret)
30113
+ });
30114
+ const issues = pipelineChecks.filter((check2) => check2.status !== "pass");
30115
+ if (issues.length > 0 && !isJsonMode()) {
30116
+ for (const issue of issues) {
30117
+ clack36.log.warn(
30118
+ `Post-deploy pipeline check: ${issue.hop} \u2014 ${issue.details}`
30119
+ );
30120
+ }
30121
+ }
30122
+ } catch (error) {
30123
+ if (!isJsonMode()) {
30124
+ clack36.log.warn(
30125
+ `Post-deploy pipeline check failed to run: ${error instanceof Error ? error.message : String(error)}`
30126
+ );
30127
+ }
30128
+ }
29541
30129
  if (isJsonMode()) {
29542
30130
  jsonSuccess("email.upgrade", {
29543
30131
  upgraded: true,