# Mail & Templates

> Emails for new assignments and finished exports.

Goravel series, part 17 of 20. By Krishan Kumar. Published 2026-10-28.
Source: https://devchalk.com/goravel/17-mail-templates
Code: https://github.com/krishankumar01/goravel-taskflow/tree/part-17

So far an assignee learns about a new task only from `GET /notifications`, and the owner of an export has to ask `GET /exports/{id}` until its file is ready. To solve it, we are going to notify them a different way: by email. Each email will have an HTML part and a plain-text part, and it will use templates to add the dynamic data.

Start from the Queues & Jobs code:

```bash
git clone https://github.com/krishankumar01/goravel-taskflow
cd goravel-taskflow
git checkout part-16
cp .env.example .env
go run . artisan key:generate
go run . artisan jwt:secret
docker compose up -d
go run . artisan migrate:fresh --seed
docker compose exec postgres psql -U goravel -d taskflow -c "CREATE DATABASE taskflow_test"
go install github.com/air-verse/air@latest
air
```

## Mail on a laptop

Goravel sends [mail](https://www.goravel.dev/digging-deeper/mail) over SMTP, so to work in this part we are going to use a tool for development called [Mailpit](https://mailpit.axllent.org). It accepts every email and shows it on a web page instead of delivering it to the recipient.

To use Goravel's mail package, let's install it first:

```bash
$ go run . artisan package:install Mail
  INFO    Mail depends on Log, Cache, Orm, Schema, DB, Queue, they will be installed simultaneously
  SUCCESS  Facade Mail installed successfully
```

`package:install` adds `config/mail.go`, the `facades.Mail()` facade and the mail service provider. It also adds empty `MAIL_` keys to `.env` and `.env.example`, which we fill in below.

Next, we need to add Mailpit to `docker-compose.yml`, so that it runs on our machine next to Postgres and Redis:

```diff lang="yaml" title="docker-compose.yml" collapse={1-19} t1:auth=/(MP_SMTP_AUTH_[A-Z_]+)/
 services:
   postgres:
     image: postgres:15-alpine
     restart: always
     environment:
       POSTGRES_DB: taskflow
       POSTGRES_USER: goravel
       POSTGRES_PASSWORD: secret
     ports:
       - "5433:5432"
     volumes:
       - postgres_data:/var/lib/postgresql/data
 
   redis:
     image: redis:alpine
     restart: always
     ports:
       - "6380:6379"
     volumes:
       - redis_data:/data
 
+  mailpit:
+    image: axllent/mailpit:v1.27
+    restart: always
+    environment:
+      MP_SMTP_AUTH_ACCEPT_ANY: 1
+      MP_SMTP_AUTH_ALLOW_INSECURE: 1
+    ports:
+      - "1025:1025"
+      - "8025:8025"
+
 volumes:
   postgres_data:
   redis_data:
```

We need the two settings because Goravel always logs in to the SMTP server on a port other than 465 or 587, even with an empty username. Mailpit offers no login by default, so Go's SMTP client stops with `smtp: server doesn't support AUTH`. The settings make Mailpit accept any username and password without TLS.

Now we point TaskFlow at Mailpit with the `MAIL_` keys. Fill them in `.env` the same way, and add them to `.env.testing` so the tests send to Mailpit too:

```diff lang="ini" title=".env.example" collapse={1-28}
 APP_NAME=Goravel
 APP_ENV=local
 APP_KEY=
 APP_DEBUG=true
 
 LOG_CHANNEL=stack
 LOG_LEVEL=debug
 
 SESSION_DRIVER=file
 SESSION_LIFETIME=120
 
 APP_URL=http://localhost
 APP_HOST=127.0.0.1
 APP_PORT=3000
 
 JWT_SECRET=
 
 DB_CONNECTION=postgres
 DB_HOST=127.0.0.1
 DB_PORT=5433
 DB_DATABASE=taskflow
 DB_USERNAME=goravel
 DB_PASSWORD=secret
 
 FILESYSTEM_DISK=local
 QUEUE_CONNECTION=redis
 
 REDIS_HOST=127.0.0.1
 REDIS_PASSWORD=
 REDIS_PORT=6380
+
+MAIL_HOST=127.0.0.1
+MAIL_PORT=1025
+MAIL_USERNAME=
+MAIL_PASSWORD=
+MAIL_FROM_ADDRESS=taskflow@example.com
+MAIL_FROM_NAME=TaskFlow
```

- `MAIL_HOST` and `MAIL_PORT` are where Mailpit listens for SMTP.
- `MAIL_FROM_ADDRESS` and `MAIL_FROM_NAME` fill the From line of every email.
- `MAIL_USERNAME` and `MAIL_PASSWORD` stay empty, because Mailpit accepts any login.

Then start Mailpit with `docker compose up -d mailpit`. Its inbox is at http://localhost:8025, where every email from this part shows up.

## The first email

We start with the assignment email. TaskFlow already fires `TaskAssigned` whenever someone assigns a task, from [Events & Listeners](https://devchalk.com/goravel/15-events-listeners-notifications#the-listener). So we are going to add a second listener for this event, called `EmailAssignee`:

```bash
$ go run . artisan make:listener EmailAssignee
  SUCCESS  Listener created successfully
```

```go title="app/listeners/email_assignee.go" collapse={1-13} collapse={17-28} t1:to=/^\s+(To)[(]/ t2:subject=/^\s+(Subject)[(]/ t3:content=/^\s+(Content)[(]/ t4:send=/^\s+(Send)[(]/
package listeners

import (
	"errors"
	"fmt"

	"github.com/goravel/framework/contracts/event"
	"github.com/goravel/framework/contracts/mail"
	frameworkerrors "github.com/goravel/framework/errors"

	"taskflow/app/facades"
	"taskflow/app/models"
)

type EmailAssignee struct {
}

func (receiver *EmailAssignee) Signature() string {
	return "email_assignee"
}

func (receiver *EmailAssignee) Queue(args ...any) event.Queue {
	return event.Queue{
		Enable:     true,
		Connection: "",
		Queue:      "",
	}
}

func (receiver *EmailAssignee) Handle(args ...any) error {
	taskID, ok1 := args[0].(uint)
	assignedTo, ok2 := args[1].(uint)
	assignedBy, ok3 := args[2].(uint)
	if !ok1 || !ok2 || !ok3 {
		return errors.New("email assignee: the ids must be uints")
	}
	if assignedTo == assignedBy {
		return nil
	}

	var task models.Task
	err := facades.Orm().Query().FindOrFail(&task, taskID)
	if errors.Is(err, frameworkerrors.OrmRecordNotFound) {
		return nil
	}
	if err != nil {
		return err
	}

	var assignee, manager models.User
	if err := facades.Orm().Query().FindOrFail(&assignee, assignedTo); err != nil {
		return err
	}
	if err := facades.Orm().Query().FindOrFail(&manager, assignedBy); err != nil {
		return err
	}

	return facades.Mail().
		To([]string{assignee.Email}).
		Subject(fmt.Sprintf("%s assigned you a task", manager.Name)).
		Content(mail.Content{Text: fmt.Sprintf("%s assigned you %q.", manager.Name, task.Title)}).
		Send()
}
```

`Handle` makes the same checks as `NotifyAssignee`, so a manager who takes a task themselves gets no email.

We build the email with [`facades.Mail()`](https://www.goravel.dev/digging-deeper/mail#send-mail), which starts a new email on every call. It gives a fluent API: each method sets one part of the email and returns the email again, so the calls chain until [`Send`](https://github.com/goravel/framework/blob/v1.18.0/contracts/mail/mail.go#L19):

- [`To`](https://github.com/goravel/framework/blob/v1.18.0/contracts/mail/mail.go#L23) takes the recipients' addresses.
- [`Subject`](https://github.com/goravel/framework/blob/v1.18.0/contracts/mail/mail.go#L21) sets the subject line.
- [`Content`](https://github.com/goravel/framework/blob/v1.18.0/contracts/mail/mail.go#L11) sets the body. With only `Text`, the email has a plain-text part and no HTML.
- [`Send`](https://github.com/goravel/framework/blob/v1.18.0/contracts/mail/mail.go#L19) sends it over SMTP right away.

The chain also has [`Cc`](https://github.com/goravel/framework/blob/v1.18.0/contracts/mail/mail.go#L9), [`Bcc`](https://github.com/goravel/framework/blob/v1.18.0/contracts/mail/mail.go#L7), [`Attach`](https://github.com/goravel/framework/blob/v1.18.0/contracts/mail/mail.go#L5), [`Headers`](https://github.com/goravel/framework/blob/v1.18.0/contracts/mail/mail.go#L15) and [`From`](https://github.com/goravel/framework/blob/v1.18.0/contracts/mail/mail.go#L13). This email doesn't need them, and without `From` it uses `MAIL_FROM_ADDRESS` and `MAIL_FROM_NAME`.

`Queue` returns `Enable: true`, as in `NotifyAssignee`, so the listener runs on the worker and sending the email doesn't hold up the request that assigned the task.

A listener runs only once `WithEvents` lists it, so we add it after `NotifyAssignee` in `bootstrap/app.go`:

```diff lang="go" title="bootstrap/app.go" collapse={1-36} collapse={43-83}
 package bootstrap
 
 import (
 	"context"
 	"strings"
 
 	"github.com/goravel/framework/auth/access"
 	contractsaccess "github.com/goravel/framework/contracts/auth/access"
 	"github.com/goravel/framework/contracts/database/schema"
 	contractsevent "github.com/goravel/framework/contracts/event"
 	contractsfoundation "github.com/goravel/framework/contracts/foundation"
 	"github.com/goravel/framework/contracts/http"
 	"github.com/goravel/framework/foundation"
 	"github.com/goravel/framework/http/limit"
 
 	"taskflow/app/enums"
 	"taskflow/app/events"
 	"taskflow/app/facades"
 	"taskflow/app/http/authuser"
 	"taskflow/app/listeners"
 	"taskflow/app/models"
 	"taskflow/app/policies"
 	"taskflow/config"
 	"taskflow/routes"
 )
 
 func Boot() contractsfoundation.Application {
 	return foundation.Setup().
 		WithJobs(Jobs).
 		WithRules(Rules).
 		WithSeeders(Seeders).
 		WithMigrations(Migrations).
 		WithRouting(func() {
 			routes.Api()
 			routes.Web()
 		}).
 		WithEvents(func() map[contractsevent.Event][]contractsevent.Listener {
 			return map[contractsevent.Event][]contractsevent.Listener{
-				&events.TaskAssigned{}: {&listeners.NotifyAssignee{}},
+				&events.TaskAssigned{}: {&listeners.NotifyAssignee{}, &listeners.EmailAssignee{}},
 			}
 		}).
 		WithProviders(Providers).
 		WithConfig(config.Boot).
 		WithCallback(func() {
 			facades.Schema().Extend(schema.Extension{
 				Models: []any{models.User{}, models.Task{}, models.Attachment{}, models.Notification{}, models.Export{}},
 			})
 
 			facades.RateLimiter().For("login", func(ctx http.Context) http.Limit {
 				email := strings.ToLower(strings.TrimSpace(ctx.Request().Input("email")))
 
 				return limit.PerMinute(5).By(email).Response(func(ctx http.Context) {
 					ctx.Response().Json(429, map[string]any{
 						"error": "Too many login attempts. Try again in a minute.",
 					}).Abort()
 				})
 			})
 
 			facades.Gate().Before(func(ctx context.Context, ability string, arguments map[string]any) contractsaccess.Response {
 				if user := authuser.From(ctx); user != nil && user.Role == enums.RoleManager {
 					return access.NewAllowResponse()
 				}
 
 				return nil
 			})
 
 			facades.Gate().Define(policies.ViewUsers, func(ctx context.Context, arguments map[string]any) contractsaccess.Response {
 				return access.NewDenyResponse("Only managers can see the users.")
 			})
 
 			taskPolicy := policies.NewTaskPolicy()
 			facades.Gate().Define(policies.CreateTask, taskPolicy.Create)
 			facades.Gate().Define(policies.UpdateTask, taskPolicy.Update)
 			facades.Gate().Define(policies.DeleteTask, taskPolicy.Delete)
 
 			attachmentPolicy := policies.NewAttachmentPolicy()
 			facades.Gate().Define(policies.CreateAttachment, attachmentPolicy.Create)
 			facades.Gate().Define(policies.ViewAttachment, attachmentPolicy.View)
 			facades.Gate().Define(policies.DeleteAttachment, attachmentPolicy.Delete)
 		}).
 		Create()
 }
```

To try it, we start from the same setup as [Queues & Jobs](https://devchalk.com/goravel/16-queues-jobs-task-export#see-it-work):

1. Reset the database with `migrate:fresh --seed`, which adds Ana as the manager, plus three members and seven tasks.
2. Register Ben. He gets id 5.
3. Log in as Ana and as Ben, and save each token in a variable, `ANA` and `BEN`.

Now when Ana assigns a task to Ben, an email arrives in Mailpit. `/view/latest.txt` shows the plain-text part of the newest email:

```bash
$ curl -X POST http://localhost:3000/api/v1/tasks \
  -H "Authorization: Bearer $ANA" \
  -H "Content-Type: application/json" \
  -d '{"title":"Fix the login page","description":"The error message overlaps the button & the logo.","assigned_to":5,"due_at":"2026-11-06 09:00:00"}'
{…,"id":8,…,"title":"Fix the login page",…}

$ curl http://localhost:8025/view/latest.txt
Ana assigned you "Fix the login page".
```

## A Mailable

Building the email inline in the listener works for one line of text. But real emails are more complex, with HTML and styles. And if we send the same email from more than one place, we have to write the same code in each of them. So we are going to use a Mailable, a struct that holds all of this for one email. TaskFlow's unit test, for one, builds the same assignment email without sending it:

```bash
$ go run . artisan make:mail Assignment
  SUCCESS  Mail created successfully
```

```go title="app/mails/assignment.go" collapse={1-9} t3:task=/"(Task)":/
package mails

import (
	"fmt"

	"github.com/goravel/framework/contracts/mail"

	"taskflow/app/models"
)

type Assignment struct {
	task     models.Task
	assignee models.User
	by       models.User
}

func NewAssignment(task models.Task, assignee, by models.User) *Assignment {
	return &Assignment{task: task, assignee: assignee, by: by}
}

func (receiver *Assignment) Headers() map[string]string {
	return map[string]string{}
}

func (receiver *Assignment) Attachments() []string {
	return []string{}
}

func (receiver *Assignment) Content() *mail.Content {
	return &mail.Content{
		HtmlView: "assignment.html",
		TextView: "assignment.txt",
		With: map[string]any{
			"Name": receiver.assignee.Name,
			"By":   receiver.by.Name,
			"Task": receiver.task,
		},
	}
}

func (receiver *Assignment) Envelope() *mail.Envelope {
	return &mail.Envelope{
		To:      []string{receiver.assignee.Email},
		Subject: fmt.Sprintf("%s assigned you a task", receiver.by.Name),
	}
}

func (receiver *Assignment) Queue() *mail.Queue {
	return &mail.Queue{}
}
```

- `NewAssignment` takes the task and both users, so the Mailable has everything the email shows.
- `Content` names the two views, one for the HTML part and one for the text part. `With` is the data they can use.
- `Headers`, `Attachments` and `Queue` come from `make:mail` and stay empty. `Queue` matters later, when we queue the export email.

In the listener, `Send` now takes the Mailable in place of the inline calls:

```diff lang="go" title="app/listeners/email_assignee.go" collapse={15-55}
 package listeners
 
 import (
 	"errors"
-	"fmt"
 
 	"github.com/goravel/framework/contracts/event"
-	"github.com/goravel/framework/contracts/mail"
 	frameworkerrors "github.com/goravel/framework/errors"
 
 	"taskflow/app/facades"
+	"taskflow/app/mails"
 	"taskflow/app/models"
 )
 
 type EmailAssignee struct {
 }
 
 func (receiver *EmailAssignee) Signature() string {
 	return "email_assignee"
 }
 
 func (receiver *EmailAssignee) Queue(args ...any) event.Queue {
 	return event.Queue{
 		Enable:     true,
 		Connection: "",
 		Queue:      "",
 	}
 }
 
 func (receiver *EmailAssignee) Handle(args ...any) error {
 	taskID, ok1 := args[0].(uint)
 	assignedTo, ok2 := args[1].(uint)
 	assignedBy, ok3 := args[2].(uint)
 	if !ok1 || !ok2 || !ok3 {
 		return errors.New("email assignee: the ids must be uints")
 	}
 	if assignedTo == assignedBy {
 		return nil
 	}
 
 	var task models.Task
 	err := facades.Orm().Query().FindOrFail(&task, taskID)
 	if errors.Is(err, frameworkerrors.OrmRecordNotFound) {
 		return nil
 	}
 	if err != nil {
 		return err
 	}
 
 	var assignee, manager models.User
 	if err := facades.Orm().Query().FindOrFail(&assignee, assignedTo); err != nil {
 		return err
 	}
 	if err := facades.Orm().Query().FindOrFail(&manager, assignedBy); err != nil {
 		return err
 	}
 
-	return facades.Mail().
-		To([]string{assignee.Email}).
-		Subject(fmt.Sprintf("%s assigned you a task", manager.Name)).
-		Content(mail.Content{Text: fmt.Sprintf("%s assigned you %q.", manager.Name, task.Title)}).
-		Send()
+	return facades.Mail().Send(mails.NewAssignment(task, assignee, manager))
 }
```

The listener no longer deals with the email's text. That now lives inside `Assignment`, so it changes in one place.

## Views

In a Mailable, or in an email built with the chain, the body goes in `Content`. It can be inline, with `Text` and `Html`, or as complex as a full HTML page. For that, Goravel has email views, set with `HtmlView` and `TextView`, which the template engine set in `config/mail.go` renders. Its default engine, `html`, looks for the views in `resources/views/mail` and reads them as Go templates. The `With` data fills them: `{{.Name}}` prints the `Name` value, and `{{.Task.Title}}` prints a field of the task:

```html title="resources/views/mail/assignment.html" t3:task=/[{][{][.](Task)[.]Title/
<p>Hi {{.Name}},</p>
<p>{{.By}} assigned you <strong>{{.Task.Title}}</strong>.</p>
{{if .Task.DueAt}}<p>It's due {{.Task.DueAt.ToDateTimeString}}.</p>{{end}}
<p>{{.Task.Description}}</p>
```

```text title="resources/views/mail/assignment.txt"
Hi {{.Name}},

{{.By}} assigned you "{{.Task.Title}}".
{{if .Task.DueAt}}It's due {{.Task.DueAt.ToDateTimeString}}.
{{end}}
{{.Task.Description}}
```

- `{{if .Task.DueAt}}` prints the due-date line only when the task has a due date.
- `{{.Task.DueAt.ToDateTimeString}}` calls a method on the date, the one the export uses for its CSV.
- Goravel renders the views with `html/template`, which escapes the values it prints, so a title with a `<` can't add HTML to the email.

The same assignment now arrives with both parts, but the last line of the text part shows `&amp;` where the description has `&`:

```text
Hi Ben,

Ana assigned you "Fix the login page".
It's due 2026-11-06 09:00:00.

The error message overlaps the button &amp; the logo.
```

Goravel's engine has two limitations for these emails:

- It renders the text view with `html/template` too, so it escapes the `&` as if the text were HTML.
- It parses each view from its own file, so the export email can't share a layout with this one.

## Another template engine

We can get past both by swapping the engine. Goravel's `custom` driver plugs in any template engine with a `Render(name, data)` method. We use [pongo2](https://github.com/flosch/pongo2), a Go engine with Django's template syntax, because its views can extend a layout and switch escaping off for the text part:

```bash
$ go get github.com/flosch/pongo2/v6
```

To plug pongo2 in, we write a small type in `app/mails` that hands each view to pongo2:

```go title="app/mails/pongo.go" collapse={1-7}
package mails

import (
	"errors"

	"github.com/flosch/pongo2/v6"
)

type Pongo struct {
	views *pongo2.TemplateSet
}

func NewPongo(dir string) *Pongo {
	return &Pongo{views: pongo2.NewSet("mail", pongo2.MustNewLocalFileSystemLoader(dir))}
}

func (receiver *Pongo) Render(name string, data any) (string, error) {
	with, ok := data.(map[string]any)
	if !ok {
		return "", errors.New("pongo: the mail's With must be a map")
	}

	view, err := receiver.views.FromCache(name)
	if err != nil {
		return "", err
	}

	return view.Execute(pongo2.Context(with))
}
```

- `NewPongo` loads the views from one folder, `resources/mail`.
- `FromCache` parses a view the first time it's used and keeps it for later emails.
- `Render` gets the Mailable's `With` as `any`, but pongo2 needs a map, so any other type returns an error.

The views move to `resources/mail` and change to pongo2's syntax. First comes the layout, which every HTML email shares. It's built from HTML tables with inline styles, because email clients show those more reliably than a style sheet. `{% block content %}` marks where each view goes:

```html title="resources/mail/layout.html"
<!DOCTYPE html>
<html>
<body style="margin: 0; padding: 24px; background: #f4f4f5; font-family: Arial, sans-serif; color: #18181b;">
  <table role="presentation" width="100%" cellpadding="0" cellspacing="0">
    <tr>
      <td align="center">
        <table role="presentation" width="560" cellpadding="0" cellspacing="0" style="background: #ffffff; border-radius: 8px;">
          <tr><td style="padding: 24px 24px 0; font-size: 18px; font-weight: bold;">TaskFlow</td></tr>
          <tr><td style="padding: 16px 24px; font-size: 15px; line-height: 1.5;">{% block content %}{% endblock %}</td></tr>
          <tr><td style="padding: 0 24px 24px; font-size: 12px; color: #71717a;">You get this email because you have a TaskFlow account.</td></tr>
        </table>
      </td>
    </tr>
  </table>
</body>
</html>
```

```html title="resources/mail/assignment.html" t3:task=/[{][{] (Task)[.]Title/
{% extends "layout.html" %}

{% block content %}
<p>Hi {{ Name }},</p>
<p>{{ By }} assigned you <strong>{{ Task.Title }}</strong>.</p>
{% if Task.DueAt %}<p>It's due {{ Task.DueAt.ToDateTimeString }}.</p>{% endif %}
<p>{{ Task.Description }}</p>
{% endblock %}
```

```text title="resources/mail/assignment.txt"
{% autoescape off %}Hi {{ Name }},

{{ By }} assigned you "{{ Task.Title }}".
{% if Task.DueAt %}It's due {{ Task.DueAt.ToDateTimeString }}.
{% endif %}
{{ Task.Description }}{% endautoescape %}
```

- `{% extends "layout.html" %}` puts the view's `content` block into the layout.
- `{{ Task.Title }}` prints a value, and pongo2 escapes it for HTML, as `html/template` does.
- `{% if Task.DueAt %}` is false when `DueAt` is nil. `{{ Task.DueAt.ToDateTimeString }}` calls the method, with no parentheses.
- `{% autoescape off %}` turns that escaping off for the text view, so the `&` stays as typed.

Last, we need to tell Goravel to use our engine. We can register the custom engine in `config/mail.go` as `pongo`, with the `custom` driver. Goravel calls its `via` function once to get the engine, and `default` makes it the one every email uses:

```diff lang="go" title="config/mail.go" collapse={14-50}
 package config
 
 import (
+	"github.com/goravel/framework/contracts/mail"
+
 	"taskflow/app/facades"
+	"taskflow/app/mails"
 )
 
 func init() {
 	config := facades.Config()
 	config.Add("mail", map[string]any{
 		// SMTP Host Address
 		//
 		// Here you may provide the host address of the SMTP server used by your
 		// applications. A default option is provided that is compatible with
 		// the Mailgun mail service which will provide reliable deliveries.
 		"host": config.Env("MAIL_HOST", ""),
 
 		// SMTP Host Port
 		//
 		// This is the SMTP port used by your application to deliver e-mails to
 		// users of the application. Like the host we have set this value to
 		// stay compatible with the Mailgun e-mail application by default.
 		"port": config.Env("MAIL_PORT", 587),
 
 		// --------------------------------------------------------------------------
 		// Global "From" Address
 		// --------------------------------------------------------------------------
 		//
 		// You may wish for all e-mails sent by your application to be sent from
 		// the same address. Here, you may specify a name and address that is
 		// used globally for all e-mails that are sent by your application.
 		"from": map[string]any{
 			"address": config.Env("MAIL_FROM_ADDRESS", "hello@example.com"),
 			"name":    config.Env("MAIL_FROM_NAME", "Example"),
 		},
 
 		// SMTP Server Username
 		//
 		// If your SMTP server requires a username for authentication, you should
 		// set it here. This will get used to authenticate with your server on
 		// connection. You may also set the "password" value below this one.
 		"username": config.Env("MAIL_USERNAME"),
 
 		"password": config.Env("MAIL_PASSWORD"),
 
 		// Template Configuration
 		//
 		// This controls template rendering for email views. Template engines are cached
 		// globally and support both built-in drivers and custom implementations via factories.
 		//
 		// Available Drivers: "html", "custom"
 		"template": map[string]any{
-			"default": config.Env("MAIL_TEMPLATE_ENGINE", "html"),
+			"default": config.Env("MAIL_TEMPLATE_ENGINE", "pongo"),
 			"engines": map[string]any{
 				"html": map[string]any{
 					"driver": "html",
 					"path":   config.Env("MAIL_VIEWS_PATH", "resources/views/mail"),
 				},
-				// Example custom template engine:
-				// "blade": map[string]any{
-				//     "driver": "custom",
-				//     "via": func() (mail.Template, error) {
-				//         return NewBladeTemplateEngine(), nil
-				//     },
-				// },
+				"pongo": map[string]any{
+					"driver": "custom",
+					"via": func() (mail.Template, error) {
+						return mails.NewPongo(facades.App().ResourcePath("mail")), nil
+					},
+				},
 			},
 		},
 	})
 }
```

The same assignment now keeps its `&`, and the HTML part in Mailpit has the TaskFlow header and footer around it:

```bash
$ curl http://localhost:8025/view/latest.txt
Hi Ben,

Ana assigned you "Fix the login page".
It's due 2026-11-06 09:00:00.

The error message overlaps the button & the logo.
```

## Queueing mail

Next, we email the owner of an export once its file is ready, so the email goes at the end of the export job. In `EmailAssignee` we used `Send`, because sending the email is the only thing that listener does. The export job is different, because it also builds the file. If we use `Send` here and SMTP fails, the whole export fails, and every retry builds the file again. So instead we use `Queue`, which sends the email as a job of its own:

```diff lang="go" title="app/jobs/export_tasks.go" collapse={1-13} collapse={19-76} t2:queue=/(Queue)[(]mails/
 package jobs
 
 import (
 	"bytes"
 	"encoding/csv"
 	"errors"
 	"fmt"
 	"strconv"
 	"time"
 
 	"github.com/goravel/framework/contracts/queue"
 	frameworkerrors "github.com/goravel/framework/errors"
 
 	"taskflow/app/enums"
 	"taskflow/app/facades"
+	"taskflow/app/mails"
 	"taskflow/app/models"
 )
 
 type ExportTasks struct {
 }
 
 func NewExportTasks(exportID uint) (*ExportTasks, []queue.Arg) {
 	return &ExportTasks{}, []queue.Arg{
 		{Type: "uint", Value: exportID},
 	}
 }
 
 // Signature The name and signature of the job.
 func (receiver *ExportTasks) Signature() string {
 	return "export_tasks"
 }
 
 // Handle Execute the job.
 func (receiver *ExportTasks) Handle(args ...any) error {
 	exportID, ok := args[0].(uint)
 	if !ok {
 		return errors.New("export tasks: the argument must be the export id")
 	}
 
 	var export models.Export
 	err := facades.Orm().Query().FindOrFail(&export, exportID)
 	if errors.Is(err, frameworkerrors.OrmRecordNotFound) {
 		return nil
 	}
 	if err != nil {
 		return err
 	}
 
 	var tasks []models.Task
 	if err := facades.Orm().Query().With("Assignee").OrderBy("id").Find(&tasks); err != nil {
 		return err
 	}
 
 	rows := [][]string{{"id", "title", "status", "assignee", "due_at"}}
 	for _, task := range tasks {
 		assignee, dueAt := "", ""
 		if task.Assignee != nil {
 			assignee = task.Assignee.Name
 		}
 		if task.DueAt != nil {
 			dueAt = task.DueAt.ToDateTimeString()
 		}
 		rows = append(rows, []string{strconv.Itoa(int(task.ID)), task.Title, string(task.Status), assignee, dueAt})
 	}
 
 	var file bytes.Buffer
 	if err := csv.NewWriter(&file).WriteAll(rows); err != nil {
 		return err
 	}
 
 	path := fmt.Sprintf("exports/%d.csv", export.ID)
 	if err := facades.Storage().Put(path, file.String()); err != nil {
 		return err
 	}
 
 	export.Status = enums.ExportDone
 	export.Path = path
+	if err := facades.Orm().Query().Save(&export); err != nil {
+		return err
+	}
+
+	var user models.User
+	if err := facades.Orm().Query().FindOrFail(&user, export.UserID); err != nil {
+		return err
+	}
 
-	return facades.Orm().Query().Save(&export)
+	return facades.Mail().Queue(mails.NewExportReady(export, user))
 }
 
 // ShouldRetry gives a failed export two more tries, five seconds apart.
 func (receiver *ExportTasks) ShouldRetry(err error, attempt int) (bool, time.Duration) {
 	return attempt < 3, 5 * time.Second
 }
```

`Queue` renders the views right away and puts the finished email on the queue. The worker then sends it as a separate job, `goravel_send_mail_job`, so an SMTP failure fails only that job and leaves its own row in `failed_jobs`.

The export email needs its own Mailable and views, and they follow the assignment's:

```go title="app/mails/export_ready.go" collapse={1-7} collapse={18-24}
package mails

import (
	"github.com/goravel/framework/contracts/mail"

	"taskflow/app/models"
)

type ExportReady struct {
	export models.Export
	user   models.User
}

func NewExportReady(export models.Export, user models.User) *ExportReady {
	return &ExportReady{export: export, user: user}
}

func (receiver *ExportReady) Headers() map[string]string {
	return map[string]string{}
}

func (receiver *ExportReady) Attachments() []string {
	return []string{}
}

func (receiver *ExportReady) Content() *mail.Content {
	return &mail.Content{
		HtmlView: "export_ready.html",
		TextView: "export_ready.txt",
		With: map[string]any{
			"Name":   receiver.user.Name,
			"Export": receiver.export,
		},
	}
}

func (receiver *ExportReady) Envelope() *mail.Envelope {
	return &mail.Envelope{
		To:      []string{receiver.user.Email},
		Subject: "Your task export is ready",
	}
}

func (receiver *ExportReady) Queue() *mail.Queue {
	return &mail.Queue{}
}
```

```html title="resources/mail/export_ready.html"
{% extends "layout.html" %}

{% block content %}
<p>Hi {{ Name }},</p>
<p>Your export of all tasks is ready. Download it with <code>GET /api/v1/exports/{{ Export.ID }}/download</code>.</p>
{% endblock %}
```

```text title="resources/mail/export_ready.txt"
{% autoescape off %}Hi {{ Name }},

Your export of all tasks is ready. Download it with GET /api/v1/exports/{{ Export.ID }}/download.{% endautoescape %}
```

An export now ends with an email to its owner:

```bash
$ curl -X POST http://localhost:3000/api/v1/exports -H "Authorization: Bearer $ANA"
{…,"id":1,"user_id":1,"status":"pending"}

$ sleep 2
$ curl http://localhost:8025/view/latest.txt
Hi Ana,

Your export of all tasks is ready. Download it with GET /api/v1/exports/1/download.
```

To check that, we stop Mailpit and ask for another export. The export still finishes, and only the email job fails:

```bash
$ docker compose stop mailpit

$ curl -X POST http://localhost:3000/api/v1/exports -H "Authorization: Bearer $ANA"
{…,"id":2,"user_id":1,"status":"pending"}

$ sleep 2
$ curl http://localhost:3000/api/v1/exports/2 -H "Authorization: Bearer $ANA"
{…,"id":2,"user_id":1,"status":"done"}
```

```text
  2026-10-27 23:23:44.45 export_tasks ........................... 6.719ms DONE
  2026-10-27 23:23:44.452 goravel_send_mail_job ...................... RUNNING
  2026-10-27 23:23:44.454 goravel_send_mail_job ................. 1.049ms FAIL
```

The failed email waits in `failed_jobs`, with the connection error:

```bash
$ docker compose exec postgres psql -U goravel taskflow -At -c "select exception from failed_jobs"
dial tcp 127.0.0.1:1025: connect: connection refused
```

`queue:failed` lists the failed job with its uuid:

```bash
$ go run . artisan queue:failed
  2026-10-27 23:23:45 aef68b21-e435-43ea-9f9e-b840b1e8cd37 ..... redis@default
```

Once Mailpit runs again, `queue:retry` puts that job back on the queue, and the worker sends the email:

```bash
$ docker compose start mailpit

$ go run . artisan queue:retry aef68b21-e435-43ea-9f9e-b840b1e8cd37
  INFO    Pushing failed queue jobs back onto the queue

  aef68b21-e435-43ea-9f9e-b840b1e8cd37 .......................... 2.134ms DONE

$ curl http://localhost:8025/view/latest.txt
Hi Ana,

Your export of all tasks is ready. Download it with GET /api/v1/exports/2/download.
```

A queue of its own for mail:

`facades.Mail().Queue` puts the job on the default connection and queue. A Mailable picks others by returning them from its own `Queue` method, such as `&mail.Queue{Queue: "mail"}`. A queue other than the default needs a worker of its own, as in [Queues & Jobs](https://devchalk.com/goravel/16-queues-jobs-task-export#two-jobs-at-once).

## Tests

We test the emails in two ways:

- [`tests/unit/mails_test.go`](https://github.com/krishankumar01/goravel-taskflow/blob/part-17/tests/unit/mails_test.go) renders the assignment email through the pongo2 engine and sends nothing. It checks the recipient, the escaped HTML, the text part as typed and the due-date line.
- [`tests/feature/mail_test.go`](https://github.com/krishankumar01/goravel-taskflow/blob/part-17/tests/feature/mail_test.go) sends real email to Mailpit and reads it back through Mailpit's API, with the helpers in `tests/mailpit.go`. It checks that the assignee gets an email, that a manager who takes a task gets none, and that a finished export emails its owner.

The feature tests send real email, so they need Mailpit running, like Postgres and Redis:

```bash
$ go test ./tests/... --env=.env.testing
?   	taskflow/tests	[no test files]
ok  	taskflow/tests/feature	2.020s
ok  	taskflow/tests/unit	0.461s
```

TaskFlow now emails the assignee of each assigned task and the owner of each finished export. All of them still go to Mailpit, because `MAIL_HOST` points there.

---

Previous: [16. Queues & Jobs](https://devchalk.com/goravel/16-queues-jobs-task-export.md)
Series: [Goravel](https://devchalk.com/goravel.md)
