Initial commit
CI / test (push) Canceled after 0s

This commit is contained in:
2026-09-12 22:22:17 +02:00
commit 904d14b64c
314 changed files with 31884 additions and 0 deletions
+3
View File
@@ -0,0 +1,3 @@
// Package mailer renders and delivers Gardomatic transactional email through
// SMTP or an append-only development file.
package mailer
+163
View File
@@ -0,0 +1,163 @@
package mailer
import (
"bytes"
"embed"
"errors"
"fmt"
"os"
"sync"
"time"
"github.com/wneessen/go-mail"
ht "html/template"
tt "text/template"
)
//go:embed "templates"
var templateFS embed.FS
// Mailer renders embedded templates and delivers the resulting message.
type Mailer struct {
client *mail.Client
mode Mode
filePath string
sender string
fileMu sync.Mutex
}
// Mode selects the delivery backend used by a Mailer.
type Mode string
const (
// ModeSMTP sends messages through an SMTP server.
ModeSMTP Mode = "smtp"
// ModeFile appends rendered messages to a local development file.
ModeFile Mode = "file"
)
// Config contains SMTP or development-file delivery settings.
type Config struct {
Mode Mode
Host string
Port int
Username string
Password string
Sender string
FilePath string
}
// New validates config and creates a Mailer.
func New(config Config) (*Mailer, error) {
mailer := &Mailer{
mode: config.Mode,
filePath: config.FilePath,
sender: config.Sender,
}
switch config.Mode {
case ModeSMTP:
client, err := mail.NewClient(
config.Host,
mail.WithSMTPAuth(mail.SMTPAuthLogin),
mail.WithPort(config.Port),
mail.WithUsername(config.Username),
mail.WithPassword(config.Password),
mail.WithTimeout(5*time.Second),
)
if err != nil {
return nil, err
}
mailer.client = client
case ModeFile:
if config.FilePath == "" {
return nil, errors.New("mailer: file path must not be empty in file mode")
}
default:
return nil, fmt.Errorf("mailer: unsupported mode %q", config.Mode)
}
return mailer, nil
}
// Send renders templateFile with data and delivers it to recipient.
func (m *Mailer) Send(recipient string, templateFile string, data any) error {
textTmpl, err := tt.New("").ParseFS(templateFS, "templates/"+templateFile)
if err != nil {
return err
}
subject := new(bytes.Buffer)
err = textTmpl.ExecuteTemplate(subject, "subject", data)
if err != nil {
return err
}
plainBody := new(bytes.Buffer)
err = textTmpl.ExecuteTemplate(plainBody, "plainBody", data)
if err != nil {
return err
}
htmlTmpl, err := ht.New("").ParseFS(templateFS, "templates/"+templateFile)
if err != nil {
return err
}
htmlBody := new(bytes.Buffer)
err = htmlTmpl.ExecuteTemplate(htmlBody, "htmlBody", data)
if err != nil {
return err
}
msg := mail.NewMsg()
err = msg.To(recipient)
if err != nil {
return err
}
err = msg.From(m.sender)
if err != nil {
return err
}
msg.Subject(subject.String())
msg.SetBodyString(mail.TypeTextPlain, plainBody.String())
msg.AddAlternativeString(mail.TypeTextHTML, htmlBody.String())
if m.mode == ModeFile {
return m.appendToFile(msg)
}
return m.client.DialAndSend(msg)
}
func (m *Mailer) appendToFile(msg *mail.Msg) error {
var content bytes.Buffer
if _, err := msg.WriteTo(&content); err != nil {
return fmt.Errorf("mailer: format message: %w", err)
}
m.fileMu.Lock()
defer m.fileMu.Unlock()
file, err := os.OpenFile(m.filePath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o600)
if err != nil {
return fmt.Errorf("mailer: open output file: %w", err)
}
defer file.Close()
if _, err = file.WriteString("\n=== gardomatic mail ===\n"); err != nil {
return fmt.Errorf("mailer: append separator: %w", err)
}
if _, err = content.WriteTo(file); err != nil {
return fmt.Errorf("mailer: append message: %w", err)
}
if _, err = file.WriteString("\n"); err != nil {
return fmt.Errorf("mailer: finish message: %w", err)
}
return nil
}
+64
View File
@@ -0,0 +1,64 @@
package mailer
import (
"os"
"path/filepath"
"strings"
"testing"
)
func TestFileModeAppendsMessages(t *testing.T) {
filePath := filepath.Join(t.TempDir(), "mails.log")
m, err := New(Config{
Mode: ModeFile,
Sender: "gardomatic@example.com",
FilePath: filePath,
})
if err != nil {
t.Fatalf("New() returned an error: %v", err)
}
data := map[string]any{
"userID": 42,
"activationToken": "test-token",
}
for _, recipient := range []string{"alice@example.com", "bob@example.com"} {
if err = m.Send(recipient, "user_welcome.tmpl", data); err != nil {
t.Fatalf("Send() returned an error: %v", err)
}
}
content, err := os.ReadFile(filePath)
if err != nil {
t.Fatalf("reading mail output: %v", err)
}
output := string(content)
if got := strings.Count(output, "=== gardomatic mail ==="); got != 2 {
t.Errorf("message count = %d, want 2", got)
}
for _, expected := range []string{
"alice@example.com",
"bob@example.com",
"Subject: Welcome to Gardomatic!",
"test-token",
} {
if !strings.Contains(output, expected) {
t.Errorf("output does not contain %q", expected)
}
}
}
func TestFileModeRequiresPath(t *testing.T) {
_, err := New(Config{Mode: ModeFile, Sender: "gardomatic@example.com"})
if err == nil {
t.Fatal("New() returned no error without a file path")
}
}
func TestNewRejectsUnknownMode(t *testing.T) {
_, err := New(Config{Mode: "unknown", Sender: "gardomatic@example.com"})
if err == nil {
t.Fatal("New() returned no error for an unknown mode")
}
}
@@ -0,0 +1,3 @@
{{define "subject"}}E-Mail-Adresse bei Gardomatic bestätigen{{end}}
{{define "plainBody"}}Bestätige deine neue E-Mail-Adresse: {{.confirmationURL}}{{end}}
{{define "htmlBody"}}<p>Bestätige deine neue E-Mail-Adresse:</p><p><a href="{{.confirmationURL}}">E-Mail-Adresse bestätigen</a></p>{{end}}
@@ -0,0 +1,3 @@
{{define "subject"}}Einladung zu Gardomatic{{end}}
{{define "plainBody"}}Du wurdest zu einem Garten eingeladen. Einladung annehmen: {{.inviteURL}}{{end}}
{{define "htmlBody"}}<p>Du wurdest zu einem Garten eingeladen.</p><p><a href="{{.inviteURL}}">Einladung annehmen</a></p>{{end}}
+25
View File
@@ -0,0 +1,25 @@
{{define "subject"}}Gardomatic Testmail{{end}}
{{define "plainBody"}}
Hallo,
diese Testmail bestätigt, dass der Mailversand von Gardomatic funktioniert.
Viele Grüße
Gardomatic
{{end}}
{{define "htmlBody"}}
<!doctype html>
<html lang="de">
<head>
<meta name="viewport" content="width=device-width">
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8">
</head>
<body>
<p>Hallo,</p>
<p>diese Testmail bestätigt, dass der Mailversand von Gardomatic funktioniert.</p>
<p>Viele Grüße<br>Gardomatic</p>
</body>
</html>
{{end}}
@@ -0,0 +1,48 @@
{{define "subject"}}Activate your Gardomatic account{{end}}
{{define "plainBody"}}
Hi,
{{if .activationURL}}Activate your account using this link:
{{.activationURL}}
Alternatively, enter this activation token on the activation page:
{{.activationToken}}
{{else}}Please send a `PUT /v1/users/activated` request with the following JSON body to activate your account:
{"token": "{{.activationToken}}"}
{{end}}
Please note that this is a one-time use token and it will expire in 3 days.
Thanks,
The Gardomatic Team
{{end}}
{{define "htmlBody"}}
<!doctype html>
<html>
<head>
<meta name="viewport" content="width=device-width" />
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8" />
</head>
<body>
<p>Hi,</p>
{{if .activationURL}}
<p><a href="{{.activationURL}}">Activate your Gardomatic account</a></p>
<p>Alternatively, enter this activation token on the activation page:</p>
<pre><code>{{.activationToken}}</code></pre>
{{else}}
<p>Please send a <code>PUT /v1/users/activated</code> request with the following JSON body to activate your account:</p>
<pre><code>
{"token": "{{.activationToken}}"}
</code></pre>
{{end}}
<p>Please note that this is a one-time use token and it will expire in 3 days.</p>
<p>Thanks,</p>
<p>The Gardomatic Team</p>
</body>
</html>
{{end}}
@@ -0,0 +1,37 @@
{{define "subject"}}Reset your Gardomatic password{{end}}
{{define "plainBody"}}
Hi,
Please send a `PUT /v1/users/password` request with the following JSON body to set a new password:
{"password": "your new password", "token": "{{.passwordResetToken}}"}
Please note that this is a one-time use token and it will expire in 45 minutes. If you need
another token please make a `POST /v1/tokens/password-reset` request.
Thanks,
The Gardomatic Team
{{end}}
{{define "htmlBody"}}
<!doctype html>
<html>
<head>
<meta name="viewport" content="width=device-width" />
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8" />
</head>
<body>
<p>Hi,</p>
<p>Please send a <code>PUT /v1/users/password</code> request with the following JSON body to set a new password:</p>
<pre><code>
{"password": "your new password", "token": "{{.passwordResetToken}}"}
</code></pre>
<p>Please note that this is a one-time use token and it will expire in 45 minutes.
If you need another token please make a <code>POST /v1/tokens/password-reset</code> request.</p>
<p>Thanks,</p>
<p>The Gardomatic Team</p>
</body>
</html>
{{end}}
@@ -0,0 +1,31 @@
{{define "subject"}}Einladung zu Gardomatic{{end}}
{{define "plainBody"}}
Hallo {{.name}},
du wurdest zu Gardomatic eingeladen. Öffne den folgenden Link, um deinen Account zu aktivieren und ein Passwort festzulegen:
{{.activationURL}}
Der Link ist drei Tage lang gültig.
Viele Grüße
Gardomatic
{{end}}
{{define "htmlBody"}}
<!doctype html>
<html lang="de">
<head>
<meta name="viewport" content="width=device-width">
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8">
</head>
<body>
<p>Hallo {{.name}},</p>
<p>du wurdest zu Gardomatic eingeladen.</p>
<p><a href="{{.activationURL}}">Account aktivieren und Passwort festlegen</a></p>
<p>Der Link ist drei Tage lang gültig.</p>
<p>Viele Grüße<br>Gardomatic</p>
</body>
</html>
{{end}}
@@ -0,0 +1,59 @@
{{define "subject"}}Welcome to Gardomatic!{{end}}
{{define "plainBody"}}
Hi,
Thanks for signing up for a Gardomatic account. We're excited to have you on board!
For future reference, your user ID number is {{.userID}}.
{{if .activationURL}}Activate your account using this link:
{{.activationURL}}
Alternatively, enter this activation token on the activation page:
{{.activationToken}}
{{else}}Please send a request to the `PUT /v1/users/activated` endpoint with the following JSON
body to activate your account:
{"token": "{{.activationToken}}"}
{{end}}
Please note that this is a one-time use token and it will expire in 3 days.
Thanks,
The Gardomatic Team
{{end}}
{{define "htmlBody"}}
<!doctype html>
<html>
<head>
<meta name="viewport" content="width=device-width" />
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8" />
</head>
<body>
<p>Hi,</p>
<p>Thanks for signing up for a Gardomatic account. We're excited to have you on board!</p>
<p>For future reference, your user ID number is {{.userID}}.</p>
{{if .activationURL}}
<p><a href="{{.activationURL}}">Activate your Gardomatic account</a></p>
<p>Alternatively, enter this activation token on the activation page:</p>
<pre><code>{{.activationToken}}</code></pre>
{{else}}
<p>Please send a request to the <code>PUT /v1/users/activated</code> endpoint with the
following JSON body to activate your account:</p>
<pre><code>
{"token": "{{.activationToken}}"}
</code></pre>
{{end}}
<p>Please note that this is a one-time use token and it will expire in 3 days.</p>
<p>Thanks,</p>
<p>The Gardomatic Team</p>
</body>
</html>
{{end}}