@@ -0,0 +1,3 @@
|
||||
// Package mailer renders and delivers Gardomatic transactional email through
|
||||
// SMTP or an append-only development file.
|
||||
package mailer
|
||||
@@ -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
|
||||
}
|
||||
@@ -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}}
|
||||
@@ -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}}
|
||||
Reference in New Issue
Block a user