Chapter 16: Email with Messenger#
1. Every App Sends Email#
Signup confirmations. Password resets. Weekly digests. Invoices with PDF attachments. Every application needs email. Nobody enjoys building it.
SMTP configuration. Plain text fallbacks. Attachment encoding. Connection timeouts. The details pile up fast. Tina4's Messenger class absorbs all of it. Configure through .env. Create an instance. Send. In development mode, Messenger intercepts every outgoing email and displays it in the dev dashboard -- no real SMTP server required.
2. Messenger Configuration via .env#
All email configuration lives in .env:
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| Variable | Description | Common Values |
|---|---|---|
TINA4_MAIL_HOST | SMTP server hostname | smtp.gmail.com, smtp.mailgun.org, smtp.sendgrid.net |
TINA4_MAIL_PORT | SMTP port | 587 (TLS), 465 (SSL), 25 (unencrypted) |
TINA4_MAIL_USERNAME | Login username | Usually your email address |
TINA4_MAIL_PASSWORD | Login password or app-specific password | App passwords for Gmail |
TINA4_MAIL_ENCRYPTION | Encryption method | tls (recommended), ssl, none |
TINA4_MAIL_FROM | Default "From" address | noreply@yourdomain.com |
TINA4_MAIL_FROM_NAME | Default "From" display name | My Store, Acme Corp |
Common Provider Configurations#
Gmail:
TINA4_MAIL_HOST=smtp.gmail.comTINA4_MAIL_PORT=587TINA4_MAIL_USERNAME=your-email@gmail.comTINA4_MAIL_PASSWORD=your-app-passwordTINA4_MAIL_ENCRYPTION=tlsGmail requires an "App Password" (not your regular password) when two-factor authentication is enabled.
Mailgun:
TINA4_MAIL_HOST=smtp.mailgun.orgTINA4_MAIL_PORT=587TINA4_MAIL_USERNAME=postmaster@mg.yourdomain.comTINA4_MAIL_PASSWORD=your-mailgun-smtp-passwordTINA4_MAIL_ENCRYPTION=tlsSendGrid:
TINA4_MAIL_HOST=smtp.sendgrid.netTINA4_MAIL_PORT=587TINA4_MAIL_USERNAME=apikeyTINA4_MAIL_PASSWORD=your-sendgrid-api-keyTINA4_MAIL_ENCRYPTION=tls3. Constructor Override Pattern#
Different emails need different SMTP accounts. Transactional emails from one server. Marketing from another. Override the configuration in the constructor:
import { Messenger } from "tina4-nodejs";โ// Uses .env defaultsconst mailer = new Messenger();โ// Override specific settingsconst marketingMailer = new Messenger({ host: "smtp.mailgun.org", port: 587, username: "marketing@mg.yourdomain.com", password: "marketing-smtp-password", encryption: "tls", fromAddress: "newsletter@yourdomain.com", fromName: "My Store Newsletter"});Constructor arguments take priority over .env values. Any argument you omit falls back to the environment variable.
4. Sending Plain Text Email#
The simplest email:
import { Router, Messenger } from "tina4-nodejs";โRouter.post("/api/contact", async (req, res) => { const body = req.body; const mailer = new Messenger();โ const result = await mailer.send({ to: body.email, subject: "Contact Form Submission", body: `Name: ${body.name}\nEmail: ${body.email}\nMessage:\n${body.message}` });โ if (result.success) { return res.json({ message: "Email sent successfully" }); } return res.status(500).json({ error: "Failed to send email", details: result.error });});curl -X POST http://localhost:7148/api/contact \ -H "Content-Type: application/json" \ -d '{"name": "Alice", "email": "alice@example.com", "message": "I love your products!"}'{"message":"Email sent successfully"}The send() method returns an object with three keys: success (boolean), error (string or null), and messageId (string or null).
The send() Method Signature#
await mailer.send({ to, // Recipient(s) -- string or array of strings subject, // Email subject line body, // Email body (plain text or HTML) html, // If true, body is treated as HTML (default: false) text, // Plain text alternative (when body is HTML) cc, // CC recipient(s) -- string or array bcc, // BCC recipient(s) -- string or array replyTo, // Reply-To address attachments, // Array of file paths or objects headers // Additional email headers (object)});5. Sending HTML Email with Text Fallback#
Most emails should carry HTML with a plain text fallback. Email clients that cannot render HTML display the text version instead:
import { Messenger } from "tina4-nodejs";โconst mailer = new Messenger();โconst 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>`;โconst textBody = [ "Hi Alice,", "", "Thank you for creating your account. We are excited to have you!", "", "Here is what you can do next:", "- Browse our product catalog: https://mystore.com/products", "- Set up your profile: https://mystore.com/profile", "- Check out our current deals: https://mystore.com/deals", "", "If you have any questions, reply to this email.", "", "Cheers,", "The My Store Team"].join("\n");โconst result = await mailer.send({ to: "alice@example.com", subject: "Welcome to My Store!", body: htmlBody, html: true, text: textBody});Pass html: true to tell Messenger the body contains HTML. The text parameter provides the plain text alternative. Messenger builds a multipart/alternative message that carries both versions.
6. Adding Attachments#
Attach files by providing their paths:
import { Messenger } from "tina4-nodejs";โconst mailer = new Messenger();โconst result = await mailer.send({ to: "accounting@example.com", subject: "Monthly Invoice #1042", body: "<h2>Invoice #1042</h2><p>Please find the invoice attached.</p>", html: true, attachments: [ "/path/to/invoices/invoice-1042.pdf", "/path/to/reports/monthly-summary.csv" ]});Messenger reads each file, determines its MIME type, and encodes it for email transmission. Each entry can be a file path string or an object for more control.
Attachments with Custom Names and Binary Content#
const result = await mailer.send({ to: "alice@example.com", subject: "Your Export", body: "<p>Here is your data export.</p>", html: true, attachments: [ { filename: "my-store-export.csv", content: csvBuffer, mime: "text/csv" } ]});The object format accepts filename (display name), content (Buffer or raw bytes), and mime (MIME type string). The recipient sees the file named my-store-export.csv regardless of how it was generated.
7. CC, BCC, and Reply-To#
import { Messenger } from "tina4-nodejs";โconst mailer = new Messenger();โconst result = await mailer.send({ to: "alice@example.com", subject: "Team Meeting Notes", body: "<p>Here are the notes from today's meeting.</p>", html: true, cc: ["bob@example.com", "charlie@example.com"], bcc: ["manager@example.com"], replyTo: "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.
- replyTo: When the recipient clicks "Reply", this address fills the "To" field instead of the "From" address.
Both cc and bcc accept a single string or an array of strings.
8. Custom Headers#
Messenger supports two methods for adding headers. The addHeader() method sets a default header on all emails sent by that instance. The headers property on send() sets headers for a single email.
Default Headers on an Instance#
import { Messenger } from "tina4-nodejs";โconst mailer = new Messenger();mailer.addHeader("X-App-Name", "My Store");mailer.addHeader("X-Environment", "production");โ// Every email from this instance carries both headersawait mailer.send({ to: "alice@example.com", subject: "Test", body: "Hello" });Per-Email Headers#
const result = await mailer.send({ to: "customer@example.com", subject: "Your Support Ticket #123", body: "We are looking into your issue.", replyTo: "support@mystore.com", headers: { "X-Ticket-Id": "123", "X-Priority": "1", "X-Mailer": "Tina4 Messenger" }});Per-email headers merge with default headers. If both define the same key, the per-email value wins.
Custom headers serve several purposes. Tracking headers like X-Ticket-Id let you correlate emails with support tickets. Priority headers influence some email clients' display. Bulk-sending headers like Precedence: bulk help mail servers classify newsletters.
| Header | Purpose |
|---|---|
X-Priority | Email priority (1=high, 3=normal, 5=low) |
X-Mailer | Identifies the sending application |
List-Unsubscribe | One-click unsubscribe link (required for bulk email) |
X-Entity-Ref-ID | Prevents threading in some email clients |
References | Controls email threading |
9. Reading Inbox via IMAP#
Messenger reads email through IMAP. Configure the IMAP server in .env:
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=sslYou can also override the IMAP host and port in the constructor:
const mailer = new Messenger({ imapHost: "imap.gmail.com", imapPort: 993 });Listing Inbox Messages#
The getInbox() method fetches message headers from the mailbox:
import { Router, Messenger } from "tina4-nodejs";โRouter.get("/api/inbox", async (req, res) => { const mailer = new Messenger();โ const emails = await mailer.getInbox({ limit: 20, offset: 0 });โ const messages = emails.map(email => ({ uid: email.uid, from: email.from, subject: email.subject, date: email.date, snippet: email.snippet, seen: email.seen }));โ return res.json({ messages, count: messages.length });});curl http://localhost:7148/api/inbox{ "messages": [ { "uid": "12345", "from": "customer@example.com", "subject": "Order question", "date": "2026-03-22T10:30:00+00:00", "snippet": "Hi, I have a question about my recent order...", "seen": false } ], "count": 1}The getInbox() method returns messages newest-first. Each message contains uid, subject, from, to, date, snippet (first 150 characters of the body), and seen (boolean).
Reading a Specific Message#
Router.get("/api/inbox/:uid", async (req, res) => { const mailer = new Messenger(); const uid = req.params.uid;โ const email = await mailer.read(uid, { markRead: true });โ if (!email) { return res.status(404).json({ error: "Email not found" }); }โ return res.json({ uid: email.uid, from: email.from, to: email.to, cc: email.cc, subject: email.subject, date: email.date, bodyHtml: email.bodyHtml, bodyText: email.bodyText, attachments: (email.attachments ?? []).map(a => ({ filename: a.filename, size: a.size, contentType: a.contentType })) });});The read() method fetches the full message including body and attachments. Pass { markRead: false } to leave the message unread.
Searching Messages#
Router.get("/api/inbox/search", async (req, res) => { const mailer = new Messenger();โ const results = await mailer.search({ subject: req.query.q as string, sender: req.query.from as string, unseenOnly: req.query.unread === "true", limit: 20 });โ return res.json({ messages: results, count: results.length });});The search() method accepts subject, sender, since (date string), before, and unseenOnly as filters. All filters combine with AND logic.
Other IMAP Operations#
const mailer = new Messenger();โ// Count unread messagesconst count = await mailer.getUnreadCount();โ// Mark a message as readawait mailer.markRead("12345");โ// Mark a message as unreadawait mailer.markUnread("12345");โ// Delete a messageawait mailer.deleteEmail("12345");โ// List all mailbox foldersconst folders = await mailer.getFolders();// ["INBOX", "Sent", "Drafts", "Trash", "Spam"]Read from a Specific Folder#
const sent = await mailer.getInbox({ folder: "Sent", limit: 10 });Testing IMAP Connectivity#
const mailer = new Messenger();const result = await mailer.testImapConnection();if (result.success) { console.log("IMAP connection works");} else { console.log(`IMAP failed: ${result.error}`);}10. Dev Mode: Email Interception#
When TINA4_DEBUG=true, Tina4 intercepts all outgoing emails and stores them locally. No real recipients receive anything. No accidental emails during development.
Navigate to /__dev and look for the "Mail" section. You see:
- Every email "sent" during the current session
- The To, CC, and BCC addresses
- The subject and body (both HTML and plain text)
- Attachments (viewable inline)
- The timestamp
This is invaluable for testing email functionality without configuring a real SMTP server. Use createMessenger() instead of new Messenger() to get automatic dev-mode interception:
import { createMessenger } from "tina4-nodejs";โconst mailer = createMessenger();// In dev mode: captures locally// In production: sends via SMTPDisabling Interception#
If you need to test real email delivery during development, override the interception:
TINA4_MAILBOX_DIR=falseWith this set, emails reach real recipients even when TINA4_DEBUG=true. Use with caution -- you do not want to accidentally email your entire user base from a dev machine.
11. Using Templates for Email Content#
Hardcoded HTML in string literals is ugly and hard to maintain. Templates fix this.
Create src/templates/emails/welcome.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#
import { Router, Messenger } from "tina4-nodejs";import { Frond } from "tina4-nodejs/frond";import crypto from "crypto";โRouter.post("/api/register", async (req, res) => { const body = req.body;โ // Create user (database logic) const userId = 42;โ // Render the email template const emailData = { name: body.name, email: body.email, user_id: userId, signed_up_at: new Date().toLocaleDateString("en-US", { year: "numeric", month: "long", day: "numeric" }), base_url: process.env.APP_URL ?? "http://localhost:7148", app_name: "My Store", promo_code: "WELCOME10", unsubscribe_token: crypto.randomBytes(16).toString("hex") };โ const htmlBody = await Frond.render("emails/welcome.html", emailData);โ // Send the email const mailer = new Messenger(); const result = await mailer.send({ to: body.email, subject: `Welcome to My Store, ${body.name}!`, body: htmlBody, html: true, text: `Hi ${body.name},\n\nWelcome to My Store! Your account (#${userId}) has been created.\n\nCheers,\nThe My Store Team` });โ return res.status(201).json({ message: "Registration successful", email_sent: result.success, user_id: userId });});curl -X POST http://localhost:7148/api/register \ -H "Content-Type: application/json" \ -d '{"name": "Alice", "email": "alice@example.com", "password": "securePass123"}'{ "message": "Registration successful", "email_sent": true, "user_id": 42}With TINA4_DEBUG=true, the email appears in the dev dashboard instead of reaching a real inbox. Inspect the rendered HTML, check that template variables substituted, and verify the layout.
12. Sending Email via Queues#
In production, never send email inside a route handler. The SMTP call blocks the response. Use the queue system from Chapter 12:
import { Router, Queue, Messenger } from "tina4-nodejs";import { Frond } from "tina4-nodejs/frond";โconst emailQueue = new Queue({ topic: "emails" });โ// In the route handler, just queue the emailRouter.post("/api/register", async (req, res) => { const userId = 42; // Simulatedโ emailQueue.push({ template: "emails/welcome.html", to: req.body.email, subject: `Welcome to My Store, ${req.body.name}!`, data: { name: req.body.name, email: req.body.email, user_id: userId, signed_up_at: "March 22, 2026", base_url: "http://localhost:7148", app_name: "My Store", promo_code: "WELCOME10" } });โ return res.status(201).json({ message: "Registration successful", user_id: userId });});โ// The consumer sends the actual emailemailQueue.process(async (job) => { const { template, to, subject, data } = job.payload as any;โ const frond = new Frond("src/templates"); const htmlBody = frond.render(template, data);โ const mailer = new Messenger(); const result = await mailer.send({ to, subject, body: htmlBody, html: true });โ if (!result.success) { console.log(`Email failed: ${result.message}`); job.fail(result.message); return; }โ console.log(`Email sent to ${to}`); job.complete();});The route handler returns in under 50 milliseconds. The queue worker sends the email on its own timeline. If the SMTP server is down, retries happen automatically.
13. Exercise: Build a Contact Form with Email Notification#
Build a contact form that sends an email notification when submitted.
Requirements#
- Create a
GET /contactpage that renders a contact form with fields: name, email, subject, and message - Create a
POST /contactendpoint 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 in HTML
- Shows a flash message on success
- Redirects back to the contact page
- Create an email template at
src/templates/emails/contact-notification.htmlthat formats the contact submission
Test with:#
# View the formcurl http://localhost:7148/contactโ# Submit the formcurl -X POST http://localhost:7148/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:7148/__dev14. Solution#
Create src/templates/emails/contact-notification.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:
{% 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.ts:
import { Router, Messenger } from "tina4-nodejs";import { Frond } from "tina4-nodejs/frond";โRouter.get("/contact", async (req, res) => { const flash = req.session._flash ?? null; delete req.session._flash; return res.render("contact.html", { flash });});โRouter.post("/contact", async (req, res) => { const body = req.body;โ // Validate const errors: string[] = []; if (!body.name) errors.push("Name is required"); if (!body.email) errors.push("Email is required"); if (!body.subject) errors.push("Subject is required"); if (!body.message) errors.push("Message is required");โ if (errors.length > 0) { req.session._flash = { type: "error", message: `Please fill in all fields: ${errors.join(", ")}` }; return res.redirect("/contact"); }โ // Render the email template const htmlBody = await Frond.render("emails/contact-notification.html", { name: body.name, email: body.email, subject: body.subject, message: body.message, submitted_at: new Date().toLocaleString() });โ // Send the email const mailer = new Messenger(); const adminEmail = process.env.ADMIN_EMAIL ?? "admin@example.com";โ const result = await mailer.send({ to: adminEmail, subject: `Contact Form: ${body.subject}`, body: htmlBody, html: true, replyTo: body.email, text: `Contact form submission from ${body.name} (${body.email}):\n\nSubject: ${body.subject}\n\nMessage:\n${body.message}` });โ if (result.success) { req.session._flash = { type: "success", message: "Thank you for your message! We will get back to you shortly." }; } else { req.session._flash = { type: "error", message: "Sorry, there was a problem sending your message. Please try again later." }; }โ return res.redirect("/contact");});Testing:
- Open
http://localhost:7148/contactin your browser - Fill in the form and submit
- You should see a green "Thank you" flash message
- Open
http://localhost:7148/__devto see the intercepted email - The email shows the sender details, subject, message, and formatted HTML
API test:
curl -X POST http://localhost:7148/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.txtThe response is a 302 redirect to /contact. Follow the redirect to see the flash message:
curl http://localhost:7148/contact -b cookies.txtThe 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 when two-factor authentication is 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. This is separate from your regular Google password.
2. Emails Go to Spam#
Problem: Emails land in the recipient's 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 records. Use a transactional email service -- Mailgun, SendGrid, or Amazon SES -- that handles email reputation for you.
3. HTML Email Looks Broken#
Problem: The email looks fine in Gmail but broken in Outlook or Apple Mail.
Cause: Email clients have different HTML/CSS support. CSS flexbox, grid, and many modern properties do not work in email.
Fix: Use inline styles (not external stylesheets or <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 (!fs.existsSync(path)) { ... }. If the file is generated dynamically (a PDF, for example), make sure the generation completes before sending.
5. Dev Mode Silently Intercepts Emails#
Problem: You configured SMTP but no emails arrive. No errors either.
Cause: TINA4_DEBUG=true intercepts all emails and stores them in the dev dashboard. The email never reaches the SMTP server.
Fix: Check the dev dashboard at /__dev for intercepted emails. If you want to send real emails during development, set TINA4_MAILBOX_DIR=false. Remove this setting before committing.
6. Email Template Variables Not Substituted#
Problem: The email body shows {{ name }} literally instead of the user's name.
Cause: You passed the raw template file content instead of rendering it through the template engine.
Fix: Use Frond.render("emails/template.html", data) to render the template with variables substituted. Do not read the file with fs.readFileSync() -- 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 from your network. The port may be blocked by a firewall, or the hostname may be wrong.
Fix: Test SMTP connectivity with mailer.testConnection(). Verify the hostname, port, and encryption settings. Check that your firewall allows outbound connections on the SMTP port. If you sit behind a corporate firewall, port 587 or 465 might be blocked -- ask your network administrator.
8. IMAP Host Not Configured#
Problem: Calling mailer.getInbox() throws MessengerError: IMAP host not configured.
Cause: You did not set TINA4_MAIL_IMAP_HOST in .env or pass imapHost to the constructor.
Fix: Add TINA4_MAIL_IMAP_HOST=imap.example.com and TINA4_MAIL_IMAP_PORT=993 to .env. The IMAP host is separate from the SMTP host -- many providers use different hostnames for sending and reading.