blog
PHP mail() function: how to send email in PHP safely
Use PHP mail() with safe headers, understand Unix and Windows transport limits, and verify inbox delivery before trusting a successful return value.

PHP can hand an email to the server with one function call. The important catch is that mail() only reports whether the configured transport accepted the message. It cannot tell you whether the email reached the recipient's inbox.
That distinction explains most of the confusion around this small, long-lived function.
What does PHP mail() actually do?
The function has this shape:
mail($to, $subject, $message, $additionalHeaders);
Behind that call, PHP uses the transport configured for the machine:
- On Linux and other Unix-like systems, PHP normally passes the message to a local
sendmail-compatible program. - On Windows, PHP can connect directly to the SMTP host and port configured in
php.ini. - A
truereturn value means that transport accepted the message. It does not prove delivery, inbox placement, or even that the destination address exists.
The PHP manual for mail() makes that last point explicitly. Treat the return value as a handoff result, not a delivery receipt.
Send a plain-text email with controlled headers
Modern PHP accepts additional headers as an array. This is easier to read than manually joining a long header string and reduces separator mistakes.
<?php
function sendWelcomeEmail(string $recipient): void
{
if (
!filter_var($recipient, FILTER_VALIDATE_EMAIL)
|| preg_match('/[\r\n]/', $recipient)
) {
throw new InvalidArgumentException('Invalid recipient address');
}
$headers = [
'From' => 'Example App <no-reply@example.com>',
'Reply-To' => 'support@example.com',
'Content-Type' => 'text/plain; charset=UTF-8',
];
$accepted = mail(
$recipient,
'Welcome to Example App',
"Your account is ready.\r\n",
$headers
);
if (!$accepted) {
throw new RuntimeException('The local mail transport rejected the message');
}
}
The sender and subject are application-controlled. If the recipient comes from a form or request, validate it and reject carriage returns or line feeds before it reaches an address or header field. PHP has supported header arrays since version 7.2.
Do not show "Email delivered" after this function returns. "Message accepted for sending" is accurate; "delivered" is still waiting for proof.
Send a simple HTML email
Set the MIME and content-type headers when the body contains HTML:
<?php
$headers = [
'From' => 'Example App <no-reply@example.com>',
'Reply-To' => 'support@example.com',
'MIME-Version' => '1.0',
'Content-Type' => 'text/html; charset=UTF-8',
];
$html = <<<'HTML'
<!doctype html>
<html lang="en">
<body>
<h1>Your account is ready</h1>
<p>You can now sign in and finish setting up your profile.</p>
</body>
</html>
HTML;
$accepted = mail(
'recipient@example.com',
'Welcome to Example App',
$html,
$headers
);
if (!$accepted) {
throw new RuntimeException('The local mail transport rejected the message');
}
This sends one HTML body. If you need a text alternative, inline images, attachments, address encoding, or several recipients, use a mail library rather than assembling a multipart message by hand. The existing PHP SMTP guide shows a complete PHPMailer path with authenticated SMTP, TLS, HTML, and attachments.
How PHP finds the mail server
The setup is different on Unix-like hosts and Windows. This is not a cosmetic detail: it determines where failures appear and which transport features are available.
| Host | What mail() calls |
Configuration to inspect |
|---|---|---|
| Linux or another Unix-like system | A local sendmail-compatible binary or wrapper | sendmail_path in php.ini, then the local mail transfer agent's queue and logs |
| Windows | A direct socket connection to an SMTP server | SMTP, smtp_port, and sendmail_from in php.ini |
The current PHP mail configuration reference documents those directives and notes that the direct SMTP settings are Windows-only.
Linux and other Unix-like systems
A common configuration is:
[mail function]
sendmail_path = "/usr/sbin/sendmail -t -i"
The path must point to a working local binary or compatible wrapper. PHP handing the message to that process is only the first step; Postfix, Exim, or another mail transfer agent still has to route it onward.
Check which configuration file the running process uses before changing anything:
php --ini
php -i | grep sendmail_path
The command-line and web-server processes can load different php.ini files. A script that works in a terminal but not under PHP-FPM may be running as another user with another configuration and another set of permissions.
Windows
The built-in Windows transport exposes a host, port, and sender address:
[mail function]
SMTP = relay.internal.example
smtp_port = 25
sendmail_from = no-reply@example.com
This only fits a trusted relay that accepts the application host under a tightly controlled network policy. The native settings do not provide username, password, or STARTTLS controls. If the provider requires authenticated submission on port 587, changing smtp_port to 587 is not enough; use PHPMailer, Symfony Mailer, a local relay wrapper, or an email API.
Do not expose an unauthenticated relay to the public internet. Restrict an internal relay by network, sender policy, and the smallest set of applications that need it.
Why mail() can return true when no email arrives
There are several handoffs between PHP and the inbox:
- PHP gives the message to its configured local program or SMTP socket.
- That transport queues or attempts the send.
- A receiving mail server accepts, temporarily defers, or rejects the message.
- The receiving provider decides whether to place it in the inbox, another tab, spam, or quarantine.
mail() only reports on the first handoff. A later bounce, DNS problem, recipient rejection, or spam decision happens after the function has finished.
When a message disappears, inspect the path in order:
- confirm which
php.iniand transport the running process uses; - check the local queue and mail logs, or the configured relay's event log;
- verify the envelope sender and recipient;
- inspect SPF, DKIM, DMARC, and received headers when a message arrives somewhere unexpected; and
- send the same template to a controlled inbox so you can separate application output from recipient-specific filtering.
The email header analyzer and email authentication checker help with the final two checks.
Keep form input out of message headers
Mail forms are a common place for header injection. An attacker tries to place a carriage return or line feed inside a field so one value becomes several headers.
Use a fixed From address and fixed destination. Put the visitor's message in the body, and only use their email as Reply-To after strict validation:
<?php
$email = filter_var(
$_POST['email'] ?? '',
FILTER_VALIDATE_EMAIL
);
$message = trim((string) ($_POST['message'] ?? ''));
if (
!$email
|| preg_match('/[\r\n]/', $email)
|| $message === ''
) {
http_response_code(400);
exit('Please check the form fields');
}
$headers = [
'From' => 'Website form <forms@example.com>',
'Reply-To' => $email,
'Content-Type' => 'text/plain; charset=UTF-8',
];
$accepted = mail(
'support@example.com',
'New website message',
$message,
$headers
);
if (!$accepted) {
http_response_code(503);
exit('The message could not be queued. Please try again.');
}
Do not put untrusted input into the fifth mail() parameter, which is passed to the sendmail command on applicable hosts. A public form also needs request limits, CSRF protection where sessions are involved, bot controls, a message-length limit, and server-side logging that does not copy sensitive message bodies into general application logs.
Prove delivery with a MailSlurp test inbox
A useful test checks the result that matters: the message reached an inbox with the expected subject. The following script expects a MailSlurp inbox created for the test and three environment variables: MAILSLURP_API_KEY, MAILSLURP_INBOX_ID, and MAILSLURP_INBOX_ADDRESS.
<?php
$apiKey = getenv('MAILSLURP_API_KEY');
$inboxId = getenv('MAILSLURP_INBOX_ID');
$inboxAddress = getenv('MAILSLURP_INBOX_ADDRESS');
if (!$apiKey || !$inboxId || !$inboxAddress) {
throw new RuntimeException('MailSlurp test settings are missing');
}
$subject = 'PHP mail smoke test';
$headers = [
'From' => 'Example App <no-reply@example.com>',
'Content-Type' => 'text/plain; charset=UTF-8',
];
if (!mail($inboxAddress, $subject, "Hello from PHP.\r\n", $headers)) {
throw new RuntimeException('The local mail transport rejected the message');
}
$query = http_build_query([
'inboxId' => $inboxId,
'timeout' => 60000,
'unreadOnly' => 'true',
]);
$request = curl_init(
'https://api.mailslurp.com/waitForLatestEmail?' . $query
);
curl_setopt_array($request, [
CURLOPT_HTTPHEADER => ['x-api-key: ' . $apiKey],
CURLOPT_RETURNTRANSFER => true,
]);
$response = curl_exec($request);
$status = curl_getinfo($request, CURLINFO_RESPONSE_CODE);
if ($response === false) {
$error = curl_error($request);
curl_close($request);
throw new RuntimeException($error);
}
curl_close($request);
if ($status >= 300) {
throw new RuntimeException('MailSlurp wait request failed');
}
$email = json_decode($response, true, 512, JSON_THROW_ON_ERROR);
if (($email['subject'] ?? null) !== $subject) {
throw new RuntimeException('Received an unexpected email');
}
Use a fresh or empty inbox for each run so an older unread message cannot satisfy the assertion. The MailSlurp PHP client documentation provides typed API alternatives, while Email Sandbox keeps test mail away from customer inboxes.
In continuous integration, give the email a realistic timeout and report the difference between transport rejection, wait timeout, and content mismatch. Those failures point to three different places. Lumping them together as "mail failed" makes the next person begin the investigation with a blindfold.
Common failures and the next useful check
mail() returns false
The local binary may be missing, the web process may lack permission to execute it, the direct SMTP socket may be unavailable, or PHP may reject the message arguments. Check the PHP error log and the transport configuration used by that exact process.
mail() returns true but the inbox stays empty
Look in the local mail queue or relay events before changing the PHP code. The message may be deferred, bounced later, rejected by the recipient, or placed in spam. Send a controlled copy to MailSlurp and compare the result.
Port 587 still reports an authentication or TLS error
The native Windows settings cannot supply credentials or negotiate STARTTLS through a PHP option. Move this send to the PHPMailer SMTP workflow or place a properly secured local relay between PHP and the provider.
HTML arrives as text or looks broken
Check the MIME-Version and Content-Type headers, then inspect the received source. For multipart alternatives, attachments, embedded images, or non-ASCII address handling, let a maintained mail library build the MIME message.
A message changes when a line begins with a dot on Windows
PHP's direct Windows SMTP path has special handling for a full stop at the beginning of a line. The PHP manual documents how to dot-stuff that case. A mail library is the less surprising choice when exact body preservation matters.
When to keep mail() and when to move on
Keep mail() for a small, established application when the host already provides a reliable local mail transfer agent, the message format is simple, and you have an inbox-level test.
Use PHPMailer, Symfony Mailer, or an email API when you need authenticated SMTP, STARTTLS, OAuth, attachments, multipart bodies, richer error information, provider events, or sustained sending. That is not because mail() is deprecated; it is because a one-bit handoff result cannot carry the operational detail those jobs need.
The safest rule is pleasantly plain: accept the function's true as permission to keep testing, not permission to announce delivery.