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:
@@ -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>© {{.AppName}}. All rights reserved.</p>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user