Add login rename and password reset flow

This commit is contained in:
DonVoo 2026-07-13 01:19:47 +02:00
parent b625c95c29
commit e7611b2d49
4 changed files with 362 additions and 28 deletions

View file

@ -17,6 +17,8 @@ func RegisterRoutes(mux *http.ServeMux) {
mux.HandleFunc("/", homeHandler)
mux.HandleFunc("/login", loginHandler)
mux.HandleFunc("/logout", logoutHandler)
mux.HandleFunc("/password/forgot", forgotPasswordHandler)
mux.HandleFunc("/password/reset", resetPasswordHandler)
mux.HandleFunc("/users", usersHandler)
mux.HandleFunc("/users/save", userSaveHandler)
mux.HandleFunc("/users/toggle-active", userToggleActiveHandler)
@ -64,9 +66,9 @@ func renderShell(w http.ResponseWriter, active, readingPane string) {
fmt.Fprintf(w, `<!doctype html><html lang="de"><head><meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Mail-Graveyard</title>
<link rel="stylesheet" href="/static/style.css?v=20260712-17">
<link rel="stylesheet" href="/static/style.css?v=20260712-18">
<script src="/static/htmx.min.js"></script>
<script src="/static/app.js?v=20260712-17" defer></script></head>
<script src="/static/app.js?v=20260712-18" defer></script></head>
<body class="ol2013">
<header class="ribbon">
<button class="app-menu-button" type="button" aria-label="Mail-Graveyard-Menue" aria-expanded="false" data-backstage-toggle><span class="mail-logo" aria-hidden="true"></span></button>
@ -384,6 +386,7 @@ func userSaveHandler(w http.ResponseWriter, r *http.Request) {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
oldUsername := strings.TrimSpace(r.FormValue("old_username"))
username := strings.TrimSpace(r.FormValue("username"))
displayName := strings.TrimSpace(r.FormValue("display_name"))
password := r.FormValue("password")
@ -393,9 +396,13 @@ func userSaveHandler(w http.ResponseWriter, r *http.Request) {
redirectUsers(w, r, "", "Benutzername fehlt.")
return
}
existing, existingErr := GetAppUser(username)
lookupUsername := username
if oldUsername != "" {
lookupUsername = oldUsername
}
existing, existingErr := GetAppUser(lookupUsername)
if !IsAdmin(r) && existingErr == nil && existing.Role != roleUser {
redirectUsers(w, r, username, "Nur Admins duerfen Verwalter oder Admins bearbeiten.")
redirectUsers(w, r, oldUsername, "Nur Admins duerfen Verwalter oder Admins bearbeiten.")
return
}
if !IsAdmin(r) {
@ -414,8 +421,8 @@ func userSaveHandler(w http.ResponseWriter, r *http.Request) {
return
}
}
if err := SaveAppUser(username, displayName, hash, role, active); err != nil {
redirectUsers(w, r, username, err.Error())
if err := UpdateAppUser(oldUsername, username, displayName, hash, role, active); err != nil {
redirectUsers(w, r, oldUsername, err.Error())
return
}
redirectUsers(w, r, username, "Benutzer gespeichert.")
@ -504,9 +511,9 @@ func renderAccountsPage(w http.ResponseWriter, accounts []Account, edit Account,
fmt.Fprintf(w, `<!doctype html><html lang="de"><head><meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Konten-Verwaltung - Mail-Graveyard</title>
<link rel="stylesheet" href="/static/style.css?v=20260712-17">
<link rel="stylesheet" href="/static/style.css?v=20260712-18">
<script src="/static/htmx.min.js"></script>
<script src="/static/app.js?v=20260712-17" defer></script></head>
<script src="/static/app.js?v=20260712-18" defer></script></head>
<body class="ol2013">
<header class="ribbon">
<button class="app-menu-button" type="button" aria-label="Mail-Graveyard-Menue" aria-expanded="false" data-backstage-toggle><span class="mail-logo" aria-hidden="true"></span></button>
@ -549,9 +556,9 @@ func renderArchivesPage(w http.ResponseWriter, archives []string, accounts []Acc
fmt.Fprintf(w, `<!doctype html><html lang="de"><head><meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Archiv-mbox Verwaltung - Mail-Graveyard</title>
<link rel="stylesheet" href="/static/style.css?v=20260712-17">
<link rel="stylesheet" href="/static/style.css?v=20260712-18">
<script src="/static/htmx.min.js"></script>
<script src="/static/app.js?v=20260712-17" defer></script></head>
<script src="/static/app.js?v=20260712-18" defer></script></head>
<body class="ol2013">
<header class="ribbon">
<button class="app-menu-button" type="button" aria-label="Mail-Graveyard-Menue" aria-expanded="false" data-backstage-toggle><span class="mail-logo" aria-hidden="true"></span></button>
@ -597,9 +604,9 @@ func renderUsersPage(w http.ResponseWriter, r *http.Request, users []AppUser, ed
fmt.Fprintf(w, `<!doctype html><html lang="de"><head><meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Benutzerverwaltung - Mail-Graveyard</title>
<link rel="stylesheet" href="/static/style.css?v=20260712-17">
<link rel="stylesheet" href="/static/style.css?v=20260712-18">
<script src="/static/htmx.min.js"></script>
<script src="/static/app.js?v=20260712-17" defer></script></head>
<script src="/static/app.js?v=20260712-18" defer></script></head>
<body class="ol2013">
<header class="ribbon">
<button class="app-menu-button" type="button" aria-label="Mail-Graveyard-Menue" aria-expanded="false" data-backstage-toggle><span class="mail-logo" aria-hidden="true"></span></button>
@ -667,9 +674,9 @@ func renderTransferPage(w http.ResponseWriter, mode transferMode, accounts []Acc
fmt.Fprintf(w, `<!doctype html><html lang="de"><head><meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>%s - Mail-Graveyard</title>
<link rel="stylesheet" href="/static/style.css?v=20260712-17">
<link rel="stylesheet" href="/static/style.css?v=20260712-18">
<script src="/static/htmx.min.js"></script>
<script src="/static/app.js?v=20260712-17" defer></script></head>
<script src="/static/app.js?v=20260712-18" defer></script></head>
<body class="ol2013">
<header class="ribbon">
<button class="app-menu-button" type="button" aria-label="Mail-Graveyard-Menue" aria-expanded="false" data-backstage-toggle><span class="mail-logo" aria-hidden="true"></span></button>
@ -708,9 +715,9 @@ func renderSourceEmailBoxPage(w http.ResponseWriter, accounts []Account) {
fmt.Fprintf(w, `<!doctype html><html lang="de"><head><meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Quell-Postfach - Mail-Graveyard</title>
<link rel="stylesheet" href="/static/style.css?v=20260712-17">
<link rel="stylesheet" href="/static/style.css?v=20260712-18">
<script src="/static/htmx.min.js"></script>
<script src="/static/app.js?v=20260712-17" defer></script></head>
<script src="/static/app.js?v=20260712-18" defer></script></head>
<body class="ol2013">
<header class="ribbon">
<button class="app-menu-button" type="button" aria-label="Mail-Graveyard-Menue" aria-expanded="false" data-backstage-toggle><span class="mail-logo" aria-hidden="true"></span></button>
@ -750,9 +757,9 @@ func renderTargetEmailBoxPage(w http.ResponseWriter, accounts []Account) {
fmt.Fprintf(w, `<!doctype html><html lang="de"><head><meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Ziel-Postfach - Mail-Graveyard</title>
<link rel="stylesheet" href="/static/style.css?v=20260712-17">
<link rel="stylesheet" href="/static/style.css?v=20260712-18">
<script src="/static/htmx.min.js"></script>
<script src="/static/app.js?v=20260712-17" defer></script></head>
<script src="/static/app.js?v=20260712-18" defer></script></head>
<body class="ol2013">
<header class="ribbon">
<button class="app-menu-button" type="button" aria-label="Mail-Graveyard-Menue" aria-expanded="false" data-backstage-toggle><span class="mail-logo" aria-hidden="true"></span></button>
@ -1034,6 +1041,7 @@ func renderUserForm(w http.ResponseWriter, r *http.Request, u AppUser) {
}
fmt.Fprintf(w, `<form class="account-form user-form" method="post" action="/users/save">
<div class="form-section">
<input type="hidden" name="old_username" value="%s">
<label class="label">Login/E-Mail<input class="input" name="username" value="%s" required></label>
<label class="check-row"><input type="checkbox" name="active" value="1" %s> Aktiv</label>
</div>
@ -1050,7 +1058,7 @@ func renderUserForm(w http.ResponseWriter, r *http.Request, u AppUser) {
<a class="btn secondary" href="/users">Neu</a>
</div>
</form>`,
html.EscapeString(u.Username), checked(u.Active), html.EscapeString(u.DisplayName), passwordHint(u.Username), passwordPlaceholder(u.Username), roleField)
html.EscapeString(u.Username), html.EscapeString(u.Username), checked(u.Active), html.EscapeString(u.DisplayName), passwordHint(u.Username), passwordPlaceholder(u.Username), roleField)
}
func userDisplayName(u AppUser) string {

View file

@ -4,6 +4,7 @@ import (
"database/sql"
"encoding/json"
"errors"
"fmt"
"os"
"strings"
@ -133,6 +134,13 @@ func ConnectDB() error {
expires_at TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
)`,
`CREATE TABLE IF NOT EXISTS app_password_resets(
token_hash TEXT PRIMARY KEY,
user_id INTEGER NOT NULL REFERENCES app_users(id) ON DELETE CASCADE,
expires_at TEXT NOT NULL,
used_at TEXT,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
)`,
}
for _, stmt := range schema {
if _, err := db.Exec(stmt); err != nil {
@ -232,6 +240,44 @@ func SaveAppUser(username, displayName, passwordHash, role string, active bool)
return err
}
func UpdateAppUser(oldUsername, username, displayName, passwordHash, role string, active bool) error {
oldUsername = strings.TrimSpace(oldUsername)
username = strings.TrimSpace(username)
displayName = strings.TrimSpace(displayName)
role = normalizeRole(role)
if oldUsername == "" || oldUsername == username {
return SaveAppUser(username, displayName, passwordHash, role, active)
}
tx, err := DB.Begin()
if err != nil {
return err
}
defer tx.Rollback()
var existing int
err = tx.QueryRow(`SELECT 1 FROM app_users WHERE username=?`, username).Scan(&existing)
if err == nil {
return fmt.Errorf("Login/E-Mail existiert bereits.")
}
if !errors.Is(err, sql.ErrNoRows) {
return err
}
if passwordHash == "" {
_, err = tx.Exec(`UPDATE app_users SET username=?, display_name=?, role=?, active=?, updated_at=CURRENT_TIMESTAMP WHERE username=?`,
username, displayName, role, boolInt(active), oldUsername)
} else {
_, err = tx.Exec(`UPDATE app_users SET username=?, display_name=?, password_hash=?, role=?, active=?, updated_at=CURRENT_TIMESTAMP WHERE username=?`,
username, displayName, passwordHash, role, boolInt(active), oldUsername)
}
if err != nil {
return err
}
_, err = tx.Exec(`DELETE FROM app_sessions WHERE user_id IN (SELECT id FROM app_users WHERE username=?)`, username)
if err != nil {
return err
}
return tx.Commit()
}
func DeleteAppUser(username string) error {
_, err := DB.Exec(`DELETE FROM app_users WHERE username=?`, username)
return err
@ -259,6 +305,46 @@ func CleanupSessions() error {
return err
}
func CreatePasswordReset(tokenHash string, userID int64, expiresAt string) error {
_, err := DB.Exec(`INSERT INTO app_password_resets(token_hash, user_id, expires_at) VALUES(?,?,?)`, tokenHash, userID, expiresAt)
return err
}
func PasswordResetUser(tokenHash string) (AppUser, error) {
row := DB.QueryRow(`SELECT u.id, u.username, u.display_name, u.password_hash, u.role, u.active
FROM app_password_resets r JOIN app_users u ON u.id=r.user_id
WHERE r.token_hash=? AND r.used_at IS NULL AND r.expires_at > CURRENT_TIMESTAMP AND u.active=1`, tokenHash)
return scanAppUser(row)
}
func UsePasswordReset(tokenHash, passwordHash string) error {
tx, err := DB.Begin()
if err != nil {
return err
}
defer tx.Rollback()
var userID int64
err = tx.QueryRow(`SELECT user_id FROM app_password_resets WHERE token_hash=? AND used_at IS NULL AND expires_at > CURRENT_TIMESTAMP`, tokenHash).Scan(&userID)
if err != nil {
return err
}
if _, err := tx.Exec(`UPDATE app_users SET password_hash=?, updated_at=CURRENT_TIMESTAMP WHERE id=?`, passwordHash, userID); err != nil {
return err
}
if _, err := tx.Exec(`UPDATE app_password_resets SET used_at=CURRENT_TIMESTAMP WHERE token_hash=?`, tokenHash); err != nil {
return err
}
if _, err := tx.Exec(`DELETE FROM app_sessions WHERE user_id=?`, userID); err != nil {
return err
}
return tx.Commit()
}
func CleanupPasswordResets() error {
_, err := DB.Exec(`DELETE FROM app_password_resets WHERE used_at IS NOT NULL OR expires_at <= CURRENT_TIMESTAMP`)
return err
}
func ListAccounts() ([]Account, error) {
rows, err := DB.Query(`SELECT id,name,src_host,src_port,src_security,src_insecure,src_user,src_pass,src_proto,
dst_host,dst_port,dst_security,dst_insecure,dst_user,dst_pass,mbox_dir,active

View file

@ -8,6 +8,7 @@ import (
"encoding/base64"
"fmt"
"html"
"log"
"net/http"
"strings"
"time"
@ -36,6 +37,9 @@ func InitAuth() error {
if err := CleanupSessions(); err != nil {
return err
}
if err := CleanupPasswordResets(); err != nil {
return err
}
count, err := CountAppUsers()
if err != nil {
return err
@ -56,7 +60,7 @@ func InitAuth() error {
// AuthMiddleware schuetzt alle Routen ausser /login und /static.
func AuthMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if strings.HasPrefix(r.URL.Path, "/static/") || r.URL.Path == "/login" {
if strings.HasPrefix(r.URL.Path, "/static/") || r.URL.Path == "/login" || strings.HasPrefix(r.URL.Path, "/password/") {
next.ServeHTTP(w, r)
return
}
@ -156,6 +160,72 @@ func loginHandler(w http.ResponseWriter, r *http.Request) {
}
}
func forgotPasswordHandler(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case http.MethodGet:
renderForgotPasswordPage(w, "")
case http.MethodPost:
if err := r.ParseForm(); err != nil {
renderForgotPasswordPage(w, err.Error())
return
}
username := strings.TrimSpace(r.FormValue("username"))
if username != "" {
user, err := GetAppUser(username)
if err == nil && user.Active {
if err := sendPasswordReset(r, user); err != nil {
log.Printf("password reset mail for %s failed: %v", username, err)
}
}
}
renderForgotPasswordPage(w, "Wenn der Benutzer existiert, wurde ein Reset-Link per E-Mail verschickt.")
default:
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
}
}
func resetPasswordHandler(w http.ResponseWriter, r *http.Request) {
token := strings.TrimSpace(r.URL.Query().Get("token"))
if token == "" {
renderResetPasswordPage(w, "", "Reset-Link fehlt.")
return
}
tokenHash := resetTokenHash(token)
user, err := PasswordResetUser(tokenHash)
if err != nil {
renderResetPasswordPage(w, "", "Reset-Link ist ungueltig oder abgelaufen.")
return
}
switch r.Method {
case http.MethodGet:
renderResetPasswordPage(w, token, "")
case http.MethodPost:
if err := r.ParseForm(); err != nil {
renderResetPasswordPage(w, token, err.Error())
return
}
password := r.FormValue("password")
confirm := r.FormValue("confirm_password")
if password == "" || password != confirm {
renderResetPasswordPage(w, token, "Passwoerter stimmen nicht ueberein.")
return
}
hash, err := HashPassword(password)
if err != nil {
renderResetPasswordPage(w, token, err.Error())
return
}
if err := UsePasswordReset(tokenHash, hash); err != nil {
renderResetPasswordPage(w, token, err.Error())
return
}
log.Printf("password reset completed for %s", user.Username)
http.Redirect(w, r, "/login?err="+urlQuery("Passwort wurde geaendert. Bitte neu anmelden."), http.StatusSeeOther)
default:
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
}
}
func logoutHandler(w http.ResponseWriter, r *http.Request) {
if cookie, err := r.Cookie(sessionCookieName); err == nil {
_ = DeleteSession(cookie.Value)
@ -169,7 +239,7 @@ func renderLoginPage(w http.ResponseWriter, errMsg string) {
fmt.Fprintf(w, `<!doctype html><html lang="de"><head><meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Login - Mail-Graveyard</title>
<link rel="stylesheet" href="/static/style.css?v=20260712-17"></head>
<link rel="stylesheet" href="/static/style.css?v=20260712-18"></head>
<body class="ol2013 login-body">
<main class="login-card">
<div class="login-brand"><span class="mail-logo" aria-hidden="true"></span><strong>Mail-Graveyard</strong></div>
@ -178,11 +248,51 @@ func renderLoginPage(w http.ResponseWriter, errMsg string) {
<label class="label">Passwort<input class="input" name="password" type="password" autocomplete="current-password" required></label>
%s
<button class="btn" type="submit">Anmelden</button>
<a class="login-link" href="/password/forgot">Passwort vergessen?</a>
</form>
</main>
</body></html>`, loginErrorHTML(errMsg))
}
func renderForgotPasswordPage(w http.ResponseWriter, msg string) {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
fmt.Fprintf(w, `<!doctype html><html lang="de"><head><meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Passwort vergessen - Mail-Graveyard</title>
<link rel="stylesheet" href="/static/style.css?v=20260712-18"></head>
<body class="ol2013 login-body">
<main class="login-card">
<div class="login-brand"><span class="mail-logo" aria-hidden="true"></span><strong>Mail-Graveyard</strong></div>
<form method="post" action="/password/forgot" class="login-form">
<label class="label">Login/E-Mail<input class="input" name="username" autocomplete="username" autofocus required></label>
%s
<button class="btn" type="submit">Reset-Link senden</button>
<a class="login-link" href="/login">Zurueck zum Login</a>
</form>
</main>
</body></html>`, loginNoticeHTML(msg))
}
func renderResetPasswordPage(w http.ResponseWriter, token, errMsg string) {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
fmt.Fprintf(w, `<!doctype html><html lang="de"><head><meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Passwort setzen - Mail-Graveyard</title>
<link rel="stylesheet" href="/static/style.css?v=20260712-18"></head>
<body class="ol2013 login-body">
<main class="login-card">
<div class="login-brand"><span class="mail-logo" aria-hidden="true"></span><strong>Mail-Graveyard</strong></div>
<form method="post" action="/password/reset?token=%s" class="login-form">
<label class="label">Neues Passwort<input class="input" name="password" type="password" autocomplete="new-password" autofocus required></label>
<label class="label">Wiederholen<input class="input" name="confirm_password" type="password" autocomplete="new-password" required></label>
%s
<button class="btn" type="submit">Passwort speichern</button>
<a class="login-link" href="/login">Zurueck zum Login</a>
</form>
</main>
</body></html>`, html.EscapeString(token), loginErrorHTML(errMsg))
}
func loginErrorHTML(errMsg string) string {
if errMsg == "" {
return ""
@ -190,6 +300,13 @@ func loginErrorHTML(errMsg string) string {
return `<div class="notice bad login-error">` + html.EscapeString(errMsg) + `</div>`
}
func loginNoticeHTML(msg string) string {
if msg == "" {
return ""
}
return `<div class="notice ok login-error">` + html.EscapeString(msg) + `</div>`
}
func redirectToLogin(w http.ResponseWriter, r *http.Request) {
if r.Header.Get("HX-Request") == "true" {
w.Header().Set("HX-Redirect", "/login")
@ -255,3 +372,39 @@ func randomToken(size int) (string, error) {
}
return base64.RawURLEncoding.EncodeToString(b), nil
}
func resetTokenHash(token string) string {
sum := sha256.Sum256([]byte(token))
return base64.RawURLEncoding.EncodeToString(sum[:])
}
func sendPasswordReset(r *http.Request, user AppUser) error {
token, err := randomToken(32)
if err != nil {
return err
}
expires := time.Now().Add(30 * time.Minute)
if err := CreatePasswordReset(resetTokenHash(token), user.ID, expires.UTC().Format("2006-01-02 15:04:05")); err != nil {
return err
}
link := baseURL(r) + "/password/reset?token=" + token
subject := "Mail-Graveyard Passwort zuruecksetzen"
body := fmt.Sprintf("Hallo %s,\n\nzum Zuruecksetzen deines Mail-Graveyard-Passworts nutze diesen Link:\n\n%s\n\nDer Link ist 30 Minuten gueltig.\n", userDisplayName(user), link)
return SendPlainMail([]string{user.Username}, subject, body)
}
func baseURL(r *http.Request) string {
proto := r.Header.Get("X-Forwarded-Proto")
if proto == "" {
if r.TLS != nil {
proto = "https"
} else {
proto = "http"
}
}
host := r.Header.Get("X-Forwarded-Host")
if host == "" {
host = r.Host
}
return proto + "://" + host
}

View file

@ -1,13 +1,100 @@
package backend
// SMTP wird AUSSCHLIESSLICH fuers manuelle Weiterleiten einzelner Mails aus dem
// Viewer benutzt (Cfg.ForwardSMTP). Der Postfach-Umzug laeuft NIE ueber SMTP,
// sondern ueber IMAP APPEND (05-imap-target.go) -- sonst gingen Datum, Flags
// und Ordnerstruktur verloren.
import (
"crypto/tls"
"fmt"
"net"
"net/smtp"
"strings"
)
// SMTP wird AUSSCHLIESSLICH fuers manuelle Weiterleiten einzelner Mails und
// App-Benachrichtigungen wie Passwort-Reset benutzt. Der Postfach-Umzug laeuft
// NIE ueber SMTP, sondern ueber IMAP APPEND (05-imap-target.go).
func SendPlainMail(to []string, subject, body string) error {
cfg := Cfg.ForwardSMTP
if strings.TrimSpace(cfg.Host) == "" || cfg.Port == 0 || strings.TrimSpace(cfg.From) == "" {
return fmt.Errorf("forward_smtp ist nicht vollstaendig konfiguriert")
}
from := cfg.From
if strings.TrimSpace(cfg.User) != "" {
from = cfg.User
}
message := []byte("From: " + cfg.From + "\r\n" +
"To: " + strings.Join(to, ", ") + "\r\n" +
"Subject: " + subject + "\r\n" +
"Content-Type: text/plain; charset=utf-8\r\n" +
"\r\n" + body)
addr := net.JoinHostPort(cfg.Host, fmt.Sprint(cfg.Port))
auth := smtp.Auth(nil)
if cfg.User != "" || cfg.Pass != "" {
auth = smtp.PlainAuth("", cfg.User, cfg.Pass, cfg.Host)
}
if cfg.Port == 465 {
return sendTLS(addr, cfg.Host, auth, from, to, message)
}
return sendStartTLS(addr, cfg.Host, auth, from, to, message)
}
func sendStartTLS(addr, host string, auth smtp.Auth, from string, to []string, message []byte) error {
c, err := smtp.Dial(addr)
if err != nil {
return err
}
defer c.Close()
if ok, _ := c.Extension("STARTTLS"); ok {
if err := c.StartTLS(&tls.Config{ServerName: host, MinVersion: tls.VersionTLS12}); err != nil {
return err
}
}
return smtpClientSend(c, auth, from, to, message)
}
func sendTLS(addr, host string, auth smtp.Auth, from string, to []string, message []byte) error {
conn, err := tls.Dial("tcp", addr, &tls.Config{ServerName: host, MinVersion: tls.VersionTLS12})
if err != nil {
return err
}
c, err := smtp.NewClient(conn, host)
if err != nil {
_ = conn.Close()
return err
}
defer c.Close()
return smtpClientSend(c, auth, from, to, message)
}
func smtpClientSend(c *smtp.Client, auth smtp.Auth, from string, to []string, message []byte) error {
if auth != nil {
if err := c.Auth(auth); err != nil {
return err
}
}
if err := c.Mail(from); err != nil {
return err
}
for _, rcpt := range to {
if err := c.Rcpt(rcpt); err != nil {
return err
}
}
w, err := c.Data()
if err != nil {
return err
}
if _, err := w.Write(message); err != nil {
_ = w.Close()
return err
}
if err := w.Close(); err != nil {
return err
}
return c.Quit()
}
// ForwardMessage leitet den mbox-Rohtext an einen Empfaenger weiter.
func ForwardMessage(rawMessage []byte, to []string, extraNote string) error {
// TODO Codex: net/smtp (stdlib) mit STARTTLS + AUTH gegen Cfg.ForwardSMTP.
// Original als Anhang oder inline weiterleiten, From = Cfg.ForwardSMTP.From.
return nil
body := extraNote + "\n\n" + string(rawMessage)
return SendPlainMail(to, "Weitergeleitete Nachricht aus Mail-Graveyard", body)
}