Custom templates (#594)
Some checks failed
Docker / Build and Push (push) Has been cancelled
github-pages / deploy (push) Has been cancelled
Docker / release (push) Has been cancelled

* allow custom mail templates (#533)

* allow to override embedded frontend (#533)
This commit is contained in:
h44z
2025-12-10 23:10:43 +01:00
committed by GitHub
parent 54ca1d8aed
commit 8cc937b031
10 changed files with 281 additions and 10 deletions

View File

@@ -12,6 +12,18 @@ core:
web: web:
external_url: http://localhost:8888 external_url: http://localhost:8888
request_logging: true request_logging: true
# Optional path where custom frontend files are stored.
# If this folder contains at least one file, it will override the embedded frontend.
# If the folder is empty or does not exist on startup, the embedded frontend will be
# written into it. Leave empty to use the embedded frontend only.
frontend_filepath: ""
mail:
# Path where custom email templates (.gotpl and .gohtml) are stored.
# If the directory is empty on startup, the default embedded templates
# will be written there so you can modify them.
# Leave empty to use embedded templates only.
templates_path: ""
webhook: webhook:
url: "" url: ""

View File

@@ -74,6 +74,7 @@ mail:
from: Wireguard Portal <noreply@wireguard.local> from: Wireguard Portal <noreply@wireguard.local>
link_only: false link_only: false
allow_peer_email: false allow_peer_email: false
templates_path: ""
auth: auth:
oidc: [] oidc: []
@@ -96,6 +97,7 @@ web:
expose_host_info: false expose_host_info: false
cert_file: "" cert_file: ""
key_File: "" key_File: ""
frontend_filepath: ""
webhook: webhook:
url: "" url: ""
@@ -485,6 +487,11 @@ To send emails to all peers that have a valid email-address as user-identifier,
If false, and the peer has no valid user record linked, emails will not be sent. If false, and the peer has no valid user record linked, emails will not be sent.
If a peer has linked a valid user, the email address is always taken from the user record. If a peer has linked a valid user, the email address is always taken from the user record.
### `templates_path`
- **Default:** *(empty)*
- **Environment Variable:** `WG_PORTAL_MAIL_TEMPLATES_PATH`
- **Description:** Path to the email template files that override embedded templates. Check [usage documentation](../usage/mail-templates.md) for an example.`
--- ---
## Auth ## Auth
@@ -841,6 +848,14 @@ Without a valid `external_url`, the login process may fail due to CSRF protectio
- **Environment Variable:** `WG_PORTAL_WEB_KEY_FILE` - **Environment Variable:** `WG_PORTAL_WEB_KEY_FILE`
- **Description:** (Optional) Path to the TLS certificate key file. - **Description:** (Optional) Path to the TLS certificate key file.
### `frontend_filepath`
- **Default:** *(empty)*
- **Environment Variable:** `WG_PORTAL_WEB_FRONTEND_FILEPATH`
- **Description:** Optional base directory from which the web frontend is served. Check out the [building](../getting-started/sources.md) documentation for more information on how to compile the frontend assets.
- If the directory contains at least one file (recursively), these files are served at `/app`, overriding the embedded frontend assets.
- If the directory is empty or does not exist on startup, the embedded frontend is copied into this directory automatically and then served.
- If left empty, the embedded frontend is served and no files are written to disk.
--- ---
## Webhook ## Webhook

View File

@@ -0,0 +1,49 @@
WireGuard Portal sends emails when you share a configuration with a user.
By default, the application uses embedded templates. You can fully customize these emails by pointing the Portal
to a folder containing your own templates. If the folder is empty on startup, the default embedded templates
are written there to get you started.
## Configuration
To enable custom templates, set the `mail.templates_path` option in the application configuration file
or the `WG_PORTAL_MAIL_TEMPLATES_PATH` environment variable to a valid folder path.
For example:
```yaml
mail:
# ... other mail options ...
# Path where custom email templates (.gotpl and .gohtml) are stored.
# If the directory is empty on startup, the default embedded templates
# will be written there so you can modify them.
# Leave empty to use embedded templates only.
templates_path: "/opt/wg-portal/mail-templates"
```
## Template files and names
The system expects the following template names. Place files with these names in your `templates_path` to override the defaults.
You do not need to override all templates, only the ones you want to customize should be present.
- Text templates (`.gotpl`):
- `mail_with_link.gotpl`
- `mail_with_attachment.gotpl`
- HTML templates (`.gohtml`):
- `mail_with_link.gohtml`
- `mail_with_attachment.gohtml`
Both [text](https://pkg.go.dev/text/template) and [HTML templates](https://pkg.go.dev/html/template) are standard Go
templates and receive the following data fields, depending on the email type:
- Common fields:
- `PortalUrl` (string) - external URL of the Portal
- `PortalName` (string) - site title/company name
- `User` (*domain.User) - the recipient user (may be partially populated when sending to a peer email)
- Link email (`mail_with_link.*`):
- `Link` (string) - the download link
- Attachment email (`mail_with_attachment.*`):
- `ConfigFileName` (string) - filename of the attached WireGuard config
- `QrcodePngName` (string) - CID content-id of the embedded QR code image
Tip: You can inspect the embedded templates in the repository under [`internal/app/mail/tpl_files/`](https://github.com/h44z/wg-portal/tree/master/internal/app/mail/tpl_files) for reference.
When the directory at `templates_path` is empty, these files are copied to your folder so you can edit them in place.

View File

@@ -4,10 +4,12 @@ import (
"context" "context"
"fmt" "fmt"
"html/template" "html/template"
"io"
"io/fs" "io/fs"
"log/slog" "log/slog"
"net/http" "net/http"
"os" "os"
"path/filepath"
"time" "time"
"github.com/go-pkgz/routegroup" "github.com/go-pkgz/routegroup"
@@ -155,6 +157,37 @@ func (s *Server) setupFrontendRoutes() {
respond.Redirect(w, r, http.StatusMovedPermanently, "/app/favicon.ico") respond.Redirect(w, r, http.StatusMovedPermanently, "/app/favicon.ico")
}) })
// If a custom frontend path is configured, serve files from there when it contains content.
// If the directory is empty or missing, populate it with the embedded frontend-dist content first.
if s.cfg.Web.FrontendFilePath != "" {
if err := os.MkdirAll(s.cfg.Web.FrontendFilePath, 0755); err != nil {
slog.Error("failed to create frontend base directory", "path", s.cfg.Web.FrontendFilePath, "error", err)
} else {
ok := true
hasFiles, err := dirHasFiles(s.cfg.Web.FrontendFilePath)
if err != nil {
slog.Error("failed to check frontend base directory", "path", s.cfg.Web.FrontendFilePath, "error", err)
ok = false
}
if !hasFiles && ok {
embeddedFS := fsMust(fs.Sub(frontendStatics, "frontend-dist"))
if err := copyEmbedDirToDisk(embeddedFS, s.cfg.Web.FrontendFilePath); err != nil {
slog.Error("failed to populate frontend base directory from embedded assets",
"path", s.cfg.Web.FrontendFilePath, "error", err)
ok = false
}
}
if ok {
// serve files from FS
slog.Debug("serving frontend files from custom path", "path", s.cfg.Web.FrontendFilePath)
s.server.HandleFiles("/app", http.Dir(s.cfg.Web.FrontendFilePath))
return
}
}
}
// Fallback: serve embedded frontend files
s.server.HandleFiles("/app", http.FS(fsMust(fs.Sub(frontendStatics, "frontend-dist")))) s.server.HandleFiles("/app", http.FS(fsMust(fs.Sub(frontendStatics, "frontend-dist"))))
} }
@@ -182,3 +215,67 @@ func fsMust(f fs.FS, err error) fs.FS {
} }
return f return f
} }
// dirHasFiles returns true if the directory contains at least one file (non-directory).
func dirHasFiles(dir string) (bool, error) {
d, err := os.Open(dir)
if err != nil {
if os.IsNotExist(err) {
return false, nil
}
return false, err
}
defer d.Close()
// Read a few entries; if any entry exists, consider it having files/dirs.
// We want to know if there is at least one file; if only subdirs exist, still treat as content.
entries, err := d.Readdir(-1)
if err != nil {
return false, err
}
for _, e := range entries {
if e.IsDir() {
// check recursively
has, err := dirHasFiles(filepath.Join(dir, e.Name()))
if err == nil && has {
return true, nil
}
continue
}
// regular file
return true, nil
}
return false, nil
}
// copyEmbedDirToDisk copies the contents of srcFS into dstDir on disk.
func copyEmbedDirToDisk(srcFS fs.FS, dstDir string) error {
return fs.WalkDir(srcFS, ".", func(path string, d fs.DirEntry, err error) error {
if err != nil {
return err
}
target := filepath.Join(dstDir, path)
if d.IsDir() {
return os.MkdirAll(target, 0755)
}
// ensure parent dir exists
if err := os.MkdirAll(filepath.Dir(target), 0755); err != nil {
return err
}
// open source file
f, err := srcFS.Open(path)
if err != nil {
return err
}
defer f.Close()
out, err := os.Create(target)
if err != nil {
return err
}
if _, err := io.Copy(out, f); err != nil {
_ = out.Close()
return err
}
return out.Close()
})
}

View File

@@ -72,7 +72,7 @@ func NewMailManager(
users UserDatabaseRepo, users UserDatabaseRepo,
wg WireguardDatabaseRepo, wg WireguardDatabaseRepo,
) (*Manager, error) { ) (*Manager, error) {
tplHandler, err := newTemplateHandler(cfg.Web.ExternalUrl, cfg.Web.SiteTitle) tplHandler, err := newTemplateHandler(cfg.Web.ExternalUrl, cfg.Web.SiteTitle, cfg.Mail.TemplatesPath)
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to initialize template handler: %w", err) return nil, fmt.Errorf("failed to initialize template handler: %w", err)
} }

View File

@@ -6,6 +6,10 @@ import (
"fmt" "fmt"
htmlTemplate "html/template" htmlTemplate "html/template"
"io" "io"
"io/fs"
"log/slog"
"os"
"path/filepath"
"text/template" "text/template"
"github.com/h44z/wg-portal/internal/domain" "github.com/h44z/wg-portal/internal/domain"
@@ -22,15 +26,50 @@ type TemplateHandler struct {
textTemplates *template.Template textTemplates *template.Template
} }
func newTemplateHandler(portalUrl, portalName string) (*TemplateHandler, error) { func newTemplateHandler(portalUrl, portalName string, basePath string) (*TemplateHandler, error) {
// Always parse embedded defaults first
htmlTemplateCache, err := htmlTemplate.New("Html").ParseFS(TemplateFiles, "tpl_files/*.gohtml") htmlTemplateCache, err := htmlTemplate.New("Html").ParseFS(TemplateFiles, "tpl_files/*.gohtml")
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to parse html template files: %w", err) return nil, fmt.Errorf("failed to parse embedded html template files: %w", err)
} }
txtTemplateCache, err := template.New("Txt").ParseFS(TemplateFiles, "tpl_files/*.gotpl") txtTemplateCache, err := template.New("Txt").ParseFS(TemplateFiles, "tpl_files/*.gotpl")
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to parse text template files: %w", err) return nil, fmt.Errorf("failed to parse embedded text template files: %w", err)
}
// If a basePath is provided, ensure existence, populate if empty, then parse to override
if basePath != "" {
if err := os.MkdirAll(basePath, 0755); err != nil {
return nil, fmt.Errorf("failed to create templates base directory %s: %w", basePath, err)
}
hasTemplates, err := dirHasTemplates(basePath)
if err != nil {
return nil, fmt.Errorf("failed to inspect templates directory: %w", err)
}
// If no templates present, copy embedded defaults to directory
if !hasTemplates {
if err := copyEmbeddedTemplates(basePath); err != nil {
return nil, fmt.Errorf("failed to populate templates directory: %w", err)
}
}
// Parse files from basePath to override embedded ones.
// Only parse when matches exist to allow partial overrides without errors.
if matches, _ := filepath.Glob(filepath.Join(basePath, "*.gohtml")); len(matches) > 0 {
slog.Debug("parsing html email templates from base path", "base-path", basePath, "files", matches)
if htmlTemplateCache, err = htmlTemplateCache.ParseFiles(matches...); err != nil {
return nil, fmt.Errorf("failed to parse html templates from base path: %w", err)
}
}
if matches, _ := filepath.Glob(filepath.Join(basePath, "*.gotpl")); len(matches) > 0 {
slog.Debug("parsing text email templates from base path", "base-path", basePath, "files", matches)
if txtTemplateCache, err = txtTemplateCache.ParseFiles(matches...); err != nil {
return nil, fmt.Errorf("failed to parse text templates from base path: %w", err)
}
}
} }
handler := &TemplateHandler{ handler := &TemplateHandler{
@@ -43,6 +82,51 @@ func newTemplateHandler(portalUrl, portalName string) (*TemplateHandler, error)
return handler, nil return handler, nil
} }
// dirHasTemplates checks whether directory contains any .gohtml or .gotpl files.
func dirHasTemplates(basePath string) (bool, error) {
entries, err := os.ReadDir(basePath)
if err != nil {
return false, err
}
for _, e := range entries {
if e.IsDir() {
continue
}
ext := filepath.Ext(e.Name())
if ext == ".gohtml" || ext == ".gotpl" {
return true, nil
}
}
return false, nil
}
// copyEmbeddedTemplates writes embedded templates into basePath.
func copyEmbeddedTemplates(basePath string) error {
list, err := fs.ReadDir(TemplateFiles, "tpl_files")
if err != nil {
return err
}
for _, entry := range list {
if entry.IsDir() {
continue
}
name := entry.Name()
// Only copy known template extensions
if ext := filepath.Ext(name); ext != ".gohtml" && ext != ".gotpl" {
continue
}
data, err := TemplateFiles.ReadFile(filepath.Join("tpl_files", name))
if err != nil {
return err
}
out := filepath.Join(basePath, name)
if err := os.WriteFile(out, data, 0644); err != nil {
return err
}
}
return nil
}
// GetConfigMail returns the text and html template for the mail with a link. // GetConfigMail returns the text and html template for the mail with a link.
func (c TemplateHandler) GetConfigMail(user *domain.User, link string) (io.Reader, io.Reader, error) { func (c TemplateHandler) GetConfigMail(user *domain.User, link string) (io.Reader, io.Reader, error) {
var tplBuff bytes.Buffer var tplBuff bytes.Buffer
@@ -52,6 +136,7 @@ func (c TemplateHandler) GetConfigMail(user *domain.User, link string) (io.Reade
"User": user, "User": user,
"Link": link, "Link": link,
"PortalUrl": c.portalUrl, "PortalUrl": c.portalUrl,
"PortalName": c.portalName,
}) })
if err != nil { if err != nil {
return nil, nil, fmt.Errorf("failed to execute template mail_with_link.gotpl: %w", err) return nil, nil, fmt.Errorf("failed to execute template mail_with_link.gotpl: %w", err)
@@ -61,6 +146,7 @@ func (c TemplateHandler) GetConfigMail(user *domain.User, link string) (io.Reade
"User": user, "User": user,
"Link": link, "Link": link,
"PortalUrl": c.portalUrl, "PortalUrl": c.portalUrl,
"PortalName": c.portalName,
}) })
if err != nil { if err != nil {
return nil, nil, fmt.Errorf("failed to execute template mail_with_link.gohtml: %w", err) return nil, nil, fmt.Errorf("failed to execute template mail_with_link.gohtml: %w", err)

View File

@@ -157,6 +157,7 @@ func defaultConfig() *Config {
SiteCompanyName: getEnvStr("WG_PORTAL_WEB_SITE_COMPANY_NAME", "WireGuard Portal"), SiteCompanyName: getEnvStr("WG_PORTAL_WEB_SITE_COMPANY_NAME", "WireGuard Portal"),
CertFile: getEnvStr("WG_PORTAL_WEB_CERT_FILE", ""), CertFile: getEnvStr("WG_PORTAL_WEB_CERT_FILE", ""),
KeyFile: getEnvStr("WG_PORTAL_WEB_KEY_FILE", ""), KeyFile: getEnvStr("WG_PORTAL_WEB_KEY_FILE", ""),
FrontendFilePath: getEnvStr("WG_PORTAL_WEB_FRONTEND_FILEPATH", ""),
} }
cfg.Advanced.LogLevel = getEnvStr("WG_PORTAL_ADVANCED_LOG_LEVEL", "info") cfg.Advanced.LogLevel = getEnvStr("WG_PORTAL_ADVANCED_LOG_LEVEL", "info")
@@ -195,6 +196,7 @@ func defaultConfig() *Config {
From: getEnvStr("WG_PORTAL_MAIL_FROM", "Wireguard Portal <noreply@wireguard.local>"), From: getEnvStr("WG_PORTAL_MAIL_FROM", "Wireguard Portal <noreply@wireguard.local>"),
LinkOnly: getEnvBool("WG_PORTAL_MAIL_LINK_ONLY", false), LinkOnly: getEnvBool("WG_PORTAL_MAIL_LINK_ONLY", false),
AllowPeerEmail: getEnvBool("WG_PORTAL_MAIL_ALLOW_PEER_EMAIL", false), AllowPeerEmail: getEnvBool("WG_PORTAL_MAIL_ALLOW_PEER_EMAIL", false),
TemplatesPath: getEnvStr("WG_PORTAL_MAIL_TEMPLATES_PATH", ""),
} }
cfg.Webhook.Url = getEnvStr("WG_PORTAL_WEBHOOK_URL", "") // no webhook by default cfg.Webhook.Url = getEnvStr("WG_PORTAL_WEBHOOK_URL", "") // no webhook by default

View File

@@ -43,4 +43,8 @@ type MailConfig struct {
LinkOnly bool `yaml:"link_only"` LinkOnly bool `yaml:"link_only"`
// AllowPeerEmail specifies whether emails should be sent to peers which have no valid user account linked, but an email address is set as "user". // AllowPeerEmail specifies whether emails should be sent to peers which have no valid user account linked, but an email address is set as "user".
AllowPeerEmail bool `yaml:"allow_peer_email"` AllowPeerEmail bool `yaml:"allow_peer_email"`
// TemplatesPath is an optional base path on the filesystem that contains email templates (.gotpl and .gohtml).
// If the directory exists but is empty, the embedded default templates will be written there on startup.
// If templates are present in the directory, they override the embedded defaults.
TemplatesPath string `yaml:"templates_path"`
} }

View File

@@ -27,6 +27,10 @@ type WebConfig struct {
CertFile string `yaml:"cert_file"` CertFile string `yaml:"cert_file"`
// KeyFile is the path to the TLS certificate key file. // KeyFile is the path to the TLS certificate key file.
KeyFile string `yaml:"key_file"` KeyFile string `yaml:"key_file"`
// FrontendFilePath is an optional path to a folder that contains the frontend files.
// If set and the folder contains at least one file, it overrides the embedded frontend.
// If set and the folder is empty or does not exist, the embedded frontend will be written into it on startup.
FrontendFilePath string `yaml:"frontend_filepath"`
} }
func (c *WebConfig) Sanitize() { func (c *WebConfig) Sanitize() {

View File

@@ -81,6 +81,7 @@ nav:
- Reverse Proxy (HTTPS): documentation/getting-started/reverse-proxy.md - Reverse Proxy (HTTPS): documentation/getting-started/reverse-proxy.md
- Configuration: - Configuration:
- Overview: documentation/configuration/overview.md - Overview: documentation/configuration/overview.md
- Mail templates: documentation/configuration/mail-templates.md
- Examples: documentation/configuration/examples.md - Examples: documentation/configuration/examples.md
- Usage: - Usage:
- General: documentation/usage/general.md - General: documentation/usage/general.md
@@ -88,6 +89,7 @@ nav:
- LDAP: documentation/usage/ldap.md - LDAP: documentation/usage/ldap.md
- Security: documentation/usage/security.md - Security: documentation/usage/security.md
- Webhooks: documentation/usage/webhooks.md - Webhooks: documentation/usage/webhooks.md
- Mail Templates: documentation/usage/mail-templates.md
- REST API: documentation/rest-api/api-doc.md - REST API: documentation/rest-api/api-doc.md
- Upgrade: documentation/upgrade/v1.md - Upgrade: documentation/upgrade/v1.md
- Monitoring: documentation/monitoring/prometheus.md - Monitoring: documentation/monitoring/prometheus.md