Part 17 of the Goravel series. The original on DevChalk has the colour-matched code, the figures and the full diffs. Code for this part: 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:
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 over SMTP, so to work in this part we are going to use a tool for development called Mailpit. 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:
$ 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:
docker-compose.yml
# ... 19 lines not shown - 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:
.env.example
# ... 28 lines not shown 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_HOSTandMAIL_PORTare where Mailpit listens for SMTP. -
MAIL_FROM_ADDRESSandMAIL_FROM_NAMEfill the From line of every email. -
MAIL_USERNAMEandMAIL_PASSWORDstay 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. So we are going to add a second listener for this event, called EmailAssignee:
$ go run . artisan make:listener EmailAssignee SUCCESS Listener created successfully app/listeners/email_assignee.go
// ... 13 lines not shown type EmailAssignee struct { } // ... 12 lines not shown 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(), 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:
-
Totakes the recipients' addresses. -
Subjectsets the subject line. -
Contentsets the body. With onlyText, the email has a plain-text part and no HTML. -
Sendsends it over SMTP right away.
The chain also has Cc, Bcc, Attach, Headers and From. 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:
bootstrap/app.go
// ... 36 lines not shown WithEvents(func() map[contractsevent.Event][]contractsevent.Listener { return map[contractsevent.Event][]contractsevent.Listener{ - &events.TaskAssigned{}: {&listeners.NotifyAssignee{}}, + &events.TaskAssigned{}: {&listeners.NotifyAssignee{}, &listeners.EmailAssignee{}}, } }). // ... 41 lines not shown To try it, we start from the same setup as Queues & Jobs:
- Reset the database with
migrate:fresh --seed, which adds Ana as the manager, plus three members and seven tasks. - Register Ben. He gets id 5.
- Log in as Ana and as Ben, and save each token in a variable,
ANAandBEN.
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:
$ 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:
$ go run . artisan make:mail Assignment SUCCESS Mail created successfully app/mails/assignment.go
// ... 9 lines not shown 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{} } -
NewAssignmenttakes the task and both users, so the Mailable has everything the email shows. -
Contentnames the two views, one for the HTML part and one for the text part.Withis the data they can use. -
Headers,AttachmentsandQueuecome frommake:mailand stay empty.Queuematters later, when we queue the export email.
In the listener, Send now takes the Mailable in place of the inline calls:
app/listeners/email_assignee.go
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" ) // ... 41 lines not shown 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:
resources/views/mail/assignment.html
<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> 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 & where the description has &:
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. Goravel's engine has two limitations for these emails:
- It renders the text view with
html/templatetoo, 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, a Go engine with Django's template syntax, because its views can extend a layout and switch escaping off for the text part:
$ 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:
app/mails/pongo.go
// ... 7 lines not shown 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)) } -
NewPongoloads the views from one folder,resources/mail. -
FromCacheparses a view the first time it's used and keeps it for later emails. -
Rendergets the Mailable'sWithasany, 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:
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> resources/mail/assignment.html
{% 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 %} 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'scontentblock into the layout. -
{{ Task.Title }}prints a value, and pongo2 escapes it for HTML, ashtml/templatedoes. -
{% if Task.DueAt %}is false whenDueAtis 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:
config/mail.go
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 // ... 37 lines not shown // 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:
$ 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:
app/jobs/export_tasks.go
// ... 13 lines not shown "taskflow/app/enums" "taskflow/app/facades" + "taskflow/app/mails" "taskflow/app/models" ) // ... 58 lines not shown 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:
app/mails/export_ready.go
// ... 7 lines not shown type ExportReady struct { export models.Export user models.User } func NewExportReady(export models.Export, user models.User) *ExportReady { return &ExportReady{export: export, user: user} } // ... 7 lines not shown 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{} } 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 %} 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:
$ 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:
$ 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"} 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:
$ 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:
$ 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:
$ 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().QueueSource:https://dev.to/krishankumar01/goravel-mail-mailables-templates-and-queues-1def
