@wraps.dev/cli 0.1.5 → 0.3.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,24 @@ var init_esm_shims = __esm({
18
18
  }
19
19
  });
20
20
 
21
+ // src/infrastructure/resources/mail-manager.ts
22
+ var mail_manager_exports = {};
23
+ __export(mail_manager_exports, {
24
+ createMailManagerArchive: () => createMailManagerArchive
25
+ });
26
+ async function createMailManagerArchive(config) {
27
+ void config;
28
+ throw new Error(
29
+ "Mail Manager Archive is not yet supported in Pulumi AWS provider. Email archiving with Mail Manager is coming soon."
30
+ );
31
+ }
32
+ var init_mail_manager = __esm({
33
+ "src/infrastructure/resources/mail-manager.ts"() {
34
+ "use strict";
35
+ init_esm_shims();
36
+ }
37
+ });
38
+
21
39
  // src/utils/errors.ts
22
40
  import * as clack from "@clack/prompts";
23
41
  import pc from "picocolors";
@@ -224,11 +242,28 @@ function estimateStorageSize(emailsPerMonth, retention, numEventTypes = 8) {
224
242
  "7days": 0.25,
225
243
  "30days": 1,
226
244
  "90days": 3,
245
+ "6months": 6,
246
+ "1year": 12,
247
+ "18months": 18,
248
+ indefinite: 120
249
+ // Assume 10 years for cost estimation
250
+ }[retention];
251
+ const totalKB = emailsPerMonth * numEventTypes * (retentionMonths ?? 12) * avgRecordSizeKB;
252
+ return totalKB / 1024 / 1024;
253
+ }
254
+ function estimateArchiveStorageSize(emailsPerMonth, retention) {
255
+ const avgEmailSizeKB = 50;
256
+ const retentionMonths = {
257
+ "7days": 0.25,
258
+ "30days": 1,
259
+ "90days": 3,
260
+ "6months": 6,
227
261
  "1year": 12,
228
- indefinite: 24
229
- // Assume 2 years for cost estimation
262
+ "18months": 18,
263
+ indefinite: 120
264
+ // Assume 10 years for cost estimation
230
265
  }[retention];
231
- const totalKB = emailsPerMonth * numEventTypes * retentionMonths * avgRecordSizeKB;
266
+ const totalKB = emailsPerMonth * (retentionMonths ?? 12) * avgEmailSizeKB;
232
267
  return totalKB / 1024 / 1024;
233
268
  }
234
269
  function calculateEventTrackingCost(config, emailsPerMonth) {
@@ -307,19 +342,35 @@ function calculateDedicatedIpCost(config) {
307
342
  description: "Dedicated IP address (requires 100k+ emails/day for warmup)"
308
343
  };
309
344
  }
345
+ function calculateEmailArchivingCost(config, emailsPerMonth) {
346
+ if (!config.emailArchiving?.enabled) {
347
+ return;
348
+ }
349
+ const retention = config.emailArchiving.retention;
350
+ const storageGB = estimateArchiveStorageSize(emailsPerMonth, retention);
351
+ const monthlyDataGB = emailsPerMonth * 50 / 1024 / 1024;
352
+ const ingestionCost = monthlyDataGB * AWS_PRICING.MAIL_MANAGER_INGESTION_PER_GB;
353
+ const storageCost = storageGB * AWS_PRICING.MAIL_MANAGER_STORAGE_PER_GB;
354
+ return {
355
+ monthly: ingestionCost + storageCost,
356
+ description: `Email archiving (${retention}, ~${storageGB.toFixed(2)} GB at steady-state)`
357
+ };
358
+ }
310
359
  function calculateCosts(config, emailsPerMonth = 1e4) {
311
360
  const tracking = calculateTrackingCost(config);
312
361
  const reputationMetrics = calculateReputationMetricsCost(config);
313
362
  const eventTracking = calculateEventTrackingCost(config, emailsPerMonth);
314
363
  const dynamoDBHistory = calculateDynamoDBCost(config, emailsPerMonth);
364
+ const emailArchiving = calculateEmailArchivingCost(config, emailsPerMonth);
315
365
  const dedicatedIp = calculateDedicatedIpCost(config);
316
366
  const sesEmailCost = Math.max(0, emailsPerMonth - FREE_TIER.SES_EMAILS) * AWS_PRICING.SES_PER_EMAIL;
317
- const totalMonthlyCost = sesEmailCost + (tracking?.monthly || 0) + (reputationMetrics?.monthly || 0) + (eventTracking?.monthly || 0) + (dynamoDBHistory?.monthly || 0) + (dedicatedIp?.monthly || 0);
367
+ const totalMonthlyCost = sesEmailCost + (tracking?.monthly || 0) + (reputationMetrics?.monthly || 0) + (eventTracking?.monthly || 0) + (dynamoDBHistory?.monthly || 0) + (emailArchiving?.monthly || 0) + (dedicatedIp?.monthly || 0);
318
368
  return {
319
369
  tracking,
320
370
  reputationMetrics,
321
371
  eventTracking,
322
372
  dynamoDBHistory,
373
+ emailArchiving,
323
374
  dedicatedIp,
324
375
  total: {
325
376
  monthly: totalMonthlyCost,
@@ -366,6 +417,11 @@ function getCostSummary(config, emailsPerMonth = 1e4) {
366
417
  ` - ${costs.dynamoDBHistory.description}: ${formatCost(costs.dynamoDBHistory.monthly)}`
367
418
  );
368
419
  }
420
+ if (costs.emailArchiving) {
421
+ lines.push(
422
+ ` - ${costs.emailArchiving.description}: ${formatCost(costs.emailArchiving.monthly)}`
423
+ );
424
+ }
369
425
  if (costs.dedicatedIp) {
370
426
  lines.push(
371
427
  ` - ${costs.dedicatedIp.description}: ${formatCost(costs.dedicatedIp.monthly)}`
@@ -408,8 +464,13 @@ var init_costs = __esm({
408
464
  // CloudWatch pricing
409
465
  CLOUDWATCH_LOGS_PER_GB: 0.5,
410
466
  // $0.50 per GB ingested
411
- CLOUDWATCH_LOGS_STORAGE_PER_GB: 0.03
467
+ CLOUDWATCH_LOGS_STORAGE_PER_GB: 0.03,
412
468
  // $0.03 per GB-month
469
+ // SES Mail Manager Archiving
470
+ MAIL_MANAGER_INGESTION_PER_GB: 2,
471
+ // $2.00 per GB ingested
472
+ MAIL_MANAGER_STORAGE_PER_GB: 0.19
473
+ // $0.19 per GB-month
413
474
  };
414
475
  FREE_TIER = {
415
476
  // SES: 3,000 emails/month for first 12 months (new AWS accounts only)
@@ -487,7 +548,8 @@ function getPresetInfo(preset) {
487
548
  features: [
488
549
  "Open & click tracking",
489
550
  "TLS encryption required",
490
- "Automatic bounce/complaint suppression"
551
+ "Automatic bounce/complaint suppression",
552
+ "Optional: Email archiving (full content storage)"
491
553
  ]
492
554
  },
493
555
  production: {
@@ -500,6 +562,7 @@ function getPresetInfo(preset) {
500
562
  "Reputation metrics dashboard",
501
563
  "Real-time event tracking (EventBridge)",
502
564
  "90-day email history storage",
565
+ "Optional: Email archiving with rendered viewer",
503
566
  "Complete event visibility"
504
567
  ]
505
568
  },
@@ -512,6 +575,7 @@ function getPresetInfo(preset) {
512
575
  "Everything in Production",
513
576
  "Dedicated IP address",
514
577
  "1-year email history",
578
+ "Optional: 1-year+ email archiving",
515
579
  "All event types tracked",
516
580
  "Priority support eligibility"
517
581
  ]
@@ -594,6 +658,11 @@ var init_presets = __esm({
594
658
  eventTracking: {
595
659
  enabled: false
596
660
  },
661
+ // Email archiving disabled by default
662
+ emailArchiving: {
663
+ enabled: false,
664
+ retention: "30days"
665
+ },
597
666
  sendingEnabled: true
598
667
  };
599
668
  PRODUCTION_PRESET = {
@@ -624,6 +693,12 @@ var init_presets = __esm({
624
693
  dynamoDBHistory: true,
625
694
  archiveRetention: "90days"
626
695
  },
696
+ // Email archiving with 90-day retention
697
+ emailArchiving: {
698
+ enabled: false,
699
+ // User can opt-in
700
+ retention: "90days"
701
+ },
627
702
  sendingEnabled: true
628
703
  };
629
704
  ENTERPRISE_PRESET = {
@@ -656,6 +731,12 @@ var init_presets = __esm({
656
731
  dynamoDBHistory: true,
657
732
  archiveRetention: "1year"
658
733
  },
734
+ // Email archiving with 1-year retention
735
+ emailArchiving: {
736
+ enabled: false,
737
+ // User can opt-in
738
+ retention: "1year"
739
+ },
659
740
  dedicatedIp: true,
660
741
  sendingEnabled: true
661
742
  };
@@ -672,6 +753,7 @@ __export(prompts_exports, {
672
753
  promptConflictResolution: () => promptConflictResolution,
673
754
  promptCustomConfig: () => promptCustomConfig,
674
755
  promptDomain: () => promptDomain,
756
+ promptEmailArchiving: () => promptEmailArchiving,
675
757
  promptEstimatedVolume: () => promptEstimatedVolume,
676
758
  promptFeatureSelection: () => promptFeatureSelection,
677
759
  promptIntegrationLevel: () => promptIntegrationLevel,
@@ -1033,6 +1115,59 @@ async function promptEstimatedVolume() {
1033
1115
  }
1034
1116
  return volume;
1035
1117
  }
1118
+ async function promptEmailArchiving() {
1119
+ const enabled = await clack3.confirm({
1120
+ message: "Enable email archiving? (Store full email content with HTML for viewing in dashboard)",
1121
+ initialValue: false
1122
+ });
1123
+ if (clack3.isCancel(enabled)) {
1124
+ clack3.cancel("Operation cancelled.");
1125
+ process.exit(0);
1126
+ }
1127
+ if (!enabled) {
1128
+ return { enabled: false, retention: "90days" };
1129
+ }
1130
+ const retention = await clack3.select({
1131
+ message: "Email archive retention period:",
1132
+ options: [
1133
+ { value: "7days", label: "7 days", hint: "~$1-2/mo for 10k emails" },
1134
+ { value: "30days", label: "30 days", hint: "~$2-4/mo for 10k emails" },
1135
+ {
1136
+ value: "90days",
1137
+ label: "90 days (recommended)",
1138
+ hint: "~$5-10/mo for 10k emails"
1139
+ },
1140
+ {
1141
+ value: "6months",
1142
+ label: "6 months",
1143
+ hint: "~$15-25/mo for 10k emails"
1144
+ },
1145
+ { value: "1year", label: "1 year", hint: "~$25-40/mo for 10k emails" },
1146
+ {
1147
+ value: "18months",
1148
+ label: "18 months",
1149
+ hint: "~$35-60/mo for 10k emails"
1150
+ }
1151
+ ],
1152
+ initialValue: "90days"
1153
+ });
1154
+ if (clack3.isCancel(retention)) {
1155
+ clack3.cancel("Operation cancelled.");
1156
+ process.exit(0);
1157
+ }
1158
+ clack3.log.info(
1159
+ pc3.dim(
1160
+ "Archiving stores full RFC 822 emails with HTML, attachments, and headers"
1161
+ )
1162
+ );
1163
+ clack3.log.info(
1164
+ pc3.dim("Cost: $2/GB ingestion + $0.19/GB/month storage (~50KB per email)")
1165
+ );
1166
+ return {
1167
+ enabled: true,
1168
+ retention
1169
+ };
1170
+ }
1036
1171
  async function promptCustomConfig() {
1037
1172
  clack3.log.info("Custom configuration builder");
1038
1173
  clack3.log.info("Configure each feature individually");
@@ -1112,6 +1247,53 @@ async function promptCustomConfig() {
1112
1247
  clack3.cancel("Operation cancelled.");
1113
1248
  process.exit(0);
1114
1249
  }
1250
+ const emailArchivingEnabled = await clack3.confirm({
1251
+ message: "Enable email archiving? (Store full email content with HTML for viewing)",
1252
+ initialValue: false
1253
+ });
1254
+ if (clack3.isCancel(emailArchivingEnabled)) {
1255
+ clack3.cancel("Operation cancelled.");
1256
+ process.exit(0);
1257
+ }
1258
+ let emailArchiveRetention = "90days";
1259
+ if (emailArchivingEnabled) {
1260
+ emailArchiveRetention = await clack3.select({
1261
+ message: "Email archive retention period:",
1262
+ options: [
1263
+ { value: "7days", label: "7 days", hint: "~$1-2/mo for 10k emails" },
1264
+ { value: "30days", label: "30 days", hint: "~$2-4/mo for 10k emails" },
1265
+ {
1266
+ value: "90days",
1267
+ label: "90 days (recommended)",
1268
+ hint: "~$5-10/mo for 10k emails"
1269
+ },
1270
+ {
1271
+ value: "6months",
1272
+ label: "6 months",
1273
+ hint: "~$15-25/mo for 10k emails"
1274
+ },
1275
+ { value: "1year", label: "1 year", hint: "~$25-40/mo for 10k emails" },
1276
+ {
1277
+ value: "18months",
1278
+ label: "18 months",
1279
+ hint: "~$35-60/mo for 10k emails"
1280
+ }
1281
+ ],
1282
+ initialValue: "90days"
1283
+ });
1284
+ if (clack3.isCancel(emailArchiveRetention)) {
1285
+ clack3.cancel("Operation cancelled.");
1286
+ process.exit(0);
1287
+ }
1288
+ clack3.log.info(
1289
+ pc3.dim(
1290
+ "Note: Archiving stores full RFC 822 emails with HTML, attachments, and headers"
1291
+ )
1292
+ );
1293
+ clack3.log.info(
1294
+ pc3.dim("Cost: $2/GB ingestion + $0.19/GB/month storage (~50KB per email)")
1295
+ );
1296
+ }
1115
1297
  return {
1116
1298
  tracking: trackingEnabled ? {
1117
1299
  enabled: true,
@@ -1140,6 +1322,10 @@ async function promptCustomConfig() {
1140
1322
  dynamoDBHistory: Boolean(dynamoDBHistory),
1141
1323
  archiveRetention: typeof archiveRetention === "string" ? archiveRetention : "90days"
1142
1324
  } : { enabled: false },
1325
+ emailArchiving: emailArchivingEnabled ? {
1326
+ enabled: true,
1327
+ retention: typeof emailArchiveRetention === "string" ? emailArchiveRetention : "90days"
1328
+ } : { enabled: false, retention: "90days" },
1143
1329
  dedicatedIp,
1144
1330
  sendingEnabled: true
1145
1331
  };
@@ -1184,6 +1370,114 @@ var init_assume_role = __esm({
1184
1370
  }
1185
1371
  });
1186
1372
 
1373
+ // src/utils/archive.ts
1374
+ import {
1375
+ GetArchiveMessageCommand,
1376
+ MailManagerClient
1377
+ } from "@aws-sdk/client-mailmanager";
1378
+ import { simpleParser } from "mailparser";
1379
+ async function getArchivedEmail(_archiveId, messageId, region) {
1380
+ const client = new MailManagerClient({ region });
1381
+ const command2 = new GetArchiveMessageCommand({
1382
+ ArchivedMessageId: messageId
1383
+ });
1384
+ const response = await client.send(command2);
1385
+ if (!response.MessageDownloadLink) {
1386
+ throw new Error("No download link available for archived message");
1387
+ }
1388
+ const emailResponse = await fetch(response.MessageDownloadLink);
1389
+ if (!emailResponse.ok) {
1390
+ throw new Error(`Failed to download email: ${emailResponse.statusText}`);
1391
+ }
1392
+ const emailRaw = await emailResponse.text();
1393
+ const parsed = await simpleParser(emailRaw);
1394
+ const attachments = parsed.attachments?.map((att) => ({
1395
+ filename: att.filename,
1396
+ contentType: att.contentType,
1397
+ size: att.size
1398
+ })) || [];
1399
+ const headers = {};
1400
+ if (parsed.headers) {
1401
+ for (const [key, value] of parsed.headers) {
1402
+ if (value instanceof Date) {
1403
+ headers[key] = value.toISOString();
1404
+ } else if (typeof value === "string") {
1405
+ headers[key] = value;
1406
+ } else if (Array.isArray(value) && value.every((v) => typeof v === "string")) {
1407
+ headers[key] = value;
1408
+ } else {
1409
+ headers[key] = JSON.stringify(value);
1410
+ }
1411
+ }
1412
+ }
1413
+ const getAddressText = (addr) => {
1414
+ if (!addr) return "";
1415
+ if (Array.isArray(addr)) {
1416
+ return addr.map((a) => a.text).join(", ");
1417
+ }
1418
+ return addr.text || "";
1419
+ };
1420
+ return {
1421
+ messageId,
1422
+ // Use the input messageId since response may not have MessageMetadata
1423
+ from: getAddressText(parsed.from),
1424
+ to: getAddressText(parsed.to),
1425
+ subject: parsed.subject || "",
1426
+ html: parsed.html || void 0,
1427
+ text: parsed.text || void 0,
1428
+ attachments,
1429
+ headers,
1430
+ timestamp: parsed.date || /* @__PURE__ */ new Date(),
1431
+ // Note: MessageMetadata is not available in GetArchiveMessageCommandOutput
1432
+ // These fields would need to be retrieved separately if needed
1433
+ metadata: {}
1434
+ };
1435
+ }
1436
+ var init_archive = __esm({
1437
+ "src/utils/archive.ts"() {
1438
+ "use strict";
1439
+ init_esm_shims();
1440
+ }
1441
+ });
1442
+
1443
+ // src/console/services/email-archive.ts
1444
+ var email_archive_exports = {};
1445
+ __export(email_archive_exports, {
1446
+ fetchArchivedEmail: () => fetchArchivedEmail
1447
+ });
1448
+ async function fetchArchivedEmail(messageId, options) {
1449
+ const { region, archiveArn } = options;
1450
+ try {
1451
+ console.log("Fetching archived email:", {
1452
+ messageId,
1453
+ archiveArn,
1454
+ region
1455
+ });
1456
+ const email = await getArchivedEmail(archiveArn, messageId, region);
1457
+ console.log("Archived email fetched successfully:", {
1458
+ messageId: email.messageId,
1459
+ hasHtml: !!email.html,
1460
+ hasText: !!email.text,
1461
+ attachmentCount: email.attachments.length
1462
+ });
1463
+ return email;
1464
+ } catch (error) {
1465
+ if (error instanceof Error && (error.message.includes("not found") || error.message.includes("ResourceNotFoundException"))) {
1466
+ console.log("Archived email not found:", messageId);
1467
+ return null;
1468
+ }
1469
+ console.error("Error fetching archived email:", error);
1470
+ throw error;
1471
+ }
1472
+ }
1473
+ var init_email_archive = __esm({
1474
+ "src/console/services/email-archive.ts"() {
1475
+ "use strict";
1476
+ init_esm_shims();
1477
+ init_archive();
1478
+ }
1479
+ });
1480
+
1187
1481
  // src/console/services/dynamodb-metrics.ts
1188
1482
  var dynamodb_metrics_exports = {};
1189
1483
  __export(dynamodb_metrics_exports, {
@@ -1285,7 +1579,7 @@ async function findHostedZone(domain, region) {
1285
1579
  return null;
1286
1580
  }
1287
1581
  }
1288
- async function createDNSRecords(hostedZoneId, domain, dkimTokens, region, customTrackingDomain) {
1582
+ async function createDNSRecords(hostedZoneId, domain, dkimTokens, region, customTrackingDomain, mailFromDomain) {
1289
1583
  const client = new Route53Client({ region });
1290
1584
  const changes = [];
1291
1585
  for (const token of dkimTokens) {
@@ -1330,6 +1624,28 @@ async function createDNSRecords(hostedZoneId, domain, dkimTokens, region, custom
1330
1624
  }
1331
1625
  });
1332
1626
  }
1627
+ if (mailFromDomain) {
1628
+ changes.push({
1629
+ Action: "UPSERT",
1630
+ ResourceRecordSet: {
1631
+ Name: mailFromDomain,
1632
+ Type: "MX",
1633
+ TTL: 1800,
1634
+ ResourceRecords: [
1635
+ { Value: `10 feedback-smtp.${region}.amazonses.com` }
1636
+ ]
1637
+ }
1638
+ });
1639
+ changes.push({
1640
+ Action: "UPSERT",
1641
+ ResourceRecordSet: {
1642
+ Name: mailFromDomain,
1643
+ Type: "TXT",
1644
+ TTL: 1800,
1645
+ ResourceRecords: [{ Value: '"v=spf1 include:amazonses.com ~all"' }]
1646
+ }
1647
+ });
1648
+ }
1333
1649
  await client.send(
1334
1650
  new ChangeResourceRecordSetsCommand({
1335
1651
  HostedZoneId: hostedZoneId,
@@ -1560,6 +1876,19 @@ async function createIAMRole(config) {
1560
1876
  Resource: "arn:aws:sqs:*:*:wraps-email-*"
1561
1877
  });
1562
1878
  }
1879
+ if (config.emailConfig.emailArchiving?.enabled) {
1880
+ statements.push({
1881
+ Effect: "Allow",
1882
+ Action: [
1883
+ "ses:GetArchive",
1884
+ "ses:GetArchiveMessage",
1885
+ "ses:GetArchiveMessageContent",
1886
+ "ses:SearchArchive",
1887
+ "ses:StartArchiveExport"
1888
+ ],
1889
+ Resource: "arn:aws:ses:*:*:mailmanager-archive/*"
1890
+ });
1891
+ }
1563
1892
  new aws3.iam.RolePolicy("wraps-email-policy", {
1564
1893
  role: role.name,
1565
1894
  policy: JSON.stringify({
@@ -1742,8 +2071,9 @@ async function createSESResources(config) {
1742
2071
  if (config.trackingConfig?.customRedirectDomain) {
1743
2072
  configSetOptions.trackingOptions = {
1744
2073
  customRedirectDomain: config.trackingConfig.customRedirectDomain,
1745
- httpsPolicy: "REQUIRE"
1746
- // Always require HTTPS for security
2074
+ // Use OPTIONAL because custom domains don't have SSL certificates by default
2075
+ // AWS's tracking domain (r.{region}.awstrack.me) doesn't have certs for custom domains
2076
+ httpsPolicy: "OPTIONAL"
1747
2077
  };
1748
2078
  }
1749
2079
  const configSet = new aws5.sesv2.ConfigurationSet(
@@ -1778,6 +2108,7 @@ async function createSESResources(config) {
1778
2108
  });
1779
2109
  let domainIdentity;
1780
2110
  let dkimTokens;
2111
+ let mailFromDomain;
1781
2112
  if (config.domain) {
1782
2113
  domainIdentity = new aws5.sesv2.EmailIdentity("wraps-email-domain", {
1783
2114
  emailIdentity: config.domain,
@@ -1793,6 +2124,20 @@ async function createSESResources(config) {
1793
2124
  dkimTokens = domainIdentity.dkimSigningAttributes.apply(
1794
2125
  (attrs) => attrs?.tokens || []
1795
2126
  );
2127
+ mailFromDomain = config.mailFromDomain || `mail.${config.domain}`;
2128
+ new aws5.sesv2.EmailIdentityMailFromAttributes(
2129
+ "wraps-email-mail-from",
2130
+ {
2131
+ emailIdentity: config.domain,
2132
+ mailFromDomain,
2133
+ behaviorOnMxFailure: "USE_DEFAULT_VALUE"
2134
+ // Fallback to amazonses.com if MX record fails
2135
+ },
2136
+ {
2137
+ dependsOn: [domainIdentity]
2138
+ // Ensure domain identity exists first
2139
+ }
2140
+ );
1796
2141
  }
1797
2142
  return {
1798
2143
  configSet,
@@ -1802,7 +2147,8 @@ async function createSESResources(config) {
1802
2147
  dkimTokens,
1803
2148
  dnsAutoCreated: false,
1804
2149
  // Will be set after deployment
1805
- customTrackingDomain: config.trackingConfig?.customRedirectDomain
2150
+ customTrackingDomain: config.trackingConfig?.customRedirectDomain,
2151
+ mailFromDomain
1806
2152
  };
1807
2153
  }
1808
2154
 
@@ -1887,6 +2233,7 @@ async function deployEmailStack(config) {
1887
2233
  if (emailConfig.tracking?.enabled || emailConfig.eventTracking?.enabled) {
1888
2234
  sesResources = await createSESResources({
1889
2235
  domain: emailConfig.domain,
2236
+ mailFromDomain: emailConfig.mailFromDomain,
1890
2237
  region: config.region,
1891
2238
  trackingConfig: emailConfig.tracking,
1892
2239
  eventTypes: emailConfig.eventTracking?.events
@@ -1918,6 +2265,15 @@ async function deployEmailStack(config) {
1918
2265
  accountId
1919
2266
  });
1920
2267
  }
2268
+ let archiveResources;
2269
+ if (emailConfig.emailArchiving?.enabled && sesResources) {
2270
+ const { createMailManagerArchive: createMailManagerArchive2 } = await Promise.resolve().then(() => (init_mail_manager(), mail_manager_exports));
2271
+ archiveResources = await createMailManagerArchive2({
2272
+ name: "email",
2273
+ retention: emailConfig.emailArchiving.retention,
2274
+ configSetName: sesResources.configSet.configurationSetName
2275
+ });
2276
+ }
1921
2277
  return {
1922
2278
  roleArn: role.arn,
1923
2279
  configSetName: sesResources?.configSet.configurationSetName,
@@ -1930,7 +2286,11 @@ async function deployEmailStack(config) {
1930
2286
  eventBusName: sesResources?.eventBus.name,
1931
2287
  queueUrl: sqsResources?.queue.url,
1932
2288
  dlqUrl: sqsResources?.dlq.url,
1933
- customTrackingDomain: sesResources?.customTrackingDomain
2289
+ customTrackingDomain: sesResources?.customTrackingDomain,
2290
+ mailFromDomain: sesResources?.mailFromDomain,
2291
+ archiveArn: archiveResources?.archive.arn,
2292
+ archivingEnabled: emailConfig.emailArchiving?.enabled,
2293
+ archiveRetention: emailConfig.emailArchiving?.enabled ? emailConfig.emailArchiving.retention : void 0
1934
2294
  };
1935
2295
  }
1936
2296
 
@@ -2149,6 +2509,14 @@ Verification should complete within a few minutes.`,
2149
2509
  pc2.bold("DMARC Record (TXT):"),
2150
2510
  ` ${pc2.cyan(`_dmarc.${domain}`)} ${pc2.dim("TXT")} "v=DMARC1; p=quarantine; rua=mailto:postmaster@${domain}"`
2151
2511
  );
2512
+ if (outputs.mailFromDomain) {
2513
+ dnsLines.push(
2514
+ "",
2515
+ pc2.bold("MAIL FROM Domain Records (for DMARC alignment):"),
2516
+ ` ${pc2.cyan(outputs.mailFromDomain)} ${pc2.dim("MX")} "10 feedback-smtp.${outputs.region}.amazonses.com"`,
2517
+ ` ${pc2.cyan(outputs.mailFromDomain)} ${pc2.dim("TXT")} "v=spf1 include:amazonses.com ~all"`
2518
+ );
2519
+ }
2152
2520
  }
2153
2521
  clack2.note(dnsLines.join("\n"), "DNS Records to add:");
2154
2522
  }
@@ -2201,7 +2569,14 @@ function displayStatus(status2) {
2201
2569
  const domainStrings = status2.domains.map((d) => {
2202
2570
  const statusIcon = d.status === "verified" ? "\u2713" : d.status === "pending" ? "\u23F1" : "\u2717";
2203
2571
  const statusColor = d.status === "verified" ? pc2.green : d.status === "pending" ? pc2.yellow : pc2.red;
2204
- return ` ${d.domain} ${statusColor(`${statusIcon} ${d.status}`)}`;
2572
+ let domainLine = ` ${d.domain} ${statusColor(`${statusIcon} ${d.status}`)}`;
2573
+ if (d.mailFromDomain) {
2574
+ const mailFromStatusIcon = d.mailFromStatus === "SUCCESS" ? "\u2713" : "\u23F1";
2575
+ const mailFromColor = d.mailFromStatus === "SUCCESS" ? pc2.green : pc2.yellow;
2576
+ domainLine += `
2577
+ ${pc2.dim("MAIL FROM:")} ${d.mailFromDomain} ${mailFromColor(mailFromStatusIcon)}`;
2578
+ }
2579
+ return domainLine;
2205
2580
  });
2206
2581
  infoLines.push(`${pc2.bold("Domains:")}
2207
2582
  ${domainStrings.join("\n")}`);
@@ -2227,6 +2602,23 @@ ${domainStrings.join("\n")}`);
2227
2602
  ` ${pc2.dim("\u25CB")} Bounce/Complaint Handling ${pc2.dim("(run 'wraps upgrade' to enable)")}`
2228
2603
  );
2229
2604
  }
2605
+ if (status2.resources.archivingEnabled) {
2606
+ const retentionLabel = {
2607
+ "7days": "7 days",
2608
+ "30days": "30 days",
2609
+ "90days": "90 days",
2610
+ "6months": "6 months",
2611
+ "1year": "1 year",
2612
+ "18months": "18 months"
2613
+ }[status2.resources.archiveRetention || "90days"] || "90 days";
2614
+ featureLines.push(
2615
+ ` ${pc2.green("\u2713")} Email Archiving ${pc2.dim(`(${retentionLabel} retention)`)}`
2616
+ );
2617
+ } else {
2618
+ featureLines.push(
2619
+ ` ${pc2.dim("\u25CB")} Email Archiving ${pc2.dim("(run 'wraps upgrade' to enable)")}`
2620
+ );
2621
+ }
2230
2622
  featureLines.push(
2231
2623
  ` ${pc2.green("\u2713")} Console Dashboard ${pc2.dim("(run 'wraps console')")}`
2232
2624
  );
@@ -2259,14 +2651,20 @@ ${domainStrings.join("\n")}`);
2259
2651
  ` ${pc2.green("\u2713")} SNS Topics: ${pc2.cyan(`${status2.resources.snsTopics} configured`)}`
2260
2652
  );
2261
2653
  }
2654
+ if (status2.resources.archiveArn) {
2655
+ resourceLines.push(
2656
+ ` ${pc2.green("\u2713")} Mail Manager Archive: ${pc2.cyan(status2.resources.archiveArn)}`
2657
+ );
2658
+ }
2262
2659
  clack2.note(resourceLines.join("\n"), "Resources");
2263
- const pendingDomains = status2.domains.filter(
2264
- (d) => d.status === "pending" && d.dkimTokens
2660
+ const domainsNeedingDNS = status2.domains.filter(
2661
+ (d) => d.status === "pending" && d.dkimTokens || d.mailFromDomain && d.mailFromStatus !== "SUCCESS"
2265
2662
  );
2266
- if (pendingDomains.length > 0) {
2267
- for (const domain of pendingDomains) {
2268
- if (domain.dkimTokens && domain.dkimTokens.length > 0) {
2269
- const dnsLines = [
2663
+ if (domainsNeedingDNS.length > 0) {
2664
+ for (const domain of domainsNeedingDNS) {
2665
+ const dnsLines = [];
2666
+ if (domain.status === "pending" && domain.dkimTokens && domain.dkimTokens.length > 0) {
2667
+ dnsLines.push(
2270
2668
  pc2.bold("DKIM Records (CNAME):"),
2271
2669
  ...domain.dkimTokens.map(
2272
2670
  (token) => ` ${pc2.cyan(`${token}._domainkey.${domain.domain}`)} ${pc2.dim("CNAME")} "${token}.dkim.amazonses.com"`
@@ -2277,11 +2675,23 @@ ${domainStrings.join("\n")}`);
2277
2675
  "",
2278
2676
  pc2.bold("DMARC Record (TXT):"),
2279
2677
  ` ${pc2.cyan(`_dmarc.${domain.domain}`)} ${pc2.dim("TXT")} "v=DMARC1; p=quarantine; rua=mailto:postmaster@${domain.domain}"`
2280
- ];
2678
+ );
2679
+ }
2680
+ if (domain.mailFromDomain && domain.mailFromStatus !== "SUCCESS") {
2681
+ if (dnsLines.length > 0) {
2682
+ dnsLines.push("");
2683
+ }
2684
+ dnsLines.push(
2685
+ pc2.bold("MAIL FROM Domain Records (for DMARC alignment):"),
2686
+ ` ${pc2.cyan(domain.mailFromDomain)} ${pc2.dim("MX")} "10 feedback-smtp.${status2.region}.amazonses.com"`,
2687
+ ` ${pc2.cyan(domain.mailFromDomain)} ${pc2.dim("TXT")} "v=spf1 include:amazonses.com ~all"`
2688
+ );
2689
+ }
2690
+ if (dnsLines.length > 0) {
2281
2691
  clack2.note(dnsLines.join("\n"), `DNS Records for ${domain.domain}`);
2282
2692
  }
2283
2693
  }
2284
- const exampleDomain = pendingDomains[0].domain;
2694
+ const exampleDomain = domainsNeedingDNS[0].domain;
2285
2695
  console.log(
2286
2696
  `
2287
2697
  ${pc2.dim("Run:")} ${pc2.yellow(`wraps verify --domain ${exampleDomain}`)} ${pc2.dim(
@@ -3205,6 +3615,46 @@ function createEmailsRouter(config) {
3205
3615
  res.status(500).json({ error: errorMessage });
3206
3616
  }
3207
3617
  });
3618
+ router.get("/:id/archive", async (req, res) => {
3619
+ try {
3620
+ const { id } = req.params;
3621
+ console.log("Archived email request received for message ID:", id);
3622
+ if (!config.archivingEnabled) {
3623
+ console.log("Email archiving not enabled");
3624
+ return res.status(400).json({
3625
+ error: "Email archiving not enabled for this deployment."
3626
+ });
3627
+ }
3628
+ if (!config.archiveArn) {
3629
+ console.log("No archive ARN configured");
3630
+ return res.status(400).json({
3631
+ error: "Archive ARN not configured."
3632
+ });
3633
+ }
3634
+ console.log("Fetching archived email from Mail Manager...");
3635
+ const { fetchArchivedEmail: fetchArchivedEmail2 } = await Promise.resolve().then(() => (init_email_archive(), email_archive_exports));
3636
+ const archivedEmail = await fetchArchivedEmail2(id, {
3637
+ region: config.region,
3638
+ archiveArn: config.archiveArn
3639
+ });
3640
+ if (!archivedEmail) {
3641
+ console.log("Archived email not found for message ID:", id);
3642
+ return res.status(404).json({
3643
+ error: "Archived email not found. It may have been sent before archiving was enabled."
3644
+ });
3645
+ }
3646
+ console.log("Archived email found:", archivedEmail.messageId);
3647
+ res.json(archivedEmail);
3648
+ } catch (error) {
3649
+ const errorMessage = error instanceof Error ? error.message : "Unknown error";
3650
+ console.error("Error fetching archived email:", error);
3651
+ console.error(
3652
+ "Stack trace:",
3653
+ error instanceof Error ? error.stack : "N/A"
3654
+ );
3655
+ res.status(500).json({ error: errorMessage });
3656
+ }
3657
+ });
3208
3658
  return router;
3209
3659
  }
3210
3660
 
@@ -3491,7 +3941,9 @@ async function fetchEmailIdentity(roleArn, region, identityName) {
3491
3941
  verifiedForSendingStatus: response.VerifiedForSendingStatus ?? false,
3492
3942
  tags: response.Tags?.reduce(
3493
3943
  (acc, tag) => {
3494
- if (tag.Key) acc[tag.Key] = tag.Value || "";
3944
+ if (tag.Key) {
3945
+ acc[tag.Key] = tag.Value || "";
3946
+ }
3495
3947
  return acc;
3496
3948
  },
3497
3949
  {}
@@ -3524,6 +3976,20 @@ async function fetchEmailSettings(roleArn, region, configSetName, domain) {
3524
3976
  // src/console/routes/settings.ts
3525
3977
  function createSettingsRouter(config) {
3526
3978
  const router = createRouter4();
3979
+ router.get("/deployment", async (_req, res) => {
3980
+ try {
3981
+ res.json({
3982
+ archivingEnabled: config.archivingEnabled ?? false,
3983
+ archiveArn: config.archiveArn,
3984
+ tableName: config.tableName,
3985
+ region: config.region
3986
+ });
3987
+ } catch (error) {
3988
+ const errorMessage = error instanceof Error ? error.message : "Unknown error";
3989
+ console.error("Error fetching deployment config:", error);
3990
+ res.status(500).json({ error: errorMessage });
3991
+ }
3992
+ });
3527
3993
  router.get("/", async (_req, res) => {
3528
3994
  try {
3529
3995
  const metadata = await loadConnectionMetadata(
@@ -3899,6 +4365,8 @@ async function runConsole(options) {
3899
4365
  process.exit(1);
3900
4366
  }
3901
4367
  const tableName = stackOutputs.tableName?.value;
4368
+ const archiveArn = stackOutputs.archiveArn?.value;
4369
+ const archivingEnabled = stackOutputs.archivingEnabled?.value ?? false;
3902
4370
  const port = options.port || await getPort({ port: [5555, 5556, 5557, 5558, 5559] });
3903
4371
  progress.stop();
3904
4372
  clack5.log.success("Starting console server...");
@@ -3912,7 +4380,9 @@ async function runConsole(options) {
3912
4380
  region,
3913
4381
  tableName,
3914
4382
  accountId: identity.accountId,
3915
- noOpen: options.noOpen ?? false
4383
+ noOpen: options.noOpen ?? false,
4384
+ archiveArn,
4385
+ archivingEnabled
3916
4386
  });
3917
4387
  console.log(`\\n${pc5.bold("Console:")} ${pc5.cyan(url)}`);
3918
4388
  console.log(`${pc5.dim("Press Ctrl+C to stop")}\\n`);
@@ -4052,17 +4522,22 @@ async function init(options) {
4052
4522
  emailConfig = await promptCustomConfig();
4053
4523
  } else {
4054
4524
  emailConfig = getPreset(preset);
4525
+ const { promptEmailArchiving: promptEmailArchiving2 } = await Promise.resolve().then(() => (init_prompts(), prompts_exports));
4526
+ const archivingConfig = await promptEmailArchiving2();
4527
+ emailConfig.emailArchiving = archivingConfig;
4055
4528
  }
4056
4529
  if (domain) {
4057
4530
  emailConfig.domain = domain;
4058
4531
  }
4059
4532
  const estimatedVolume = await promptEstimatedVolume();
4060
- progress.info("\n" + pc7.bold("Cost Estimate:"));
4533
+ progress.info(`
4534
+ ${pc7.bold("Cost Estimate:")}`);
4061
4535
  const costSummary = getCostSummary(emailConfig, estimatedVolume);
4062
4536
  clack7.log.info(costSummary);
4063
4537
  const warnings = validateConfig(emailConfig);
4064
4538
  if (warnings.length > 0) {
4065
- progress.info("\n" + pc7.yellow(pc7.bold("Configuration Warnings:")));
4539
+ progress.info(`
4540
+ ${pc7.yellow(pc7.bold("Configuration Warnings:"))}`);
4066
4541
  for (const warning of warnings) {
4067
4542
  clack7.log.warn(warning);
4068
4543
  }
@@ -4110,7 +4585,11 @@ async function init(options) {
4110
4585
  lambdaFunctions: result.lambdaFunctions,
4111
4586
  domain: result.domain,
4112
4587
  dkimTokens: result.dkimTokens,
4113
- customTrackingDomain: result.customTrackingDomain
4588
+ customTrackingDomain: result.customTrackingDomain,
4589
+ mailFromDomain: result.mailFromDomain,
4590
+ archiveArn: result.archiveArn,
4591
+ archivingEnabled: result.archivingEnabled,
4592
+ archiveRetention: result.archiveRetention
4114
4593
  };
4115
4594
  }
4116
4595
  },
@@ -4139,7 +4618,11 @@ async function init(options) {
4139
4618
  lambdaFunctions: pulumiOutputs.lambdaFunctions?.value,
4140
4619
  domain: pulumiOutputs.domain?.value,
4141
4620
  dkimTokens: pulumiOutputs.dkimTokens?.value,
4142
- customTrackingDomain: pulumiOutputs.customTrackingDomain?.value
4621
+ customTrackingDomain: pulumiOutputs.customTrackingDomain?.value,
4622
+ mailFromDomain: pulumiOutputs.mailFromDomain?.value,
4623
+ archiveArn: pulumiOutputs.archiveArn?.value,
4624
+ archivingEnabled: pulumiOutputs.archivingEnabled?.value,
4625
+ archiveRetention: pulumiOutputs.archiveRetention?.value
4143
4626
  };
4144
4627
  }
4145
4628
  );
@@ -4168,7 +4651,8 @@ async function init(options) {
4168
4651
  outputs.domain,
4169
4652
  outputs.dkimTokens,
4170
4653
  region,
4171
- outputs.customTrackingDomain
4654
+ outputs.customTrackingDomain,
4655
+ outputs.mailFromDomain
4172
4656
  );
4173
4657
  progress.succeed("DNS records created in Route53");
4174
4658
  dnsAutoCreated = true;
@@ -4195,7 +4679,8 @@ async function init(options) {
4195
4679
  tableName: outputs.tableName,
4196
4680
  dnsRecords: dnsRecords.length > 0 ? dnsRecords : void 0,
4197
4681
  dnsAutoCreated,
4198
- domain: outputs.domain
4682
+ domain: outputs.domain,
4683
+ mailFromDomain: outputs.mailFromDomain
4199
4684
  });
4200
4685
  }
4201
4686
 
@@ -4345,13 +4830,17 @@ Run ${pc9.cyan("wraps init")} to deploy infrastructure.
4345
4830
  return {
4346
4831
  domain: d.domain,
4347
4832
  status: d.verified ? "verified" : "pending",
4348
- dkimTokens: identity2.DkimAttributes?.Tokens || []
4833
+ dkimTokens: identity2.DkimAttributes?.Tokens || [],
4834
+ mailFromDomain: identity2.MailFromAttributes?.MailFromDomain,
4835
+ mailFromStatus: identity2.MailFromAttributes?.MailFromDomainStatus
4349
4836
  };
4350
4837
  } catch (_error) {
4351
4838
  return {
4352
4839
  domain: d.domain,
4353
4840
  status: d.verified ? "verified" : "pending",
4354
- dkimTokens: void 0
4841
+ dkimTokens: void 0,
4842
+ mailFromDomain: void 0,
4843
+ mailFromStatus: void 0
4355
4844
  };
4356
4845
  }
4357
4846
  })
@@ -4367,7 +4856,10 @@ Run ${pc9.cyan("wraps init")} to deploy infrastructure.
4367
4856
  configSetName: stackOutputs.configSetName?.value,
4368
4857
  tableName: stackOutputs.tableName?.value,
4369
4858
  lambdaFunctions: stackOutputs.lambdaFunctions?.value?.length || 0,
4370
- snsTopics: integrationLevel === "enhanced" ? 1 : 0
4859
+ snsTopics: integrationLevel === "enhanced" ? 1 : 0,
4860
+ archiveArn: stackOutputs.archiveArn?.value,
4861
+ archivingEnabled: stackOutputs.archivingEnabled?.value,
4862
+ archiveRetention: stackOutputs.archiveRetention?.value
4371
4863
  }
4372
4864
  });
4373
4865
  }
@@ -4634,6 +5126,18 @@ ${pc11.bold("Current Configuration:")}
4634
5126
  if (config.dedicatedIp) {
4635
5127
  console.log(` ${pc11.green("\u2713")} Dedicated IP Address`);
4636
5128
  }
5129
+ if (config.emailArchiving?.enabled) {
5130
+ const retentionLabel = {
5131
+ "7days": "7 days",
5132
+ "30days": "30 days",
5133
+ "90days": "90 days",
5134
+ "6months": "6 months",
5135
+ "1year": "1 year",
5136
+ "18months": "18 months",
5137
+ indefinite: "indefinite"
5138
+ }[config.emailArchiving.retention] || "90 days";
5139
+ console.log(` ${pc11.green("\u2713")} Email Archiving (${retentionLabel})`);
5140
+ }
4637
5141
  const currentCostData = calculateCosts(config, 5e4);
4638
5142
  console.log(
4639
5143
  `
@@ -4648,6 +5152,11 @@ ${pc11.bold("Current Configuration:")}
4648
5152
  label: "Upgrade to a different preset",
4649
5153
  hint: "Starter \u2192 Production \u2192 Enterprise"
4650
5154
  },
5155
+ {
5156
+ value: "archiving",
5157
+ label: config.emailArchiving?.enabled ? "Change email archiving settings" : "Enable email archiving",
5158
+ hint: config.emailArchiving?.enabled ? "Update retention or disable" : "Store full email content with HTML"
5159
+ },
4651
5160
  {
4652
5161
  value: "tracking-domain",
4653
5162
  label: "Add/change custom tracking domain",
@@ -4656,7 +5165,7 @@ ${pc11.bold("Current Configuration:")}
4656
5165
  {
4657
5166
  value: "retention",
4658
5167
  label: "Change email history retention",
4659
- hint: "7 days, 30 days, 90 days, 1 year, indefinite"
5168
+ hint: "7 days, 30 days, 90 days, 6 months, 1 year, 18 months"
4660
5169
  },
4661
5170
  {
4662
5171
  value: "events",
@@ -4714,6 +5223,166 @@ ${pc11.bold("Current Configuration:")}
4714
5223
  newPreset = selectedPreset;
4715
5224
  break;
4716
5225
  }
5226
+ case "archiving": {
5227
+ if (config.emailArchiving?.enabled) {
5228
+ const archivingAction = await clack11.select({
5229
+ message: "What would you like to do with email archiving?",
5230
+ options: [
5231
+ {
5232
+ value: "change-retention",
5233
+ label: "Change retention period",
5234
+ hint: `Current: ${config.emailArchiving.retention}`
5235
+ },
5236
+ {
5237
+ value: "disable",
5238
+ label: "Disable email archiving",
5239
+ hint: "Stop storing full email content"
5240
+ }
5241
+ ]
5242
+ });
5243
+ if (clack11.isCancel(archivingAction)) {
5244
+ clack11.cancel("Upgrade cancelled.");
5245
+ process.exit(0);
5246
+ }
5247
+ if (archivingAction === "disable") {
5248
+ const confirmDisable = await clack11.confirm({
5249
+ message: "Are you sure? Existing archived emails will remain, but new emails won't be archived.",
5250
+ initialValue: false
5251
+ });
5252
+ if (clack11.isCancel(confirmDisable) || !confirmDisable) {
5253
+ clack11.cancel("Archiving not disabled.");
5254
+ process.exit(0);
5255
+ }
5256
+ updatedConfig = {
5257
+ ...config,
5258
+ emailArchiving: {
5259
+ enabled: false,
5260
+ retention: config.emailArchiving.retention
5261
+ }
5262
+ };
5263
+ } else {
5264
+ const retention = await clack11.select({
5265
+ message: "Email archive retention period:",
5266
+ options: [
5267
+ {
5268
+ value: "7days",
5269
+ label: "7 days",
5270
+ hint: "~$1-2/mo for 10k emails"
5271
+ },
5272
+ {
5273
+ value: "30days",
5274
+ label: "30 days",
5275
+ hint: "~$2-4/mo for 10k emails"
5276
+ },
5277
+ {
5278
+ value: "90days",
5279
+ label: "90 days (recommended)",
5280
+ hint: "~$5-10/mo for 10k emails"
5281
+ },
5282
+ {
5283
+ value: "6months",
5284
+ label: "6 months",
5285
+ hint: "~$15-25/mo for 10k emails"
5286
+ },
5287
+ {
5288
+ value: "1year",
5289
+ label: "1 year",
5290
+ hint: "~$25-40/mo for 10k emails"
5291
+ },
5292
+ {
5293
+ value: "18months",
5294
+ label: "18 months",
5295
+ hint: "~$35-60/mo for 10k emails"
5296
+ }
5297
+ ],
5298
+ initialValue: config.emailArchiving.retention
5299
+ });
5300
+ if (clack11.isCancel(retention)) {
5301
+ clack11.cancel("Upgrade cancelled.");
5302
+ process.exit(0);
5303
+ }
5304
+ updatedConfig = {
5305
+ ...config,
5306
+ emailArchiving: {
5307
+ enabled: true,
5308
+ retention
5309
+ }
5310
+ };
5311
+ }
5312
+ } else {
5313
+ const enableArchiving = await clack11.confirm({
5314
+ message: "Enable email archiving? (Store full email content with HTML for viewing)",
5315
+ initialValue: true
5316
+ });
5317
+ if (clack11.isCancel(enableArchiving)) {
5318
+ clack11.cancel("Upgrade cancelled.");
5319
+ process.exit(0);
5320
+ }
5321
+ if (!enableArchiving) {
5322
+ clack11.log.info("Email archiving not enabled.");
5323
+ process.exit(0);
5324
+ }
5325
+ const retention = await clack11.select({
5326
+ message: "Email archive retention period:",
5327
+ options: [
5328
+ {
5329
+ value: "7days",
5330
+ label: "7 days",
5331
+ hint: "~$1-2/mo for 10k emails"
5332
+ },
5333
+ {
5334
+ value: "30days",
5335
+ label: "30 days",
5336
+ hint: "~$2-4/mo for 10k emails"
5337
+ },
5338
+ {
5339
+ value: "90days",
5340
+ label: "90 days (recommended)",
5341
+ hint: "~$5-10/mo for 10k emails"
5342
+ },
5343
+ {
5344
+ value: "6months",
5345
+ label: "6 months",
5346
+ hint: "~$15-25/mo for 10k emails"
5347
+ },
5348
+ {
5349
+ value: "1year",
5350
+ label: "1 year",
5351
+ hint: "~$25-40/mo for 10k emails"
5352
+ },
5353
+ {
5354
+ value: "18months",
5355
+ label: "18 months",
5356
+ hint: "~$35-60/mo for 10k emails"
5357
+ }
5358
+ ],
5359
+ initialValue: "90days"
5360
+ });
5361
+ if (clack11.isCancel(retention)) {
5362
+ clack11.cancel("Upgrade cancelled.");
5363
+ process.exit(0);
5364
+ }
5365
+ clack11.log.info(
5366
+ pc11.dim(
5367
+ "Archiving stores full RFC 822 emails with HTML, attachments, and headers"
5368
+ )
5369
+ );
5370
+ clack11.log.info(
5371
+ pc11.dim(
5372
+ "Cost: $2/GB ingestion + $0.19/GB/month storage (~50KB per email)"
5373
+ )
5374
+ );
5375
+ updatedConfig = {
5376
+ ...config,
5377
+ emailArchiving: {
5378
+ enabled: true,
5379
+ retention
5380
+ }
5381
+ };
5382
+ }
5383
+ newPreset = void 0;
5384
+ break;
5385
+ }
4717
5386
  case "tracking-domain": {
4718
5387
  if (!config.domain) {
4719
5388
  clack11.log.error(
@@ -4772,7 +5441,7 @@ ${pc11.bold("Current Configuration:")}
4772
5441
  }
4773
5442
  case "retention": {
4774
5443
  const retention = await clack11.select({
4775
- message: "Email history retention period:",
5444
+ message: "Email history retention period (event data in DynamoDB):",
4776
5445
  options: [
4777
5446
  { value: "7days", label: "7 days", hint: "Minimal storage cost" },
4778
5447
  { value: "30days", label: "30 days", hint: "Development/testing" },
@@ -4781,11 +5450,16 @@ ${pc11.bold("Current Configuration:")}
4781
5450
  label: "90 days (recommended)",
4782
5451
  hint: "Standard retention"
4783
5452
  },
5453
+ {
5454
+ value: "6months",
5455
+ label: "6 months",
5456
+ hint: "Extended retention"
5457
+ },
4784
5458
  { value: "1year", label: "1 year", hint: "Compliance requirements" },
4785
5459
  {
4786
- value: "indefinite",
4787
- label: "Indefinite",
4788
- hint: "Higher storage cost"
5460
+ value: "18months",
5461
+ label: "18 months",
5462
+ hint: "Long-term retention"
4789
5463
  }
4790
5464
  ],
4791
5465
  initialValue: config.eventTracking?.archiveRetention || "90days"
@@ -4794,6 +5468,16 @@ ${pc11.bold("Current Configuration:")}
4794
5468
  clack11.cancel("Upgrade cancelled.");
4795
5469
  process.exit(0);
4796
5470
  }
5471
+ clack11.log.info(
5472
+ pc11.dim(
5473
+ "Note: This is for event data (sent, delivered, opened, etc.) stored in DynamoDB."
5474
+ )
5475
+ );
5476
+ clack11.log.info(
5477
+ pc11.dim(
5478
+ "For full email content storage, use 'Enable email archiving' option."
5479
+ )
5480
+ );
4797
5481
  updatedConfig = {
4798
5482
  ...config,
4799
5483
  eventTracking: {
@@ -4957,7 +5641,10 @@ ${pc11.bold("Cost Impact:")}`);
4957
5641
  lambdaFunctions: result.lambdaFunctions,
4958
5642
  domain: result.domain,
4959
5643
  dkimTokens: result.dkimTokens,
4960
- customTrackingDomain: result.customTrackingDomain
5644
+ customTrackingDomain: result.customTrackingDomain,
5645
+ archiveArn: result.archiveArn,
5646
+ archivingEnabled: result.archivingEnabled,
5647
+ archiveRetention: result.archiveRetention
4961
5648
  };
4962
5649
  }
4963
5650
  },
@@ -4984,7 +5671,10 @@ ${pc11.bold("Cost Impact:")}`);
4984
5671
  lambdaFunctions: pulumiOutputs.lambdaFunctions?.value,
4985
5672
  domain: pulumiOutputs.domain?.value,
4986
5673
  dkimTokens: pulumiOutputs.dkimTokens?.value,
4987
- customTrackingDomain: pulumiOutputs.customTrackingDomain?.value
5674
+ customTrackingDomain: pulumiOutputs.customTrackingDomain?.value,
5675
+ archiveArn: pulumiOutputs.archiveArn?.value,
5676
+ archivingEnabled: pulumiOutputs.archivingEnabled?.value,
5677
+ archiveRetention: pulumiOutputs.archiveRetention?.value
4988
5678
  };
4989
5679
  }
4990
5680
  );
@@ -5023,12 +5713,12 @@ ${pc11.green("\u2713")} ${pc11.bold("Upgrade complete!")}
5023
5713
  `);
5024
5714
  if (upgradeAction === "preset" && newPreset) {
5025
5715
  console.log(
5026
- `Upgraded to ${pc11.cyan(newPreset)} preset (${pc11.green(formatCost(newCostData.total.monthly) + "/mo")})
5716
+ `Upgraded to ${pc11.cyan(newPreset)} preset (${pc11.green(`${formatCost(newCostData.total.monthly)}/mo`)})
5027
5717
  `
5028
5718
  );
5029
5719
  } else {
5030
5720
  console.log(
5031
- `Updated configuration (${pc11.green(formatCost(newCostData.total.monthly) + "/mo")})
5721
+ `Updated configuration (${pc11.green(`${formatCost(newCostData.total.monthly)}/mo`)})
5032
5722
  `
5033
5723
  );
5034
5724
  }
@@ -5048,6 +5738,7 @@ async function verify(options) {
5048
5738
  const sesClient = new SESv2Client3({ region });
5049
5739
  let identity;
5050
5740
  let dkimTokens = [];
5741
+ let mailFromDomain;
5051
5742
  try {
5052
5743
  identity = await progress.execute(
5053
5744
  "Checking SES verification status",
@@ -5059,6 +5750,7 @@ async function verify(options) {
5059
5750
  }
5060
5751
  );
5061
5752
  dkimTokens = identity.DkimAttributes?.Tokens || [];
5753
+ mailFromDomain = identity.MailFromAttributes?.MailFromDomain;
5062
5754
  } catch (_error) {
5063
5755
  progress.stop();
5064
5756
  clack12.log.error(`Domain ${options.domain} not found in SES`);
@@ -5070,6 +5762,7 @@ Run ${pc12.cyan(`wraps init --domain ${options.domain}`)} to add this domain.
5070
5762
  process.exit(1);
5071
5763
  }
5072
5764
  const resolver = new Resolver();
5765
+ resolver.setServers(["8.8.8.8", "1.1.1.1"]);
5073
5766
  const dnsResults = [];
5074
5767
  for (const token of dkimTokens) {
5075
5768
  const dkimRecord = `${token}._domainkey.${options.domain}`;
@@ -5124,17 +5817,60 @@ Run ${pc12.cyan(`wraps init --domain ${options.domain}`)} to add this domain.
5124
5817
  status: "missing"
5125
5818
  });
5126
5819
  }
5820
+ if (mailFromDomain) {
5821
+ try {
5822
+ const mxRecords = await resolver.resolveMx(mailFromDomain);
5823
+ const expectedMx = `feedback-smtp.${region}.amazonses.com`;
5824
+ const hasMx = mxRecords.some(
5825
+ (r) => r.exchange === expectedMx || r.exchange === `${expectedMx}.`
5826
+ );
5827
+ dnsResults.push({
5828
+ name: mailFromDomain,
5829
+ type: "MX",
5830
+ status: hasMx ? "verified" : mxRecords.length > 0 ? "incorrect" : "missing",
5831
+ records: mxRecords.map((r) => `${r.priority} ${r.exchange}`)
5832
+ });
5833
+ } catch (_error) {
5834
+ dnsResults.push({
5835
+ name: mailFromDomain,
5836
+ type: "MX",
5837
+ status: "missing"
5838
+ });
5839
+ }
5840
+ try {
5841
+ const records = await resolver.resolveTxt(mailFromDomain);
5842
+ const spfRecord = records.flat().find((r) => r.startsWith("v=spf1"));
5843
+ const hasAmazonSES = spfRecord?.includes("include:amazonses.com");
5844
+ dnsResults.push({
5845
+ name: mailFromDomain,
5846
+ type: "TXT (SPF)",
5847
+ status: hasAmazonSES ? "verified" : spfRecord ? "incorrect" : "missing",
5848
+ records: spfRecord ? [spfRecord] : void 0
5849
+ });
5850
+ } catch (_error) {
5851
+ dnsResults.push({
5852
+ name: mailFromDomain,
5853
+ type: "TXT (SPF)",
5854
+ status: "missing"
5855
+ });
5856
+ }
5857
+ }
5127
5858
  progress.stop();
5128
5859
  const verificationStatus = identity.VerifiedForSendingStatus ? "verified" : "pending";
5129
5860
  const dkimStatus = identity.DkimAttributes?.Status || "PENDING";
5130
- clack12.note(
5131
- [
5132
- `${pc12.bold("Domain:")} ${options.domain}`,
5133
- `${pc12.bold("Verification Status:")} ${verificationStatus === "verified" ? pc12.green("\u2713 Verified") : pc12.yellow("\u23F1 Pending")}`,
5134
- `${pc12.bold("DKIM Status:")} ${dkimStatus === "SUCCESS" ? pc12.green("\u2713 Success") : pc12.yellow(`\u23F1 ${dkimStatus}`)}`
5135
- ].join("\n"),
5136
- "SES Status"
5137
- );
5861
+ const mailFromStatus = identity.MailFromAttributes?.MailFromDomainStatus || "NOT_CONFIGURED";
5862
+ const statusLines = [
5863
+ `${pc12.bold("Domain:")} ${options.domain}`,
5864
+ `${pc12.bold("Verification Status:")} ${verificationStatus === "verified" ? pc12.green("\u2713 Verified") : pc12.yellow("\u23F1 Pending")}`,
5865
+ `${pc12.bold("DKIM Status:")} ${dkimStatus === "SUCCESS" ? pc12.green("\u2713 Success") : pc12.yellow(`\u23F1 ${dkimStatus}`)}`
5866
+ ];
5867
+ if (mailFromDomain) {
5868
+ statusLines.push(
5869
+ `${pc12.bold("MAIL FROM Domain:")} ${mailFromDomain}`,
5870
+ `${pc12.bold("MAIL FROM Status:")} ${mailFromStatus === "SUCCESS" ? pc12.green("\u2713 Success") : mailFromStatus === "NOT_CONFIGURED" ? pc12.yellow("\u23F1 Not Configured") : pc12.yellow(`\u23F1 ${mailFromStatus}`)}`
5871
+ );
5872
+ }
5873
+ clack12.note(statusLines.join("\n"), "SES Status");
5138
5874
  const dnsLines = dnsResults.map((record) => {
5139
5875
  let statusIcon;
5140
5876
  let statusColor;