diff --git a/config.yml.sample b/config.yml.sample index 638cf9b..9e5ef5d 100644 --- a/config.yml.sample +++ b/config.yml.sample @@ -12,6 +12,18 @@ core: web: external_url: http://localhost:8888 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: url: "" diff --git a/docs/documentation/configuration/overview.md b/docs/documentation/configuration/overview.md index cad0040..b926ddd 100644 --- a/docs/documentation/configuration/overview.md +++ b/docs/documentation/configuration/overview.md @@ -74,6 +74,7 @@ mail: from: Wireguard Portal link_only: false allow_peer_email: false + templates_path: "" auth: oidc: [] @@ -96,6 +97,7 @@ web: expose_host_info: false cert_file: "" key_File: "" + frontend_filepath: "" webhook: 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 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 @@ -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` - **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 diff --git a/docs/documentation/usage/mail-templates.md b/docs/documentation/usage/mail-templates.md new file mode 100644 index 0000000..b61150e --- /dev/null +++ b/docs/documentation/usage/mail-templates.md @@ -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. diff --git a/internal/app/api/core/server.go b/internal/app/api/core/server.go index 4cc986c..5ed8746 100644 --- a/internal/app/api/core/server.go +++ b/internal/app/api/core/server.go @@ -4,10 +4,12 @@ import ( "context" "fmt" "html/template" + "io" "io/fs" "log/slog" "net/http" "os" + "path/filepath" "time" "github.com/go-pkgz/routegroup" @@ -155,6 +157,37 @@ func (s *Server) setupFrontendRoutes() { 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")))) } @@ -182,3 +215,67 @@ func fsMust(f fs.FS, err error) fs.FS { } 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() + }) +} diff --git a/internal/app/mail/manager.go b/internal/app/mail/manager.go index 21f50f3..5a20278 100644 --- a/internal/app/mail/manager.go +++ b/internal/app/mail/manager.go @@ -72,7 +72,7 @@ func NewMailManager( users UserDatabaseRepo, wg WireguardDatabaseRepo, ) (*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 { return nil, fmt.Errorf("failed to initialize template handler: %w", err) } diff --git a/internal/app/mail/template.go b/internal/app/mail/template.go index 722d534..10f22aa 100644 --- a/internal/app/mail/template.go +++ b/internal/app/mail/template.go @@ -6,6 +6,10 @@ import ( "fmt" htmlTemplate "html/template" "io" + "io/fs" + "log/slog" + "os" + "path/filepath" "text/template" "github.com/h44z/wg-portal/internal/domain" @@ -22,15 +26,50 @@ type TemplateHandler struct { 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") 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") 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{ @@ -43,24 +82,71 @@ func newTemplateHandler(portalUrl, portalName string) (*TemplateHandler, error) 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. func (c TemplateHandler) GetConfigMail(user *domain.User, link string) (io.Reader, io.Reader, error) { var tplBuff bytes.Buffer var htmlTplBuff bytes.Buffer err := c.textTemplates.ExecuteTemplate(&tplBuff, "mail_with_link.gotpl", map[string]any{ - "User": user, - "Link": link, - "PortalUrl": c.portalUrl, + "User": user, + "Link": link, + "PortalUrl": c.portalUrl, + "PortalName": c.portalName, }) if err != nil { return nil, nil, fmt.Errorf("failed to execute template mail_with_link.gotpl: %w", err) } err = c.htmlTemplates.ExecuteTemplate(&htmlTplBuff, "mail_with_link.gohtml", map[string]any{ - "User": user, - "Link": link, - "PortalUrl": c.portalUrl, + "User": user, + "Link": link, + "PortalUrl": c.portalUrl, + "PortalName": c.portalName, }) if err != nil { return nil, nil, fmt.Errorf("failed to execute template mail_with_link.gohtml: %w", err) diff --git a/internal/config/config.go b/internal/config/config.go index 5cd4bff..d9ffe54 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -157,6 +157,7 @@ func defaultConfig() *Config { SiteCompanyName: getEnvStr("WG_PORTAL_WEB_SITE_COMPANY_NAME", "WireGuard Portal"), CertFile: getEnvStr("WG_PORTAL_WEB_CERT_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") @@ -195,6 +196,7 @@ func defaultConfig() *Config { From: getEnvStr("WG_PORTAL_MAIL_FROM", "Wireguard Portal "), LinkOnly: getEnvBool("WG_PORTAL_MAIL_LINK_ONLY", 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 diff --git a/internal/config/mail.go b/internal/config/mail.go index 56df2b0..baff62b 100644 --- a/internal/config/mail.go +++ b/internal/config/mail.go @@ -43,4 +43,8 @@ type MailConfig struct { 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 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"` } diff --git a/internal/config/web.go b/internal/config/web.go index e4d8dd3..407356c 100644 --- a/internal/config/web.go +++ b/internal/config/web.go @@ -27,6 +27,10 @@ type WebConfig struct { CertFile string `yaml:"cert_file"` // KeyFile is the path to the TLS certificate 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() { diff --git a/mkdocs.yml b/mkdocs.yml index 5bf7686..3f2f420 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -81,6 +81,7 @@ nav: - Reverse Proxy (HTTPS): documentation/getting-started/reverse-proxy.md - Configuration: - Overview: documentation/configuration/overview.md + - Mail templates: documentation/configuration/mail-templates.md - Examples: documentation/configuration/examples.md - Usage: - General: documentation/usage/general.md @@ -88,6 +89,7 @@ nav: - LDAP: documentation/usage/ldap.md - Security: documentation/usage/security.md - Webhooks: documentation/usage/webhooks.md + - Mail Templates: documentation/usage/mail-templates.md - REST API: documentation/rest-api/api-doc.md - Upgrade: documentation/upgrade/v1.md - Monitoring: documentation/monitoring/prometheus.md