@thinhnguyencth1204/nextcli 0.1.0 → 0.2.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
@@ -20,29 +20,21 @@ node dist/cli.js --help
20
20
  Create a new project from the base template:
21
21
 
22
22
  ```bash
23
- node dist/cli.js create my-nextjs-app
23
+ node dist/cli.js create
24
24
  ```
25
25
 
26
- This command is interactive by default:
26
+ This command is fully interactive:
27
+
28
+ - enter project name
27
29
  - select package manager in CLI UI (`npm`, `pnpm`, `yarn`, `bun`)
28
- - multi-select optional modules (`chat`, `supabase`, `supabase-realtime`, `seo`)
30
+ - multi-select optional modules (`chat`, `supabase`, `supabase-realtime`, `seo`, `resend`)
29
31
  - confirm install step
30
32
  - normalizes project directory name into a safe project slug for generated `package.json` and env placeholders
31
33
 
32
- ### Non-interactive create options (for CI/automation)
33
-
34
- - `--yes`: skip prompts and use defaults
35
- - `--package-manager <npm|pnpm|yarn|bun>`: set package manager explicitly
36
- - `--module <id...>`: preselect optional modules (repeat or comma-separated)
37
- - `--install` / `--no-install`: force install behavior
38
-
39
- ```bash
40
- node dist/cli.js create my-nextjs-app --yes --module supabase --module seo --no-install
41
- ```
42
-
43
34
  ## Core auth in base template
44
35
 
45
36
  Generated projects now include:
37
+
46
38
  - Better Auth + Prisma adapter with JWT plugin enabled
47
39
  - email/password sign-in scaffold (`/sign-in`)
48
40
  - account sample page (`/account`)
@@ -53,6 +45,7 @@ Generated projects now include:
53
45
  - `GET /api/v1/auth/me`
54
46
 
55
47
  Axios setup is split:
48
+
56
49
  - `publicApi`: public calls
57
50
  - `protectedApi`: bearer token calls with 401 refresh queue + retry
58
51
  - refresh uses HttpOnly cookie strategy (`withCredentials: true`)
@@ -78,6 +71,7 @@ node ../dist/cli.js add feature orders
78
71
  ```
79
72
 
80
73
  `add feature` now always creates:
74
+
81
75
  - `src/features/<feature>/api/use-<feature>.ts`
82
76
  - `src/features/<feature>/components/`
83
77
  - `src/features/<feature>/services.ts`
@@ -95,10 +89,12 @@ node ../dist/cli.js add module
95
89
  Module copy is non-destructive: existing files are kept and reported as skipped conflicts.
96
90
 
97
91
  Interactive multiselect shows available module catalog:
92
+
98
93
  - `chat`
99
94
  - `supabase`
100
95
  - `supabase-realtime`
101
96
  - `seo`
97
+ - `resend`
102
98
 
103
99
  Non-interactive example:
104
100
 
@@ -115,6 +111,7 @@ node ../dist/cli.js add auth-provider
115
111
  ```
116
112
 
117
113
  Supported providers:
114
+
118
115
  - `google`
119
116
  - `facebook`
120
117
 
@@ -125,6 +122,7 @@ node ../dist/cli.js add auth-provider --provider google --provider facebook --ye
125
122
  ```
126
123
 
127
124
  This command:
125
+
128
126
  - updates `src/lib/auth.ts` provider block
129
127
  - merges provider env keys into `.env` and `.env.example`
130
128
  - runs Better Auth schema generation helper (with install prompt if CLI is missing in interactive mode)
@@ -138,6 +136,7 @@ node ../dist/cli.js migrate
138
136
  ```
139
137
 
140
138
  Optional flags:
139
+
141
140
  - `--name <migration-name>`: set migration name manually
142
141
  - `--skip-generate`: pass through to `prisma migrate dev --skip-generate`
143
142
 
@@ -161,7 +160,7 @@ node ../dist/cli.js migrate --name init_auth --skip-generate
161
160
  - i18n base with `next-intl`
162
161
  - Sonner notifications
163
162
  - date-fns utility library
164
- - Optional modules: chat, supabase, supabase-realtime, seo
163
+ - Optional modules: chat, supabase, supabase-realtime, seo, resend
165
164
 
166
165
  ## Template structure
167
166
 
@@ -175,11 +174,12 @@ Optional module templates:
175
174
  - `templates/features/supabase`
176
175
  - `templates/features/supabase-realtime`
177
176
  - `templates/features/seo`
177
+ - `templates/features/resend`
178
178
 
179
179
  ## Realtime chat schema foundation
180
180
 
181
181
  Chatbox Prisma entities are appended only when you add the `chat` module
182
- (`create --module chat` or `add module --module chat`).
182
+ (during `create` or via `add module --module chat`).
183
183
 
184
184
  When chat is selected, NexTCLI auto-adds `supabase-realtime` if missing.
185
185
 
package/dist/cli.js CHANGED
@@ -169,7 +169,8 @@ var templatePaths = {
169
169
  chat: path2.join(rootDir, "templates/features/chat"),
170
170
  supabase: path2.join(rootDir, "templates/features/supabase"),
171
171
  supabaseRealtime: path2.join(rootDir, "templates/features/supabase-realtime"),
172
- seo: path2.join(rootDir, "templates/features/seo")
172
+ seo: path2.join(rootDir, "templates/features/seo"),
173
+ resend: path2.join(rootDir, "templates/features/resend")
173
174
  };
174
175
 
175
176
  // src/core/modules.ts
@@ -216,6 +217,21 @@ var optionalModules = [
216
217
  description: "Adds robots/sitemap and JsonLd helper files",
217
218
  templatePath: templatePaths.seo,
218
219
  env: {}
220
+ },
221
+ {
222
+ id: "resend",
223
+ label: "Resend email",
224
+ description: "Adds Resend client, send helper, and React Email welcome template",
225
+ templatePath: templatePaths.resend,
226
+ env: {
227
+ RESEND_API_KEY: "",
228
+ RESEND_FROM_EMAIL: ""
229
+ },
230
+ dependencies: {
231
+ resend: "^6.9.2",
232
+ "@react-email/components": "^1.0.12",
233
+ "react-email": "^4.0.0"
234
+ }
219
235
  }
220
236
  ];
221
237
  function getModuleById(moduleId) {
@@ -234,23 +250,24 @@ import {
234
250
  isCancel,
235
251
  multiselect,
236
252
  outro,
237
- select
253
+ select,
254
+ text
238
255
  } from "@clack/prompts";
239
256
 
240
257
  // src/core/theme.ts
241
258
  var reset = "\x1B[0m";
242
- function paint(code, text) {
243
- return `${code}${text}${reset}`;
259
+ function paint(code, text2) {
260
+ return `${code}${text2}${reset}`;
244
261
  }
245
262
  var theme = {
246
- cyan: (text) => paint("\x1B[36m", text),
247
- green: (text) => paint("\x1B[32m", text),
248
- yellow: (text) => paint("\x1B[33m", text),
249
- red: (text) => paint("\x1B[31m", text),
250
- blue: (text) => paint("\x1B[34m", text),
251
- magenta: (text) => paint("\x1B[35m", text),
252
- dim: (text) => paint("\x1B[2m", text),
253
- bold: (text) => paint("\x1B[1m", text)
263
+ cyan: (text2) => paint("\x1B[36m", text2),
264
+ green: (text2) => paint("\x1B[32m", text2),
265
+ yellow: (text2) => paint("\x1B[33m", text2),
266
+ red: (text2) => paint("\x1B[31m", text2),
267
+ blue: (text2) => paint("\x1B[34m", text2),
268
+ magenta: (text2) => paint("\x1B[35m", text2),
269
+ dim: (text2) => paint("\x1B[2m", text2),
270
+ bold: (text2) => paint("\x1B[1m", text2)
254
271
  };
255
272
  var bannerGradient = [
256
273
  "\x1B[38;5;51m",
@@ -324,6 +341,16 @@ async function askConfirm(message, initialValue = false) {
324
341
  handleCancelled(value);
325
342
  return Boolean(value);
326
343
  }
344
+ async function askText(message, options = {}) {
345
+ const value = await text({
346
+ message,
347
+ placeholder: options.placeholder,
348
+ initialValue: options.initialValue,
349
+ validate: options.validate
350
+ });
351
+ handleCancelled(value);
352
+ return value;
353
+ }
327
354
 
328
355
  // src/core/auth-bootstrap.ts
329
356
  import { spawn } from "child_process";
@@ -1075,24 +1102,14 @@ async function runInstall(packageManager, cwd) {
1075
1102
  resolve();
1076
1103
  return;
1077
1104
  }
1078
- reject(new Error(`Dependency installation failed with code ${code ?? "unknown"}`));
1105
+ reject(
1106
+ new Error(
1107
+ `Dependency installation failed with code ${code ?? "unknown"}`
1108
+ )
1109
+ );
1079
1110
  });
1080
1111
  });
1081
1112
  }
1082
- function parseModuleIds(moduleValues) {
1083
- if (!moduleValues || moduleValues.length === 0) {
1084
- return [];
1085
- }
1086
- const flattened = moduleValues.flatMap((value) => value.split(","));
1087
- const normalized = flattened.map((value) => value.trim()).filter(Boolean);
1088
- const validIds = new Set(optionalModules.map((module) => module.id));
1089
- for (const moduleId of normalized) {
1090
- if (!validIds.has(moduleId)) {
1091
- throw new Error(`Unknown module: ${moduleId}`);
1092
- }
1093
- }
1094
- return [...new Set(normalized)];
1095
- }
1096
1113
  function toProjectSlug(input) {
1097
1114
  return input.trim().toLowerCase().replace(/[^a-z0-9._-]+/g, "-").replace(/^-+|-+$/g, "");
1098
1115
  }
@@ -1108,39 +1125,49 @@ function normalizeModuleSelection2(moduleIds) {
1108
1125
  autoAddedModules: autoAdded
1109
1126
  };
1110
1127
  }
1128
+ async function resolveProjectName() {
1129
+ while (true) {
1130
+ const projectName = await askText("What is your project name?", {
1131
+ placeholder: "my-nextjs-app",
1132
+ validate: (value) => {
1133
+ if (!value.trim()) {
1134
+ return "Project name is required.";
1135
+ }
1136
+ if (!toProjectSlug(value)) {
1137
+ return "Use letters, numbers, dots, underscores, or dashes.";
1138
+ }
1139
+ }
1140
+ });
1141
+ const targetPath = path5.resolve(process.cwd(), projectName);
1142
+ if (await pathExists(targetPath)) {
1143
+ log.error(`Target directory already exists: ${targetPath}`);
1144
+ continue;
1145
+ }
1146
+ return projectName;
1147
+ }
1148
+ }
1111
1149
  function registerCreateCommand(program2) {
1112
- program2.command("create").description("Create a new outsource-ready Next.js app").argument("<project-name>", "Target project name").option("--package-manager <manager>", "Package manager: npm|pnpm|yarn|bun").option("--module <module...>", "Preselect modules (chat,supabase,supabase-realtime,seo)").option("--install", "Install dependencies after generation").option("--no-install", "Skip dependency installation").option("--yes", "Skip prompts and use defaults").action(async (projectName, options) => {
1150
+ program2.command("create").description("Create a new outsource-ready Next.js app").action(async () => {
1113
1151
  startPrompt("NexTCLI project creation");
1114
- const packageManager = options.packageManager ?? (options.yes ? "npm" : await askSelect(
1152
+ const projectName = await resolveProjectName();
1153
+ const targetPath = path5.resolve(process.cwd(), projectName);
1154
+ const projectDirectoryName = path5.basename(targetPath);
1155
+ const projectSlug = toProjectSlug(projectDirectoryName);
1156
+ const packageManager = await askSelect(
1115
1157
  "Which package manager do you want to use?",
1116
1158
  [
1117
1159
  { value: "npm", label: "npm", hint: "Default and stable" },
1118
1160
  { value: "pnpm", label: "pnpm", hint: "Fast and disk-efficient" },
1119
1161
  { value: "yarn", label: "yarn", hint: "Classic workspace choice" },
1120
- { value: "bun", label: "bun", hint: "Fast runtime and package manager" }
1162
+ {
1163
+ value: "bun",
1164
+ label: "bun",
1165
+ hint: "Fast runtime and package manager"
1166
+ }
1121
1167
  ],
1122
1168
  "npm"
1123
- ));
1124
- const allowedManagers = /* @__PURE__ */ new Set(["npm", "pnpm", "yarn", "bun"]);
1125
- if (!allowedManagers.has(packageManager)) {
1126
- log.error("Invalid --package-manager value. Use npm, pnpm, yarn, or bun.");
1127
- process.exitCode = 1;
1128
- return;
1129
- }
1130
- const targetPath = path5.resolve(process.cwd(), projectName);
1131
- if (await pathExists(targetPath)) {
1132
- log.error(`Target directory already exists: ${targetPath}`);
1133
- process.exitCode = 1;
1134
- return;
1135
- }
1136
- const projectDirectoryName = path5.basename(targetPath);
1137
- const projectSlug = toProjectSlug(projectDirectoryName);
1138
- if (!projectSlug) {
1139
- log.error("Invalid project name. Use letters, numbers, dots, underscores, or dashes.");
1140
- process.exitCode = 1;
1141
- return;
1142
- }
1143
- const rawModules = options.module && options.module.length > 0 ? parseModuleIds(options.module) : options.yes ? [] : await askMultiSelect(
1169
+ );
1170
+ const rawModules = await askMultiSelect(
1144
1171
  "Select optional modules to include:",
1145
1172
  optionalModules.map((module) => ({
1146
1173
  value: module.id,
@@ -1150,8 +1177,7 @@ function registerCreateCommand(program2) {
1150
1177
  []
1151
1178
  );
1152
1179
  const { selectedModules, autoAddedModules } = normalizeModuleSelection2(rawModules);
1153
- const installFlagProvided = process.argv.includes("--install") || process.argv.includes("--no-install");
1154
- const shouldInstall = installFlagProvided ? Boolean(options.install) : options.yes ? false : await askConfirm("Install dependencies now?", true);
1180
+ const shouldInstall = await askConfirm("Install dependencies now?", true);
1155
1181
  await copyDirectory(templatePaths.base, targetPath);
1156
1182
  for (const moduleId of selectedModules) {
1157
1183
  const moduleDefinition = getModuleById(moduleId);
@@ -1161,19 +1187,18 @@ function registerCreateCommand(program2) {
1161
1187
  if (selectedModules.includes("chat")) {
1162
1188
  chatSchemaStatus = await ensureChatSchemaInProject(targetPath);
1163
1189
  }
1164
- const moduleEnvEntries = selectedModules.reduce((acc, moduleId) => {
1165
- const module = getModuleById(moduleId);
1166
- return {
1167
- ...acc,
1168
- ...module.env
1169
- };
1170
- }, {});
1190
+ const moduleEnvEntries = selectedModules.reduce(
1191
+ (acc, moduleId) => {
1192
+ const module = getModuleById(moduleId);
1193
+ return {
1194
+ ...acc,
1195
+ ...module.env
1196
+ };
1197
+ },
1198
+ {}
1199
+ );
1171
1200
  if (Object.keys(moduleEnvEntries).length > 0) {
1172
- const envTargets = [
1173
- ".env",
1174
- ".env.example",
1175
- ".env.development"
1176
- ];
1201
+ const envTargets = [".env", ".env.example", ".env.development"];
1177
1202
  for (const envFile of envTargets) {
1178
1203
  const envPath = path5.join(targetPath, envFile);
1179
1204
  if (await pathExists(envPath)) {
@@ -1181,21 +1206,27 @@ function registerCreateCommand(program2) {
1181
1206
  }
1182
1207
  }
1183
1208
  }
1184
- const dependencyEntries = selectedModules.reduce((acc, moduleId) => {
1185
- const module = getModuleById(moduleId);
1186
- return {
1187
- ...acc,
1188
- ...module.dependencies ?? {}
1189
- };
1190
- }, {});
1209
+ const dependencyEntries = selectedModules.reduce(
1210
+ (acc, moduleId) => {
1211
+ const module = getModuleById(moduleId);
1212
+ return {
1213
+ ...acc,
1214
+ ...module.dependencies ?? {}
1215
+ };
1216
+ },
1217
+ {}
1218
+ );
1191
1219
  if (Object.keys(dependencyEntries).length > 0) {
1192
- await addDependencies(path5.join(targetPath, "package.json"), dependencyEntries);
1220
+ await addDependencies(
1221
+ path5.join(targetPath, "package.json"),
1222
+ dependencyEntries
1223
+ );
1193
1224
  }
1194
1225
  const betterAuthSecret = randomBytes(32).toString("base64url");
1195
1226
  await replaceTokensInDirectory(targetPath, {
1196
- "__PROJECT_NAME__": projectSlug,
1197
- "__ENABLE_CHAT__": selectedModules.includes("chat") ? "true" : "false",
1198
- "__BETTER_AUTH_SECRET__": betterAuthSecret
1227
+ __PROJECT_NAME__: projectSlug,
1228
+ __ENABLE_CHAT__: selectedModules.includes("chat") ? "true" : "false",
1229
+ __BETTER_AUTH_SECRET__: betterAuthSecret
1199
1230
  });
1200
1231
  if (shouldInstall) {
1201
1232
  log.step(`Installing dependencies with ${packageManager}...`);
@@ -1214,7 +1245,9 @@ function registerCreateCommand(program2) {
1214
1245
  log.detail("Auto-added", autoAddedModules.join(", "));
1215
1246
  }
1216
1247
  if (chatSchemaStatus === "added") {
1217
- log.info("Optional chat schema block was appended to prisma/schema.prisma.");
1248
+ log.info(
1249
+ "Optional chat schema block was appended to prisma/schema.prisma."
1250
+ );
1218
1251
  }
1219
1252
  log.step(`Next: cd ${projectName} && ${packageManager} run dev`);
1220
1253
  });
@@ -1431,7 +1464,7 @@ var NexTCLICommand = class _NexTCLICommand extends Command {
1431
1464
 
1432
1465
  // src/cli.ts
1433
1466
  var program = new NexTCLICommand();
1434
- program.name("nextcli").description("Scaffold outsource-ready Next.js projects").version("0.1.0");
1467
+ program.name("nextcli").description("Scaffold outsource-ready Next.js projects").version("0.2.1");
1435
1468
  registerCreateCommand(program);
1436
1469
  registerAddCommand(program);
1437
1470
  registerMigrateCommand(program);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@thinhnguyencth1204/nextcli",
3
- "version": "0.1.0",
3
+ "version": "0.2.1",
4
4
  "description": "CLI scaffolder for outsourced Next.js projects",
5
5
  "type": "module",
6
6
  "bin": {
@@ -0,0 +1,28 @@
1
+ import {
2
+ Body,
3
+ Container,
4
+ Head,
5
+ Heading,
6
+ Html,
7
+ Preview,
8
+ Text,
9
+ } from "@react-email/components";
10
+
11
+ export type WelcomeEmailProps = {
12
+ name: string;
13
+ };
14
+
15
+ export function WelcomeEmail({ name }: WelcomeEmailProps) {
16
+ return (
17
+ <Html>
18
+ <Head />
19
+ <Preview>Welcome aboard</Preview>
20
+ <Body style={{ fontFamily: "sans-serif", backgroundColor: "#f6f6f6" }}>
21
+ <Container style={{ padding: "24px", backgroundColor: "#ffffff" }}>
22
+ <Heading>Welcome, {name}</Heading>
23
+ <Text>Thanks for joining. We are glad to have you on board.</Text>
24
+ </Container>
25
+ </Body>
26
+ </Html>
27
+ );
28
+ }
@@ -0,0 +1,5 @@
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;
@@ -0,0 +1,8 @@
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
+ }
@@ -0,0 +1,146 @@
1
+ import type { ReactNode } from "react";
2
+ import type { CreateEmailOptions } from "resend";
3
+
4
+ import { resend } from "@/lib/resend/client";
5
+ import { getResendFromAddress } from "@/lib/resend/config";
6
+
7
+ export type ResendError = {
8
+ message: string;
9
+ name: string;
10
+ };
11
+
12
+ export type SendEmailResult = {
13
+ data: { id: string } | null;
14
+ error: ResendError | null;
15
+ };
16
+
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
+ };
37
+
38
+ function notConfigured(message: string): SendEmailResult {
39
+ return {
40
+ data: null,
41
+ error: { message, name: "NOT_CONFIGURED" },
42
+ };
43
+ }
44
+
45
+ function validationError(message: string): SendEmailResult {
46
+ return {
47
+ data: null,
48
+ error: { message, name: "VALIDATION_ERROR" },
49
+ };
50
+ }
51
+
52
+ function countContentFields(input: SendEmailInput): number {
53
+ let count = 0;
54
+ if (input.html) count += 1;
55
+ if (input.text) count += 1;
56
+ if (input.react) count += 1;
57
+ if (input.template) count += 1;
58
+ return count;
59
+ }
60
+
61
+ export async function sendEmail(
62
+ input: SendEmailInput,
63
+ ): Promise<SendEmailResult> {
64
+ if (!resend) {
65
+ return notConfigured(
66
+ "Resend is not configured. Set RESEND_API_KEY in your environment.",
67
+ );
68
+ }
69
+
70
+ const from = getResendFromAddress();
71
+ if (!from) {
72
+ return notConfigured(
73
+ "Resend from address is not configured. Set RESEND_FROM_EMAIL to a verified domain sender.",
74
+ );
75
+ }
76
+
77
+ const contentCount = countContentFields(input);
78
+ if (contentCount === 0) {
79
+ return validationError(
80
+ "Provide one of html, text, react, or template for the email body.",
81
+ );
82
+ }
83
+
84
+ if (contentCount > 1) {
85
+ return validationError(
86
+ "Provide only one body source: html, text, react, or template.",
87
+ );
88
+ }
89
+
90
+ const { idempotencyKey, template, ...rest } = input;
91
+
92
+ const payload: CreateEmailOptions = template
93
+ ? {
94
+ from,
95
+ to: rest.to,
96
+ subject: rest.subject,
97
+ template,
98
+ cc: rest.cc,
99
+ bcc: rest.bcc,
100
+ replyTo: rest.replyTo,
101
+ scheduledAt: rest.scheduledAt,
102
+ headers: rest.headers,
103
+ tags: rest.tags,
104
+ attachments: rest.attachments,
105
+ }
106
+ : {
107
+ from,
108
+ to: rest.to,
109
+ subject: rest.subject,
110
+ html: rest.html,
111
+ text: rest.text,
112
+ react: rest.react,
113
+ cc: rest.cc,
114
+ bcc: rest.bcc,
115
+ replyTo: rest.replyTo,
116
+ scheduledAt: rest.scheduledAt,
117
+ headers: rest.headers,
118
+ tags: rest.tags,
119
+ attachments: rest.attachments,
120
+ };
121
+
122
+ const result = await resend.emails.send(
123
+ payload,
124
+ idempotencyKey ? { idempotencyKey } : undefined,
125
+ );
126
+
127
+ return {
128
+ data: result.data,
129
+ error: result.error,
130
+ };
131
+ }
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
+ }