M email/send.go => email/send.go +24 -36
@@ 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)
}
M email/worker.go => email/worker.go +127 -13
@@ 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.
M go.mod => go.mod +2 -1
@@ 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
)
M go.sum => go.sum +12 -0
@@ 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=
M server/email.go => server/email.go +14 -18
@@ 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")
}