From 606ff2ef7d42bdb89306e07a0840034a3912d9e0 Mon Sep 17 00:00:00 2001 From: Drew DeVault Date: Sat, 14 Nov 2020 12:26:39 -0500 Subject: [PATCH] Sign outgoing emails --- email/send.go | 60 +++++++++------------ email/worker.go | 140 +++++++++++++++++++++++++++++++++++++++++++----- go.mod | 3 +- go.sum | 12 +++++ server/email.go | 32 +++++------ 5 files changed, 179 insertions(+), 68 deletions(-) diff --git a/email/send.go b/email/send.go index 1eb14c1a2a754b6d56e9a7d2561f27aa6e83524c..204a900ec2e3da2aeb8ab61419e97e5fdead7241 100644 --- a/email/send.go +++ b/email/send.go @@ -1,58 +1,46 @@ package email import ( - "bytes" "context" - "crypto/rand" - "encoding/binary" - "errors" "fmt" - "os" + "io" + "net/mail" "strconv" - "time" - "github.com/martinlindhe/base36" - gomail "gopkg.in/mail.v2" + "github.com/emersion/go-sasl" + "github.com/emersion/go-smtp" + _ "github.com/emersion/go-message/charset" "git.sr.ht/~sircmpwn/core-go/config" ) // Sends an email. Blocks until it's sent or an error occurs. -func Send(ctx context.Context, m *gomail.Message) error { +func Send(ctx context.Context, msg io.Reader, rcpts []string) error { conf := config.ForContext(ctx) portStr, ok := conf.Get("mail", "smtp-port") if !ok { - return errors.New("internal system error") + panic(fmt.Errorf("[mail]smtp-port unset")) + } + port, err := strconv.Atoi(portStr) + if err != nil { + panic(fmt.Errorf("Unable to parse [mail]smtp-port (must be integer)")) } - port, _ := strconv.Atoi(portStr) - host, _ := conf.Get("mail", "smtp-host") - user, _ := conf.Get("mail", "smtp-user") - pass, _ := conf.Get("mail", "smtp-password") - - m.SetHeader("Message-ID", generateMessageID()) - m.SetDateHeader("Date", time.Now().UTC()) - d := gomail.NewDialer(host, port, user, pass) - return d.DialAndSend(m) -} + host, ok1 := conf.Get("mail", "smtp-host") + user, ok2 := conf.Get("mail", "smtp-user") + pass, ok3 := conf.Get("mail", "smtp-password") + from, ok4 := conf.Get("mail", "smtp-from") + if !ok1 || !ok2 || !ok3 || !ok4 { + panic(fmt.Errorf("Missing SMTP configuration options")) + } -// Generates an RFC 2822-compliant Message-Id based on the informational draft -// "Recommendations for generating Message IDs", for lack of a better -// authoritative source. -func generateMessageID() string { - var ( - now bytes.Buffer - nonce []byte = make([]byte, 8) - ) - binary.Write(&now, binary.BigEndian, time.Now().UnixNano()) - rand.Read(nonce) - hostname, err := os.Hostname() + sender, err := mail.ParseAddress(from) if err != nil { - hostname = "localhost" + panic(err) } - return fmt.Sprintf("<%s.%s@%s>", - base36.EncodeBytes(now.Bytes()), - base36.EncodeBytes(nonce), - hostname) + + auth := sasl.NewPlainClient("", user, pass) + return smtp.SendMail(fmt.Sprintf("%s:%d", host, port), + auth, sender.Address, rcpts, msg) } diff --git a/email/worker.go b/email/worker.go index 6f14ca6b6f2a96a87b6752eb04ef58275a583744..e91602f0000dc1ca10ae8b9a42cf24ba1383b6d8 100644 --- a/email/worker.go +++ b/email/worker.go @@ -1,14 +1,24 @@ package email import ( + "bytes" "context" "errors" + "fmt" + "io" "log" "net/http" + "os" "strings" + "time" "git.sr.ht/~sircmpwn/dowork" - gomail "gopkg.in/mail.v2" + "github.com/emersion/go-message/mail" + "github.com/emersion/go-pgpmail" + "golang.org/x/crypto/openpgp" + _ "github.com/emersion/go-message/charset" + + "git.sr.ht/~sircmpwn/core-go/config" ) var emailCtxKey = &contextKey{"email"} @@ -20,27 +30,131 @@ type contextKey struct { // Returns a task which will send this email for the work queue. If the caller // does not need to customize the task parameters, the Enqueue function may be // more desirable. -func NewTask(m *gomail.Message) *work.Task { +func NewTask(msg *bytes.Buffer, rcpts []string) *work.Task { return work.NewTask(func(ctx context.Context) error { - return Send(ctx, m) + err := Send(ctx, msg, rcpts) + if err != nil { + log.Printf("Error sending mail: %e", err) + } + return err }).Retries(10).After(func(ctx context.Context, task *work.Task) { if task.Result() == nil { - log.Printf("Mail to %s: '%s' sent after %d attempts", - strings.Join(m.GetHeader("To"), ";"), - strings.Join(m.GetHeader("Subject"), ";"), - task.Attempts()) + log.Printf("Mail to %s sent after %d attempts", + strings.Join(rcpts, ", "), task.Attempts()) } else { - log.Printf("Mail to %s: '%s' failed after %d attempts: %v", - strings.Join(m.GetHeader("To"), ";"), - strings.Join(m.GetHeader("Subject"), ";"), - task.Attempts(), task.Result()) + log.Printf("Mail to %s failed after %d attempts: %v", + strings.Join(rcpts, ", "), task.Attempts(), task.Result()) } }) } // Enqueues an email for sending with the default parameters. -func Enqueue(ctx context.Context, m *gomail.Message) { - ForContext(ctx).Enqueue(NewTask(m)) +func Enqueue(ctx context.Context, msg *bytes.Buffer, rcpts []string) { + ForContext(ctx).Enqueue(NewTask(msg, rcpts)) +} + +// Updates an email with the standard SourceHut headers, signs and optionally +// encrypts it, and then queues it for delivery. +// +// Senders should fill in at least the To and Subject headers, and the message +// body. Message-ID, Date, From, and Reply-To will be added here. +func EnqueueStd(ctx context.Context, header mail.Header, + bodyReader io.Reader, rcptKey *string) error { + + // XXX: Do we really need to load all this shit every time we send an email + conf := config.ForContext(ctx) + smtpFrom, ok := conf.Get("mail", "smtp-from") + if !ok { + panic(errors.New("Expected [mail]smtp-from in config")) + } + ownerName, ok := conf.Get("sr.ht", "owner-name") + if !ok { + panic(errors.New("Expected [sr.ht]owner-name in config")) + } + ownerEmail, ok := conf.Get("sr.ht", "owner-email") + if !ok { + panic(errors.New("Expected [sr.ht]owner-email in config")) + } + + from, err := header.AddressList("From") + if err != nil { + return err + } + to, err := header.AddressList("To") + if err != nil { + return err + } + cc, err := header.AddressList("Cc") + if err != nil { + return err + } + + if addr, err := mail.ParseAddress(smtpFrom); err == nil { + from = append(from, addr) + } else { + panic(err) + } + + header.GenerateMessageID() + header.SetDate(time.Now().UTC()) + header.SetAddressList("From", from) + header.Header.SetText("Reply-To", + fmt.Sprintf("%s <%s>", ownerName, ownerEmail)) + + privKeyPath, ok := conf.Get("mail", "pgp-privkey") + if !ok { + panic(errors.New("Expected [sr.ht]owner-email in config")) + } + privKeyFile, err := os.Open(privKeyPath) + if err != nil { + panic(err) + } + defer privKeyFile.Close() + + keyring, err := openpgp.ReadArmoredKeyRing(privKeyFile) + if err != nil { + panic(err) + } + if len(keyring) != 1 { + panic(errors.New("Expected site PGP key to contain one key")) + } + entity := keyring[0] + if entity.PrivateKey == nil || entity.PrivateKey.Encrypted { + panic(errors.New("Failed to load private key for email signature")) + } + + var signedHeader mail.Header + signedHeader.SetContentType("text/plain", nil) + + var buf bytes.Buffer + cleartext, err := pgpmail.Sign(&buf, header.Header.Header, entity, nil) + if err != nil { + log.Fatal(err) + } + + body, err := mail.CreateSingleInlineWriter(cleartext, signedHeader) + if err != nil { + panic(err) + } + + _, err = io.Copy(body, bodyReader) + if err != nil { + log.Fatal(err) + } + + cleartext.Close() + body.Close() + + var rcpts []string + for _, addr := range to { + rcpts = append(rcpts, addr.Address) + } + for _, addr := range cc { + rcpts = append(rcpts, addr.Address) + } + + Enqueue(ctx, &buf, rcpts) + return nil } // Creates a new email processing queue. diff --git a/go.mod b/go.mod index 2785c048f8205b8323d072f8dcd0521c612f6781..86ff169cb4bba2cd0d320de586c63a9c5d95cf6e 100644 --- a/go.mod +++ b/go.mod @@ -9,6 +9,8 @@ require ( github.com/99designs/gqlgen v0.13.0 github.com/DATA-DOG/go-sqlmock v1.5.0 github.com/Masterminds/squirrel v1.4.0 + github.com/emersion/go-message v0.13.1-0.20201112194930-f77964fe28bd // indirect + github.com/emersion/go-textwrapper v0.0.0-20200911093747-65d896831594 // indirect github.com/fernet/fernet-go v0.0.0-20191111064656-eff2850e6001 github.com/go-chi/chi v4.1.2+incompatible github.com/go-redis/redis/v8 v8.2.3 @@ -26,5 +28,4 @@ require ( golang.org/x/crypto v0.0.0-20200728195943-123391ffb6de golang.org/x/sys v0.0.0-20201013132646-2da7054afaeb // indirect google.golang.org/protobuf v1.25.0 // indirect - gopkg.in/mail.v2 v2.3.1 ) diff --git a/go.sum b/go.sum index 70b691d4d4dc3d6e42bb48de7e52d8e5b055695e..a397a580651a2dd3ffbf2ea0421e6e85cea17adf 100644 --- a/go.sum +++ b/go.sum @@ -76,6 +76,16 @@ github.com/eapache/go-resiliency v1.1.0/go.mod h1:kFI+JgMyC7bLPUVY133qvEBtVayf5m github.com/eapache/go-xerial-snappy v0.0.0-20180814174437-776d5712da21/go.mod h1:+020luEh2TKB4/GOp8oxxtq0Daoen/Cii55CzbTV6DU= github.com/eapache/queue v1.1.0/go.mod h1:6eCeP0CKFpHLu8blIFXhExK/dRa7WDZfr6jVFPTqq+I= github.com/edsrzf/mmap-go v1.0.0/go.mod h1:YO35OhQPt3KJa3ryjFM5Bs14WD66h8eGKpfaBNrHW5M= +github.com/emersion/go-message v0.12.0 h1:mZnv35eZ6lB6EftTQBgYXspOH0FQdhpFhSUhA9i6/Zg= +github.com/emersion/go-message v0.12.0/go.mod h1:C4jnca5HOTo4bGN9YdqNQM9sITuT3Y0K6bSUw9RklvY= +github.com/emersion/go-message v0.12.1-0.20200903165315-e1abe21f389a h1:3C6qIGgPr1qAT0ikRD5NbyKpME/iHCDeXhpv/JJsFsE= +github.com/emersion/go-message v0.12.1-0.20200903165315-e1abe21f389a/go.mod h1:kYIioST9GDHte9/BRWgi93rpqbDuFftMjKSMaXS8ABo= +github.com/emersion/go-message v0.13.1-0.20201112194930-f77964fe28bd h1:6CXxdoOzAyQForkd2U/JNceVyNpmg92alCU2R+4dwIY= +github.com/emersion/go-message v0.13.1-0.20201112194930-f77964fe28bd/go.mod h1:SXSs/8KamlsyxjpHL1Q3yf5Jrv7QG5icuvPK1SMcnzw= +github.com/emersion/go-textwrapper v0.0.0-20160606182133-d0e65e56babe h1:40SWqY0zE3qCi6ZrtTf5OUdNm5lDnGnjRSq9GgmeTrg= +github.com/emersion/go-textwrapper v0.0.0-20160606182133-d0e65e56babe/go.mod h1:aqO8z8wPrjkscevZJFVE1wXJrLpC5LtJG7fqLOsPb2U= +github.com/emersion/go-textwrapper v0.0.0-20200911093747-65d896831594 h1:IbFBtwoTQyw0fIM5xv1HF+Y+3ZijDR839WMulgxCcUY= +github.com/emersion/go-textwrapper v0.0.0-20200911093747-65d896831594/go.mod h1:aqO8z8wPrjkscevZJFVE1wXJrLpC5LtJG7fqLOsPb2U= github.com/envoyproxy/go-control-plane v0.6.9/go.mod h1:SBwIajubJHhxtWwsL9s8ss4safvEdbitLhGGK48rN6g= github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c= @@ -201,6 +211,7 @@ github.com/lightstep/lightstep-tracer-common/golang/gogo v0.0.0-20190605223551-b github.com/lightstep/lightstep-tracer-go v0.18.1/go.mod h1:jlF1pusYV4pidLvZ+XD0UBX0ZE6WURAspgAczcDHrL4= github.com/logrusorgru/aurora v0.0.0-20200102142835-e9ef32dff381/go.mod h1:7rIyQOR62GCctdiQpZ/zOJlFyk6y+94wXzv6RNZgaR4= github.com/lyft/protoc-gen-validate v0.0.13/go.mod h1:XbGvPuh87YZc5TdIa2/I4pLk0QoUACkjt2znoq26NVQ= +github.com/martinlindhe/base36 v1.0.0/go.mod h1:+AtEs8xrBpCeYgSLoY/aJ6Wf37jtBuR0s35750M27+8= github.com/martinlindhe/base36 v1.1.0 h1:cIwvvwYse/0+1CkUPYH5ZvVIYG3JrILmQEIbLuar02Y= github.com/martinlindhe/base36 v1.1.0/go.mod h1:+AtEs8xrBpCeYgSLoY/aJ6Wf37jtBuR0s35750M27+8= github.com/matryer/moq v0.0.0-20200106131100-75d0ddfc0007/go.mod h1:9ELz6aaclSIGnZBoaSLZ3NAl1VTufbOrXBPvtcy6WiQ= @@ -434,6 +445,7 @@ golang.org/x/sys v0.0.0-20201013132646-2da7054afaeb h1:HS9IzC4UFbpMBLQUDSQcU+ViV golang.org/x/sys v0.0.0-20201013132646-2da7054afaeb/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= +golang.org/x/text v0.3.4-0.20201021145329-22f1617af38e/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/time v0.0.0-20180412165947-fbb02b2291d2/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/time v0.0.0-20191024005414-555d28b269f0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/tools v0.0.0-20180221164845-07fd8470d635/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= diff --git a/server/email.go b/server/email.go index 5728b27bcdff6a2b99b31a585f8a3a596b69d11f..38f112f4f9f180fb33690814c670c4ee1402f5f2 100644 --- a/server/email.go +++ b/server/email.go @@ -5,11 +5,12 @@ import ( "errors" "fmt" "log" - "net/mail" "runtime" + "strings" + gomail "net/mail" "github.com/99designs/gqlgen/graphql" - gomail "gopkg.in/mail.v2" + "github.com/emersion/go-message/mail" "git.sr.ht/~sircmpwn/core-go/auth" "git.sr.ht/~sircmpwn/core-go/config" @@ -48,31 +49,26 @@ func emailRecover(ctx context.Context, _origErr interface{}) error { return fmt.Errorf("internal system error") } + var header mail.Header + header.SetSubject(fmt.Sprintf("[%s] GraphQL query error: %v", + config.ServiceName(ctx), origErr)) + conf := config.ForContext(ctx) to, ok := conf.Get("mail", "error-to") if !ok { return fmt.Errorf("internal system error") } - from, _ := conf.Get("mail", "error-from") - - m := gomail.NewMessage() - sender, err := mail.ParseAddress(from) + rcpt, err := gomail.ParseAddress(to) if err != nil { - log.Fatalf("Failed to parse sender address") + panic(errors.New("Failed to parse sender address")) } - m.SetAddressHeader("From", sender.Address, sender.Name) - recipient, err := mail.ParseAddress(to) - if err != nil { - log.Fatalf("Failed to parse recipient address") - } - m.SetAddressHeader("To", recipient.Address, recipient.Name) - m.SetHeader("Subject", fmt.Sprintf( - "[%s] GraphQL query error: %v", config.ServiceName(ctx), origErr)) + addr := mail.Address(*rcpt) + header.SetAddressList("To", []*mail.Address{&addr}) quser := auth.ForContext(ctx) octx := graphql.GetOperationContext(ctx) - - m.SetBody("text/plain", fmt.Sprintf(`Error occured processing GraphQL request: + reader := strings.NewReader( + fmt.Sprintf(`Error occured processing GraphQL request: %v @@ -84,6 +80,6 @@ The following stack trace was produced: %s`, origErr, quser.Username, quser.Email, octx.RawQuery, string(stack[:i]))) - email.Enqueue(ctx, m) + email.EnqueueStd(ctx, header, reader, nil) return fmt.Errorf("internal system error") }