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
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
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
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
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
fromaddress 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
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.