core-go/email/worker.go

209 lines
5.1 KiB
Go

package email
import (
"bytes"
"context"
"errors"
"fmt"
"io"
"log"
"net/http"
"os"
"strings"
"time"
"git.sr.ht/~sircmpwn/dowork"
_ "github.com/emersion/go-message/charset"
"github.com/emersion/go-message/mail"
"github.com/emersion/go-pgpmail"
"golang.org/x/crypto/openpgp"
"git.sr.ht/~sircmpwn/core-go/config"
)
var emailCtxKey = &contextKey{"email"}
type contextKey struct {
name string
}
// 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(msg *bytes.Buffer, rcpts []string) *work.Task {
return work.NewTask(func(ctx context.Context) error {
err := Send(ctx, msg, rcpts)
if err != nil {
log.Printf("Error sending mail: %v", err)
}
return err
}).Retries(10).After(func(ctx context.Context, task *work.Task) {
if task.Result() == nil {
log.Printf("Mail to %s sent after %d attempts",
strings.Join(rcpts, ", "), task.Attempts())
} else {
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, 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)
}
var rcpts []string
for _, addr := range to {
rcpts = append(rcpts, addr.Address)
}
for _, addr := range cc {
rcpts = append(rcpts, addr.Address)
}
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 (
buf bytes.Buffer
cleartext io.WriteCloser
)
if rcptKey != nil {
keyring, err = openpgp.ReadArmoredKeyRing(strings.NewReader(*rcptKey))
if err != nil {
log.Fatal(err)
}
if len(keyring) != 1 {
return errors.New("Expected user PGP key to contain one key")
}
rcptEntity := keyring[0]
cleartext, err = pgpmail.Encrypt(&buf, header.Header.Header,
[]*openpgp.Entity{rcptEntity}, entity, nil)
if err != nil {
log.Fatal(err)
}
defer cleartext.Close()
} else {
cleartext, err = pgpmail.Sign(&buf, header.Header.Header,
entity, nil)
if err != nil {
log.Fatal(err)
}
defer cleartext.Close()
}
var inlineHeader mail.Header
inlineHeader.SetContentType("text/plain", nil)
body, err := mail.CreateSingleInlineWriter(cleartext, inlineHeader)
if err != nil {
panic(err)
}
defer body.Close()
_, err = io.Copy(body, bodyReader)
if err != nil {
log.Fatal(err)
}
Enqueue(ctx, &buf, rcpts)
return nil
}
// Creates a new email processing queue.
func NewQueue() *work.Queue {
return work.NewQueue("email")
}
// Returns the email worker for this context.
func ForContext(ctx context.Context) *work.Queue {
q, ok := ctx.Value(emailCtxKey).(*work.Queue)
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 *work.Queue) context.Context {
return context.WithValue(ctx, emailCtxKey, queue)
}
// Adds HTTP middleware to provide an email work queue to this context.
func Middleware(queue *work.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)
})
}
}