@wraps.dev/cli 1.0.0 → 1.1.2

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";
@@ -383,11 +762,23 @@ function estimateStorageSize(emailsPerMonth, retention, numEventTypes = 8) {
383
762
  "7days": 0.25,
384
763
  "30days": 1,
385
764
  "90days": 3,
765
+ "3months": 3,
386
766
  "6months": 6,
767
+ "9months": 9,
387
768
  "1year": 12,
388
769
  "18months": 18,
389
- indefinite: 120
390
- // Assume 10 years for cost estimation
770
+ "2years": 24,
771
+ "30months": 30,
772
+ "3years": 36,
773
+ "4years": 48,
774
+ "5years": 60,
775
+ "6years": 72,
776
+ "7years": 84,
777
+ "8years": 96,
778
+ "9years": 108,
779
+ "10years": 120,
780
+ indefinite: 120,
781
+ permanent: 120
391
782
  }[retention];
392
783
  const totalKB = emailsPerMonth * numEventTypes * (retentionMonths ?? 12) * avgRecordSizeKB;
393
784
  return totalKB / 1024 / 1024;
@@ -398,11 +789,23 @@ function estimateArchiveStorageSize(emailsPerMonth, retention) {
398
789
  "7days": 0.25,
399
790
  "30days": 1,
400
791
  "90days": 3,
792
+ "3months": 3,
401
793
  "6months": 6,
794
+ "9months": 9,
402
795
  "1year": 12,
403
796
  "18months": 18,
404
- indefinite: 120
405
- // Assume 10 years for cost estimation
797
+ "2years": 24,
798
+ "30months": 30,
799
+ "3years": 36,
800
+ "4years": 48,
801
+ "5years": 60,
802
+ "6years": 72,
803
+ "7years": 84,
804
+ "8years": 96,
805
+ "9years": 108,
806
+ "10years": 120,
807
+ indefinite: 120,
808
+ permanent: 120
406
809
  }[retention];
407
810
  const totalKB = emailsPerMonth * (retentionMonths ?? 12) * avgEmailSizeKB;
408
811
  return totalKB / 1024 / 1024;
@@ -1478,116 +1881,34 @@ var init_prompts = __esm({
1478
1881
  }
1479
1882
  });
1480
1883
 
1481
- // src/utils/email/route53.ts
1482
- var route53_exports = {};
1483
- __export(route53_exports, {
1484
- createDNSRecords: () => createDNSRecords,
1485
- findHostedZone: () => findHostedZone
1884
+ // src/utils/shared/assume-role.ts
1885
+ var assume_role_exports = {};
1886
+ __export(assume_role_exports, {
1887
+ assumeRole: () => assumeRole
1486
1888
  });
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
- }
1889
+ import { AssumeRoleCommand, STSClient as STSClient2 } from "@aws-sdk/client-sts";
1890
+ async function assumeRole(roleArn, region, sessionName = "wraps-console") {
1891
+ const sts = new STSClient2({ region });
1892
+ const response = await sts.send(
1893
+ new AssumeRoleCommand({
1894
+ RoleArn: roleArn,
1895
+ RoleSessionName: sessionName,
1896
+ DurationSeconds: 3600
1897
+ // 1 hour
1586
1898
  })
1587
1899
  );
1900
+ if (!response.Credentials) {
1901
+ throw new Error("Failed to assume role: No credentials returned");
1902
+ }
1903
+ return {
1904
+ accessKeyId: response.Credentials.AccessKeyId,
1905
+ secretAccessKey: response.Credentials.SecretAccessKey,
1906
+ sessionToken: response.Credentials.SessionToken,
1907
+ expiration: response.Credentials.Expiration
1908
+ };
1588
1909
  }
1589
- var init_route53 = __esm({
1590
- "src/utils/email/route53.ts"() {
1910
+ var init_assume_role = __esm({
1911
+ "src/utils/shared/assume-role.ts"() {
1591
1912
  "use strict";
1592
1913
  init_esm_shims();
1593
1914
  }
@@ -1895,15 +2216,63 @@ import pc3 from "picocolors";
1895
2216
 
1896
2217
  // src/infrastructure/email-stack.ts
1897
2218
  init_esm_shims();
1898
- import * as aws8 from "@pulumi/aws";
2219
+ import * as aws10 from "@pulumi/aws";
1899
2220
  import * as pulumi4 from "@pulumi/pulumi";
1900
2221
 
1901
2222
  // src/infrastructure/resources/dynamodb.ts
1902
2223
  init_esm_shims();
1903
2224
  import * as aws from "@pulumi/aws";
2225
+ async function tableExists(tableName) {
2226
+ try {
2227
+ const { DynamoDBClient: DynamoDBClient4, DescribeTableCommand: DescribeTableCommand2 } = await import("@aws-sdk/client-dynamodb");
2228
+ const dynamodb2 = new DynamoDBClient4({});
2229
+ await dynamodb2.send(new DescribeTableCommand2({ TableName: tableName }));
2230
+ return true;
2231
+ } catch (error) {
2232
+ if (error.name === "ResourceNotFoundException") {
2233
+ return false;
2234
+ }
2235
+ console.error("Error checking for existing DynamoDB table:", error);
2236
+ return false;
2237
+ }
2238
+ }
1904
2239
  async function createDynamoDBTables(_config) {
1905
- const emailHistory = new aws.dynamodb.Table("wraps-email-history", {
1906
- name: "wraps-email-history",
2240
+ const tableName = "wraps-email-history";
2241
+ const exists = await tableExists(tableName);
2242
+ const emailHistory = exists ? new aws.dynamodb.Table(
2243
+ tableName,
2244
+ {
2245
+ name: tableName,
2246
+ billingMode: "PAY_PER_REQUEST",
2247
+ hashKey: "messageId",
2248
+ rangeKey: "sentAt",
2249
+ attributes: [
2250
+ { name: "messageId", type: "S" },
2251
+ { name: "sentAt", type: "N" },
2252
+ { name: "accountId", type: "S" }
2253
+ ],
2254
+ globalSecondaryIndexes: [
2255
+ {
2256
+ name: "accountId-sentAt-index",
2257
+ hashKey: "accountId",
2258
+ rangeKey: "sentAt",
2259
+ projectionType: "ALL"
2260
+ }
2261
+ ],
2262
+ ttl: {
2263
+ enabled: true,
2264
+ attributeName: "expiresAt"
2265
+ },
2266
+ tags: {
2267
+ ManagedBy: "wraps-cli"
2268
+ }
2269
+ },
2270
+ {
2271
+ import: tableName
2272
+ // Import existing table
2273
+ }
2274
+ ) : new aws.dynamodb.Table(tableName, {
2275
+ name: tableName,
1907
2276
  billingMode: "PAY_PER_REQUEST",
1908
2277
  hashKey: "messageId",
1909
2278
  rangeKey: "sentAt",
@@ -1990,6 +2359,20 @@ async function createEventBridgeResources(config2) {
1990
2359
  init_esm_shims();
1991
2360
  import * as aws3 from "@pulumi/aws";
1992
2361
  import * as pulumi2 from "@pulumi/pulumi";
2362
+ async function roleExists(roleName) {
2363
+ try {
2364
+ const { IAMClient: IAMClient2, GetRoleCommand } = await import("@aws-sdk/client-iam");
2365
+ const iam4 = new IAMClient2({});
2366
+ await iam4.send(new GetRoleCommand({ RoleName: roleName }));
2367
+ return true;
2368
+ } catch (error) {
2369
+ if (error.name === "NoSuchEntityException") {
2370
+ return false;
2371
+ }
2372
+ console.error("Error checking for existing IAM role:", error);
2373
+ return false;
2374
+ }
2375
+ }
1993
2376
  async function createIAMRole(config2) {
1994
2377
  let assumeRolePolicy;
1995
2378
  if (config2.provider === "vercel" && config2.oidcProvider) {
@@ -2025,8 +2408,24 @@ async function createIAMRole(config2) {
2025
2408
  } else {
2026
2409
  throw new Error("Other providers not yet implemented");
2027
2410
  }
2028
- const role = new aws3.iam.Role("wraps-email-role", {
2029
- name: "wraps-email-role",
2411
+ const roleName = "wraps-email-role";
2412
+ const exists = await roleExists(roleName);
2413
+ const role = exists ? new aws3.iam.Role(
2414
+ roleName,
2415
+ {
2416
+ name: roleName,
2417
+ assumeRolePolicy,
2418
+ tags: {
2419
+ ManagedBy: "wraps-cli",
2420
+ Provider: config2.provider
2421
+ }
2422
+ },
2423
+ {
2424
+ import: roleName
2425
+ // Import existing role (use role name, not ARN)
2426
+ }
2427
+ ) : new aws3.iam.Role(roleName, {
2428
+ name: roleName,
2030
2429
  assumeRolePolicy,
2031
2430
  tags: {
2032
2431
  ManagedBy: "wraps-cli",
@@ -2143,6 +2542,36 @@ function getPackageRoot() {
2143
2542
  }
2144
2543
  throw new Error("Could not find package.json");
2145
2544
  }
2545
+ async function lambdaFunctionExists(functionName) {
2546
+ try {
2547
+ const { LambdaClient: LambdaClient2, GetFunctionCommand } = await import("@aws-sdk/client-lambda");
2548
+ const lambda2 = new LambdaClient2({});
2549
+ await lambda2.send(new GetFunctionCommand({ FunctionName: functionName }));
2550
+ return true;
2551
+ } catch (error) {
2552
+ if (error.name === "ResourceNotFoundException") {
2553
+ return false;
2554
+ }
2555
+ console.error("Error checking for existing Lambda function:", error);
2556
+ return false;
2557
+ }
2558
+ }
2559
+ async function findEventSourceMapping(functionName, queueArn) {
2560
+ try {
2561
+ const { LambdaClient: LambdaClient2, ListEventSourceMappingsCommand } = await import("@aws-sdk/client-lambda");
2562
+ const lambda2 = new LambdaClient2({});
2563
+ const response = await lambda2.send(
2564
+ new ListEventSourceMappingsCommand({
2565
+ FunctionName: functionName,
2566
+ EventSourceArn: queueArn
2567
+ })
2568
+ );
2569
+ return response.EventSourceMappings?.[0]?.UUID || null;
2570
+ } catch (error) {
2571
+ console.error("Error finding event source mapping:", error);
2572
+ return null;
2573
+ }
2574
+ }
2146
2575
  async function getLambdaCode(functionName) {
2147
2576
  const packageRoot = getPackageRoot();
2148
2577
  const distLambdaPath = join(packageRoot, "dist", "lambda", functionName);
@@ -2238,10 +2667,12 @@ async function deployLambdaFunctions(config2) {
2238
2667
  })
2239
2668
  )
2240
2669
  });
2241
- const eventProcessor = new aws4.lambda.Function(
2242
- "wraps-email-event-processor",
2670
+ const functionName = "wraps-email-event-processor";
2671
+ const exists = await lambdaFunctionExists(functionName);
2672
+ const eventProcessor = exists ? new aws4.lambda.Function(
2673
+ functionName,
2243
2674
  {
2244
- name: "wraps-email-event-processor",
2675
+ name: functionName,
2245
2676
  runtime: aws4.lambda.Runtime.NodeJS20dX,
2246
2677
  handler: "index.handler",
2247
2678
  role: lambdaRole.arn,
@@ -2259,20 +2690,56 @@ async function deployLambdaFunctions(config2) {
2259
2690
  ManagedBy: "wraps-cli",
2260
2691
  Description: "Process SES email events from SQS and store in DynamoDB"
2261
2692
  }
2693
+ },
2694
+ {
2695
+ import: functionName
2696
+ // Import existing function
2697
+ }
2698
+ ) : new aws4.lambda.Function(functionName, {
2699
+ name: functionName,
2700
+ runtime: aws4.lambda.Runtime.NodeJS20dX,
2701
+ handler: "index.handler",
2702
+ role: lambdaRole.arn,
2703
+ code: new pulumi3.asset.FileArchive(eventProcessorCode),
2704
+ timeout: 300,
2705
+ // 5 minutes (matches SQS visibility timeout)
2706
+ memorySize: 512,
2707
+ environment: {
2708
+ variables: {
2709
+ TABLE_NAME: config2.tableName,
2710
+ AWS_ACCOUNT_ID: config2.accountId
2711
+ }
2712
+ },
2713
+ tags: {
2714
+ ManagedBy: "wraps-cli",
2715
+ Description: "Process SES email events from SQS and store in DynamoDB"
2262
2716
  }
2717
+ });
2718
+ const queueArnValue = `arn:aws:sqs:${config2.region}:${config2.accountId}:wraps-email-events`;
2719
+ const existingMappingUuid = await findEventSourceMapping(
2720
+ functionName,
2721
+ queueArnValue
2263
2722
  );
2264
- const eventSourceMapping = new aws4.lambda.EventSourceMapping(
2723
+ const mappingConfig = {
2724
+ eventSourceArn: config2.queueArn,
2725
+ functionName: eventProcessor.name,
2726
+ batchSize: 10,
2727
+ // Process up to 10 messages per invocation
2728
+ maximumBatchingWindowInSeconds: 5,
2729
+ // Wait up to 5 seconds to batch messages
2730
+ functionResponseTypes: ["ReportBatchItemFailures"]
2731
+ // Enable partial batch responses
2732
+ };
2733
+ const eventSourceMapping = existingMappingUuid ? new aws4.lambda.EventSourceMapping(
2265
2734
  "wraps-email-event-source-mapping",
2735
+ mappingConfig,
2266
2736
  {
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
2737
+ import: existingMappingUuid
2738
+ // Import with the UUID
2275
2739
  }
2740
+ ) : new aws4.lambda.EventSourceMapping(
2741
+ "wraps-email-event-source-mapping",
2742
+ mappingConfig
2276
2743
  );
2277
2744
  return {
2278
2745
  eventProcessor,
@@ -2283,6 +2750,56 @@ async function deployLambdaFunctions(config2) {
2283
2750
  // src/infrastructure/resources/ses.ts
2284
2751
  init_esm_shims();
2285
2752
  import * as aws5 from "@pulumi/aws";
2753
+ async function configurationSetExists(configSetName, region) {
2754
+ try {
2755
+ const { SESv2Client: SESv2Client5, GetConfigurationSetCommand: GetConfigurationSetCommand2 } = await import("@aws-sdk/client-sesv2");
2756
+ const ses = new SESv2Client5({ region });
2757
+ await ses.send(
2758
+ new GetConfigurationSetCommand2({ ConfigurationSetName: configSetName })
2759
+ );
2760
+ return true;
2761
+ } catch (error) {
2762
+ if (error.name === "NotFoundException") {
2763
+ return false;
2764
+ }
2765
+ console.error("Error checking for existing configuration set:", error);
2766
+ return false;
2767
+ }
2768
+ }
2769
+ async function emailIdentityExists(emailIdentity, region) {
2770
+ try {
2771
+ const { SESv2Client: SESv2Client5, GetEmailIdentityCommand: GetEmailIdentityCommand4 } = await import("@aws-sdk/client-sesv2");
2772
+ const ses = new SESv2Client5({ region });
2773
+ await ses.send(
2774
+ new GetEmailIdentityCommand4({ EmailIdentity: emailIdentity })
2775
+ );
2776
+ return true;
2777
+ } catch (error) {
2778
+ if (error.name === "NotFoundException") {
2779
+ return false;
2780
+ }
2781
+ console.error("Error checking for existing email identity:", error);
2782
+ return false;
2783
+ }
2784
+ }
2785
+ async function eventDestinationExists(configSetName, eventDestName, region) {
2786
+ try {
2787
+ const { SESv2Client: SESv2Client5, GetConfigurationSetEventDestinationsCommand } = await import("@aws-sdk/client-sesv2");
2788
+ const ses = new SESv2Client5({ region });
2789
+ const response = await ses.send(
2790
+ new GetConfigurationSetEventDestinationsCommand({
2791
+ ConfigurationSetName: configSetName
2792
+ })
2793
+ );
2794
+ return response.EventDestinations?.some((dest) => dest.Name === eventDestName) ?? false;
2795
+ } catch (error) {
2796
+ if (error.name === "NotFoundException") {
2797
+ return false;
2798
+ }
2799
+ console.error("Error checking for existing event destination:", error);
2800
+ return false;
2801
+ }
2802
+ }
2286
2803
  async function createSESResources(config2) {
2287
2804
  const configSetOptions = {
2288
2805
  configurationSetName: "wraps-email-tracking",
@@ -2294,46 +2811,78 @@ async function createSESResources(config2) {
2294
2811
  if (config2.trackingConfig?.customRedirectDomain) {
2295
2812
  configSetOptions.trackingOptions = {
2296
2813
  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"
2814
+ // HTTPS policy depends on whether HTTPS tracking is enabled
2815
+ // - REQUIRE: When using CloudFront with SSL certificate
2816
+ // - OPTIONAL: When using direct SES tracking endpoint (no SSL)
2817
+ httpsPolicy: config2.trackingConfig.httpsEnabled ? "REQUIRE" : "OPTIONAL"
2300
2818
  };
2301
2819
  }
2302
- const configSet = new aws5.sesv2.ConfigurationSet(
2303
- "wraps-email-tracking",
2304
- configSetOptions
2305
- );
2820
+ const configSetName = "wraps-email-tracking";
2821
+ const exists = await configurationSetExists(configSetName, config2.region);
2822
+ const configSet = exists ? new aws5.sesv2.ConfigurationSet(configSetName, configSetOptions, {
2823
+ import: configSetName
2824
+ // Import existing configuration set
2825
+ }) : new aws5.sesv2.ConfigurationSet(configSetName, configSetOptions);
2306
2826
  const defaultEventBus = aws5.cloudwatch.getEventBusOutput({
2307
2827
  name: "default"
2308
2828
  });
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
2829
+ const eventDestName = "wraps-email-eventbridge";
2830
+ const eventDestExists = await eventDestinationExists(
2831
+ configSetName,
2832
+ eventDestName,
2833
+ config2.region
2834
+ );
2835
+ if (!eventDestExists) {
2836
+ new aws5.sesv2.ConfigurationSetEventDestination("wraps-email-all-events", {
2837
+ configurationSetName: configSet.configurationSetName,
2838
+ eventDestinationName: eventDestName,
2839
+ eventDestination: {
2840
+ enabled: true,
2841
+ matchingEventTypes: [
2842
+ "SEND",
2843
+ "DELIVERY",
2844
+ "OPEN",
2845
+ "CLICK",
2846
+ "BOUNCE",
2847
+ "COMPLAINT",
2848
+ "REJECT",
2849
+ "RENDERING_FAILURE",
2850
+ "DELIVERY_DELAY",
2851
+ "SUBSCRIPTION"
2852
+ ],
2853
+ eventBridgeDestination: {
2854
+ // SES requires default bus - cannot use custom bus
2855
+ eventBusArn: defaultEventBus.arn
2856
+ }
2329
2857
  }
2330
- }
2331
- });
2858
+ });
2859
+ }
2332
2860
  let domainIdentity;
2333
2861
  let dkimTokens;
2334
2862
  let mailFromDomain;
2335
2863
  if (config2.domain) {
2336
- domainIdentity = new aws5.sesv2.EmailIdentity("wraps-email-domain", {
2864
+ const identityExists = await emailIdentityExists(
2865
+ config2.domain,
2866
+ config2.region
2867
+ );
2868
+ domainIdentity = identityExists ? new aws5.sesv2.EmailIdentity(
2869
+ "wraps-email-domain",
2870
+ {
2871
+ emailIdentity: config2.domain,
2872
+ configurationSetName: configSet.configurationSetName,
2873
+ // Link configuration set to domain
2874
+ dkimSigningAttributes: {
2875
+ nextSigningKeyLength: "RSA_2048_BIT"
2876
+ },
2877
+ tags: {
2878
+ ManagedBy: "wraps-cli"
2879
+ }
2880
+ },
2881
+ {
2882
+ import: config2.domain
2883
+ // Import existing identity
2884
+ }
2885
+ ) : new aws5.sesv2.EmailIdentity("wraps-email-domain", {
2337
2886
  emailIdentity: config2.domain,
2338
2887
  configurationSetName: configSet.configurationSetName,
2339
2888
  // Link configuration set to domain
@@ -2417,9 +2966,48 @@ async function createSQSResources() {
2417
2966
  // src/infrastructure/vercel-oidc.ts
2418
2967
  init_esm_shims();
2419
2968
  import * as aws7 from "@pulumi/aws";
2969
+ async function getExistingOIDCProviderArn(url) {
2970
+ try {
2971
+ const { IAMClient: IAMClient2, ListOpenIDConnectProvidersCommand } = await import("@aws-sdk/client-iam");
2972
+ const iam4 = new IAMClient2({});
2973
+ const response = await iam4.send(new ListOpenIDConnectProvidersCommand({}));
2974
+ const expectedArnSuffix = url.replace("https://", "");
2975
+ const provider = response.OpenIDConnectProviderList?.find(
2976
+ (p) => p.Arn?.endsWith(expectedArnSuffix)
2977
+ );
2978
+ return provider?.Arn || null;
2979
+ } catch (error) {
2980
+ console.error("Error checking for existing OIDC provider:", error);
2981
+ return null;
2982
+ }
2983
+ }
2420
2984
  async function createVercelOIDC(config2) {
2985
+ const url = `https://oidc.vercel.com/${config2.teamSlug}`;
2986
+ const existingArn = await getExistingOIDCProviderArn(url);
2987
+ if (existingArn) {
2988
+ return new aws7.iam.OpenIdConnectProvider(
2989
+ "wraps-vercel-oidc",
2990
+ {
2991
+ url,
2992
+ clientIdLists: [`https://vercel.com/${config2.teamSlug}`],
2993
+ thumbprintLists: [
2994
+ // Vercel OIDC thumbprints
2995
+ "20032e77eca0785eece16b56b42c9b330b906320",
2996
+ "696db3af0dffc17e65c6a20d925c5a7bd24dec7e"
2997
+ ],
2998
+ tags: {
2999
+ ManagedBy: "wraps-cli",
3000
+ Provider: "vercel"
3001
+ }
3002
+ },
3003
+ {
3004
+ import: existingArn
3005
+ // Import existing resource
3006
+ }
3007
+ );
3008
+ }
2421
3009
  return new aws7.iam.OpenIdConnectProvider("wraps-vercel-oidc", {
2422
- url: `https://oidc.vercel.com/${config2.teamSlug}`,
3010
+ url,
2423
3011
  clientIdLists: [`https://vercel.com/${config2.teamSlug}`],
2424
3012
  thumbprintLists: [
2425
3013
  // Vercel OIDC thumbprints
@@ -2435,7 +3023,7 @@ async function createVercelOIDC(config2) {
2435
3023
 
2436
3024
  // src/infrastructure/email-stack.ts
2437
3025
  async function deployEmailStack(config2) {
2438
- const identity = await aws8.getCallerIdentity();
3026
+ const identity = await aws10.getCallerIdentity();
2439
3027
  const accountId = identity.accountId;
2440
3028
  let oidcProvider;
2441
3029
  if (config2.provider === "vercel" && config2.vercel) {
@@ -2452,6 +3040,27 @@ async function deployEmailStack(config2) {
2452
3040
  vercelProjectName: config2.vercel?.projectName,
2453
3041
  emailConfig
2454
3042
  });
3043
+ let cloudFrontResources;
3044
+ let acmResources;
3045
+ if (emailConfig.tracking?.enabled && emailConfig.tracking.customRedirectDomain && emailConfig.tracking.httpsEnabled) {
3046
+ const { findHostedZone: findHostedZone2 } = await Promise.resolve().then(() => (init_route53(), route53_exports));
3047
+ const hostedZone = await findHostedZone2(
3048
+ emailConfig.tracking.customRedirectDomain,
3049
+ config2.region
3050
+ );
3051
+ const { createACMCertificate: createACMCertificate2 } = await Promise.resolve().then(() => (init_acm(), acm_exports));
3052
+ acmResources = await createACMCertificate2({
3053
+ domain: emailConfig.tracking.customRedirectDomain,
3054
+ hostedZoneId: hostedZone?.id
3055
+ });
3056
+ const { createCloudFrontTracking: createCloudFrontTracking2 } = await Promise.resolve().then(() => (init_cloudfront(), cloudfront_exports));
3057
+ const certificateArn = acmResources.certificateValidation ? acmResources.certificateValidation.certificateArn : acmResources.certificate.arn;
3058
+ cloudFrontResources = await createCloudFrontTracking2({
3059
+ customTrackingDomain: emailConfig.tracking.customRedirectDomain,
3060
+ region: config2.region,
3061
+ certificateArn
3062
+ });
3063
+ }
2455
3064
  let sesResources;
2456
3065
  if (emailConfig.tracking?.enabled || emailConfig.eventTracking?.enabled) {
2457
3066
  sesResources = await createSESResources({
@@ -2485,7 +3094,8 @@ async function deployEmailStack(config2) {
2485
3094
  roleArn: role.arn,
2486
3095
  tableName: dynamoTables.emailHistory.name,
2487
3096
  queueArn: sqsResources.queue.arn,
2488
- accountId
3097
+ accountId,
3098
+ region: config2.region
2489
3099
  });
2490
3100
  }
2491
3101
  let archiveResources;
@@ -2511,8 +3121,10 @@ async function deployEmailStack(config2) {
2511
3121
  queueUrl: sqsResources?.queue.url,
2512
3122
  dlqUrl: sqsResources?.dlq.url,
2513
3123
  customTrackingDomain: sesResources?.customTrackingDomain,
3124
+ httpsTrackingEnabled: emailConfig.tracking?.httpsEnabled,
3125
+ cloudFrontDomain: cloudFrontResources?.domainName,
3126
+ acmCertificateValidationRecords: acmResources?.validationRecords,
2514
3127
  mailFromDomain: sesResources?.mailFromDomain,
2515
- archiveId: archiveResources?.archiveId,
2516
3128
  archiveArn: archiveResources?.archiveArn,
2517
3129
  archivingEnabled: emailConfig.emailArchiving?.enabled,
2518
3130
  archiveRetention: emailConfig.emailArchiving?.enabled ? emailConfig.emailArchiving.retention : void 0
@@ -2742,7 +3354,7 @@ function displaySuccess(outputs) {
2742
3354
  "",
2743
3355
  pc2.bold("Next steps:"),
2744
3356
  ` 1. Install SDK: ${pc2.yellow("npm install @wraps/sdk")}`,
2745
- ` 2. View dashboard: ${pc2.blue("https://dashboard.wraps.dev")}`,
3357
+ ` 2. View dashboard: ${pc2.blue("https://app.wraps.dev")}`,
2746
3358
  ""
2747
3359
  );
2748
3360
  clack2.outro(pc2.green("Email infrastructure deployed successfully!"));
@@ -2785,9 +3397,29 @@ Verification should complete within a few minutes.`,
2785
3397
  }
2786
3398
  clack2.note(dnsLines.join("\n"), "DNS Records to add:");
2787
3399
  }
3400
+ if (outputs.acmValidationRecords && outputs.acmValidationRecords.length > 0) {
3401
+ const acmDnsLines = [
3402
+ pc2.bold("SSL Certificate Validation (ACM):"),
3403
+ ...outputs.acmValidationRecords.map(
3404
+ (record) => ` ${pc2.cyan(record.name)} ${pc2.dim(record.type)} "${record.value}"`
3405
+ ),
3406
+ "",
3407
+ pc2.dim(
3408
+ "Note: These records are required to validate your SSL certificate."
3409
+ ),
3410
+ pc2.dim(
3411
+ "CloudFront will be enabled automatically after certificate validation."
3412
+ )
3413
+ ];
3414
+ clack2.note(
3415
+ acmDnsLines.join("\n"),
3416
+ "SSL Certificate Validation DNS Records:"
3417
+ );
3418
+ }
2788
3419
  if (outputs.trackingDomainDnsRecords && outputs.trackingDomainDnsRecords.length > 0) {
3420
+ const trackingProtocol = outputs.httpsTrackingEnabled ? "HTTPS" : "HTTP";
2789
3421
  const trackingDnsLines = [
2790
- pc2.bold("Custom Tracking Domain - Redirect CNAME:"),
3422
+ pc2.bold(`Custom Tracking Domain - ${trackingProtocol} Redirect CNAME:`),
2791
3423
  ...outputs.trackingDomainDnsRecords.map(
2792
3424
  (record) => ` ${pc2.cyan(record.name)} ${pc2.dim(record.type)} "${record.value}"`
2793
3425
  ),
@@ -2797,6 +3429,12 @@ Verification should complete within a few minutes.`,
2797
3429
  ),
2798
3430
  pc2.dim("your custom domain for open and click tracking.")
2799
3431
  ];
3432
+ if (outputs.httpsTrackingEnabled) {
3433
+ trackingDnsLines.push(
3434
+ "",
3435
+ pc2.dim("HTTPS tracking is enabled via CloudFront with SSL certificate.")
3436
+ );
3437
+ }
2800
3438
  clack2.note(
2801
3439
  trackingDnsLines.join("\n"),
2802
3440
  "Custom Tracking Domain DNS Records:"
@@ -2811,7 +3449,8 @@ ${pc2.dim("Run:")} ${pc2.yellow(`wraps verify --domain ${outputs.customTrackingD
2811
3449
  );
2812
3450
  }
2813
3451
  }
2814
- if (outputs.customTrackingDomain && !outputs.dnsAutoCreated && (!outputs.dnsRecords || outputs.dnsRecords.length === 0) && (!outputs.trackingDomainDnsRecords || outputs.trackingDomainDnsRecords.length === 0)) {
3452
+ if (outputs.customTrackingDomain && !outputs.httpsTrackingEnabled && // Only show for HTTP tracking
3453
+ !outputs.dnsAutoCreated && (!outputs.dnsRecords || outputs.dnsRecords.length === 0) && (!outputs.trackingDomainDnsRecords || outputs.trackingDomainDnsRecords.length === 0)) {
2815
3454
  const trackingLines = [
2816
3455
  pc2.bold("Tracking Domain (CNAME):"),
2817
3456
  ` ${pc2.cyan(outputs.customTrackingDomain)} ${pc2.dim("CNAME")} "r.${outputs.region}.awstrack.me"`,
@@ -2884,6 +3523,19 @@ ${domainStrings.join("\n")}`);
2884
3523
  ` ${pc2.dim("\u25CB")} Email Archiving ${pc2.dim("(run 'wraps upgrade' to enable)")}`
2885
3524
  );
2886
3525
  }
3526
+ if (status2.tracking?.customTrackingDomain) {
3527
+ const protocol = status2.tracking.httpsEnabled ? "HTTPS" : "HTTP";
3528
+ const cloudFrontStatus = status2.tracking.httpsEnabled ? status2.tracking.cloudFrontDomain ? pc2.green("\u2713 Active") : pc2.yellow("\u23F1 Pending") : "";
3529
+ const trackingLabel = status2.tracking.httpsEnabled ? `${protocol} tracking ${cloudFrontStatus}` : `${protocol} tracking`;
3530
+ featureLines.push(
3531
+ ` ${pc2.green("\u2713")} Custom Tracking Domain ${pc2.dim(`(${trackingLabel})`)}`
3532
+ );
3533
+ featureLines.push(` ${pc2.cyan(status2.tracking.customTrackingDomain)}`);
3534
+ } else {
3535
+ featureLines.push(
3536
+ ` ${pc2.dim("\u25CB")} Custom Tracking Domain ${pc2.dim("(run 'wraps upgrade' to enable)")}`
3537
+ );
3538
+ }
2887
3539
  featureLines.push(
2888
3540
  ` ${pc2.green("\u2713")} Console Dashboard ${pc2.dim("(run 'wraps console')")}`
2889
3541
  );
@@ -2965,11 +3617,9 @@ ${pc2.dim("Run:")} ${pc2.yellow(`wraps verify --domain ${exampleDomain}`)} ${pc2
2965
3617
  `
2966
3618
  );
2967
3619
  }
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")}
3620
+ console.log(`
3621
+ ${pc2.bold("Dashboard:")} ${pc2.blue("https://app.wraps.dev")}`);
3622
+ console.log(`${pc2.bold("Docs:")} ${pc2.blue("https://wraps.dev/docs")}
2973
3623
  `);
2974
3624
  }
2975
3625
 
@@ -4324,9 +4974,12 @@ ${pc8.bold("The following Wraps resources will be removed:")}
4324
4974
  if (metadata.services.email?.pulumiStackName) {
4325
4975
  await progress.execute("Removing Wraps infrastructure", async () => {
4326
4976
  try {
4977
+ if (!metadata.services.email?.pulumiStackName) {
4978
+ throw new Error("No Pulumi stack name found in metadata");
4979
+ }
4327
4980
  const stack = await pulumi8.automation.LocalWorkspace.selectStack(
4328
4981
  {
4329
- stackName: metadata.services.email?.pulumiStackName,
4982
+ stackName: metadata.services.email.pulumiStackName,
4330
4983
  projectName: "wraps-email",
4331
4984
  program: async () => {
4332
4985
  }
@@ -4343,7 +4996,7 @@ ${pc8.bold("The following Wraps resources will be removed:")}
4343
4996
  await stack.destroy({ onOutput: () => {
4344
4997
  } });
4345
4998
  await stack.workspace.removeStack(
4346
- metadata.services.email?.pulumiStackName
4999
+ metadata.services.email.pulumiStackName
4347
5000
  );
4348
5001
  } catch (error) {
4349
5002
  throw new Error(`Failed to destroy Pulumi stack: ${error.message}`);
@@ -4443,10 +5096,23 @@ ${pc9.bold("Current Configuration:")}
4443
5096
  "7days": "7 days",
4444
5097
  "30days": "30 days",
4445
5098
  "90days": "90 days",
5099
+ "3months": "3 months",
4446
5100
  "6months": "6 months",
5101
+ "9months": "9 months",
4447
5102
  "1year": "1 year",
4448
5103
  "18months": "18 months",
4449
- indefinite: "indefinite"
5104
+ "2years": "2 years",
5105
+ "30months": "30 months",
5106
+ "3years": "3 years",
5107
+ "4years": "4 years",
5108
+ "5years": "5 years",
5109
+ "6years": "6 years",
5110
+ "7years": "7 years",
5111
+ "8years": "8 years",
5112
+ "9years": "9 years",
5113
+ "10years": "10 years",
5114
+ indefinite: "indefinite",
5115
+ permanent: "permanent"
4450
5116
  }[config2.emailArchiving.retention] || "90 days";
4451
5117
  console.log(` ${pc9.green("\u2713")} Email Archiving (${retentionLabel})`);
4452
5118
  }
@@ -4740,14 +5406,94 @@ ${pc9.bold("Current Configuration:")}
4740
5406
  clack9.cancel("Upgrade cancelled.");
4741
5407
  process.exit(0);
4742
5408
  }
4743
- updatedConfig = {
4744
- ...config2,
4745
- tracking: {
4746
- ...config2.tracking,
4747
- enabled: true,
4748
- customRedirectDomain: trackingDomain || void 0
5409
+ const enableHttps = await clack9.confirm({
5410
+ message: "Enable HTTPS tracking with CloudFront + SSL certificate?",
5411
+ initialValue: true
5412
+ });
5413
+ if (clack9.isCancel(enableHttps)) {
5414
+ clack9.cancel("Upgrade cancelled.");
5415
+ process.exit(0);
5416
+ }
5417
+ if (enableHttps) {
5418
+ clack9.log.info(
5419
+ pc9.dim(
5420
+ "HTTPS tracking creates a CloudFront distribution with an SSL certificate."
5421
+ )
5422
+ );
5423
+ clack9.log.info(
5424
+ pc9.dim(
5425
+ "This ensures all tracking links use secure HTTPS connections."
5426
+ )
5427
+ );
5428
+ const { findHostedZone: findHostedZone2 } = await Promise.resolve().then(() => (init_route53(), route53_exports));
5429
+ const hostedZone = await progress.execute(
5430
+ "Checking for Route53 hosted zone",
5431
+ async () => await findHostedZone2(trackingDomain || config2.domain, region)
5432
+ );
5433
+ if (hostedZone) {
5434
+ progress.info(
5435
+ `Found Route53 hosted zone: ${pc9.cyan(hostedZone.name)} ${pc9.green("\u2713")}`
5436
+ );
5437
+ clack9.log.info(
5438
+ pc9.dim(
5439
+ "DNS records (SSL certificate validation + CloudFront) will be created automatically."
5440
+ )
5441
+ );
5442
+ } else {
5443
+ clack9.log.warn(
5444
+ `No Route53 hosted zone found for ${pc9.cyan(trackingDomain || config2.domain)}`
5445
+ );
5446
+ clack9.log.info(
5447
+ pc9.dim(
5448
+ "You'll need to manually create DNS records for SSL certificate validation and CloudFront."
5449
+ )
5450
+ );
5451
+ clack9.log.info(
5452
+ pc9.dim("DNS record details will be shown after deployment.")
5453
+ );
4749
5454
  }
4750
- };
5455
+ const confirmHttps = await clack9.confirm({
5456
+ message: hostedZone ? "Proceed with automatic HTTPS setup?" : "Proceed with manual HTTPS setup (requires DNS configuration)?",
5457
+ initialValue: true
5458
+ });
5459
+ if (clack9.isCancel(confirmHttps) || !confirmHttps) {
5460
+ clack9.log.info("HTTPS tracking not enabled. Using HTTP tracking.");
5461
+ updatedConfig = {
5462
+ ...config2,
5463
+ tracking: {
5464
+ ...config2.tracking,
5465
+ enabled: true,
5466
+ customRedirectDomain: trackingDomain || void 0,
5467
+ httpsEnabled: false
5468
+ }
5469
+ };
5470
+ } else {
5471
+ updatedConfig = {
5472
+ ...config2,
5473
+ tracking: {
5474
+ ...config2.tracking,
5475
+ enabled: true,
5476
+ customRedirectDomain: trackingDomain || void 0,
5477
+ httpsEnabled: true
5478
+ }
5479
+ };
5480
+ }
5481
+ } else {
5482
+ clack9.log.info(
5483
+ pc9.dim(
5484
+ "Using HTTP tracking (standard). Links will use http:// protocol."
5485
+ )
5486
+ );
5487
+ updatedConfig = {
5488
+ ...config2,
5489
+ tracking: {
5490
+ ...config2.tracking,
5491
+ enabled: true,
5492
+ customRedirectDomain: trackingDomain || void 0,
5493
+ httpsEnabled: false
5494
+ }
5495
+ };
5496
+ }
4751
5497
  newPreset = void 0;
4752
5498
  break;
4753
5499
  }
@@ -4954,6 +5700,9 @@ ${pc9.bold("Cost Impact:")}`);
4954
5700
  domain: result.domain,
4955
5701
  dkimTokens: result.dkimTokens,
4956
5702
  customTrackingDomain: result.customTrackingDomain,
5703
+ httpsTrackingEnabled: result.httpsTrackingEnabled,
5704
+ cloudFrontDomain: result.cloudFrontDomain,
5705
+ acmCertificateValidationRecords: result.acmCertificateValidationRecords,
4957
5706
  archiveArn: result.archiveArn,
4958
5707
  archivingEnabled: result.archivingEnabled,
4959
5708
  archiveRetention: result.archiveRetention
@@ -4972,6 +5721,8 @@ ${pc9.bold("Cost Impact:")}`);
4972
5721
  metadata.services.email?.pulumiStackName || `wraps-${identity.accountId}-${region}`
4973
5722
  );
4974
5723
  await stack.setConfig("aws:region", { value: region });
5724
+ await stack.refresh({ onOutput: () => {
5725
+ } });
4975
5726
  const upResult = await stack.up({ onOutput: () => {
4976
5727
  } });
4977
5728
  const pulumiOutputs = upResult.outputs;
@@ -4984,6 +5735,9 @@ ${pc9.bold("Cost Impact:")}`);
4984
5735
  domain: pulumiOutputs.domain?.value,
4985
5736
  dkimTokens: pulumiOutputs.dkimTokens?.value,
4986
5737
  customTrackingDomain: pulumiOutputs.customTrackingDomain?.value,
5738
+ httpsTrackingEnabled: pulumiOutputs.httpsTrackingEnabled?.value,
5739
+ cloudFrontDomain: pulumiOutputs.cloudFrontDomain?.value,
5740
+ acmCertificateValidationRecords: pulumiOutputs.acmCertificateValidationRecords?.value,
4987
5741
  archiveArn: pulumiOutputs.archiveArn?.value,
4988
5742
  archivingEnabled: pulumiOutputs.archivingEnabled?.value,
4989
5743
  archiveRetention: pulumiOutputs.archiveRetention?.value
@@ -5007,20 +5761,37 @@ ${pc9.bold("Cost Impact:")}`);
5007
5761
  await saveConnectionMetadata(metadata);
5008
5762
  progress.info("Connection metadata updated");
5009
5763
  const trackingDomainDnsRecords = [];
5764
+ const acmValidationRecords = [];
5010
5765
  if (outputs.customTrackingDomain) {
5011
- trackingDomainDnsRecords.push({
5012
- name: outputs.customTrackingDomain,
5013
- type: "CNAME",
5014
- value: `r.${outputs.region}.awstrack.me`
5015
- });
5766
+ if (outputs.httpsTrackingEnabled) {
5767
+ if (outputs.cloudFrontDomain) {
5768
+ trackingDomainDnsRecords.push({
5769
+ name: outputs.customTrackingDomain,
5770
+ type: "CNAME",
5771
+ value: outputs.cloudFrontDomain
5772
+ });
5773
+ }
5774
+ } else {
5775
+ trackingDomainDnsRecords.push({
5776
+ name: outputs.customTrackingDomain,
5777
+ type: "CNAME",
5778
+ value: `r.${outputs.region}.awstrack.me`
5779
+ });
5780
+ }
5016
5781
  }
5782
+ if (outputs.httpsTrackingEnabled && outputs.acmCertificateValidationRecords) {
5783
+ acmValidationRecords.push(...outputs.acmCertificateValidationRecords);
5784
+ }
5785
+ const needsCertificateValidation = outputs.httpsTrackingEnabled && acmValidationRecords.length > 0 && !outputs.cloudFrontDomain;
5017
5786
  displaySuccess({
5018
5787
  roleArn: outputs.roleArn,
5019
5788
  configSetName: outputs.configSetName,
5020
5789
  region: outputs.region,
5021
5790
  tableName: outputs.tableName,
5022
5791
  trackingDomainDnsRecords: trackingDomainDnsRecords.length > 0 ? trackingDomainDnsRecords : void 0,
5023
- customTrackingDomain: outputs.customTrackingDomain
5792
+ acmValidationRecords: acmValidationRecords.length > 0 ? acmValidationRecords : void 0,
5793
+ customTrackingDomain: outputs.customTrackingDomain,
5794
+ httpsTrackingEnabled: outputs.httpsTrackingEnabled
5024
5795
  });
5025
5796
  console.log(`
5026
5797
  ${pc9.green("\u2713")} ${pc9.bold("Upgrade complete!")}
@@ -5036,6 +5807,28 @@ ${pc9.green("\u2713")} ${pc9.bold("Upgrade complete!")}
5036
5807
  `
5037
5808
  );
5038
5809
  }
5810
+ if (needsCertificateValidation) {
5811
+ console.log(pc9.bold("\u26A0\uFE0F HTTPS Tracking - Next Steps:\n"));
5812
+ console.log(
5813
+ " 1. Add the SSL certificate validation DNS record shown above to your DNS provider"
5814
+ );
5815
+ console.log(
5816
+ " 2. Wait for DNS propagation and certificate validation (5-30 minutes)"
5817
+ );
5818
+ console.log(
5819
+ ` 3. Run ${pc9.cyan("wraps email upgrade")} again to complete CloudFront setup
5820
+ `
5821
+ );
5822
+ console.log(
5823
+ pc9.dim(
5824
+ " Note: CloudFront distribution will be created once the certificate is validated.\n"
5825
+ )
5826
+ );
5827
+ } else if (outputs.httpsTrackingEnabled && outputs.cloudFrontDomain) {
5828
+ console.log(
5829
+ pc9.green("\u2713") + " " + pc9.bold("HTTPS tracking is fully configured and ready to use!\n")
5830
+ );
5831
+ }
5039
5832
  }
5040
5833
 
5041
5834
  // src/commands/shared/dashboard.ts
@@ -5082,34 +5875,9 @@ import { Router as createRouter } from "express";
5082
5875
 
5083
5876
  // src/console/services/ses-service.ts
5084
5877
  init_esm_shims();
5878
+ init_assume_role();
5085
5879
  import { GetSendQuotaCommand, SESClient as SESClient3 } from "@aws-sdk/client-ses";
5086
5880
  import { GetEmailIdentityCommand as GetEmailIdentityCommand2, SESv2Client as SESv2Client3 } from "@aws-sdk/client-sesv2";
5087
-
5088
- // src/utils/shared/assume-role.ts
5089
- init_esm_shims();
5090
- import { AssumeRoleCommand, STSClient as STSClient2 } from "@aws-sdk/client-sts";
5091
- async function assumeRole(roleArn, region, sessionName = "wraps-console") {
5092
- const sts = new STSClient2({ region });
5093
- const response = await sts.send(
5094
- new AssumeRoleCommand({
5095
- RoleArn: roleArn,
5096
- RoleSessionName: sessionName,
5097
- DurationSeconds: 3600
5098
- // 1 hour
5099
- })
5100
- );
5101
- if (!response.Credentials) {
5102
- throw new Error("Failed to assume role: No credentials returned");
5103
- }
5104
- return {
5105
- accessKeyId: response.Credentials.AccessKeyId,
5106
- secretAccessKey: response.Credentials.SecretAccessKey,
5107
- sessionToken: response.Credentials.SessionToken,
5108
- expiration: response.Credentials.Expiration
5109
- };
5110
- }
5111
-
5112
- // src/console/services/ses-service.ts
5113
5881
  async function fetchSendQuota(roleArn, region) {
5114
5882
  const credentials = roleArn ? await assumeRole(roleArn, region) : void 0;
5115
5883
  const ses = new SESClient3({ region, credentials });
@@ -5596,6 +6364,7 @@ import { Router as createRouter3 } from "express";
5596
6364
 
5597
6365
  // src/console/services/aws-metrics.ts
5598
6366
  init_esm_shims();
6367
+ init_assume_role();
5599
6368
  import {
5600
6369
  CloudWatchClient,
5601
6370
  GetMetricDataCommand
@@ -5809,6 +6578,7 @@ import { Router as createRouter4 } from "express";
5809
6578
 
5810
6579
  // src/console/services/settings-service.ts
5811
6580
  init_esm_shims();
6581
+ init_assume_role();
5812
6582
  import {
5813
6583
  GetConfigurationSetCommand,
5814
6584
  GetEmailIdentityCommand as GetEmailIdentityCommand3,
@@ -6039,7 +6809,7 @@ function createSettingsRouter(config2) {
6039
6809
  `[Settings] Updating sending options for ${configSetName}: ${enabled}`
6040
6810
  );
6041
6811
  const { SESv2Client: SESv2Client5, PutConfigurationSetSendingOptionsCommand } = await import("@aws-sdk/client-sesv2");
6042
- const { assumeRole: assumeRole2 } = await import("../../utils/assume-role.js");
6812
+ const { assumeRole: assumeRole2 } = await Promise.resolve().then(() => (init_assume_role(), assume_role_exports));
6043
6813
  const credentials = config2.roleArn ? await assumeRole2(config2.roleArn, config2.region) : void 0;
6044
6814
  const sesClient = new SESv2Client5({ region: config2.region, credentials });
6045
6815
  await sesClient.send(
@@ -6076,7 +6846,7 @@ function createSettingsRouter(config2) {
6076
6846
  `[Settings] Updating reputation options for ${configSetName}: ${enabled}`
6077
6847
  );
6078
6848
  const { SESv2Client: SESv2Client5, PutConfigurationSetReputationOptionsCommand } = await import("@aws-sdk/client-sesv2");
6079
- const { assumeRole: assumeRole2 } = await import("../../utils/assume-role.js");
6849
+ const { assumeRole: assumeRole2 } = await Promise.resolve().then(() => (init_assume_role(), assume_role_exports));
6080
6850
  const credentials = config2.roleArn ? await assumeRole2(config2.roleArn, config2.region) : void 0;
6081
6851
  const sesClient = new SESv2Client5({ region: config2.region, credentials });
6082
6852
  await sesClient.send(
@@ -6119,7 +6889,7 @@ function createSettingsRouter(config2) {
6119
6889
  `[Settings] Updating tracking domain for ${configSetName}: ${domain}`
6120
6890
  );
6121
6891
  const { SESv2Client: SESv2Client5, PutConfigurationSetTrackingOptionsCommand } = await import("@aws-sdk/client-sesv2");
6122
- const { assumeRole: assumeRole2 } = await import("../../utils/assume-role.js");
6892
+ const { assumeRole: assumeRole2 } = await Promise.resolve().then(() => (init_assume_role(), assume_role_exports));
6123
6893
  const credentials = config2.roleArn ? await assumeRole2(config2.roleArn, config2.region) : void 0;
6124
6894
  const sesClient = new SESv2Client5({
6125
6895
  region: config2.region,
@@ -6167,7 +6937,7 @@ function createUserRouter(config2) {
6167
6937
  try {
6168
6938
  if (config2.roleArn) {
6169
6939
  console.log("[User API] Attempting to fetch account alias via IAM");
6170
- const { assumeRole: assumeRole2 } = await import("../../utils/assume-role.js");
6940
+ const { assumeRole: assumeRole2 } = await Promise.resolve().then(() => (init_assume_role(), assume_role_exports));
6171
6941
  const { IAMClient: IAMClient2, ListAccountAliasesCommand } = await import("@aws-sdk/client-iam");
6172
6942
  const credentials = await assumeRole2(config2.roleArn, region);
6173
6943
  const iamClient = new IAMClient2({ region, credentials });
@@ -6480,7 +7250,12 @@ Run ${pc12.cyan("wraps init")} to deploy infrastructure.
6480
7250
  archiveArn: stackOutputs.archiveArn?.value,
6481
7251
  archivingEnabled: stackOutputs.archivingEnabled?.value,
6482
7252
  archiveRetention: stackOutputs.archiveRetention?.value
6483
- }
7253
+ },
7254
+ tracking: stackOutputs.customTrackingDomain?.value ? {
7255
+ customTrackingDomain: stackOutputs.customTrackingDomain?.value,
7256
+ httpsEnabled: stackOutputs.httpsTrackingEnabled?.value,
7257
+ cloudFrontDomain: stackOutputs.cloudFrontDomain?.value
7258
+ } : void 0
6484
7259
  });
6485
7260
  }
6486
7261