@wraps.dev/cli 2.23.3 → 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.
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());
@@ -17839,8 +17839,19 @@ async function createEventBridgeResources(config2) {
17839
17839
  const target = new aws7.cloudwatch.EventTarget("wraps-email-events-target", {
17840
17840
  rule: rule.name,
17841
17841
  eventBusName,
17842
- arn: config2.queueArn
17842
+ arn: config2.queueArn,
17843
+ deadLetterConfig: { arn: config2.dlqArn }
17843
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
+ );
17844
17855
  let webhookConnection;
17845
17856
  let webhookApiDestination;
17846
17857
  let webhookTarget;
@@ -17911,7 +17922,8 @@ async function createEventBridgeResources(config2) {
17911
17922
  rule: rule.name,
17912
17923
  eventBusName,
17913
17924
  arn: webhookApiDestination.arn,
17914
- roleArn: webhookRole.arn
17925
+ roleArn: webhookRole.arn,
17926
+ deadLetterConfig: { arn: config2.dlqArn }
17915
17927
  });
17916
17928
  }
17917
17929
  let userWebhookConnection;
@@ -17929,9 +17941,33 @@ async function createEventBridgeResources(config2) {
17929
17941
  userWebhookApiDestination = userWebhookResources.apiDestination;
17930
17942
  userWebhookTarget = userWebhookResources.target;
17931
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
+ );
17932
17966
  return {
17933
17967
  rule,
17934
17968
  target,
17969
+ failedInvocationsAlarm,
17970
+ archive,
17935
17971
  webhookConnection,
17936
17972
  webhookApiDestination,
17937
17973
  webhookTarget,
@@ -18376,6 +18412,30 @@ async function createSQSResources(config2) {
18376
18412
  ...SQS_TIMEOUTS,
18377
18413
  ...dlqUrl ? { import: dlqUrl } : {}
18378
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
+ });
18379
18439
  const queueConfig = {
18380
18440
  name: queueName,
18381
18441
  visibilityTimeoutSeconds: 300,
@@ -18505,13 +18565,27 @@ async function deployEmailStack(config2) {
18505
18565
  }
18506
18566
  let sqsResources;
18507
18567
  if (emailConfig.eventTracking?.enabled) {
18508
- 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
+ });
18509
18581
  }
18510
18582
  if (emailConfig.eventTracking?.enabled && sesResources && sqsResources) {
18511
18583
  await createEventBridgeResources({
18512
18584
  eventBusArn: sesResources.eventBus.arn,
18513
18585
  queueArn: sqsResources.queue.arn,
18514
18586
  queueUrl: sqsResources.queue.url,
18587
+ dlqArn: sqsResources.dlq.arn,
18588
+ alertTopicArn: alertingResources?.topic.arn,
18515
18589
  // Include webhook config if provided (for Wraps platform integration)
18516
18590
  webhook: config2.webhook,
18517
18591
  // Include user webhook config if provided
@@ -18552,15 +18626,6 @@ async function deployEmailStack(config2) {
18552
18626
  region: config2.region
18553
18627
  });
18554
18628
  }
18555
- let alertingResources;
18556
- if (emailConfig.alerts?.enabled) {
18557
- alertingResources = await createAlertingResources({
18558
- alertConfig: emailConfig.alerts,
18559
- configSetName: sesResources?.configSet.configurationSetName,
18560
- dlqName: sqsResources ? "wraps-email-events-dlq" : void 0,
18561
- region: config2.region
18562
- });
18563
- }
18564
18629
  let inboundResources;
18565
18630
  let replySecretsOutput;
18566
18631
  if (emailConfig.inbound?.enabled) {
@@ -19509,6 +19574,26 @@ async function emailDestroy(options) {
19509
19574
  const domain = emailConfig?.domain;
19510
19575
  const storedStackName = emailService?.pulumiStackName;
19511
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");
19512
19597
  const confirmed = await clack18.confirm({
19513
19598
  message: pc20.red(
19514
19599
  "Are you sure you want to destroy all email infrastructure?"
@@ -19519,6 +19604,19 @@ async function emailDestroy(options) {
19519
19604
  clack18.cancel("Destruction cancelled.");
19520
19605
  process.exit(0);
19521
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
+ );
19522
19620
  }
19523
19621
  let shouldCleanDNS = false;
19524
19622
  let hostedZone = null;
@@ -19764,12 +19862,6 @@ Run ${pc20.cyan("wraps email init")} to deploy infrastructure again.
19764
19862
  // src/commands/email/doctor.ts
19765
19863
  init_esm_shims();
19766
19864
  init_events();
19767
- init_aws();
19768
- init_fs();
19769
- init_json_output();
19770
- init_metadata();
19771
- init_output();
19772
- init_scanner();
19773
19865
  import { DeleteTableCommand, DynamoDBClient as DynamoDBClient2 } from "@aws-sdk/client-dynamodb";
19774
19866
  import {
19775
19867
  DeleteRoleCommand,
@@ -19785,6 +19877,458 @@ import { DeleteTopicCommand, SNSClient as SNSClient2 } from "@aws-sdk/client-sns
19785
19877
  import * as clack19 from "@clack/prompts";
19786
19878
  import * as pulumi17 from "@pulumi/pulumi";
19787
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
+ }
19788
20332
  function runResourceDiagnostics(wrapsResources, hasStack) {
19789
20333
  const results = [];
19790
20334
  const orphanSuffix = hasStack ? void 0 : " (orphan \u2014 no Pulumi state)";
@@ -19880,11 +20424,11 @@ async function emailDoctor(options) {
19880
20424
  async () => validateAWSCredentials()
19881
20425
  );
19882
20426
  let region = options.region || await getAWSRegion();
20427
+ const emailConnections = await findConnectionsWithService(
20428
+ identity.accountId,
20429
+ "email"
20430
+ );
19883
20431
  if (!(options.region || process.env.AWS_REGION || process.env.AWS_DEFAULT_REGION)) {
19884
- const emailConnections = await findConnectionsWithService(
19885
- identity.accountId,
19886
- "email"
19887
- );
19888
20432
  if (emailConnections.length === 1) {
19889
20433
  region = emailConnections[0].region;
19890
20434
  } else if (emailConnections.length > 1 && !isJsonMode()) {
@@ -19919,8 +20463,23 @@ async function emailDoctor(options) {
19919
20463
  } catch (_error) {
19920
20464
  hasStack = false;
19921
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
+ }
19922
20478
  progress.stop();
19923
- const results = runResourceDiagnostics(wrapsResources, hasStack);
20479
+ const results = [
20480
+ ...runResourceDiagnostics(wrapsResources, hasStack),
20481
+ ...pipelineResults
20482
+ ];
19924
20483
  if (isJsonMode()) {
19925
20484
  jsonSuccess("email.doctor", {
19926
20485
  region,
@@ -19935,7 +20494,7 @@ async function emailDoctor(options) {
19935
20494
  });
19936
20495
  return;
19937
20496
  }
19938
- if (totalResources === 0) {
20497
+ if (results.length === 0) {
19939
20498
  clack19.log.info("No wraps-* resources found in this region.");
19940
20499
  clack19.outro(pc21.dim("Your AWS account is clean."));
19941
20500
  } else {
@@ -21922,7 +22481,7 @@ import {
21922
22481
  UpdateReceiptRuleCommand
21923
22482
  } from "@aws-sdk/client-ses";
21924
22483
  var RULE_SET_NAME = "wraps-inbound-rules";
21925
- var RULE_NAME = "wraps-inbound-catch-all";
22484
+ var RULE_NAME2 = "wraps-inbound-catch-all";
21926
22485
  function createSESClient(region) {
21927
22486
  return new SESClient4({ region });
21928
22487
  }
@@ -21947,13 +22506,13 @@ async function createReceiptRule(region, receivingDomain, s3BucketName) {
21947
22506
  await ses.send(
21948
22507
  new DescribeReceiptRuleCommand({
21949
22508
  RuleSetName: RULE_SET_NAME,
21950
- RuleName: RULE_NAME
22509
+ RuleName: RULE_NAME2
21951
22510
  })
21952
22511
  );
21953
22512
  await ses.send(
21954
22513
  new DeleteReceiptRuleCommand({
21955
22514
  RuleSetName: RULE_SET_NAME,
21956
- RuleName: RULE_NAME
22515
+ RuleName: RULE_NAME2
21957
22516
  })
21958
22517
  );
21959
22518
  } catch (error) {
@@ -21965,7 +22524,7 @@ async function createReceiptRule(region, receivingDomain, s3BucketName) {
21965
22524
  new CreateReceiptRuleCommand({
21966
22525
  RuleSetName: RULE_SET_NAME,
21967
22526
  Rule: {
21968
- Name: RULE_NAME,
22527
+ Name: RULE_NAME2,
21969
22528
  Enabled: true,
21970
22529
  ScanEnabled: true,
21971
22530
  // Enable spam and virus scanning
@@ -22013,7 +22572,7 @@ async function deleteReceiptRule(region) {
22013
22572
  await ses.send(
22014
22573
  new DeleteReceiptRuleCommand({
22015
22574
  RuleSetName: RULE_SET_NAME,
22016
- RuleName: RULE_NAME
22575
+ RuleName: RULE_NAME2
22017
22576
  })
22018
22577
  );
22019
22578
  } catch (error) {
@@ -22048,7 +22607,7 @@ async function addDomainToReceiptRule(region, domain, s3BucketName) {
22048
22607
  const response = await ses.send(
22049
22608
  new DescribeReceiptRuleCommand({
22050
22609
  RuleSetName: RULE_SET_NAME,
22051
- RuleName: RULE_NAME
22610
+ RuleName: RULE_NAME2
22052
22611
  })
22053
22612
  );
22054
22613
  const existingRecipients = response.Rule?.Recipients ?? [];
@@ -22059,7 +22618,7 @@ async function addDomainToReceiptRule(region, domain, s3BucketName) {
22059
22618
  new UpdateReceiptRuleCommand({
22060
22619
  RuleSetName: RULE_SET_NAME,
22061
22620
  Rule: {
22062
- Name: RULE_NAME,
22621
+ Name: RULE_NAME2,
22063
22622
  Enabled: response.Rule?.Enabled,
22064
22623
  ScanEnabled: response.Rule?.ScanEnabled,
22065
22624
  TlsPolicy: response.Rule?.TlsPolicy,
@@ -22084,7 +22643,7 @@ async function removeDomainFromReceiptRule(region, domain) {
22084
22643
  const response = await ses.send(
22085
22644
  new DescribeReceiptRuleCommand({
22086
22645
  RuleSetName: RULE_SET_NAME,
22087
- RuleName: RULE_NAME
22646
+ RuleName: RULE_NAME2
22088
22647
  })
22089
22648
  );
22090
22649
  const existingRecipients = response.Rule?.Recipients ?? [];
@@ -22097,7 +22656,7 @@ async function removeDomainFromReceiptRule(region, domain) {
22097
22656
  new UpdateReceiptRuleCommand({
22098
22657
  RuleSetName: RULE_SET_NAME,
22099
22658
  Rule: {
22100
- Name: RULE_NAME,
22659
+ Name: RULE_NAME2,
22101
22660
  Enabled: response.Rule?.Enabled,
22102
22661
  ScanEnabled: response.Rule?.ScanEnabled,
22103
22662
  TlsPolicy: response.Rule?.TlsPolicy,
@@ -22119,7 +22678,7 @@ async function getReceiptRuleDomains(region) {
22119
22678
  const response = await ses.send(
22120
22679
  new DescribeReceiptRuleCommand({
22121
22680
  RuleSetName: RULE_SET_NAME,
22122
- RuleName: RULE_NAME
22681
+ RuleName: RULE_NAME2
22123
22682
  })
22124
22683
  );
22125
22684
  return response.Rule?.Recipients ?? [];
@@ -26840,7 +27399,7 @@ init_esm_shims();
26840
27399
  // src/utils/email/template-mustache-case.ts
26841
27400
  init_esm_shims();
26842
27401
  var HANDLEBARS_BLOCK_HELPERS = /* @__PURE__ */ new Set(["if", "unless", "each", "with"]);
26843
- function normalizePlainTextMustaches(text12, canonicalVars) {
27402
+ function normalizePlainTextMustaches(text13, canonicalVars) {
26844
27403
  const canonicalByLower = /* @__PURE__ */ new Map();
26845
27404
  for (const name of canonicalVars) {
26846
27405
  canonicalByLower.set(name.toLowerCase(), name);
@@ -26848,7 +27407,7 @@ function normalizePlainTextMustaches(text12, canonicalVars) {
26848
27407
  function restoreIdentifier(name) {
26849
27408
  return canonicalByLower.get(name.toLowerCase()) ?? name;
26850
27409
  }
26851
- return text12.replace(
27410
+ return text13.replace(
26852
27411
  /\{\{([#/]?)([A-Z][A-Z0-9_]*(?:\.[A-Z0-9_]+)*)((?:\s+[A-Z0-9_.]+)*)\s*\}\}/g,
26853
27412
  (match, sigil, name, args) => {
26854
27413
  const lower = name.toLowerCase();
@@ -26898,8 +27457,8 @@ async function renderTemplateWithProxy(Component) {
26898
27457
  match = regex.exec(html);
26899
27458
  }
26900
27459
  const canonicalVars = /* @__PURE__ */ new Set([...htmlVarNames, ...accessedProps]);
26901
- const text12 = normalizePlainTextMustaches(rawText, canonicalVars);
26902
- return { html, text: text12, accessedProps };
27460
+ const text13 = normalizePlainTextMustaches(rawText, canonicalVars);
27461
+ return { html, text: text13, accessedProps };
26903
27462
  }
26904
27463
 
26905
27464
  // src/commands/email/templates/push.ts
@@ -27138,11 +27697,11 @@ async function compileTemplate(filePath, slug, source, sourceHash, wrapsDir) {
27138
27697
  "Template must have a default export (React component function)"
27139
27698
  );
27140
27699
  }
27141
- const { html, text: text12, accessedProps } = await renderTemplateWithProxy(Component);
27700
+ const { html, text: text13, accessedProps } = await renderTemplateWithProxy(Component);
27142
27701
  const variables = mergeVariables(extractVariables(html), accessedProps);
27143
27702
  const sesSubject = transformVariablesForSes(subject);
27144
27703
  const sesHtml = transformVariablesForSes(html);
27145
- const sesText = transformVariablesForSes(text12);
27704
+ const sesText = transformVariablesForSes(text13);
27146
27705
  const sesTemplateName = slug.replace(/[^a-zA-Z0-9-_]/g, "-").replace(/-+/g, "-").replace(/^-|-$/g, "").substring(0, 64);
27147
27706
  return {
27148
27707
  slug,
@@ -29545,6 +30104,28 @@ ${pc37.green("\u2713")} ${pc37.bold("Upgrade complete!")}
29545
30104
  }
29546
30105
  }
29547
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
+ }
29548
30129
  if (isJsonMode()) {
29549
30130
  jsonSuccess("email.upgrade", {
29550
30131
  upgraded: true,