Skip to main content

    Lesson 31 • Advanced

    Building Email Systems 📧

    By the end of this lesson you'll send authenticated SMTP email from PHP with PHPMailer — HTML and plain-text, with attachments — queue large sends so they never block a request, and set up SPF, DKIM, and DMARC so your mail actually reaches the inbox.

    What You'll Learn in This Lesson

    • Explain why the built-in mail() function is unreliable
    • Send authenticated, TLS-encrypted email with PHPMailer
    • Send both HTML and a plain-text fallback in one message
    • Attach files and add CC / BCC recipients
    • Queue bulk sends so they never block the web request
    • Set up SPF, DKIM, and DMARC for reliable deliverability

    1️⃣ Why mail() Is Not Enough

    PHP ships with a mail() function, and it looks tempting because it's one line. But it hands your message to the server's local mail program with no authentication (no proof of who you are) and no encryption. Worse, it returns true the moment the message is handed off locally — that is not the same as the email being delivered. Gmail and Outlook routinely send mail() messages straight to spam, and you get no error explaining why.

    The old mail() way — avoid this in production
    <?php
    // The OLD way: PHP's built-in mail() function.
    // It hands your message to the server's local "sendmail" program.
    // It looks simple, but it is the #1 reason emails land in spam.
    
    $to      = "alice@example.com";
    $subject = "Welcome!";
    $body    = "Thanks for signing up.";
    $headers = "From: noreply@yoursite.com\r\n";
    
    // mail() returns true only if the message was HANDED OFF locally —
    // NOT if it was actually delivered. There is no authentication,
    // no encryption, and no error detail. That is the whole problem.
    if (mail($to, $subject, $body, $headers)) {
        echo "Handed to the local mail system (delivery NOT guaranteed).\n";
    } else {
        echo "mail() failed.\n";
    }
    ?>
    Output
    Handed to the local mail system (delivery NOT guaranteed).
    This is real code — run it for free atonecompiler.com/phpor in your own editor.

    The takeaway: a successful mail() call tells you almost nothing. For real, reliable email you need a proper SMTP connection — and that's what PHPMailer gives you.

    2️⃣ Authenticated SMTP with PHPMailer

    SMTP (Simple Mail Transfer Protocol) is the language mail servers speak. PHPMailer is the most popular PHP library for it — install it once with composer require phpmailer/phpmailer. It connects to a real mail server with a username and password (authentication) over a TLS-encrypted channel, so your mail is trusted and private. Notice the password comes from getenv() — never written in the code — and every send is wrapped in try/catch so a failure is handled, not ignored.

    Sending real email over authenticated, encrypted SMTP
    <?php
    // The RIGHT way: PHPMailer talks SMTP directly to a real mail server,
    // with a username/password (auth) and TLS (encryption).
    // Install once:  composer require phpmailer/phpmailer
    
    require __DIR__ . "/vendor/autoload.php";        // Composer's autoloader
    
    use PHPMailer\PHPMailer\PHPMailer;
    use PHPMailer\PHPMailer\Exception;
    
    $mail = new PHPMailer(true);   // true = throw an Exception when something fails
    
    try {
        // 1) Connect to an AUTHENTICATED, ENCRYPTED SMTP server.
        $mail->isSMTP();                                      // use SMTP, not mail()
        $mail->Host       = "smtp.sendgrid.net";             // your provider's host
        $mail->SMTPAuth   = true;                            // turn on login
        $mail->Username   = "apikey";                        // provider username
        $mail->Password   = getenv("SMTP_PASSWORD");         // 👈 from the environment
        $mail->SMTPSecure = PHPMailer::ENCRYPTION_STARTTLS;  // upgrade to TLS
        $mail->Port       = 587;                             // 587 = submission + STARTTLS
    
        // 2) Who it's from and who it's going to.
        $mail->setFrom("hello@yoursite.com", "Your App");
        $mail->addAddress("alice@example.com", "Alice");     // call again for more
    
        // 3) Send BOTH an HTML version and a plain-text fallback.
        $mail->isHTML(true);
        $mail->Subject = "Welcome to Your App";
        $mail->Body    = "<h1>Welcome!</h1><p>We're glad you're here.</p>";
        $mail->AltBody = "Welcome! We're glad you're here.";  // shown if HTML is blocked
    
        $mail->send();
        echo "Email sent.\n";        // only printed if delivery to SMTP succeeded
    } catch (Exception $e) {
        // ALWAYS handle the failure — log it, don't crash the request.
        echo "Email NOT sent: {$mail->ErrorInfo}\n";
    }
    ?>
    Output
    Email sent.
    This needs Composer (composer require phpmailer/phpmailer) and a real SMTP account, so it won't run in an online sandbox — run it on your own server. Symfony Mailer (used by Laravel) does the same job with a DSN like smtp://user:pass@host:587.

    Two details that matter for the inbox: isHTML(true) sends a styled HTML message, and AltBody provides a plain-text fallback for clients that block HTML. Always set both — HTML-only mail is more likely to be flagged as spam.

    3️⃣ Attachments, CC & BCC

    Transactional email often carries a file — an invoice, a receipt, a ticket. PHPMailer makes this one call: addAttachment(path, name) reads a file from disk and gives it a clean display name. You can also copy other people in: addCC() adds a visible copy, while addBCC() adds a hidden one the other recipients can't see.

    Emailing an invoice with an attachment
    <?php
    require __DIR__ . "/vendor/autoload.php";
    use PHPMailer\PHPMailer\PHPMailer;
    use PHPMailer\PHPMailer\Exception;
    
    $mail = new PHPMailer(true);
    $mail->isSMTP();
    $mail->Host       = "smtp.sendgrid.net";
    $mail->SMTPAuth   = true;
    $mail->Username   = "apikey";
    $mail->Password   = getenv("SMTP_PASSWORD");
    $mail->SMTPSecure = PHPMailer::ENCRYPTION_STARTTLS;
    $mail->Port       = 587;
    
    $mail->setFrom("billing@yoursite.com", "Billing");
    $mail->addAddress("alice@example.com");
    $mail->addCC("manager@yoursite.com");        // visible copy
    $mail->addBCC("audit@yoursite.com");         // hidden copy
    
    $mail->isHTML(true);
    $mail->Subject = "Your invoice #1001";
    $mail->Body    = "<p>Your invoice is attached.</p>";
    $mail->AltBody = "Your invoice is attached.";
    
    // Attach a file from disk — give it a clean name the recipient will see.
    $mail->addAttachment("/var/invoices/1001.pdf", "invoice-1001.pdf");
    
    try {
        $mail->send();
        echo "Invoice emailed with attachment.\n";
    } catch (Exception $e) {
        echo "Failed: {$mail->ErrorInfo}\n";
    }
    ?>
    Output
    Invoice emailed with attachment.
    Needs PHPMailer + an SMTP account and a real file at the attachment path — run it on your own server.

    4️⃣ Sending to Many — Queue, Don't Block

    An SMTP round-trip takes 1–3 seconds. If you send email during a web request, the user waits the whole time — and a newsletter to 1,000 people would time out completely. The fix is a queue: during the request you do a fast database write to record the job, then return immediately. A separate background worker (a script you run on a schedule, like php worker.php) pulls jobs off the queue and does the slow sending out of the user's way.

    Queue emails now, send them in the background
    <?php
    // Sending email is SLOW (an SMTP round-trip is ~1–3 seconds). If you do it
    // during the web request, the user stares at a spinner. Instead, QUEUE it:
    // save the job now (fast), and let a separate worker send it later.
    
    // --- During the web request: just enqueue and return immediately ---
    function queueEmail(PDO $db, string $to, string $subject, string $body): void {
        $stmt = $db->prepare(
            "INSERT INTO email_queue (recipient, subject, body, status)
             VALUES (?, ?, ?, 'pending')"
        );
        $stmt->execute([$to, $subject, $body]);   // a quick DB write, not an SMTP call
    }
    
    // Sign up 1,000 users to a newsletter without blocking anyone:
    $recipients = ["a@example.com", "b@example.com", "c@example.com"]; // ...1000 of them
    foreach ($recipients as $email) {
        queueEmail($db, $email, "Our newsletter", "<p>Hello!</p>");
    }
    echo "Queued " . count($recipients) . " emails. Request done.\n";
    
    // --- A separate worker (php worker.php, run on a schedule) does the sending ---
    // while ($job = fetchNextPending($db)) {
    //     sendWithPHPMailer($job);              // the slow part runs in the background
    //     markSent($db, $job['id']);
    // }
    ?>
    Output
    Queued 3 emails. Request done.
    The queueEmail() logic is plain PHP (replace $db with a real PDO connection). Laravel and Symfony give you this queue machinery out of the box.

    Now you try. The SMTP setup below is almost complete — fill in each ___ using the 👉 hint, then check it against the Output panel.

    🎯 Your turn: complete the SMTP authentication
    <?php
    // 🎯 YOUR TURN — finish the SMTP setup so the email can authenticate.
    require __DIR__ . "/vendor/autoload.php";
    use PHPMailer\PHPMailer\PHPMailer;
    
    $mail = new PHPMailer(true);
    $mail->isSMTP();
    $mail->Host = "smtp.sendgrid.net";
    
    // 1) Turn ON SMTP authentication (the server requires a login):
    $mail->SMTPAuth = ___;          // 👉 use the boolean  true
    
    // 2) Read the password from the environment, NEVER hardcode it:
    $mail->Password = ___;          // 👉 getenv("SMTP_PASSWORD")
    
    // 3) Use the standard submission port for STARTTLS:
    $mail->Port = ___;              // 👉 the number 587
    
    echo "Auth: " . ($mail->SMTPAuth ? "on" : "off") . ", port " . $mail->Port . "\n";
    
    // ✅ Expected output:
    //    Auth: on, port 587
    ?>
    Output
    Auth: on, port 587
    Fill the three ___ blanks: turn auth on with true, read the password with getenv(...), and set the port to 587.

    One more. This HTML email needs a plain-text fallback so it isn't flagged as spam. Add the missing AltBody.

    🎯 Your turn: add a plain-text fallback
    <?php
    // 🎯 YOUR TURN — give this HTML email a plain-text fallback.
    // Some clients (and spam filters) reject HTML-only mail, so ALWAYS set AltBody.
    require __DIR__ . "/vendor/autoload.php";
    use PHPMailer\PHPMailer\PHPMailer;
    
    $mail = new PHPMailer(true);
    $mail->isHTML(true);
    $mail->Subject = "Order confirmed";
    $mail->Body    = "<h1>Order confirmed</h1><p>Thank you!</p>";
    
    // Add a plain-text version of the same message:
    $mail->AltBody = ___;     // 👉 "Order confirmed. Thank you!"
    
    echo "HTML body set, plain-text fallback: " . $mail->AltBody . "\n";
    
    // ✅ Expected output:
    //    HTML body set, plain-text fallback: Order confirmed. Thank you!
    ?>
    Output
    HTML body set, plain-text fallback: Order confirmed. Thank you!
    Replace the ___ with a plain-text version of the message, e.g. "Order confirmed. Thank you!"

    5️⃣ Deliverability: SPF, DKIM & DMARC

    Even perfect PHPMailer code lands in spam if your domain doesn't vouch for it. Three DNS TXT records do that. SPF lists which servers may send for your domain. DKIM adds a cryptographic signature proving the message wasn't tampered with. DMARC tells receivers what to do when SPF or DKIM fail (e.g. quarantine), and where to email reports. Set all three and your deliverability jumps dramatically.

    DNS records to add (example)

    RecordHostValue (example)
    SPF@v=spf1 include:sendgrid.net ~all
    DKIMs1._domainkeyv=DKIM1; k=rsa; p=MIGfMA0...
    DMARC_dmarcv=DMARC1; p=quarantine; rua=mailto:dmarc@yoursite.com

    In practice you rarely manage all of this yourself. Transactional email providers — SendGrid, Mailgun, Amazon SES, Postmark — maintain trusted sending IPs, sign DKIM for you, and give you analytics, bounce handling, and webhooks. You simply point PHPMailer at their SMTP host (or call their API). For production, this is almost always the right call.

    Common Errors (and the fix)

    • Your mail lands in the spam folder — you sent it with mail() or without DNS records. Switch to authenticated SMTP (PHPMailer), add SPF, DKIM, and DMARC, and always include an AltBody plain-text fallback.
    • You hardcoded the SMTP password — never put $mail->Password = "secret123" in source code; it leaks the moment the file is shared or committed. Read it from the environment with getenv("SMTP_PASSWORD") and keep secrets in a .env file that's git-ignored.
    • Your page hangs for several seconds when a user signs up — you're sending email in the request. SMTP is slow. Queue the job (a fast DB write) and let a background worker send it, so the response returns instantly.
    • "SMTP connect() failed" with no detail — you didn't pass new PHPMailer(true), so exceptions are off. Pass true, wrap send() in try/catch, and read $mail->ErrorInfo. Missing error handling hides the real cause.
    • "550 SPF check failed" or messages bounce — your sending server isn't listed in your domain's SPF record (or DKIM/DMARC is missing). Add the DNS TXT records your email provider gives you, then verify with a tool like mail-tester.com.

    Pro Tips

    • 💡 Use port 587 with STARTTLS for modern submission. Port 465 (implicit TLS) also works; port 25 is for server-to-server and is usually blocked for sending.
    • 💡 Always send a multipart message — set both Body (HTML) and AltBody (plain text). It improves both rendering and spam scores.
    • 💡 Test deliverability before launch with mail-tester.com — it scores your SPF/DKIM/DMARC and flags spammy content out of 10.

    📋 Quick Reference — Email Systems

    Tool / TermExampleWhat It Does
    mail()mail($to, $sub, $body)Built-in, unauthenticated — avoid
    PHPMailer$mail->send()Authenticated SMTP, the standard way
    SMTPAuth / Porttrue / 587Log in over STARTTLS
    Body / AltBodyHTML / plain textSend both versions of the message
    addAttachment()("/file.pdf", "name")Attach a file from disk
    queue + workerINSERT … 'pending'Send in the background, don't block
    SPF / DKIM / DMARCDNS TXT recordsProve your mail is legitimate
    SendGrid / SESsmtp.sendgrid.netTransactional provider for scale

    Frequently Asked Questions

    Q: Why does PHP's mail() function send emails to spam?

    mail() hands your message to the server's local sendmail program with no SMTP authentication and no encryption, so receiving servers can't verify that you're allowed to send for your domain. It also returns true as soon as the message is handed off locally — that's not the same as delivery — and gives you no error detail when something goes wrong. For anything real, connect to an authenticated SMTP server with a library like PHPMailer or Symfony Mailer instead.

    Q: Should I use PHPMailer or Symfony Mailer?

    Both are excellent and do the same core job: authenticated, encrypted SMTP with HTML, attachments, and proper error handling. PHPMailer is a single, mature library you drop into any project — great for plain PHP. Symfony Mailer is the modern choice inside Symfony or Laravel apps (Laravel's Mail facade is built on it) and has a cleaner transport/DSN system. If you're not already in a framework, PHPMailer is the simplest start.

    Q: What are SPF, DKIM, and DMARC, and do I really need them?

    They're three DNS TXT records that prove your mail is legitimate. SPF lists which servers are allowed to send for your domain. DKIM adds a cryptographic signature so receivers can confirm the message wasn't altered. DMARC tells receiving servers what to do when SPF or DKIM fail (e.g. quarantine or reject) and where to send reports. Without them, Gmail and Outlook will often spam-folder or bounce your mail — so yes, you need all three for reliable delivery.

    Q: How do I send thousands of emails without slowing down my site?

    Never send email during the web request — each SMTP round-trip takes 1–3 seconds and the user is left waiting. Instead, write the message to a queue (a database table, Redis, or a system like RabbitMQ) in milliseconds and return the response immediately. A separate background worker then pulls jobs off the queue and sends them. This keeps your pages fast and lets you respect provider rate limits.

    Q: Why use a service like SendGrid, Mailgun, or Amazon SES instead of my own server?

    Running your own mail server means managing IP reputation, deliverability, bounce handling, and blocklists — a full-time job. Transactional providers like SendGrid, Mailgun, and Amazon SES maintain trusted sending IPs, handle SPF/DKIM signing for you, give you delivery analytics and webhooks, and scale to millions of emails. You just point PHPMailer at their SMTP host (or call their API). For production, this is almost always the right choice.

    Mini-Challenge: A Reusable sendEmail() Helper

    No code is filled in this time — just a brief and an outline. Write the function yourself, then run it on your own server with PHPMailer installed and an SMTP account configured. This is the exact helper you'll reuse across a real application.

    🎯 Mini-Challenge: write a sendEmail() function
    <?php
    // 🎯 MINI-CHALLENGE: a reusable sendEmail() helper.
    // No code is filled in — work from the steps, then run it on your own server.
    //
    require __DIR__ . "/vendor/autoload.php";
    use PHPMailer\PHPMailer\PHPMailer;
    use PHPMailer\PHPMailer\Exception;
    
    // 1. Write a function:  sendEmail(string $to, string $subject, string $html): bool
    // 2. Inside it, create a PHPMailer(true) and configure SMTP:
    //      - isSMTP(), Host, SMTPAuth = true, Username
    //      - Password = getenv("SMTP_PASSWORD")   (never hardcode!)
    //      - SMTPSecure = STARTTLS, Port = 587
    // 3. setFrom(), addAddress($to), isHTML(true), Subject, Body, AltBody
    // 4. Wrap send() in try/catch:
    //      - return true on success
    //      - on Exception, log $mail->ErrorInfo and return false
    // 5. Call it once and print "sent" or "failed".
    //
    // ✅ Expected output (when SMTP is configured correctly):
    //    sent
    
    // your code here
    ?>
    Build a sendEmail($to, $subject, $html) that configures authenticated SMTP, sends HTML + plain text, and returns true/false with a try/catch.

    🎉 Lesson Complete!

    • mail() is unauthenticated and unreliable — it lands in spam and hides errors
    • PHPMailer (or Symfony Mailer) sends over authenticated, TLS-encrypted SMTP
    • ✅ Always send HTML Body + plain-text AltBody, and read secrets from getenv()
    • ✅ Attach files with addAttachment(); copy people with addCC() / addBCC()
    • Queue bulk sends and let a background worker do the slow part — never block the request
    • SPF, DKIM, and DMARC prove your mail is legitimate; providers like SendGrid / SES handle scale
    • Next lesson: Image Processing — handle uploads, resize, and optimise images in PHP

    Sign up for free to track which lessons you've completed and get learning reminders.

    Previous

    Cookie & Privacy Settings

    We use cookies to improve your experience, analyze traffic, and show personalized ads. You can manage your preferences below.

    By clicking "Accept All", you consent to our use of cookies for analytics and personalized advertising. You can customize your preferences or reject non-essential cookies.

    Privacy PolicyTerms of Service

    Install LearnCodingFast

    Learn faster with the app on your home screen.