Kritim Yantra
Sep 15, 2025
What we’re building (features)
htmlspecialchars()
.env
+ vlucas/phpdotenv
secure-contact/
├─ public/
│ ├─ index.php # The form
│ ├─ process.php # Handles POST + sends email
│ ├─ assets/
│ │ └─ css/bootstrap.min.css
├─ vendor/ # Composer deps (auto-created)
├─ .env # Secrets (NEVER commit)
├─ .env.example
├─ composer.json
├─ bootstrap.php # Loads autoloader, Dotenv, sessions, security helpers
└─ .htaccess # Deny access to .env (if using Apache)
Put
.env
andbootstrap.php
outsidepublic/
so the web server can’t serve them directly.
You’ll need PHP 8+ and Composer.
cd secure-contact
composer init -n
composer require phpmailer/phpmailer vlucas/phpdotenv
Create .env
in the project root (same folder as composer.json
):
# Mail settings
MAIL_HOST=smtp.gmail.com
MAIL_PORT=587
MAIL_USERNAME=your_gmail_address@gmail.com
MAIL_PASSWORD=your_app_password_here
MAIL_FROM=your_gmail_address@gmail.com
MAIL_FROM_NAME="Website Contact"
MAIL_TO=destination@example.com
# App
APP_ENV=production
APP_DEBUG=false
Gmail SMTP tips
- Turn on 2-Step Verification for your Google account.
- Create an App Password (16-char) and paste it as
MAIL_PASSWORD
.- Gmail disabled “Less secure apps”; App Passwords are the correct way now.
Add a shareable sample file for teammates:
.env.example
MAIL_HOST=smtp.gmail.com
MAIL_PORT=587
MAIL_USERNAME=example@gmail.com
MAIL_PASSWORD=app_password_here
MAIL_FROM=example@gmail.com
MAIL_FROM_NAME="Website Contact"
MAIL_TO=destination@example.com
APP_ENV=local
APP_DEBUG=true
bootstrap.php
<?php
declare(strict_types=1);
require __DIR__ . '/vendor/autoload.php';
use Dotenv\Dotenv;
$dotenv = Dotenv::createImmutable(__DIR__);
$dotenv->safeLoad(); // don't error if .env missing
// Basic PHP production-safe defaults (override via php.ini in real deployments)
ini_set('display_errors', $_ENV['APP_DEBUG'] === 'true' ? '1' : '0');
ini_set('log_errors', '1');
ini_set('error_log', __DIR__ . '/php_errors.log');
// Start secure session early
if (session_status() !== PHP_SESSION_ACTIVE) {
session_start([
'cookie_httponly' => true,
'cookie_secure' => isset($_SERVER['HTTPS']),
'cookie_samesite' => 'Lax',
]);
}
// CSRF token helper
function csrf_token(): string {
if (empty($_SESSION['csrf_token'])) {
$_SESSION['csrf_token'] = bin2hex(random_bytes(32));
}
return $_SESSION['csrf_token'];
}
function verify_csrf(?string $token): bool {
return isset($_SESSION['csrf_token']) && hash_equals($_SESSION['csrf_token'], (string)$token);
}
// Output encoding helper (prevents XSS when echoing user input)
function e(string $value): string {
return htmlspecialchars($value, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
}
// Basic rate limiting per session (N posts every M minutes)
function rate_limited(int $max = 3, int $minutes = 5): bool {
$now = time();
$_SESSION['submits'] = array_filter($_SESSION['submits'] ?? [], fn($t) => $t > $now - $minutes * 60);
if (count($_SESSION['submits']) >= $max) {
return true;
}
$_SESSION['submits'][] = $now;
return false;
}
// Very small email header injection guard
function sanitize_email_header(string $email): string {
// strip CRLF to avoid injecting headers
return preg_replace("/[\r\n]+/", '', $email);
}
public/.htaccess
(optional but recommended if using Apache):
# Deny access to sensitive files
<FilesMatch "^(\.env|composer\.json|composer\.lock|php_errors\.log)$">
Require all denied
</FilesMatch>
public/index.php
<?php require __DIR__ . '/../bootstrap.php'; ?>
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Contact Us</title>
<meta name="viewport" content="width=device-width,initial-scale=1">
<link href="assets/css/bootstrap.min.css" rel="stylesheet">
<style>
/* Honeypot: hidden from humans, visible to bots */
.hp-field { position:absolute; left:-9999px; width:1px; height:1px; overflow:hidden; }
</style>
</head>
<body class="bg-light">
<div class="container py-5">
<div class="row justify-content-center">
<div class="col-lg-7">
<div class="card shadow-sm">
<div class="card-body p-4">
<h1 class="h3 mb-3">Contact Us</h1>
<?php if (!empty($_SESSION['flash'])): ?>
<div class="alert alert-<?= e($_SESSION['flash']['type']) ?>"><?= e($_SESSION['flash']['message']) ?></div>
<?php unset($_SESSION['flash']); ?>
<?php endif; ?>
<form action="process.php" method="post" novalidate>
<input type="hidden" name="csrf_token" value="<?= e(csrf_token()) ?>">
<!-- Honeypot (should stay empty) -->
<div class="hp-field">
<label>Leave this field empty</label>
<input type="text" name="website" autocomplete="off">
</div>
<div class="mb-3">
<label class="form-label">Your Name</label>
<input type="text" class="form-control" name="name" maxlength="100" required>
<div class="form-text">Max 100 characters.</div>
</div>
<div class="mb-3">
<label class="form-label">Email address</label>
<input type="email" class="form-control" name="email" maxlength="160" required>
</div>
<div class="mb-3">
<label class="form-label">Subject</label>
<input type="text" class="form-control" name="subject" maxlength="150" required>
</div>
<div class="mb-3">
<label class="form-label">Message</label>
<textarea class="form-control" name="message" rows="6" maxlength="5000" required></textarea>
<div class="form-text">Up to 5000 characters.</div>
</div>
<button class="btn btn-primary w-100" type="submit">Send Message</button>
</form>
<p class="small text-muted mt-3">
We’ll never show detailed errors here. If something goes wrong, we log it and let you know gracefully. 🙂
</p>
</div>
</div>
</div>
</div>
</div>
</body>
</html>
Why this is safe:
e(...)
.maxlength
prevents oversized payloads; server will re-validate too.public/process.php
<?php
declare(strict_types=1);
require __DIR__ . '/../bootstrap.php';
use PHPMailer\PHPMailer\PHPMailer;
use PHPMailer\PHPMailer\Exception;
function fail(string $message, string $type = 'danger'): void {
$_SESSION['flash'] = ['type' => $type, 'message' => $message];
header('Location: index.php');
exit;
}
// 1) Basic request checks
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
fail('Invalid request method.');
}
if (!verify_csrf($_POST['csrf_token'] ?? '')) {
fail('Security token mismatch. Please try again.');
}
// 2) Honeypot check
if (!empty($_POST['website'])) {
// bot likely
fail('Submission blocked.');
}
// 3) Rate limit
if (rate_limited(max: 3, minutes: 5)) {
fail('Too many messages. Please wait a few minutes and try again.');
}
// 4) Collect and validate input
$name = trim((string)($_POST['name'] ?? ''));
$email = trim((string)($_POST['email'] ?? ''));
$subject = trim((string)($_POST['subject'] ?? ''));
$message = trim((string)($_POST['message'] ?? ''));
$errors = [];
// Required + length checks
if ($name === '' || mb_strlen($name) > 100) $errors[] = 'Please provide a valid name (max 100 chars).';
if ($subject === '' || mb_strlen($subject) > 150) $errors[] = 'Please provide a valid subject (max 150 chars).';
if ($message === '' || mb_strlen($message) > 5000) $errors[] = 'Message is required (max 5000 chars).';
if ($email === '' || mb_strlen($email) > 160 || !filter_var($email, FILTER_VALIDATE_EMAIL)) {
$errors[] = 'Please provide a valid email address.';
}
if ($errors) {
fail(implode(' ', $errors));
}
// Guard against email header injection
$email = sanitize_email_header($email);
// 5) Build a safe, simple plaintext + HTML body
$plainText = "New contact form submission:\n"
. "Name: {$name}\n"
. "Email: {$email}\n"
. "Subject: {$subject}\n\n"
. "Message:\n{$message}\n";
$htmlBody = '<h2>New contact form submission</h2>'
. '<p><strong>Name:</strong> ' . e($name) . '</p>'
. '<p><strong>Email:</strong> ' . e($email) . '</p>'
. '<p><strong>Subject:</strong> ' . e($subject) . '</p>'
. '<p><strong>Message:</strong><br>' . nl2br(e($message)) . '</p>';
try {
// 6) Configure PHPMailer
$mail = new PHPMailer(true);
$mail->isSMTP();
$mail->Host = $_ENV['MAIL_HOST'] ?? 'smtp.gmail.com';
$mail->SMTPAuth = true;
$mail->Username = $_ENV['MAIL_USERNAME'] ?? '';
$mail->Password = $_ENV['MAIL_PASSWORD'] ?? '';
$mail->SMTPSecure = PHPMailer::ENCRYPTION_STARTTLS; // TLS on 587
$mail->Port = (int)($_ENV['MAIL_PORT'] ?? 587);
// Avoid verbose SMTP debug in production
$mail->SMTPDebug = 0;
// Gmail sometimes requires strong ciphers; leave defaults unless needed:
// $mail->SMTPOptions = ['ssl' => ['verify_peer' => true, 'verify_peer_name' => true, 'allow_self_signed' => false]];
// 7) From / To
$fromEmail = $_ENV['MAIL_FROM'] ?? $_ENV['MAIL_USERNAME'] ?? '';
$fromName = $_ENV['MAIL_FROM_NAME'] ?? 'Website Contact';
$mail->setFrom($fromEmail, $fromName);
$mail->addAddress((string)($_ENV['MAIL_TO'] ?? $fromEmail)); // fallback to self
$mail->addReplyTo($email, $name); // replying goes to the sender
// 8) Content
$mail->Subject = "[Contact] " . $subject;
$mail->isHTML(true);
$mail->Body = $htmlBody;
$mail->AltBody = $plainText;
// 9) Send
$mail->send();
// Success
fail('Thank you! Your message has been sent. ✅', 'success');
} catch (Exception $e) {
// Log internal error for admins only
error_log('Mail error: ' . $e->getMessage());
fail('Sorry, we could not send your message right now. Please try again later.');
}
Security notes (what we did & why):
htmlspecialchars
everywhere before rendering back => prevents XSS.error_log
.You can add HTML5 validation or small JS hints, but never rely on it alone. The server rules above remain the source of truth.
csrf_token
→ blocked.<script>alert(1)</script>
in message → email should show the tags as text, not execute.public/
as your web root. Keep vendor/
, .env
, and bootstrap.php
outside public.Secure
automatically if HTTPS is detected.Q1: I get “Could not authenticate” when sending mail.
Make sure you use App Password (not your normal Gmail password), and MAIL_USERNAME
equals your Gmail address.
Q2: Can I add reCAPTCHA?
Yes—add Google reCAPTCHA v2/3 on the form and verify in process.php
before sending. Keep CSRF + honeypot too.
Q3: Can I send to multiple recipients?
Yes: $mail->addAddress('a@b.com'); $mail->addAddress('c@d.com');
.env
: set MAIL_USERNAME
, MAIL_PASSWORD
(App Password), MAIL_TO
.index.php
: customize labels/branding.process.php
: adjust rate limit numbers if needed.No comments yet. Be the first to comment!
Please log in to post a comment:
Sign in with Google