Full Stack Guide — Security · SMTP · Deployment

PHP Contact Form
SMTP, Security & Deployment

A complete guide to building a secure PHP contact form — hashed credentials, honeypot spam trapping, reCAPTCHA, and SMTP relay via Gmail, Outlook, and SendGrid

PHP 8.x PHPMailer Gmail SMTP Outlook SMTP SendGrid reCAPTCHA v2 Honeypot

Created: March 2026  ·  Written by Nicole M. Taylor

What the finished forms look like
Checkbox Version — Multiple Selections
Contact Us
First Name *
Jane
Last Name *
Smith
Email *
jane@example.com
Services Needed *
Message *
Your message here...
I'm not a robot
reCAPTCHA
Send Message →
✓ Thank you! We will be in touch soon.
Dropdown Version — Single Selection
Contact Us
First Name *
Jane
Last Name *
Smith
Email *
jane@example.com
Interested In *
Option A
Message *
Your message here...
I'm not a robot
reCAPTCHA
Send Message →
✓ Thank you! We will be in touch soon.
⚠️
Disclaimer — Security & Software Changes Over Time This guide was written in March 2026. SMTP provider settings, app password procedures, and API formats change over time — always verify against your provider's current documentation. Never commit real credentials to version control. All passwords and keys shown are examples only. Written by Nicole M. Taylor   Contact
00

Starting Point — The mail() Version & Why We Upgrade It

If you already have a working contact form using PHP's built-in mail() function, this section is for you. Your form likely looks something like this:

Your Existing process_form.php — Using mail()PHP
<?php
if ($_SERVER["REQUEST_METHOD"] == "POST") {
    $first_name = $_POST['first-name'];
    $last_name  = $_POST['last-name'];
    $email      = $_POST['email'];
    $message    = $_POST['message'];
    $to      = "you@yourdomain.com";
    $subject = "New Contact Form Submission";
    $body    = "From: {$first_name} {$last_name}\nEmail: {$email}\n\n{$message}";
    $headers = "From: {$email}\nReply-To: {$email}";
    if (mail($to, $subject, $body, $headers)) {
        echo "<p>Thank you! We will get back to you soon.</p>";
    } else {
        echo "<p>Sorry, something went wrong.</p>";
    }
}
✅ When mail() Works Fine
  • cPanel shared hosting (most providers configure Sendmail for you)
  • Servers with Postfix or Sendmail properly set up
  • Low-volume forms (a few submissions per day)
  • Forms where landing in spam occasionally is acceptable
❌ When mail() Fails or Is Risky
  • VPS or dedicated servers without a configured MTA
  • Email consistently landing in spam / junk folders
  • Hosting providers that block outbound port 25
  • When you need delivery confirmation or error details
  • High volume — ISPs rate-limit unverified mail servers
If Your mail() Form Already Works — Keep Using It mail() is a perfectly valid solution when it works. PHPMailer + SMTP relay is an alternative for when mail() is not working or emails are going to junk. Either way your reCAPTCHA verification, field collection, honeypot check, and email body code are all identical — only the actual send block at the very bottom changes.

01

Why PHP's mail() Fails & What to Use Instead

PHP's built-in mail() function has been the default contact form solution for decades — and it fails silently on most modern hosting environments.

The Problem with mail()

The Solution — PHPMailer + SMTP Relay

PHPMailer connects directly to an authenticated SMTP relay — Gmail, Outlook, SendGrid, or any other provider — using your credentials over an encrypted TLS connection.

⚠️
Keep Your SMTP Credentials Out of Your PHP Files Never hard-code your Gmail app password or API key directly in process_form.php. Section 3 covers storing credentials safely in a config file outside the web root.

02

Installing PHPMailer

Option A — Install via Composer (Recommended)

Install Composer on Ubuntu/DebianBASH
curl -sS https://getcomposer.org/installer | php
sudo mv composer.phar /usr/local/bin/composer
sudo chmod +x /usr/local/bin/composer
composer --version
Install PHPMailerBASH
cd /var/www/example.com/public_html
composer require phpmailer/phpmailer
⚠️
Protect the vendor/ Directory Add this to your .htaccess to block direct browser access to the vendor folder:
.htaccess — Block vendor/ accessAPACHE
<IfModule mod_rewrite.c>
  RewriteEngine On
  RewriteRule ^vendor/ - [F,L]
</IfModule>

Option B — Manual Download (No Composer)

  1. Go to github.com/PHPMailer/PHPMailer and download the latest release ZIP.
  2. Extract it. You only need: src/PHPMailer.php, src/SMTP.php, and src/Exception.php.
  3. Upload these three files to a PHPMailer/ subfolder inside your website directory.
  4. In your process_form.php, require them manually instead of the Composer autoloader.

03

Hashed Credentials Config File

The safest way to store SMTP credentials is in a separate config file that lives outside your web root and stores sensitive values as encoded strings rather than plaintext.

Recommended Directory StructureTEXT
/var/www/example.com/
├── config/
│   └── mail_config.php        ← credentials live here (NOT public)
└── public_html/               ← web root — publicly accessible
    ├── index.php
    ├── contact.html
    ├── process_form.php
    └── vendor/
Create Config DirectoryBASH
sudo mkdir -p /var/www/example.com/config
sudo chown www-data:www-data /var/www/example.com/config
sudo chmod 750 /var/www/example.com/config
Generate Base64-Encoded Credentials — Run OnceBASH
php -r "echo base64_encode('yourapppasswordhere') . PHP_EOL;"
/var/www/example.com/config/mail_config.phpPHP
<?php
// Which provider to use: 'gmail' | 'outlook' | 'sendgrid'
define('MAIL_PROVIDER',  'gmail');

define('GMAIL_USER',     'youraddress@gmail.com');
define('GMAIL_PASS_ENC', 'eW91cmFwcHBhc3N3b3JkaGVyZQ=='); // base64 encoded

define('OUTLOOK_USER',    'youraddress@outlook.com');
define('OUTLOOK_PASS_ENC','eW91cnBhc3N3b3JkaGVyZQ==');

define('SENDGRID_API_ENC','U0cueW91cmFwaWtleWhlcmU=');
define('SENDGRID_FROM',   'youraddress@yourdomain.com');

define('MAIL_TO',         'youraddress@gmail.com');
define('MAIL_TO_NAME',    'Your Name');
define('RECAPTCHA_SECRET', 'your_recaptcha_secret_key_here');

function decode_credential($encoded) {
    return base64_decode($encoded);
}
🚫
Add mail_config.php to .gitignore Immediately Add config/mail_config.php to your .gitignore before your first commit. If you accidentally commit credentials, rotate them immediately by generating a new app password or API key.

04

Gmail SMTP Setup — App Password ✓ Updated March 2026

■ Gmail / Google Workspace
⚠️
You Cannot Use Your Regular Gmail Password Google does not allow third-party apps to authenticate with your actual Gmail password. You must generate an App Password — a 16-character code Google generates specifically for SMTP access. App Passwords require two-factor authentication (2FA) to be enabled on your Google account first.

Step 1 — Enable 2-Factor Authentication

  1. Go to myaccount.google.com and sign in.
  2. Click Security in the left navigation — or use the search bar at the top of the page and type 2-Step Verification to jump straight there.
  3. Under "How you sign in to Google", click 2-Step Verification and follow the prompts to enable it.
  4. Google Workspace users only: Your admin must first enable App Passwords in the Admin Console under Security → Authentication → 2-Step Verification → Allow users to generate app passwords. Without this step, the App Passwords option will not appear even after enabling personal 2FA — contact your Workspace admin if you don't see it.

Step 2 — Generate an App Password

  1. Go directly to myaccount.google.com/apppasswords (2FA must be enabled or this page will not exist — Google may prompt you to re-authenticate before continuing).
  2. In the App name field, type a descriptive label like PHP Contact Form. Note: the old two-dropdown interface (separate "App" and "Device" selectors) no longer exists — there is now just a single name field.
  3. Click Create. Google shows you a 16-character password like abcd efgh ijkl mnop.
  4. Copy it immediately — Google will never show it again. Remove the spaces before encoding it.
  5. Base64-encode it and store it in mail_config.php as GMAIL_PASS_ENC.
⚠️
App Passwords Are Revoked When You Change Your Google Password If you ever change your Google account password, all previously generated App Passwords are automatically invalidated with no warning. You'll need to generate a new one and update GMAIL_PASS_ENC in mail_config.php — otherwise your contact form will silently stop sending email.

Gmail SMTP Settings

Gmail SMTP Connection SettingsTEXT
Host:       smtp.gmail.com
Port:       587  (STARTTLS — recommended)
            465  (SSL — alternative)
Encryption: STARTTLS (port 587) or SSL/TLS (port 465)
Username:   your full Gmail address
Password:   App Password (16 characters, no spaces)

PHPMailer Configuration for Gmail

Gmail PHPMailer SetupPHP
$mail->isSMTP();
$mail->Host       = 'smtp.gmail.com';
$mail->SMTPAuth   = true;
$mail->Username   = GMAIL_USER;
$mail->Password   = decode_credential(GMAIL_PASS_ENC);
$mail->SMTPSecure = PHPMailer::ENCRYPTION_STARTTLS;
$mail->Port       = 587;
ℹ️
Google Workspace (Business Gmail) — Same Process If you use Google Workspace with a custom domain like you@yourbusiness.com, the App Password process is identical — same SMTP host and port. Your username is your full Workspace email address.
⚠️
Gmail Daily Sending Limit Free Gmail accounts are limited to 500 emails per day via SMTP. Google Workspace accounts allow 2,000 per day. For high-volume forms, use SendGrid instead.

05

Outlook / Microsoft 365 SMTP Setup

■ Outlook / Microsoft 365
⚠️
Microsoft Has Been Tightening SMTP Access Microsoft has been progressively disabling Basic Authentication for SMTP in favour of OAuth2. As of 2024, free Outlook.com accounts may have SMTP AUTH disabled by default. If you are on Microsoft 365 Business, an administrator must enable SMTP AUTH per-mailbox. Always verify current Microsoft documentation before setting this up.

Enable SMTP AUTH for Your Account

  1. For Outlook.com: Go to outlook.live.com → Settings → Mail → Sync email and ensure "POP and IMAP" is enabled.
  2. For Microsoft 365 Business: An admin must go to the Microsoft 365 Admin Center → Users → Active users, select the mailbox, go to Mail → Manage email apps, and enable Authenticated SMTP.
Outlook / Microsoft 365 SMTP SettingsTEXT
Host:       smtp.office365.com   (Microsoft 365 / Outlook.com)
Port:       587
Encryption: STARTTLS
Username:   your full email address
Password:   your account password (or App Password if MFA enabled)
Outlook PHPMailer SetupPHP
$mail->isSMTP();
$mail->Host       = 'smtp.office365.com';
$mail->SMTPAuth   = true;
$mail->Username   = OUTLOOK_USER;
$mail->Password   = decode_credential(OUTLOOK_PASS_ENC);
$mail->SMTPSecure = PHPMailer::ENCRYPTION_STARTTLS;
$mail->Port       = 587;

06

SendGrid API Setup

■ SendGrid

SendGrid is a dedicated transactional email service — the best choice for high-volume forms and professional deliverability. The free tier allows 100 emails per day permanently.

  1. Go to sendgrid.com and create a free account.
  2. In the dashboard, go to Settings → API Keys → Create API Key. Set permission to Restricted Access → Mail Send → Full Access. Click Create & View.
  3. Copy the API key immediately — SendGrid shows it only once. Base64-encode it and store it in mail_config.php.
  4. Go to Settings → Sender Authentication and verify your sender email address or domain.
SendGrid SMTP SettingsTEXT
Host:       smtp.sendgrid.net
Port:       587  (STARTTLS — recommended)
Username:   apikey         ← literally the word "apikey", not your email
Password:   your SendGrid API key
SendGrid PHPMailer SetupPHP
$mail->isSMTP();
$mail->Host       = 'smtp.sendgrid.net';
$mail->SMTPAuth   = true;
$mail->Username   = 'apikey'; // Always literally 'apikey'
$mail->Password   = decode_credential(SENDGRID_API_ENC);
$mail->SMTPSecure = PHPMailer::ENCRYPTION_STARTTLS;
$mail->Port       = 587;

07

Honeypot Spam Trap

A honeypot is a hidden form field that human visitors never see or fill in — but spam bots do. When the honeypot field is populated on submission, silently reject it.

HTML — Hidden Honeypot FieldHTML
<div style="position:absolute;left:-9999px;top:-9999px;opacity:0;height:0;width:0;overflow:hidden;"
     aria-hidden="true">
  <input type="text" name="website" tabindex="-1" autocomplete="off">
</div>
process_form.php — Honeypot CheckPHP
if (!empty($_POST['website'])) {
    // Silent rejection — return fake success so bot doesn't retry
    echo '<p>Thank you! We will be in touch soon.</p>';
    exit;
}
ℹ️
Why "website"? Bots fill all visible fields automatically. Never use display:none or visibility:hidden — some bots detect and skip those. The off-screen CSS pushes it out of view for humans while keeping it technically present in the DOM.

08

Google reCAPTCHA v2

reCAPTCHA v2 adds the "I'm not a robot" checkbox. It's a second layer on top of the honeypot — the honeypot catches automated bots, reCAPTCHA catches more sophisticated ones.

Step 1 — Register Your Site

  1. Go to google.com/recaptcha/admin/create and sign in.
  2. Select reCAPTCHA v2 → "I'm not a robot" Checkbox.
  3. Add your domain (e.g. anfamily.cloud). Also add localhost for local testing.
  4. Click Submit. You receive two keys: Site Key (goes in HTML — public) and Secret Key (goes in mail_config.php as RECAPTCHA_SECRET — never expose).

Add reCAPTCHA to Your HTML Form

HTML — reCAPTCHA WidgetHTML
<!-- Place just before your submit button -->
<div class="g-recaptcha" data-sitekey="YOUR_SITE_KEY_HERE"></div>
<button type="submit">Submit</button>
<!-- Load reCAPTCHA script at bottom of page -->
<script src="https://www.google.com/recaptcha/api.js" async defer></script>

Verify reCAPTCHA in PHP

process_form.php — reCAPTCHA VerificationPHP
$recaptcha_response = trim($_POST['g-recaptcha-response'] ?? '');
if (empty($recaptcha_response)) { json_error('Please complete the reCAPTCHA.'); }
$rv = json_decode(file_get_contents(
    'https://www.google.com/recaptcha/api/siteverify?secret='
    . urlencode(RECAPTCHA_SECRET)
    . '&response=' . urlencode($recaptcha_response)
    . '&remoteip=' . urlencode($_SERVER['REMOTE_ADDR'])
), true);
if (empty($rv['success'])) { json_error('reCAPTCHA failed. Please try again.'); }

09

The Complete process_form.php

ℹ️
MAIL_PROVIDER Switch The handler reads MAIL_PROVIDER from your config file and uses the correct SMTP settings automatically. To switch from Gmail to SendGrid, change one line in mail_config.php.
process_form.php — Complete Production HandlerPHP
<?php
error_reporting(0);
ini_set('display_errors', 0);

// Load config (outside web root)
$config_path = dirname(__DIR__) . '/config/mail_config.php';
if (!file_exists($config_path)) { http_response_code(500); die('Server configuration error.'); }
require_once $config_path;

// Load PHPMailer — Option A: Composer autoloader (recommended)
require_once __DIR__ . '/vendor/autoload.php';
// Option B: Manual include (no Composer)
// require_once __DIR__ . '/PHPMailer/PHPMailer.php';
// require_once __DIR__ . '/PHPMailer/SMTP.php';
// require_once __DIR__ . '/PHPMailer/Exception.php';

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

if ($_SERVER['REQUEST_METHOD'] !== 'POST') { http_response_code(405); die('Method not allowed.'); }

function json_error($msg, $code = 400) {
    http_response_code($code); header('Content-Type: application/json');
    echo json_encode(['success' => false, 'message' => $msg]); exit;
}
function clean($val) {
    return htmlspecialchars(strip_tags(trim($val)), ENT_QUOTES, 'UTF-8');
}

// 1. Honeypot check
if (!empty($_POST['website'])) {
    header('Content-Type: application/json');
    echo json_encode(['success' => true, 'message' => 'Thank you! We will be in touch.']); exit;
}

// 2. reCAPTCHA verification
$recaptcha_response = trim($_POST['g-recaptcha-response'] ?? '');
if (empty($recaptcha_response)) { json_error('Please complete the reCAPTCHA.'); }
$rv = json_decode(file_get_contents(
    'https://www.google.com/recaptcha/api/siteverify?secret='
    . urlencode(RECAPTCHA_SECRET) . '&response=' . urlencode($recaptcha_response)
    . '&remoteip=' . urlencode($_SERVER['REMOTE_ADDR'])
), true);
if (empty($rv['success'])) { json_error('reCAPTCHA failed. Please try again.'); }

// 3. Collect and sanitize fields
$first_name = clean($_POST['first-name']  ?? '');
$last_name  = clean($_POST['last-name']   ?? '');
$phone      = clean($_POST['phone']       ?? '');
$email      = filter_var(trim($_POST['email'] ?? ''), FILTER_SANITIZE_EMAIL);
$message    = clean($_POST['message']     ?? '');
if (empty($first_name) || empty($last_name) || empty($email) || empty($message)) {
    json_error('Please fill in all required fields.');
}
if (!filter_var($email, FILTER_VALIDATE_EMAIL)) { json_error('Please enter a valid email address.'); }

$services = '';
if (!empty($_POST['services']) && is_array($_POST['services'])) {
    $services = implode(', ', array_map('htmlspecialchars', $_POST['services']));
}

// 4. Build HTML email body
$body = "<html><body style='font-family:Arial,sans-serif;max-width:600px;'>
<h2 style='color:#3b1f7a;border-bottom:2px solid #ede9fe;padding-bottom:8px;'>New Contact Form Submission</h2>
<table style='width:100%;border-collapse:collapse;'>
<tr><td style='padding:8px;font-weight:bold;color:#4b5563;width:140px;'>Name</td>
    <td style='padding:8px;'>{$first_name} {$last_name}</td></tr>
<tr style='background:#f3f0ff;'>
    <td style='padding:8px;font-weight:bold;color:#4b5563;'>Email</td>
    <td style='padding:8px;'><a href='mailto:{$email}'>{$email}</a></td></tr>
<tr><td style='padding:8px;font-weight:bold;color:#4b5563;'>Phone</td>
    <td style='padding:8px;'>{$phone}</td></tr>";
if (!empty($services)) {
    $body .= "<tr style='background:#f3f0ff;'><td style='padding:8px;font-weight:bold;color:#4b5563;'>Services</td><td style='padding:8px;'>{$services}</td></tr>";
}
$body .= "</table><h3 style='color:#3b1f7a;margin-top:20px;'>Message</h3>
<div style='background:#f3f0ff;padding:16px;border-radius:6px;border-left:4px solid #8b5cf6;white-space:pre-wrap;'>{$message}</div>
<p style='color:#9ca3af;font-size:12px;margin-top:20px;'>Submitted: " . date('Y-m-d H:i:s T') . " &nbsp;·&nbsp; IP: " . htmlspecialchars($_SERVER['REMOTE_ADDR']) . "</p></body></html>";

// 5. Send via PHPMailer
try {
    $mail = new PHPMailer(true);
    switch (MAIL_PROVIDER) {
        case 'gmail':
            $mail->isSMTP(); $mail->Host = 'smtp.gmail.com'; $mail->SMTPAuth = true;
            $mail->Username = GMAIL_USER; $mail->Password = decode_credential(GMAIL_PASS_ENC);
            $mail->SMTPSecure = PHPMailer::ENCRYPTION_STARTTLS; $mail->Port = 587;
            $from_addr = GMAIL_USER; break;
        case 'outlook':
            $mail->isSMTP(); $mail->Host = 'smtp.office365.com'; $mail->SMTPAuth = true;
            $mail->Username = OUTLOOK_USER; $mail->Password = decode_credential(OUTLOOK_PASS_ENC);
            $mail->SMTPSecure = PHPMailer::ENCRYPTION_STARTTLS; $mail->Port = 587;
            $from_addr = OUTLOOK_USER; break;
        case 'sendgrid':
        default:
            $mail->isSMTP(); $mail->Host = 'smtp.sendgrid.net'; $mail->SMTPAuth = true;
            $mail->Username = 'apikey'; $mail->Password = decode_credential(SENDGRID_API_ENC);
            $mail->SMTPSecure = PHPMailer::ENCRYPTION_STARTTLS; $mail->Port = 587;
            $from_addr = SENDGRID_FROM; break;
    }
    $mail->setFrom($from_addr, 'Contact Form');
    $mail->addAddress(MAIL_TO, MAIL_TO_NAME);
    $mail->addReplyTo($email, "{$first_name} {$last_name}");
    $mail->isHTML(true);
    $mail->Subject = "New Contact: {$first_name} {$last_name}";
    $mail->Body    = $body;
    $mail->AltBody = "Name: {$first_name} {$last_name}\nEmail: {$email}\nPhone: {$phone}\n\nMessage:\n{$message}";
    $mail->send();
    header('Content-Type: application/json');
    echo json_encode(['success' => true, 'message' => 'Thank you! We will be in touch soon.']);
} catch (Exception $e) {
    error_log('PHPMailer error: ' . $mail->ErrorInfo);
    json_error('Message could not be sent. Please try again later.', 500);
}

10

The Complete HTML Contact Form

Both form variations — checkbox (multiple selections) and dropdown (single selection) — include the honeypot, reCAPTCHA, and AJAX submit.

✅ Use Checkboxes When
  • The user might need more than one option at the same time
  • Example: a notary form where someone needs Standard Notarization AND Mobile AND Certified Copies
▾ Use a Dropdown When
  • The user picks exactly one option — mutually exclusive choices
  • Example: "How did you hear about us?" or a single service category

Checkbox Version — Multiple Selections

contact.html — Checkbox Version with AJAX SubmitHTML
<form id="contactForm" action="process_form.php" method="POST">

  <!-- Honeypot -->
  <div style="position:absolute;left:-9999px;opacity:0;height:0;overflow:hidden;" aria-hidden="true">
    <input type="text" name="website" tabindex="-1" autocomplete="off">
  </div>

  <div class="form-row">
    <div class="form-group">
      <label for="first-name">First Name <span class="req">*</span></label>
      <input type="text" id="first-name" name="first-name" autocomplete="given-name" required>
    </div>
    <div class="form-group">
      <label for="last-name">Last Name <span class="req">*</span></label>
      <input type="text" id="last-name" name="last-name" autocomplete="family-name" required>
    </div>
  </div>

  <div class="form-group">
    <label for="email">Email Address <span class="req">*</span></label>
    <input type="email" id="email" name="email" autocomplete="email" required>
  </div>

  <div class="form-group">
    <label for="phone">Phone</label>
    <input type="tel" id="phone" name="phone" autocomplete="tel">
  </div>

  <div class="form-group">
    <label>Services Needed <span class="req">*</span></label>
    <!-- Add your checkboxes here with name="services[]" -->
    <label><input type="checkbox" name="services[]" value="Option A"> Option A</label>
    <label><input type="checkbox" name="services[]" value="Option B"> Option B</label>
    <label><input type="checkbox" name="services[]" value="Option C"> Option C</label>
  </div>

  <div class="form-group">
    <label for="message">Message <span class="req">*</span></label>
    <textarea id="message" name="message" rows="5" required></textarea>
  </div>

  <div class="g-recaptcha" data-sitekey="YOUR_RECAPTCHA_SITE_KEY" style="margin-bottom:1rem;"></div>
  <div id="form-status" style="display:none;padding:0.75rem 1rem;border-radius:6px;margin-bottom:1rem;"></div>
  <button type="submit" id="submitBtn">Send Message</button>
</form>

<script src="https://www.google.com/recaptcha/api.js" async defer></script>
<script>
document.getElementById('contactForm').addEventListener('submit', async (e) => {
  e.preventDefault();
  const btn = document.getElementById('submitBtn');
  const status = document.getElementById('form-status');
  btn.disabled = true; btn.textContent = 'Sending…'; status.style.display = 'none';
  try {
    const res  = await fetch('process_form.php', { method: 'POST', body: new FormData(e.target) });
    const data = await res.json();
    status.style.display = 'block';
    if (data.success) {
      status.style.cssText = 'display:block;padding:.75rem 1rem;border-radius:6px;background:#f0fdf4;border:1px solid #86efac;color:#14532d;';
      status.textContent = data.message; e.target.reset();
      if (window.grecaptcha) grecaptcha.reset();
    } else {
      status.style.cssText = 'display:block;padding:.75rem 1rem;border-radius:6px;background:#fef2f2;border:1px solid #fca5a5;color:#7f1d1d;';
      status.textContent = data.message;
      if (window.grecaptcha) grecaptcha.reset();
    }
  } catch (err) {
    status.style.cssText = 'display:block;padding:.75rem 1rem;border-radius:6px;background:#fef2f2;color:#7f1d1d;';
    status.textContent = 'Network error. Please try again.';
  } finally {
    btn.disabled = false; btn.textContent = 'Send Message';
  }
});
</script>

Dropdown Version — Single Selection

Swap This In — Dropdown Instead of CheckboxesHTML
<div class="form-group">
  <label for="service">Interested In <span class="req">*</span></label>
  <select id="service" name="service" required>
    <option value="">Select an option...</option>
    <option value="Option A">Option A</option>
    <option value="Option B">Option B</option>
    <option value="Option C">Option C</option>
    <option value="Other">Other</option>
  </select>
</div>
<!-- PHP: collect as $service = clean($_POST['service'] ?? ''); -->
ℹ️
The Only PHP Difference Between the Two Checkboxes send an array: use implode(", ", $_POST['services']). Dropdowns send a single string: use clean($_POST['service'] ?? '') directly. That's the only difference in your PHP handler.

11

Deploying & Testing

Deployment Checklist

  1. Upload process_form.php and your HTML form to /var/www/example.com/public_html/
  2. Upload mail_config.php to /var/www/example.com/config/ — confirm it is outside the public web root
  3. Run composer install in your public_html folder to install PHPMailer
  4. Set correct permissions:
    File PermissionsBASH
    sudo chmod 640 /var/www/example.com/config/mail_config.php
    sudo chown www-data:www-data /var/www/example.com/config/mail_config.php
    sudo chmod 644 /var/www/example.com/public_html/process_form.php
  5. Verify mail_config.php is NOT accessible from the browser — https://example.com/config/mail_config.php should return 403 or 404
  6. Submit a test form and check your inbox

Testing SMTP Without a Form

smtp_test.php — Delete After TestingPHP
<?php
error_reporting(E_ALL); ini_set('display_errors', 1);
require_once dirname(__DIR__) . '/config/mail_config.php';
require_once __DIR__ . '/vendor/autoload.php';
use PHPMailer\PHPMailer\PHPMailer;
use PHPMailer\PHPMailer\Exception;
$mail = new PHPMailer(true);
$mail->SMTPDebug = 2; // Verbose output — shows full SMTP conversation
$mail->isSMTP(); $mail->Host = 'smtp.gmail.com'; $mail->SMTPAuth = true;
$mail->Username = GMAIL_USER; $mail->Password = decode_credential(GMAIL_PASS_ENC);
$mail->SMTPSecure = PHPMailer::ENCRYPTION_STARTTLS; $mail->Port = 587;
$mail->setFrom(GMAIL_USER, 'SMTP Test'); $mail->addAddress(MAIL_TO);
$mail->Subject = 'SMTP Test'; $mail->Body = 'If you received this, SMTP is working.';
try { $mail->send(); echo 'Test email sent successfully.'; }
catch (Exception $e) { echo 'Error: ' . $mail->ErrorInfo; }
// DELETE THIS FILE immediately after testing
🚫
Delete smtp_test.php Immediately After Testing The test script has display_errors enabled and outputs full SMTP debug info. Delete it as soon as you confirm email is working.

Common Errors and Fixes

Common SMTP ErrorsTEXT
SMTP Error: Could not authenticate
  → Wrong username or App Password / spaces not removed from App Password
  → Gmail: make sure 2FA is on and you generated an App Password, not your login password

Connection refused / Could not connect to host
  → Port 587 blocked by hosting provider — try port 465
  → Check UFW: sudo ufw allow out 587/tcp

535 Authentication credentials invalid (SendGrid)
  → Username must be literally 'apikey' — not your email address

stream_socket_enable_crypto(): SSL operation failed
  → OpenSSL PHP extension not installed
  → sudo apt install php8.3-curl php8.3-openssl

mail_config.php not found
  → dirname(__DIR__) path incorrect — verify with:
  → php -r "echo dirname(__DIR__);" run from your public_html folder

App Password stopped working (no config changes)
  → Your Google account password was changed — all App Passwords auto-revoke
  → Generate a new App Password at myaccount.google.com/apppasswords

12

Fixing the Portfolio Contact Form

Step 1 — Add the Form Action and Honeypot to portfolio.php

portfolio.php — Updated Form TagHTML
<!-- Change this: -->
<form>

<!-- To this: -->
<form id="contactForm" action="process_form.php" method="POST">
  <div style="position:absolute;left:-9999px;opacity:0;height:0;overflow:hidden;" aria-hidden="true">
    <input type="text" name="website" tabindex="-1" autocomplete="off">
  </div>

Step 2 — Add reCAPTCHA and Status Before Submit Button

portfolio.php — reCAPTCHA + Status + SubmitHTML
<div class="form-group">
  <div class="g-recaptcha" data-sitekey="YOUR_RECAPTCHA_SITE_KEY"></div>
</div>
<div id="form-status" style="display:none;padding:0.75rem 1rem;border-radius:6px;margin-bottom:1rem;"></div>
<button type="submit" id="submitBtn" class="form-submit">Send Message →</button>
<script src="https://www.google.com/recaptcha/api.js" async defer></script>

Step 3 — Add the AJAX Submit Script Before </body>

portfolio.php — AJAX Submit ScriptHTML
<script>
const cf = document.getElementById('contactForm');
if (cf) {
  cf.addEventListener('submit', async (e) => {
    e.preventDefault();
    const btn = document.getElementById('submitBtn');
    const status = document.getElementById('form-status');
    btn.disabled = true; btn.textContent = 'Sending…'; status.style.display = 'none';
    try {
      const res  = await fetch('process_form.php', { method: 'POST', body: new FormData(e.target) });
      const data = await res.json();
      status.style.display = 'block';
      if (data.success) {
        status.style.cssText = 'display:block;padding:.75rem 1rem;border-radius:6px;background:#f0fdf4;border:1px solid #86efac;color:#14532d;';
        status.textContent = data.message; e.target.reset();
        if (window.grecaptcha) grecaptcha.reset();
      } else {
        status.style.cssText = 'display:block;padding:.75rem 1rem;border-radius:6px;background:#fef2f2;border:1px solid #fca5a5;color:#7f1d1d;';
        status.textContent = data.message;
        if (window.grecaptcha) grecaptcha.reset();
      }
    } catch(err) {
      status.style.cssText = 'display:block;padding:.75rem 1rem;border-radius:6px;background:#fef2f2;color:#7f1d1d;';
      status.textContent = 'Network error. Please try again.';
    } finally {
      btn.disabled = false; btn.textContent = 'Send Message →';
    }
  });
}
</script>

Step 4 — Create process_form.php with Portfolio Field Names

process_form.php — Portfolio Field NamesPHP
// Use these field names to match portfolio.php form inputs
$first_name = clean($_POST['fname']    ?? '');
$last_name  = clean($_POST['lname']    ?? '');
$email      = filter_var(trim($_POST['email'] ?? ''), FILTER_SANITIZE_EMAIL);
$service    = clean($_POST['service']  ?? '');
$message    = clean($_POST['message']  ?? '');

Step 5 — Deploy All Files

  1. Upload process_form.php to your web root alongside portfolio.php
  2. Create /var/www/anfamily.cloud/config/mail_config.php with your Gmail App Password encoded
  3. Run composer require phpmailer/phpmailer in your web root
  4. Upload the updated portfolio.php with form action, honeypot, reCAPTCHA widget, status div, and AJAX script
  5. Run smtp_test.php to confirm email delivery, then delete it immediately
  6. Submit a live test through the portfolio form
All Done Your portfolio contact form now sends via authenticated SMTP, is protected by honeypot and reCAPTCHA, stores credentials safely outside the web root, and gives users instant feedback without a page reload.