diff --git a/.gitignore b/.gitignore index 925435a..cd9697f 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ notes/ +./run.sh ./dethnote ./dethnote.exe diff --git a/main.go b/main.go index 19f6df5..305274e 100644 --- a/main.go +++ b/main.go @@ -30,10 +30,8 @@ var ( root string rootDefault = "./notes" - // -mailgun-url : mailgun API URL for sending out emails. - // -mg Takes the format "@" - // Example: "key-a1b23c@https://api.mailgun.net/v3/mg.example.com" - mailgunURL string + // -smtp : mail server settings, like "user:password@localhost:25" + smtpURL string // -listen
: listen on an HTTP port at this address. // -l
@@ -68,8 +66,7 @@ func init() { flag.StringVar(&listen, "listen", listenDefault, "HTTP address to listen on") 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(&mailgunURL, "mg", "", "Mailgun API URL for sending out emails (shorthand)") + flag.StringVar(&smtpURL, "smtp", "localhost:22", "SMTP address for sending mail, in the format `[login:password@]server:port`") 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.") @@ -110,7 +107,9 @@ func main() { } app := dethnote.NewServer(root, debug) - app.MailgunURL = mailgunURL + if err := app.SetSMTP(smtpURL); err != nil { + panic(err) + } app.SetupHTTP() app.Run(listen) } diff --git a/src/create_handler.go b/src/create_handler.go index 90f68ab..493819c 100644 --- a/src/create_handler.go +++ b/src/create_handler.go @@ -43,6 +43,9 @@ func (s *Server) CreateHandler(w http.ResponseWriter, r *http.Request) { return } + // Send them a verification email. + s.SendVerificationEmail(r, m) + // Store the hash path in the browser's cookies. s.SetCookie(w, "hash_path", vault.HashToFilename(m.PasswordHash)) diff --git a/src/mail.go b/src/mail.go new file mode 100644 index 0000000..b25a275 --- /dev/null +++ b/src/mail.go @@ -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(?:ssl|smtps)://)?` + // optional ssl:// protocol prefix + `(?:(?P[^:]+):` + // `username:password@` syntax either + `(?P[^@]+)@)?` + // must exist or be entirely absent. + `(?P[^:]+):?` + // `host[:port]` standard case + `(?P[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()) +} diff --git a/src/mail_test.go b/src/mail_test.go new file mode 100644 index 0000000..36ad2ba --- /dev/null +++ b/src/mail_test.go @@ -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, + ) + } + } +} diff --git a/src/server.go b/src/server.go index c3b44ea..745186a 100644 --- a/src/server.go +++ b/src/server.go @@ -9,8 +9,8 @@ import ( // Server is the master struct for the web app. type Server struct { - // Public configurable fields. - MailgunURL string // format is like "key-1a2b3c@https://api.mailgun.net/v3/mg.example.com" + // smtp server config + smtp MailConfig root string debug bool @@ -45,6 +45,17 @@ func (s *Server) SetupHTTP() { "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( negroni.NewRecovery(), diff --git a/src/templates.go b/src/templates.go index 8f0b258..9ed3d07 100644 --- a/src/templates.go +++ b/src/templates.go @@ -3,14 +3,23 @@ package dethnote import ( "bytes" "errors" + "fmt" "html/template" "io" "net/http" "os" + "path/filepath" ) 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. func (s *Server) IncludeFunc(name string, v ...interface{}) template.HTML { buf := bytes.NewBuffer([]byte{}) @@ -36,13 +45,16 @@ func (s *Server) Template(w io.Writer, name string, v interface{}) error { "Include": s.IncludeFunc, } } + + basename := filepath.Base(name) + // Find the template file locally. filename := "./templates/" + name if _, err := os.Stat(filename); os.IsNotExist(err) { return errors.New("template not found: " + name) } - t := template.New(name).Funcs(functions) + t := template.New(basename).Funcs(functions) t, err := t.ParseFiles(filename) if err != nil { 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 // control the outgoing HTTP status code. buf := bytes.NewBuffer([]byte{}) - err = t.ExecuteTemplate(buf, name, v) + err = t.ExecuteTemplate(buf, basename, v) if err != nil { return err } diff --git a/src/vault/models.go b/src/vault/models.go index 8405ce4..5be133a 100644 --- a/src/vault/models.go +++ b/src/vault/models.go @@ -1,6 +1,7 @@ package vault import ( + "encoding/hex" "errors" "os" "path/filepath" @@ -10,9 +11,10 @@ import ( // Message is an encrypted file that contains the settings for a secure note, // but does not contain the note itself. type Message struct { - Email string `json:"email"` // owner email address - Verified bool `json:"verified"` // verified owner email? - Timeout int `json:"timeout"` // hours for the unlock window + Email string `json:"email"` // owner email address + 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 PasswordHash []byte `json:"hash"` // to verify the password is correct Created time.Time `json:"created"` @@ -34,8 +36,14 @@ func NewMessage(email string, timeout int, message string, passwordLength int) ( return nil, err } + verify, err := GenerateHash(password + email) + if err != nil { + return nil, err + } + return &Message{ Email: email, + VerifyToken: hex.EncodeToString(verify), Timeout: timeout, Message: message, Password: password, diff --git a/src/verify_email.go b/src/verify_email.go new file mode 100644 index 0000000..8536c93 --- /dev/null +++ b/src/verify_email.go @@ -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 +} diff --git a/templates/mail/footer.gohtml b/templates/mail/footer.gohtml new file mode 100644 index 0000000..8a45564 --- /dev/null +++ b/templates/mail/footer.gohtml @@ -0,0 +1,16 @@ + + + + + + + This e-mail was automatically generated; do not reply to it. + + + + + + + + + diff --git a/templates/mail/header.gohtml b/templates/mail/header.gohtml new file mode 100644 index 0000000..8dccafe --- /dev/null +++ b/templates/mail/header.gohtml @@ -0,0 +1,23 @@ + + + + + + + + {{ .Subject }} + + + +
+ + + + + +
+ + Dethnote: {{ .Subject }} + +
+ diff --git a/templates/mail/verify-email.gohtml b/templates/mail/verify-email.gohtml new file mode 100644 index 0000000..e76caad --- /dev/null +++ b/templates/mail/verify-email.gohtml @@ -0,0 +1,33 @@ +{{ Include "mail/header.gohtml" . }} + +

+ Your secure note has been encrypted and stored on the server. Next, you + must verify your e-mail address. +

+ +

+ Please click on the link below to verify your email: +

+ +

+ {{ .VerifyLink }} +

+ +

Deletion Link

+ +

+ 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. +

+ +

+ If the deletion link is ever used, you will be sent one last confirmation + e-mail telling you that the note has been deleted. +

+ +

+ {{ .DeleteLink }} +

+ +{{ Include "mail/footer.gohtml" }}