Skip to content

6. Email the author

Since v0.3.0

When someone comments on an issue, its author gets an email. The comment handler of part 4 already emits CommentAdded; you add the email and a listener that sends it from a queue job, so a slow mail server never slows the page down, and a failed send is retried.

The email

Create app/mailers/comment.go, in package mailers (where make:auth put the account emails):

import (
	"context"

	"anetos.dev/anetos/mailer"

	"tracker/views"
)

(Copied from examples/tutorial/app/mailers/comment.go, region imports.)

// NewComment tells an issue's author that someone commented on it.
type NewComment struct {
	Name, Email string // the author's
	Issue       string // the issue's title
	By, Body    string // the comment's author and text
	URL         string // the issue's page
}

// Build implements mailer.Mailable.
func (m NewComment) Build(context.Context) (*mailer.Message, error) {
	return &mailer.Message{
		To:      []mailer.Address{{Address: m.Email}},
		Subject: "New comment on " + m.Issue,
		HTML:    views.NewCommentMail(m.Name, m.Issue, m.By, m.Body, m.URL),
	}, nil
}

(Copied from examples/tutorial/app/mailers/comment.go, region mailable.)

And its HTML, views/comment_mail.templ, in the frame of the account emails (authMail, from make:auth):

// NewCommentMail is the email about a new comment.
templ NewCommentMail(name, issue, by, body, url string) {
	@authMail() {
		<p>Hello { name },</p>
		<p>{ by } commented on “{ issue }”:</p>
		<blockquote>{ body }</blockquote>
		<p><a href={ templ.SafeURL(url) }>See the issue</a></p>
	}
}

(Copied from examples/tutorial/views/comment_mail.templ, region mail.)

The listener

Create app/listeners/comments.go, a new package, listeners:

import (
	"context"
	"errors"
	"fmt"

	"anetos.dev/anetos/db"
	"anetos.dev/anetos/mailer"

	"tracker/app/mailers"
	"tracker/app/models"
)

(Copied from examples/tutorial/app/listeners/comments.go, region imports.)

// EmailAuthor emails an issue's author about a new comment, unless they
// wrote it. It runs as a queue job (events.OnQueued), after the comment
// is committed: retried if the mail server fails.
func EmailAuthor(ctx context.Context, e models.CommentAdded) error {
	comment, err := db.Query[models.Comment](ctx).With(models.CommentRels.Author).Find(e.CommentID)
	if errors.Is(err, db.ErrNotFound) {
		return nil // deleted since
	}
	if err != nil {
		return err
	}
	issue, err := db.Query[models.Issue](ctx).With(models.IssueRels.Author).Find(comment.IssueID)
	if err != nil {
		return err
	}
	if issue.AuthorID == comment.AuthorID {
		return nil
	}
	url, err := mailer.URL(ctx, fmt.Sprintf("/issues/%d", issue.ID)) // on APP_URL
	if err != nil {
		return err
	}
	return mailer.Send(ctx, mailers.NewComment{
		Name: issue.Author.Name, Email: issue.Author.Email, Issue: issue.Title,
		By: comment.Author.Name, Body: comment.Body, URL: url,
	})
}

(Copied from examples/tutorial/app/listeners/comments.go, region listener.)

The listener reads the comment again rather than trusting the event’s contents: it runs later, maybe after a retry. mailer.URL makes the link absolute, on APP_URL, since an email leaves the app.

Add it in main.go: import "tracker/app/listeners", and where setup sets up the events, replace the events.ForApp lines with:

bus, err := events.ForApp(app)
if err != nil {
	return nil, err
}
// The author's email about a new comment, sent by a queue worker.
if err := events.OnQueued(bus, listeners.EmailAuthor); err != nil {
	return nil, err
}

(Copied from examples/tutorial/main.go, region listeners.)

events.OnQueued turns each CommentAdded into a job, once the comment’s transaction commits. The queue’s workers run it: in development they run in the app (QUEUE_DRIVER=database in .env); in production, in the same binary or in processes of their own (run --only=workers).

Try it

Register a second user in another browser (or a private window), comment on the first user’s issue, and look in the anetos dev terminal: the email is in the log, a moment later.

Test it

notify_test.go, in package main:

import (
	"net/url"
	"testing"

	"anetos.dev/anetos/anetostest"

	"tracker/app/mailers"
	"tracker/app/models"
	"tracker/database/factories"
)

(Copied from examples/tutorial/notify_test.go, region imports.)

// The author gets an email when someone else comments. In tests the
// queue runs jobs at once, and emails are kept, not sent.
func TestEmailAuthor(t *testing.T) {
	app := anetostest.New(t, setup)
	ada := anetostest.Create(app, factories.Users)
	grace := anetostest.Create(app, factories.Users)
	anetostest.Create(app, factories.Issues.With(func(i *models.Issue) { i.Title, i.AuthorID = "Login fails", ada.ID }))

	anetostest.ActingAs(app, &ada)
	app.PostForm("/issues/1/comments", url.Values{"body": {"More details."}}).AssertRedirect("/issues/1")
	anetostest.AssertMailNotSent(app, func(m mailers.NewComment) bool { return true }) // her own

	anetostest.ActingAs(app, &grace)
	app.PostForm("/issues/1/comments", url.Values{"body": {"Same here."}}).AssertRedirect("/issues/1")
	anetostest.AssertMailSent(app, func(m mailers.NewComment) bool {
		return m.Email == ada.Email && m.By == grace.Name && m.URL == "http://example.test/issues/1"
	})
}

(Copied from examples/tutorial/notify_test.go, region test.)

The events, queues and mail guides go further: delays, retries, SMTP and Postmark.

Next: 7. Deploy.