package email
import (
"bytes"
"context"
"errors"
"fmt"
"io"
"log"
"net/http"
"os"
"strings"
"time"
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/vaughan0/go-ini"
)
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())
}
})
}
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)
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")
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}
}
}
defer cleartext.Close()
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)
}
defer body.Close()
_, err = io.Copy(body, bodyReader)
if err != nil {
log.Fatal(err)
}
return queue.Enqueue(NewTask(&buf, rcpts))
}
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("Expected [mail]smtp-from in config")
}
ownerName, ok := conf.Get("sr.ht", "owner-name")
if !ok {
panic("Expected [sr.ht]owner-name in config")
}
ownerEmail, ok := conf.Get("sr.ht", "owner-email")
if !ok {
panic("Expected [sr.ht]owner-email in config")
}
addr, err := mail.ParseAddress(smtpFrom)
if err != nil {
panic("Invalid [mail]smtp-from: " + 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("Expected [mail]pgp-privkey to contain one key")
}
entity = keyring[0]
if entity.PrivateKey == nil || entity.PrivateKey.Encrypted {
panic("Failed to load [mail]pgp-privkey for email signature")
}
}
return &Queue{
Queue: work.NewQueue("email"),
smtpFrom: addr,
ownerAddress: ownerAddr,
entity: entity,
}
}
// Returns the email worker for this context.
func ForContext(ctx context.Context) *Queue {
q, ok := ctx.Value(emailCtxKey).(*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 *Queue) context.Context {
return context.WithValue(ctx, emailCtxKey, 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)
})
}
}