feat: add SMTP email service with signature reminders

Add configurable SMTP service for sending signature reminder emails.

Features:
- Configurable via ACKIFY_MAIL_* environment variables
- Multilingual templates (en/fr) with HTML + text versions
- Template rendering with automatic variable injection
- Graceful degradation when SMTP not configured
- TLS/STARTTLS support with configurable timeout
- MailHog integration for local testing
This commit is contained in:
Benjamin
2025-10-06 14:04:42 +02:00
parent 0015af12e1
commit 2c24c3f2f6
14 changed files with 774 additions and 1 deletions

5
go.mod
View File

@@ -4,12 +4,14 @@ go 1.24.5
require (
github.com/go-chi/chi/v5 v5.2.3
github.com/go-mail/mail/v2 v2.3.0
github.com/golang-migrate/migrate/v4 v4.19.0
github.com/gorilla/securecookie v1.1.2
github.com/gorilla/sessions v1.4.0
github.com/lib/pq v1.10.9
github.com/stretchr/testify v1.11.1
golang.org/x/oauth2 v0.31.0
golang.org/x/text v0.29.0
)
require (
@@ -19,7 +21,8 @@ require (
github.com/kr/pretty v0.3.1 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/rogpeppe/go-internal v1.13.1 // indirect
golang.org/x/text v0.29.0 // indirect
gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc // indirect
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect
gopkg.in/mail.v2 v2.3.1 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)

6
go.sum
View File

@@ -27,6 +27,8 @@ github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
github.com/go-mail/mail/v2 v2.3.0 h1:wha99yf2v3cpUzD1V9ujP404Jbw2uEvs+rBJybkdYcw=
github.com/go-mail/mail/v2 v2.3.0/go.mod h1:oE2UK8qebZAjjV1ZYUpY7FPnbi/kIU53l1dmqPRb4go=
github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q=
github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q=
github.com/golang-migrate/migrate/v4 v4.19.0 h1:RcjOnCGz3Or6HQYEJ/EEVLfWnmw9KnoigPSjzhCuaSE=
@@ -87,8 +89,12 @@ golang.org/x/sys v0.31.0 h1:ioabZlmFYtWhL+TRYpcnNlLwhyxaM9kWTDEmfnprqik=
golang.org/x/sys v0.31.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
golang.org/x/text v0.29.0 h1:1neNs90w9YzJ9BocxfsQNHKuAT4pkghyXc4nhZ6sJvk=
golang.org/x/text v0.29.0/go.mod h1:7MhJOA9CD2qZyOKYazxdYMF85OwPdEr9jTtBpO7ydH4=
gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc h1:2gGKlE2+asNV9m7xrywl36YYNnBG5ZQ0r/BOOxqPpmk=
gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc/go.mod h1:m7x9LTH6d71AHyAX77c9yqWCCa3UKHcVEj9y7hAtKDk=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
gopkg.in/mail.v2 v2.3.1 h1:WYFn/oANrAGP2C0dcV6/pbkPzv8yGzqTjPmTeO7qoXk=
gopkg.in/mail.v2 v2.3.1/go.mod h1:htwXN1Qh09vZJ1NVKxQqHPBaCBbzKhp5GzuJEA4VJWw=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

View File

@@ -16,6 +16,7 @@ type Config struct {
OAuth OAuthConfig
Server ServerConfig
Logger LoggerConfig
Mail MailConfig
}
type AppConfig struct {
@@ -50,6 +51,21 @@ type LoggerConfig struct {
Level string
}
type MailConfig struct {
Host string
Port int
Username string
Password string
TLS bool
StartTLS bool
Timeout string
From string
FromName string
SubjectPrefix string
TemplateDir string
DefaultLocale string
}
// Load loads configuration from environment variables
func Load() (*Config, error) {
config := &Config{}
@@ -118,6 +134,23 @@ func Load() (*Config, error) {
}
}
// Parse mail config (optional, service disabled if MAIL_HOST not set)
mailHost := getEnv("ACKIFY_MAIL_HOST", "")
if mailHost != "" {
config.Mail.Host = mailHost
config.Mail.Port = getEnvInt("ACKIFY_MAIL_PORT", 587)
config.Mail.Username = getEnv("ACKIFY_MAIL_USERNAME", "")
config.Mail.Password = getEnv("ACKIFY_MAIL_PASSWORD", "")
config.Mail.TLS = getEnvBool("ACKIFY_MAIL_TLS", true)
config.Mail.StartTLS = getEnvBool("ACKIFY_MAIL_STARTTLS", true)
config.Mail.Timeout = getEnv("ACKIFY_MAIL_TIMEOUT", "10s")
config.Mail.From = getEnv("ACKIFY_MAIL_FROM", "")
config.Mail.FromName = getEnv("ACKIFY_MAIL_FROM_NAME", config.App.Organisation)
config.Mail.SubjectPrefix = getEnv("ACKIFY_MAIL_SUBJECT_PREFIX", "")
config.Mail.TemplateDir = getEnv("ACKIFY_MAIL_TEMPLATE_DIR", "templates/emails")
config.Mail.DefaultLocale = getEnv("ACKIFY_MAIL_DEFAULT_LOCALE", "en")
}
return config, nil
}
@@ -151,3 +184,23 @@ func parseCookieSecret() ([]byte, error) {
return []byte(raw), nil
}
func getEnvInt(key string, defaultValue int) int {
value := strings.TrimSpace(os.Getenv(key))
if value == "" {
return defaultValue
}
var result int
if _, err := fmt.Sscanf(value, "%d", &result); err == nil {
return result
}
return defaultValue
}
func getEnvBool(key string, defaultValue bool) bool {
value := strings.TrimSpace(os.Getenv(key))
if value == "" {
return defaultValue
}
return strings.ToLower(value) == "true" || value == "1"
}

View File

@@ -933,3 +933,273 @@ func equalSlices(a, b []string) bool {
}
return true
}
func TestLoad_MailConfig(t *testing.T) {
t.Run("mail config with all settings", func(t *testing.T) {
envVars := map[string]string{
"ACKIFY_BASE_URL": "https://ackify.example.com",
"ACKIFY_ORGANISATION": "Test Org",
"ACKIFY_DB_DSN": "postgres://user:pass@localhost/test",
"ACKIFY_OAUTH_CLIENT_ID": "test-client-id",
"ACKIFY_OAUTH_CLIENT_SECRET": "test-client-secret",
"ACKIFY_OAUTH_PROVIDER": "google",
"ACKIFY_MAIL_HOST": "smtp.example.com",
"ACKIFY_MAIL_PORT": "465",
"ACKIFY_MAIL_USERNAME": "noreply@example.com",
"ACKIFY_MAIL_PASSWORD": "smtp-password",
"ACKIFY_MAIL_TLS": "true",
"ACKIFY_MAIL_STARTTLS": "false",
"ACKIFY_MAIL_TIMEOUT": "30s",
"ACKIFY_MAIL_FROM": "noreply@example.com",
"ACKIFY_MAIL_FROM_NAME": "Ackify Notifications",
"ACKIFY_MAIL_SUBJECT_PREFIX": "[Ackify]",
"ACKIFY_MAIL_TEMPLATE_DIR": "/custom/templates/emails",
"ACKIFY_MAIL_DEFAULT_LOCALE": "fr",
}
for key, value := range envVars {
_ = os.Setenv(key, value)
}
defer func() {
for key := range envVars {
_ = os.Unsetenv(key)
}
}()
config, err := Load()
if err != nil {
t.Fatalf("Load() failed: %v", err)
}
if config.Mail.Host != "smtp.example.com" {
t.Errorf("Mail.Host = %v, expected smtp.example.com", config.Mail.Host)
}
if config.Mail.Port != 465 {
t.Errorf("Mail.Port = %v, expected 465", config.Mail.Port)
}
if config.Mail.Username != "noreply@example.com" {
t.Errorf("Mail.Username = %v, expected noreply@example.com", config.Mail.Username)
}
if config.Mail.Password != "smtp-password" {
t.Errorf("Mail.Password = %v, expected smtp-password", config.Mail.Password)
}
if !config.Mail.TLS {
t.Error("Mail.TLS should be true")
}
if config.Mail.StartTLS {
t.Error("Mail.StartTLS should be false")
}
if config.Mail.Timeout != "30s" {
t.Errorf("Mail.Timeout = %v, expected 30s", config.Mail.Timeout)
}
if config.Mail.From != "noreply@example.com" {
t.Errorf("Mail.From = %v, expected noreply@example.com", config.Mail.From)
}
if config.Mail.FromName != "Ackify Notifications" {
t.Errorf("Mail.FromName = %v, expected Ackify Notifications", config.Mail.FromName)
}
if config.Mail.SubjectPrefix != "[Ackify]" {
t.Errorf("Mail.SubjectPrefix = %v, expected [Ackify]", config.Mail.SubjectPrefix)
}
if config.Mail.TemplateDir != "/custom/templates/emails" {
t.Errorf("Mail.TemplateDir = %v, expected /custom/templates/emails", config.Mail.TemplateDir)
}
if config.Mail.DefaultLocale != "fr" {
t.Errorf("Mail.DefaultLocale = %v, expected fr", config.Mail.DefaultLocale)
}
})
t.Run("mail config with defaults", func(t *testing.T) {
envVars := map[string]string{
"ACKIFY_BASE_URL": "https://ackify.example.com",
"ACKIFY_ORGANISATION": "Test Org",
"ACKIFY_DB_DSN": "postgres://user:pass@localhost/test",
"ACKIFY_OAUTH_CLIENT_ID": "test-client-id",
"ACKIFY_OAUTH_CLIENT_SECRET": "test-client-secret",
"ACKIFY_OAUTH_PROVIDER": "google",
"ACKIFY_MAIL_HOST": "smtp.example.com",
}
for key, value := range envVars {
_ = os.Setenv(key, value)
}
defer func() {
for key := range envVars {
_ = os.Unsetenv(key)
}
}()
config, err := Load()
if err != nil {
t.Fatalf("Load() failed: %v", err)
}
if config.Mail.Port != 587 {
t.Errorf("Mail.Port = %v, expected default 587", config.Mail.Port)
}
if !config.Mail.TLS {
t.Error("Mail.TLS should default to true")
}
if !config.Mail.StartTLS {
t.Error("Mail.StartTLS should default to true")
}
if config.Mail.Timeout != "10s" {
t.Errorf("Mail.Timeout = %v, expected default 10s", config.Mail.Timeout)
}
if config.Mail.FromName != "Test Org" {
t.Errorf("Mail.FromName = %v, expected organisation name", config.Mail.FromName)
}
if config.Mail.TemplateDir != "templates/emails" {
t.Errorf("Mail.TemplateDir = %v, expected default templates/emails", config.Mail.TemplateDir)
}
if config.Mail.DefaultLocale != "en" {
t.Errorf("Mail.DefaultLocale = %v, expected default en", config.Mail.DefaultLocale)
}
})
t.Run("mail disabled when MAIL_HOST not set", func(t *testing.T) {
envVars := map[string]string{
"ACKIFY_BASE_URL": "https://ackify.example.com",
"ACKIFY_ORGANISATION": "Test Org",
"ACKIFY_DB_DSN": "postgres://user:pass@localhost/test",
"ACKIFY_OAUTH_CLIENT_ID": "test-client-id",
"ACKIFY_OAUTH_CLIENT_SECRET": "test-client-secret",
"ACKIFY_OAUTH_PROVIDER": "google",
}
for key, value := range envVars {
_ = os.Setenv(key, value)
}
defer func() {
for key := range envVars {
_ = os.Unsetenv(key)
}
}()
_ = os.Unsetenv("ACKIFY_MAIL_HOST")
config, err := Load()
if err != nil {
t.Fatalf("Load() failed: %v", err)
}
if config.Mail.Host != "" {
t.Errorf("Mail.Host should be empty when not configured, got %v", config.Mail.Host)
}
if config.Mail.Port != 0 {
t.Errorf("Mail.Port should be 0 when not configured, got %v", config.Mail.Port)
}
})
}
func TestGetEnvInt(t *testing.T) {
tests := []struct {
name string
key string
envValue string
defaultValue int
expected int
}{
{
name: "valid integer",
key: "TEST_INT_VAR",
envValue: "587",
defaultValue: 25,
expected: 587,
},
{
name: "missing uses default",
key: "MISSING_INT_VAR",
envValue: "",
defaultValue: 100,
expected: 100,
},
{
name: "invalid integer uses default",
key: "INVALID_INT_VAR",
envValue: "not-a-number",
defaultValue: 50,
expected: 50,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
_ = os.Unsetenv(tt.key)
if tt.envValue != "" {
_ = os.Setenv(tt.key, tt.envValue)
defer func() {
_ = os.Unsetenv(tt.key)
}()
}
result := getEnvInt(tt.key, tt.defaultValue)
if result != tt.expected {
t.Errorf("getEnvInt() = %v, expected %v", result, tt.expected)
}
})
}
}
func TestGetEnvBool(t *testing.T) {
tests := []struct {
name string
key string
envValue string
defaultValue bool
expected bool
}{
{
name: "true string",
key: "TEST_BOOL_VAR",
envValue: "true",
defaultValue: false,
expected: true,
},
{
name: "1 string",
key: "TEST_BOOL_VAR",
envValue: "1",
defaultValue: false,
expected: true,
},
{
name: "false string",
key: "TEST_BOOL_VAR",
envValue: "false",
defaultValue: true,
expected: false,
},
{
name: "missing uses default true",
key: "MISSING_BOOL_VAR",
envValue: "",
defaultValue: true,
expected: true,
},
{
name: "missing uses default false",
key: "MISSING_BOOL_VAR",
envValue: "",
defaultValue: false,
expected: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
_ = os.Unsetenv(tt.key)
if tt.envValue != "" {
_ = os.Setenv(tt.key, tt.envValue)
defer func() {
_ = os.Unsetenv(tt.key)
}()
}
result := getEnvBool(tt.key, tt.defaultValue)
if result != tt.expected {
t.Errorf("getEnvBool() = %v, expected %v", result, tt.expected)
}
})
}
}

View File

@@ -0,0 +1,33 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
package email
import (
"context"
)
func SendEmail(ctx context.Context, sender Sender, template string, to []string, locale string, subject string, data map[string]any) error {
msg := Message{
To: to,
Subject: subject,
Template: template,
Locale: locale,
Data: data,
}
return sender.Send(ctx, msg)
}
func SendSignatureReminderEmail(ctx context.Context, sender Sender, to []string, locale, docID, docURL, signURL string) error {
data := map[string]any{
"DocID": docID,
"DocURL": docURL,
"SignURL": signURL,
}
subject := "Reminder: Document signature required"
if locale == "fr" {
subject = "Rappel : Signature de document requise"
}
return SendEmail(ctx, sender, "signature_reminder", to, locale, subject, data)
}

View File

@@ -0,0 +1,121 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
package email
import (
"bytes"
"fmt"
htmlTemplate "html/template"
"os"
"path/filepath"
txtTemplate "text/template"
)
type Renderer struct {
templateDir string
baseURL string
organisation string
fromName string
fromMail string
defaultLocale string
}
type TemplateData struct {
Organisation string
BaseURL string
FromName string
FromMail string
Data map[string]any
}
func NewRenderer(templateDir, baseURL, organisation, fromName, fromMail, defaultLocale string) *Renderer {
return &Renderer{
templateDir: templateDir,
baseURL: baseURL,
organisation: organisation,
fromName: fromName,
fromMail: fromMail,
defaultLocale: defaultLocale,
}
}
func (r *Renderer) Render(templateName, locale string, data map[string]any) (htmlBody, textBody string, err error) {
if locale == "" {
locale = r.defaultLocale
}
templateData := TemplateData{
Organisation: r.organisation,
BaseURL: r.baseURL,
FromName: r.fromName,
FromMail: r.fromMail,
Data: data,
}
htmlBody, err = r.renderHTML(templateName, locale, templateData)
if err != nil {
return "", "", fmt.Errorf("failed to render HTML: %w", err)
}
textBody, err = r.renderText(templateName, locale, templateData)
if err != nil {
return "", "", fmt.Errorf("failed to render text: %w", err)
}
return htmlBody, textBody, nil
}
func (r *Renderer) renderHTML(templateName, locale string, data TemplateData) (string, error) {
baseTemplatePath := filepath.Join(r.templateDir, "base.html.tmpl")
templatePath := r.resolveTemplatePath(templateName, locale, "html.tmpl")
if templatePath == "" {
return "", fmt.Errorf("template not found: %s (locale: %s)", templateName, locale)
}
tmpl, err := htmlTemplate.ParseFiles(baseTemplatePath, templatePath)
if err != nil {
return "", fmt.Errorf("failed to parse template: %w", err)
}
var buf bytes.Buffer
if err := tmpl.ExecuteTemplate(&buf, "base", data); err != nil {
return "", fmt.Errorf("failed to execute template: %w", err)
}
return buf.String(), nil
}
func (r *Renderer) renderText(templateName, locale string, data TemplateData) (string, error) {
baseTemplatePath := filepath.Join(r.templateDir, "base.txt.tmpl")
templatePath := r.resolveTemplatePath(templateName, locale, "txt.tmpl")
if templatePath == "" {
return "", fmt.Errorf("template not found: %s (locale: %s)", templateName, locale)
}
tmpl, err := txtTemplate.ParseFiles(baseTemplatePath, templatePath)
if err != nil {
return "", fmt.Errorf("failed to parse template: %w", err)
}
var buf bytes.Buffer
if err := tmpl.ExecuteTemplate(&buf, "base", data); err != nil {
return "", fmt.Errorf("failed to execute template: %w", err)
}
return buf.String(), nil
}
func (r *Renderer) resolveTemplatePath(templateName, locale, extension string) string {
localizedPath := filepath.Join(r.templateDir, fmt.Sprintf("%s.%s.%s", templateName, locale, extension))
if _, err := os.Stat(localizedPath); err == nil {
return localizedPath
}
fallbackPath := filepath.Join(r.templateDir, fmt.Sprintf("%s.en.%s", templateName, extension))
if _, err := os.Stat(fallbackPath); err == nil {
return fallbackPath
}
return ""
}

View File

@@ -0,0 +1,113 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
package email
import (
"context"
"crypto/tls"
"fmt"
"time"
mail "github.com/go-mail/mail/v2"
"github.com/btouchard/ackify-ce/internal/infrastructure/config"
"github.com/btouchard/ackify-ce/pkg/logger"
)
type Sender interface {
Send(ctx context.Context, msg Message) error
}
type Message struct {
To []string
Cc []string
Bcc []string
Subject string
Template string
Locale string
Data map[string]any
Headers map[string]string
}
type SMTPSender struct {
config config.MailConfig
renderer *Renderer
}
func NewSMTPSender(cfg config.MailConfig, renderer *Renderer) *SMTPSender {
return &SMTPSender{
config: cfg,
renderer: renderer,
}
}
func (s *SMTPSender) Send(ctx context.Context, msg Message) error {
if s.config.Host == "" {
logger.Logger.Info("SMTP not configured, email not sent", "template", msg.Template)
return nil
}
htmlBody, textBody, err := s.renderer.Render(msg.Template, msg.Locale, msg.Data)
if err != nil {
return fmt.Errorf("failed to render email template: %w", err)
}
m := mail.NewMessage()
from := s.config.From
if from == "" {
return fmt.Errorf("ACKIFY_MAIL_FROM not set")
}
m.SetHeader("From", m.FormatAddress(from, s.config.FromName))
if len(msg.To) == 0 {
return fmt.Errorf("no recipients specified")
}
m.SetHeader("To", msg.To...)
if len(msg.Cc) > 0 {
m.SetHeader("Cc", msg.Cc...)
}
if len(msg.Bcc) > 0 {
m.SetHeader("Bcc", msg.Bcc...)
}
subject := msg.Subject
if s.config.SubjectPrefix != "" {
subject = s.config.SubjectPrefix + subject
}
m.SetHeader("Subject", subject)
for key, value := range msg.Headers {
m.SetHeader(key, value)
}
m.SetBody("text/plain", textBody)
m.AddAlternative("text/html", htmlBody)
timeout, err := time.ParseDuration(s.config.Timeout)
if err != nil {
timeout = 10 * time.Second
}
d := mail.NewDialer(s.config.Host, s.config.Port, s.config.Username, s.config.Password)
if s.config.TLS {
d.SSL = true
}
if s.config.StartTLS {
d.TLSConfig = &tls.Config{ServerName: s.config.Host}
}
d.Timeout = timeout
logger.Logger.Info("Sending email", "to", msg.To, "template", msg.Template, "locale", msg.Locale)
if err := d.DialAndSend(m); err != nil {
return fmt.Errorf("failed to send email: %w", err)
}
logger.Logger.Info("Email sent successfully", "to", msg.To)
return nil
}

View File

@@ -0,0 +1,61 @@
{{define "base"}}
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{{.Organisation}}</title>
<style>
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
line-height: 1.6;
color: #333;
max-width: 600px;
margin: 0 auto;
padding: 20px;
}
.header {
border-bottom: 2px solid #4F46E5;
padding-bottom: 20px;
margin-bottom: 30px;
}
.header h1 {
color: #4F46E5;
margin: 0;
font-size: 24px;
}
.content {
margin-bottom: 30px;
}
.footer {
border-top: 1px solid #e5e7eb;
padding-top: 20px;
margin-top: 30px;
font-size: 14px;
color: #6b7280;
}
a {
color: #4F46E5;
text-decoration: none;
}
a:hover {
text-decoration: underline;
}
</style>
</head>
<body>
<div class="header">
<h1>{{.Organisation}}</h1>
</div>
<div class="content">
{{template "content" .}}
</div>
<div class="footer">
<p>This email was sent by <a href="{{.BaseURL}}">{{.Organisation}}</a></p>
<p>Powered by Ackify - Proof of Read</p>
</div>
</body>
</html>
{{end}}

View File

@@ -0,0 +1,13 @@
{{define "base"}}
========================================
{{.Organisation}}
========================================
{{template "content" .}}
----------------------------------------
This email was sent by {{.Organisation}}
{{.BaseURL}}
Powered by Ackify - Proof of Read
{{end}}

View File

@@ -0,0 +1,29 @@
{{define "content"}}
<h2>Document Signature Reminder</h2>
<p>Hello,</p>
<p>This is a reminder that the following document requires your signature:</p>
<div style="background-color: #f3f4f6; padding: 15px; border-radius: 8px; margin: 20px 0;">
<p style="margin: 0;"><strong>Document ID:</strong> {{.Data.DocID}}</p>
</div>
<p>To review and sign this document, please follow these steps:</p>
<ol>
<li>Review the document at: <a href="{{.Data.DocURL}}">{{.Data.DocURL}}</a></li>
<li>Sign the document at: <a href="{{.Data.SignURL}}">{{.Data.SignURL}}</a></li>
</ol>
<div style="margin: 30px 0;">
<a href="{{.Data.SignURL}}" style="background-color: #4F46E5; color: white; padding: 12px 24px; text-decoration: none; border-radius: 6px; display: inline-block;">Sign Document Now</a>
</div>
<p>Your cryptographic signature will provide verifiable proof that you have read and acknowledged this document.</p>
<p>If you have any questions, please contact your administrator.</p>
<p>Best regards,<br>
The {{.Organisation}} Team</p>
{{end}}

View File

@@ -0,0 +1,21 @@
{{define "content"}}
Document Signature Reminder
Hello,
This is a reminder that the following document requires your signature:
Document ID: {{.Data.DocID}}
To review and sign this document, please follow these steps:
1. Review the document at: {{.Data.DocURL}}
2. Sign the document at: {{.Data.SignURL}}
Your cryptographic signature will provide verifiable proof that you have read and acknowledged this document.
If you have any questions, please contact your administrator.
Best regards,
The {{.Organisation}} Team
{{end}}

View File

@@ -0,0 +1,29 @@
{{define "content"}}
<h2>Rappel de signature de document</h2>
<p>Bonjour,</p>
<p>Ceci est un rappel que le document suivant nécessite votre signature :</p>
<div style="background-color: #f3f4f6; padding: 15px; border-radius: 8px; margin: 20px 0;">
<p style="margin: 0;"><strong>ID du document :</strong> {{.Data.DocID}}</p>
</div>
<p>Pour consulter et signer ce document, veuillez suivre ces étapes :</p>
<ol>
<li>Consulter le document à : <a href="{{.Data.DocURL}}">{{.Data.DocURL}}</a></li>
<li>Signer le document à : <a href="{{.Data.SignURL}}">{{.Data.SignURL}}</a></li>
</ol>
<div style="margin: 30px 0;">
<a href="{{.Data.SignURL}}" style="background-color: #4F46E5; color: white; padding: 12px 24px; text-decoration: none; border-radius: 6px; display: inline-block;">Signer le document maintenant</a>
</div>
<p>Votre signature cryptographique fournira une preuve vérifiable que vous avez lu et pris connaissance de ce document.</p>
<p>Si vous avez des questions, veuillez contacter votre administrateur.</p>
<p>Cordialement,<br>
L'équipe {{.Organisation}}</p>
{{end}}

View File

@@ -0,0 +1,21 @@
{{define "content"}}
Rappel de signature de document
Bonjour,
Ceci est un rappel que le document suivant nécessite votre signature :
ID du document : {{.Data.DocID}}
Pour consulter et signer ce document, veuillez suivre ces étapes :
1. Consulter le document à : {{.Data.DocURL}}
2. Signer le document à : {{.Data.SignURL}}
Votre signature cryptographique fournira une preuve vérifiable que vous avez lu et pris connaissance de ce document.
Si vous avez des questions, veuillez contacter votre administrateur.
Cordialement,
L'équipe {{.Organisation}}
{{end}}