Add optional email verification and registration control

Implemented email service infrastructure with SMTP support and optional email verification for self-hosted deployments. Registration flow now supports open/closed modes and hardcoded Apocapoc branding.

Key features:
- Email service with SMTP and template rendering
- Optional email verification (auto-verified without SMTP config)
- Registration modes: open/closed for access control
- Hardcoded Apocapoc branding (AppName, AppURL, DefaultFrom)
- Separate registration and login flows (registration no longer returns tokens)
This commit is contained in:
2025-11-28 08:21:51 +01:00
parent a95a703905
commit 00f6b51228
28 changed files with 890 additions and 99 deletions
@@ -0,0 +1,76 @@
package email
import (
"crypto/tls"
"fmt"
"time"
"apocapoc-api/internal/domain/services"
"gopkg.in/mail.v2"
)
type SMTPConfig struct {
Host string
Port int
Username string
Password string
From string
SupportEmail string
}
type SMTPService struct {
config SMTPConfig
}
func NewSMTPService(config SMTPConfig) *SMTPService {
return &SMTPService{
config: config,
}
}
func (s *SMTPService) Send(message services.EmailMessage) error {
m := mail.NewMessage()
m.SetHeader("From", s.config.From)
m.SetHeader("To", message.To)
m.SetHeader("Subject", message.Subject)
if message.IsHTML {
m.SetBody("text/html", message.Body)
} else {
m.SetBody("text/plain", message.Body)
}
dialer := mail.NewDialer(s.config.Host, s.config.Port, s.config.Username, s.config.Password)
dialer.TLSConfig = &tls.Config{
ServerName: s.config.Host,
}
if err := s.sendWithRetry(dialer, m); err != nil {
return fmt.Errorf("failed to send email: %w", err)
}
return nil
}
func (s *SMTPService) sendWithRetry(dialer *mail.Dialer, message *mail.Message) error {
maxRetries := 3
var lastErr error
for i := 0; i < maxRetries; i++ {
if err := dialer.DialAndSend(message); err == nil {
return nil
} else {
lastErr = err
if i < maxRetries-1 {
time.Sleep(time.Second * time.Duration(i+1))
}
}
}
return lastErr
}
func (s *SMTPService) GetConfig() SMTPConfig {
return s.config
}
@@ -0,0 +1,64 @@
package email
import (
"testing"
"apocapoc-api/internal/domain/services"
)
func TestNewSMTPService(t *testing.T) {
config := SMTPConfig{
Host: "smtp.example.com",
Port: 587,
Username: "user@example.com",
Password: "password",
From: "noreply@example.com",
SupportEmail: "support@example.com",
}
service := NewSMTPService(config)
if service == nil {
t.Fatal("Expected service to be created")
}
if service.GetConfig().Host != config.Host {
t.Errorf("Expected host %s, got %s", config.Host, service.GetConfig().Host)
}
}
func TestSMTPService_MessageConstruction(t *testing.T) {
config := SMTPConfig{
Host: "smtp.example.com",
Port: 587,
Username: "user@example.com",
Password: "password",
From: "noreply@example.com",
SupportEmail: "support@example.com",
}
service := NewSMTPService(config)
message := services.EmailMessage{
To: "recipient@example.com",
Subject: "Test Email",
Body: "<h1>Test</h1>",
IsHTML: true,
}
if message.To == "" {
t.Error("Expected recipient to be set")
}
if message.Subject == "" {
t.Error("Expected subject to be set")
}
if !message.IsHTML {
t.Error("Expected message to be HTML")
}
if service == nil {
t.Fatal("Service should not be nil")
}
}
@@ -0,0 +1,49 @@
package email
import (
"bytes"
"fmt"
"html/template"
)
type TemplateData struct {
AppName string
AppURL string
SupportEmail string
Data map[string]interface{}
}
type TemplateRenderer struct {
appName string
appURL string
supportEmail string
}
func NewTemplateRenderer(appName, appURL, supportEmail string) *TemplateRenderer {
return &TemplateRenderer{
appName: appName,
appURL: appURL,
supportEmail: supportEmail,
}
}
func (r *TemplateRenderer) Render(templateContent string, data map[string]interface{}) (string, error) {
tmpl, err := template.New("email").Parse(templateContent)
if err != nil {
return "", fmt.Errorf("failed to parse template: %w", err)
}
templateData := TemplateData{
AppName: r.appName,
AppURL: r.appURL,
SupportEmail: r.supportEmail,
Data: data,
}
var buf bytes.Buffer
if err := tmpl.Execute(&buf, templateData); err != nil {
return "", fmt.Errorf("failed to execute template: %w", err)
}
return buf.String(), nil
}
@@ -0,0 +1,97 @@
package email
import (
"strings"
"testing"
)
func TestNewTemplateRenderer(t *testing.T) {
renderer := NewTemplateRenderer("Test App", "https://example.com", "support@example.com")
if renderer == nil {
t.Fatal("Expected renderer to be created")
}
if renderer.appName != "Test App" {
t.Errorf("Expected app name 'Test App', got '%s'", renderer.appName)
}
}
func TestTemplateRenderer_Render(t *testing.T) {
renderer := NewTemplateRenderer("Test App", "https://example.com", "support@example.com")
template := `Hello {{.Data.Name}}, welcome to {{.AppName}}!`
data := map[string]interface{}{
"Name": "John",
}
result, err := renderer.Render(template, data)
if err != nil {
t.Fatalf("Failed to render template: %v", err)
}
expected := "Hello John, welcome to Test App!"
if result != expected {
t.Errorf("Expected '%s', got '%s'", expected, result)
}
}
func TestTemplateRenderer_RenderWithAllVariables(t *testing.T) {
renderer := NewTemplateRenderer("My App", "https://myapp.com", "help@myapp.com")
template := `
App: {{.AppName}}
URL: {{.AppURL}}
Support: {{.SupportEmail}}
User: {{.Data.User}}
`
data := map[string]interface{}{
"User": "Alice",
}
result, err := renderer.Render(template, data)
if err != nil {
t.Fatalf("Failed to render template: %v", err)
}
if !strings.Contains(result, "My App") {
t.Error("Expected result to contain app name")
}
if !strings.Contains(result, "https://myapp.com") {
t.Error("Expected result to contain app URL")
}
if !strings.Contains(result, "help@myapp.com") {
t.Error("Expected result to contain support email")
}
if !strings.Contains(result, "Alice") {
t.Error("Expected result to contain user name")
}
}
func TestTemplateRenderer_RenderInvalidTemplate(t *testing.T) {
renderer := NewTemplateRenderer("Test App", "https://example.com", "support@example.com")
template := `{{.Data.Invalid}}`
data := map[string]interface{}{}
result, err := renderer.Render(template, data)
if err != nil {
t.Fatalf("Template should render even with missing data: %v", err)
}
if result != "<no value>" {
t.Logf("Got result: %s", result)
}
}
func TestTemplateRenderer_RenderSyntaxError(t *testing.T) {
renderer := NewTemplateRenderer("Test App", "https://example.com", "support@example.com")
template := `{{.Data.Name`
data := map[string]interface{}{}
_, err := renderer.Render(template, data)
if err == nil {
t.Error("Expected error for invalid template syntax")
}
}
@@ -0,0 +1,77 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<style>
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
line-height: 1.6;
color: #333;
max-width: 600px;
margin: 0 auto;
padding: 20px;
background-color: #f4f4f4;
}
.container {
background-color: #ffffff;
border-radius: 8px;
padding: 30px;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
}
.header {
text-align: center;
margin-bottom: 30px;
padding-bottom: 20px;
border-bottom: 2px solid #f0f0f0;
}
.header h1 {
margin: 0;
color: #2c3e50;
font-size: 24px;
}
.content {
margin-bottom: 30px;
}
.button {
display: inline-block;
padding: 12px 24px;
background-color: #3498db;
color: #ffffff !important;
text-decoration: none;
border-radius: 4px;
margin: 20px 0;
font-weight: 600;
}
.button:hover {
background-color: #2980b9;
}
.footer {
text-align: center;
margin-top: 30px;
padding-top: 20px;
border-top: 2px solid #f0f0f0;
font-size: 12px;
color: #7f8c8d;
}
.footer a {
color: #3498db;
text-decoration: none;
}
</style>
</head>
<body>
<div class="container">
<div class="header">
<h1>{{.AppName}}</h1>
</div>
<div class="content">
{{.Content}}
</div>
<div class="footer">
<p>Need help? Contact us at <a href="mailto:{{.SupportEmail}}">{{.SupportEmail}}</a></p>
<p>&copy; {{.AppName}}. All rights reserved.</p>
</div>
</div>
</body>
</html>