package email
import (
"bytes"
"context"
"errors"
"fmt"
"io"
"log"
"net/http"
"os"
"strings"
"time"
"git.sr.ht/~sircmpwn/core-go/config"
work "git.sr.ht/~sircmpwn/dowork"
"github.com/ProtonMail/go-crypto/openpgp"
_ "github.com/emersion/go-message/charset"
"github.com/emersion/go-message/mail"
"github.com/emersion/go-pgpmail"
"github.com/emersion/go-smtp"
"github.com/vaughan0/go-ini"
)
var emailCtxKey = &contextKey{"email"}
type contextKey struct {
name string
}
type workerContext struct {
client *smtp.Client
sender *mail.Address
queue *Queue
}
// 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(data []byte, rcpts []string) *work.Task {
return work.NewTask(func(ctx context.Context) error {
err := Send(ctx, bytes.NewReader(data), rcpts)
if err != nil {
log.Printf("Error sending mail: %v", err)
}
return err
}).Retries(10).After(func(ctx context.Context, task *work.Task) {
var to string
if len(rcpts) == 1 {
to = rcpts[0]
} else {
to = fmt.Sprintf("%d recipients", len(rcpts))
}
if task.Result() == nil {
log.Printf("Mail to %s sent after %d attempts",
to, task.Attempts())
} else {
log.Printf("Mail to %s failed after %d attempts: %v",
to, task.Attempts(), task.Result())
}
})
}
func prepareEncrypted(rcptKey *string, header mail.Header,
buf *bytes.Buffer, signed *openpgp.Entity) (io.WriteCloser, error) {
keyring, err := openpgp.ReadArmoredKeyRing(strings.NewReader(*rcptKey))
if err != nil {
return nil, err
}
if len(keyring) != 1 {
return nil, errors.New("Expected user PGP key to contain one key")
}
rcptEntity := keyring[0]
return pgpmail.Encrypt(buf, header.Header.Header,
[]*openpgp.Entity{rcptEntity}, signed, nil)
}
func prepareSigned(header mail.Header, buf *bytes.Buffer,
signed *openpgp.Entity) (io.WriteCloser, error) {
result, err := pgpmail.Sign(buf, header.Header.Header, signed, nil)
if err != nil {
return nil, err
}
return result, nil
}
// 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 also be added if they are not
// already present.
func EnqueueStd(ctx context.Context, header mail.Header,
bodyReader io.Reader, rcptKey *string) error {
queue := ForContext(ctx).queue
to, err := header.AddressList("To")
if err != nil {
return fmt.Errorf("invalid To header field: %v", err)
}
cc, err := header.AddressList("Cc")
if err != nil {
return fmt.Errorf("invalid Cc header field: %v", err)
}
var rcpts []string
for _, addr := range to {
rcpts = append(rcpts, addr.Address)
}
for _, addr := range cc {
rcpts = append(rcpts, addr.Address)
}
// Disallow content headers, signing/encrypting will change this
header.Del("Content-Transfer-Encoding")
header.Del("Content-Type")
header.Del("Content-Disposition")
// Force mime version on the top level headers to prevent pgpmail from
// inserting it in the wrong place (first part)
header.Set("Mime-Version", "1.0")
if !header.Has("Message-Id") {
header.GenerateMessageID()
}
if !header.Has("Date") {
header.SetDate(time.Now().UTC())
}
if !header.Has("From") {
header.SetAddressList("From", []*mail.Address{queue.smtpFrom})
}
if !header.Has("Reply-To") {
header.SetAddressList("Reply-To", []*mail.Address{queue.ownerAddress})
}
var (
buf bytes.Buffer
cleartext io.WriteCloser
)
if rcptKey != nil {
cleartext, err = prepareEncrypted(rcptKey, header, &buf, queue.entity)
}
// Fall back to unencrypted email if encryption did not work
// TODO should we add the error message to the email?
if rcptKey == nil || err != nil {
if err != nil {
buf.Reset()
log.Printf("Encrypting mail to %s failed: %s",
strings.Join(rcpts, ", "), err.Error())
}
if queue.entity != nil {
cleartext, err = prepareSigned(header, &buf, queue.entity)
if err != nil {
log.Fatalf("Signing mail failed: %v", err)
}
} else {
cleartext = nopWriteCloser{&buf}
}
}
var inlineHeader mail.Header
inlineHeader.SetContentType("text/plain", map[string]string{
"charset": "UTF-8",
"format": "flowed",
})
body, err := mail.CreateSingleInlineWriter(cleartext, inlineHeader)
if err != nil {
panic(err)
}
_, err = io.Copy(body, bodyReader)
if err != nil {
log.Fatal(err)
}
body.Close()
cleartext.Close()
return queue.Enqueue(NewTask(buf.Bytes(), rcpts))
}
func EnqueueRaw(ctx context.Context, data []byte, to []string) error {
if len(to) == 0 {
panic(fmt.Errorf("recipients list cannot be empty"))
}
queue := ForContext(ctx).queue
return queue.Enqueue(NewTask(data, to))
}
type nopWriteCloser struct {
io.Writer
}
func (nopWriteCloser) Close() error {
return nil
}
type Queue struct {
*work.Queue
smtpFrom *mail.Address
ownerAddress *mail.Address
entity *openpgp.Entity
}
// CanPGPSign checks whether emails sent via the queue are signed via OpenPGP.
func (q *Queue) CanPGPSign() bool {
return q.entity != nil
}
// Creates a new email processing queue.
func NewQueue(conf ini.File) *Queue {
smtpFrom, ok := conf.Get("mail", "smtp-from")
if !ok {
panic(fmt.Errorf("Expected [mail]smtp-from in config"))
}
ownerName, ok := conf.Get("sr.ht", "owner-name")
if !ok {
panic(fmt.Errorf("Expected [sr.ht]owner-name in config"))
}
ownerEmail, ok := conf.Get("sr.ht", "owner-email")
if !ok {
panic(fmt.Errorf("Expected [sr.ht]owner-email in config"))
}
addr, err := mail.ParseAddress(smtpFrom)
if err != nil {
panic(fmt.Errorf("Invalid [mail]smtp-from: %s", err.Error()))
}
ownerAddr := &mail.Address{
Name: ownerName,
Address: ownerEmail,
}
var entity *openpgp.Entity
if privKeyPath, ok := conf.Get("mail", "pgp-privkey"); ok {
privKeyFile, err := os.Open(privKeyPath)
if err != nil {
panic(fmt.Errorf("Failed to open [mail]pgp-privkey: %v", err))
}
defer privKeyFile.Close()
keyring, err := openpgp.ReadArmoredKeyRing(privKeyFile)
if err != nil {
panic(fmt.Errorf("Failed to read PGP key ring from [mail]pgp-privkey: %v", err))
}
if len(keyring) != 1 {
panic(fmt.Errorf("Expected [mail]pgp-privkey to contain one key"))
}
entity = keyring[0]
if entity.PrivateKey == nil || entity.PrivateKey.Encrypted {
panic(fmt.Errorf("Failed to load [mail]pgp-privkey for email signature"))
}
}
queueSize := config.GetInt(conf, "mail", "egress-queue-size", config.DefaultQueueSize)
return &Queue{
Queue: work.NewQueue("email", queueSize),
smtpFrom: addr,
ownerAddress: ownerAddr,
entity: entity,
}
}
// Returns the email worker for this context.
func ForContext(ctx context.Context) *workerContext {
q, ok := ctx.Value(emailCtxKey).(*workerContext)
if !ok {
panic(errors.New("No email worker for this context"))
}
return q
}
// Returns a context which includes the given mail worker.
func Context(ctx context.Context, queue *Queue) context.Context {
return context.WithValue(ctx, emailCtxKey, &workerContext{queue: queue})
}
// Adds HTTP middleware to provide an email work queue to this context.
func Middleware(queue *Queue) func(next http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
r = r.WithContext(Context(r.Context(), queue))
next.ServeHTTP(w, r)
})
}
}