چند سال پیش، یک سایت شرکتی که تازه تحویل داده بودم، بعد از دو ماه با پیام نگران‌کننده‌ی کارفرما برگشت: «هیچ‌کس از فرم تماس پیام نمی‌فرستد.» اول فکر کردم شاید رقیبش بهتر شده؛ ولی وقتی وارد پنل هاست شدم و لاگ SMTP را باز کردم، دیدم فرم سالم ارسال می‌شود — فقط ایمیل‌ها به اسپم می‌رفتند چون هدر From از دامنه‌ی خودِ سایت نبود و رکورد SPF ست نشده بود. آن روز برایم روشن شد که ساخت فرم تماس با PHP، یک تمرین آموزشگاهی نیست؛ یک معماری کوچک است که هم‌زمان باید با HTML، PHP، سرور ایمیل، امنیت و تجربه‌ی کاربری سر و کله بزند. در این مقاله همان مسیری را می‌روم که در پروژه‌های واقعی طی می‌کنم: از صفر تا فرمی که هم امن باشد، هم به دست مخاطب برسد.

چرا فرم تماس اختصاصی بسازیم؟

قبل از هر خط کد، این سوال را باید جواب داد. اگر روی وردپرس کار می‌کنید، گزینه‌های آماده‌ی قدرتمندی مثل افزونه‌های فرم‌ساز وردپرس وجود دارد و در اکثر پروژه‌ها، من هم همان‌ها را پیشنهاد می‌دهم. ولی در سه سناریو، فرم اختصاصی با PHP منطقی‌تر است:

  • پروژه‌ای که وردپرس نیست: یک سایت خالص PHP، یک اپلیکیشن کوچک داخلی، یا حتی یک لندینگ مستقل. اینجا نیازی به بار کردن کل اکوسیستم وردپرس ندارید.
  • نیاز به منطق اختصاصی: فرمی که باید با یک CRM بومی صحبت کند، در یک جدول اختصاصی ذخیره شود، یا خروجی‌اش با منطق کسب‌وکار خاصی پردازش شود.
  • یادگیری عمیق: اگر می‌خواهید بفهمید زیر پوسته‌ی افزونه‌های آماده چه می‌گذرد، این تمرین بی‌نظیر است. اکثر باگ‌های فرم‌سازها را وقتی می‌فهمید که یک بار خودتان از صفر ساخته باشید.
فرم تماس، ویترین اعتماد سایت شماست؛ اگر در فرمی که خودتان ساخته‌اید، خطای اعتبارسنجی مبهم ببینید، مطمئن باشید کاربر هم می‌بیند.

ساختار HTML فرم: پایه‌ای که نباید بلنگد

شروع کار، نوشتن HTML است؛ ولی نه هر HTMLی. یک فرم تماس حرفه‌ای، از نظر دسترس‌پذیری و ساختار باید بی‌نقص باشد. سه اصل که همیشه رعایت می‌کنم:

  1. هر فیلد یک <label> متصل دارد (نه فقط placeholder). کاربر صفحه‌خوان باید بداند فیلد چیست.
  2. ویژگی‌های type، autocomplete و inputmode درست تنظیم شده‌اند تا کیبورد موبایل مناسب باز شود.
  3. فیلدها با required و maxlength در سمت کلاینت محدود شده‌اند، ولی هرگز به‌عنوان تنها لایه‌ی اعتبارسنجی به آن‌ها تکیه نمی‌کنم.
<form action="contact.php" method="post" novalidate>
  <label for="name">نام و نام خانوادگی</label>
  <input type="text" id="name" name="name" required maxlength="80" autocomplete="name">

  <label for="email">ایمیل</label>
  <input type="email" id="email" name="email" required autocomplete="email">

  <label for="message">پیام</label>
  <textarea id="message" name="message" required maxlength="2000"></textarea>

  <!-- فیلد تله‌ی اسپم -->
  <input type="text" name="website" style="display:none" tabindex="-1" autocomplete="off">

  <input type="hidden" name="csrf_token" value="<?php echo $token; ?>">
  <button type="submit">ارسال</button>
</form>

دو نکته‌ی ظریف در همین اسکلت ساده: novalidate را عمداً گذاشتم تا کنترل اعتبارسنجی را کامل به PHP بسپارم و کاربر با دو پیام متناقض (یکی از مرورگر، یکی از سرور) مواجه نشود. دوم، فیلد تله‌ی اسپم — یک فیلد مخفی که ربات‌ها پر می‌کنند ولی انسان‌ها نمی‌بینند — که در بخش ضداسپم مفصل بازش می‌کنم. برای دسترس‌پذیری کامل فرم، اصول کلی را در استانداردهای دسترس‌پذیری وب می‌توانید مرور کنید.

هندلر PHP: قلب تپنده‌ی فرم

سمت سرور، چهار مرحله‌ی جدایی‌ناپذیر داریم: بررسی متد درخواست، اعتبارسنجی، پاک‌سازی، و ارسال. هر مرحله را در یک تابع جدا می‌نویسم تا کد قابل تست و بازبینی باشد. اگر با مفهوم توابع در PHP آشنایی کامل ندارید، آموزش PHP از صفر نقطه‌ی شروع خوبی است.

<?php
declare(strict_types=1);
session_start();

if ($_SERVER["REQUEST_METHOD"] !== "POST") {
    http_response_code(405);
    exit("Method Not Allowed");
}

$errors = [];
$input  = [
    "name"    => trim((string) ($_POST["name"]    ?? "")),
    "email"   => trim((string) ($_POST["email"]   ?? "")),
    "message" => trim((string) ($_POST["message"] ?? "")),
];

چند انتخاب آگاهانه در همین چند خط: declare(strict_types=1) جلوی تبدیل‌های خودکار نامطلوب را می‌گیرد؛ (string) قبل از trim مطمئن می‌شود اگر کاربر آرایه فرستاد (سناریوی حمله)، کد ما به یک رشته تبدیلش کند نه اینکه روی آرایه عملیات رشته‌ای بزند. این نوع دفاع، همان چیزی است که در امنیت در PHP به‌عنوان پایه‌ی defensive programming توضیح داده‌ام.

اعتبارسنجی و پاک‌سازی ورودی

اعتبارسنجی و پاک‌سازی دو کار جدا هستند و اشتباه گرفتنشان، یکی از شایع‌ترین باگ‌های امنیتی است. اعتبارسنجی می‌گوید «آیا این داده پذیرفتنی است؟»، پاک‌سازی می‌گوید «این داده را برای مصرف بعدی بی‌خطر کن». هر دو لازماند.

if ($input["name"] === "" || mb_strlen($input["name"]) > 80) {
    $errors[] = "نام را درست وارد کنید.";
}

if (!filter_var($input["email"], FILTER_VALIDATE_EMAIL)) {
    $errors[] = "ایمیل معتبر نیست.";
}

if (mb_strlen($input["message"]) < 10) {
    $errors[] = "متن پیام کوتاه است.";
}

// مرحله پاک‌سازی برای خروجی
$clean = [
    "name"    => htmlspecialchars($input["name"], ENT_QUOTES, "UTF-8"),
    "email"   => filter_var($input["email"], FILTER_SANITIZE_EMAIL),
    "message" => htmlspecialchars($input["message"], ENT_QUOTES, "UTF-8"),
];

نکته‌ی مهم: از mb_strlen استفاده می‌کنم نه strlen. متن فارسی چند بایتی است و strlen طول کاراکتری را اشتباه حساب می‌کند. این یک خط اشتباه، می‌تواند طول مجاز پیام فارسی را یک‌سوم کند و کاربر را با پیام‌های «طولانی است» مواجه کند که واقعاً نیستند. اگر می‌خواهید مفهوم اعتبارسنجی و پاک‌سازی را در بستر وردپرس هم ببینید، اعتبارسنجی داده‌ها در کدنویسی وردپرس و پاک‌سازی داده‌ها را مرور کنید؛ توابع وردپرسی در این بستر امن‌تر و خواناترند.

مرحلههدفابزار نمونه
اعتبارسنجیپذیرش یا رد دادهfilter_var، mb_strlen
پاک‌سازیبی‌خطر کردن برای مصرفhtmlspecialchars، strip_tags
کدگذاری خروجیجلوگیری از XSS در نمایشhtmlspecialchars با ENT_QUOTES

محافظت در برابر CSRF

CSRF (Cross-Site Request Forgery) یعنی سایت دیگری، مرورگر کاربر شما را فریب بدهد تا به‌جای کاربر، فرم شما را ارسال کند. راه‌حل استاندارد، یک توکن یکتا در سشن است که در فرم قرار می‌گیرد و هنگام پردازش، تطبیق داده می‌شود.

// هنگام نمایش فرم
if (empty($_SESSION["csrf_token"])) {
    $_SESSION["csrf_token"] = bin2hex(random_bytes(32));
}
$token = $_SESSION["csrf_token"];

// هنگام پردازش فرم
$posted = $_POST["csrf_token"] ?? "";
if (!hash_equals($_SESSION["csrf_token"] ?? "", $posted)) {
    http_response_code(403);
    exit("درخواست نامعتبر");
}

دو انتخاب مهم در همین قطعه: random_bytes به‌جای rand یا uniqid (چون منبع امن تصادفی است) و hash_equals به‌جای === (چون مقایسه‌ی مقاوم در برابر timing attack). این دو جزئیات، تفاوت بین یک محافظت نمایشی و یک محافظت واقعی است. بعد از موفقیت ارسال، توکن را در سشن بازتولید می‌کنم تا یک توکن قدیمی دوباره قابل استفاده نباشد — همان اصلی که در امنیت در PHP روی session regeneration تأکید کرده‌ام.

یک توکن CSRF که بعد از هر ارسال تغییر نکند، فقط یک لایه‌ی تزئینی است؛ بازتولید توکن، همان چیزی است که آن را واقعی می‌کند.

ارسال ایمیل: mail() یا SMTP؟

تابع mail() ساده‌ترین راه است، ولی در عمل، در ۸۰٪ سرورهای اشتراکی، ایمیل‌هایش یا به اسپم می‌روند یا اصلاً ارسال نمی‌شوند. دلیلش این است که mail() از sendmail محلی استفاده می‌کند که نه SPF دارد، نه DKIM، و نه شهرت ارسال. تجربه‌ی من در پروژه‌ها: هرگز روی mail() برای فرم تماس جدی حساب نکنید.

راه درست، SMTP احراز هویت‌شده با یک سرویس معتبر است (سرویس ایمیل تراکنشی، Gmail با App Password، یا SMTP سرور اصلی هاست با تنظیمات SPF و DKIM). با Composer و کتابخانه‌ی PHPMailer، این کار ده دقیقه بیشتر طول نمی‌کشد؛ اگر با Composer تازه آشنا می‌شوید، آموزش Composer در PHP مسیر را نشان می‌دهد.

use PHPMailer\PHPMailer\PHPMailer;
use PHPMailer\PHPMailer\Exception;

$mail = new PHPMailer(true);

try {
    $mail->isSMTP();
    $mail->Host       = "smtp.example.com";
    $mail->SMTPAuth   = true;
    $mail->Username   = "user@example.com";
    $mail->Password   = getenv("SMTP_PASSWORD");
    $mail->SMTPSecure = PHPMailer::ENCRYPTION_STARTTLS;
    $mail->Port       = 587;

    $mail->setFrom("no-reply@example.com", "سایت من");
    $mail->addAddress("info@example.com");
    $mail->addReplyTo($clean["email"], $clean["name"]);

    $mail->Subject = "پیام جدید از فرم تماس";
    $mail->Body    = "نام: {$clean["name"]}\n\n{$clean["message"]}";

    $mail->send();
} catch (Exception $e) {
    error_log("Mail error: " . $mail->ErrorInfo);
    $errors[] = "ارسال ایمیل با خطا مواجه شد.";
}

سه نکته‌ی عملی از پروژه‌های واقعی: (۱) Reply-To را همیشه روی ایمیل کاربر بگذارید، وگرنه پاسخ دادن سخت می‌شود؛ (۲) رمز عبور را هرگز مستقیم در کد ننویسید — از متغیر محیطی یا فایل خارج از ریشه‌ی وب استفاده کنید؛ (۳) خطاها را در error_log ثبت کنید، نه در خروجی صفحه — پیام‌های تشخیصی به کاربر، پاشنه‌ی آشیل فرم‌های تازه‌کار است.

مقابله با اسپم بدون کپچای آزاردهنده

کپچای تصویری، نرخ تبدیل فرم را پایین می‌آورد و تجربه‌ی کاربری موبایل را خراب می‌کند. در پروژه‌های اخیرم، چند لایه‌ی سبک‌تر را جایگزین کرده‌ام و نتیجه بهتر بوده:

  • فیلد تله (Honeypot): فیلد مخفی که با CSS پنهان شده. ربات‌ها آن را پر می‌کنند، انسان‌ها نمی‌بینند.
  • زمان پر کردن فرم: زمان شروع نمایش فرم را در سشن ذخیره کنید و اگر کمتر از دو ثانیه بعد ارسال شد، رد کنید. انسان‌ها این‌قدر سریع تایپ نمی‌کنند.
  • محدودیت نرخ ارسال (Rate Limiting): یک شمارنده‌ی ساده در سشن یا دیتابیس که اجازه ندهد از یک IP بیش از چند پیام در دقیقه ارسال شود.
  • بررسی سرور مبدأ با سرویس‌هایی مثل Akismet: گزینه‌ی مقیاس‌پذیر برای سایت‌های پربازدید. جایگزین‌های کپچا را در جایگزین‌های کپچا برای تجربه کاربری بهتر مقایسه کرده‌ام.
// Honeypot
if (!empty($_POST["website"])) {
    exit; // ربات
}

// حداقل زمان پر کردن
$elapsed = time() - ($_SESSION["form_started_at"] ?? time());
if ($elapsed < 2) {
    exit; // ارسال غیرانسانی
}

مدیریت خطا و بازخورد به کاربر

کاربر نباید هرگز با صفحه‌ی سفید یا پیام مبهم روبرو شود. سه لایه‌ی بازخورد طراحی می‌کنم:

  1. خطاهای فیلد به فیلد: زیر هر فیلد، پیام دقیق و انسانی. «ایمیل معتبر نیست» به‌جای «Invalid input».
  2. خطاهای کلی: اگر ارسال ایمیل شکست خورد، پیام کلی با گزینه‌ی تماس تلفنی یا واتس‌اپ جایگزین نمایش داده شود.
  3. پیام موفقیت با انتظار مشخص: «پیام شما دریافت شد. تا ۲۴ ساعت پاسخ می‌دهیم» بسیار بهتر از «با موفقیت ارسال شد» است.

پیام‌های دقیق را همان‌طور که در مدیریت خطا در PHP توضیح داده‌ام، در متغیرهای مجزا نگه دارید تا بعداً برای ترجمه یا تغییر لحن، تک‌تک نگردید. برای پروژه‌های وردپرسی، تابع wp_send_json_error و wp_send_json_success هم همین الگو را رعایت می‌کنند و بازخورد را یکدست نگه می‌دارند.

معماری فرم تماس در پروژه‌های جدی

وقتی از فرم‌های کوچک عبور می‌کنیم و به فرم‌هایی می‌رسیم که روزانه صدها ارسال دارند، تصمیمات معماری وارد می‌شوند. سه محور که در پروژه‌های واقعی به آن‌ها رسیده‌ام:

۱) جداسازی لایه‌ها

در فرم‌های ساده، همه‌چیز در یک فایل PHP جمع می‌شود. ولی به‌محض افزایش پیچیدگی، بهتر است لایه‌ی نمایش (HTML)، لایه‌ی پردازش (validation و ارسال)، و لایه‌ی ذخیره‌سازی (ذخیره در دیتابیس برای بازیابی) از هم جدا شوند. این جداسازی، تکرار کد در فرم‌های بعدی را حذف می‌کند و تست‌پذیری را بالا می‌برد — همان اصل معماری که در ساختار فایل‌های یک افزونه استاندارد وردپرس هم روی آن تأکید کرده‌ام.

۲) ذخیره در دیتابیس، موازی با ارسال ایمیل

ایمیل ممکن است گم شود، به اسپم برود، یا سرور لحظه‌ای از کار بیفتد. برای فرم‌های حساس (درخواست قیمت، سفارش سازمانی)، ذخیره‌ی هر ارسال در یک جدول اختصاصی، شبکه‌ی امنیت پایه است. با PDO و prepared statements این کار ساده و امن می‌شود؛ اگر با PDO آشنا نیستید، آموزش PDO در PHP نمونه‌های عملی دارد.

$stmt = $pdo->prepare(
    "INSERT INTO contact_messages (name, email, message, ip, created_at)
     VALUES (:name, :email, :message, :ip, NOW())"
);
$stmt->execute([
    ":name"    => $input["name"],
    ":email"   => $input["email"],
    ":message" => $input["message"],
    ":ip"      => $_SERVER["REMOTE_ADDR"] ?? "unknown",
]);

۳) محدودیت نرخ در سطح زیرساخت

محدودیت نرخ PHP، فقط جلوی ربات‌های ساده را می‌گیرد. در پروژه‌های پربازدید، این کار را در لبه‌ی شبکه (پروکسی معکوس، فایروال ابری، Nginx) انجام می‌دهم تا درخواست‌های زائد حتی به PHP نرسند. تفاوت این دو لایه را در فایروال ابری در برابر فایروال سنتی باز کرده‌ام و برای سایت‌های وردپرسی، همین منطق در بخش ارسال فرم هم پیاده می‌شود.

یک توصیه‌ی جانبی از تجربه: اگر فرم‌تان روی وردپرس است و می‌خواهید همان اسکلت اختصاصی را داشته باشید، به‌جای فایل مستقل PHP، یک شورت‌کد یا افزونه‌ی سبک بنویسید تا با اکوسیستم هماهنگ باشد. روش ساخت شورت‌کد را در ساخت شورت‌کد با کدنویسی وردپرس و ساختار درست افزونه را در افزونه وردپرس چیست توضیح داده‌ام.

سخن آخر

ساخت فرم تماس با PHP، از آن تمرین‌هایی است که فکر می‌کنید یک‌ساعته تمام می‌شود، ولی وقتی عمیق می‌شوید، ده‌ها تصمیم ریز امنیتی، تجربه‌ی کاربری و زیرساختی در آن پنهان است. اگر همین امروز بخواهید فرمی بسازید که به‌جای اسپم، به دست مشتری واقعی برسد، سه اولویت را رعایت کنید: اول، از SMTP احراز هویت‌شده به‌جای mail() استفاده کنید و SPF/DKIM دامنه را ست کنید؛ دوم، CSRF را با توکن بازتولید‌شونده و مقایسه‌ی hash_equals جدی بگیرید؛ سوم، بازخورد به کاربر را با دقت طراحی کنید — موفقیت و شکست باید همان‌قدر شفاف باشند که یک پیام موفقیت آمیز در CRM شما.

اگر روی پروژه‌ی فعلی‌تان فرم تماس دارید و مطمئن نیستید ایمیل‌ها واقعاً به دست مخاطب می‌رسند، یک آزمایش کوچک کافی است: از یک ایمیل شخصی، پیام تستی بفرستید و ببینید در Gmail به کدام تب می‌رود. این یک آزمون یک‌دقیقه‌ای، بیشتر از هر تحلیل امنیتی، حقیقت سیستم ایمیل شما را نشان می‌دهد. تجربه‌ی خودتان از فرم‌هایی که به اسپم افتاده‌اند یا فرم‌هایی که CSRF‌شان دست شما را رو کرده، در دیدگاه‌ها بنویسید؛ مخصوصاً اگر راه‌حل متفاوتی برای ضداسپم بدون کپچا پیدا کرده‌اید که می‌تواند برای خواننده‌ی بعدی مفید باشد. 📬