fix: P0 工程修复(eslint 9 / .env.example / 联系表单后端)

- eslint 迁移至 v9 flat config + eslint-config-next 16,lint 脚本改为 eslint .(next lint 在 Next 16 已移除),新增 typecheck 脚本
- React Compiler 严格规则对存量代码降为警告,修复邮件模板未转义字符
- .env.example 补齐 RESEND_VERIFIED_DOMAIN / RESEND_FROM_NAME / NEXT_PUBLIC_APP_NAME / CONTACT_RECEIVER_EMAIL
- email.ts 发件地址改为发送时求值,避免冷启动缓存错误值
- 联系表单接入 server action:留言落库 contact_message,并邮件通知管理员(HTML 转义防注入)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
CoolChange.AI
2026-07-31 20:35:17 -07:00
co-authored by Claude Fable 5
parent f3088e996f
commit 89a9e318c2
10 changed files with 1192 additions and 672 deletions
+12 -1
View File
@@ -36,12 +36,20 @@ RESEND_API_KEY="re_your_api_key"
# Email Configuration - Choose one of the following options:
#
# Set a complete from email address (recommended, highest priority)
# Option A (recommended, highest priority): set a complete from email address
RESEND_FROM_EMAIL="Your App <noreply@yourdomain.com>"
# Option B: set a verified domain (production requires A or B),
# from address becomes "{RESEND_FROM_NAME} <noreply@{RESEND_VERIFIED_DOMAIN}>"
RESEND_VERIFIED_DOMAIN=""
RESEND_FROM_NAME=""
# Optional: Resend Audience ID for newsletter/marketing
RESEND_AUDIENCE_ID="your-resend-audience-id"
# Optional: where contact form notifications are sent (admin inbox)
CONTACT_RECEIVER_EMAIL=""
# ====================================
# Analytics & Monitoring (Optional)
# ====================================
@@ -75,6 +83,9 @@ STORAGE_PUBLIC_URL=""
# Public app URL (update in production)
NEXT_PUBLIC_APP_URL="http://localhost:3000"
# App name used in email sender and titles (optional)
NEXT_PUBLIC_APP_NAME="Toolso.AI"
# Node environment
NODE_ENV="development"
-3
View File
@@ -1,3 +0,0 @@
{
"extends": "next/core-web-vitals"
}
+20
View File
@@ -0,0 +1,20 @@
import { defineConfig, globalIgnores } from "eslint/config";
import coreWebVitals from "eslint-config-next/core-web-vitals";
export default defineConfig([
...coreWebVitals,
globalIgnores([
".next/**",
"node_modules/**",
"src/drizzle/**",
"src/lib/blog-manifest.generated.ts",
]),
{
rules: {
// eslint-config-next 16 引入的 React Compiler 严格规则,
// 存量代码待逐步重构,先降为警告避免阻塞 CI
"react-hooks/set-state-in-effect": "warn",
"react-hooks/purity": "warn",
},
},
]);
+5 -4
View File
@@ -31,14 +31,15 @@
"dev": "node scripts/run-dev.mjs",
"build": "npm run generate:blog-manifest && next build",
"start": "next start",
"lint": "next lint",
"lint": "eslint .",
"db:generate": "drizzle-kit generate",
"db:migrate": "drizzle-kit migrate",
"db:push": "drizzle-kit push",
"db:studio": "drizzle-kit studio",
"admin:setup": "tsx scripts/setup-admin.ts",
"seed:tools": "tsx scripts/seed-tools.ts",
"generate:blog-manifest": "node scripts/generate-blog-manifest.mjs"
"generate:blog-manifest": "node scripts/generate-blog-manifest.mjs",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@aws-sdk/client-s3": "^3.958.0",
@@ -96,8 +97,8 @@
"@types/react": "^19.2.7",
"@types/react-dom": "^19.2.3",
"dotenv": "^17.2.2",
"eslint": "^8",
"eslint-config-next": "14.2.3",
"eslint": "^9.39.5",
"eslint-config-next": "16.1.1",
"pg": "^8.16.3",
"playwright": "^1.57.0",
"png-to-ico": "^3.0.1",
+1080 -656
View File
File diff suppressed because it is too large Load Diff
+2 -2
View File
@@ -53,8 +53,8 @@ export const ResetPasswordEmail = ({
</Text>
<Text style={footer}>
If you didn't request a password reset, you can safely ignore this email.
Your password won't be changed.
If you didn&apos;t request a password reset, you can safely ignore this email.
Your password won&apos;t be changed.
</Text>
</Container>
</Body>
+1 -1
View File
@@ -48,7 +48,7 @@ export const VerificationEmail = ({
</Link>
<Text style={footer}>
If you didn't sign up for Toolso.AI, you can safely ignore this email.
If you didn&apos;t sign up for Toolso.AI, you can safely ignore this email.
</Text>
</Container>
</Body>
@@ -0,0 +1,60 @@
"use server";
import { nanoid } from "nanoid";
import { db } from "@/lib/db";
import { contactMessage } from "@/lib/db/schema";
import { sendEmail } from "@/lib/email";
import { contactSchema, type ContactInput } from "@/features/marketing/schemas";
export async function submitContactMessage(input: ContactInput) {
const parsed = contactSchema.safeParse(input);
if (!parsed.success) {
return { success: false as const, error: "INVALID_INPUT" };
}
const { name, email, company, message } = parsed.data;
try {
await db.insert(contactMessage).values({
id: nanoid(),
name,
email,
company,
message,
});
} catch (error) {
console.error("Failed to save contact message:", error);
return { success: false as const, error: "SAVE_FAILED" };
}
// 邮件通知站点管理员;发信失败不影响留言已落库的结果
const receiver = process.env.CONTACT_RECEIVER_EMAIL;
if (receiver) {
await sendEmail({
to: receiver,
subject: `New contact message from ${name} - Toolso.AI`,
replyTo: email,
html: `
<div style="font-family: sans-serif; max-width: 600px; margin: 0 auto;">
<h2 style="color: #333;">New Contact Message</h2>
<p><strong>Name:</strong> ${escapeHtml(name)}</p>
<p><strong>Email:</strong> ${escapeHtml(email)}</p>
<p><strong>Company:</strong> ${escapeHtml(company)}</p>
<p><strong>Message:</strong></p>
<p style="white-space: pre-wrap; background: #f5f5f5; padding: 12px; border-radius: 6px;">${escapeHtml(message)}</p>
</div>
`,
});
}
return { success: true as const };
}
function escapeHtml(value: string): string {
return value
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#39;");
}
@@ -10,7 +10,10 @@ import {
FormTextareaField,
FormTextField,
} from "@/features/forms/components/form-text-field";
import { toast } from "sonner";
import { ContactInput, contactSchema } from "@/features/marketing/schemas";
import { submitContactMessage } from "@/features/marketing/actions/contact-actions";
import {
IconBrandGithub,
IconBrandLinkedin,
@@ -54,8 +57,13 @@ export function ContactForm() {
});
async function onSubmit(values: ContactInput) {
// TODO: wire up to backend when available
console.log("submitted form", values);
const result = await submitContactMessage(values);
if (result.success) {
toast.success(t("form.submitSuccess"));
form.reset();
} else {
toast.error(t("form.submitError"));
}
}
return (
+2 -3
View File
@@ -29,8 +29,6 @@ const getDefaultFromEmail = () => {
return `${fromName} <noreply@${process.env.RESEND_VERIFIED_DOMAIN}>`;
};
const DEFAULT_FROM_EMAIL = getDefaultFromEmail();
export interface SendEmailOptions {
to: string | string[];
subject: string;
@@ -47,7 +45,8 @@ export async function sendEmail({
react,
html,
text,
from = DEFAULT_FROM_EMAIL,
// 每次发送时求值,避免 serverless 冷启动时 env 未就绪被永久缓存
from = getDefaultFromEmail(),
replyTo,
}: SendEmailOptions) {
try {