@thinhnguyencth1204/nextcli 0.1.0 → 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/README.md +16 -16
- package/dist/cli.js +110 -77
- package/package.json +1 -1
- package/templates/features/resend/src/emails/welcome-email.tsx +28 -0
- package/templates/features/resend/src/lib/resend/client.ts +5 -0
- package/templates/features/resend/src/lib/resend/config.ts +8 -0
- package/templates/features/resend/src/lib/resend/send-email.ts +146 -0
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
|
|
23
|
+
node dist/cli.js create
|
|
24
24
|
```
|
|
25
25
|
|
|
26
|
-
This command is interactive
|
|
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
|
|
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,
|
|
243
|
-
return `${code}${
|
|
259
|
+
function paint(code, text2) {
|
|
260
|
+
return `${code}${text2}${reset}`;
|
|
244
261
|
}
|
|
245
262
|
var theme = {
|
|
246
|
-
cyan: (
|
|
247
|
-
green: (
|
|
248
|
-
yellow: (
|
|
249
|
-
red: (
|
|
250
|
-
blue: (
|
|
251
|
-
magenta: (
|
|
252
|
-
dim: (
|
|
253
|
-
bold: (
|
|
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(
|
|
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").
|
|
1150
|
+
program2.command("create").description("Create a new outsource-ready Next.js app").action(async () => {
|
|
1113
1151
|
startPrompt("NexTCLI project creation");
|
|
1114
|
-
const
|
|
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
|
-
{
|
|
1162
|
+
{
|
|
1163
|
+
value: "bun",
|
|
1164
|
+
label: "bun",
|
|
1165
|
+
hint: "Fast runtime and package manager"
|
|
1166
|
+
}
|
|
1121
1167
|
],
|
|
1122
1168
|
"npm"
|
|
1123
|
-
)
|
|
1124
|
-
const
|
|
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
|
|
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(
|
|
1165
|
-
|
|
1166
|
-
|
|
1167
|
-
|
|
1168
|
-
|
|
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(
|
|
1185
|
-
|
|
1186
|
-
|
|
1187
|
-
|
|
1188
|
-
|
|
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(
|
|
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
|
-
|
|
1197
|
-
|
|
1198
|
-
|
|
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(
|
|
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
|
});
|
package/package.json
CHANGED
|
@@ -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,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
|
+
}
|