~bigbes/core-go

606ff2ef7d42bdb89306e07a0840034a3912d9e0 — Drew DeVault 5 years ago bdd0eb3
Sign outgoing emails
5 files changed, 179 insertions(+), 68 deletions(-)

M email/send.go
M email/worker.go
M go.mod
M go.sum
M server/email.go
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")
}