DEV Community

Alex Neamtu
Alex Neamtu

Posted on • Originally published at sendrec.eu

How We Built a Welcome Email That Actually Gets Sent

Most SaaS products send a welcome email the moment you sign up. The problem: if the user hasn't confirmed their email yet, you're sending a "start using the product" message to someone who can't log in. They have to find the confirmation email first, click the link, then come back. The welcome email gets buried.

We wanted the welcome email to arrive at exactly the right moment — after the user confirms their email address and can actually use the product.

The confirmation flow

SendRec requires email verification before login. Here's what happens when someone registers:

  1. User fills out the registration form
  2. Backend creates the account with email_verified = false
  3. Confirmation email is sent with a 24-hour token
  4. User clicks the link
  5. Backend sets email_verified = true
  6. User can now log in

Step 5 is where the welcome email belongs. The user just proved they own the email address, and their next action is to log in and start recording. The welcome email should be waiting in their inbox with a direct link.

The implementation

We use Listmonk for transactional email — it's open source, self-hosted, and speaks a simple HTTP API. Our Go backend already had an email client with methods for password resets, comment notifications, view notifications, and confirmation emails. Adding a welcome email followed the same pattern.

The email client

The email package wraps Listmonk's transactional API. Each email type is a method that takes the recipient details and template-specific data:

func (c *Client) SendWelcome(ctx context.Context, toEmail, toName, dashboardURL string) error {
    if c.config.BaseURL == "" {
        slog.Warn("email not configured, welcome email skipped", "recipient", toEmail)
        return nil
    }

    if c.config.WelcomeTemplateID == 0 {
        slog.Warn("welcome template ID not set, skipping welcome email", "recipient", toEmail)
        return nil
    }

    c.ensureSubscriber(ctx, toEmail, toName)

    body := txRequest{
        SubscriberEmail: toEmail,
        TemplateID:      c.config.WelcomeTemplateID,
        Data: map[string]any{
            "name":         toName,
            "dashboardURL": dashboardURL,
        },
        ContentType: "html",
    }

    return c.sendTx(ctx, body)
}
Enter fullscreen mode Exit fullscreen mode

Two guard clauses handle graceful degradation. If Listmonk isn't configured (BaseURL is empty), the method logs a warning and returns nil — self-hosters who don't run Listmonk won't see errors. If the template ID isn't set, same thing. The application never crashes because email is down.

Bypassing the allowlist

We have an email allowlist feature for staging environments. When set, only emails matching specific domains or addresses get sent. This prevents test runs from emailing real users.

But confirmation and welcome emails must always be sent — they're part of the core authentication flow. A user on staging who can't confirm their email can't test anything. So both methods bypass the allowlist check:

// Welcome emails bypass the allowlist — they are part of the core
// onboarding flow and must always be sent after email confirmation.
c.ensureSubscriber(ctx, toEmail, toName)
Enter fullscreen mode Exit fullscreen mode

Compare this with the view notification method, which checks the allowlist first:

if !c.isAllowed(toEmail) {
    return nil
}
Enter fullscreen mode Exit fullscreen mode

Triggering after confirmation

The ConfirmEmail handler already updates email_verified in the database. We added the welcome email call right after:

if _, err := h.db.Exec(r.Context(),
    "UPDATE users SET email_verified = true, updated_at = now() WHERE id = $1",
    userID,
); err != nil {
    httputil.WriteError(w, http.StatusInternalServerError, "failed to verify email")
    return
}

if h.emailSender != nil {
    var userEmail, userName string
    if err := h.db.QueryRow(r.Context(),
        "SELECT email, name FROM users WHERE id = $1", userID,
    ).Scan(&userEmail, &userName); err != nil {
        slog.Error("confirm-email: failed to load user for welcome email", "error", err)
    } else {
        if err := h.emailSender.SendWelcome(r.Context(), userEmail, userName, h.baseURL); err != nil {
            slog.Error("confirm-email: failed to send welcome email", "error", err)
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

A few design decisions here:

The welcome email is best-effort. If the database query fails or Listmonk is down, we log the error but still return a success response. The user confirmed their email — that worked. The welcome email is a nice-to-have, not a gate.

We query for the user's email and name. The ConfirmEmail handler only has the userID from the confirmation token lookup. We need the email and name to personalize the welcome message, so we make one additional query.

The emailSender nil check handles the case where the email system isn't configured at all. This is common in development and testing.

The interface

The EmailSender interface in the auth package defines what the auth handler needs from the email system:

type EmailSender interface {
    SendPasswordReset(ctx context.Context, toEmail, toName, resetLink string) error
    SendConfirmation(ctx context.Context, toEmail, toName, confirmLink string) error
    SendWelcome(ctx context.Context, toEmail, toName, dashboardURL string) error
}
Enter fullscreen mode Exit fullscreen mode

This keeps the auth package decoupled from the email implementation. In tests, we use a mock that records what was called:

type mockEmailSender struct {
    lastEmail        string
    lastName         string
    lastDashboardURL string
    welcomeCalled    bool
    sendErr          error
}

func (m *mockEmailSender) SendWelcome(_ context.Context, toEmail, toName, dashboardURL string) error {
    m.welcomeCalled = true
    m.lastEmail = toEmail
    m.lastName = toName
    m.lastDashboardURL = dashboardURL
    return m.sendErr
}
Enter fullscreen mode Exit fullscreen mode

The Listmonk template

Listmonk templates are managed in its admin UI, not in code. The template uses Go's text/template syntax with Listmonk's .Tx.Data namespace:

<p>Hi {{ .Tx.Data.name }},</p>

<p>Welcome to SendRec. Your account is live and ready to use.</p>

<p>Everything you record stays on EU infrastructure — no US cloud
in the data path, no third-party processors to vet.</p>

<p>Getting started is fast: click "New Recording" from your dashboard,
choose screen, camera, or both, and hit record. When you stop,
your video gets a shareable link instantly.</p>

<p>Your free tier includes 25 videos per month with AI transcription,
timestamped comments, and viewer analytics on every video.
No credit card needed.</p>

<p><a href="{{ .Tx.Data.dashboardURL }}">Record Your First Video</a></p>
Enter fullscreen mode Exit fullscreen mode

The template ID is passed to the application via an environment variable: LISTMONK_WELCOME_TEMPLATE_ID. This means self-hosters can create their own template with different copy, point the env var at it, and everything works.

A routing mistake

The first version linked the CTA button to ${baseURL}/dashboard. When we tested it, the link went to a 404. Our SPA doesn't have a /dashboard route — the root path / loads the Record page, and authenticated users land there after login.

The fix was simple: link to the base URL instead. But it's a good reminder to actually click the links in your emails before shipping them.

Testing

Three tests for the email client method:

func TestSendWelcome_Success(t *testing.T)            // verifies template ID, email, data
func TestSendWelcome_SkipsWhenTemplateIDZero(t *testing.T) // no HTTP call when unconfigured
func TestSendWelcome_BypassesAllowlist(t *testing.T)   // sent even when allowlist blocks
Enter fullscreen mode Exit fullscreen mode

And the existing TestConfirmEmail_Success was updated to verify the welcome email is sent after confirmation:

if !emailSender.welcomeCalled {
    t.Error("expected welcome email to be sent after confirmation")
}
if emailSender.lastEmail != "alice@example.com" {
    t.Errorf("expected welcome email to alice@example.com, got %q", emailSender.lastEmail)
}
Enter fullscreen mode Exit fullscreen mode

The result

The user experience is now: register, receive confirmation email, click the link, receive welcome email with a direct "Record Your First Video" button. Two emails, in the right order, each arriving when they're useful.

The welcome email is the first step in a planned onboarding sequence. Next up: a "share your first video" nudge on day 2 and a Pro upgrade prompt on day 7. All powered by Listmonk's transactional API, all triggered from application events rather than time-based campaigns.

Try it

SendRec is open source (AGPL-3.0) and self-hostable. Register at app.sendrec.eu to see the welcome email in action, or browse the source code.

Top comments (0)