@thinhnguyencth1204/nextcli 0.5.0 → 0.6.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -27,7 +27,7 @@ This command is fully interactive:
27
27
 
28
28
  - enter project name
29
29
  - select package manager in CLI UI (`npm`, `pnpm`, `yarn`, `bun`)
30
- - multi-select optional modules (`chat`, `supabase-realtime`, `seo`, `resend`)
30
+ - multi-select optional modules (`chat`, `supabase-realtime`, `seo`, `email`)
31
31
  - confirm install step
32
32
  - normalizes project directory name into a safe project slug for generated `package.json` and env placeholders
33
33
  - ships `SETUP.md` and `PROJECT_STRUCTURE.md` in the generated project root
@@ -97,7 +97,7 @@ Interactive multiselect shows available module catalog:
97
97
  - `chat`
98
98
  - `supabase-realtime`
99
99
  - `seo`
100
- - `resend`
100
+ - `email`
101
101
 
102
102
  Non-interactive example:
103
103
 
@@ -106,6 +106,9 @@ node ../dist/cli.js add module --module seo --module supabase-realtime --yes
106
106
  ```
107
107
 
108
108
  `supabase` is no longer an optional module; Supabase client, Storage helpers, and Supabase Postgres env placeholders are part of the base template.
109
+ `resend` is now handled by the `email` module (legacy `--module resend` maps to `email` with provider `resend`).
110
+
111
+ When `email` is selected, CLI asks for provider (`resend` or `smtp`) and only merges env keys/dependencies for that provider.
109
112
 
110
113
  ## Command: add auth-provider
111
114
 
@@ -166,7 +169,7 @@ node ../dist/cli.js migrate --name init_auth --skip-generate
166
169
  - i18n base with `next-intl`
167
170
  - Sonner notifications
168
171
  - date-fns utility library
169
- - Optional modules: chat, supabase-realtime, seo, resend
172
+ - Optional modules: chat, supabase-realtime, seo, email
170
173
 
171
174
  ## Template structure
172
175
 
@@ -179,7 +182,7 @@ Optional module templates:
179
182
  - `templates/features/chat`
180
183
  - `templates/features/supabase-realtime`
181
184
  - `templates/features/seo`
182
- - `templates/features/resend`
185
+ - `templates/features/email`
183
186
 
184
187
  ## Realtime chat schema foundation
185
188
 
package/dist/cli.js CHANGED
@@ -216,7 +216,7 @@ var templatePaths = {
216
216
  chat: path2.join(rootDir, "templates/features/chat"),
217
217
  supabaseRealtime: path2.join(rootDir, "templates/features/supabase-realtime"),
218
218
  seo: path2.join(rootDir, "templates/features/seo"),
219
- resend: path2.join(rootDir, "templates/features/resend")
219
+ email: path2.join(rootDir, "templates/features/email")
220
220
  };
221
221
 
222
222
  // src/core/modules.ts
@@ -258,23 +258,17 @@ Requires \`supabase-realtime\` (auto-added). Run \`db:migrate\` after add \u2014
258
258
  setupSection: `No extra env keys. Edit \`src/app/robots.ts\`, \`sitemap.ts\`, and JSON-LD helpers after add.`
259
259
  },
260
260
  {
261
- id: "resend",
262
- label: "Resend email",
263
- description: "Adds Resend client, send helper, and React Email welcome template",
264
- templatePath: templatePaths.resend,
265
- env: {
266
- RESEND_API_KEY: "",
267
- RESEND_FROM_EMAIL: ""
268
- },
269
- dependencies: {
270
- resend: "^6.9.2",
271
- "@react-email/components": "^1.0.12",
272
- "react-email": "^4.0.0"
273
- },
274
- setupSection: `| Variable | Where to get |
275
- | -------- | ------------ |
276
- | \`RESEND_API_KEY\` | resend.com \u2192 API Keys \u2192 Create |
277
- | \`RESEND_FROM_EMAIL\` | resend.com \u2192 Domains \u2192 verify domain \u2192 use \`Name <you@domain.com>\` |`
261
+ id: "email",
262
+ label: "Email module (SMTP or Resend)",
263
+ description: "Adds provider-agnostic sendEmail helper with SMTP/Resend adapters",
264
+ templatePath: templatePaths.email,
265
+ env: {},
266
+ setupSection: `When you add this module, NexTCLI asks which provider to configure:
267
+
268
+ - **Resend**: \`EMAIL_PROVIDER=resend\`, \`RESEND_API_KEY\`, \`RESEND_FROM_EMAIL\`
269
+ - **SMTP**: \`EMAIL_PROVIDER=smtp\`, \`SMTP_HOST\`, \`SMTP_PORT\`, \`SMTP_USER\`, \`SMTP_PASSWORD\`, \`SMTP_FROM\`
270
+
271
+ Only the selected provider keys are merged into your env files.`
278
272
  }
279
273
  ];
280
274
  function getModuleById(moduleId) {
@@ -448,6 +442,42 @@ async function ensureBetterAuthGenerate(cwd, options = {}) {
448
442
  }
449
443
  }
450
444
 
445
+ // src/core/email-module.ts
446
+ var EMAIL_PROVIDER_OPTIONS = ["resend", "smtp"];
447
+ function isEmailProvider(value) {
448
+ return EMAIL_PROVIDER_OPTIONS.includes(value);
449
+ }
450
+ function getEmailProviderEnv(provider) {
451
+ if (provider === "smtp") {
452
+ return {
453
+ EMAIL_PROVIDER: "smtp",
454
+ SMTP_HOST: "",
455
+ SMTP_PORT: "587",
456
+ SMTP_USER: "",
457
+ SMTP_PASSWORD: "",
458
+ SMTP_FROM: ""
459
+ };
460
+ }
461
+ return {
462
+ EMAIL_PROVIDER: "resend",
463
+ RESEND_API_KEY: "",
464
+ RESEND_FROM_EMAIL: ""
465
+ };
466
+ }
467
+ function getEmailProviderDependencies(provider) {
468
+ if (provider === "smtp") {
469
+ return {
470
+ nodemailer: "^6.10.1",
471
+ "@types/nodemailer": "^6.4.17"
472
+ };
473
+ }
474
+ return {
475
+ resend: "^6.9.2",
476
+ "@react-email/components": "^1.0.12",
477
+ "react-email": "^4.0.0"
478
+ };
479
+ }
480
+
451
481
  // src/core/chat-schema.ts
452
482
  import path3 from "path";
453
483
  import { readFile as readFile2, writeFile as writeFile2 } from "fs/promises";
@@ -552,7 +582,7 @@ import { readdir as readdir3, readFile as readFile4, writeFile as writeFile4 } f
552
582
  import path4 from "path";
553
583
  import { readdir as readdir2, readFile as readFile3, writeFile as writeFile3 } from "fs/promises";
554
584
  var defaultManifest = {
555
- cli: "0.5.0",
585
+ cli: "0.6.1",
556
586
  defaultLocale: "vi",
557
587
  locales: ["vi"],
558
588
  namespaces: ["common", "auth", "example"],
@@ -617,9 +647,7 @@ async function reconcileManifest(projectDir) {
617
647
  ]
618
648
  };
619
649
  merged.defaultLocale = merged.locales.includes(merged.defaultLocale) ? merged.defaultLocale : merged.locales[0] ?? "vi";
620
- merged.modules = [...new Set(merged.modules)].filter(
621
- (moduleId) => moduleId !== "supabase"
622
- );
650
+ merged.modules = [...new Set(merged.modules)].map((moduleId) => moduleId === "resend" ? "email" : moduleId).filter((moduleId) => moduleId !== "supabase");
623
651
  merged.features = [...new Set(merged.features)];
624
652
  await writeManifest(projectDir, merged);
625
653
  return merged;
@@ -1419,166 +1447,239 @@ function registerAddCommand(program2) {
1419
1447
  "No migration was executed. Run your migration command manually when ready."
1420
1448
  );
1421
1449
  });
1422
- add.command("module").description("Add optional modules using interactive multi-select").option("--module <module...>", "Preselect module ids").option("--yes", "Skip prompts").action(async (options) => {
1423
- const cwd = process.cwd();
1424
- const hasSrc = await pathExists(path7.join(cwd, "src"));
1425
- const hasPackageJson = await pathExists(path7.join(cwd, "package.json"));
1426
- if (!hasSrc || !hasPackageJson) {
1427
- log.error("Run this command from your generated Next.js project root.");
1428
- process.exitCode = 1;
1429
- return;
1430
- }
1431
- const validIds = new Set(
1432
- optionalModules.map((module) => module.id)
1433
- );
1434
- const requestedValues = options.module ? options.module.flatMap((value) => value.split(",")).map((value) => value.trim()).filter(Boolean) : [];
1435
- const requestedIds = [];
1436
- for (const moduleId of requestedValues) {
1437
- if (validIds.has(moduleId)) {
1438
- requestedIds.push(moduleId);
1439
- continue;
1440
- }
1441
- if (moduleId === "supabase") {
1442
- log.info(
1443
- "Supabase is now part of the base template; only supabase-realtime is optional."
1450
+ add.command("module").description("Add optional modules using interactive multi-select").option("--module <module...>", "Preselect module ids").option(
1451
+ "--email-provider <provider>",
1452
+ "Email provider for email module: resend or smtp"
1453
+ ).option("--yes", "Skip prompts").action(
1454
+ async (options) => {
1455
+ const cwd = process.cwd();
1456
+ const hasSrc = await pathExists(path7.join(cwd, "src"));
1457
+ const hasPackageJson = await pathExists(path7.join(cwd, "package.json"));
1458
+ if (!hasSrc || !hasPackageJson) {
1459
+ log.error(
1460
+ "Run this command from your generated Next.js project root."
1444
1461
  );
1445
- continue;
1462
+ process.exitCode = 1;
1463
+ return;
1446
1464
  }
1447
- if (!validIds.has(moduleId)) {
1448
- log.error(`Unknown module: ${moduleId}`);
1465
+ const validIds = new Set(
1466
+ optionalModules.map((module) => module.id)
1467
+ );
1468
+ const requestedValues = options.module ? options.module.flatMap((value) => value.split(",")).map((value) => value.trim()).filter(Boolean) : [];
1469
+ const requestedIds = [];
1470
+ let emailProviderFromModuleAlias;
1471
+ const optionEmailProvider = options.emailProvider ? options.emailProvider.trim().toLowerCase() : void 0;
1472
+ if (optionEmailProvider && !isEmailProvider(optionEmailProvider)) {
1473
+ log.error(`Unknown email provider: ${options.emailProvider}`);
1449
1474
  process.exitCode = 1;
1450
1475
  return;
1451
1476
  }
1452
- }
1453
- startPrompt("NexTCLI optional modules");
1454
- const rawModules = requestedIds.length > 0 ? [...new Set(requestedIds)] : options.yes ? [] : await askMultiSelect(
1455
- "Select modules to add:",
1456
- optionalModules.map((module) => ({
1457
- value: module.id,
1458
- label: module.label,
1459
- hint: module.description
1460
- })),
1461
- []
1462
- );
1463
- const { selectedModules, autoAddedModules } = normalizeModuleSelection(rawModules);
1464
- if (selectedModules.length === 0) {
1465
- finishPrompt("No modules selected.");
1466
- return;
1467
- }
1468
- const skippedConflictsByModule = [];
1469
- let copiedFileCount = 0;
1470
- for (const moduleId of selectedModules) {
1471
- const module = getModuleById(moduleId);
1472
- const copyReport = await copyDirectorySafely(module.templatePath, cwd);
1473
- copiedFileCount += copyReport.copiedCount;
1474
- if (copyReport.skippedConflicts.length > 0) {
1475
- skippedConflictsByModule.push({
1476
- moduleId,
1477
- paths: copyReport.skippedConflicts
1478
- });
1477
+ for (const moduleId of requestedValues) {
1478
+ if (validIds.has(moduleId)) {
1479
+ requestedIds.push(moduleId);
1480
+ continue;
1481
+ }
1482
+ if (moduleId === "supabase") {
1483
+ log.info(
1484
+ "Supabase is now part of the base template; only supabase-realtime is optional."
1485
+ );
1486
+ continue;
1487
+ }
1488
+ if (moduleId === "resend") {
1489
+ log.info(
1490
+ "Module 'resend' is now 'email'; selecting email with provider resend."
1491
+ );
1492
+ requestedIds.push("email");
1493
+ emailProviderFromModuleAlias = "resend";
1494
+ continue;
1495
+ }
1496
+ if (!validIds.has(moduleId)) {
1497
+ log.error(`Unknown module: ${moduleId}`);
1498
+ process.exitCode = 1;
1499
+ return;
1500
+ }
1479
1501
  }
1480
- }
1481
- let chatSchemaStatus;
1482
- if (selectedModules.includes("chat")) {
1483
- chatSchemaStatus = await ensureChatSchemaInProject(cwd);
1484
- }
1485
- const envTargets = [".env", ".env.example", ".env.development"];
1486
- for (const moduleId of selectedModules) {
1487
- const module = getModuleById(moduleId);
1488
- if (Object.keys(module.env).length === 0) {
1489
- continue;
1502
+ startPrompt("NexTCLI optional modules");
1503
+ const rawModules = requestedIds.length > 0 ? [...new Set(requestedIds)] : options.yes ? [] : await askMultiSelect(
1504
+ "Select modules to add:",
1505
+ optionalModules.map((module) => ({
1506
+ value: module.id,
1507
+ label: module.label,
1508
+ hint: module.description
1509
+ })),
1510
+ []
1511
+ );
1512
+ const { selectedModules, autoAddedModules } = normalizeModuleSelection(rawModules);
1513
+ if (optionEmailProvider && !selectedModules.includes("email")) {
1514
+ log.warn(
1515
+ "Ignoring --email-provider because email module is not selected."
1516
+ );
1490
1517
  }
1491
- for (const envFile of envTargets) {
1492
- const envPath = path7.join(cwd, envFile);
1493
- if (await pathExists(envPath)) {
1494
- await mergeEnvFile(envPath, module.env, {
1495
- header: `# --- module: ${module.id} ---`
1518
+ let emailProvider;
1519
+ if (selectedModules.includes("email")) {
1520
+ if (emailProviderFromModuleAlias) {
1521
+ emailProvider = emailProviderFromModuleAlias;
1522
+ } else if (optionEmailProvider && isEmailProvider(optionEmailProvider)) {
1523
+ emailProvider = optionEmailProvider;
1524
+ } else if (options.yes) {
1525
+ emailProvider = "resend";
1526
+ log.info(
1527
+ "No --email-provider supplied in --yes mode; defaulting email provider to resend."
1528
+ );
1529
+ } else {
1530
+ emailProvider = await askSelect(
1531
+ "Select email provider:",
1532
+ [
1533
+ {
1534
+ value: "resend",
1535
+ label: "Resend",
1536
+ hint: "Transactional email API with React Email support"
1537
+ },
1538
+ {
1539
+ value: "smtp",
1540
+ label: "SMTP",
1541
+ hint: "Use your SMTP relay credentials"
1542
+ }
1543
+ ],
1544
+ "resend"
1545
+ );
1546
+ }
1547
+ }
1548
+ if (selectedModules.length === 0) {
1549
+ finishPrompt("No modules selected.");
1550
+ return;
1551
+ }
1552
+ const skippedConflictsByModule = [];
1553
+ let copiedFileCount = 0;
1554
+ for (const moduleId of selectedModules) {
1555
+ const module = getModuleById(moduleId);
1556
+ const copyReport = await copyDirectorySafely(
1557
+ module.templatePath,
1558
+ cwd
1559
+ );
1560
+ copiedFileCount += copyReport.copiedCount;
1561
+ if (copyReport.skippedConflicts.length > 0) {
1562
+ skippedConflictsByModule.push({
1563
+ moduleId,
1564
+ paths: copyReport.skippedConflicts
1496
1565
  });
1497
1566
  }
1498
1567
  }
1499
- }
1500
- const dependencyEntries = selectedModules.reduce(
1501
- (acc, moduleId) => {
1568
+ let chatSchemaStatus;
1569
+ if (selectedModules.includes("chat")) {
1570
+ chatSchemaStatus = await ensureChatSchemaInProject(cwd);
1571
+ }
1572
+ const envTargets = [".env", ".env.example", ".env.development"];
1573
+ for (const moduleId of selectedModules) {
1502
1574
  const module = getModuleById(moduleId);
1575
+ const moduleEnv = moduleId === "email" && emailProvider ? getEmailProviderEnv(emailProvider) : module.env;
1576
+ if (Object.keys(moduleEnv).length === 0) {
1577
+ continue;
1578
+ }
1579
+ for (const envFile of envTargets) {
1580
+ const envPath = path7.join(cwd, envFile);
1581
+ if (await pathExists(envPath)) {
1582
+ await mergeEnvFile(envPath, moduleEnv, {
1583
+ header: `# --- module: ${module.id} ---`
1584
+ });
1585
+ }
1586
+ }
1587
+ }
1588
+ const dependencyEntries = selectedModules.reduce((acc, moduleId) => {
1589
+ const module = getModuleById(moduleId);
1590
+ const emailDependencies = moduleId === "email" && emailProvider ? getEmailProviderDependencies(emailProvider) : {};
1503
1591
  return {
1504
1592
  ...acc,
1593
+ ...emailDependencies,
1505
1594
  ...module.dependencies ?? {}
1506
1595
  };
1507
- },
1508
- {}
1509
- );
1510
- if (Object.keys(dependencyEntries).length > 0) {
1511
- await addDependencies(
1512
- path7.join(cwd, "package.json"),
1513
- dependencyEntries
1514
- );
1515
- }
1516
- const state = await detectProjectState(cwd);
1517
- const namespaceSet = new Set(state.namespaces);
1518
- for (const moduleId of selectedModules) {
1519
- const moduleTemplateMessages = path7.join(
1520
- getModuleById(moduleId).templatePath,
1521
- "messages/vi"
1522
- );
1523
- if (!await pathExists(moduleTemplateMessages)) {
1524
- continue;
1596
+ }, {});
1597
+ if (Object.keys(dependencyEntries).length > 0) {
1598
+ await addDependencies(
1599
+ path7.join(cwd, "package.json"),
1600
+ dependencyEntries
1601
+ );
1525
1602
  }
1526
- const files = await readdir4(moduleTemplateMessages, {
1527
- withFileTypes: true
1528
- });
1529
- for (const file of files) {
1530
- if (!file.isFile() || !file.name.endsWith(".json")) {
1603
+ const state = await detectProjectState(cwd);
1604
+ const namespaceSet = new Set(state.namespaces);
1605
+ for (const moduleId of selectedModules) {
1606
+ const moduleTemplateMessages = path7.join(
1607
+ getModuleById(moduleId).templatePath,
1608
+ "messages/vi"
1609
+ );
1610
+ if (!await pathExists(moduleTemplateMessages)) {
1531
1611
  continue;
1532
1612
  }
1533
- const namespace = file.name.replace(/\.json$/, "");
1534
- namespaceSet.add(namespace);
1535
- const templateData = JSON.parse(
1536
- await readFile6(
1537
- path7.join(moduleTemplateMessages, file.name),
1538
- "utf8"
1613
+ const files = await readdir4(moduleTemplateMessages, {
1614
+ withFileTypes: true
1615
+ });
1616
+ for (const file of files) {
1617
+ if (!file.isFile() || !file.name.endsWith(".json")) {
1618
+ continue;
1619
+ }
1620
+ const namespace = file.name.replace(/\.json$/, "");
1621
+ namespaceSet.add(namespace);
1622
+ const templateData = JSON.parse(
1623
+ await readFile6(
1624
+ path7.join(moduleTemplateMessages, file.name),
1625
+ "utf8"
1626
+ )
1627
+ );
1628
+ await writeNamespaceMessages(cwd, namespace, templateData);
1629
+ await appendNamespace(cwd, namespace);
1630
+ }
1631
+ }
1632
+ await writeManifest(cwd, {
1633
+ ...state,
1634
+ namespaces: [...namespaceSet],
1635
+ modules: [
1636
+ ...new Set(
1637
+ [...state.modules, ...selectedModules].map(
1638
+ (moduleId) => moduleId === "resend" ? "email" : moduleId
1639
+ )
1640
+ )
1641
+ ]
1642
+ });
1643
+ const mergedModules = [
1644
+ ...new Set(
1645
+ [...state.modules, ...selectedModules].map(
1646
+ (moduleId) => moduleId === "resend" ? "email" : moduleId
1539
1647
  )
1648
+ )
1649
+ ];
1650
+ await mergeModuleSetupSections(cwd, selectedModules, mergedModules);
1651
+ finishPrompt(`Added modules: ${selectedModules.join(", ")}`);
1652
+ log.detail("Copied files", String(copiedFileCount));
1653
+ if (autoAddedModules.length > 0) {
1654
+ log.detail("Auto-added", autoAddedModules.join(", "));
1655
+ }
1656
+ if (emailProvider) {
1657
+ log.detail("Email provider", emailProvider);
1658
+ }
1659
+ if (skippedConflictsByModule.length > 0) {
1660
+ const totalSkipped = skippedConflictsByModule.reduce(
1661
+ (total, item) => total + item.paths.length,
1662
+ 0
1663
+ );
1664
+ log.warn(
1665
+ `Skipped ${totalSkipped} existing file(s). Existing project files were kept unchanged.`
1540
1666
  );
1541
- await writeNamespaceMessages(cwd, namespace, templateData);
1542
- await appendNamespace(cwd, namespace);
1667
+ for (const conflict of skippedConflictsByModule) {
1668
+ const preview = conflict.paths.slice(0, 3).join(", ");
1669
+ const suffix = conflict.paths.length > 3 ? ", ..." : "";
1670
+ log.detail(`Skipped (${conflict.moduleId})`, `${preview}${suffix}`);
1671
+ }
1543
1672
  }
1544
- }
1545
- await writeManifest(cwd, {
1546
- ...state,
1547
- namespaces: [...namespaceSet],
1548
- modules: [.../* @__PURE__ */ new Set([...state.modules, ...selectedModules])]
1549
- });
1550
- const mergedModules = [
1551
- .../* @__PURE__ */ new Set([...state.modules, ...selectedModules])
1552
- ];
1553
- await mergeModuleSetupSections(cwd, selectedModules, mergedModules);
1554
- finishPrompt(`Added modules: ${selectedModules.join(", ")}`);
1555
- log.detail("Copied files", String(copiedFileCount));
1556
- if (autoAddedModules.length > 0) {
1557
- log.detail("Auto-added", autoAddedModules.join(", "));
1558
- }
1559
- if (skippedConflictsByModule.length > 0) {
1560
- const totalSkipped = skippedConflictsByModule.reduce(
1561
- (total, item) => total + item.paths.length,
1562
- 0
1563
- );
1564
- log.warn(
1565
- `Skipped ${totalSkipped} existing file(s). Existing project files were kept unchanged.`
1566
- );
1567
- for (const conflict of skippedConflictsByModule) {
1568
- const preview = conflict.paths.slice(0, 3).join(", ");
1569
- const suffix = conflict.paths.length > 3 ? ", ..." : "";
1570
- log.detail(`Skipped (${conflict.moduleId})`, `${preview}${suffix}`);
1673
+ if (chatSchemaStatus === "added") {
1674
+ log.info(
1675
+ "Optional chat schema block was appended to prisma/schema.prisma."
1676
+ );
1571
1677
  }
1572
- }
1573
- if (chatSchemaStatus === "added") {
1574
- log.info(
1575
- "Optional chat schema block was appended to prisma/schema.prisma."
1678
+ log.step(
1679
+ "Next: run your package manager install to apply new dependencies."
1576
1680
  );
1577
1681
  }
1578
- log.step(
1579
- "Next: run your package manager install to apply new dependencies."
1580
- );
1581
- });
1682
+ );
1582
1683
  add.command("language").description("Add locales and clone message files from Vietnamese").option("--locale <locale...>", "Preselect locales: en,ja,ko").option("--yes", "Skip prompts").action(async (options) => {
1583
1684
  const cwd = process.cwd();
1584
1685
  const hasMessages = await pathExists(path7.join(cwd, "messages"));
@@ -1821,6 +1922,25 @@ function registerCreateCommand(program2) {
1821
1922
  []
1822
1923
  );
1823
1924
  const { selectedModules, autoAddedModules } = normalizeModuleSelection2(rawModules);
1925
+ let emailProvider;
1926
+ if (selectedModules.includes("email")) {
1927
+ emailProvider = await askSelect(
1928
+ "Select email provider:",
1929
+ [
1930
+ {
1931
+ value: "resend",
1932
+ label: "Resend",
1933
+ hint: "Transactional email API with React Email support"
1934
+ },
1935
+ {
1936
+ value: "smtp",
1937
+ label: "SMTP",
1938
+ hint: "Use your SMTP relay credentials"
1939
+ }
1940
+ ],
1941
+ "resend"
1942
+ );
1943
+ }
1824
1944
  const shouldInstall = await askConfirm("Install dependencies now?", true);
1825
1945
  await copyDirectory(templatePaths.base, targetPath);
1826
1946
  for (const moduleId of selectedModules) {
@@ -1834,13 +1954,14 @@ function registerCreateCommand(program2) {
1834
1954
  const envTargets = [".env", ".env.example", ".env.development"];
1835
1955
  for (const moduleId of selectedModules) {
1836
1956
  const module = getModuleById(moduleId);
1837
- if (Object.keys(module.env).length === 0) {
1957
+ const moduleEnv = moduleId === "email" && emailProvider ? getEmailProviderEnv(emailProvider) : module.env;
1958
+ if (Object.keys(moduleEnv).length === 0) {
1838
1959
  continue;
1839
1960
  }
1840
1961
  for (const envFile of envTargets) {
1841
1962
  const envPath = path8.join(targetPath, envFile);
1842
1963
  if (await pathExists(envPath)) {
1843
- await mergeEnvFile(envPath, module.env, {
1964
+ await mergeEnvFile(envPath, moduleEnv, {
1844
1965
  header: `# --- module: ${module.id} ---`
1845
1966
  });
1846
1967
  }
@@ -1849,8 +1970,10 @@ function registerCreateCommand(program2) {
1849
1970
  const dependencyEntries = selectedModules.reduce(
1850
1971
  (acc, moduleId) => {
1851
1972
  const module = getModuleById(moduleId);
1973
+ const emailDependencies = moduleId === "email" && emailProvider ? getEmailProviderDependencies(emailProvider) : {};
1852
1974
  return {
1853
1975
  ...acc,
1976
+ ...emailDependencies,
1854
1977
  ...module.dependencies ?? {}
1855
1978
  };
1856
1979
  },
@@ -1866,7 +1989,7 @@ function registerCreateCommand(program2) {
1866
1989
  await replaceTokensInDirectory(targetPath, {
1867
1990
  __PROJECT_NAME__: projectSlug,
1868
1991
  __BETTER_AUTH_SECRET__: betterAuthSecret,
1869
- __NEXTCLI_VERSION__: "0.5.0"
1992
+ __NEXTCLI_VERSION__: "0.6.1"
1870
1993
  });
1871
1994
  await mergeModuleSetupSections(
1872
1995
  targetPath,
@@ -1877,7 +2000,7 @@ function registerCreateCommand(program2) {
1877
2000
  if (manifest) {
1878
2001
  await writeManifest(targetPath, {
1879
2002
  ...manifest,
1880
- cli: "0.5.0",
2003
+ cli: "0.6.1",
1881
2004
  modules: selectedModules
1882
2005
  });
1883
2006
  }
@@ -1897,6 +2020,9 @@ function registerCreateCommand(program2) {
1897
2020
  if (autoAddedModules.length > 0) {
1898
2021
  log.detail("Auto-added", autoAddedModules.join(", "));
1899
2022
  }
2023
+ if (emailProvider) {
2024
+ log.detail("Email provider", emailProvider);
2025
+ }
1900
2026
  if (chatSchemaStatus === "added") {
1901
2027
  log.info(
1902
2028
  "Optional chat schema block was appended to prisma/schema.prisma."
@@ -2119,7 +2245,7 @@ var NexTCLICommand = class _NexTCLICommand extends Command {
2119
2245
 
2120
2246
  // src/cli.ts
2121
2247
  var program = new NexTCLICommand();
2122
- program.name("nextcli").description("Scaffold outsource-ready Next.js projects").version("0.5.0");
2248
+ program.name("nextcli").description("Scaffold outsource-ready Next.js projects").version("0.6.1");
2123
2249
  registerCreateCommand(program);
2124
2250
  registerAddCommand(program);
2125
2251
  registerMigrateCommand(program);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@thinhnguyencth1204/nextcli",
3
- "version": "0.5.0",
3
+ "version": "0.6.1",
4
4
  "description": "CLI scaffolder for outsourced Next.js projects",
5
5
  "type": "module",
6
6
  "bin": {
@@ -1,39 +1,19 @@
1
- import type { ReactNode } from "react";
2
1
  import type { CreateEmailOptions } from "resend";
2
+ import { Resend } from "resend";
3
3
 
4
- import { resend } from "@/lib/resend/client";
5
- import { getResendFromAddress } from "@/lib/resend/config";
4
+ import type { SendEmailInput, SendEmailResult } from "@/lib/email/types";
6
5
 
7
- export type ResendError = {
8
- message: string;
9
- name: string;
10
- };
6
+ const apiKey = process.env.RESEND_API_KEY?.trim();
7
+ const resend = apiKey ? new Resend(apiKey) : null;
11
8
 
12
- export type SendEmailResult = {
13
- data: { id: string } | null;
14
- error: ResendError | null;
15
- };
9
+ function getResendFromAddress(): string | null {
10
+ const from = process.env.RESEND_FROM_EMAIL?.trim();
11
+ return from || null;
12
+ }
16
13
 
17
- export type SendEmailInput = {
18
- to: string | string[];
19
- subject: string;
20
- html?: string;
21
- text?: string;
22
- /** Pass as a function call: WelcomeEmail({ name }), not JSX. */
23
- react?: ReactNode;
24
- cc?: string | string[];
25
- bcc?: string | string[];
26
- replyTo?: string | string[];
27
- scheduledAt?: string;
28
- headers?: Record<string, string>;
29
- tags?: Array<{ name: string; value: string }>;
30
- attachments?: CreateEmailOptions["attachments"];
31
- template?: {
32
- id: string;
33
- variables?: Record<string, string | number>;
34
- };
35
- idempotencyKey?: string;
36
- };
14
+ export function isResendConfigured(): boolean {
15
+ return Boolean(process.env.RESEND_API_KEY?.trim() && getResendFromAddress());
16
+ }
37
17
 
38
18
  function notConfigured(message: string): SendEmailResult {
39
19
  return {
@@ -58,7 +38,7 @@ function countContentFields(input: SendEmailInput): number {
58
38
  return count;
59
39
  }
60
40
 
61
- export async function sendEmail(
41
+ export async function sendWithResend(
62
42
  input: SendEmailInput,
63
43
  ): Promise<SendEmailResult> {
64
44
  if (!resend) {
@@ -101,7 +81,7 @@ export async function sendEmail(
101
81
  scheduledAt: rest.scheduledAt,
102
82
  headers: rest.headers,
103
83
  tags: rest.tags,
104
- attachments: rest.attachments,
84
+ attachments: rest.attachments as CreateEmailOptions["attachments"],
105
85
  }
106
86
  : {
107
87
  from,
@@ -116,7 +96,7 @@ export async function sendEmail(
116
96
  scheduledAt: rest.scheduledAt,
117
97
  headers: rest.headers,
118
98
  tags: rest.tags,
119
- attachments: rest.attachments,
99
+ attachments: rest.attachments as CreateEmailOptions["attachments"],
120
100
  };
121
101
 
122
102
  const result = await resend.emails.send(
@@ -129,18 +109,3 @@ export async function sendEmail(
129
109
  error: result.error,
130
110
  };
131
111
  }
132
-
133
- export async function sendWelcomeEmail(input: {
134
- to: string;
135
- name: string;
136
- idempotencyKey?: string;
137
- }): Promise<SendEmailResult> {
138
- const { WelcomeEmail } = await import("@/emails/welcome-email");
139
-
140
- return sendEmail({
141
- to: input.to,
142
- subject: "Welcome",
143
- react: WelcomeEmail({ name: input.name }),
144
- idempotencyKey: input.idempotencyKey ?? `welcome-user/${input.to}`,
145
- });
146
- }
@@ -0,0 +1,116 @@
1
+ import nodemailer from "nodemailer";
2
+
3
+ import type { SendEmailInput, SendEmailResult } from "@/lib/email/types";
4
+
5
+ type SmtpConfig = {
6
+ host: string;
7
+ port: number;
8
+ user: string;
9
+ password: string;
10
+ from: string;
11
+ };
12
+
13
+ function readSmtpConfig(): SmtpConfig | null {
14
+ const host = process.env.SMTP_HOST?.trim();
15
+ const portRaw = process.env.SMTP_PORT?.trim() || "587";
16
+ const user = process.env.SMTP_USER?.trim();
17
+ const password = process.env.SMTP_PASSWORD?.trim();
18
+ const from = process.env.SMTP_FROM?.trim();
19
+ const port = Number.parseInt(portRaw, 10);
20
+
21
+ if (!host || !user || !password || !from || Number.isNaN(port)) {
22
+ return null;
23
+ }
24
+
25
+ return { host, port, user, password, from };
26
+ }
27
+
28
+ export function isSmtpConfigured(): boolean {
29
+ return readSmtpConfig() !== null;
30
+ }
31
+
32
+ function notConfigured(message: string): SendEmailResult {
33
+ return {
34
+ data: null,
35
+ error: { message, name: "NOT_CONFIGURED" },
36
+ };
37
+ }
38
+
39
+ function validationError(message: string): SendEmailResult {
40
+ return {
41
+ data: null,
42
+ error: { message, name: "VALIDATION_ERROR" },
43
+ };
44
+ }
45
+
46
+ function countContentFields(input: SendEmailInput): number {
47
+ let count = 0;
48
+ if (input.html) count += 1;
49
+ if (input.text) count += 1;
50
+ if (input.react) count += 1;
51
+ if (input.template) count += 1;
52
+ return count;
53
+ }
54
+
55
+ export async function sendWithSmtp(
56
+ input: SendEmailInput,
57
+ ): Promise<SendEmailResult> {
58
+ const config = readSmtpConfig();
59
+ if (!config) {
60
+ return notConfigured(
61
+ "SMTP is not configured. Set SMTP_HOST, SMTP_PORT, SMTP_USER, SMTP_PASSWORD, and SMTP_FROM.",
62
+ );
63
+ }
64
+
65
+ const contentCount = countContentFields(input);
66
+ if (contentCount === 0) {
67
+ return validationError("Provide one of html or text for SMTP email body.");
68
+ }
69
+ if (contentCount > 1) {
70
+ return validationError(
71
+ "Provide only one body source: html or text for SMTP transport.",
72
+ );
73
+ }
74
+ if (input.react || input.template) {
75
+ return validationError(
76
+ "SMTP adapter supports html/text only. Convert React/template content before sending.",
77
+ );
78
+ }
79
+
80
+ const transporter = nodemailer.createTransport({
81
+ host: config.host,
82
+ port: config.port,
83
+ secure: config.port === 465,
84
+ auth: {
85
+ user: config.user,
86
+ pass: config.password,
87
+ },
88
+ });
89
+
90
+ try {
91
+ const info = await transporter.sendMail({
92
+ from: config.from,
93
+ to: input.to,
94
+ subject: input.subject,
95
+ html: input.html,
96
+ text: input.text,
97
+ cc: input.cc,
98
+ bcc: input.bcc,
99
+ replyTo: input.replyTo,
100
+ headers: input.headers,
101
+ attachments: input.attachments as never,
102
+ });
103
+ return {
104
+ data: { id: info.messageId || `smtp-${Date.now()}` },
105
+ error: null,
106
+ };
107
+ } catch (error) {
108
+ return {
109
+ data: null,
110
+ error: {
111
+ name: "SMTP_ERROR",
112
+ message: error instanceof Error ? error.message : "SMTP send failed.",
113
+ },
114
+ };
115
+ }
116
+ }
@@ -0,0 +1,68 @@
1
+ import type {
2
+ EmailProvider,
3
+ SendEmailInput,
4
+ SendEmailResult,
5
+ } from "@/lib/email/types";
6
+ import { sendWithResend } from "@/lib/email/providers/resend";
7
+ import { sendWithSmtp } from "@/lib/email/providers/smtp";
8
+
9
+ function notConfigured(message: string): SendEmailResult {
10
+ return {
11
+ data: null,
12
+ error: { message, name: "NOT_CONFIGURED" },
13
+ };
14
+ }
15
+
16
+ function resolveEmailProvider(): EmailProvider | null {
17
+ const provider = process.env.EMAIL_PROVIDER?.trim().toLowerCase();
18
+ if (provider === "resend" || provider === "smtp") {
19
+ return provider;
20
+ }
21
+ return null;
22
+ }
23
+
24
+ export async function sendEmail(
25
+ input: SendEmailInput,
26
+ ): Promise<SendEmailResult> {
27
+ const provider = resolveEmailProvider();
28
+ if (!provider) {
29
+ return notConfigured(
30
+ "Email provider not configured. Set EMAIL_PROVIDER to 'resend' or 'smtp'.",
31
+ );
32
+ }
33
+
34
+ if (provider === "smtp") {
35
+ return sendWithSmtp(input);
36
+ }
37
+
38
+ return sendWithResend(input);
39
+ }
40
+
41
+ export async function sendWelcomeEmail(input: {
42
+ to: string;
43
+ name: string;
44
+ idempotencyKey?: string;
45
+ }): Promise<SendEmailResult> {
46
+ const provider = resolveEmailProvider();
47
+ if (!provider) {
48
+ return notConfigured(
49
+ "Email provider not configured. Set EMAIL_PROVIDER to 'resend' or 'smtp'.",
50
+ );
51
+ }
52
+
53
+ if (provider === "smtp") {
54
+ return sendWithSmtp({
55
+ to: input.to,
56
+ subject: "Welcome",
57
+ text: `Welcome, ${input.name}. Thanks for joining.`,
58
+ });
59
+ }
60
+
61
+ const { WelcomeEmail } = await import("@/emails/welcome-email");
62
+ return sendWithResend({
63
+ to: input.to,
64
+ subject: "Welcome",
65
+ react: WelcomeEmail({ name: input.name }),
66
+ idempotencyKey: input.idempotencyKey ?? `welcome-user/${input.to}`,
67
+ });
68
+ }
@@ -0,0 +1,34 @@
1
+ import type { ReactNode } from "react";
2
+
3
+ export type EmailProvider = "resend" | "smtp";
4
+
5
+ export type EmailError = {
6
+ message: string;
7
+ name: string;
8
+ };
9
+
10
+ export type SendEmailResult = {
11
+ data: { id: string } | null;
12
+ error: EmailError | null;
13
+ };
14
+
15
+ export type SendEmailInput = {
16
+ to: string | string[];
17
+ subject: string;
18
+ html?: string;
19
+ text?: string;
20
+ /** Pass as a function call: WelcomeEmail({ name }), not JSX. */
21
+ react?: ReactNode;
22
+ cc?: string | string[];
23
+ bcc?: string | string[];
24
+ replyTo?: string | string[];
25
+ scheduledAt?: string;
26
+ headers?: Record<string, string>;
27
+ tags?: Array<{ name: string; value: string }>;
28
+ attachments?: unknown;
29
+ template?: {
30
+ id: string;
31
+ variables?: Record<string, string | number>;
32
+ };
33
+ idempotencyKey?: string;
34
+ };
@@ -1,7 +1,10 @@
1
1
  # --- Database (Supabase) ---
2
- DATABASE_URL="postgresql://postgres:your-supabase-db-password@db.your-project-ref.supabase.co:5432/postgres"
2
+ DATABASE_URL="postgresql://postgres.your-project-ref:your-supabase-db-password@aws-0-us-east-1.pooler.supabase.com:6543/postgres?pgbouncer=true"
3
+ DIRECT_URL="postgresql://postgres.your-project-ref:your-supabase-db-password@aws-0-us-east-1.pooler.supabase.com:5432/postgres"
3
4
  NEXT_PUBLIC_SUPABASE_URL=""
5
+ # Public/browser-safe anon key from Supabase Project Settings -> API -> anon public
4
6
  NEXT_PUBLIC_SUPABASE_ANON_KEY=""
7
+ # Storage bucket name used by the app (default scaffold bucket name: public)
5
8
  NEXT_PUBLIC_SUPABASE_STORAGE_BUCKET="public"
6
9
 
7
10
  # --- Auth ---
@@ -10,4 +13,4 @@ BETTER_AUTH_URL="http://localhost:3000"
10
13
 
11
14
  # --- App ---
12
15
  NEXT_PUBLIC_APP_URL="http://localhost:3000"
13
- NEXT_PUBLIC_DEFAULT_LOCALE="en"
16
+ NEXT_PUBLIC_DEFAULT_LOCALE="vi"
@@ -1,7 +1,10 @@
1
1
  # --- Database (Supabase) ---
2
- DATABASE_URL="postgresql://postgres:your-supabase-db-password@db.your-project-ref.supabase.co:5432/postgres"
2
+ DATABASE_URL="postgresql://postgres.your-project-ref:your-supabase-db-password@aws-0-us-east-1.pooler.supabase.com:6543/postgres?pgbouncer=true"
3
+ DIRECT_URL="postgresql://postgres.your-project-ref:your-supabase-db-password@aws-0-us-east-1.pooler.supabase.com:5432/postgres"
3
4
  NEXT_PUBLIC_SUPABASE_URL=""
5
+ # Public/browser-safe anon key from Supabase Project Settings -> API -> anon public
4
6
  NEXT_PUBLIC_SUPABASE_ANON_KEY=""
7
+ # Storage bucket name used by the app (default scaffold bucket name: public)
5
8
  NEXT_PUBLIC_SUPABASE_STORAGE_BUCKET="public"
6
9
 
7
10
  # --- Auth ---
@@ -10,4 +13,4 @@ BETTER_AUTH_URL="http://localhost:3000"
10
13
 
11
14
  # --- App ---
12
15
  NEXT_PUBLIC_APP_URL="http://localhost:3000"
13
- NEXT_PUBLIC_DEFAULT_LOCALE="en"
16
+ NEXT_PUBLIC_DEFAULT_LOCALE="vi"
@@ -1,7 +1,10 @@
1
1
  # --- Database (Supabase) ---
2
- DATABASE_URL="postgresql://postgres:your-supabase-db-password@db.your-project-ref.supabase.co:5432/postgres"
2
+ DATABASE_URL="postgresql://postgres.your-project-ref:your-supabase-db-password@aws-0-us-east-1.pooler.supabase.com:6543/postgres?pgbouncer=true"
3
+ DIRECT_URL="postgresql://postgres.your-project-ref:your-supabase-db-password@aws-0-us-east-1.pooler.supabase.com:5432/postgres"
3
4
  NEXT_PUBLIC_SUPABASE_URL=""
5
+ # Public/browser-safe anon key from Supabase Project Settings -> API -> anon public
4
6
  NEXT_PUBLIC_SUPABASE_ANON_KEY=""
7
+ # Storage bucket name used by the app (default scaffold bucket name: public)
5
8
  NEXT_PUBLIC_SUPABASE_STORAGE_BUCKET="public"
6
9
 
7
10
  # --- Auth ---
@@ -10,4 +13,4 @@ BETTER_AUTH_URL="http://localhost:3000"
10
13
 
11
14
  # --- App ---
12
15
  NEXT_PUBLIC_APP_URL="http://localhost:3000"
13
- NEXT_PUBLIC_DEFAULT_LOCALE="en"
16
+ NEXT_PUBLIC_DEFAULT_LOCALE="vi"
@@ -34,15 +34,15 @@ Generated by NexTCLI. Folders marked **(module)** or **(feature)** appear only w
34
34
 
35
35
  ## `src/lib/` — Shared runtime
36
36
 
37
- | Path | Purpose |
38
- | ---------------------------- | ------------------------------------------------- |
39
- | `auth.ts` / `auth-client.ts` | Better Auth (username + JWT) |
40
- | `bootstrap.ts` | Seeds `admin` role/user on startup |
41
- | `rbac.ts` | Role hierarchy guards |
42
- | `prisma.ts` | DB client |
43
- | `api-response.ts` | `/api/v1/*` envelope helpers |
44
- | `supabase/` | Supabase client + Storage helpers |
45
- | `resend/` | **(module: `resend`)** email client + send helper |
37
+ | Path | Purpose |
38
+ | ---------------------------- | --------------------------------------------------------------- |
39
+ | `auth.ts` / `auth-client.ts` | Better Auth (username + JWT) |
40
+ | `bootstrap.ts` | Seeds `admin` role/user on startup |
41
+ | `rbac.ts` | Role hierarchy guards |
42
+ | `prisma.ts` | DB client |
43
+ | `api-response.ts` | `/api/v1/*` envelope helpers |
44
+ | `supabase/` | Supabase client + Storage helpers |
45
+ | `email/` | **(module: `email`)** provider-agnostic email helper + adapters |
46
46
 
47
47
  ## `src/features/` vs `src/example/`
48
48
 
@@ -67,7 +67,7 @@ Locale config with `nextcli:locales` / `nextcli:namespaces` markers (patched by
67
67
 
68
68
  ## `src/emails/`
69
69
 
70
- **(module: `resend`)** React Email templates.
70
+ **(module: `email`)** React Email templates.
71
71
 
72
72
  ## Optional modules (via `nextcli add module`)
73
73
 
@@ -76,7 +76,7 @@ Locale config with `nextcli:locales` / `nextcli:namespaces` markers (patched by
76
76
  | `chat` | Chat routes, hooks, Prisma chat models (+ auto `supabase-realtime`) |
77
77
  | `supabase-realtime` | Realtime channel helpers |
78
78
  | `seo` | robots/sitemap/JSON-LD helpers |
79
- | `resend` | `src/lib/resend/*`, `src/emails/*` |
79
+ | `email` | `src/lib/email/*`, `src/emails/*` (provider: SMTP or Resend) |
80
80
 
81
81
  Module **env variables** and where to find them: see `SETUP.md` → **Optional module environment** (full catalog; `Enabled modules` line updates when you create/add modules).
82
82
 
@@ -4,7 +4,7 @@ Quick reference for env vars and branding after `nextcli create`.
4
4
 
5
5
  ## First run (required)
6
6
 
7
- 1. Create a Supabase project and set `DATABASE_URL` in `.env`.
7
+ 1. Create a Supabase project and set `DATABASE_URL` and `DIRECT_URL` in `.env`.
8
8
  2. `bun run db:migrate` — applies `prisma/migrations` (includes `Role`, `User.username`, `requirePasswordChange`).
9
9
  3. `bun run dev` — bootstrap seeds `admin` / `admin` on first start.
10
10
 
@@ -23,15 +23,16 @@ Quick reference for env vars and branding after `nextcli create`.
23
23
 
24
24
  Set in `.env` / `.env.development` (secrets are generated at create time).
25
25
 
26
- | Variable | Purpose | Where to get |
27
- | ------------------------------------- | -------------------------- | ---------------------------------------------------------------------- |
28
- | `DATABASE_URL` | Supabase Postgres database | Supabase Dashboard → Project SettingsDatabaseConnection string |
29
- | `NEXT_PUBLIC_SUPABASE_URL` | Supabase client URL | Supabase Dashboard → Project SettingsAPIProject URL |
30
- | `NEXT_PUBLIC_SUPABASE_ANON_KEY` | Supabase browser anon key | Same page → Project API keys `anon` `public` |
31
- | `NEXT_PUBLIC_SUPABASE_STORAGE_BUCKET` | Supabase Storage bucket | Storage create bucketuse bucket name (default scaffold: `public`) |
32
- | `BETTER_AUTH_SECRET` | Auth signing secret | Auto-generated on create; rotate in production |
33
- | `BETTER_AUTH_URL` | Server auth base URL | Your app URL (e.g. `http://localhost:3000`) |
34
- | `NEXT_PUBLIC_APP_URL` | Client-visible app URL | Same as public site URL |
26
+ | Variable | Purpose | Where to get |
27
+ | ------------------------------------- | ------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------- |
28
+ | `DATABASE_URL` | Postgres URL for app runtime and Prisma CLI (`prisma.config.ts` → `datasource.url`) | Supabase Dashboard → Connect ORMs Prismapooled URL (`:6543`, `?pgbouncer=true`) |
29
+ | `DIRECT_URL` | Direct/session Postgres URL from Supabase Connect (keep in env for reference/tooling) | Supabase Dashboard → Connect ORMs Prismadirect/session URL (`:5432`) |
30
+ | `NEXT_PUBLIC_SUPABASE_URL` | Supabase client URL | Supabase Dashboard → Project Settings → API → Project URL |
31
+ | `NEXT_PUBLIC_SUPABASE_ANON_KEY` | Browser-safe anon API key (public key used by client SDK) | Same page Project API keys `anon` `public` |
32
+ | `NEXT_PUBLIC_SUPABASE_STORAGE_BUCKET` | Storage bucket name used by app uploads/reads (`public` is the default bucket name) | Storage create/select bucket use that bucket name |
33
+ | `BETTER_AUTH_SECRET` | Auth signing secret | Auto-generated on create; rotate in production |
34
+ | `BETTER_AUTH_URL` | Server auth base URL | Your app URL (e.g. `http://localhost:3000`) |
35
+ | `NEXT_PUBLIC_APP_URL` | Client-visible app URL | Same as public site URL |
35
36
 
36
37
  ### Default admin account
37
38
 
@@ -65,13 +66,29 @@ Uses the base Supabase URL/anon key. Enable Realtime on tables in Supabase Dashb
65
66
 
66
67
  No extra env keys. Edit `src/app/robots.ts`, `sitemap.ts`, and JSON-LD helpers after add.
67
68
 
68
- ### Module: Resend email (`resend`)
69
+ ### Module: Email module (`email`)
70
+
71
+ | Variable | Where to get |
72
+ | ---------------- | ------------------ |
73
+ | `EMAIL_PROVIDER` | `resend` or `smtp` |
74
+
75
+ When `EMAIL_PROVIDER=resend`:
69
76
 
70
77
  | Variable | Where to get |
71
78
  | ------------------- | ------------------------------------------------------------------ |
72
79
  | `RESEND_API_KEY` | resend.com → API Keys → Create |
73
80
  | `RESEND_FROM_EMAIL` | resend.com → Domains → verify domain → use `Name <you@domain.com>` |
74
81
 
82
+ When `EMAIL_PROVIDER=smtp`:
83
+
84
+ | Variable | Where to get |
85
+ | --------------- | -------------------------------------------- |
86
+ | `SMTP_HOST` | SMTP provider host (Mailgun, SES, etc.) |
87
+ | `SMTP_PORT` | SMTP provider port (commonly `587` or `465`) |
88
+ | `SMTP_USER` | SMTP username |
89
+ | `SMTP_PASSWORD` | SMTP password or app-specific token |
90
+ | `SMTP_FROM` | Sender email (or `Name <mail@domain.com>`) |
91
+
75
92
  <!-- nextcli:module-env:end -->
76
93
 
77
94
  ## After adding modules
@@ -1,5 +0,0 @@
1
- import { Resend } from "resend";
2
-
3
- const apiKey = process.env.RESEND_API_KEY?.trim();
4
-
5
- export const resend = apiKey ? new Resend(apiKey) : null;
@@ -1,8 +0,0 @@
1
- export function getResendFromAddress(): string | null {
2
- const from = process.env.RESEND_FROM_EMAIL?.trim();
3
- return from || null;
4
- }
5
-
6
- export function isResendConfigured(): boolean {
7
- return Boolean(process.env.RESEND_API_KEY?.trim() && getResendFromAddress());
8
- }