@wraps.dev/cli 0.1.5 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cli.js CHANGED
@@ -1285,7 +1285,7 @@ async function findHostedZone(domain, region) {
1285
1285
  return null;
1286
1286
  }
1287
1287
  }
1288
- async function createDNSRecords(hostedZoneId, domain, dkimTokens, region, customTrackingDomain) {
1288
+ async function createDNSRecords(hostedZoneId, domain, dkimTokens, region, customTrackingDomain, mailFromDomain) {
1289
1289
  const client = new Route53Client({ region });
1290
1290
  const changes = [];
1291
1291
  for (const token of dkimTokens) {
@@ -1330,6 +1330,28 @@ async function createDNSRecords(hostedZoneId, domain, dkimTokens, region, custom
1330
1330
  }
1331
1331
  });
1332
1332
  }
1333
+ if (mailFromDomain) {
1334
+ changes.push({
1335
+ Action: "UPSERT",
1336
+ ResourceRecordSet: {
1337
+ Name: mailFromDomain,
1338
+ Type: "MX",
1339
+ TTL: 1800,
1340
+ ResourceRecords: [
1341
+ { Value: `10 feedback-smtp.${region}.amazonses.com` }
1342
+ ]
1343
+ }
1344
+ });
1345
+ changes.push({
1346
+ Action: "UPSERT",
1347
+ ResourceRecordSet: {
1348
+ Name: mailFromDomain,
1349
+ Type: "TXT",
1350
+ TTL: 1800,
1351
+ ResourceRecords: [{ Value: '"v=spf1 include:amazonses.com ~all"' }]
1352
+ }
1353
+ });
1354
+ }
1333
1355
  await client.send(
1334
1356
  new ChangeResourceRecordSetsCommand({
1335
1357
  HostedZoneId: hostedZoneId,
@@ -1778,6 +1800,7 @@ async function createSESResources(config) {
1778
1800
  });
1779
1801
  let domainIdentity;
1780
1802
  let dkimTokens;
1803
+ let mailFromDomain;
1781
1804
  if (config.domain) {
1782
1805
  domainIdentity = new aws5.sesv2.EmailIdentity("wraps-email-domain", {
1783
1806
  emailIdentity: config.domain,
@@ -1793,6 +1816,20 @@ async function createSESResources(config) {
1793
1816
  dkimTokens = domainIdentity.dkimSigningAttributes.apply(
1794
1817
  (attrs) => attrs?.tokens || []
1795
1818
  );
1819
+ mailFromDomain = config.mailFromDomain || `mail.${config.domain}`;
1820
+ new aws5.sesv2.EmailIdentityMailFromAttributes(
1821
+ "wraps-email-mail-from",
1822
+ {
1823
+ emailIdentity: config.domain,
1824
+ mailFromDomain,
1825
+ behaviorOnMxFailure: "USE_DEFAULT_VALUE"
1826
+ // Fallback to amazonses.com if MX record fails
1827
+ },
1828
+ {
1829
+ dependsOn: [domainIdentity]
1830
+ // Ensure domain identity exists first
1831
+ }
1832
+ );
1796
1833
  }
1797
1834
  return {
1798
1835
  configSet,
@@ -1802,7 +1839,8 @@ async function createSESResources(config) {
1802
1839
  dkimTokens,
1803
1840
  dnsAutoCreated: false,
1804
1841
  // Will be set after deployment
1805
- customTrackingDomain: config.trackingConfig?.customRedirectDomain
1842
+ customTrackingDomain: config.trackingConfig?.customRedirectDomain,
1843
+ mailFromDomain
1806
1844
  };
1807
1845
  }
1808
1846
 
@@ -1887,6 +1925,7 @@ async function deployEmailStack(config) {
1887
1925
  if (emailConfig.tracking?.enabled || emailConfig.eventTracking?.enabled) {
1888
1926
  sesResources = await createSESResources({
1889
1927
  domain: emailConfig.domain,
1928
+ mailFromDomain: emailConfig.mailFromDomain,
1890
1929
  region: config.region,
1891
1930
  trackingConfig: emailConfig.tracking,
1892
1931
  eventTypes: emailConfig.eventTracking?.events
@@ -1930,7 +1969,8 @@ async function deployEmailStack(config) {
1930
1969
  eventBusName: sesResources?.eventBus.name,
1931
1970
  queueUrl: sqsResources?.queue.url,
1932
1971
  dlqUrl: sqsResources?.dlq.url,
1933
- customTrackingDomain: sesResources?.customTrackingDomain
1972
+ customTrackingDomain: sesResources?.customTrackingDomain,
1973
+ mailFromDomain: sesResources?.mailFromDomain
1934
1974
  };
1935
1975
  }
1936
1976
 
@@ -2149,6 +2189,14 @@ Verification should complete within a few minutes.`,
2149
2189
  pc2.bold("DMARC Record (TXT):"),
2150
2190
  ` ${pc2.cyan(`_dmarc.${domain}`)} ${pc2.dim("TXT")} "v=DMARC1; p=quarantine; rua=mailto:postmaster@${domain}"`
2151
2191
  );
2192
+ if (outputs.mailFromDomain) {
2193
+ dnsLines.push(
2194
+ "",
2195
+ pc2.bold("MAIL FROM Domain Records (for DMARC alignment):"),
2196
+ ` ${pc2.cyan(outputs.mailFromDomain)} ${pc2.dim("MX")} "10 feedback-smtp.${outputs.region}.amazonses.com"`,
2197
+ ` ${pc2.cyan(outputs.mailFromDomain)} ${pc2.dim("TXT")} "v=spf1 include:amazonses.com ~all"`
2198
+ );
2199
+ }
2152
2200
  }
2153
2201
  clack2.note(dnsLines.join("\n"), "DNS Records to add:");
2154
2202
  }
@@ -2201,7 +2249,14 @@ function displayStatus(status2) {
2201
2249
  const domainStrings = status2.domains.map((d) => {
2202
2250
  const statusIcon = d.status === "verified" ? "\u2713" : d.status === "pending" ? "\u23F1" : "\u2717";
2203
2251
  const statusColor = d.status === "verified" ? pc2.green : d.status === "pending" ? pc2.yellow : pc2.red;
2204
- return ` ${d.domain} ${statusColor(`${statusIcon} ${d.status}`)}`;
2252
+ let domainLine = ` ${d.domain} ${statusColor(`${statusIcon} ${d.status}`)}`;
2253
+ if (d.mailFromDomain) {
2254
+ const mailFromStatusIcon = d.mailFromStatus === "SUCCESS" ? "\u2713" : "\u23F1";
2255
+ const mailFromColor = d.mailFromStatus === "SUCCESS" ? pc2.green : pc2.yellow;
2256
+ domainLine += `
2257
+ ${pc2.dim("MAIL FROM:")} ${d.mailFromDomain} ${mailFromColor(mailFromStatusIcon)}`;
2258
+ }
2259
+ return domainLine;
2205
2260
  });
2206
2261
  infoLines.push(`${pc2.bold("Domains:")}
2207
2262
  ${domainStrings.join("\n")}`);
@@ -2260,13 +2315,14 @@ ${domainStrings.join("\n")}`);
2260
2315
  );
2261
2316
  }
2262
2317
  clack2.note(resourceLines.join("\n"), "Resources");
2263
- const pendingDomains = status2.domains.filter(
2264
- (d) => d.status === "pending" && d.dkimTokens
2318
+ const domainsNeedingDNS = status2.domains.filter(
2319
+ (d) => d.status === "pending" && d.dkimTokens || d.mailFromDomain && d.mailFromStatus !== "SUCCESS"
2265
2320
  );
2266
- if (pendingDomains.length > 0) {
2267
- for (const domain of pendingDomains) {
2268
- if (domain.dkimTokens && domain.dkimTokens.length > 0) {
2269
- const dnsLines = [
2321
+ if (domainsNeedingDNS.length > 0) {
2322
+ for (const domain of domainsNeedingDNS) {
2323
+ const dnsLines = [];
2324
+ if (domain.status === "pending" && domain.dkimTokens && domain.dkimTokens.length > 0) {
2325
+ dnsLines.push(
2270
2326
  pc2.bold("DKIM Records (CNAME):"),
2271
2327
  ...domain.dkimTokens.map(
2272
2328
  (token) => ` ${pc2.cyan(`${token}._domainkey.${domain.domain}`)} ${pc2.dim("CNAME")} "${token}.dkim.amazonses.com"`
@@ -2277,11 +2333,21 @@ ${domainStrings.join("\n")}`);
2277
2333
  "",
2278
2334
  pc2.bold("DMARC Record (TXT):"),
2279
2335
  ` ${pc2.cyan(`_dmarc.${domain.domain}`)} ${pc2.dim("TXT")} "v=DMARC1; p=quarantine; rua=mailto:postmaster@${domain.domain}"`
2280
- ];
2336
+ );
2337
+ }
2338
+ if (domain.mailFromDomain && domain.mailFromStatus !== "SUCCESS") {
2339
+ if (dnsLines.length > 0) dnsLines.push("");
2340
+ dnsLines.push(
2341
+ pc2.bold("MAIL FROM Domain Records (for DMARC alignment):"),
2342
+ ` ${pc2.cyan(domain.mailFromDomain)} ${pc2.dim("MX")} "10 feedback-smtp.${status2.region}.amazonses.com"`,
2343
+ ` ${pc2.cyan(domain.mailFromDomain)} ${pc2.dim("TXT")} "v=spf1 include:amazonses.com ~all"`
2344
+ );
2345
+ }
2346
+ if (dnsLines.length > 0) {
2281
2347
  clack2.note(dnsLines.join("\n"), `DNS Records for ${domain.domain}`);
2282
2348
  }
2283
2349
  }
2284
- const exampleDomain = pendingDomains[0].domain;
2350
+ const exampleDomain = domainsNeedingDNS[0].domain;
2285
2351
  console.log(
2286
2352
  `
2287
2353
  ${pc2.dim("Run:")} ${pc2.yellow(`wraps verify --domain ${exampleDomain}`)} ${pc2.dim(
@@ -4168,7 +4234,8 @@ async function init(options) {
4168
4234
  outputs.domain,
4169
4235
  outputs.dkimTokens,
4170
4236
  region,
4171
- outputs.customTrackingDomain
4237
+ outputs.customTrackingDomain,
4238
+ outputs.mailFromDomain
4172
4239
  );
4173
4240
  progress.succeed("DNS records created in Route53");
4174
4241
  dnsAutoCreated = true;
@@ -4195,7 +4262,8 @@ async function init(options) {
4195
4262
  tableName: outputs.tableName,
4196
4263
  dnsRecords: dnsRecords.length > 0 ? dnsRecords : void 0,
4197
4264
  dnsAutoCreated,
4198
- domain: outputs.domain
4265
+ domain: outputs.domain,
4266
+ mailFromDomain: outputs.mailFromDomain
4199
4267
  });
4200
4268
  }
4201
4269
 
@@ -4345,13 +4413,17 @@ Run ${pc9.cyan("wraps init")} to deploy infrastructure.
4345
4413
  return {
4346
4414
  domain: d.domain,
4347
4415
  status: d.verified ? "verified" : "pending",
4348
- dkimTokens: identity2.DkimAttributes?.Tokens || []
4416
+ dkimTokens: identity2.DkimAttributes?.Tokens || [],
4417
+ mailFromDomain: identity2.MailFromAttributes?.MailFromDomain,
4418
+ mailFromStatus: identity2.MailFromAttributes?.MailFromDomainStatus
4349
4419
  };
4350
4420
  } catch (_error) {
4351
4421
  return {
4352
4422
  domain: d.domain,
4353
4423
  status: d.verified ? "verified" : "pending",
4354
- dkimTokens: void 0
4424
+ dkimTokens: void 0,
4425
+ mailFromDomain: void 0,
4426
+ mailFromStatus: void 0
4355
4427
  };
4356
4428
  }
4357
4429
  })
@@ -5048,6 +5120,7 @@ async function verify(options) {
5048
5120
  const sesClient = new SESv2Client3({ region });
5049
5121
  let identity;
5050
5122
  let dkimTokens = [];
5123
+ let mailFromDomain;
5051
5124
  try {
5052
5125
  identity = await progress.execute(
5053
5126
  "Checking SES verification status",
@@ -5059,6 +5132,7 @@ async function verify(options) {
5059
5132
  }
5060
5133
  );
5061
5134
  dkimTokens = identity.DkimAttributes?.Tokens || [];
5135
+ mailFromDomain = identity.MailFromAttributes?.MailFromDomain;
5062
5136
  } catch (_error) {
5063
5137
  progress.stop();
5064
5138
  clack12.log.error(`Domain ${options.domain} not found in SES`);
@@ -5070,6 +5144,7 @@ Run ${pc12.cyan(`wraps init --domain ${options.domain}`)} to add this domain.
5070
5144
  process.exit(1);
5071
5145
  }
5072
5146
  const resolver = new Resolver();
5147
+ resolver.setServers(["8.8.8.8", "1.1.1.1"]);
5073
5148
  const dnsResults = [];
5074
5149
  for (const token of dkimTokens) {
5075
5150
  const dkimRecord = `${token}._domainkey.${options.domain}`;
@@ -5124,17 +5199,60 @@ Run ${pc12.cyan(`wraps init --domain ${options.domain}`)} to add this domain.
5124
5199
  status: "missing"
5125
5200
  });
5126
5201
  }
5202
+ if (mailFromDomain) {
5203
+ try {
5204
+ const mxRecords = await resolver.resolveMx(mailFromDomain);
5205
+ const expectedMx = `feedback-smtp.${region}.amazonses.com`;
5206
+ const hasMx = mxRecords.some(
5207
+ (r) => r.exchange === expectedMx || r.exchange === `${expectedMx}.`
5208
+ );
5209
+ dnsResults.push({
5210
+ name: mailFromDomain,
5211
+ type: "MX",
5212
+ status: hasMx ? "verified" : mxRecords.length > 0 ? "incorrect" : "missing",
5213
+ records: mxRecords.map((r) => `${r.priority} ${r.exchange}`)
5214
+ });
5215
+ } catch (_error) {
5216
+ dnsResults.push({
5217
+ name: mailFromDomain,
5218
+ type: "MX",
5219
+ status: "missing"
5220
+ });
5221
+ }
5222
+ try {
5223
+ const records = await resolver.resolveTxt(mailFromDomain);
5224
+ const spfRecord = records.flat().find((r) => r.startsWith("v=spf1"));
5225
+ const hasAmazonSES = spfRecord?.includes("include:amazonses.com");
5226
+ dnsResults.push({
5227
+ name: mailFromDomain,
5228
+ type: "TXT (SPF)",
5229
+ status: hasAmazonSES ? "verified" : spfRecord ? "incorrect" : "missing",
5230
+ records: spfRecord ? [spfRecord] : void 0
5231
+ });
5232
+ } catch (_error) {
5233
+ dnsResults.push({
5234
+ name: mailFromDomain,
5235
+ type: "TXT (SPF)",
5236
+ status: "missing"
5237
+ });
5238
+ }
5239
+ }
5127
5240
  progress.stop();
5128
5241
  const verificationStatus = identity.VerifiedForSendingStatus ? "verified" : "pending";
5129
5242
  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
- );
5243
+ const mailFromStatus = identity.MailFromAttributes?.MailFromDomainStatus || "NOT_CONFIGURED";
5244
+ const statusLines = [
5245
+ `${pc12.bold("Domain:")} ${options.domain}`,
5246
+ `${pc12.bold("Verification Status:")} ${verificationStatus === "verified" ? pc12.green("\u2713 Verified") : pc12.yellow("\u23F1 Pending")}`,
5247
+ `${pc12.bold("DKIM Status:")} ${dkimStatus === "SUCCESS" ? pc12.green("\u2713 Success") : pc12.yellow(`\u23F1 ${dkimStatus}`)}`
5248
+ ];
5249
+ if (mailFromDomain) {
5250
+ statusLines.push(
5251
+ `${pc12.bold("MAIL FROM Domain:")} ${mailFromDomain}`,
5252
+ `${pc12.bold("MAIL FROM Status:")} ${mailFromStatus === "SUCCESS" ? pc12.green("\u2713 Success") : mailFromStatus === "NOT_CONFIGURED" ? pc12.yellow("\u23F1 Not Configured") : pc12.yellow(`\u23F1 ${mailFromStatus}`)}`
5253
+ );
5254
+ }
5255
+ clack12.note(statusLines.join("\n"), "SES Status");
5138
5256
  const dnsLines = dnsResults.map((record) => {
5139
5257
  let statusIcon;
5140
5258
  let statusColor;