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:
co-authored by
Claude Fable 5
parent
f3088e996f
commit
89a9e318c2
+12
-1
@@ -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"
|
||||
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
{
|
||||
"extends": "next/core-web-vitals"
|
||||
}
|
||||
@@ -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
@@ -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",
|
||||
|
||||
Generated
+1080
-656
File diff suppressed because it is too large
Load Diff
@@ -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't request a password reset, you can safely ignore this email.
|
||||
Your password won't be changed.
|
||||
</Text>
|
||||
</Container>
|
||||
</Body>
|
||||
|
||||
@@ -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'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, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """)
|
||||
.replace(/'/g, "'");
|
||||
}
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user