jlelse
/
MailyGo
Archived
1
Fork 0

Initial version

This commit is contained in:
Jan-Lukas Else 2020-03-14 22:26:34 +01:00
commit 197817cb03
11 changed files with 684 additions and 0 deletions

1
.gitignore vendored Normal file
View File

@ -0,0 +1 @@
/.idea

48
config.go Normal file
View File

@ -0,0 +1,48 @@
package main
import (
"errors"
"github.com/caarlos0/env/v6"
)
type config struct {
Port int `env:"PORT" envDefault:"8080"`
HoneyPots []string `env:"HONEYPOTS" envDefault:"_t_email" envSeparator:","`
DefaultRecipient string `env:"DEFAULT_TO"`
AllowedRecipients []string `env:"ALLOWED_TO" envSeparator:","`
Sender string `env:"EMAIL_FROM"`
SmtpUser string `env:"SMTP_USER"`
SmtpPassword string `env:"SMTP_PASS"`
SmtpHost string `env:"SMTP_HOST"`
SmtpPort int `env:"SMTP_PORT" envDefault:"587"`
}
func parseConfig() (config, error) {
cfg := config{}
if err := env.Parse(&cfg); err != nil {
return cfg, errors.New("failed to parse config")
}
return cfg, nil
}
func checkRequiredConfig(cfg config) bool {
if cfg.DefaultRecipient == "" {
return false
}
if len(cfg.AllowedRecipients) < 1 {
return false
}
if cfg.Sender == "" {
return false
}
if cfg.SmtpUser == "" {
return false
}
if cfg.SmtpPassword == "" {
return false
}
if cfg.SmtpHost == "" {
return false
}
return true
}

140
config_test.go Normal file
View File

@ -0,0 +1,140 @@
package main
import (
"os"
"reflect"
"testing"
)
func Test_parseConfig(t *testing.T) {
t.Run("Default config", func(t *testing.T) {
os.Clearenv()
cfg, err := parseConfig()
if err != nil {
t.Error()
return
}
if cfg.Port != 8080 {
t.Error("Default Port not 8080")
}
if len(cfg.HoneyPots) != 1 || cfg.HoneyPots[0] != "_t_email" {
t.Error("Default HoneyPots are wrong")
}
if cfg.SmtpPort != 587 {
t.Error("SMTP Port not 587")
}
})
t.Run("Correct config parsing", func(t *testing.T) {
os.Clearenv()
_ = os.Setenv("PORT", "1111")
_ = os.Setenv("HONEYPOTS", "pot,abc")
_ = os.Setenv("DEFAULT_TO", "mail@example.com")
_ = os.Setenv("ALLOWED_TO", "mail@example.com,test@example.com")
_ = os.Setenv("EMAIL_FROM", "forms@example.com")
_ = os.Setenv("SMTP_USER", "test@example.com")
_ = os.Setenv("SMTP_PASS", "secret")
_ = os.Setenv("SMTP_HOST", "smtp.example.com")
_ = os.Setenv("SMTP_PORT", "100")
cfg, err := parseConfig()
if err != nil {
t.Error()
return
}
if !reflect.DeepEqual(cfg.Port, 1111) {
t.Error("Port is wrong")
}
if !reflect.DeepEqual(cfg.HoneyPots, []string{"pot", "abc"}) {
t.Error("HoneyPots are wrong")
}
if !reflect.DeepEqual(cfg.DefaultRecipient, "mail@example.com") {
t.Error("DefaultRecipient is wrong")
}
if !reflect.DeepEqual(cfg.AllowedRecipients, []string{"mail@example.com", "test@example.com"}) {
t.Error("AllowedRecipients are wrong")
}
if !reflect.DeepEqual(cfg.Sender, "forms@example.com") {
t.Error("Sender is wrong")
}
if !reflect.DeepEqual(cfg.SmtpUser, "test@example.com") {
t.Error("SMTP user is wrong")
}
if !reflect.DeepEqual(cfg.SmtpPassword, "secret") {
t.Error("SMTP password is wrong")
}
if !reflect.DeepEqual(cfg.SmtpHost, "smtp.example.com") {
t.Error("SMTP host is wrong")
}
if !reflect.DeepEqual(cfg.SmtpPort, 100) {
t.Error("SMTP port is wrong")
}
})
t.Run("Error when wrong config", func(t *testing.T) {
os.Clearenv()
_ = os.Setenv("PORT", "ABC")
_, err := parseConfig()
if err == nil {
t.Error()
}
})
}
func Test_checkRequiredConfig(t *testing.T) {
validConfig := config{
Port: 8080,
HoneyPots: []string{"_t_email"},
DefaultRecipient: "mail@example.com",
AllowedRecipients: []string{"mail@example.com"},
Sender: "forms@example.com",
SmtpUser: "test@example.com",
SmtpPassword: "secret",
SmtpHost: "smtp.example.com",
SmtpPort: 587,
}
t.Run("Valid config", func(t *testing.T) {
if true != checkRequiredConfig(validConfig) {
t.Error()
}
})
t.Run("Default recipient missing", func(t *testing.T) {
newConfig := validConfig
newConfig.DefaultRecipient = ""
if false != checkRequiredConfig(newConfig) {
t.Error()
}
})
t.Run("Allowed recipients missing", func(t *testing.T) {
newConfig := validConfig
newConfig.AllowedRecipients = nil
if false != checkRequiredConfig(newConfig) {
t.Error()
}
})
t.Run("Sender missing", func(t *testing.T) {
newConfig := validConfig
newConfig.Sender = ""
if false != checkRequiredConfig(newConfig) {
t.Error()
}
})
t.Run("SMTP user missing", func(t *testing.T) {
newConfig := validConfig
newConfig.SmtpUser = ""
if false != checkRequiredConfig(newConfig) {
t.Error()
}
})
t.Run("SMTP password missing", func(t *testing.T) {
newConfig := validConfig
newConfig.SmtpPassword = ""
if false != checkRequiredConfig(newConfig) {
t.Error()
}
})
t.Run("SMTP host missing", func(t *testing.T) {
newConfig := validConfig
newConfig.SmtpHost = ""
if false != checkRequiredConfig(newConfig) {
t.Error()
}
})
}

29
form.html Normal file
View File

@ -0,0 +1,29 @@
<!DOCTYPE html>
<html lang="en">
<head>
<title>Test-Form</title>
</head>
<body>
<form action="//localhost:8080" method="post">
<input type="hidden" name="_to" value="test@example.com">
<input type="hidden" name="_formName" value="Test Form">
<input type="hidden" name="_redirectTo" value="https://example.com">
<label for="TEmail" style="display: none;">Test</label>
<input id="TEmail" type="email" name="_t_email" style="display: none;">
<label for="Email">Email</label>
<br />
<input id="Email" type="email" name="_replyTo" required>
<br />
<label for="Name">Name</label>
<br />
<input id="Name" type="text" name="Name" required>
<br />
<label for="Message">Message</label>
<br />
<textarea id="Message" name="Message"></textarea>
<br />
<input type="submit" value="Send">
</form>
</body>
</html>

67
forms.go Normal file
View File

@ -0,0 +1,67 @@
package main
import (
"github.com/microcosm-cc/bluemonday"
"net/http"
"net/url"
)
type FormValues map[string][]string
func FormHandler(w http.ResponseWriter, r *http.Request) {
if r.Method == http.MethodGet {
_, _ = w.Write([]byte("MailyGo works!"))
return
}
if r.Method != http.MethodPost {
w.WriteHeader(http.StatusMethodNotAllowed)
_, _ = w.Write([]byte("The HTTP method is not allowed, make a POST request"))
return
}
_ = r.ParseForm()
sanitizedForm := sanitizeForm(r.PostForm)
if !isBot(sanitizedForm) {
sendForm(sanitizedForm)
}
sendResponse(sanitizedForm, w)
return
}
func sanitizeForm(values url.Values) FormValues {
p := bluemonday.StrictPolicy()
sanitizedForm := make(FormValues)
for key, values := range values {
var sanitizedValues []string
for _, value := range values {
sanitizedValues = append(sanitizedValues, p.Sanitize(value))
}
sanitizedForm[p.Sanitize(key)] = sanitizedValues
}
return sanitizedForm
}
func isBot(values FormValues) bool {
for _, honeyPot := range appConfig.HoneyPots {
if len(values[honeyPot]) > 0 {
for _, value := range values[honeyPot] {
if value != "" {
return true
}
}
}
}
return false
}
func sendResponse(values FormValues, w http.ResponseWriter) {
if len(values["_redirectTo"]) == 1 && values["_redirectTo"][0] != "" {
w.Header().Add("Location", values["_redirectTo"][0])
w.WriteHeader(http.StatusSeeOther)
_, _ = w.Write([]byte("Go to " + values["_redirectTo"][0]))
return
} else {
w.WriteHeader(http.StatusCreated)
_, _ = w.Write([]byte("Submitted form"))
return
}
}

118
forms_test.go Normal file
View File

@ -0,0 +1,118 @@
package main
import (
"net/http"
"net/http/httptest"
"net/url"
"os"
"reflect"
"testing"
)
func Test_sanitizeForm(t *testing.T) {
t.Run("Sanitize form", func(t *testing.T) {
result := sanitizeForm(url.Values{"<b>Test</b>": {"<a href=\"https://example.com\">Test</a>"}})
want := FormValues{"Test": {"Test"}}
if !reflect.DeepEqual(result, want) {
t.Error()
}
})
}
func TestFormHandler(t *testing.T) {
t.Run("GET request to FormHandler", func(t *testing.T) {
req := httptest.NewRequest("GET", "http://example.com/", nil)
w := httptest.NewRecorder()
FormHandler(w, req)
resp := w.Result()
if resp.StatusCode != http.StatusOK {
t.Error()
}
})
t.Run("POST request to FormHandler", func(t *testing.T) {
req := httptest.NewRequest("POST", "http://example.com/", nil)
w := httptest.NewRecorder()
FormHandler(w, req)
resp := w.Result()
if resp.StatusCode != http.StatusCreated {
t.Error()
}
})
t.Run("Wrong method request to FormHandler", func(t *testing.T) {
req := httptest.NewRequest("DELETE", "http://example.com/", nil)
w := httptest.NewRecorder()
FormHandler(w, req)
resp := w.Result()
if resp.StatusCode != http.StatusMethodNotAllowed {
t.Error()
}
})
}
func Test_isBot(t *testing.T) {
t.Run("No bot", func(t *testing.T) {
os.Clearenv()
result := isBot(FormValues{"_t_email": {""}})
if !reflect.DeepEqual(result, false) {
t.Error()
}
})
t.Run("No honeypot", func(t *testing.T) {
os.Clearenv()
result := isBot(FormValues{})
if !reflect.DeepEqual(result, false) {
t.Error()
}
})
t.Run("Bot", func(t *testing.T) {
os.Clearenv()
result := isBot(FormValues{"_t_email": {"Test", ""}})
if !reflect.DeepEqual(result, true) {
t.Error()
}
})
}
func Test_sendResponse(t *testing.T) {
t.Run("No redirect", func(t *testing.T) {
values := FormValues{}
w := httptest.NewRecorder()
sendResponse(values, w)
if w.Code != http.StatusCreated {
t.Error()
}
})
t.Run("No redirect 2", func(t *testing.T) {
values := FormValues{
"_redirectTo": {""},
}
w := httptest.NewRecorder()
sendResponse(values, w)
if w.Code != http.StatusCreated {
t.Error()
}
})
t.Run("No redirect 3", func(t *testing.T) {
values := FormValues{
"_redirectTo": {"abc", "def"},
}
w := httptest.NewRecorder()
sendResponse(values, w)
if w.Code != http.StatusCreated {
t.Error()
}
})
t.Run("Redirect", func(t *testing.T) {
values := FormValues{
"_redirectTo": {"https://example.com"},
}
w := httptest.NewRecorder()
sendResponse(values, w)
if w.Code != http.StatusSeeOther {
t.Error()
}
if w.Header().Get("Location") != "https://example.com" {
t.Error()
}
})
}

9
go.mod Normal file
View File

@ -0,0 +1,9 @@
module codeberg.org/jlelse/mailygo
go 1.14
require (
github.com/caarlos0/env/v6 v6.2.1
github.com/microcosm-cc/bluemonday v1.0.3-0.20191119130333-0a75d7616912
golang.org/x/net v0.0.0-20200301022130-244492dfa37a // indirect
)

28
go.sum Normal file
View File

@ -0,0 +1,28 @@
github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk=
github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4=
github.com/caarlos0/env/v6 v6.2.1 h1:/bFpX1dg4TNioJjg7mrQaSrBoQvRfLUHNfXivdFbbEo=
github.com/caarlos0/env/v6 v6.2.1/go.mod h1:3LpmfcAYCG6gCiSgDLaFR5Km1FRpPwFvBbRcjHar6Sw=
github.com/chris-ramon/douceur v0.2.0 h1:IDMEdxlEUUBYBKE4z/mJnFyVXox+MjuEVDJNN27glkU=
github.com/chris-ramon/douceur v0.2.0/go.mod h1:wDW5xjJdeoMm1mRt4sD4c/LbF/mWdEpRXQKjTR8nIBE=
github.com/davecgh/go-spew v1.1.0 h1:ZDRjVQ15GmhC3fiQ8ni8+OwkZQO4DARzQgrnXU1Liz8=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/gorilla/css v1.0.0 h1:BQqNyPTi50JCFMTw/b67hByjMVXZRwGha6wxVGkeihY=
github.com/gorilla/css v1.0.0/go.mod h1:Dn721qIggHpt4+EFCcTLTU/vk5ySda2ReITrtgBl60c=
github.com/microcosm-cc/bluemonday v1.0.3-0.20191119130333-0a75d7616912 h1:hJde9rA24hlTcAYSwJoXpDUyGtfKQ/jsofw+WaDqGrI=
github.com/microcosm-cc/bluemonday v1.0.3-0.20191119130333-0a75d7616912/go.mod h1:8iwZnFn2CDDNZ0r6UXhF4xawGvzaqzCRa1n3/lO3W2w=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.5.1 h1:nOGnQDM7FYENwehXlg/kFVnos3rEvtKTjRvOWSzb6H4=
github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/net v0.0.0-20181220203305-927f97764cc3 h1:eH6Eip3UpmR+yM/qI9Ijluzb1bNv/cAU/n+6l8tRSis=
golang.org/x/net v0.0.0-20181220203305-927f97764cc3/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20200301022130-244492dfa37a h1:GuSPYbZzB5/dcLNCwLQLsg3obCJtX9IJhpXkvY7kzk0=
golang.org/x/net v0.0.0-20200301022130-244492dfa37a/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v2 v2.2.2 h1:ZCJp+EgiOT7lHqUV2J862kp8Qj64Jo6az82+3Td9dZw=
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=

82
mail.go Normal file
View File

@ -0,0 +1,82 @@
package main
import (
"bytes"
"fmt"
"net/smtp"
"strconv"
"strings"
"time"
)
func sendForm(values FormValues) {
recipient := findRecipient(values)
sendMail(recipient, buildMessage(recipient, time.Now(), values))
}
func buildMessage(recipient string, date time.Time, values FormValues) string {
var msgBuffer bytes.Buffer
_, _ = fmt.Fprintf(&msgBuffer, "From: Forms <%s>", appConfig.Sender)
_, _ = fmt.Fprintln(&msgBuffer)
_, _ = fmt.Fprintf(&msgBuffer, "To: %s", recipient)
_, _ = fmt.Fprintln(&msgBuffer)
if replyTo := findReplyTo(values); replyTo != "" {
_, _ = fmt.Fprintf(&msgBuffer, "Reply-To: %s", replyTo)
_, _ = fmt.Fprintln(&msgBuffer)
}
_, _ = fmt.Fprintf(&msgBuffer, "Date: %s", date.Format(time.RFC1123Z))
_, _ = fmt.Fprintln(&msgBuffer)
_, _ = fmt.Fprintf(&msgBuffer, "Subject: New submission on %s", findFormName(values))
_, _ = fmt.Fprintln(&msgBuffer)
_, _ = fmt.Fprintln(&msgBuffer)
for key, value := range removeMetaValues(values) {
_, _ = fmt.Fprint(&msgBuffer, key)
_, _ = fmt.Fprint(&msgBuffer, ": ")
_, _ = fmt.Fprintln(&msgBuffer, strings.Join(value, ", "))
}
return msgBuffer.String()
}
func sendMail(to, message string) {
auth := smtp.PlainAuth("", appConfig.SmtpUser, appConfig.SmtpPassword, appConfig.SmtpHost)
err := smtp.SendMail(appConfig.SmtpHost+":"+strconv.Itoa(appConfig.SmtpPort), auth, appConfig.Sender, []string{to}, []byte(message))
if err != nil {
fmt.Println("Failed to send mail:", err.Error())
}
}
func findRecipient(values FormValues) string {
if len(values["_to"]) == 1 && values["_to"][0] != "" {
formDefinedRecipient := values["_to"][0]
for _, allowed := range appConfig.AllowedRecipients {
if formDefinedRecipient == allowed {
return formDefinedRecipient
}
}
}
return appConfig.DefaultRecipient
}
func findFormName(values FormValues) string {
if len(values["_formName"]) == 1 && values["_formName"][0] != "" {
return values["_formName"][0]
}
return "a form"
}
func findReplyTo(values FormValues) string {
if len(values["_replyTo"]) == 1 && values["_replyTo"][0] != "" {
return values["_replyTo"][0]
}
return ""
}
func removeMetaValues(values FormValues) FormValues {
cleanedValues := FormValues{}
for key, value := range values {
if !strings.HasPrefix(key, "_") {
cleanedValues[key] = value
}
}
return cleanedValues
}

137
mail_test.go Normal file
View File

@ -0,0 +1,137 @@
package main
import (
"os"
"reflect"
"strings"
"testing"
"time"
)
func Test_findRecipient(t *testing.T) {
prepare := func() {
os.Clearenv()
_ = os.Setenv("ALLOWED_TO", "mail@example.com,test@example.com")
_ = os.Setenv("DEFAULT_TO", "mail@example.com")
appConfig, _ = parseConfig()
}
t.Run("No recipient specified", func(t *testing.T) {
prepare()
values := FormValues{}
result := findRecipient(values)
if result != "mail@example.com" {
t.Error()
}
})
t.Run("Multiple recipients specified", func(t *testing.T) {
prepare()
values := FormValues{
"_to": {"abc@example.com", "def@example.com"},
}
result := findRecipient(values)
if result != "mail@example.com" {
t.Error()
}
})
t.Run("Allowed recipient specified", func(t *testing.T) {
prepare()
values := FormValues{
"_to": {"test@example.com"},
}
result := findRecipient(values)
if result != "test@example.com" {
t.Error()
}
})
t.Run("Forbidden recipient specified", func(t *testing.T) {
prepare()
values := FormValues{
"_to": {"forbidden@example.com"},
}
result := findRecipient(values)
if result != "mail@example.com" {
t.Error()
}
})
}
func Test_findFormName(t *testing.T) {
t.Run("No form name", func(t *testing.T) {
if "a form" != findFormName(FormValues{}) {
t.Error()
}
})
t.Run("Multiple form names", func(t *testing.T) {
if "a form" != findFormName(FormValues{"_formName": {"Test", "ABC"}}) {
t.Error()
}
})
t.Run("Form name", func(t *testing.T) {
if "Test" != findFormName(FormValues{"_formName": {"Test"}}) {
t.Error()
}
})
}
func Test_findReplyTo(t *testing.T) {
t.Run("No replyTo", func(t *testing.T) {
if "" != findReplyTo(FormValues{}) {
t.Error()
}
})
t.Run("Multiple replyTo", func(t *testing.T) {
if "" != findReplyTo(FormValues{"_replyTo": {"test@example.com", "test2@example.com"}}) {
t.Error()
}
})
t.Run("replyTo", func(t *testing.T) {
if "test@example.com" != findReplyTo(FormValues{"_replyTo": {"test@example.com"}}) {
t.Error()
}
})
}
func Test_removeMetaValues(t *testing.T) {
t.Run("Remove meta values", func(t *testing.T) {
result := removeMetaValues(FormValues{
"_test": {"abc"},
"test": {"def"},
})
want := FormValues{
"test": {"def"},
}
if !reflect.DeepEqual(result, want) {
t.Error()
}
})
}
func Test_buildMessage(t *testing.T) {
t.Run("Test message", func(t *testing.T) {
os.Clearenv()
_ = os.Setenv("EMAIL_TO", "mail@example.com")
_ = os.Setenv("ALLOWED_TO", "mail@example.com,test@example.com")
_ = os.Setenv("EMAIL_FROM", "forms@example.com")
appConfig, _ = parseConfig()
values := FormValues{
"_formName": {"Testform"},
"_replyTo": {"reply@example.com"},
"Testkey": {"Testvalue"},
"Another Key": {"Test", "ABC"},
}
date := time.Now()
result := buildMessage("test@example.com", date, values)
if !strings.Contains(result, "Reply-To: reply@example.com") {
t.Error()
}
if !strings.Contains(result, "Subject: New submission on Testform") {
t.Error()
}
if !strings.Contains(result, "Testkey: Testvalue") {
t.Error()
}
if !strings.Contains(result, "Another Key: Test, ABC") {
t.Error()
}
})
}

25
mailygo.go Normal file
View File

@ -0,0 +1,25 @@
package main
import (
"log"
"net/http"
"strconv"
)
var appConfig config
func init() {
cfg, err := parseConfig()
if err != nil {
log.Fatal(err)
}
appConfig = cfg
}
func main() {
if !checkRequiredConfig(appConfig) {
log.Fatal("Not all required configurations are set")
}
http.HandleFunc("/", FormHandler)
log.Fatal(http.ListenAndServe(":"+strconv.Itoa(appConfig.Port), nil))
}