Tina4

Email with Messenger#

1. Every App Sends Email#

Your SaaS app needs signup confirmations. Password resets. Weekly digests. Attachments. HTML templates. Reliable delivery.

Email is plumbing. Every application needs it. Nobody wants to build it. SMTP configuration. Plain text fallbacks. Attachment encoding. Connection timeouts. Bounce handling. The details pile up.

Tina4's Messenger class handles all of it. Configure in .env. Create an instance. Send. Without an SMTP host, Messenger captures mail in the dev mailbox. An SMTP host enables real delivery, so staging needs an explicit safety setting.


2. Messenger Configuration via .env#

All email configuration lives in .env:

bash
TINA4_MAIL_HOST=smtp.example.comTINA4_MAIL_PORT=587TINA4_MAIL_USERNAME=your-email@example.comTINA4_MAIL_PASSWORD=your-app-passwordTINA4_MAIL_ENCRYPTION=tlsTINA4_MAIL_FROM=noreply@example.comTINA4_MAIL_FROM_NAME=My Store
VariableDescriptionCommon Values
TINA4_MAIL_HOSTSMTP server hostnamesmtp.gmail.com, smtp.mailgun.org, smtp.sendgrid.net
TINA4_MAIL_PORTSMTP port587 (TLS), 465 (SSL), 25 (unencrypted)
TINA4_MAIL_USERNAMELogin usernameUsually your email address
TINA4_MAIL_PASSWORDLogin password or app-specific passwordApp passwords for Gmail
TINA4_MAIL_ENCRYPTIONEncryption methodtls (recommended), ssl, none
TINA4_MAIL_FROMDefault "From" addressnoreply@yourdomain.com
TINA4_MAIL_FROM_NAMEDefault "From" display nameMy Store, Acme Corp
TINA4_MAIL_CAPTUREDisable SMTP and capture each message locallytrue on staging when no external delivery is allowed
TINA4_MAIL_REDIRECT_TOReplace every SMTP recipient with a safety listqa@example.com,product@example.com

Staging delivery safety#

TINA4_DEBUG controls developer tools. It does not disable SMTP delivery. Pick one explicit mail policy for staging:

bash
# No message leaves the application. Inspect it in the dev mailbox.TINA4_MAIL_CAPTURE=true

Capture wins even when an SMTP host exists. A missing SMTP host also captures mail. TINA4_MAILBOX_DIR only selects the dev mailbox directory; it does not turn capture on or off.

Use real SMTP without risking customer delivery by replacing all recipients:

bash
TINA4_MAIL_REDIRECT_TO=qa@example.com,product@example.com

Tina4 trims each comma-separated address and drops blank entries. On the SMTP path, the safety list replaces To; Tina4 clears Cc and Bcc and stores all original recipients in X-Tina4-Original-To. An unset or empty list leaves delivery unchanged. If capture and redirect are both set, capture wins and nothing reaches SMTP.

Common Provider Configurations#

Gmail:

bash
TINA4_MAIL_HOST=smtp.gmail.comTINA4_MAIL_PORT=587TINA4_MAIL_USERNAME=your-email@gmail.comTINA4_MAIL_PASSWORD=your-app-passwordTINA4_MAIL_ENCRYPTION=tls

Note: Gmail requires an "App Password" (not your regular password) when two-factor authentication is enabled.

Mailgun:

bash
TINA4_MAIL_HOST=smtp.mailgun.orgTINA4_MAIL_PORT=587TINA4_MAIL_USERNAME=postmaster@mg.yourdomain.comTINA4_MAIL_PASSWORD=your-mailgun-smtp-passwordTINA4_MAIL_ENCRYPTION=tls

SendGrid:

bash
TINA4_MAIL_HOST=smtp.sendgrid.netTINA4_MAIL_PORT=587TINA4_MAIL_USERNAME=apikeyTINA4_MAIL_PASSWORD=your-sendgrid-api-keyTINA4_MAIL_ENCRYPTION=tls

3. Constructor Override Pattern#

Different purposes need different accounts. Transactional emails from one sender. Marketing from another. Override the configuration in the constructor:

php
<?phpuse Tina4\Messenger;โ€‹// Uses .env defaults$mailer = new Messenger();โ€‹// Override specific settings$marketingMailer = new Messenger([    "host" => "smtp.mailgun.org",    "port" => 587,    "username" => "marketing@mg.yourdomain.com",    "password" => "marketing-smtp-password",    "encryption" => "tls",    "from_address" => "newsletter@yourdomain.com",    "from_name" => "My Store Newsletter"]);

The constructor accepts an associative array. Override any .env value. Unspecified keys fall back to the .env configuration.


4. Sending Plain Text Email#

The simplest email:

php
<?phpuse Tina4\Router;use Tina4\Messenger;โ€‹Router::post("/api/contact", function ($request, $response) {    $body = $request->body;โ€‹    $mailer = new Messenger();โ€‹    $result = $mailer->send(        $body["email"],                    // To        "Contact Form Submission",         // Subject        "Name: " . $body["name"] . "\n" .  // Body (plain text)        "Email: " . $body["email"] . "\n" .        "Message:\n" . $body["message"]    );โ€‹    if ($result["success"]) {        return $response->json(["message" => "Email sent successfully"]);    }โ€‹    return $response->json(["error" => "Failed to send email", "details" => $result["error"]], 500);});
bash
curl -X POST http://localhost:7145/api/contact \  -H "Content-Type: application/json" \  -d '{"name": "Alice", "email": "alice@example.com", "message": "I love your products!"}'
json
{"message":"Email sent successfully"}

The send() method returns an array with "success" (boolean) and "error" (string, present only on failure).

The send() Method#

php
$mailer->send($to, $subject, $body, $options = []);
  • $to: Recipient email address (string)
  • $subject: Email subject line (string)
  • $body: Email body content (string -- plain text or HTML)
  • $options: Optional settings (array)

5. Sending HTML Email with Text Fallback#

Most emails should be HTML with a plain text fallback. Some email clients do not render HTML:

php
<?phpuse Tina4\Messenger;โ€‹$mailer = new Messenger();โ€‹$htmlBody = '<html><body style="font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto;">    <div style="background: #1a1a2e; color: white; padding: 20px; text-align: center;">        <h1 style="margin: 0;">Welcome to My Store!</h1>    </div>    <div style="padding: 20px;">        <p>Hi Alice,</p>        <p>Thank you for creating your account. We are excited to have you!</p>        <p>Here is what you can do next:</p>        <ul>            <li>Browse our <a href="https://mystore.com/products">product catalog</a></li>            <li>Set up your <a href="https://mystore.com/profile">profile</a></li>            <li>Check out our <a href="https://mystore.com/deals">current deals</a></li>        </ul>        <p>If you have any questions, reply to this email and we will get back to you within 24 hours.</p>        <p>Cheers,<br>The My Store Team</p>    </div>    <div style="background: #f5f5f5; padding: 12px; text-align: center; font-size: 12px; color: #888;">        <p>You received this because you signed up at mystore.com</p>    </div></body></html>';โ€‹$textBody = "Hi Alice,\n\n" .    "Thank you for creating your account. We are excited to have you!\n\n" .    "Here is what you can do next:\n" .    "- Browse our product catalog: https://mystore.com/products\n" .    "- Set up your profile: https://mystore.com/profile\n" .    "- Check out our current deals: https://mystore.com/deals\n\n" .    "If you have any questions, reply to this email.\n\n" .    "Cheers,\nThe My Store Team";โ€‹$result = $mailer->send(    "alice@example.com",    "Welcome to My Store!",    $htmlBody,    ["text_body" => $textBody]);

The text_body option provides the plain text fallback. Email clients that cannot render HTML show the text version instead.


6. Adding Attachments#

Attach files by path:

php
<?phpuse Tina4\Messenger;โ€‹$mailer = new Messenger();โ€‹$result = $mailer->send(    "accounting@example.com",    "Monthly Invoice #1042",    "<h2>Invoice #1042</h2><p>Please find the invoice attached.</p>",    [        "attachments" => [            "/path/to/invoices/invoice-1042.pdf",            "/path/to/reports/monthly-summary.csv"        ]    ]);

Each attachment is an absolute file path. Tina4 reads the file, determines the MIME type, and encodes it for transmission.

Inline Attachments with Custom Names#

php
$result = $mailer->send(    "alice@example.com",    "Your Export",    "<p>Here is your data export.</p>",    [        "attachments" => [            [                "path" => "/tmp/export-20260322.csv",                "name" => "my-store-export.csv"  // Custom filename            ]        ]    ]);

The recipient sees my-store-export.csv regardless of the actual filename on disk.


7. CC and BCC#

php
<?phpuse Tina4\Messenger;โ€‹$mailer = new Messenger();โ€‹$result = $mailer->send(    "alice@example.com",    "Team Meeting Notes",    "<p>Here are the notes from today's meeting.</p>",    [        "cc" => ["bob@example.com", "charlie@example.com"],        "bcc" => ["manager@example.com"],        "reply_to" => "alice@example.com"    ]);
  • cc: Array of email addresses to carbon copy. All recipients see CC addresses.
  • bcc: Array of email addresses to blind carbon copy. Recipients cannot see BCC addresses.
  • reply_to: When the recipient clicks "Reply," this address is used instead of the "From" address.

8. Custom Headers#

Add custom headers to outgoing emails with addHeader():

php
<?phpuse Tina4\Messenger;โ€‹$mailer = new Messenger();โ€‹$mailer->addHeader("X-Priority", "1");$mailer->addHeader("X-Mailer", "Tina4 Messenger");$mailer->addHeader("List-Unsubscribe", "<https://mystore.com/unsubscribe?token=abc123>");โ€‹$result = $mailer->send(    "alice@example.com",    "Important Update",    "<p>Your account requires attention.</p>");

Headers persist for the lifetime of the Messenger instance. Create a fresh instance if you need different headers for the next email.

Common custom headers:

HeaderPurpose
X-Priority1 (high), 3 (normal), 5 (low)
X-MailerIdentifies the sending application
List-UnsubscribeOne-click unsubscribe link (required by some providers)
X-Entity-Ref-IDPrevents email threading in some clients
Precedencebulk for mass emails, list for mailing lists

9. Reading Inbox via IMAP#

Tina4's Messenger reads emails via IMAP:

bash
TINA4_MAIL_IMAP_HOST=imap.example.comTINA4_MAIL_IMAP_PORT=993TINA4_MAIL_IMAP_USERNAME=support@example.comTINA4_MAIL_IMAP_PASSWORD=your-imap-passwordTINA4_MAIL_IMAP_ENCRYPTION=ssl
php
<?phpuse Tina4\Router;use Tina4\Messenger;โ€‹Router::get("/api/inbox", function ($request, $response) {    $mailer = new Messenger();โ€‹    $emails = $mailer->getInbox([        "limit" => 20,        "unread_only" => true    ]);โ€‹    $messages = [];    foreach ($emails as $email) {        $messages[] = [            "id" => $email["id"],            "from" => $email["from"],            "subject" => $email["subject"],            "date" => $email["date"],            "preview" => substr($email["text_body"], 0, 200),            "has_attachments" => !empty($email["attachments"])        ];    }โ€‹    return $response->json(["messages" => $messages, "count" => count($messages)]);});
bash
curl http://localhost:7145/api/inbox
json
{  "messages": [    {      "id": "12345",      "from": "customer@example.com",      "subject": "Order question",      "date": "2026-03-22T10:30:00+00:00",      "preview": "Hi, I have a question about my recent order...",      "has_attachments": false    }  ],  "count": 1}

Reading a Specific Email#

php
Router::get("/api/inbox/{id}", function ($request, $response) {    $mailer = new Messenger();    $emailId = $request->params["id"];โ€‹    $email = $mailer->getMessage($emailId);โ€‹    if ($email === null) {        return $response->json(["error" => "Email not found"], 404);    }โ€‹    return $response->json([        "id" => $email["id"],        "from" => $email["from"],        "to" => $email["to"],        "subject" => $email["subject"],        "date" => $email["date"],        "html_body" => $email["html_body"],        "text_body" => $email["text_body"],        "attachments" => array_map(fn($a) => [            "name" => $a["name"],            "size" => $a["size"],            "type" => $a["type"]        ], $email["attachments"] ?? [])    ]);});

Searching Emails#

Search the inbox with IMAP search criteria:

php
$mailer = new Messenger();โ€‹// Search by subject$results = $mailer->searchInbox("SUBJECT \"invoice\"");โ€‹// Search by sender$results = $mailer->searchInbox("FROM \"alice@example.com\"");โ€‹// Search by date range$results = $mailer->searchInbox("SINCE \"22-Mar-2026\" BEFORE \"29-Mar-2026\"");โ€‹// Unread messages from a specific sender$results = $mailer->searchInbox("UNSEEN FROM \"support@example.com\"");

The search string follows IMAP search syntax. Common operators: FROM, TO, SUBJECT, BODY, SINCE, BEFORE, SEEN, UNSEEN.

Marking Messages#

php
$mailer = new Messenger();โ€‹// Mark as read$mailer->markRead("12345");โ€‹// Mark as unread$mailer->markUnread("12345");

Pass the message ID returned by getInbox() or searchInbox().

Deleting Messages#

php
$mailer = new Messenger();$mailer->deleteMessage("12345");

The message moves to the Trash folder (or is marked for deletion, depending on the IMAP server).

Listing Folders#

php
$mailer = new Messenger();$folders = $mailer->getFolders();// ["INBOX", "Sent", "Drafts", "Trash", "Spam", "Archive"]

Reading from a Specific Folder#

php
$mailer = new Messenger();$sentEmails = $mailer->getInbox([    "folder" => "Sent",    "limit" => 10]);

Pass the folder option to read from any IMAP folder. The default is "INBOX".


10. Dev Mailbox Capture#

Messenger captures outgoing mail when no SMTP host exists or when TINA4_MAIL_CAPTURE=true. TINA4_DEBUG only makes the dev dashboard available; it does not change delivery.

Navigate to /__dev and find the "Mail" section. You see:

  • Every email "sent" during the current session
  • The To, CC, and BCC addresses
  • The subject and body (HTML and plain text)
  • Attachments (viewable inline)
  • The timestamp

Test email without configuring a real SMTP server. Enable TINA4_DEBUG=true to inspect captured messages in the dashboard.

Sending from a development environment#

Configure an SMTP host and leave TINA4_MAIL_CAPTURE unset or false. The TINA4_DEBUG value does not matter to delivery. To test SMTP without reaching customers, set TINA4_MAIL_REDIRECT_TO to controlled inboxes.


11. Using Templates for Email Content#

Hardcoding HTML in PHP strings is fragile and hard to read. Use Frond templates for email content.

Create src/templates/emails/welcome.html:

html
<!DOCTYPE html><html><head>    <meta charset="UTF-8"></head><body style="font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto; background: #f5f5f5; padding: 20px;">    <div style="background: #1a1a2e; color: white; padding: 24px; text-align: center; border-radius: 8px 8px 0 0;">        <h1 style="margin: 0; font-size: 24px;">Welcome, {{ name }}!</h1>    </div>    <div style="background: white; padding: 24px; border-radius: 0 0 8px 8px;">        <p>Hi {{ name }},</p>        <p>Your account has been created successfully. Here are your details:</p>         <table style="width: 100%; border-collapse: collapse; margin: 16px 0;">            <tr>                <td style="padding: 8px; border-bottom: 1px solid #eee; font-weight: bold;">Email</td>                <td style="padding: 8px; border-bottom: 1px solid #eee;">{{ email }}</td>            </tr>            <tr>                <td style="padding: 8px; border-bottom: 1px solid #eee; font-weight: bold;">Account ID</td>                <td style="padding: 8px; border-bottom: 1px solid #eee;">#{{ user_id }}</td>            </tr>            <tr>                <td style="padding: 8px; border-bottom: 1px solid #eee; font-weight: bold;">Signed Up</td>                <td style="padding: 8px; border-bottom: 1px solid #eee;">{{ signed_up_at }}</td>            </tr>        </table>         <p>Get started by exploring:</p>        <ul>            <li><a href="{{ base_url }}/products" style="color: #1a1a2e;">Our product catalog</a></li>            <li><a href="{{ base_url }}/profile" style="color: #1a1a2e;">Your profile settings</a></li>        </ul>         {% if promo_code %}            <div style="background: #d4edda; padding: 16px; border-radius: 4px; margin: 16px 0;">                <strong>Special offer!</strong> Use code <code>{{ promo_code }}</code> for 10% off your first order.            </div>        {% endif %}         <p>Cheers,<br>The {{ app_name }} Team</p>    </div>     <div style="text-align: center; padding: 12px; color: #888; font-size: 12px;">        <p>You received this because you signed up at {{ app_name }}.</p>        <p><a href="{{ base_url }}/unsubscribe?token={{ unsubscribe_token }}" style="color: #888;">Unsubscribe</a></p>    </div></body></html>

Rendering and Sending#

php
<?phpuse Tina4\Router;use Tina4\Messenger;use Tina4\Frond;โ€‹Router::post("/api/register", function ($request, $response) {    $body = $request->body;โ€‹    // Create user (database logic)    $userId = 42;โ€‹    // Render the email template    $emailData = [        "name" => $body["name"],        "email" => $body["email"],        "user_id" => $userId,        "signed_up_at" => date("F j, Y"),        "base_url" => $_ENV["APP_URL"] ?? "http://localhost:7145",        "app_name" => "My Store",        "promo_code" => "WELCOME10",        "unsubscribe_token" => bin2hex(random_bytes(16))    ];โ€‹    $htmlBody = Frond::render("emails/welcome.html", $emailData);โ€‹    // Send the email    $mailer = new Messenger();    $result = $mailer->send(        $body["email"],        "Welcome to My Store, " . $body["name"] . "!",        $htmlBody,        [            "text_body" => "Hi " . $body["name"] . ",\n\nWelcome to My Store! Your account (#" . $userId . ") has been created.\n\nCheers,\nThe My Store Team"        ]    );โ€‹    return $response->json([        "message" => "Registration successful",        "email_sent" => $result["success"],        "user_id" => $userId    ], 201);});
bash
curl -X POST http://localhost:7145/api/register \  -H "Content-Type: application/json" \  -d '{"name": "Alice", "email": "alice@example.com", "password": "securePass123"}'
json
{  "message": "Registration successful",  "email_sent": true,  "user_id": 42}

With TINA4_MAIL_CAPTURE=true, the email stays in the dev mailbox instead of reaching a real inbox. Enable TINA4_DEBUG=true to inspect the rendered HTML and verify the template values in the dashboard.


12. Sending Email via Queues#

In production, do not send email inside a route handler. The SMTP handshake takes time. The user waits. Push email work to the queue system (Chapter 12):

php
<?phpuse Tina4\Router;use Tina4\Queue;use Tina4\Messenger;use Tina4\Frond;โ€‹// In the route handler, just queue the emailRouter::post("/api/register", function ($request, $response) {    $body = $request->body;    $userId = 42; // Simulatedโ€‹    Queue::produce("emails", [        "template" => "emails/welcome.html",        "to" => $body["email"],        "subject" => "Welcome to My Store, " . $body["name"] . "!",        "data" => [            "name" => $body["name"],            "email" => $body["email"],            "user_id" => $userId,            "signed_up_at" => date("F j, Y"),            "base_url" => $_ENV["APP_URL"] ?? "http://localhost:7145",            "app_name" => "My Store",            "promo_code" => "WELCOME10"        ]    ]);โ€‹    return $response->json(["message" => "Registration successful", "user_id" => $userId], 201);});โ€‹// The consumer sends the actual emailQueue::consume("emails", function ($job) {    $payload = $job->payload;โ€‹    $htmlBody = Frond::render($payload["template"], $payload["data"]);โ€‹    $mailer = new Messenger();    $result = $mailer->send(        $payload["to"],        $payload["subject"],        $htmlBody    );โ€‹    if (!$result["success"]) {        error_log("Email failed: " . $result["error"]);        return false; // Retry    }โ€‹    error_log("Email sent to " . $payload["to"]);    return true;});

The route handler returns in under 50 milliseconds. The queue worker sends the email in the background. Automatic retries handle temporary SMTP failures.


13. Exercise: Build a Contact Form with Email Notification#

Build a contact form that sends an email notification when submitted.

Requirements#

  1. Create a GET /contact page that renders a contact form with fields: name, email, subject, and message
  2. Create a POST /contact endpoint that:

    • Validates all fields are present
    • Sends an email notification to the site admin (admin@example.com)
    • The email should include all form fields, formatted with HTML
    • Shows a flash message on success
    • Redirects back to the contact page
  3. Create an email template at src/templates/emails/contact-notification.html that formats the contact submission

Test with:#

bash
# View the formcurl http://localhost:7145/contactโ€‹# Submit the formcurl -X POST http://localhost:7145/contact \  -H "Content-Type: application/json" \  -d '{"name": "Bob", "email": "bob@example.com", "subject": "Product inquiry", "message": "Do you ship internationally?"}'โ€‹# Check the dev dashboard for the intercepted email# Navigate to http://localhost:7145/__dev

14. Solution#

Create src/templates/emails/contact-notification.html:

html
<!DOCTYPE html><html><head><meta charset="UTF-8"></head><body style="font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto;">    <div style="background: #1a1a2e; color: white; padding: 16px; text-align: center;">        <h2 style="margin: 0;">New Contact Form Submission</h2>    </div>    <div style="padding: 20px; background: white;">        <table style="width: 100%; border-collapse: collapse;">            <tr>                <td style="padding: 10px; border-bottom: 1px solid #eee; font-weight: bold; width: 100px;">From</td>                <td style="padding: 10px; border-bottom: 1px solid #eee;">{{ name }} ({{ email }})</td>            </tr>            <tr>                <td style="padding: 10px; border-bottom: 1px solid #eee; font-weight: bold;">Subject</td>                <td style="padding: 10px; border-bottom: 1px solid #eee;">{{ subject }}</td>            </tr>            <tr>                <td style="padding: 10px; border-bottom: 1px solid #eee; font-weight: bold;">Date</td>                <td style="padding: 10px; border-bottom: 1px solid #eee;">{{ submitted_at }}</td>            </tr>        </table>         <div style="margin-top: 16px; padding: 16px; background: #f8f9fa; border-radius: 4px;">            <h3 style="margin-top: 0;">Message</h3>            <p style="white-space: pre-wrap;">{{ message }}</p>        </div>         <p style="margin-top: 16px; color: #888; font-size: 12px;">            Reply directly to this email to respond to {{ name }} at {{ email }}.        </p>    </div></body></html>

Create src/templates/contact.html:

html
{% extends "base.html" %} {% block title %}Contact Us{% endblock %} {% block content %}    <h1>Contact Us</h1>     {% if flash %}        <div style="padding: 12px; border-radius: 4px; margin-bottom: 16px;            {% if flash.type == 'success' %}background: #d4edda; color: #155724;{% endif %}            {% if flash.type == 'error' %}background: #f8d7da; color: #721c24;{% endif %}">            {{ flash.message }}        </div>    {% endif %}     <form method="POST" action="/contact" style="max-width: 500px;">        <div style="margin-bottom: 12px;">            <label for="name" style="display: block; margin-bottom: 4px; font-weight: bold;">Name</label>            <input type="text" name="name" id="name" required                   style="width: 100%; padding: 8px; border: 1px solid #ddd; border-radius: 4px;">        </div>         <div style="margin-bottom: 12px;">            <label for="email" style="display: block; margin-bottom: 4px; font-weight: bold;">Email</label>            <input type="email" name="email" id="email" required                   style="width: 100%; padding: 8px; border: 1px solid #ddd; border-radius: 4px;">        </div>         <div style="margin-bottom: 12px;">            <label for="subject" style="display: block; margin-bottom: 4px; font-weight: bold;">Subject</label>            <input type="text" name="subject" id="subject" required                   style="width: 100%; padding: 8px; border: 1px solid #ddd; border-radius: 4px;">        </div>         <div style="margin-bottom: 12px;">            <label for="message" style="display: block; margin-bottom: 4px; font-weight: bold;">Message</label>            <textarea name="message" id="message" rows="6" required                      style="width: 100%; padding: 8px; border: 1px solid #ddd; border-radius: 4px;"></textarea>        </div>         <button type="submit"                style="padding: 10px 20px; background: #1a1a2e; color: white; border: none; border-radius: 4px; cursor: pointer;">            Send Message        </button>    </form>{% endblock %}

Create src/routes/contact.php:

php
<?phpuse Tina4\Router;use Tina4\Messenger;use Tina4\Frond;โ€‹Router::get("/contact", function ($request, $response) {    $flash = $request->session["_flash"] ?? null;    unset($request->session["_flash"]);โ€‹    return $response->render("contact.html", [        "flash" => $flash    ]);});โ€‹Router::post("/contact", function ($request, $response) {    $body = $request->body;โ€‹    // Validate    $errors = [];    if (empty($body["name"])) $errors[] = "Name is required";    if (empty($body["email"])) $errors[] = "Email is required";    if (empty($body["subject"])) $errors[] = "Subject is required";    if (empty($body["message"])) $errors[] = "Message is required";โ€‹    if (!empty($errors)) {        $request->session["_flash"] = [            "type" => "error",            "message" => "Please fill in all fields: " . implode(", ", $errors)        ];        return $response->redirect("/contact");    }โ€‹    // Render the email template    $htmlBody = Frond::render("emails/contact-notification.html", [        "name" => $body["name"],        "email" => $body["email"],        "subject" => $body["subject"],        "message" => $body["message"],        "submitted_at" => date("F j, Y \a\\t g:i A")    ]);โ€‹    // Send the email    $mailer = new Messenger();    $adminEmail = $_ENV["ADMIN_EMAIL"] ?? "admin@example.com";โ€‹    $result = $mailer->send(        $adminEmail,        "Contact Form: " . $body["subject"],        $htmlBody,        [            "reply_to" => $body["email"],            "text_body" => "Contact form submission from " . $body["name"] . " (" . $body["email"] . "):\n\n" .                          "Subject: " . $body["subject"] . "\n\n" .                          "Message:\n" . $body["message"]        ]    );โ€‹    if ($result["success"]) {        $request->session["_flash"] = [            "type" => "success",            "message" => "Thank you for your message! We will get back to you shortly."        ];    } else {        $request->session["_flash"] = [            "type" => "error",            "message" => "Sorry, there was a problem sending your message. Please try again later."        ];    }โ€‹    return $response->redirect("/contact");});

Testing:

  1. Open http://localhost:7145/contact in your browser
  2. Fill in the form and submit
  3. You should see a green "Thank you" flash message
  4. Open http://localhost:7145/__dev to see the intercepted email
  5. The email should show the sender details, subject, message, and formatted HTML

API test:

bash
curl -X POST http://localhost:7145/contact \  -H "Content-Type: application/json" \  -d '{"name": "Bob", "email": "bob@example.com", "subject": "Product inquiry", "message": "Do you ship internationally?"}' \  -c cookies.txt -b cookies.txt

The response is a 302 redirect to /contact. Follow the redirect to see the flash message:

bash
curl http://localhost:7145/contact -b cookies.txt

The HTML response includes the success flash message.


15. Gotchas#

1. Gmail Blocks "Less Secure" Apps#

Problem: Sending via Gmail fails with "Authentication failed" or "Username and Password not accepted."

Cause: Gmail blocks SMTP access from apps that do not use OAuth2 by default. Your regular password will not work with two-factor authentication enabled.

Fix: Generate an "App Password" in your Google Account settings (Security > 2-Step Verification > App Passwords). Use this 16-character password as TINA4_MAIL_PASSWORD. It is separate from your regular Google password.

2. Emails Go to Spam#

Problem: Emails are delivered but land in the spam folder.

Cause: Your sending domain lacks proper DNS records (SPF, DKIM, DMARC), or you send from a free email provider (Gmail, Yahoo).

Fix: Use a dedicated sending domain with proper DNS records. Set up SPF, DKIM, and DMARC. Or use a transactional email service -- Mailgun, SendGrid, Amazon SES -- that manages email reputation for you.

3. HTML Email Looks Broken#

Problem: The email renders in Gmail but breaks in Outlook or Apple Mail.

Cause: Email clients have wildly different HTML/CSS support. CSS flexbox, grid, and many modern properties do not work in email.

Fix: Use inline styles. Not external stylesheets, not <style> blocks. Use table-based layouts for complex designs. Test with an email preview tool. Keep it simple. Most transactional emails do not need elaborate designs.

4. Attachment File Not Found#

Problem: $mailer->send() returns an error about a missing file.

Cause: The attachment path is relative or incorrect. The file does not exist at the specified location.

Fix: Use absolute paths for attachments. Verify the file exists before calling send(): if (!file_exists($path)) { ... }. If the file is generated at runtime (a PDF, for example), make sure the generation completes before sending.

5. Dev Mode Intercepts Emails Silently#

Problem: You set up SMTP. No emails arrive. No errors either.

Cause: TINA4_MAIL_CAPTURE=true or a missing SMTP host sends mail to the dev mailbox. TINA4_DEBUG does not control delivery.

Fix: Check /__dev for captured mail. To use SMTP, configure TINA4_MAIL_HOST and remove TINA4_MAIL_CAPTURE. On staging, set TINA4_MAIL_REDIRECT_TO so tests cannot reach customer addresses.

6. Email Template Variables Not Substituted#

Problem: The email body shows {{ name }} instead of the user's name.

Cause: You passed the raw template file content instead of rendering it through Frond. The template engine never ran.

Fix: Use Frond::render("emails/template.html", $data) to render the template with variables substituted. Do not use file_get_contents(). That gives you the raw template source.

7. Connection Timeout on Send#

Problem: $mailer->send() hangs for 30 seconds and then fails with a timeout error.

Cause: The SMTP server is unreachable. The port is blocked by a firewall. The hostname is wrong.

Fix: Test SMTP connectivity: telnet smtp.example.com 587. Verify the hostname, port, and encryption settings. Check that your firewall allows outbound connections on the SMTP port. Corporate firewalls often block ports 587 and 465. Ask your network administrator.