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:
|
# 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>"
|
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
|
# Optional: Resend Audience ID for newsletter/marketing
|
||||||
RESEND_AUDIENCE_ID="your-resend-audience-id"
|
RESEND_AUDIENCE_ID="your-resend-audience-id"
|
||||||
|
|
||||||
|
# Optional: where contact form notifications are sent (admin inbox)
|
||||||
|
CONTACT_RECEIVER_EMAIL=""
|
||||||
|
|
||||||
# ====================================
|
# ====================================
|
||||||
# Analytics & Monitoring (Optional)
|
# Analytics & Monitoring (Optional)
|
||||||
# ====================================
|
# ====================================
|
||||||
@@ -75,6 +83,9 @@ STORAGE_PUBLIC_URL=""
|
|||||||
# Public app URL (update in production)
|
# Public app URL (update in production)
|
||||||
NEXT_PUBLIC_APP_URL="http://localhost:3000"
|
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 environment
|
||||||
NODE_ENV="development"
|
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",
|
"dev": "node scripts/run-dev.mjs",
|
||||||
"build": "npm run generate:blog-manifest && next build",
|
"build": "npm run generate:blog-manifest && next build",
|
||||||
"start": "next start",
|
"start": "next start",
|
||||||
"lint": "next lint",
|
"lint": "eslint .",
|
||||||
"db:generate": "drizzle-kit generate",
|
"db:generate": "drizzle-kit generate",
|
||||||
"db:migrate": "drizzle-kit migrate",
|
"db:migrate": "drizzle-kit migrate",
|
||||||
"db:push": "drizzle-kit push",
|
"db:push": "drizzle-kit push",
|
||||||
"db:studio": "drizzle-kit studio",
|
"db:studio": "drizzle-kit studio",
|
||||||
"admin:setup": "tsx scripts/setup-admin.ts",
|
"admin:setup": "tsx scripts/setup-admin.ts",
|
||||||
"seed:tools": "tsx scripts/seed-tools.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": {
|
"dependencies": {
|
||||||
"@aws-sdk/client-s3": "^3.958.0",
|
"@aws-sdk/client-s3": "^3.958.0",
|
||||||
@@ -96,8 +97,8 @@
|
|||||||
"@types/react": "^19.2.7",
|
"@types/react": "^19.2.7",
|
||||||
"@types/react-dom": "^19.2.3",
|
"@types/react-dom": "^19.2.3",
|
||||||
"dotenv": "^17.2.2",
|
"dotenv": "^17.2.2",
|
||||||
"eslint": "^8",
|
"eslint": "^9.39.5",
|
||||||
"eslint-config-next": "14.2.3",
|
"eslint-config-next": "16.1.1",
|
||||||
"pg": "^8.16.3",
|
"pg": "^8.16.3",
|
||||||
"playwright": "^1.57.0",
|
"playwright": "^1.57.0",
|
||||||
"png-to-ico": "^3.0.1",
|
"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>
|
||||||
|
|
||||||
<Text style={footer}>
|
<Text style={footer}>
|
||||||
If you didn't request a password reset, you can safely ignore this email.
|
If you didn't request a password reset, you can safely ignore this email.
|
||||||
Your password won't be changed.
|
Your password won't be changed.
|
||||||
</Text>
|
</Text>
|
||||||
</Container>
|
</Container>
|
||||||
</Body>
|
</Body>
|
||||||
|
|||||||
@@ -48,7 +48,7 @@ export const VerificationEmail = ({
|
|||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
<Text style={footer}>
|
<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>
|
</Text>
|
||||||
</Container>
|
</Container>
|
||||||
</Body>
|
</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,
|
FormTextareaField,
|
||||||
FormTextField,
|
FormTextField,
|
||||||
} from "@/features/forms/components/form-text-field";
|
} from "@/features/forms/components/form-text-field";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
|
||||||
import { ContactInput, contactSchema } from "@/features/marketing/schemas";
|
import { ContactInput, contactSchema } from "@/features/marketing/schemas";
|
||||||
|
import { submitContactMessage } from "@/features/marketing/actions/contact-actions";
|
||||||
import {
|
import {
|
||||||
IconBrandGithub,
|
IconBrandGithub,
|
||||||
IconBrandLinkedin,
|
IconBrandLinkedin,
|
||||||
@@ -54,8 +57,13 @@ export function ContactForm() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
async function onSubmit(values: ContactInput) {
|
async function onSubmit(values: ContactInput) {
|
||||||
// TODO: wire up to backend when available
|
const result = await submitContactMessage(values);
|
||||||
console.log("submitted form", values);
|
if (result.success) {
|
||||||
|
toast.success(t("form.submitSuccess"));
|
||||||
|
form.reset();
|
||||||
|
} else {
|
||||||
|
toast.error(t("form.submitError"));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
+2
-3
@@ -29,8 +29,6 @@ const getDefaultFromEmail = () => {
|
|||||||
return `${fromName} <noreply@${process.env.RESEND_VERIFIED_DOMAIN}>`;
|
return `${fromName} <noreply@${process.env.RESEND_VERIFIED_DOMAIN}>`;
|
||||||
};
|
};
|
||||||
|
|
||||||
const DEFAULT_FROM_EMAIL = getDefaultFromEmail();
|
|
||||||
|
|
||||||
export interface SendEmailOptions {
|
export interface SendEmailOptions {
|
||||||
to: string | string[];
|
to: string | string[];
|
||||||
subject: string;
|
subject: string;
|
||||||
@@ -47,7 +45,8 @@ export async function sendEmail({
|
|||||||
react,
|
react,
|
||||||
html,
|
html,
|
||||||
text,
|
text,
|
||||||
from = DEFAULT_FROM_EMAIL,
|
// 每次发送时求值,避免 serverless 冷启动时 env 未就绪被永久缓存
|
||||||
|
from = getDefaultFromEmail(),
|
||||||
replyTo,
|
replyTo,
|
||||||
}: SendEmailOptions) {
|
}: SendEmailOptions) {
|
||||||
try {
|
try {
|
||||||
|
|||||||
Reference in New Issue
Block a user