feat(PROJ-28): Self-Service Onboarding — Signup, Verify, Password Reset, Invites
- internal/mailer: SMTP-Out via net/smtp (TLS + STARTTLS), HTML+Text-Templates - internal/tokenstore: auth_tokens Tabelle, SHA-256-Hash, TTL, einmalig verwendbar - userstore: CreateInactive(), Activate(), GetByEmail(), SetPassword() - API: POST /signup, GET /verify, POST /forgot-password, POST /reset-password - API: POST /admin/invite (domain_admin+), GET /auth/invite?token (check) - Login-Seite: Links zu "Passwort vergessen" und "Registrieren" - Frontend: /signup, /verify, /forgot-password, /reset-password Seiten - server.fqdn nicht konfiguriert → Startup-Warnung, Self-Service deaktiviert - LDAP-Nutzer: Passwort-Reset abgewiesen Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,152 @@
|
||||
// Package mailer sends transactional emails via an outbound SMTP relay.
|
||||
// It uses only the Go standard library (net/smtp + crypto/tls) to avoid
|
||||
// adding external dependencies.
|
||||
package mailer
|
||||
|
||||
import (
|
||||
"crypto/tls"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/smtp"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/archivmail/config"
|
||||
)
|
||||
|
||||
// Mailer sends transactional emails via the configured SMTP-Out relay.
|
||||
type Mailer struct {
|
||||
cfg config.SMTPOutConfig
|
||||
}
|
||||
|
||||
// New creates a Mailer from the smtp_out config section.
|
||||
func New(cfg config.SMTPOutConfig) *Mailer {
|
||||
return &Mailer{cfg: cfg}
|
||||
}
|
||||
|
||||
// IsConfigured returns true when the smtp_out config is usable (host + from set).
|
||||
func (m *Mailer) IsConfigured() bool {
|
||||
return m.cfg.Host != "" && m.cfg.From != ""
|
||||
}
|
||||
|
||||
// Send sends an HTML + plaintext email to a single recipient.
|
||||
func (m *Mailer) Send(to, subject, htmlBody, textBody string) error {
|
||||
if !m.IsConfigured() {
|
||||
return fmt.Errorf("mailer: smtp_out not configured")
|
||||
}
|
||||
|
||||
addr := fmt.Sprintf("%s:%d", m.cfg.Host, port(m.cfg.Port))
|
||||
|
||||
msg := buildMIME(m.cfg.From, to, subject, htmlBody, textBody)
|
||||
|
||||
var auth smtp.Auth
|
||||
if m.cfg.User != "" {
|
||||
auth = smtp.PlainAuth("", m.cfg.User, m.cfg.Password, m.cfg.Host)
|
||||
}
|
||||
|
||||
if m.cfg.TLS {
|
||||
return sendTLS(addr, m.cfg.Host, auth, m.cfg.From, to, msg)
|
||||
}
|
||||
return sendSTARTTLS(addr, auth, m.cfg.From, to, msg)
|
||||
}
|
||||
|
||||
func port(p int) int {
|
||||
if p == 0 {
|
||||
return 587
|
||||
}
|
||||
return p
|
||||
}
|
||||
|
||||
// sendTLS connects directly via TLS (port 465).
|
||||
func sendTLS(addr, host string, auth smtp.Auth, from, to string, msg []byte) error {
|
||||
tlsCfg := &tls.Config{ServerName: host, MinVersion: tls.VersionTLS12}
|
||||
conn, err := tls.DialWithDialer(&net.Dialer{Timeout: 10 * time.Second}, "tcp", addr, tlsCfg)
|
||||
if err != nil {
|
||||
return fmt.Errorf("mailer: tls dial: %w", err)
|
||||
}
|
||||
defer conn.Close()
|
||||
|
||||
c, err := smtp.NewClient(conn, host)
|
||||
if err != nil {
|
||||
return fmt.Errorf("mailer: smtp client: %w", err)
|
||||
}
|
||||
defer c.Close()
|
||||
|
||||
if auth != nil {
|
||||
if err := c.Auth(auth); err != nil {
|
||||
return fmt.Errorf("mailer: auth: %w", err)
|
||||
}
|
||||
}
|
||||
return send(c, from, to, msg)
|
||||
}
|
||||
|
||||
// sendSTARTTLS connects plain and upgrades via STARTTLS (port 587).
|
||||
func sendSTARTTLS(addr string, auth smtp.Auth, from, to string, msg []byte) error {
|
||||
c, err := smtp.Dial(addr)
|
||||
if err != nil {
|
||||
return fmt.Errorf("mailer: dial: %w", err)
|
||||
}
|
||||
defer c.Close()
|
||||
|
||||
host, _, _ := net.SplitHostPort(addr)
|
||||
if ok, _ := c.Extension("STARTTLS"); ok {
|
||||
tlsCfg := &tls.Config{ServerName: host, MinVersion: tls.VersionTLS12}
|
||||
if err := c.StartTLS(tlsCfg); err != nil {
|
||||
return fmt.Errorf("mailer: starttls: %w", err)
|
||||
}
|
||||
}
|
||||
if auth != nil {
|
||||
if err := c.Auth(auth); err != nil {
|
||||
return fmt.Errorf("mailer: auth: %w", err)
|
||||
}
|
||||
}
|
||||
return send(c, from, to, msg)
|
||||
}
|
||||
|
||||
func send(c *smtp.Client, from, to string, msg []byte) error {
|
||||
if err := c.Mail(from); err != nil {
|
||||
return fmt.Errorf("mailer: MAIL FROM: %w", err)
|
||||
}
|
||||
if err := c.Rcpt(to); err != nil {
|
||||
return fmt.Errorf("mailer: RCPT TO: %w", err)
|
||||
}
|
||||
wc, err := c.Data()
|
||||
if err != nil {
|
||||
return fmt.Errorf("mailer: DATA: %w", err)
|
||||
}
|
||||
defer wc.Close()
|
||||
if _, err := wc.Write(msg); err != nil {
|
||||
return fmt.Errorf("mailer: write: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// buildMIME constructs a multipart/alternative MIME message.
|
||||
func buildMIME(from, to, subject, htmlBody, textBody string) []byte {
|
||||
boundary := "----=archivmail_boundary_20260101"
|
||||
var b strings.Builder
|
||||
|
||||
b.WriteString("From: " + from + "\r\n")
|
||||
b.WriteString("To: " + to + "\r\n")
|
||||
b.WriteString("Subject: " + subject + "\r\n")
|
||||
b.WriteString("MIME-Version: 1.0\r\n")
|
||||
b.WriteString(`Content-Type: multipart/alternative; boundary="` + boundary + `"` + "\r\n")
|
||||
b.WriteString("\r\n")
|
||||
|
||||
// Plaintext part
|
||||
b.WriteString("--" + boundary + "\r\n")
|
||||
b.WriteString("Content-Type: text/plain; charset=UTF-8\r\n")
|
||||
b.WriteString("Content-Transfer-Encoding: quoted-printable\r\n")
|
||||
b.WriteString("\r\n")
|
||||
b.WriteString(textBody + "\r\n")
|
||||
|
||||
// HTML part
|
||||
b.WriteString("--" + boundary + "\r\n")
|
||||
b.WriteString("Content-Type: text/html; charset=UTF-8\r\n")
|
||||
b.WriteString("Content-Transfer-Encoding: quoted-printable\r\n")
|
||||
b.WriteString("\r\n")
|
||||
b.WriteString(htmlBody + "\r\n")
|
||||
|
||||
b.WriteString("--" + boundary + "--\r\n")
|
||||
return []byte(b.String())
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
package mailer
|
||||
|
||||
import "fmt"
|
||||
|
||||
// ── Email templates ───────────────────────────────────────────────────────────
|
||||
// Simple inline templates. No embed.FS needed at this stage.
|
||||
|
||||
// VerifyEmailHTML returns the HTML body for an email verification message.
|
||||
func VerifyEmailHTML(fqdn, token, username string) string {
|
||||
link := fmt.Sprintf("https://%s/verify?token=%s", fqdn, token)
|
||||
return fmt.Sprintf(`<!DOCTYPE html>
|
||||
<html><body style="font-family:sans-serif;max-width:600px;margin:40px auto;color:#333">
|
||||
<h2>E-Mail-Adresse bestätigen</h2>
|
||||
<p>Hallo %s,</p>
|
||||
<p>Bitte bestätige deine E-Mail-Adresse, um deinen archivmail-Account zu aktivieren.</p>
|
||||
<p><a href="%s" style="background:#2563eb;color:#fff;padding:12px 24px;text-decoration:none;border-radius:6px;display:inline-block">E-Mail bestätigen</a></p>
|
||||
<p style="color:#666;font-size:13px">Der Link ist 24 Stunden gültig.<br>Falls du dich nicht registriert hast, kannst du diese E-Mail ignorieren.</p>
|
||||
<p style="color:#999;font-size:12px">%s</p>
|
||||
</body></html>`, username, link, link)
|
||||
}
|
||||
|
||||
// VerifyEmailText returns the plain-text body for email verification.
|
||||
func VerifyEmailText(fqdn, token, username string) string {
|
||||
link := fmt.Sprintf("https://%s/verify?token=%s", fqdn, token)
|
||||
return fmt.Sprintf("Hallo %s,\n\nbitte bestätige deine E-Mail-Adresse:\n\n%s\n\nDer Link ist 24 Stunden gültig.\n\narcivmail", username, link)
|
||||
}
|
||||
|
||||
// ResetPasswordHTML returns the HTML body for a password-reset message.
|
||||
func ResetPasswordHTML(fqdn, token, username string) string {
|
||||
link := fmt.Sprintf("https://%s/reset-password?token=%s", fqdn, token)
|
||||
return fmt.Sprintf(`<!DOCTYPE html>
|
||||
<html><body style="font-family:sans-serif;max-width:600px;margin:40px auto;color:#333">
|
||||
<h2>Passwort zurücksetzen</h2>
|
||||
<p>Hallo %s,</p>
|
||||
<p>Du hast eine Passwort-Reset-Anfrage gestellt. Klicke auf den Link, um ein neues Passwort zu setzen.</p>
|
||||
<p><a href="%s" style="background:#2563eb;color:#fff;padding:12px 24px;text-decoration:none;border-radius:6px;display:inline-block">Passwort zurücksetzen</a></p>
|
||||
<p style="color:#666;font-size:13px">Der Link ist 1 Stunde gültig und kann nur einmal verwendet werden.<br>Falls du kein Passwort zurücksetzen wolltest, ignoriere diese E-Mail.</p>
|
||||
<p style="color:#999;font-size:12px">%s</p>
|
||||
</body></html>`, username, link, link)
|
||||
}
|
||||
|
||||
// ResetPasswordText returns the plain-text body for password reset.
|
||||
func ResetPasswordText(fqdn, token, username string) string {
|
||||
link := fmt.Sprintf("https://%s/reset-password?token=%s", fqdn, token)
|
||||
return fmt.Sprintf("Hallo %s,\n\nPasswort zurücksetzen:\n\n%s\n\nDer Link ist 1 Stunde gültig.\n\narcivmail", username, link)
|
||||
}
|
||||
|
||||
// InviteHTML returns the HTML body for a tenant invitation.
|
||||
func InviteHTML(fqdn, token, tenantName string) string {
|
||||
link := fmt.Sprintf("https://%s/signup?invite=%s", fqdn, token)
|
||||
return fmt.Sprintf(`<!DOCTYPE html>
|
||||
<html><body style="font-family:sans-serif;max-width:600px;margin:40px auto;color:#333">
|
||||
<h2>Einladung zu %s</h2>
|
||||
<p>Du wurdest eingeladen, dem archivmail-System beizutreten.</p>
|
||||
<p><a href="%s" style="background:#2563eb;color:#fff;padding:12px 24px;text-decoration:none;border-radius:6px;display:inline-block">Einladung annehmen</a></p>
|
||||
<p style="color:#666;font-size:13px">Der Link ist 72 Stunden gültig und kann nur einmal verwendet werden.</p>
|
||||
<p style="color:#999;font-size:12px">%s</p>
|
||||
</body></html>`, tenantName, link, link)
|
||||
}
|
||||
|
||||
// InviteText returns the plain-text body for a tenant invitation.
|
||||
func InviteText(fqdn, token, tenantName string) string {
|
||||
link := fmt.Sprintf("https://%s/signup?invite=%s", fqdn, token)
|
||||
return fmt.Sprintf("Einladung zu %s:\n\n%s\n\nDer Link ist 72 Stunden gültig.\n\narcivmail", tenantName, link)
|
||||
}
|
||||
Reference in New Issue
Block a user