Skip to main content

Email Services Integration

Openlane delivers email through the emailtemplates library with asynchronous processing via the Riverboat job queue. Handlers render a templated message, enqueue it as a job, and dedicated River workers deliver it through the configured provider; nothing sends email inline on the request path.

Architecture Overview​

Email Processing Flow​

Template rendering lives in the emailtemplates library; queueing, delivery, retry, and job-status tracking are handled by Riverboat workers. The sections below cover configuration, delivery behavior, and operational concerns.

Email Template System​

Template Library Integration​

We use the github.com/theopenlane/emailtemplates library for consistent, branded email communications:

// Email configuration in handlers
type Handler struct {
Emailer emailtemplates.Config
DBClient *ent.Client
// ... other fields
}

// Creating verification email
email, err := h.Emailer.NewVerifyEmail(emailtemplates.Recipient{
Email: user.Email,
FirstName: user.FirstName,
LastName: user.LastName,
}, token)

Supported Email Types​

Templates fall into two families: authentication flows (verification, password reset, login notifications) and business notifications (invitations, subscriber confirmations, system alerts). The emailtemplates library holds the full set, so check it rather than this page when you need to confirm whether a template exists.

Template Configuration​

// Email configuration structure
type emailtemplates.Config struct {
// Company Information
CompanyName string `json:"companyName" koanf:"companyName"`
CompanyAddress string `json:"companyAddress" koanf:"companyAddress"`
Corporation string `json:"corporation" koanf:"corporation"`
Year int `json:"year" koanf:"year"`

// Sender Configuration
FromEmail string `json:"fromEmail" koanf:"fromEmail"`
SupportEmail string `json:"supportEmail" koanf:"supportEmail"`
LogoURL string `json:"logoURL" koanf:"logoURL"`

// URL Configuration
URLs struct {
Root string `json:"root" koanf:"root"`
Product string `json:"product" koanf:"product"`
Docs string `json:"docs" koanf:"docs"`
Verify string `json:"verify" koanf:"verify"`
Invite string `json:"invite" koanf:"invite"`
Reset string `json:"reset" koanf:"reset"`
VerifySubscriber string `json:"verifySubscriber" koanf:"verifySubscriber"`
VerifyBilling string `json:"verifyBilling" koanf:"verifyBilling"`
} `json:"urls" koanf:"urls"`

// Template Path
TemplatesPath string `json:"templatesPath" koanf:"templatesPath"`
}

Riverboat Job Queue Integration​

Job Queue Architecture​

We use Riverboat (River) for asynchronous email processing:

// Email job definition
type EmailArgs struct {
Message emailtemplates.Message `json:"message"`
}

// Job insertion for email delivery
_, err = h.DBClient.Job.Insert(ctx, jobs.EmailArgs{
Message: *email,
}, nil)

Email Workers​

Email processing is handled by dedicated workers in the Riverboat system:

# Riverboat worker configuration
riverboat:
workers:
emailWorker:
configDevMode: "false"
configToken: "your-email-service-token"
configFromEmail: "[email protected]"

Job Processing Flow​

  1. Email Creation - Application creates email with template data
  2. Job Queuing - Email job added to River queue with message payload
  3. Worker Processing - Email worker picks up job and renders final template
  4. Delivery Attempt - Worker sends email via configured provider
  5. Status Tracking - Job marked complete or retried on failure
  6. Error Handling - Failed jobs moved to dead letter queue after retries

Email Delivery Methods​

Development Mode​

For development and testing:

// Development configuration
emailConfig := emailtemplates.Config{
DevMode: true, // Logs emails instead of sending
// ... other config
}

Production Delivery​

# SMTP provider configuration
email:
provider: "smtp"
smtp:
host: "smtp.sendgrid.net"
port: 587
username: "apikey"
password: "${SENDGRID_API_KEY}"
tls: true

Email Types and Implementation​

User Verification Email​

// Send verification email during registration
func (h *Handler) sendVerificationEmail(ctx context.Context, user *User, token string) error {
email, err := h.Emailer.NewVerifyEmail(emailtemplates.Recipient{
Email: user.Email,
FirstName: user.FirstName,
LastName: user.LastName,
}, token)
if err != nil {
return err
}

_, err = h.DBClient.Job.Insert(ctx, jobs.EmailArgs{
Message: *email,
}, nil)
return err
}

Password Reset Email​

// Send password reset email
func (h *Handler) sendPasswordResetRequestEmail(ctx context.Context, user *User) error {
email, err := h.Emailer.NewPasswordResetRequestEmail(emailtemplates.Recipient{
Email: user.Email,
FirstName: user.FirstName,
LastName: user.LastName,
}, user.GetPasswordResetToken())
if err != nil {
return err
}

_, err = h.DBClient.Job.Insert(ctx, jobs.EmailArgs{
Message: *email,
}, nil)
return err
}

Invitation Email​

// Send organization invitation email
func (h *Handler) sendInvitationEmail(ctx context.Context, invite *Invite, org *Organization) error {
email, err := h.Emailer.NewInviteEmail(emailtemplates.Recipient{
Email: invite.Recipient,
FirstName: "", // May not be known for invitations
LastName: "",
}, org.DisplayName, invite.Token, invite.Role)
if err != nil {
return err
}

_, err = h.DBClient.Job.Insert(ctx, jobs.EmailArgs{
Message: *email,
}, nil)
return err
}

Subscriber Confirmation Email​

// Send subscriber confirmation email
func (h *Handler) sendSubscriberEmail(ctx context.Context, user *User, orgID string) error {
org, err := h.getOrgByID(ctx, orgID)
if err != nil {
return err
}

email, err := h.Emailer.NewSubscriberEmail(emailtemplates.Recipient{
Email: user.Email,
FirstName: user.FirstName,
LastName: user.LastName,
}, org.DisplayName, user.GetVerificationToken())
if err != nil {
return err
}

_, err = h.DBClient.Job.Insert(ctx, jobs.EmailArgs{
Message: *email,
}, nil)
return err
}

Email Template Customization​

Template Structure​

Email templates support rich HTML formatting with variable substitution:

<!DOCTYPE html>
<html>
<body>
<div style="max-width: 600px; margin: 0 auto;">
<img src="{{.LogoURL}}" alt="{{.CompanyName}}" style="height: 50px;">

<h1>Welcome to {{.CompanyName}}!</h1>

<p>Hello {{.FirstName}},</p>

<p>Please verify your email address by clicking the button below:</p>

<a href="{{.VerifyURL}}" style="background-color: #007bff; color: white; padding: 12px 24px; text-decoration: none; border-radius: 4px;">
Verify Email
</a>

<p>If the button doesn't work, copy and paste this link:</p>
<p><a href="{{.VerifyURL}}">{{.VerifyURL}}</a></p>

<hr>
<p><small>{{.CompanyName}} | {{.CompanyAddress}}</small></p>
</div>
</body>
</html>

Variable Substitution​

Templates support dynamic content via variables:

// Template variables available
type TemplateData struct {
// User information
FirstName string
LastName string
Email string

// Company information
CompanyName string
CompanyAddress string
LogoURL string

// Action URLs
VerifyURL string
ResetURL string
InviteURL string

// Context-specific data
Token string
OrganizationName string
ExpiryTime time.Time
}

Error Handling and Reliability​

Job Retry Logic​

// Email job with retry configuration
type EmailWorker struct {
river.WorkerDefaults[EmailArgs]
}

func (EmailWorker) Work(ctx context.Context, job *river.Job[EmailArgs]) error {
// Attempt email delivery
if err := sendEmail(job.Args.Message); err != nil {
// Return error for automatic retry
return fmt.Errorf("failed to send email: %w", err)
}
return nil
}

// Worker configuration with retry settings
&river.WorkerConfig{
MaxAttempts: 3,
Queue: "email",
Retry: river.RetryConfig{
InitialInterval: 1 * time.Minute,
MaxInterval: 10 * time.Minute,
Multiplier: 2.0,
},
}

Error Monitoring​

// Email delivery metrics
var (
emailsSentTotal = prometheus.NewCounterVec(
prometheus.CounterOpts{
Name: "emails_sent_total",
Help: "Total emails sent",
},
[]string{"type", "status"},
)

emailDeliveryDuration = prometheus.NewHistogramVec(
prometheus.HistogramOpts{
Name: "email_delivery_duration_seconds",
Help: "Email delivery latency",
},
[]string{"type", "provider"},
)
)

Dead Letter Queue​

Failed emails are moved to a dead letter queue for manual review:

-- Query failed email jobs
SELECT
id,
kind,
args,
errors,
attempted_at,
finalized_at
FROM river_job
WHERE state = 'discarded'
AND kind = 'email'
ORDER BY attempted_at DESC;

Configuration Examples​

Development Setup​

# Local development configuration
email:
companyName: "Openlane Dev"
fromEmail: "dev@localhost"
supportEmail: "dev@localhost"
urls:
root: "http://localhost:3001"
product: "http://localhost:3001"
verify: "http://localhost:3001/verify"
# Use console output for development
provider: "console"

Production Setup​

# Production configuration
email:
companyName: "Openlane"
fromEmail: "[email protected]"
supportEmail: "[email protected]"
logoURL: "https://theopenlane.io/assets/logo.png"
urls:
root: "https://theopenlane.io"
product: "https://console.theopenlane.io"
docs: "https://docs.theopenlane.io"
verify: "https://console.theopenlane.io/verify"
invite: "https://console.theopenlane.io/invite"
reset: "https://console.theopenlane.io/reset"
# Use production email service
provider: "sendgrid"
apiKey: "${SENDGRID_API_KEY}"

Domain Inheritance​

# Set global domain for automatic URL configuration
CORE_DOMAIN=theopenlane.io

# Automatically configures:
# - Verify URL: https://console.theopenlane.io/verify
# - Reset URL: https://console.theopenlane.io/reset
# - Invite URL: https://console.theopenlane.io/invite

Monitoring and Observability​

Email Metrics Dashboard​

Key metrics to monitor:

MetricMeaning
Email Queue DepthNumber of pending email jobs
Delivery Success RatePercentage of successfully delivered emails
Delivery LatencyTime from job creation to delivery
Error Rate by TypeFailed deliveries categorized by error type
Template Rendering TimePerformance of template processing

Health Checks​

// Email system health check
func (h *Handler) EmailHealthCheck() error {
// Check job queue connectivity
if err := h.DBClient.Job.Ping(context.Background()); err != nil {
return fmt.Errorf("job queue unhealthy: %w", err)
}

// Check email service connectivity
if err := h.Emailer.HealthCheck(); err != nil {
return fmt.Errorf("email service unhealthy: %w", err)
}

return nil
}

Alerting Thresholds​

# Recommended alerting thresholds
alerts:
email_queue_depth_high:
threshold: 1000
duration: "5m"
email_delivery_failure_rate_high:
threshold: 5 # 5% failure rate
duration: "10m"
email_delivery_latency_high:
threshold: 300 # 5 minutes
duration: "5m"

Testing and Development​

Email Testing Tools​

// Test email delivery in development
func TestEmailDelivery(t *testing.T) {
// Use test configuration
config := emailtemplates.Config{
DevMode: true,
CompanyName: "Test Company",
FromEmail: "[email protected]",
}

// Create test recipient
recipient := emailtemplates.Recipient{
FirstName: "Test",
LastName: "User",
}

// Generate test email
email, err := config.NewVerifyEmail(recipient, "test-token")
assert.NoError(t, err)
assert.Contains(t, email.Subject, "Verify")
assert.Contains(t, email.HTML, "Test")
}

Local SMTP Server​

Use Mailhog for local email testing:

# Start Mailhog for local email testing
docker run -d -p 1025:1025 -p 8025:8025 mailhog/mailhog

# Configure Openlane to use Mailhog
export CORE_EMAIL_SMTP_HOST=localhost
export CORE_EMAIL_SMTP_PORT=1025
export CORE_EMAIL_SMTP_TLS=false

# View emails at http://localhost:8025

Security Considerations​

ConcernMitigation
Sender spoofingSender Policy Framework and DomainKeys Identified Mail (SPF/DKIM) and Domain-based Message Authentication, Reporting, and Conformance (DMARC) records for email authentication
Transport interceptionEncrypted SMTP connections (TLS/SSL)
Email abuseRate limiting on send paths
Cross-site scripting (XSS) in templatesContent sanitization
Stale action linksToken expiry on time-sensitive links
Personally identifiable information (PII) exposureCareful handling of PII in email content and logs
Sensitive job payloadsEncryption of sensitive data in the job queue
AccountabilityAudit logging of email delivery events
Data accumulationRetention policies for email job data
EU recipientsGeneral Data Protection Regulation (GDPR) compliance