Email verification progress
This commit is contained in:
parent
ac845e8fe7
commit
26d1437aa5
1
.gitignore
vendored
1
.gitignore
vendored
|
@ -1,3 +1,4 @@
|
||||||
notes/
|
notes/
|
||||||
|
./run.sh
|
||||||
./dethnote
|
./dethnote
|
||||||
./dethnote.exe
|
./dethnote.exe
|
||||||
|
|
13
main.go
13
main.go
|
@ -30,10 +30,8 @@ var (
|
||||||
root string
|
root string
|
||||||
rootDefault = "./notes"
|
rootDefault = "./notes"
|
||||||
|
|
||||||
// -mailgun-url <url>: mailgun API URL for sending out emails.
|
// -smtp <url>: mail server settings, like "user:password@localhost:25"
|
||||||
// -mg <url> Takes the format "<api_key>@<api_base_url>"
|
smtpURL string
|
||||||
// Example: "key-a1b23c@https://api.mailgun.net/v3/mg.example.com"
|
|
||||||
mailgunURL string
|
|
||||||
|
|
||||||
// -listen <address>: listen on an HTTP port at this address.
|
// -listen <address>: listen on an HTTP port at this address.
|
||||||
// -l <address>
|
// -l <address>
|
||||||
|
@ -68,8 +66,7 @@ func init() {
|
||||||
flag.StringVar(&listen, "listen", listenDefault, "HTTP address to listen on")
|
flag.StringVar(&listen, "listen", listenDefault, "HTTP address to listen on")
|
||||||
flag.StringVar(&listen, "l", listenDefault, "HTTP address to listen on (shorthand)")
|
flag.StringVar(&listen, "l", listenDefault, "HTTP address to listen on (shorthand)")
|
||||||
|
|
||||||
flag.StringVar(&mailgunURL, "mailgun-url", "", "Mailgun API URL for sending out emails")
|
flag.StringVar(&smtpURL, "smtp", "localhost:22", "SMTP address for sending mail, in the format `[login:password@]server:port`")
|
||||||
flag.StringVar(&mailgunURL, "mg", "", "Mailgun API URL for sending out emails (shorthand)")
|
|
||||||
|
|
||||||
flag.BoolVar(&cmdOpen, "open", false, "Immediately open and print a secure note. The passphrase is provided via CLI arguments.")
|
flag.BoolVar(&cmdOpen, "open", false, "Immediately open and print a secure note. The passphrase is provided via CLI arguments.")
|
||||||
flag.BoolVar(&cmdDelete, "delete", false, "Immediately delete a secure note. The passphrase is provided via CLI arguments.")
|
flag.BoolVar(&cmdDelete, "delete", false, "Immediately delete a secure note. The passphrase is provided via CLI arguments.")
|
||||||
|
@ -110,7 +107,9 @@ func main() {
|
||||||
}
|
}
|
||||||
|
|
||||||
app := dethnote.NewServer(root, debug)
|
app := dethnote.NewServer(root, debug)
|
||||||
app.MailgunURL = mailgunURL
|
if err := app.SetSMTP(smtpURL); err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
app.SetupHTTP()
|
app.SetupHTTP()
|
||||||
app.Run(listen)
|
app.Run(listen)
|
||||||
}
|
}
|
||||||
|
|
|
@ -43,6 +43,9 @@ func (s *Server) CreateHandler(w http.ResponseWriter, r *http.Request) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Send them a verification email.
|
||||||
|
s.SendVerificationEmail(r, m)
|
||||||
|
|
||||||
// Store the hash path in the browser's cookies.
|
// Store the hash path in the browser's cookies.
|
||||||
s.SetCookie(w, "hash_path", vault.HashToFilename(m.PasswordHash))
|
s.SetCookie(w, "hash_path", vault.HashToFilename(m.PasswordHash))
|
||||||
|
|
||||||
|
|
125
src/mail.go
Normal file
125
src/mail.go
Normal file
|
@ -0,0 +1,125 @@
|
||||||
|
package dethnote
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"net/mail"
|
||||||
|
"net/smtp"
|
||||||
|
"net/textproto"
|
||||||
|
"regexp"
|
||||||
|
"strconv"
|
||||||
|
)
|
||||||
|
|
||||||
|
var smtpURLRegexp = regexp.MustCompile(
|
||||||
|
`^` + // bind to beginning
|
||||||
|
`(?P<proto>(?:ssl|smtps)://)?` + // optional ssl:// protocol prefix
|
||||||
|
`(?:(?P<user>[^:]+):` + // `username:password@` syntax either
|
||||||
|
`(?P<pass>[^@]+)@)?` + // must exist or be entirely absent.
|
||||||
|
`(?P<host>[^:]+):?` + // `host[:port]` standard case
|
||||||
|
`(?P<port>[0-9]+)?` +
|
||||||
|
`$`,
|
||||||
|
)
|
||||||
|
|
||||||
|
// MailConfig holds SMTP settings.
|
||||||
|
type MailConfig struct {
|
||||||
|
Username string
|
||||||
|
Password string
|
||||||
|
Hostname string
|
||||||
|
Port int
|
||||||
|
SSL bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m MailConfig) String() string {
|
||||||
|
username := ""
|
||||||
|
if m.Username != "" {
|
||||||
|
username = fmt.Sprintf("'%s'@", m.Username)
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("%s%s:%d", username, m.Hostname, m.Port)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ParseSMTPUrl validates and parses out the SMTP URL.
|
||||||
|
func ParseSMTPUrl(url string) (MailConfig, error) {
|
||||||
|
|
||||||
|
m := smtpURLRegexp.FindStringSubmatch(url)
|
||||||
|
if len(m) == 0 {
|
||||||
|
return MailConfig{}, errors.New("failed to match regexp")
|
||||||
|
}
|
||||||
|
|
||||||
|
ssl := m[1] != ""
|
||||||
|
port, _ := strconv.Atoi(m[5])
|
||||||
|
if port == 0 && ssl {
|
||||||
|
port = 465
|
||||||
|
} else if port == 0 {
|
||||||
|
port = 25
|
||||||
|
}
|
||||||
|
|
||||||
|
return MailConfig{
|
||||||
|
Username: m[2],
|
||||||
|
Password: m[3],
|
||||||
|
Hostname: m[4],
|
||||||
|
Port: port,
|
||||||
|
SSL: ssl,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mail is an email message to be delivered.
|
||||||
|
type Mail struct {
|
||||||
|
From string
|
||||||
|
To string
|
||||||
|
Subject string
|
||||||
|
HTML string
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetSMTP sets the SMTP config by URL.
|
||||||
|
func (s *Server) SetSMTP(url string) error {
|
||||||
|
config, err := ParseSMTPUrl(url)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
s.smtp = config
|
||||||
|
Log.Info("Email server configuration: %s", config)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SendMail sends an email out.
|
||||||
|
func (s *Server) SendMail(m Mail) error {
|
||||||
|
// Server config.
|
||||||
|
cfg := s.smtp
|
||||||
|
|
||||||
|
// validate from address
|
||||||
|
from, err := mail.ParseAddress(m.From)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// validate to address
|
||||||
|
to, err := mail.ParseAddress(m.To)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// set headers for html email
|
||||||
|
header := textproto.MIMEHeader{}
|
||||||
|
header.Set(textproto.CanonicalMIMEHeaderKey("from"), from.Address)
|
||||||
|
header.Set(textproto.CanonicalMIMEHeaderKey("to"), to.Address)
|
||||||
|
header.Set(textproto.CanonicalMIMEHeaderKey("content-type"), "text/html; charset=UTF-8")
|
||||||
|
header.Set(textproto.CanonicalMIMEHeaderKey("mime-version"), "1.0")
|
||||||
|
header.Set(textproto.CanonicalMIMEHeaderKey("subject"), m.Subject)
|
||||||
|
|
||||||
|
// init empty message
|
||||||
|
var buffer bytes.Buffer
|
||||||
|
|
||||||
|
// write header
|
||||||
|
for key, value := range header {
|
||||||
|
buffer.WriteString(fmt.Sprintf("%s: %s\r\n", key, value[0]))
|
||||||
|
}
|
||||||
|
|
||||||
|
// write body
|
||||||
|
buffer.WriteString(fmt.Sprintf("\r\n%s", m.HTML))
|
||||||
|
|
||||||
|
// send email
|
||||||
|
addr := fmt.Sprintf("%s:%d", cfg.Hostname, cfg.Port)
|
||||||
|
auth := smtp.PlainAuth("", cfg.Username, cfg.Password, cfg.Hostname)
|
||||||
|
return smtp.SendMail(addr, auth, from.Address, []string{to.Address}, buffer.Bytes())
|
||||||
|
}
|
72
src/mail_test.go
Normal file
72
src/mail_test.go
Normal file
|
@ -0,0 +1,72 @@
|
||||||
|
package dethnote_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
dethnote "git.kirsle.net/apps/dethnote/src"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestParseSMTPUrl(t *testing.T) {
|
||||||
|
type testPair struct {
|
||||||
|
url string
|
||||||
|
expect dethnote.MailConfig
|
||||||
|
}
|
||||||
|
tests := []testPair{
|
||||||
|
testPair{
|
||||||
|
url: "localhost:25",
|
||||||
|
expect: dethnote.MailConfig{
|
||||||
|
Hostname: "localhost",
|
||||||
|
Port: 25,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
testPair{
|
||||||
|
url: "ssl://mail.example.com:465",
|
||||||
|
expect: dethnote.MailConfig{
|
||||||
|
Hostname: "mail.example.com",
|
||||||
|
Port: 465,
|
||||||
|
SSL: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
testPair{
|
||||||
|
url: "user:pass@localhost:25",
|
||||||
|
expect: dethnote.MailConfig{
|
||||||
|
Username: "user",
|
||||||
|
Password: "pass",
|
||||||
|
Hostname: "localhost",
|
||||||
|
Port: 25,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
testPair{
|
||||||
|
url: "smtps://user:pass@mail.example.com",
|
||||||
|
expect: dethnote.MailConfig{
|
||||||
|
Hostname: "mail.example.com",
|
||||||
|
Port: 465,
|
||||||
|
Username: "user",
|
||||||
|
Password: "pass",
|
||||||
|
SSL: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
testPair{
|
||||||
|
url: "localhost",
|
||||||
|
expect: dethnote.MailConfig{
|
||||||
|
Hostname: "localhost",
|
||||||
|
Port: 25,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, test := range tests {
|
||||||
|
actual, _ := dethnote.ParseSMTPUrl(test.url)
|
||||||
|
if actual != test.expect {
|
||||||
|
t.Errorf(
|
||||||
|
"Unexpected parsing of SMTP URL\n"+
|
||||||
|
"URL: %s\n"+
|
||||||
|
"Expect: %+v\n"+
|
||||||
|
"Actual: %+v",
|
||||||
|
test.url,
|
||||||
|
test.expect,
|
||||||
|
actual,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -9,8 +9,8 @@ import (
|
||||||
|
|
||||||
// Server is the master struct for the web app.
|
// Server is the master struct for the web app.
|
||||||
type Server struct {
|
type Server struct {
|
||||||
// Public configurable fields.
|
// smtp server config
|
||||||
MailgunURL string // format is like "key-1a2b3c@https://api.mailgun.net/v3/mg.example.com"
|
smtp MailConfig
|
||||||
|
|
||||||
root string
|
root string
|
||||||
debug bool
|
debug bool
|
||||||
|
@ -45,6 +45,17 @@ func (s *Server) SetupHTTP() {
|
||||||
"HashPath": s.GetCookie(r, "hash_path"),
|
"HashPath": s.GetCookie(r, "hash_path"),
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
r.HandleFunc("/verify/{token}/{path:[A-Fa-f0-9/]+}", s.VerifyHandler)
|
||||||
|
r.HandleFunc("/tmp", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
err := s.Template(w, "mail/verify-email.gohtml", map[string]interface{}{
|
||||||
|
"Subject": "Verify Your Email",
|
||||||
|
"URLBase": URLBase(r),
|
||||||
|
"HashPath": s.GetCookie(r, "hash_path"),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
WriteError(w, err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
n := negroni.New(
|
n := negroni.New(
|
||||||
negroni.NewRecovery(),
|
negroni.NewRecovery(),
|
||||||
|
|
|
@ -3,14 +3,23 @@ package dethnote
|
||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
"errors"
|
"errors"
|
||||||
|
"fmt"
|
||||||
"html/template"
|
"html/template"
|
||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
|
"path/filepath"
|
||||||
)
|
)
|
||||||
|
|
||||||
var functions template.FuncMap
|
var functions template.FuncMap
|
||||||
|
|
||||||
|
// URLBase returns the base URL of the current HTTP request.
|
||||||
|
func URLBase(r *http.Request) string {
|
||||||
|
return fmt.Sprintf("https://%s",
|
||||||
|
r.Host,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
// IncludeFunc implements the "Include" command for the templates.
|
// IncludeFunc implements the "Include" command for the templates.
|
||||||
func (s *Server) IncludeFunc(name string, v ...interface{}) template.HTML {
|
func (s *Server) IncludeFunc(name string, v ...interface{}) template.HTML {
|
||||||
buf := bytes.NewBuffer([]byte{})
|
buf := bytes.NewBuffer([]byte{})
|
||||||
|
@ -36,13 +45,16 @@ func (s *Server) Template(w io.Writer, name string, v interface{}) error {
|
||||||
"Include": s.IncludeFunc,
|
"Include": s.IncludeFunc,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
basename := filepath.Base(name)
|
||||||
|
|
||||||
// Find the template file locally.
|
// Find the template file locally.
|
||||||
filename := "./templates/" + name
|
filename := "./templates/" + name
|
||||||
if _, err := os.Stat(filename); os.IsNotExist(err) {
|
if _, err := os.Stat(filename); os.IsNotExist(err) {
|
||||||
return errors.New("template not found: " + name)
|
return errors.New("template not found: " + name)
|
||||||
}
|
}
|
||||||
|
|
||||||
t := template.New(name).Funcs(functions)
|
t := template.New(basename).Funcs(functions)
|
||||||
t, err := t.ParseFiles(filename)
|
t, err := t.ParseFiles(filename)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
|
@ -51,7 +63,7 @@ func (s *Server) Template(w io.Writer, name string, v interface{}) error {
|
||||||
// Write the template into a buffer in case of errors, so we can still
|
// Write the template into a buffer in case of errors, so we can still
|
||||||
// control the outgoing HTTP status code.
|
// control the outgoing HTTP status code.
|
||||||
buf := bytes.NewBuffer([]byte{})
|
buf := bytes.NewBuffer([]byte{})
|
||||||
err = t.ExecuteTemplate(buf, name, v)
|
err = t.ExecuteTemplate(buf, basename, v)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,6 +1,7 @@
|
||||||
package vault
|
package vault
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"encoding/hex"
|
||||||
"errors"
|
"errors"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
@ -12,6 +13,7 @@ import (
|
||||||
type Message struct {
|
type Message struct {
|
||||||
Email string `json:"email"` // owner email address
|
Email string `json:"email"` // owner email address
|
||||||
Verified bool `json:"verified"` // verified owner email?
|
Verified bool `json:"verified"` // verified owner email?
|
||||||
|
VerifyToken string `json:"token"` // verification token for confirmation link
|
||||||
Timeout int `json:"timeout"` // hours for the unlock window
|
Timeout int `json:"timeout"` // hours for the unlock window
|
||||||
|
|
||||||
PasswordHash []byte `json:"hash"` // to verify the password is correct
|
PasswordHash []byte `json:"hash"` // to verify the password is correct
|
||||||
|
@ -34,8 +36,14 @@ func NewMessage(email string, timeout int, message string, passwordLength int) (
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
verify, err := GenerateHash(password + email)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
return &Message{
|
return &Message{
|
||||||
Email: email,
|
Email: email,
|
||||||
|
VerifyToken: hex.EncodeToString(verify),
|
||||||
Timeout: timeout,
|
Timeout: timeout,
|
||||||
Message: message,
|
Message: message,
|
||||||
Password: password,
|
Password: password,
|
||||||
|
|
48
src/verify_email.go
Normal file
48
src/verify_email.go
Normal file
|
@ -0,0 +1,48 @@
|
||||||
|
package dethnote
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"git.kirsle.net/apps/dethnote/src/vault"
|
||||||
|
"github.com/gorilla/mux"
|
||||||
|
)
|
||||||
|
|
||||||
|
// VerifyHandler is the callback when a user verifies their email.
|
||||||
|
func (s *Server) VerifyHandler(w http.ResponseWriter, r *http.Request) {
|
||||||
|
vars := mux.Vars(r)
|
||||||
|
token := vars["token"]
|
||||||
|
path := vars["path"]
|
||||||
|
_, _ = token, path
|
||||||
|
}
|
||||||
|
|
||||||
|
// SendVerificationEmail sends a verification email.
|
||||||
|
func (s *Server) SendVerificationEmail(r *http.Request, m *vault.Message) error {
|
||||||
|
base := URLBase(r)
|
||||||
|
hashPath := vault.HashToFilename(m.PasswordHash)
|
||||||
|
|
||||||
|
v := map[string]interface{}{
|
||||||
|
"Subject": "Verify Your Email",
|
||||||
|
"VerifyLink": fmt.Sprintf("%s/verify/%s/%s",
|
||||||
|
base,
|
||||||
|
m.VerifyToken,
|
||||||
|
hashPath,
|
||||||
|
),
|
||||||
|
"DeleteLink": fmt.Sprintf("%s/delete/%s",
|
||||||
|
base,
|
||||||
|
hashPath,
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
html := bytes.NewBuffer([]byte{})
|
||||||
|
s.Template(html, "mail/verify-email.gohtml", v)
|
||||||
|
|
||||||
|
err := s.SendMail(Mail{
|
||||||
|
From: "noreply@kirsle.net", // TODO
|
||||||
|
To: m.Email,
|
||||||
|
Subject: v["Subject"].(string),
|
||||||
|
HTML: html.String(),
|
||||||
|
})
|
||||||
|
return err
|
||||||
|
}
|
16
templates/mail/footer.gohtml
Normal file
16
templates/mail/footer.gohtml
Normal file
|
@ -0,0 +1,16 @@
|
||||||
|
</font>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td align="left" valign="top" bgcolor="#C0C0C0">
|
||||||
|
<font face="Helvetica,Arial,Verdana-sans-serif" size="3" color="#000000">
|
||||||
|
This e-mail was automatically generated; do not reply to it.
|
||||||
|
</font>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</center>
|
||||||
|
|
||||||
|
|
||||||
|
</body>
|
||||||
|
</html>
|
23
templates/mail/header.gohtml
Normal file
23
templates/mail/header.gohtml
Normal file
|
@ -0,0 +1,23 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width">
|
||||||
|
<meta http-equiv="X-UA-Compatible" content="IE=edge">
|
||||||
|
<meta name="x-apple-disable-message-reformatting"><!-- Disable auto-scale in iOS 10 Mail -->
|
||||||
|
<title>{{ .Subject }}</title>
|
||||||
|
</head>
|
||||||
|
<body width="100%" bgcolor="#FFFFFF" color="#000000" style="margin: 20px 0; mso-line-height-rule: exactly;">
|
||||||
|
|
||||||
|
<center>
|
||||||
|
<table width="90%" cellspacing="0" cellpadding="8" style="border: 1px solid #000000">
|
||||||
|
<tr>
|
||||||
|
<td align="left" valign="top" bgcolor="#C0C0C0">
|
||||||
|
<font face="Helvetica,Arial,Verdana-sans-serif" size="6" color="#000000">
|
||||||
|
<b>Dethnote: {{ .Subject }}</b>
|
||||||
|
</font>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td align="left" valign="top" bgcolor="#FEFEFE">
|
||||||
|
<font face="Helvetica,Arial,Verdana-sans-serif" size="3" color="#000000">
|
33
templates/mail/verify-email.gohtml
Normal file
33
templates/mail/verify-email.gohtml
Normal file
|
@ -0,0 +1,33 @@
|
||||||
|
{{ Include "mail/header.gohtml" . }}
|
||||||
|
|
||||||
|
<p>
|
||||||
|
Your secure note has been encrypted and stored on the server. Next, you
|
||||||
|
must verify your e-mail address.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p>
|
||||||
|
Please click on the link below to verify your email:
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p>
|
||||||
|
<a href="{{ .VerifyLink }}">{{ .VerifyLink }}</a>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h3>Deletion Link</h3>
|
||||||
|
|
||||||
|
<p>
|
||||||
|
Changed your mind? The link below can be used to completely delete the
|
||||||
|
secure note, without needing to enter a password for it. Keep this link
|
||||||
|
safe in case you need it.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p>
|
||||||
|
If the deletion link is ever used, you will be sent one last confirmation
|
||||||
|
e-mail telling you that the note has been deleted.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p>
|
||||||
|
<a href="{{ .DeleteLink }}">{{ .DeleteLink }}</a>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{{ Include "mail/footer.gohtml" }}
|
Loading…
Reference in New Issue
Block a user