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. Bounce handling. The details compound. Tina4's Messenger class owns 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 |
Messenger also accepts legacy SMTP_* prefixed variables as fallback. The TINA4_MAIL_* prefix takes priority.
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:
# Uses .env defaultsmailer = Tina4::Messenger.newโ# Override specific settingsmarketing_mailer = Tina4::Messenger.new( 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")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:
Tina4::Router.post("/api/contact") do |request, response| body = request.bodyโ mail = Tina4::Messenger.new result = mail.send( to: body["email"], subject: "Contact Form Submission", body: "Name: #{body['name']}\nEmail: #{body['email']}\nMessage:\n#{body['message']}" )โ if result[:success] response.json({ message: "Email sent successfully" }) else response.json({ error: "Failed to send email", details: result[:message] }, 500) endendcurl -X POST http://localhost:7147/api/contact \ -H "Content-Type: application/json" \ -d '{"name": "Alice", "email": "alice@example.com", "message": "Hello!"}'{"message":"Email sent successfully"}The send method returns a hash with three keys: success (boolean), message (string), and id (message ID string or nil).
The send Method Signature#
mail.send( to:, # Recipient(s) -- string or array of strings subject:, # Email subject line body:, # Email body (plain text or HTML) html: false, # If true, body is treated as HTML text: nil, # Plain text alternative (when body is HTML) cc: [], # CC recipient(s) -- string or array bcc: [], # BCC recipient(s) -- string or array reply_to: nil, # Reply-To address attachments: [], # List of file paths or hashes headers: {} # Additional email headers (hash))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:
mail = Tina4::Messenger.newโhtml_body = <<~HTML <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>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>HTMLโtext_body = <<~TEXT 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โ Cheers, The My Store TeamTEXTโresult = mail.send( to: "alice@example.com", subject: "Welcome to My Store!", body: html_body, html: true, text: text_body)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:
mail = Tina4::Messenger.newโresult = mail.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.
Multiple Attachments#
mail = Tina4::Messenger.newresult = mail.send( to: "alice@example.com", subject: "Monthly Report", body: "Here are this month's reports.", attachments: [ "/reports/sales.pdf", "/reports/analytics.xlsx", "/reports/summary.csv" ])7. CC, BCC, and Reply-To#
mail = Tina4::Messenger.newโresult = mail.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"], reply_to: "alice@example.com")- cc: List of email addresses to carbon copy. All recipients see CC addresses.
- bcc: List of email addresses to blind carbon copy. Recipients cannot see BCC addresses.
- reply_to: 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 custom headers through the headers parameter on send:
result = mail.send( to: "customer@example.com", subject: "Your Support Ticket #123", body: "We are looking into your issue.", reply_to: "support@mystore.com", headers: { "X-Ticket-Id" => "123", "X-Priority" => "1", "X-Mailer" => "Tina4 Messenger" })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.
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=993Messenger reuses TINA4_MAIL_USERNAME and TINA4_MAIL_PASSWORD for IMAP authentication. You can also override the IMAP host and port in the constructor:
mailer = Tina4::Messenger.new(imap_host: "imap.gmail.com", imap_port: 993)Listing Inbox Messages#
The inbox method fetches message headers from the mailbox:
Tina4::Router.get("/api/inbox") do |request, response| mailer = Tina4::Messenger.newโ emails = mailer.inbox(limit: 20, offset: 0)โ messages = emails.map do |email| { uid: email[:uid], from: email[:from], subject: email[:subject], date: email[:date], snippet: email[:snippet], seen: email[:seen] } endโ response.json({ messages: messages, count: messages.length })endcurl http://localhost:7147/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 inbox 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#
Tina4::Router.get("/api/inbox/{uid}") do |request, response| mailer = Tina4::Messenger.new uid = request.params["uid"]โ email = mailer.read(uid, mark_read: true)โ if email.nil? return response.json({ error: "Email not found" }, 404) endโ response.json({ uid: email[:uid], from: email[:from], to: email[:to], cc: email[:cc], subject: email[:subject], date: email[:date], body_html: email[:body_html], body_text: email[:body_text], attachments: (email[:attachments] || []).map { |a| { filename: a[:filename], size: a[:size], content_type: a[:content_type] } } })endThe read method fetches the full message including body and attachments. Pass mark_read: false to leave the message unread.
Searching Messages#
Tina4::Router.get("/api/inbox/search") do |request, response| mailer = Tina4::Messenger.newโ results = mailer.search( subject: request.params["q"], sender: request.params["from"], unseen_only: request.params["unread"] == "true", limit: 20 )โ response.json({ messages: results, count: results.length })endThe search method accepts subject, sender, since (date string), before, and unseen_only as filters. All filters combine with AND logic.
Other IMAP Operations#
mailer = Tina4::Messenger.newโ# Count unread messagescount = mailer.unreadโ# List all mailbox foldersfolders = mailer.folders# ["INBOX", "Sent", "Drafts", "Trash", "Spam"]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 click "Emails" to see:
- Recipient, subject, and timestamp
- Full HTML preview
- Plain text fallback
- Attachments list
- Headers
This means you can test email functionality without configuring SMTP during development.
Disabling 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 Ruby strings 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. 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> </table> <p>Get started:</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> </div></body></html>Rendering and Sending#
Use html_template and template_data on the Messenger instance:
Tina4::Router.post("/api/register") do |request, response| body = request.bodyโ # Create user (database logic) user_id = 42โ mail = Tina4::Messenger.new mail.to = body["email"] mail.subject = "Welcome to My Store, #{body['name']}!" mail.html_template = "emails/welcome.html" mail.template_data = { name: body["name"], email: body["email"], user_id: user_id, base_url: ENV["APP_URL"] || "http://localhost:7147", app_name: "My Store", promo_code: "WELCOME10" } mail.sendโ response.json({ message: "Registration successful", user_id: user_id }, 201)endcurl -X POST http://localhost:7147/api/register \ -H "Content-Type: application/json" \ -d '{"name": "Alice", "email": "alice@example.com", "password": "securePass123"}'{ "message": "Registration successful", "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:
# In the route handler, queue the emailTina4::Router.post("/api/register") do |request, response| body = request.body user_id = 42 # Simulatedโ Tina4::Queue.produce("send-email", { to: body["email"], subject: "Welcome to My Store, #{body['name']}!", template: "emails/welcome.html", data: { name: body["name"], email: body["email"], user_id: user_id, base_url: "http://localhost:7147", app_name: "My Store", promo_code: "WELCOME10" } })โ response.json({ message: "Registration successful", user_id: user_id }, 201)endโ# The consumer sends the actual emailTina4::Queue.consume("send-email") do |job| mail = Tina4::Messenger.new mail.to = job.payload["to"] mail.subject = job.payload["subject"]โ if job.payload["template"] mail.html_template = job.payload["template"] mail.template_data = job.payload["data"] || {} else mail.body = job.payload["body"] || "" endโ mail.send trueendThe 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 an Email Notification System#
Build an email system that sends different types of notifications.
Requirements#
POST /api/notify/welcome-- Send a welcome email with an HTML templatePOST /api/notify/order-- Send an order confirmation with order detailsPOST /api/notify/reset-- Send a password reset email with a token link
Each endpoint should queue the email rather than sending it directly.
Test with:#
curl -X POST http://localhost:7147/api/notify/welcome \ -H "Content-Type: application/json" \ -d '{"name": "Alice", "email": "alice@example.com"}'โcurl -X POST http://localhost:7147/api/notify/order \ -H "Content-Type: application/json" \ -d '{"email": "alice@example.com", "order_id": 101, "total": 159.98}'โcurl -X POST http://localhost:7147/api/notify/reset \ -H "Content-Type: application/json" \ -d '{"email": "alice@example.com", "reset_token": "abc123def456"}'14. Solution#
Create src/routes/notifications.rb:
# @noauthTina4::Router.post("/api/notify/welcome") do |request, response| body = request.bodyโ Tina4::Queue.produce("send-email", { to: body["email"], subject: "Welcome to My Store, #{body['name']}!", template: "emails/welcome.html", data: { name: body["name"], login_url: "https://mystore.com/login", year: Time.now.year } })โ response.json({ message: "Welcome email queued" })endโ# @noauthTina4::Router.post("/api/notify/order") do |request, response| body = request.bodyโ Tina4::Queue.produce("send-email", { to: body["email"], subject: "Order Confirmation ##{body['order_id']}", template: "emails/order-confirmation.html", data: { order_id: body["order_id"], total: body["total"] } })โ response.json({ message: "Order confirmation email queued" })endโ# @noauthTina4::Router.post("/api/notify/reset") do |request, response| body = request.bodyโ Tina4::Queue.produce("send-email", { to: body["email"], subject: "Password Reset Request", template: "emails/password-reset.html", data: { reset_url: "https://mystore.com/reset?token=#{body['reset_token']}" } })โ response.json({ message: "Password reset email queued" })end15. Gotchas#
1. Gmail Requires App Passwords#
Problem: Gmail login fails with "authentication error".
Fix: Enable 2FA on your Google account, then generate an App Password at https://myaccount.google.com/apppasswords. Use the app password, not your regular password.
2. Emails Go to Spam#
Problem: Emails arrive in the spam folder.
Fix: Set up SPF, DKIM, and DMARC DNS records for your domain. Use a reputable email service like Mailgun, SendGrid, or Postmark.
3. HTML Email Rendering Differences#
Problem: Your email looks different in Gmail, Outlook, and Apple Mail.
Fix: Use inline CSS. Avoid flexbox and grid. Use tables for layout. Test with a tool like Litmus or Email on Acid.
4. Attachment File Not Found#
Problem: attachments: ["/path/to/file.pdf"] raises a file not found error.
Fix: Use absolute paths. Relative paths resolve from the working directory, which may differ in production.
5. SMTP Connection Timeout#
Problem: Sending email hangs for 30 seconds and then times out.
Fix: Check your SMTP host, port, and encryption settings. Common mistake: using port 587 with SSL instead of TLS.
6. Dev Mode Emails Disappear on Restart#
Problem: Intercepted emails in the dev dashboard vanish when you restart the server.
Fix: This is expected. Dev mode stores emails in memory. For persistent storage, configure a real SMTP server.
7. Unicode Characters Display as Question Marks#
Problem: Non-ASCII characters (accents, CJK) show as ? in the email.
Fix: Tina4 sets UTF-8 encoding by default. If you are constructing raw headers, make sure you include Content-Type: text/html; charset=utf-8.
8. IMAP Connection Fails#
Problem: inbox or read raises a connection error.
Fix: Verify TINA4_MAIL_IMAP_HOST and TINA4_MAIL_IMAP_PORT in .env. Gmail uses imap.gmail.com on port 993. Make sure your email provider allows IMAP access -- some providers disable it by default.