Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions cmd/server/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -172,6 +172,7 @@ func main() {
mux.Handle("GET /admin/stats", adminOnly(h.Wrap(h.AdminStatsPage)))
mux.Handle("GET /admin/settings", adminOnly(h.Wrap(h.AdminSettingsPage)))
mux.Handle("POST /admin/settings", adminOnly(h.Wrap(h.SaveSettings)))
mux.Handle("GET /admin/settings/send-reminders", adminOnly(h.Wrap(h.SendRemindersModal)))
mux.Handle("POST /admin/settings/send-reminders", adminOnly(h.Wrap(h.SendReminders)))

// Catch-all: styled 404 for unmatched routes.
Expand Down
97 changes: 91 additions & 6 deletions internal/handler/admin_settings.go
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,14 @@ func (h *Handler) SaveSettings(w http.ResponseWriter, r *http.Request) error {
if err := validateTextLen(smtpFrom, 255, "SMTP From"); err != nil {
return err
}
smtpFromName := strings.TrimSpace(r.FormValue("smtp_from_name"))
if err := validateTextLen(smtpFromName, 255, "Absender-Name"); err != nil {
return err
}
emailSubject := strings.TrimSpace(r.FormValue("email_subject"))
if err := validateTextLen(emailSubject, 255, "Betreff"); err != nil {
return err
}
emailTemplate := strings.TrimSpace(r.FormValue("email_template"))
if err := validateTextLen(emailTemplate, 10000, "E-Mail-Template"); err != nil {
return err
Expand All @@ -135,6 +143,8 @@ func (h *Handler) SaveSettings(w http.ResponseWriter, r *http.Request) error {
SMTPUser: smtpUser,
SMTPPassword: r.FormValue("smtp_password"),
SMTPFrom: smtpFrom,
SMTPFromName: smtpFromName,
EmailSubject: emailSubject,
EmailTemplate: emailTemplate,
}

Expand All @@ -152,11 +162,67 @@ func (h *Handler) SaveSettings(w http.ResponseWriter, r *http.Request) error {
return nil
}

// SendReminders sends balance reminder emails to all active users.
// SendRemindersModal renders a confirmation modal showing how many users would receive reminders.
func (h *Handler) SendRemindersModal(w http.ResponseWriter, r *http.Request) error {
ctx := r.Context()
db := h.Store.DB()

limitType := r.URL.Query().Get("type")
if limitType != "warning" && limitType != "hard" {
return &ValidationError{Message: "Ungültiger Typ"}
}

settings, err := store.GetSettings(ctx, db)
if err != nil {
return fmt.Errorf("send reminders modal: get settings: %w", err)
}

users, err := store.ListActiveUsersWithBalance(ctx, db)
if err != nil {
return fmt.Errorf("send reminders modal: list users: %w", err)
}

var threshold int64
var title, description string
if limitType == "warning" {
threshold = settings.WarningLimit
title = "Erinnerung: Warnlimit"
description = fmt.Sprintf("unter dem Warnlimit (%s)", formatCentsToEuro(settings.WarningLimit))
} else {
threshold = -settings.HardSpendingLimit
title = "Erinnerung: Ausgabelimit"
description = fmt.Sprintf("unter dem Ausgabelimit (%s)", formatCentsToEuro(-settings.HardSpendingLimit))
}

var count int
for _, u := range users {
if u.Balance < threshold {
count++
}
}

data := map[string]any{
"Title": title,
"Message": fmt.Sprintf("Erinnerungsmail an %d Nutzer %s senden?", count, description),
"PostURL": "/admin/settings/send-reminders?type=" + limitType,
"CSRFToken": middleware.CSRFTokenFromContext(ctx),
"Count": count,
}

h.Renderer.Fragment(w, r, "confirm-send-reminders-modal", data)
return nil
}

// SendReminders sends balance reminder emails to active users below the specified limit.
func (h *Handler) SendReminders(w http.ResponseWriter, r *http.Request) error {
ctx := r.Context()
db := h.Store.DB()

limitType := r.URL.Query().Get("type")
if limitType != "warning" && limitType != "hard" {
return &ValidationError{Message: "Ungültiger Typ"}
}

users, err := store.ListActiveUsersWithBalance(ctx, db)
if err != nil {
return fmt.Errorf("send reminders: list users: %w", err)
Expand All @@ -167,6 +233,13 @@ func (h *Handler) SendReminders(w http.ResponseWriter, r *http.Request) error {
return fmt.Errorf("send reminders: get settings: %w", err)
}

var threshold int64
if limitType == "warning" {
threshold = settings.WarningLimit
} else {
threshold = -settings.HardSpendingLimit
}

tmpl, err := template.New("email").Parse(settings.EmailTemplate)
if err != nil {
return &ValidationError{Message: "E-Mail-Template ist ungültig: " + err.Error()}
Expand All @@ -178,33 +251,45 @@ func (h *Handler) SendReminders(w http.ResponseWriter, r *http.Request) error {
Username: settings.SMTPUser,
Password: settings.SMTPPassword,
From: settings.SMTPFrom,
FromName: settings.SMTPFromName,
}

var successes, failures int
for _, u := range users {
// Render per-user email body.
if u.Balance >= threshold {
continue
}

var buf bytes.Buffer
data := map[string]string{
"Name": u.FullName,
"Balance": formatCentsToEuro(u.Balance),
"Name": u.FullName,
"FirstName": u.GivenName,
"Balance": formatCentsToEuro(u.Balance),
}
if err := tmpl.Execute(&buf, data); err != nil {
log.Printf("send reminders: render template for %s: %v", u.Email, err)
failures++
continue
}

if err := mailer.Send(u.Email, "Kontostand-Erinnerung", buf.String()); err != nil {
if err := mailer.Send(u.Email, settings.EmailSubject, buf.String()); err != nil {
log.Printf("send reminders: send to %s: %v", u.Email, err)
failures++
continue
}
successes++
}

toastType := "success"
if successes == 0 && failures > 0 {
toastType = "error"
} else if failures > 0 {
toastType = "warning"
}

msg := fmt.Sprintf("%d Emails gesendet, %d Fehler", successes, failures)
h.Renderer.Fragment(w, r, "toast", map[string]string{
"Type": "success",
"Type": toastType,
"Message": msg,
})
return nil
Expand Down
80 changes: 74 additions & 6 deletions internal/mail/mail.go
Original file line number Diff line number Diff line change
@@ -1,34 +1,102 @@
package mail

import (
"crypto/tls"
"fmt"
"net"
"net/mail"
"net/smtp"
"strings"
"time"
)

const dialTimeout = 10 * time.Second

// Mailer sends emails via SMTP.
type Mailer struct {
Host string
Port string
Username string
Password string
From string
FromName string
}

// formatFrom returns the From header value, using RFC 5322 format when FromName is set.
func (m *Mailer) formatFrom() string {
if m.FromName == "" {
return m.From
}
return (&mail.Address{Name: m.FromName, Address: m.From}).String()
}

// Send sends an email to the given recipient with the specified subject and body.
func (m *Mailer) Send(to, subject, body string) error {
addr := net.JoinHostPort(m.Host, m.Port)

msg := fmt.Sprintf("From: %s\r\nTo: %s\r\nSubject: %s\r\nMIME-Version: 1.0\r\nContent-Type: text/plain; charset=UTF-8\r\n\r\n%s", m.From, to, subject, body)
msg := fmt.Sprintf(
"From: %s\r\nTo: %s\r\nSubject: %s\r\nMIME-Version: 1.0\r\nContent-Type: text/plain; charset=UTF-8\r\n\r\n%s",
m.formatFrom(), to, subject, body,
)

tlsConfig := &tls.Config{ServerName: m.Host}
dialer := &net.Dialer{Timeout: dialTimeout}

var conn net.Conn
var err error

// Port 465 uses implicit TLS (SMTPS); all others use STARTTLS.
if m.Port == "465" {
conn, err = tls.DialWithDialer(dialer, "tcp", addr, tlsConfig)
} else {
conn, err = dialer.Dial("tcp", addr)
}
if err != nil {
return fmt.Errorf("dial %s: %w", addr, err)
}

client, err := smtp.NewClient(conn, m.Host)
if err != nil {
conn.Close()
return fmt.Errorf("smtp client: %w", err)
}
defer client.Close()

var auth smtp.Auth
// STARTTLS for non-465 ports that advertise it (port 25 may not support TLS).
if m.Port != "465" {
if ok, _ := client.Extension("STARTTLS"); ok {
if err := client.StartTLS(tlsConfig); err != nil {
return fmt.Errorf("starttls: %w", err)
}
}
}

// Authenticate if credentials are provided.
if m.Username != "" {
auth = smtp.PlainAuth("", m.Username, m.Password, m.Host)
auth := smtp.PlainAuth("", m.Username, m.Password, m.Host)
if err := client.Auth(auth); err != nil {
return fmt.Errorf("auth: %w", err)
}
}

if err := smtp.SendMail(addr, auth, m.From, []string{to}, []byte(msg)); err != nil {
return fmt.Errorf("sending email to %s: %w", to, err)
// Send the message.
if err := client.Mail(m.From); err != nil {
return fmt.Errorf("mail from: %w", err)
}
if err := client.Rcpt(to); err != nil {
return fmt.Errorf("rcpt to %s: %w", to, err)
}

w, err := client.Data()
if err != nil {
return fmt.Errorf("data: %w", err)
}
if _, err := strings.NewReader(msg).WriteTo(w); err != nil {
return fmt.Errorf("write body: %w", err)
}
if err := w.Close(); err != nil {
return fmt.Errorf("close data: %w", err)
}

return nil
return client.Quit()
}
2 changes: 2 additions & 0 deletions internal/model/models.go
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,8 @@ type Settings struct {
SMTPUser string
SMTPPassword string
SMTPFrom string
SMTPFromName string
EmailSubject string
EmailTemplate string
UpdatedAt time.Time
}
10 changes: 6 additions & 4 deletions internal/store/settings.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,13 +15,13 @@ func GetSettings(ctx context.Context, db DBTX) (*model.Settings, error) {
custom_tx_min, custom_tx_max, max_item_quantity,
cancellation_minutes, pagination_size,
smtp_host, smtp_port, smtp_user, smtp_password, smtp_from,
email_template, updated_at
smtp_from_name, email_subject, email_template, updated_at
FROM settings WHERE id = 1`).Scan(
&s.ID, &s.WarningLimit, &s.HardSpendingLimit, &s.HardLimitEnabled,
&s.CustomTxMin, &s.CustomTxMax, &s.MaxItemQuantity,
&s.CancellationMinutes, &s.PaginationSize,
&s.SMTPHost, &s.SMTPPort, &s.SMTPUser, &s.SMTPPassword, &s.SMTPFrom,
&s.EmailTemplate, &s.UpdatedAt,
&s.SMTPFromName, &s.EmailSubject, &s.EmailTemplate, &s.UpdatedAt,
)
if err != nil {
return nil, fmt.Errorf("get settings: %w", err)
Expand All @@ -46,14 +46,16 @@ func UpdateSettings(ctx context.Context, db DBTX, s *model.Settings) error {
smtp_user = $11,
smtp_password = $12,
smtp_from = $13,
email_template = $14,
smtp_from_name = $14,
email_subject = $15,
email_template = $16,
updated_at = NOW()
WHERE id = 1`,
s.WarningLimit, s.HardSpendingLimit, s.HardLimitEnabled,
s.CustomTxMin, s.CustomTxMax, s.MaxItemQuantity,
s.CancellationMinutes, s.PaginationSize,
s.SMTPHost, s.SMTPPort, s.SMTPUser, s.SMTPPassword, s.SMTPFrom,
s.EmailTemplate,
s.SMTPFromName, s.EmailSubject, s.EmailTemplate,
)
if err != nil {
return fmt.Errorf("update settings: %w", err)
Expand Down
7 changes: 7 additions & 0 deletions migrations/004_add_email_settings.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
-- +goose Up
ALTER TABLE settings ADD COLUMN smtp_from_name TEXT NOT NULL DEFAULT '';
ALTER TABLE settings ADD COLUMN email_subject TEXT NOT NULL DEFAULT 'Kontostand-Erinnerung';

-- +goose Down
ALTER TABLE settings DROP COLUMN IF EXISTS email_subject;
ALTER TABLE settings DROP COLUMN IF EXISTS smtp_from_name;
41 changes: 33 additions & 8 deletions templates/pages/admin_settings.html
Original file line number Diff line number Diff line change
Expand Up @@ -101,7 +101,14 @@ <h2 class="card-title text-lg">E-Mail (SMTP)</h2>
value="{{.Settings.SMTPPassword}}"
class="input input-sm w-full">
</fieldset>
<fieldset class="fieldset sm:col-span-2">
<fieldset class="fieldset">
<label class="label">Absender-Name</label>
<input type="text" name="smtp_from_name"
value="{{.Settings.SMTPFromName}}"
placeholder="z.B. K4-Bar"
class="input input-sm w-full">
</fieldset>
<fieldset class="fieldset">
<label class="label">Absender-Email</label>
<input type="text" name="smtp_from"
value="{{.Settings.SMTPFrom}}"
Expand All @@ -116,9 +123,22 @@ <h2 class="card-title text-lg">E-Mail (SMTP)</h2>
<div class="card-body p-4 space-y-4">
<h2 class="card-title text-lg">Email-Vorlage</h2>
<fieldset class="fieldset">
<label class="label">Betreff</label>
<input type="text" name="email_subject"
value="{{.Settings.EmailSubject}}"
class="input input-sm w-full">
</fieldset>
<fieldset class="fieldset">
<label class="label">Inhalt</label>
<textarea name="email_template" rows="8"
class="textarea w-full font-mono text-sm">{{.Settings.EmailTemplate}}</textarea>
</fieldset>
<div class="text-xs text-base-content/50">
Verfügbare Variablen:
<code class="bg-base-300 px-1 rounded">{{"{{"}} .Name {{"}}"}}</code> (vollständiger Name),
<code class="bg-base-300 px-1 rounded">{{"{{"}} .FirstName {{"}}"}}</code> (Vorname),
<code class="bg-base-300 px-1 rounded">{{"{{"}} .Balance {{"}}"}}</code> (Kontostand, z.B. &ldquo;-3.50 EUR&rdquo;)
</div>
</div>
</div>

Expand All @@ -131,15 +151,20 @@ <h2 class="card-title text-lg">Email-Vorlage</h2>
<div class="card bg-base-200 shadow-sm">
<div class="card-body p-4">
<h2 class="card-title text-lg">Erinnerungsmails</h2>
<p class="text-sm text-base-content/60">Sendet Erinnerungsmails an alle Nutzer mit negativem Kontostand.</p>
<div class="card-actions mt-2">
<p class="text-sm text-base-content/60">Sendet Erinnerungsmails an Nutzer unterhalb des gewählten Limits.</p>
<div class="card-actions mt-2 flex-wrap gap-2">
<button class="btn btn-warning btn-sm"
hx-post="/admin/settings/send-reminders"
hx-target="#toast-zone"
hx-swap="afterbegin"
hx-confirm="Wirklich Erinnerungsmails an alle Nutzer mit negativem Kontostand senden?">
Erinnerungsmails senden
hx-get="/admin/settings/send-reminders?type=warning"
hx-target="#modal">
Unter Warnlimit
</button>
{{if .Settings.HardLimitEnabled}}
<button class="btn btn-error btn-sm"
hx-get="/admin/settings/send-reminders?type=hard"
hx-target="#modal">
Unter Ausgabelimit
</button>
{{end}}
</div>
</div>
</div>
Expand Down
Loading
Loading