PHP Mail & SMTP
debt(d7/e3/b5/t7)
Closest to 'only careful code review or runtime testing' (d7). The core issue—mail() returning true without actual delivery—cannot be caught by static analysis. No tools are specified in detection_hints. Email deliverability problems (spam folder, rejection) only surface through manual testing, user complaints, or production monitoring of bounce rates. A linter could flag bare mail() usage, but the underlying deliverability issues require runtime observation.
Closest to 'simple parameterised fix' (e3). The quick_fix indicates replacing mail() with PHPMailer/SMTP is a contained change—swapping the mail sending mechanism and adding DNS records. This is a 'two-hour setup' per why_it_matters, touching email configuration and DNS but not requiring cross-cutting refactors. If emails are queued, additional queue infrastructure may be needed, but the core fix is localized.
Closest to 'persistent productivity tax' (b5). Email infrastructure applies to web and CLI contexts per applies_to, affecting account verification, password resets, order confirmations, and notifications. A poor email setup creates ongoing deliverability issues across multiple features. However, once properly configured with PHPMailer/SMTP and DNS records, the burden stabilizes—it doesn't define the entire system's architecture.
Closest to 'serious trap' (t7). The misconception explicitly states developers believe mail() returning true means delivery succeeded, when it only means the local MTA accepted the message. This contradicts how return values typically work in PHP (success = operation completed). The gap between 'accepted by MTA' and 'delivered to inbox' is non-obvious, and the silent failure mode (emails in spam days later) compounds the trap.
Also Known As
TL;DR
Explanation
PHP's built-in mail() function is a thin wrapper around the server's sendmail binary. It works on correctly configured servers but has no SMTP authentication, no TLS support, no error handling beyond a boolean return, and no queue — if the mail server is unavailable, the email is silently lost. For production, PHPMailer or Symfony Mailer send via authenticated SMTP with TLS, provide proper error exceptions, and support HTML email with embedded images. Transactional email APIs (Mailgun, SendGrid, Amazon SES, Postmark) provide delivery tracking, bounce handling, and high deliverability without managing an SMTP server. Deliverability requires three DNS records: SPF (which servers may send on your behalf), DKIM (cryptographic signature proving origin), and DMARC (policy for failed SPF/DKIM). Missing these records causes emails to land in spam or be rejected silently.
Common Misconception
Why It Matters
Common Mistakes
- Using mail() in production without SPF, DKIM, and DMARC DNS records — emails go to spam or are rejected.
- Not using a queue for email sending — synchronous email blocks the HTTP response and fails silently if the SMTP server is slow.
- Not validating email addresses with filter_var($email, FILTER_VALIDATE_EMAIL) before sending.
- Exposing SMTP credentials in version-controlled config files — always use environment variables for SMTP passwords and API keys.
Code Examples
// mail() — no error handling, no TLS, poor deliverability
$sent = mail(
$_POST['email'], // unvalidated
'Welcome',
'Thanks for signing up',
'From: noreply@example.com' // likely to be spam
);
// Returns true even if email never delivered
// PHPMailer with SMTP + TLS
use PHPMailer\PHPMailer\PHPMailer;
use PHPMailer\PHPMailer\Exception;
$mail = new PHPMailer(true); // true = throw exceptions
try {
$mail->isSMTP();
$mail->Host = $_ENV['SMTP_HOST'];
$mail->SMTPAuth = true;
$mail->Username = $_ENV['SMTP_USER'];
$mail->Password = $_ENV['SMTP_PASS'];
$mail->SMTPSecure = PHPMailer::ENCRYPTION_STARTTLS;
$mail->Port = 587;
$mail->setFrom('noreply@example.com', 'My App');
$mail->addAddress(filter_var($email, FILTER_VALIDATE_EMAIL));
$mail->Subject = 'Welcome';
$mail->Body = 'Thanks for signing up';
$mail->send();
} catch (Exception $e) {
logger()->error('Mail failed: ' . $e->getMessage());
}