Commit 370ffd97 authored by ale's avatar ale

Add packages to generate and send emails

We're using language-specific Markdown templates to allow simultaneous
generation of text and HTML for fancy multipart/alternative
emails. The package also supports PGP/MIME for signatures (no
encryption yet).
parent 0fa24c66
package mail
import (
"git.autistici.org/ai3/go-common/mail/message"
"git.autistici.org/ai3/go-common/mail/template"
)
// SendPlainTextMessage sends a simple plaintext message to the
// specified recipient.
func (m *Mailer) SendPlainTextMessage(templateName, lang, subject, rcpt string, values map[string]interface{}) error {
tpl, err := template.New(templateName, lang, values)
if err != nil {
return err
}
msg := message.NewText("text/plain", tpl.Text())
return m.Send(msg, rcpt, subject)
}
// SendTextAndHTMLMessage builds a multipart/alternative message with
// both a plaintext and a HTML part, and sends it to the recipient.
func (m *Mailer) SendTextAndHTMLMessage(templateName, lang, subject, rcpt string, values map[string]interface{}) error {
tpl, err := template.New(templateName, lang, values)
if err != nil {
return err
}
msg := message.NewMultiPart(
"multipart/alternative",
message.NewText("text/plain", tpl.Text()),
message.NewText("text/html", tpl.HTML()),
)
return m.Send(msg, rcpt, subject)
}
package mail
import (
"bytes"
"errors"
"fmt"
"net"
"net/mail"
"net/smtp"
"strings"
"time"
"git.autistici.org/ai3/go-common/mail/message"
"git.autistici.org/ai3/go-common/mail/pgp"
)
var userAgent = "go-mailer/0.1"
type Config struct {
SenderName string `yaml:"sender_name"`
SenderAddr string `yaml:"sender_addr"`
SigningKeyFile string `yaml:"signing_key_file"`
SigningKeyID string `yaml:"signing_key_id"`
SMTP struct {
Server string `yaml:"server"`
AuthUser string `yaml:"auth_user"`
AuthPassword string `yaml:"auth_password"`
} `yaml:"smtp"`
}
type Mailer struct {
sender *mail.Address
senderDomain string
middleware message.MiddlewareList
smtpServer string
smtpAuth smtp.Auth
}
func New(config *Config) (*Mailer, error) {
if config.SenderAddr == "" {
return nil, errors.New("sender_addr must not be empty")
}
senderDomainParts := strings.Split(config.SenderAddr, "@")
senderDomain := senderDomainParts[len(senderDomainParts)-1]
sender := &mail.Address{
Name: config.SenderName,
Address: config.SenderAddr,
}
// Load the signing key, if specified.
var mwl message.MiddlewareList
if config.SigningKeyFile != "" {
signer, err := pgp.NewSigner(config.SigningKeyFile, config.SigningKeyID, sender)
if err != nil {
return nil, err
}
mwl.Add(signer)
}
// Parse SMTP authentication params.
var smtpAuth smtp.Auth
if config.SMTP.AuthUser != "" {
// The hostname is used by net/smtp to validate the TLS certificate.
hostname := config.SMTP.Server
if h, _, err := net.SplitHostPort(hostname); err == nil {
hostname = h
}
smtpAuth = smtp.PlainAuth("", config.SMTP.AuthUser, config.SMTP.AuthPassword, hostname)
}
return &Mailer{
sender: sender,
senderDomain: senderDomain,
middleware: mwl,
smtpServer: config.SMTP.Server,
smtpAuth: smtpAuth,
}, nil
}
func (m *Mailer) WithEnvelope(msg *message.Part, rcpt, subject string) (*message.Part, error) {
rcptAddr := mail.Address{Address: rcpt}
hdr := msg.Header
hdr.Set("From", m.sender.String())
hdr.Set("To", rcptAddr.String())
hdr.Set("Subject", subject)
hdr.Set("User-Agent", userAgent)
hdr.Set("MIME-Version", "1.0")
hdr.Set("Message-ID", fmt.Sprintf("<%s>", m.randomMessageID()))
hdr.Set("Date", currentTimeFn().Format(time.RFC1123Z))
var err error
msg, err = m.middleware.Process(msg)
if err != nil {
return nil, err
}
return msg, nil
}
func (m *Mailer) Render(msg *message.Part) (body string, err error) {
var buf bytes.Buffer
err = msg.Render(&buf)
if err == nil {
body = buf.String()
}
return
}
func (m *Mailer) randomMessageID() string {
// Re-use randomBoundary.
return fmt.Sprintf("%s@%s", message.RandomBoundaryFn(), m.senderDomain)
}
func (m *Mailer) Send(msg *message.Part, rcpt, subject string) error {
var err error
msg, err = m.WithEnvelope(msg, rcpt, subject)
if err != nil {
return err
}
var buf bytes.Buffer
if err := msg.Render(&buf); err != nil {
return err
}
return smtp.SendMail(m.smtpServer, m.smtpAuth, m.sender.Address, []string{rcpt}, buf.Bytes())
}
// Let us stub out the time function for testing.
var currentTimeFn func() time.Time = currentTime
func currentTime() time.Time {
return time.Now().UTC()
}
This diff is collapsed.
package mdtext
import (
"io"
"log"
"strings"
"github.com/bbrks/wrap"
bf "gopkg.in/russross/blackfriday.v2"
)
// The textWriter can indent and wrap individual "blocks" of text,
// accumulated with writeString().
type textWriter struct {
prefixes []string
firstPrefixes []string
curBlock string
wrapSize int
}
func (tw *textWriter) pushPrefix(firstPfx, pfx string) {
// Make the old rightmost first entry same as the non-first
// one. This is a special case that pretty much only applies
// to list items, where we desire only the single, rightmost
// bullet point to be visible.
if len(tw.firstPrefixes) > 0 {
tw.firstPrefixes[len(tw.firstPrefixes)-1] = tw.prefixes[len(tw.prefixes)-1]
}
tw.firstPrefixes = append(tw.firstPrefixes, firstPfx)
tw.prefixes = append(tw.prefixes, pfx)
}
func (tw *textWriter) popPrefix() {
tw.firstPrefixes = tw.firstPrefixes[:len(tw.firstPrefixes)-1]
tw.prefixes = tw.prefixes[:len(tw.prefixes)-1]
}
func (tw *textWriter) prefixLen() int {
var l int
for _, p := range tw.prefixes {
l += len(p)
}
return l
}
func (tw *textWriter) writeString(_ io.Writer, s string) {
tw.curBlock += s
}
func (tw *textWriter) emitBlock(w io.Writer, doWrap bool) {
s := tw.curBlock
if doWrap {
n := tw.wrapSize - tw.prefixLen()
if n < 10 {
n = 10
}
// Remove newlines eventually embedded within the
// text, effectively ignoring breaks in the paragraph.
s = strings.Replace(s, "\n", " ", -1)
s = wrap.Wrap(s, n)
} else {
s = strings.TrimSpace(s)
}
empty := true
for idx, line := range strings.Split(s, "\n") {
if line == "" {
if !doWrap {
io.WriteString(w, "\n") // nolint
}
continue
}
prefixes := tw.firstPrefixes
if idx > 0 {
prefixes = tw.prefixes
}
for _, p := range prefixes {
io.WriteString(w, p) // nolint
}
io.WriteString(w, line) // nolint
io.WriteString(w, "\n") // nolint
empty = false
}
if !empty {
io.WriteString(w, "\n") // nolint
}
tw.curBlock = ""
}
// Text renderer for Markdown.
type textRenderer struct {
*textWriter
}
// NewTextRenderer creates a new blackfriday.Renderer that renders
// Markdown to well-formatted plain text (with line length wrapped at
// wrapSize).
func NewTextRenderer(wrapSize int) bf.Renderer {
if wrapSize < 1 {
wrapSize = 75
}
return &textRenderer{
textWriter: &textWriter{
wrapSize: wrapSize,
},
}
}
func (r *textRenderer) RenderNode(w io.Writer, node *bf.Node, entering bool) bf.WalkStatus {
switch node.Type {
case bf.BlockQuote:
if entering {
r.pushPrefix("> ", "> ")
} else {
r.emitBlock(w, true)
r.popPrefix()
}
case bf.CodeBlock:
r.pushPrefix(" ", " ")
r.writeString(w, string(node.Literal))
r.emitBlock(w, false)
r.popPrefix()
case bf.Del:
break
case bf.Document:
break
case bf.Emph:
r.writeString(w, "*")
case bf.Hardbreak:
r.writeString(w, "\n")
r.emitBlock(w, false)
case bf.Heading:
if entering {
switch node.Level {
case 1:
r.writeString(w, "# ")
case 2:
r.writeString(w, "## ")
case 3:
r.writeString(w, "### ")
case 4:
r.writeString(w, "#### ")
case 5:
r.writeString(w, "##### ")
case 6:
r.writeString(w, "###### ")
}
} else {
r.emitBlock(w, true)
}
case bf.HTMLBlock, bf.HTMLSpan:
break
case bf.HorizontalRule:
r.writeString(w, "-------------------------------------------------------------")
r.emitBlock(w, false)
case bf.Image:
break
case bf.Item:
if entering {
r.pushPrefix("* ", " ")
} else {
r.emitBlock(w, true)
r.popPrefix()
}
case bf.Link:
r.writeString(w, string(node.LinkData.Destination))
return bf.SkipChildren
case bf.List:
if node.IsFootnotesList {
return bf.SkipChildren
}
case bf.Paragraph:
if !entering {
r.emitBlock(w, true)
}
case bf.Softbreak:
break
case bf.Strong:
r.writeString(w, "**")
case bf.Table, bf.TableBody, bf.TableCell, bf.TableRow:
break
case bf.Code, bf.Text:
r.writeString(w, string(node.Literal))
default:
log.Printf("unknown node type %v", node.Type)
}
return bf.GoToNext
}
func (r *textRenderer) RenderHeader(w io.Writer, ast *bf.Node) {
}
func (r *textRenderer) RenderFooter(w io.Writer, ast *bf.Node) {
}
package mdtext
import (
"testing"
"github.com/google/go-cmp/cmp"
bf "gopkg.in/russross/blackfriday.v2"
)
var (
testText = `
doctitle
========
This is a *Markdown* test document that is meant to be rendered to text, with the ultimate purpose of generating nicely formatted email templates.
* list element one
* nested list element one, whose only purpose is to have a very long message so we can see how (and *if*) the text wraps around onto multiple lines with the proper indentation
* nested list element two
* list element two
* list element three
* more information on [wikipedia](https://wikipedia.org/).
## Level 2 header
Yet another long paragraph to showcase the word wrapping capabilities of the software.
In theory the long lines should be wrapped at around 75 characters, so as to generate nicely
formatted emails (the original text is wrapped at 100 chars for the purpose of showcasing this
specific feature.
> This is a block quote, and as such we would like to see it indented by four spaces, but also
> maintaining the proper indentation on the lines following the first one, which is the feature
> tested by this snippet.
Code blocks, on the other hand, should have no line wrapping and should keep their own formatting.
Finally another paragraph to conclude this test.
`
testTextExpected60 = `# doctitle
This is a *Markdown* test document that is meant to be
rendered to text, with the ultimate purpose of generating
nicely formatted email templates.
* list element one
* nested list element one, whose only purpose is to have a
very long message so we can see how (and *if*) the text
wraps around onto multiple lines with the proper
indentation
* nested list element two
* list element two
* list element three
* more information on https://wikipedia.org/.
## Level 2 header
Yet another long paragraph to showcase the word wrapping
capabilities of the software. In theory the long lines
should be wrapped at around 75 characters, so as to generate
nicely formatted emails (the original text is wrapped at 100
chars for the purpose of showcasing this specific feature.
> This is a block quote, and as such we would like to see it
> indented by four spaces, but also maintaining the proper
> indentation on the lines following the first one, which is
> the feature tested by this snippet.
Code blocks, on the other hand, should have no line wrapping and should keep their own formatting.
Finally another paragraph to conclude this test.
`
testTextExpected40 = `# doctitle
This is a *Markdown* test document that
is meant to be rendered to text, with
the ultimate purpose of generating
nicely formatted email templates.
* list element one
* nested list element one, whose only
purpose is to have a very long
message so we can see how (and *if*)
the text wraps around onto multiple
lines with the proper indentation
* nested list element two
* list element two
* list element three
* more information on
https://wikipedia.org/.
## Level 2 header
Yet another long paragraph to showcase
the word wrapping capabilities of the
software. In theory the long lines
should be wrapped at around 75
characters, so as to generate nicely
formatted emails (the original text is
wrapped at 100 chars for the purpose of
showcasing this specific feature.
> This is a block quote, and as such we
> would like to see it indented by four
> spaces, but also maintaining the
> proper indentation on the lines
> following the first one, which is the
> feature tested by this snippet.
Code blocks, on the other hand, should have no line wrapping and should keep their own formatting.
Finally another paragraph to conclude
this test.
`
)
func runTest(t *testing.T, width int, expected string) {
r := NewTextRenderer(width)
output := string(bf.Run([]byte(testText), bf.WithRenderer(r)))
if diffs := cmp.Diff(expected, output); diffs != "" {
t.Errorf("mismatched rendered output:\n%s", diffs)
}
t.Logf("result:\n%s", output)
}
func Test_Text_60(t *testing.T) {
runTest(t, 60, testTextExpected60)
}
func Test_Text_40(t *testing.T) {
runTest(t, 40, testTextExpected40)
}
package message
import (
"bytes"
"crypto/rand"
"encoding/base64"
"errors"
"fmt"
"io"
"mime"
"mime/multipart"
"mime/quotedprintable"
"net/textproto"
"os"
"path/filepath"
"sort"
"strings"
"github.com/emersion/go-textwrapper"
)
var (
mimeDefaultBody = []byte("This is a multi-part message in MIME format.\r\n")
)
// Middleware is an interface for something that modifies a message
// (identified by the top-level Part).
type Middleware interface {
Process(*Part) (*Part, error)
}
// MiddlewareList is a list of Middleware instances.
type MiddlewareList []Middleware
// Add a Middleware to the list.
func (l *MiddlewareList) Add(m Middleware) {
*l = append(*l, m)
}
// Process a message. Implements the Middleware interface.
func (l MiddlewareList) Process(part *Part) (*Part, error) {
for _, m := range l {
var err error
part, err = m.Process(part)
if err != nil {
return nil, err
}
}
return part, nil
}
// Part is a MIME multipart entity. It can contain a body, or
// sub-parts. Use the New* methods to create one. An email message is
// represented by a tree of Part objects.
type Part struct {
Header textproto.MIMEHeader
Body []byte
Subparts []*Part
boundary string
}
// NewPart creates a new Part with the given header and body.
func NewPart(hdr textproto.MIMEHeader, body []byte) *Part {
return &Part{
Header: hdr,
Body: body,
}
}
// NewMultiPart creates a multipart entity. The content-type must be
// manually specified and must start with "multipart/".
func NewMultiPart(ctype string, parts ...*Part) *Part {
boundary := RandomBoundaryFn()
return &Part{
Header: textproto.MIMEHeader{
"Content-Type": []string{fmt.Sprintf("%s; boundary=\"%s\"", ctype, boundary)},
},
Body: mimeDefaultBody,
Subparts: parts,
boundary: boundary,
}
}
// NewText creates a text MIME part. Charset is assumed to be UTF-8,
// and quoted-printable encoding is used.
func NewText(ctype string, body []byte) *Part {
return &Part{
Header: textproto.MIMEHeader{
"Content-Type": []string{fmt.Sprintf("%s; charset=UTF-8", ctype)},
"Content-Transfer-Encoding": []string{"quoted-printable"},
},
Body: quopri(body),
}
}
// NewAttachment creates a MIME multipart object representing a file
// attachment. The filename is the desired name for the object in MIME
// headers, while path points at the local filesystem path.
func NewAttachment(filename, ctype, path string) (*Part, error) {
f, err := os.Open(path)
if err != nil {
return nil, err
}
defer f.Close()
var buf bytes.Buffer
enc := base64.NewEncoder(base64.StdEncoding, textwrapper.NewRFC822(&buf))
if _, err := io.Copy(enc, f); err != nil {
return nil, err
}
// Autodetect content-type if empty.
if ctype == "" {
ctype = mime.TypeByExtension(filepath.Ext(filename))
}
if ctype == "" {
ctype = "application/octet-stream"
}
return &Part{
Header: textproto.MIMEHeader{
"Content-Type": []string{fmt.Sprintf("%s; name=\"%s\"", ctype, filename)},