@wraps.dev/cli 1.0.0 → 1.1.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
@@ -18,6 +18,356 @@ var init_esm_shims = __esm({
18
18
  }
19
19
  });
20
20
 
21
+ // src/utils/email/route53.ts
22
+ var route53_exports = {};
23
+ __export(route53_exports, {
24
+ createDNSRecords: () => createDNSRecords,
25
+ findHostedZone: () => findHostedZone
26
+ });
27
+ import {
28
+ ChangeResourceRecordSetsCommand,
29
+ ListHostedZonesByNameCommand,
30
+ Route53Client
31
+ } from "@aws-sdk/client-route-53";
32
+ async function findHostedZone(domain, region) {
33
+ const client = new Route53Client({ region });
34
+ try {
35
+ const response = await client.send(
36
+ new ListHostedZonesByNameCommand({
37
+ DNSName: domain,
38
+ MaxItems: 1
39
+ })
40
+ );
41
+ const zone = response.HostedZones?.[0];
42
+ if (zone && zone.Name === `${domain}.` && zone.Id) {
43
+ return {
44
+ id: zone.Id.replace("/hostedzone/", ""),
45
+ name: zone.Name
46
+ };
47
+ }
48
+ } catch (_error) {
49
+ }
50
+ const parts = domain.split(".");
51
+ if (parts.length > 2) {
52
+ const parentDomain = parts.slice(1).join(".");
53
+ return findHostedZone(parentDomain, region);
54
+ }
55
+ return null;
56
+ }
57
+ async function createDNSRecords(hostedZoneId, domain, dkimTokens, region, customTrackingDomain, mailFromDomain, cloudFrontDomain) {
58
+ const client = new Route53Client({ region });
59
+ const changes = [];
60
+ for (const token of dkimTokens) {
61
+ changes.push({
62
+ Action: "UPSERT",
63
+ ResourceRecordSet: {
64
+ Name: `${token}._domainkey.${domain}`,
65
+ Type: "CNAME",
66
+ TTL: 1800,
67
+ ResourceRecords: [{ Value: `${token}.dkim.amazonses.com` }]
68
+ }
69
+ });
70
+ }
71
+ changes.push({
72
+ Action: "UPSERT",
73
+ ResourceRecordSet: {
74
+ Name: domain,
75
+ Type: "TXT",
76
+ TTL: 1800,
77
+ ResourceRecords: [{ Value: '"v=spf1 include:amazonses.com ~all"' }]
78
+ }
79
+ });
80
+ changes.push({
81
+ Action: "UPSERT",
82
+ ResourceRecordSet: {
83
+ Name: `_dmarc.${domain}`,
84
+ Type: "TXT",
85
+ TTL: 1800,
86
+ ResourceRecords: [
87
+ { Value: `"v=DMARC1; p=quarantine; rua=mailto:postmaster@${domain}"` }
88
+ ]
89
+ }
90
+ });
91
+ if (customTrackingDomain) {
92
+ const targetDomain = cloudFrontDomain || `r.${region}.awstrack.me`;
93
+ changes.push({
94
+ Action: "UPSERT",
95
+ ResourceRecordSet: {
96
+ Name: customTrackingDomain,
97
+ Type: "CNAME",
98
+ TTL: 1800,
99
+ ResourceRecords: [{ Value: targetDomain }]
100
+ }
101
+ });
102
+ }
103
+ if (mailFromDomain) {
104
+ changes.push({
105
+ Action: "UPSERT",
106
+ ResourceRecordSet: {
107
+ Name: mailFromDomain,
108
+ Type: "MX",
109
+ TTL: 1800,
110
+ ResourceRecords: [
111
+ { Value: `10 feedback-smtp.${region}.amazonses.com` }
112
+ ]
113
+ }
114
+ });
115
+ changes.push({
116
+ Action: "UPSERT",
117
+ ResourceRecordSet: {
118
+ Name: mailFromDomain,
119
+ Type: "TXT",
120
+ TTL: 1800,
121
+ ResourceRecords: [{ Value: '"v=spf1 include:amazonses.com ~all"' }]
122
+ }
123
+ });
124
+ }
125
+ await client.send(
126
+ new ChangeResourceRecordSetsCommand({
127
+ HostedZoneId: hostedZoneId,
128
+ ChangeBatch: {
129
+ Changes: changes
130
+ }
131
+ })
132
+ );
133
+ }
134
+ var init_route53 = __esm({
135
+ "src/utils/email/route53.ts"() {
136
+ "use strict";
137
+ init_esm_shims();
138
+ }
139
+ });
140
+
141
+ // src/infrastructure/resources/acm.ts
142
+ var acm_exports = {};
143
+ __export(acm_exports, {
144
+ createACMCertificate: () => createACMCertificate
145
+ });
146
+ import * as aws8 from "@pulumi/aws";
147
+ async function createACMCertificate(config2) {
148
+ const usEast1Provider = new aws8.Provider("acm-us-east-1", {
149
+ region: "us-east-1"
150
+ });
151
+ const certificate = new aws8.acm.Certificate(
152
+ "wraps-email-tracking-cert",
153
+ {
154
+ domainName: config2.domain,
155
+ validationMethod: "DNS",
156
+ tags: {
157
+ ManagedBy: "wraps-cli",
158
+ Description: "SSL certificate for Wraps email tracking domain"
159
+ }
160
+ },
161
+ {
162
+ provider: usEast1Provider
163
+ }
164
+ );
165
+ const validationRecords = certificate.domainValidationOptions.apply(
166
+ (options) => options.map((option) => ({
167
+ name: option.resourceRecordName,
168
+ type: option.resourceRecordType,
169
+ value: option.resourceRecordValue
170
+ }))
171
+ );
172
+ let certificateValidation;
173
+ if (config2.hostedZoneId) {
174
+ const validationRecord = new aws8.route53.Record(
175
+ "wraps-email-tracking-cert-validation",
176
+ {
177
+ zoneId: config2.hostedZoneId,
178
+ name: certificate.domainValidationOptions[0].resourceRecordName,
179
+ type: certificate.domainValidationOptions[0].resourceRecordType,
180
+ records: [certificate.domainValidationOptions[0].resourceRecordValue],
181
+ ttl: 60
182
+ }
183
+ );
184
+ certificateValidation = new aws8.acm.CertificateValidation(
185
+ "wraps-email-tracking-cert-validation-waiter",
186
+ {
187
+ certificateArn: certificate.arn,
188
+ validationRecordFqdns: [validationRecord.fqdn]
189
+ },
190
+ {
191
+ provider: usEast1Provider
192
+ }
193
+ );
194
+ }
195
+ return {
196
+ certificate,
197
+ certificateValidation,
198
+ validationRecords
199
+ };
200
+ }
201
+ var init_acm = __esm({
202
+ "src/infrastructure/resources/acm.ts"() {
203
+ "use strict";
204
+ init_esm_shims();
205
+ }
206
+ });
207
+
208
+ // src/infrastructure/resources/cloudfront.ts
209
+ var cloudfront_exports = {};
210
+ __export(cloudfront_exports, {
211
+ createCloudFrontTracking: () => createCloudFrontTracking
212
+ });
213
+ import * as aws9 from "@pulumi/aws";
214
+ async function findDistributionByAlias(alias) {
215
+ try {
216
+ const { CloudFrontClient, ListDistributionsCommand } = await import("@aws-sdk/client-cloudfront");
217
+ const cloudfront2 = new CloudFrontClient({ region: "us-east-1" });
218
+ const response = await cloudfront2.send(new ListDistributionsCommand({}));
219
+ const distribution = response.DistributionList?.Items?.find(
220
+ (dist) => dist.Aliases?.Items?.includes(alias)
221
+ );
222
+ return distribution?.Id || null;
223
+ } catch (error) {
224
+ console.error("Error finding CloudFront distribution:", error);
225
+ return null;
226
+ }
227
+ }
228
+ async function createWAFWebACL() {
229
+ const usEast1Provider = new aws9.Provider("waf-us-east-1", {
230
+ region: "us-east-1"
231
+ });
232
+ const webAcl = new aws9.wafv2.WebAcl(
233
+ "wraps-email-tracking-waf",
234
+ {
235
+ scope: "CLOUDFRONT",
236
+ // WAF for CloudFront must use CLOUDFRONT scope
237
+ description: "Rate limiting protection for Wraps email tracking",
238
+ defaultAction: {
239
+ allow: {}
240
+ // Allow by default
241
+ },
242
+ rules: [
243
+ {
244
+ name: "RateLimitRule",
245
+ priority: 1,
246
+ action: {
247
+ block: {}
248
+ // Block requests exceeding rate limit
249
+ },
250
+ statement: {
251
+ rateBasedStatement: {
252
+ limit: 2e3,
253
+ // 2000 requests per 5 minutes per IP
254
+ aggregateKeyType: "IP"
255
+ }
256
+ },
257
+ visibilityConfig: {
258
+ sampledRequestsEnabled: true,
259
+ cloudwatchMetricsEnabled: true,
260
+ metricName: "RateLimitRule"
261
+ }
262
+ }
263
+ ],
264
+ visibilityConfig: {
265
+ sampledRequestsEnabled: true,
266
+ cloudwatchMetricsEnabled: true,
267
+ metricName: "wraps-email-tracking-waf"
268
+ },
269
+ tags: {
270
+ ManagedBy: "wraps-cli",
271
+ Description: "WAF for Wraps email tracking with rate limiting"
272
+ }
273
+ },
274
+ {
275
+ provider: usEast1Provider
276
+ }
277
+ );
278
+ return webAcl;
279
+ }
280
+ async function createCloudFrontTracking(config2) {
281
+ const sesTrackingOrigin = `r.${config2.region}.awstrack.me`;
282
+ const webAcl = await createWAFWebACL();
283
+ const existingDistributionId = await findDistributionByAlias(
284
+ config2.customTrackingDomain
285
+ );
286
+ const distributionConfig = {
287
+ enabled: true,
288
+ comment: "Wraps email tracking with HTTPS support",
289
+ aliases: [config2.customTrackingDomain],
290
+ // Attach WAF Web ACL for rate limiting protection
291
+ webAclId: webAcl.arn,
292
+ // Origin: SES tracking endpoint
293
+ origins: [
294
+ {
295
+ domainName: sesTrackingOrigin,
296
+ originId: "ses-tracking",
297
+ customOriginConfig: {
298
+ httpPort: 80,
299
+ httpsPort: 443,
300
+ originProtocolPolicy: "http-only",
301
+ // SES tracking endpoint is HTTP
302
+ originSslProtocols: ["TLSv1.2"]
303
+ }
304
+ }
305
+ ],
306
+ // Default cache behavior
307
+ defaultCacheBehavior: {
308
+ targetOriginId: "ses-tracking",
309
+ viewerProtocolPolicy: "redirect-to-https",
310
+ // Force HTTPS
311
+ allowedMethods: ["GET", "HEAD", "OPTIONS"],
312
+ cachedMethods: ["GET", "HEAD"],
313
+ // Forward all query strings and headers (tracking links use query params)
314
+ forwardedValues: {
315
+ queryString: true,
316
+ cookies: {
317
+ forward: "all"
318
+ },
319
+ headers: ["*"]
320
+ // Forward all headers to preserve tracking functionality
321
+ },
322
+ // Minimal caching for tracking redirects
323
+ minTtl: 0,
324
+ defaultTtl: 0,
325
+ maxTtl: 31536e3,
326
+ compress: true
327
+ },
328
+ // Price class (use only North America & Europe for cost optimization)
329
+ priceClass: "PriceClass_100",
330
+ // Restrictions (none)
331
+ restrictions: {
332
+ geoRestriction: {
333
+ restrictionType: "none"
334
+ }
335
+ },
336
+ // SSL certificate from ACM
337
+ viewerCertificate: {
338
+ acmCertificateArn: config2.certificateArn,
339
+ sslSupportMethod: "sni-only",
340
+ minimumProtocolVersion: "TLSv1.2_2021"
341
+ },
342
+ tags: {
343
+ ManagedBy: "wraps-cli",
344
+ Description: "Wraps email tracking CloudFront distribution"
345
+ }
346
+ };
347
+ const distribution = existingDistributionId ? new aws9.cloudfront.Distribution(
348
+ "wraps-email-tracking-cdn",
349
+ distributionConfig,
350
+ {
351
+ import: existingDistributionId
352
+ // Import existing distribution
353
+ }
354
+ ) : new aws9.cloudfront.Distribution(
355
+ "wraps-email-tracking-cdn",
356
+ distributionConfig
357
+ );
358
+ return {
359
+ distribution,
360
+ domainName: distribution.domainName,
361
+ webAcl
362
+ };
363
+ }
364
+ var init_cloudfront = __esm({
365
+ "src/infrastructure/resources/cloudfront.ts"() {
366
+ "use strict";
367
+ init_esm_shims();
368
+ }
369
+ });
370
+
21
371
  // src/infrastructure/resources/mail-manager.ts
22
372
  var mail_manager_exports = {};
23
373
  __export(mail_manager_exports, {
@@ -224,14 +574,14 @@ var init_errors = __esm({
224
574
  "AWS credentials not found",
225
575
  "NO_AWS_CREDENTIALS",
226
576
  "Run: aws configure\nOr set AWS_PROFILE environment variable",
227
- "https://docs.wraps.dev/setup/aws-credentials"
577
+ "https://wraps.dev/docs/setup/aws-credentials"
228
578
  ),
229
579
  stackExists: (stackName) => new WrapsError(
230
580
  `Stack "${stackName}" already exists`,
231
581
  "STACK_EXISTS",
232
582
  `To update: wraps upgrade
233
583
  To remove: wraps destroy --stack ${stackName}`,
234
- "https://docs.wraps.dev/cli/upgrade"
584
+ "https://wraps.dev/docs/cli/upgrade"
235
585
  ),
236
586
  invalidRegion: (region) => new WrapsError(
237
587
  `Invalid AWS region: ${region}`,
@@ -243,13 +593,13 @@ To remove: wraps destroy --stack ${stackName}`,
243
593
  `Infrastructure deployment failed: ${message}`,
244
594
  "PULUMI_ERROR",
245
595
  "Check your AWS permissions and try again",
246
- "https://docs.wraps.dev/troubleshooting"
596
+ "https://wraps.dev/docs/troubleshooting"
247
597
  ),
248
598
  noStack: () => new WrapsError(
249
599
  "No Wraps infrastructure found in this AWS account",
250
600
  "NO_STACK",
251
601
  "Run: wraps init\nTo deploy new infrastructure",
252
- "https://docs.wraps.dev/cli/init"
602
+ "https://wraps.dev/docs/cli/init"
253
603
  ),
254
604
  pulumiNotInstalled: () => new WrapsError(
255
605
  "Pulumi CLI is not installed",
@@ -265,11 +615,13 @@ To remove: wraps destroy --stack ${stackName}`,
265
615
  var aws_exports = {};
266
616
  __export(aws_exports, {
267
617
  checkRegion: () => checkRegion,
618
+ getACMCertificateStatus: () => getACMCertificateStatus,
268
619
  getAWSRegion: () => getAWSRegion,
269
620
  isSESSandbox: () => isSESSandbox,
270
621
  listSESDomains: () => listSESDomains,
271
622
  validateAWSCredentials: () => validateAWSCredentials
272
623
  });
624
+ import { ACMClient, DescribeCertificateCommand } from "@aws-sdk/client-acm";
273
625
  import {
274
626
  GetIdentityVerificationAttributesCommand,
275
627
  ListIdentitiesCommand,
@@ -368,6 +720,33 @@ async function isSESSandbox(region) {
368
720
  throw error;
369
721
  }
370
722
  }
723
+ async function getACMCertificateStatus(certificateArn) {
724
+ const acm2 = new ACMClient({ region: "us-east-1" });
725
+ try {
726
+ const response = await acm2.send(
727
+ new DescribeCertificateCommand({
728
+ CertificateArn: certificateArn
729
+ })
730
+ );
731
+ const certificate = response.Certificate;
732
+ if (!certificate) {
733
+ return null;
734
+ }
735
+ const validationRecords = certificate.DomainValidationOptions?.map((option) => ({
736
+ name: option.ResourceRecord?.Name || "",
737
+ type: option.ResourceRecord?.Type || "",
738
+ value: option.ResourceRecord?.Value || ""
739
+ })) || [];
740
+ return {
741
+ status: certificate.Status || "UNKNOWN",
742
+ domainName: certificate.DomainName || "",
743
+ validationRecords
744
+ };
745
+ } catch (error) {
746
+ console.error("Error getting ACM certificate status:", error);
747
+ return null;
748
+ }
749
+ }
371
750
  var init_aws = __esm({
372
751
  "src/utils/shared/aws.ts"() {
373
752
  "use strict";
@@ -1469,125 +1848,10 @@ async function promptCustomConfig() {
1469
1848
  } : { enabled: false, retention: "90days" },
1470
1849
  dedicatedIp,
1471
1850
  sendingEnabled: true
1472
- };
1473
- }
1474
- var init_prompts = __esm({
1475
- "src/utils/shared/prompts.ts"() {
1476
- "use strict";
1477
- init_esm_shims();
1478
- }
1479
- });
1480
-
1481
- // src/utils/email/route53.ts
1482
- var route53_exports = {};
1483
- __export(route53_exports, {
1484
- createDNSRecords: () => createDNSRecords,
1485
- findHostedZone: () => findHostedZone
1486
- });
1487
- import {
1488
- ChangeResourceRecordSetsCommand,
1489
- ListHostedZonesByNameCommand,
1490
- Route53Client
1491
- } from "@aws-sdk/client-route-53";
1492
- async function findHostedZone(domain, region) {
1493
- const client = new Route53Client({ region });
1494
- try {
1495
- const response = await client.send(
1496
- new ListHostedZonesByNameCommand({
1497
- DNSName: domain,
1498
- MaxItems: 1
1499
- })
1500
- );
1501
- const zone = response.HostedZones?.[0];
1502
- if (zone && zone.Name === `${domain}.` && zone.Id) {
1503
- return {
1504
- id: zone.Id.replace("/hostedzone/", ""),
1505
- name: zone.Name
1506
- };
1507
- }
1508
- return null;
1509
- } catch (_error) {
1510
- return null;
1511
- }
1512
- }
1513
- async function createDNSRecords(hostedZoneId, domain, dkimTokens, region, customTrackingDomain, mailFromDomain) {
1514
- const client = new Route53Client({ region });
1515
- const changes = [];
1516
- for (const token of dkimTokens) {
1517
- changes.push({
1518
- Action: "UPSERT",
1519
- ResourceRecordSet: {
1520
- Name: `${token}._domainkey.${domain}`,
1521
- Type: "CNAME",
1522
- TTL: 1800,
1523
- ResourceRecords: [{ Value: `${token}.dkim.amazonses.com` }]
1524
- }
1525
- });
1526
- }
1527
- changes.push({
1528
- Action: "UPSERT",
1529
- ResourceRecordSet: {
1530
- Name: domain,
1531
- Type: "TXT",
1532
- TTL: 1800,
1533
- ResourceRecords: [{ Value: '"v=spf1 include:amazonses.com ~all"' }]
1534
- }
1535
- });
1536
- changes.push({
1537
- Action: "UPSERT",
1538
- ResourceRecordSet: {
1539
- Name: `_dmarc.${domain}`,
1540
- Type: "TXT",
1541
- TTL: 1800,
1542
- ResourceRecords: [
1543
- { Value: `"v=DMARC1; p=quarantine; rua=mailto:postmaster@${domain}"` }
1544
- ]
1545
- }
1546
- });
1547
- if (customTrackingDomain) {
1548
- changes.push({
1549
- Action: "UPSERT",
1550
- ResourceRecordSet: {
1551
- Name: customTrackingDomain,
1552
- Type: "CNAME",
1553
- TTL: 1800,
1554
- ResourceRecords: [{ Value: `r.${region}.awstrack.me` }]
1555
- }
1556
- });
1557
- }
1558
- if (mailFromDomain) {
1559
- changes.push({
1560
- Action: "UPSERT",
1561
- ResourceRecordSet: {
1562
- Name: mailFromDomain,
1563
- Type: "MX",
1564
- TTL: 1800,
1565
- ResourceRecords: [
1566
- { Value: `10 feedback-smtp.${region}.amazonses.com` }
1567
- ]
1568
- }
1569
- });
1570
- changes.push({
1571
- Action: "UPSERT",
1572
- ResourceRecordSet: {
1573
- Name: mailFromDomain,
1574
- Type: "TXT",
1575
- TTL: 1800,
1576
- ResourceRecords: [{ Value: '"v=spf1 include:amazonses.com ~all"' }]
1577
- }
1578
- });
1579
- }
1580
- await client.send(
1581
- new ChangeResourceRecordSetsCommand({
1582
- HostedZoneId: hostedZoneId,
1583
- ChangeBatch: {
1584
- Changes: changes
1585
- }
1586
- })
1587
- );
1851
+ };
1588
1852
  }
1589
- var init_route53 = __esm({
1590
- "src/utils/email/route53.ts"() {
1853
+ var init_prompts = __esm({
1854
+ "src/utils/shared/prompts.ts"() {
1591
1855
  "use strict";
1592
1856
  init_esm_shims();
1593
1857
  }
@@ -1895,15 +2159,63 @@ import pc3 from "picocolors";
1895
2159
 
1896
2160
  // src/infrastructure/email-stack.ts
1897
2161
  init_esm_shims();
1898
- import * as aws8 from "@pulumi/aws";
2162
+ import * as aws10 from "@pulumi/aws";
1899
2163
  import * as pulumi4 from "@pulumi/pulumi";
1900
2164
 
1901
2165
  // src/infrastructure/resources/dynamodb.ts
1902
2166
  init_esm_shims();
1903
2167
  import * as aws from "@pulumi/aws";
2168
+ async function tableExists(tableName) {
2169
+ try {
2170
+ const { DynamoDBClient: DynamoDBClient4, DescribeTableCommand: DescribeTableCommand2 } = await import("@aws-sdk/client-dynamodb");
2171
+ const dynamodb2 = new DynamoDBClient4({});
2172
+ await dynamodb2.send(new DescribeTableCommand2({ TableName: tableName }));
2173
+ return true;
2174
+ } catch (error) {
2175
+ if (error.name === "ResourceNotFoundException") {
2176
+ return false;
2177
+ }
2178
+ console.error("Error checking for existing DynamoDB table:", error);
2179
+ return false;
2180
+ }
2181
+ }
1904
2182
  async function createDynamoDBTables(_config) {
1905
- const emailHistory = new aws.dynamodb.Table("wraps-email-history", {
1906
- name: "wraps-email-history",
2183
+ const tableName = "wraps-email-history";
2184
+ const exists = await tableExists(tableName);
2185
+ const emailHistory = exists ? new aws.dynamodb.Table(
2186
+ tableName,
2187
+ {
2188
+ name: tableName,
2189
+ billingMode: "PAY_PER_REQUEST",
2190
+ hashKey: "messageId",
2191
+ rangeKey: "sentAt",
2192
+ attributes: [
2193
+ { name: "messageId", type: "S" },
2194
+ { name: "sentAt", type: "N" },
2195
+ { name: "accountId", type: "S" }
2196
+ ],
2197
+ globalSecondaryIndexes: [
2198
+ {
2199
+ name: "accountId-sentAt-index",
2200
+ hashKey: "accountId",
2201
+ rangeKey: "sentAt",
2202
+ projectionType: "ALL"
2203
+ }
2204
+ ],
2205
+ ttl: {
2206
+ enabled: true,
2207
+ attributeName: "expiresAt"
2208
+ },
2209
+ tags: {
2210
+ ManagedBy: "wraps-cli"
2211
+ }
2212
+ },
2213
+ {
2214
+ import: tableName
2215
+ // Import existing table
2216
+ }
2217
+ ) : new aws.dynamodb.Table(tableName, {
2218
+ name: tableName,
1907
2219
  billingMode: "PAY_PER_REQUEST",
1908
2220
  hashKey: "messageId",
1909
2221
  rangeKey: "sentAt",
@@ -1990,6 +2302,20 @@ async function createEventBridgeResources(config2) {
1990
2302
  init_esm_shims();
1991
2303
  import * as aws3 from "@pulumi/aws";
1992
2304
  import * as pulumi2 from "@pulumi/pulumi";
2305
+ async function roleExists(roleName) {
2306
+ try {
2307
+ const { IAMClient: IAMClient2, GetRoleCommand } = await import("@aws-sdk/client-iam");
2308
+ const iam4 = new IAMClient2({});
2309
+ await iam4.send(new GetRoleCommand({ RoleName: roleName }));
2310
+ return true;
2311
+ } catch (error) {
2312
+ if (error.name === "NoSuchEntityException") {
2313
+ return false;
2314
+ }
2315
+ console.error("Error checking for existing IAM role:", error);
2316
+ return false;
2317
+ }
2318
+ }
1993
2319
  async function createIAMRole(config2) {
1994
2320
  let assumeRolePolicy;
1995
2321
  if (config2.provider === "vercel" && config2.oidcProvider) {
@@ -2025,8 +2351,24 @@ async function createIAMRole(config2) {
2025
2351
  } else {
2026
2352
  throw new Error("Other providers not yet implemented");
2027
2353
  }
2028
- const role = new aws3.iam.Role("wraps-email-role", {
2029
- name: "wraps-email-role",
2354
+ const roleName = "wraps-email-role";
2355
+ const exists = await roleExists(roleName);
2356
+ const role = exists ? new aws3.iam.Role(
2357
+ roleName,
2358
+ {
2359
+ name: roleName,
2360
+ assumeRolePolicy,
2361
+ tags: {
2362
+ ManagedBy: "wraps-cli",
2363
+ Provider: config2.provider
2364
+ }
2365
+ },
2366
+ {
2367
+ import: roleName
2368
+ // Import existing role (use role name, not ARN)
2369
+ }
2370
+ ) : new aws3.iam.Role(roleName, {
2371
+ name: roleName,
2030
2372
  assumeRolePolicy,
2031
2373
  tags: {
2032
2374
  ManagedBy: "wraps-cli",
@@ -2143,6 +2485,36 @@ function getPackageRoot() {
2143
2485
  }
2144
2486
  throw new Error("Could not find package.json");
2145
2487
  }
2488
+ async function lambdaFunctionExists(functionName) {
2489
+ try {
2490
+ const { LambdaClient: LambdaClient2, GetFunctionCommand } = await import("@aws-sdk/client-lambda");
2491
+ const lambda2 = new LambdaClient2({});
2492
+ await lambda2.send(new GetFunctionCommand({ FunctionName: functionName }));
2493
+ return true;
2494
+ } catch (error) {
2495
+ if (error.name === "ResourceNotFoundException") {
2496
+ return false;
2497
+ }
2498
+ console.error("Error checking for existing Lambda function:", error);
2499
+ return false;
2500
+ }
2501
+ }
2502
+ async function findEventSourceMapping(functionName, queueArn) {
2503
+ try {
2504
+ const { LambdaClient: LambdaClient2, ListEventSourceMappingsCommand } = await import("@aws-sdk/client-lambda");
2505
+ const lambda2 = new LambdaClient2({});
2506
+ const response = await lambda2.send(
2507
+ new ListEventSourceMappingsCommand({
2508
+ FunctionName: functionName,
2509
+ EventSourceArn: queueArn
2510
+ })
2511
+ );
2512
+ return response.EventSourceMappings?.[0]?.UUID || null;
2513
+ } catch (error) {
2514
+ console.error("Error finding event source mapping:", error);
2515
+ return null;
2516
+ }
2517
+ }
2146
2518
  async function getLambdaCode(functionName) {
2147
2519
  const packageRoot = getPackageRoot();
2148
2520
  const distLambdaPath = join(packageRoot, "dist", "lambda", functionName);
@@ -2238,10 +2610,12 @@ async function deployLambdaFunctions(config2) {
2238
2610
  })
2239
2611
  )
2240
2612
  });
2241
- const eventProcessor = new aws4.lambda.Function(
2242
- "wraps-email-event-processor",
2613
+ const functionName = "wraps-email-event-processor";
2614
+ const exists = await lambdaFunctionExists(functionName);
2615
+ const eventProcessor = exists ? new aws4.lambda.Function(
2616
+ functionName,
2243
2617
  {
2244
- name: "wraps-email-event-processor",
2618
+ name: functionName,
2245
2619
  runtime: aws4.lambda.Runtime.NodeJS20dX,
2246
2620
  handler: "index.handler",
2247
2621
  role: lambdaRole.arn,
@@ -2259,20 +2633,56 @@ async function deployLambdaFunctions(config2) {
2259
2633
  ManagedBy: "wraps-cli",
2260
2634
  Description: "Process SES email events from SQS and store in DynamoDB"
2261
2635
  }
2636
+ },
2637
+ {
2638
+ import: functionName
2639
+ // Import existing function
2640
+ }
2641
+ ) : new aws4.lambda.Function(functionName, {
2642
+ name: functionName,
2643
+ runtime: aws4.lambda.Runtime.NodeJS20dX,
2644
+ handler: "index.handler",
2645
+ role: lambdaRole.arn,
2646
+ code: new pulumi3.asset.FileArchive(eventProcessorCode),
2647
+ timeout: 300,
2648
+ // 5 minutes (matches SQS visibility timeout)
2649
+ memorySize: 512,
2650
+ environment: {
2651
+ variables: {
2652
+ TABLE_NAME: config2.tableName,
2653
+ AWS_ACCOUNT_ID: config2.accountId
2654
+ }
2655
+ },
2656
+ tags: {
2657
+ ManagedBy: "wraps-cli",
2658
+ Description: "Process SES email events from SQS and store in DynamoDB"
2262
2659
  }
2660
+ });
2661
+ const queueArnValue = `arn:aws:sqs:${config2.region}:${config2.accountId}:wraps-email-events`;
2662
+ const existingMappingUuid = await findEventSourceMapping(
2663
+ functionName,
2664
+ queueArnValue
2263
2665
  );
2264
- const eventSourceMapping = new aws4.lambda.EventSourceMapping(
2666
+ const mappingConfig = {
2667
+ eventSourceArn: config2.queueArn,
2668
+ functionName: eventProcessor.name,
2669
+ batchSize: 10,
2670
+ // Process up to 10 messages per invocation
2671
+ maximumBatchingWindowInSeconds: 5,
2672
+ // Wait up to 5 seconds to batch messages
2673
+ functionResponseTypes: ["ReportBatchItemFailures"]
2674
+ // Enable partial batch responses
2675
+ };
2676
+ const eventSourceMapping = existingMappingUuid ? new aws4.lambda.EventSourceMapping(
2265
2677
  "wraps-email-event-source-mapping",
2678
+ mappingConfig,
2266
2679
  {
2267
- eventSourceArn: config2.queueArn,
2268
- functionName: eventProcessor.name,
2269
- batchSize: 10,
2270
- // Process up to 10 messages per invocation
2271
- maximumBatchingWindowInSeconds: 5,
2272
- // Wait up to 5 seconds to batch messages
2273
- functionResponseTypes: ["ReportBatchItemFailures"]
2274
- // Enable partial batch responses
2680
+ import: existingMappingUuid
2681
+ // Import with the UUID
2275
2682
  }
2683
+ ) : new aws4.lambda.EventSourceMapping(
2684
+ "wraps-email-event-source-mapping",
2685
+ mappingConfig
2276
2686
  );
2277
2687
  return {
2278
2688
  eventProcessor,
@@ -2283,6 +2693,58 @@ async function deployLambdaFunctions(config2) {
2283
2693
  // src/infrastructure/resources/ses.ts
2284
2694
  init_esm_shims();
2285
2695
  import * as aws5 from "@pulumi/aws";
2696
+ async function configurationSetExists(configSetName, region) {
2697
+ try {
2698
+ const { SESv2Client: SESv2Client5, GetConfigurationSetCommand: GetConfigurationSetCommand2 } = await import("@aws-sdk/client-sesv2");
2699
+ const ses = new SESv2Client5({ region });
2700
+ await ses.send(
2701
+ new GetConfigurationSetCommand2({ ConfigurationSetName: configSetName })
2702
+ );
2703
+ return true;
2704
+ } catch (error) {
2705
+ if (error.name === "NotFoundException") {
2706
+ return false;
2707
+ }
2708
+ console.error("Error checking for existing configuration set:", error);
2709
+ return false;
2710
+ }
2711
+ }
2712
+ async function emailIdentityExists(emailIdentity, region) {
2713
+ try {
2714
+ const { SESv2Client: SESv2Client5, GetEmailIdentityCommand: GetEmailIdentityCommand4 } = await import("@aws-sdk/client-sesv2");
2715
+ const ses = new SESv2Client5({ region });
2716
+ await ses.send(
2717
+ new GetEmailIdentityCommand4({ EmailIdentity: emailIdentity })
2718
+ );
2719
+ return true;
2720
+ } catch (error) {
2721
+ if (error.name === "NotFoundException") {
2722
+ return false;
2723
+ }
2724
+ console.error("Error checking for existing email identity:", error);
2725
+ return false;
2726
+ }
2727
+ }
2728
+ async function eventDestinationExists(configSetName, eventDestName, region) {
2729
+ try {
2730
+ const { SESv2Client: SESv2Client5, GetConfigurationSetEventDestinationsCommand } = await import("@aws-sdk/client-sesv2");
2731
+ const ses = new SESv2Client5({ region });
2732
+ const response = await ses.send(
2733
+ new GetConfigurationSetEventDestinationsCommand({
2734
+ ConfigurationSetName: configSetName
2735
+ })
2736
+ );
2737
+ return response.EventDestinations?.some(
2738
+ (dest) => dest.Name === eventDestName
2739
+ );
2740
+ } catch (error) {
2741
+ if (error.name === "NotFoundException") {
2742
+ return false;
2743
+ }
2744
+ console.error("Error checking for existing event destination:", error);
2745
+ return false;
2746
+ }
2747
+ }
2286
2748
  async function createSESResources(config2) {
2287
2749
  const configSetOptions = {
2288
2750
  configurationSetName: "wraps-email-tracking",
@@ -2294,46 +2756,78 @@ async function createSESResources(config2) {
2294
2756
  if (config2.trackingConfig?.customRedirectDomain) {
2295
2757
  configSetOptions.trackingOptions = {
2296
2758
  customRedirectDomain: config2.trackingConfig.customRedirectDomain,
2297
- // Use OPTIONAL because custom domains don't have SSL certificates by default
2298
- // AWS's tracking domain (r.{region}.awstrack.me) doesn't have certs for custom domains
2299
- httpsPolicy: "OPTIONAL"
2759
+ // HTTPS policy depends on whether HTTPS tracking is enabled
2760
+ // - REQUIRE: When using CloudFront with SSL certificate
2761
+ // - OPTIONAL: When using direct SES tracking endpoint (no SSL)
2762
+ httpsPolicy: config2.trackingConfig.httpsEnabled ? "REQUIRE" : "OPTIONAL"
2300
2763
  };
2301
2764
  }
2302
- const configSet = new aws5.sesv2.ConfigurationSet(
2303
- "wraps-email-tracking",
2304
- configSetOptions
2305
- );
2765
+ const configSetName = "wraps-email-tracking";
2766
+ const exists = await configurationSetExists(configSetName, config2.region);
2767
+ const configSet = exists ? new aws5.sesv2.ConfigurationSet(configSetName, configSetOptions, {
2768
+ import: configSetName
2769
+ // Import existing configuration set
2770
+ }) : new aws5.sesv2.ConfigurationSet(configSetName, configSetOptions);
2306
2771
  const defaultEventBus = aws5.cloudwatch.getEventBusOutput({
2307
2772
  name: "default"
2308
2773
  });
2309
- new aws5.sesv2.ConfigurationSetEventDestination("wraps-email-all-events", {
2310
- configurationSetName: configSet.configurationSetName,
2311
- eventDestinationName: "wraps-email-eventbridge",
2312
- eventDestination: {
2313
- enabled: true,
2314
- matchingEventTypes: [
2315
- "SEND",
2316
- "DELIVERY",
2317
- "OPEN",
2318
- "CLICK",
2319
- "BOUNCE",
2320
- "COMPLAINT",
2321
- "REJECT",
2322
- "RENDERING_FAILURE",
2323
- "DELIVERY_DELAY",
2324
- "SUBSCRIPTION"
2325
- ],
2326
- eventBridgeDestination: {
2327
- // SES requires default bus - cannot use custom bus
2328
- eventBusArn: defaultEventBus.arn
2774
+ const eventDestName = "wraps-email-eventbridge";
2775
+ const eventDestExists = await eventDestinationExists(
2776
+ configSetName,
2777
+ eventDestName,
2778
+ config2.region
2779
+ );
2780
+ if (!eventDestExists) {
2781
+ new aws5.sesv2.ConfigurationSetEventDestination("wraps-email-all-events", {
2782
+ configurationSetName: configSet.configurationSetName,
2783
+ eventDestinationName: eventDestName,
2784
+ eventDestination: {
2785
+ enabled: true,
2786
+ matchingEventTypes: [
2787
+ "SEND",
2788
+ "DELIVERY",
2789
+ "OPEN",
2790
+ "CLICK",
2791
+ "BOUNCE",
2792
+ "COMPLAINT",
2793
+ "REJECT",
2794
+ "RENDERING_FAILURE",
2795
+ "DELIVERY_DELAY",
2796
+ "SUBSCRIPTION"
2797
+ ],
2798
+ eventBridgeDestination: {
2799
+ // SES requires default bus - cannot use custom bus
2800
+ eventBusArn: defaultEventBus.arn
2801
+ }
2329
2802
  }
2330
- }
2331
- });
2803
+ });
2804
+ }
2332
2805
  let domainIdentity;
2333
2806
  let dkimTokens;
2334
2807
  let mailFromDomain;
2335
2808
  if (config2.domain) {
2336
- domainIdentity = new aws5.sesv2.EmailIdentity("wraps-email-domain", {
2809
+ const identityExists = await emailIdentityExists(
2810
+ config2.domain,
2811
+ config2.region
2812
+ );
2813
+ domainIdentity = identityExists ? new aws5.sesv2.EmailIdentity(
2814
+ "wraps-email-domain",
2815
+ {
2816
+ emailIdentity: config2.domain,
2817
+ configurationSetName: configSet.configurationSetName,
2818
+ // Link configuration set to domain
2819
+ dkimSigningAttributes: {
2820
+ nextSigningKeyLength: "RSA_2048_BIT"
2821
+ },
2822
+ tags: {
2823
+ ManagedBy: "wraps-cli"
2824
+ }
2825
+ },
2826
+ {
2827
+ import: config2.domain
2828
+ // Import existing identity
2829
+ }
2830
+ ) : new aws5.sesv2.EmailIdentity("wraps-email-domain", {
2337
2831
  emailIdentity: config2.domain,
2338
2832
  configurationSetName: configSet.configurationSetName,
2339
2833
  // Link configuration set to domain
@@ -2417,9 +2911,48 @@ async function createSQSResources() {
2417
2911
  // src/infrastructure/vercel-oidc.ts
2418
2912
  init_esm_shims();
2419
2913
  import * as aws7 from "@pulumi/aws";
2914
+ async function getExistingOIDCProviderArn(url, accountId) {
2915
+ try {
2916
+ const { IAMClient: IAMClient2, ListOpenIDConnectProvidersCommand } = await import("@aws-sdk/client-iam");
2917
+ const iam4 = new IAMClient2({});
2918
+ const response = await iam4.send(new ListOpenIDConnectProvidersCommand({}));
2919
+ const expectedArnSuffix = url.replace("https://", "");
2920
+ const provider = response.OpenIDConnectProviderList?.find(
2921
+ (p) => p.Arn?.endsWith(expectedArnSuffix)
2922
+ );
2923
+ return provider?.Arn || null;
2924
+ } catch (error) {
2925
+ console.error("Error checking for existing OIDC provider:", error);
2926
+ return null;
2927
+ }
2928
+ }
2420
2929
  async function createVercelOIDC(config2) {
2930
+ const url = `https://oidc.vercel.com/${config2.teamSlug}`;
2931
+ const existingArn = await getExistingOIDCProviderArn(url, config2.accountId);
2932
+ if (existingArn) {
2933
+ return new aws7.iam.OpenIdConnectProvider(
2934
+ "wraps-vercel-oidc",
2935
+ {
2936
+ url,
2937
+ clientIdLists: [`https://vercel.com/${config2.teamSlug}`],
2938
+ thumbprintLists: [
2939
+ // Vercel OIDC thumbprints
2940
+ "20032e77eca0785eece16b56b42c9b330b906320",
2941
+ "696db3af0dffc17e65c6a20d925c5a7bd24dec7e"
2942
+ ],
2943
+ tags: {
2944
+ ManagedBy: "wraps-cli",
2945
+ Provider: "vercel"
2946
+ }
2947
+ },
2948
+ {
2949
+ import: existingArn
2950
+ // Import existing resource
2951
+ }
2952
+ );
2953
+ }
2421
2954
  return new aws7.iam.OpenIdConnectProvider("wraps-vercel-oidc", {
2422
- url: `https://oidc.vercel.com/${config2.teamSlug}`,
2955
+ url,
2423
2956
  clientIdLists: [`https://vercel.com/${config2.teamSlug}`],
2424
2957
  thumbprintLists: [
2425
2958
  // Vercel OIDC thumbprints
@@ -2435,7 +2968,7 @@ async function createVercelOIDC(config2) {
2435
2968
 
2436
2969
  // src/infrastructure/email-stack.ts
2437
2970
  async function deployEmailStack(config2) {
2438
- const identity = await aws8.getCallerIdentity();
2971
+ const identity = await aws10.getCallerIdentity();
2439
2972
  const accountId = identity.accountId;
2440
2973
  let oidcProvider;
2441
2974
  if (config2.provider === "vercel" && config2.vercel) {
@@ -2452,6 +2985,27 @@ async function deployEmailStack(config2) {
2452
2985
  vercelProjectName: config2.vercel?.projectName,
2453
2986
  emailConfig
2454
2987
  });
2988
+ let cloudFrontResources;
2989
+ let acmResources;
2990
+ if (emailConfig.tracking?.enabled && emailConfig.tracking.customRedirectDomain && emailConfig.tracking.httpsEnabled) {
2991
+ const { findHostedZone: findHostedZone2 } = await Promise.resolve().then(() => (init_route53(), route53_exports));
2992
+ const hostedZone = await findHostedZone2(
2993
+ emailConfig.tracking.customRedirectDomain,
2994
+ config2.region
2995
+ );
2996
+ const { createACMCertificate: createACMCertificate2 } = await Promise.resolve().then(() => (init_acm(), acm_exports));
2997
+ acmResources = await createACMCertificate2({
2998
+ domain: emailConfig.tracking.customRedirectDomain,
2999
+ hostedZoneId: hostedZone?.id
3000
+ });
3001
+ const { createCloudFrontTracking: createCloudFrontTracking2 } = await Promise.resolve().then(() => (init_cloudfront(), cloudfront_exports));
3002
+ const certificateArn = acmResources.certificateValidation ? acmResources.certificateValidation.certificateArn : acmResources.certificate.arn;
3003
+ cloudFrontResources = await createCloudFrontTracking2({
3004
+ customTrackingDomain: emailConfig.tracking.customRedirectDomain,
3005
+ region: config2.region,
3006
+ certificateArn
3007
+ });
3008
+ }
2455
3009
  let sesResources;
2456
3010
  if (emailConfig.tracking?.enabled || emailConfig.eventTracking?.enabled) {
2457
3011
  sesResources = await createSESResources({
@@ -2485,7 +3039,8 @@ async function deployEmailStack(config2) {
2485
3039
  roleArn: role.arn,
2486
3040
  tableName: dynamoTables.emailHistory.name,
2487
3041
  queueArn: sqsResources.queue.arn,
2488
- accountId
3042
+ accountId,
3043
+ region: config2.region
2489
3044
  });
2490
3045
  }
2491
3046
  let archiveResources;
@@ -2511,8 +3066,10 @@ async function deployEmailStack(config2) {
2511
3066
  queueUrl: sqsResources?.queue.url,
2512
3067
  dlqUrl: sqsResources?.dlq.url,
2513
3068
  customTrackingDomain: sesResources?.customTrackingDomain,
3069
+ httpsTrackingEnabled: emailConfig.tracking?.httpsEnabled,
3070
+ cloudFrontDomain: cloudFrontResources?.domainName,
3071
+ acmCertificateValidationRecords: acmResources?.validationRecords,
2514
3072
  mailFromDomain: sesResources?.mailFromDomain,
2515
- archiveId: archiveResources?.archiveId,
2516
3073
  archiveArn: archiveResources?.archiveArn,
2517
3074
  archivingEnabled: emailConfig.emailArchiving?.enabled,
2518
3075
  archiveRetention: emailConfig.emailArchiving?.enabled ? emailConfig.emailArchiving.retention : void 0
@@ -2742,7 +3299,7 @@ function displaySuccess(outputs) {
2742
3299
  "",
2743
3300
  pc2.bold("Next steps:"),
2744
3301
  ` 1. Install SDK: ${pc2.yellow("npm install @wraps/sdk")}`,
2745
- ` 2. View dashboard: ${pc2.blue("https://dashboard.wraps.dev")}`,
3302
+ ` 2. View dashboard: ${pc2.blue("https://app.wraps.dev")}`,
2746
3303
  ""
2747
3304
  );
2748
3305
  clack2.outro(pc2.green("Email infrastructure deployed successfully!"));
@@ -2785,9 +3342,29 @@ Verification should complete within a few minutes.`,
2785
3342
  }
2786
3343
  clack2.note(dnsLines.join("\n"), "DNS Records to add:");
2787
3344
  }
3345
+ if (outputs.acmValidationRecords && outputs.acmValidationRecords.length > 0) {
3346
+ const acmDnsLines = [
3347
+ pc2.bold("SSL Certificate Validation (ACM):"),
3348
+ ...outputs.acmValidationRecords.map(
3349
+ (record) => ` ${pc2.cyan(record.name)} ${pc2.dim(record.type)} "${record.value}"`
3350
+ ),
3351
+ "",
3352
+ pc2.dim(
3353
+ "Note: These records are required to validate your SSL certificate."
3354
+ ),
3355
+ pc2.dim(
3356
+ "CloudFront will be enabled automatically after certificate validation."
3357
+ )
3358
+ ];
3359
+ clack2.note(
3360
+ acmDnsLines.join("\n"),
3361
+ "SSL Certificate Validation DNS Records:"
3362
+ );
3363
+ }
2788
3364
  if (outputs.trackingDomainDnsRecords && outputs.trackingDomainDnsRecords.length > 0) {
3365
+ const trackingProtocol = outputs.httpsTrackingEnabled ? "HTTPS" : "HTTP";
2789
3366
  const trackingDnsLines = [
2790
- pc2.bold("Custom Tracking Domain - Redirect CNAME:"),
3367
+ pc2.bold(`Custom Tracking Domain - ${trackingProtocol} Redirect CNAME:`),
2791
3368
  ...outputs.trackingDomainDnsRecords.map(
2792
3369
  (record) => ` ${pc2.cyan(record.name)} ${pc2.dim(record.type)} "${record.value}"`
2793
3370
  ),
@@ -2797,6 +3374,12 @@ Verification should complete within a few minutes.`,
2797
3374
  ),
2798
3375
  pc2.dim("your custom domain for open and click tracking.")
2799
3376
  ];
3377
+ if (outputs.httpsTrackingEnabled) {
3378
+ trackingDnsLines.push(
3379
+ "",
3380
+ pc2.dim("HTTPS tracking is enabled via CloudFront with SSL certificate.")
3381
+ );
3382
+ }
2800
3383
  clack2.note(
2801
3384
  trackingDnsLines.join("\n"),
2802
3385
  "Custom Tracking Domain DNS Records:"
@@ -2811,7 +3394,8 @@ ${pc2.dim("Run:")} ${pc2.yellow(`wraps verify --domain ${outputs.customTrackingD
2811
3394
  );
2812
3395
  }
2813
3396
  }
2814
- if (outputs.customTrackingDomain && !outputs.dnsAutoCreated && (!outputs.dnsRecords || outputs.dnsRecords.length === 0) && (!outputs.trackingDomainDnsRecords || outputs.trackingDomainDnsRecords.length === 0)) {
3397
+ if (outputs.customTrackingDomain && !outputs.httpsTrackingEnabled && // Only show for HTTP tracking
3398
+ !outputs.dnsAutoCreated && (!outputs.dnsRecords || outputs.dnsRecords.length === 0) && (!outputs.trackingDomainDnsRecords || outputs.trackingDomainDnsRecords.length === 0)) {
2815
3399
  const trackingLines = [
2816
3400
  pc2.bold("Tracking Domain (CNAME):"),
2817
3401
  ` ${pc2.cyan(outputs.customTrackingDomain)} ${pc2.dim("CNAME")} "r.${outputs.region}.awstrack.me"`,
@@ -2884,6 +3468,19 @@ ${domainStrings.join("\n")}`);
2884
3468
  ` ${pc2.dim("\u25CB")} Email Archiving ${pc2.dim("(run 'wraps upgrade' to enable)")}`
2885
3469
  );
2886
3470
  }
3471
+ if (status2.tracking?.customTrackingDomain) {
3472
+ const protocol = status2.tracking.httpsEnabled ? "HTTPS" : "HTTP";
3473
+ const cloudFrontStatus = status2.tracking.httpsEnabled ? status2.tracking.cloudFrontDomain ? pc2.green("\u2713 Active") : pc2.yellow("\u23F1 Pending") : "";
3474
+ const trackingLabel = status2.tracking.httpsEnabled ? `${protocol} tracking ${cloudFrontStatus}` : `${protocol} tracking`;
3475
+ featureLines.push(
3476
+ ` ${pc2.green("\u2713")} Custom Tracking Domain ${pc2.dim(`(${trackingLabel})`)}`
3477
+ );
3478
+ featureLines.push(` ${pc2.cyan(status2.tracking.customTrackingDomain)}`);
3479
+ } else {
3480
+ featureLines.push(
3481
+ ` ${pc2.dim("\u25CB")} Custom Tracking Domain ${pc2.dim("(run 'wraps upgrade' to enable)")}`
3482
+ );
3483
+ }
2887
3484
  featureLines.push(
2888
3485
  ` ${pc2.green("\u2713")} Console Dashboard ${pc2.dim("(run 'wraps console')")}`
2889
3486
  );
@@ -2965,11 +3562,9 @@ ${pc2.dim("Run:")} ${pc2.yellow(`wraps verify --domain ${exampleDomain}`)} ${pc2
2965
3562
  `
2966
3563
  );
2967
3564
  }
2968
- console.log(
2969
- `
2970
- ${pc2.bold("Dashboard:")} ${pc2.blue("https://dashboard.wraps.dev")}`
2971
- );
2972
- console.log(`${pc2.bold("Docs:")} ${pc2.blue("https://docs.wraps.dev")}
3565
+ console.log(`
3566
+ ${pc2.bold("Dashboard:")} ${pc2.blue("https://app.wraps.dev")}`);
3567
+ console.log(`${pc2.bold("Docs:")} ${pc2.blue("https://wraps.dev/docs")}
2973
3568
  `);
2974
3569
  }
2975
3570
 
@@ -4324,9 +4919,12 @@ ${pc8.bold("The following Wraps resources will be removed:")}
4324
4919
  if (metadata.services.email?.pulumiStackName) {
4325
4920
  await progress.execute("Removing Wraps infrastructure", async () => {
4326
4921
  try {
4922
+ if (!metadata.services.email?.pulumiStackName) {
4923
+ throw new Error("No Pulumi stack name found in metadata");
4924
+ }
4327
4925
  const stack = await pulumi8.automation.LocalWorkspace.selectStack(
4328
4926
  {
4329
- stackName: metadata.services.email?.pulumiStackName,
4927
+ stackName: metadata.services.email.pulumiStackName,
4330
4928
  projectName: "wraps-email",
4331
4929
  program: async () => {
4332
4930
  }
@@ -4343,7 +4941,7 @@ ${pc8.bold("The following Wraps resources will be removed:")}
4343
4941
  await stack.destroy({ onOutput: () => {
4344
4942
  } });
4345
4943
  await stack.workspace.removeStack(
4346
- metadata.services.email?.pulumiStackName
4944
+ metadata.services.email.pulumiStackName
4347
4945
  );
4348
4946
  } catch (error) {
4349
4947
  throw new Error(`Failed to destroy Pulumi stack: ${error.message}`);
@@ -4740,14 +5338,94 @@ ${pc9.bold("Current Configuration:")}
4740
5338
  clack9.cancel("Upgrade cancelled.");
4741
5339
  process.exit(0);
4742
5340
  }
4743
- updatedConfig = {
4744
- ...config2,
4745
- tracking: {
4746
- ...config2.tracking,
4747
- enabled: true,
4748
- customRedirectDomain: trackingDomain || void 0
5341
+ const enableHttps = await clack9.confirm({
5342
+ message: "Enable HTTPS tracking with CloudFront + SSL certificate?",
5343
+ initialValue: true
5344
+ });
5345
+ if (clack9.isCancel(enableHttps)) {
5346
+ clack9.cancel("Upgrade cancelled.");
5347
+ process.exit(0);
5348
+ }
5349
+ if (enableHttps) {
5350
+ clack9.log.info(
5351
+ pc9.dim(
5352
+ "HTTPS tracking creates a CloudFront distribution with an SSL certificate."
5353
+ )
5354
+ );
5355
+ clack9.log.info(
5356
+ pc9.dim(
5357
+ "This ensures all tracking links use secure HTTPS connections."
5358
+ )
5359
+ );
5360
+ const { findHostedZone: findHostedZone2 } = await Promise.resolve().then(() => (init_route53(), route53_exports));
5361
+ const hostedZone = await progress.execute(
5362
+ "Checking for Route53 hosted zone",
5363
+ async () => await findHostedZone2(trackingDomain || config2.domain, region)
5364
+ );
5365
+ if (hostedZone) {
5366
+ progress.info(
5367
+ `Found Route53 hosted zone: ${pc9.cyan(hostedZone.name)} ${pc9.green("\u2713")}`
5368
+ );
5369
+ clack9.log.info(
5370
+ pc9.dim(
5371
+ "DNS records (SSL certificate validation + CloudFront) will be created automatically."
5372
+ )
5373
+ );
5374
+ } else {
5375
+ clack9.log.warn(
5376
+ `No Route53 hosted zone found for ${pc9.cyan(trackingDomain || config2.domain)}`
5377
+ );
5378
+ clack9.log.info(
5379
+ pc9.dim(
5380
+ "You'll need to manually create DNS records for SSL certificate validation and CloudFront."
5381
+ )
5382
+ );
5383
+ clack9.log.info(
5384
+ pc9.dim("DNS record details will be shown after deployment.")
5385
+ );
4749
5386
  }
4750
- };
5387
+ const confirmHttps = await clack9.confirm({
5388
+ message: hostedZone ? "Proceed with automatic HTTPS setup?" : "Proceed with manual HTTPS setup (requires DNS configuration)?",
5389
+ initialValue: true
5390
+ });
5391
+ if (clack9.isCancel(confirmHttps) || !confirmHttps) {
5392
+ clack9.log.info("HTTPS tracking not enabled. Using HTTP tracking.");
5393
+ updatedConfig = {
5394
+ ...config2,
5395
+ tracking: {
5396
+ ...config2.tracking,
5397
+ enabled: true,
5398
+ customRedirectDomain: trackingDomain || void 0,
5399
+ httpsEnabled: false
5400
+ }
5401
+ };
5402
+ } else {
5403
+ updatedConfig = {
5404
+ ...config2,
5405
+ tracking: {
5406
+ ...config2.tracking,
5407
+ enabled: true,
5408
+ customRedirectDomain: trackingDomain || void 0,
5409
+ httpsEnabled: true
5410
+ }
5411
+ };
5412
+ }
5413
+ } else {
5414
+ clack9.log.info(
5415
+ pc9.dim(
5416
+ "Using HTTP tracking (standard). Links will use http:// protocol."
5417
+ )
5418
+ );
5419
+ updatedConfig = {
5420
+ ...config2,
5421
+ tracking: {
5422
+ ...config2.tracking,
5423
+ enabled: true,
5424
+ customRedirectDomain: trackingDomain || void 0,
5425
+ httpsEnabled: false
5426
+ }
5427
+ };
5428
+ }
4751
5429
  newPreset = void 0;
4752
5430
  break;
4753
5431
  }
@@ -4954,6 +5632,9 @@ ${pc9.bold("Cost Impact:")}`);
4954
5632
  domain: result.domain,
4955
5633
  dkimTokens: result.dkimTokens,
4956
5634
  customTrackingDomain: result.customTrackingDomain,
5635
+ httpsTrackingEnabled: result.httpsTrackingEnabled,
5636
+ cloudFrontDomain: result.cloudFrontDomain,
5637
+ acmCertificateValidationRecords: result.acmCertificateValidationRecords,
4957
5638
  archiveArn: result.archiveArn,
4958
5639
  archivingEnabled: result.archivingEnabled,
4959
5640
  archiveRetention: result.archiveRetention
@@ -4972,6 +5653,8 @@ ${pc9.bold("Cost Impact:")}`);
4972
5653
  metadata.services.email?.pulumiStackName || `wraps-${identity.accountId}-${region}`
4973
5654
  );
4974
5655
  await stack.setConfig("aws:region", { value: region });
5656
+ await stack.refresh({ onOutput: () => {
5657
+ } });
4975
5658
  const upResult = await stack.up({ onOutput: () => {
4976
5659
  } });
4977
5660
  const pulumiOutputs = upResult.outputs;
@@ -4984,6 +5667,9 @@ ${pc9.bold("Cost Impact:")}`);
4984
5667
  domain: pulumiOutputs.domain?.value,
4985
5668
  dkimTokens: pulumiOutputs.dkimTokens?.value,
4986
5669
  customTrackingDomain: pulumiOutputs.customTrackingDomain?.value,
5670
+ httpsTrackingEnabled: pulumiOutputs.httpsTrackingEnabled?.value,
5671
+ cloudFrontDomain: pulumiOutputs.cloudFrontDomain?.value,
5672
+ acmCertificateValidationRecords: pulumiOutputs.acmCertificateValidationRecords?.value,
4987
5673
  archiveArn: pulumiOutputs.archiveArn?.value,
4988
5674
  archivingEnabled: pulumiOutputs.archivingEnabled?.value,
4989
5675
  archiveRetention: pulumiOutputs.archiveRetention?.value
@@ -5007,12 +5693,37 @@ ${pc9.bold("Cost Impact:")}`);
5007
5693
  await saveConnectionMetadata(metadata);
5008
5694
  progress.info("Connection metadata updated");
5009
5695
  const trackingDomainDnsRecords = [];
5696
+ const acmValidationRecords = [];
5010
5697
  if (outputs.customTrackingDomain) {
5011
- trackingDomainDnsRecords.push({
5012
- name: outputs.customTrackingDomain,
5013
- type: "CNAME",
5014
- value: `r.${outputs.region}.awstrack.me`
5015
- });
5698
+ if (outputs.httpsTrackingEnabled) {
5699
+ if (outputs.cloudFrontDomain) {
5700
+ trackingDomainDnsRecords.push({
5701
+ name: outputs.customTrackingDomain,
5702
+ type: "CNAME",
5703
+ value: outputs.cloudFrontDomain
5704
+ });
5705
+ }
5706
+ } else {
5707
+ trackingDomainDnsRecords.push({
5708
+ name: outputs.customTrackingDomain,
5709
+ type: "CNAME",
5710
+ value: `r.${outputs.region}.awstrack.me`
5711
+ });
5712
+ }
5713
+ }
5714
+ if (outputs.httpsTrackingEnabled && outputs.acmCertificateValidationRecords) {
5715
+ acmValidationRecords.push(...outputs.acmCertificateValidationRecords);
5716
+ }
5717
+ let needsCertificateValidation = false;
5718
+ let certificateStatus;
5719
+ if (outputs.httpsTrackingEnabled && acmValidationRecords.length > 0 && !outputs.cloudFrontDomain) {
5720
+ try {
5721
+ const { ACMClient: ACMClient2, DescribeCertificateCommand: DescribeCertificateCommand2 } = await import("@aws-sdk/client-acm");
5722
+ const acmClient = new ACMClient2({ region: "us-east-1" });
5723
+ needsCertificateValidation = true;
5724
+ } catch (error) {
5725
+ needsCertificateValidation = true;
5726
+ }
5016
5727
  }
5017
5728
  displaySuccess({
5018
5729
  roleArn: outputs.roleArn,
@@ -5020,7 +5731,9 @@ ${pc9.bold("Cost Impact:")}`);
5020
5731
  region: outputs.region,
5021
5732
  tableName: outputs.tableName,
5022
5733
  trackingDomainDnsRecords: trackingDomainDnsRecords.length > 0 ? trackingDomainDnsRecords : void 0,
5023
- customTrackingDomain: outputs.customTrackingDomain
5734
+ acmValidationRecords: acmValidationRecords.length > 0 ? acmValidationRecords : void 0,
5735
+ customTrackingDomain: outputs.customTrackingDomain,
5736
+ httpsTrackingEnabled: outputs.httpsTrackingEnabled
5024
5737
  });
5025
5738
  console.log(`
5026
5739
  ${pc9.green("\u2713")} ${pc9.bold("Upgrade complete!")}
@@ -5036,6 +5749,28 @@ ${pc9.green("\u2713")} ${pc9.bold("Upgrade complete!")}
5036
5749
  `
5037
5750
  );
5038
5751
  }
5752
+ if (needsCertificateValidation) {
5753
+ console.log(pc9.bold("\u26A0\uFE0F HTTPS Tracking - Next Steps:\n"));
5754
+ console.log(
5755
+ " 1. Add the SSL certificate validation DNS record shown above to your DNS provider"
5756
+ );
5757
+ console.log(
5758
+ " 2. Wait for DNS propagation and certificate validation (5-30 minutes)"
5759
+ );
5760
+ console.log(
5761
+ ` 3. Run ${pc9.cyan("wraps email upgrade")} again to complete CloudFront setup
5762
+ `
5763
+ );
5764
+ console.log(
5765
+ pc9.dim(
5766
+ " Note: CloudFront distribution will be created once the certificate is validated.\n"
5767
+ )
5768
+ );
5769
+ } else if (outputs.httpsTrackingEnabled && outputs.cloudFrontDomain) {
5770
+ console.log(
5771
+ pc9.green("\u2713") + " " + pc9.bold("HTTPS tracking is fully configured and ready to use!\n")
5772
+ );
5773
+ }
5039
5774
  }
5040
5775
 
5041
5776
  // src/commands/shared/dashboard.ts
@@ -6480,7 +7215,12 @@ Run ${pc12.cyan("wraps init")} to deploy infrastructure.
6480
7215
  archiveArn: stackOutputs.archiveArn?.value,
6481
7216
  archivingEnabled: stackOutputs.archivingEnabled?.value,
6482
7217
  archiveRetention: stackOutputs.archiveRetention?.value
6483
- }
7218
+ },
7219
+ tracking: stackOutputs.customTrackingDomain?.value ? {
7220
+ customTrackingDomain: stackOutputs.customTrackingDomain?.value,
7221
+ httpsEnabled: stackOutputs.httpsTrackingEnabled?.value,
7222
+ cloudFrontDomain: stackOutputs.cloudFrontDomain?.value
7223
+ } : void 0
6484
7224
  });
6485
7225
  }
6486
7226