Sending Email
Web applications send email constantly: account verification, password resets, order confirmations, invoices, and contact-form notifications. Sending a message that actually arrives — in the inbox, no
Web applications send email constantly: account verification, password resets, order confirmations, invoices, and contact-form notifications. Sending a message that actually arrives — in the inbox, not the spam folder — requires more than calling one function. This lesson covers PHP's built-in mail() function and its limits, header injection, sending with a mail library over SMTP, building good message content, and deliverability.
PHP's mail() Function
Syntax
mail(string $to, string $subject, string $message, array|string $additional_headers = [], string $additional_params = ""): bool
<?php
$sent = mail(
'asha@example.com',
'Welcome to GoCourse',
"Hi Asha,\n\nThanks for signing up!\n",
[
'From' => 'GoCourse <no-reply@gocourse.in>',
'Reply-To' => 'support@gocourse.in',
'Content-Type' => 'text/plain; charset=UTF-8',
]
);
var_dump($sent);
How It Works
mail()hands the message to the server's local mail transfer agent (thesendmailprogram on Linux, configured bysendmail_path), which then delivers it.- Headers can be passed as an associative array (PHP 7.2+).
- The return value
trueonly means that the local mail system accepted the message for delivery — not that it reached the recipient.
Limitations of mail()
| Limitation | Consequence |
|---|---|
| Needs a configured mail server on the machine | Often unavailable on Windows, in containers, and on many hosts |
| No SMTP authentication or TLS settings | Cannot send through Gmail, Amazon SES, or other providers directly |
| No helpers for HTML + text, attachments, or encoding | Complex MIME formatting must be built by hand |
| Messages from web servers often lack SPF/DKIM alignment | High chance of landing in spam |
| No useful error information | Failures are hard to diagnose |
For these reasons, real applications use a mail library that sends through an SMTP server or an email provider's API.
Header Injection
If user input is placed into email headers, a newline in that input can add new headers:
// DANGEROUS: $email comes from a contact form
mail('support@gocourse.in', 'Contact form', $message, "Reply-To: $email");
An attacker submits the "email" x@example.com\r\nBcc: victim1@example.com, victim2@example.com, turning your contact form into a spam relay. Header values must never contain line breaks:
<?php
function isSafeHeaderValue(string $value): bool
{
return !preg_match('/[\r\n]/', $value);
}
var_dump(isSafeHeaderValue('asha@example.com'));
var_dump(isSafeHeaderValue("asha@example.com\r\nBcc: victim@example.com"));
Output
bool(true)
bool(false)
Additionally, validate email addresses with filter_var(..., FILTER_VALIDATE_EMAIL), which rejects line breaks. Mail libraries validate and encode headers automatically — another reason to use them.
Sending With Symfony Mailer
Symfony Mailer is a modern, widely used mail library (also used by Laravel). It supports SMTP and the APIs of major email providers.
composer require symfony/mailer
<?php
require __DIR__ . '/vendor/autoload.php';
use Symfony\Component\Mailer\Mailer;
use Symfony\Component\Mailer\Transport;
use Symfony\Component\Mime\Address;
use Symfony\Component\Mime\Email;
$transport = Transport::fromDsn(getenv('MAILER_DSN')); // e.g. smtp://user:pass@smtp.example.com:587
$mailer = new Mailer($transport);
$email = (new Email())
->from(new Address('no-reply@gocourse.in', 'GoCourse'))
->to(new Address('asha@example.com', 'Asha'))
->replyTo('support@gocourse.in')
->subject('Your order #501 is confirmed')
->text("Hi Asha,\n\nThank you for your order #501.\n")
->html('<p>Hi Asha,</p><p>Thank you for your order <strong>#501</strong>.</p>')
->attachFromPath(__DIR__ . '/invoices/invoice-501.pdf');
$mailer->send($email);
What This Does
- The DSN (connection string) holds the server, port, username, and password. It is read from an environment variable so credentials never appear in code (see $_ENV).
text()andhtml()create a multipart message: email clients that cannot display HTML show the text version, and spam filters treat messages with both parts more favorably.- Attachments, encoding, and header formatting are handled by the library.
send()throws aTransportExceptionInterfaceexception if sending fails, which you can catch and log.
PHPMailer is another popular, long-established library with similar capabilities:
$mail = new PHPMailer\PHPMailer\PHPMailer(true);
$mail->isSMTP();
$mail->Host = 'smtp.example.com';
$mail->SMTPAuth = true;
$mail->Username = getenv('SMTP_USER');
$mail->Password = getenv('SMTP_PASSWORD');
$mail->SMTPSecure = PHPMailer\PHPMailer\PHPMailer::ENCRYPTION_STARTTLS;
$mail->Port = 587;
$mail->CharSet = 'UTF-8';
$mail->setFrom('no-reply@gocourse.in', 'GoCourse');
$mail->addAddress('asha@example.com', 'Asha');
$mail->Subject = 'Welcome to GoCourse';
$mail->Body = '<p>Welcome!</p>';
$mail->AltBody = 'Welcome!';
$mail->send();
Building Message Content
Keep email content in functions or templates, separate from the sending code. This makes it easy to preview and test:
<?php
function buildOrderConfirmation(array $order): array
{
$lines = [];
$rows = '';
$total = 0;
foreach ($order['items'] as $item) {
$lineTotal = $item['qty'] * $item['price'];
$total += $lineTotal;
$lines[] = sprintf('- %s x %d: ₹%s', $item['name'], $item['qty'], number_format($lineTotal, 2));
$rows .= sprintf(
'<tr><td>%s</td><td>%d</td><td>₹%s</td></tr>',
htmlspecialchars($item['name']),
$item['qty'],
number_format($lineTotal, 2)
);
}
$text = "Hi {$order['customer']},\n\n"
. "Thank you for your order #{$order['id']}.\n\n"
. implode("\n", $lines) . "\n\n"
. 'Total: ₹' . number_format($total, 2) . "\n\n"
. "Team GoCourse\n";
$html = '<p>Hi ' . htmlspecialchars($order['customer']) . ',</p>'
. "<table>$rows</table>"
. '<p><strong>Total: ₹' . number_format($total, 2) . '</strong></p>';
return ['subject' => "Order #{$order['id']} confirmed", 'text' => $text, 'html' => $html];
}
$message = buildOrderConfirmation([
'id' => 501,
'customer' => 'Asha',
'items' => [
['name' => 'Gel Pen', 'qty' => 10, 'price' => 20],
['name' => 'Notebook <A5>', 'qty' => 3, 'price' => 60],
],
]);
echo "Subject: {$message['subject']}\n\n" . $message['text'];
Output
Subject: Order #501 confirmed
Hi Asha,
Thank you for your order #501.
- Gel Pen x 10: ₹200.00
- Notebook <A5> x 3: ₹180.00
Total: ₹380.00
Team GoCourse
Key Points
- The text version is plain text, so values are not HTML-escaped.
- In the HTML version, every value is escaped with
htmlspecialchars(): product names and customer names are data and could contain HTML (here<A5>becomes<A5>). - The function returns data only; sending is a separate step. This makes it possible to preview emails in the browser or test them without sending.
Email HTML is rendered by many different clients (Gmail, Outlook, mobile apps) with limited CSS support: use simple layouts, tables, and inline styles, or a dedicated email template tool.
Deliverability: Getting Into the Inbox
| Measure | Purpose |
|---|---|
| SPF DNS record | Lists the servers allowed to send mail for your domain |
| DKIM signature | Cryptographically signs messages, proving they were not altered |
| DMARC DNS record | Tells receivers how to handle mail failing SPF/DKIM, and requests reports |
Matching From domain | Send from your own domain, through a provider authorized in SPF/DKIM |
| Transactional email provider | Services such as Amazon SES, Postmark, SendGrid, or Mailgun maintain sending reputation |
| Plain-text alternative and honest subject lines | Lower spam scores |
| Unsubscribe links (for newsletters) | Required by many providers and laws |
Without SPF, DKIM, and DMARC, major providers such as Gmail increasingly reject or spam-folder messages, especially from domains that send in volume.
Sending Email Reliably
- Do not send during the web request when possible. Sending can take seconds or fail temporarily. Put the email into a queue (a database table or a queue system), and let a background worker send it with retries. Frameworks provide queued mail out of the box.
- Log each sent email (recipient, type, status) without logging sensitive content.
- Rate-limit forms that trigger emails (contact forms, password resets) to prevent abuse.
- Never let users choose arbitrary recipients for messages sent from your domain.
Testing Without Sending Real Emails
Local tools such as Mailpit run a fake SMTP server (for example, on port 1025) and show captured messages in a web interface. Point your development MAILER_DSN to it (smtp://localhost:1025) to see exactly what would be sent, without emailing real people.
Common Mistakes
Relying on mail() in Production
Effect: Messages silently fail or land in spam.
Fix: Use a mail library with an SMTP server or provider API.
Putting User Input Into Headers Unchecked
Effect: Header injection turns your server into a spam relay.
Fix: Validate addresses and reject line breaks; let libraries build headers.
Unescaped Values in HTML Emails
Effect: Broken layouts and injected content in messages.
Fix: Escape all dynamic values in the HTML part.
Storing SMTP Credentials in Code
Effect: Credentials leak through version control.
Fix: Use environment variables.
Sending Synchronously in Every Request
Effect: Slow pages and lost emails when the mail server is unavailable.
Fix: Queue emails and send them in the background with retries.
Best Practices
- Send through an authenticated SMTP server or email provider using Symfony Mailer or PHPMailer.
- Provide both HTML and plain-text versions, and escape dynamic HTML content.
- Configure SPF, DKIM, and DMARC for your sending domain.
- Queue emails, log their status, and rate-limit email-triggering forms.
- Test with a local mail catcher before sending to real users.
Official reference: https://www.php.net/manual/en/function.mail.php