web php

Sending Mail Safely

Web applications send email for password resets, account verification, receipts, alerts, invites, and support messages. Email is user-visible and often security-sensitive, so sending it safely matters.

In modern PHP projects, prefer a maintained mailer library or framework mail system over building raw email strings by hand.

Validate recipients

Email addresses come from users and databases, so validate them before sending.

PHP example
<?php

declare(strict_types=1);

function validEmail(string $email): ?string
{
    $email = trim($email);
    $validated = filter_var($email, FILTER_VALIDATE_EMAIL);

    return is_string($validated) ? $validated : null;
}

echo validEmail('dev@example.com') ?? 'invalid';
echo PHP_EOL;
echo validEmail("bad\nBcc: victim@example.com") ?? 'invalid';

// Prints:
// dev@example.com
// invalid

Validation is not only about format. Some applications also need verified addresses, unsubscribe rules, bounce handling, or domain restrictions.

Avoid header injection

Raw email headers are line-based. If user input can introduce new lines into a header, attackers may add extra headers such as Bcc.

Do not put user input directly into headers. Let a mailer library handle header encoding, and reject values containing line breaks when you build simple examples.

PHP example
<?php

declare(strict_types=1);

function safeHeaderText(string $value): string
{
    if (str_contains($value, "\r") || str_contains($value, "\n")) {
        throw new InvalidArgumentException('Header text cannot contain line breaks.');
    }

    return trim($value);
}

echo safeHeaderText('Password reset') . PHP_EOL;

// Prints:
// Password reset

Use a trusted sender

The visible sender should usually be an address your application controls, such as no-reply@example.com or support@example.com.

If a user submits a message, put their email in Reply-To only after validation. Do not let users choose arbitrary From headers. Mail providers often reject or flag spoofed senders because SPF, DKIM, and DMARC are tied to the sending domain.

PHP example
<?php

declare(strict_types=1);

$message = [
    'from' => 'support@example.com',
    'reply_to' => validEmail('customer@example.com'),
    'subject' => safeHeaderText('Support request received'),
];

echo $message['from'] . ' reply-to ' . $message['reply_to'] . PHP_EOL;

// Prints:
// support@example.com reply-to customer@example.com

Use templates and escape output

Email bodies can be plain text, HTML, or both. HTML email still needs escaping when it includes user-controlled values.

PHP example
<?php

declare(strict_types=1);

function escapeHtml(string $value): string
{
    return htmlspecialchars($value, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
}

$name = 'Asha <Admin>';

echo '<p>Hello ' . escapeHtml($name) . ', reset your password.</p>' . PHP_EOL;

// Prints:
// <p>Hello Asha &lt;Admin&gt;, reset your password.</p>

Plain-text email does not execute HTML, but it can still be confusing or abusive if user content is inserted without review.

Queue mail when possible

Sending mail can be slow or fail because of network or provider issues. Many applications queue mail jobs so the web request can finish quickly.

For security-critical mail, such as password resets, store the token and expiry before queueing the email. The email job should send a link to an already-created token, not create a new token independently.

Do not leak secrets in logs

Email failures often get logged. Avoid logging password reset tokens, one-time codes, full API keys, or full email body content when it contains sensitive information.

What to check in a project

Check that the application uses a framework mailer, Symfony Mailer, PHPMailer, or a provider SDK instead of hand-built raw messages.

Check recipient validation and verified-email requirements.

Check that user input is not placed directly into headers.

Check password reset and invite links for token expiry, single-use behaviour, and safe logging.

Check environment separation. Development and staging should not accidentally send real customer emails.

What you should be able to do

After this lesson, you should be able to validate recipients, avoid header injection, use trusted sender addresses, escape HTML email content, understand why mail is often queued, and identify sensitive logging risks.

Practice

Task: Prepare A Safe Mail Message

Write a small PHP script that prepares a safe email message array without actually sending mail.

Requirements

  • Use declare(strict_types=1);.
  • Validate the recipient email address.
  • Reject subject text containing line breaks.
  • Use a trusted from address controlled by the application.
  • Escape a user display name for an HTML body.
  • Include one valid message case.
  • Include one invalid recipient or header-injection case.
  • Print the result without sending email.

Check Your Work

Run the script and confirm the invalid case is rejected before a message is prepared.

Show solution

This solution prepares the data a mailer library would receive. It does not call mail() or contact a provider.

PHP example
<?php

declare(strict_types=1);

function validEmail(string $email): string
{
    $validated = filter_var(trim($email), FILTER_VALIDATE_EMAIL);

    if (!is_string($validated)) {
        throw new InvalidArgumentException('Invalid email address.');
    }

    return $validated;
}

function safeHeaderText(string $value): string
{
    if (str_contains($value, "\r") || str_contains($value, "\n")) {
        throw new InvalidArgumentException('Header text cannot contain line breaks.');
    }

    return trim($value);
}

function escapeHtml(string $value): string
{
    return htmlspecialchars($value, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
}

/**
 * @return array{from: string, to: string, subject: string, html: string}
 */
function prepareWelcomeMessage(string $to, string $subject, string $displayName): array
{
    return [
        'from' => 'support@example.com',
        'to' => validEmail($to),
        'subject' => safeHeaderText($subject),
        'html' => '<p>Hello ' . escapeHtml($displayName) . ', welcome.</p>',
    ];
}

$message = prepareWelcomeMessage('dev@example.com', 'Welcome', 'Asha <Admin>');
echo $message['to'] . ' ' . $message['subject'] . ' ' . $message['html'] . PHP_EOL;

try {
    prepareWelcomeMessage("bad\nBcc: victim@example.com", 'Welcome', 'Asha');
} catch (InvalidArgumentException $exception) {
    echo $exception->getMessage() . PHP_EOL;
}

// Prints:
// dev@example.com Welcome <p>Hello Asha &lt;Admin&gt;, welcome.</p>
// Invalid email address.

Why This Works

The valid case proves the message uses a trusted sender and escaped HTML. The invalid case proves unsafe recipient input is rejected before sending.