diff --git a/mail/helpers.go b/mail/helpers.go
new file mode 100644
index 0000000000000000000000000000000000000000..3b6aa838d01f04b99f5cace8570856b0a3f6ef4d
--- /dev/null
+++ b/mail/helpers.go
@@ -0,0 +1,32 @@
+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)
+}
diff --git a/mail/mail.go b/mail/mail.go
new file mode 100644
index 0000000000000000000000000000000000000000..dc814e7fc25b35a80f794c881c34aa1d5817cc99
--- /dev/null
+++ b/mail/mail.go
@@ -0,0 +1,135 @@
+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()
+}
diff --git a/mail/mail_test.go b/mail/mail_test.go
new file mode 100644
index 0000000000000000000000000000000000000000..2095a3b162bca60f811afdad921e5647549890da
--- /dev/null
+++ b/mail/mail_test.go
@@ -0,0 +1,359 @@
+package mail
+
+import (
+	"io/ioutil"
+	"os"
+	"path/filepath"
+	"strings"
+	"testing"
+	"time"
+
+	"github.com/google/go-cmp/cmp"
+
+	"git.autistici.org/ai3/go-common/mail/message"
+	"git.autistici.org/ai3/go-common/mail/template"
+)
+
+func setupTestEnv(t testing.TB, files map[string]string) (string, func()) {
+	tmpdir, err := ioutil.TempDir("", "")
+	if err != nil {
+		t.Fatal(err)
+	}
+
+	for path, contents := range files {
+		if err := ioutil.WriteFile(filepath.Join(tmpdir, path), []byte(contents), 0600); err != nil {
+			t.Fatal(err)
+		}
+	}
+
+	template.SetTemplateDirectory(tmpdir)
+	return tmpdir, func() {
+		os.RemoveAll(tmpdir)
+	}
+}
+
+func TestMail_Template(t *testing.T) {
+	_, cleanup := setupTestEnv(t, map[string]string{
+		"testmsg.en.md": "value: {{.value}}",
+	})
+	defer cleanup()
+
+	m, err := New(&Config{
+		SenderAddr: "me@localhost",
+	})
+	if err != nil {
+		t.Fatalf("New(): %v", err)
+	}
+
+	tpl, err := template.New("testmsg", "en", map[string]interface{}{
+		"value": 42,
+	})
+	if err != nil {
+		t.Fatalf("template.New(): %v", err)
+	}
+	txt := message.NewText("text/plain", tpl.Text())
+
+	msg, err := m.WithEnvelope(txt, "you@localhost", "Hello")
+	if err != nil {
+		t.Fatalf("Mailer.Envelope(): %v", err)
+	}
+
+	s, err := m.Render(msg)
+	if err != nil {
+		t.Fatalf("Mailer.Render(): %v", err)
+	}
+
+	expected := strings.Replace(`Content-Transfer-Encoding: quoted-printable
+Content-Type: text/plain; charset=UTF-8
+Date: Fri, 21 Nov 1997 09:55:06 -0600
+From: <me@localhost>
+Message-Id: <xxxxxx@localhost>
+Mime-Version: 1.0
+Subject: Hello
+To: <you@localhost>
+User-Agent: go-mailer/0.1
+
+value: 42
+
+`, "\n", "\r\n", -1)
+	if diffs := diffStr(expected, s); diffs != "" {
+		t.Errorf("unexpected output:\n%s", diffs)
+	}
+}
+
+func TestMail_TemplateMultipartAlternative(t *testing.T) {
+	_, cleanup := setupTestEnv(t, map[string]string{
+		"testmsg.en.md": "value: {{.value}}",
+	})
+	defer cleanup()
+
+	m, err := New(&Config{
+		SenderAddr: "me@localhost",
+	})
+	if err != nil {
+		t.Fatalf("New(): %v", err)
+	}
+
+	tpl, err := template.New("testmsg", "en", map[string]interface{}{
+		"value": 42,
+	})
+	if err != nil {
+		t.Fatalf("template.New(): %v", err)
+	}
+	txt1 := message.NewText("text/plain", tpl.Text())
+	txt2 := message.NewText("text/html", tpl.HTML())
+
+	mm := message.NewMultiPart("multipart/alternative", txt1, txt2)
+
+	msg, err := m.WithEnvelope(mm, "you@localhost", "Hello")
+	if err != nil {
+		t.Fatalf("Mailer.WithEnvelope(): %v", err)
+	}
+
+	s, err := m.Render(msg)
+	if err != nil {
+		t.Fatalf("Mailer.Render(): %v", err)
+	}
+
+	expected := strings.Replace(`Content-Type: multipart/alternative; boundary="xxxxxx"
+Date: Fri, 21 Nov 1997 09:55:06 -0600
+From: <me@localhost>
+Message-Id: <xxxxxx@localhost>
+Mime-Version: 1.0
+Subject: Hello
+To: <you@localhost>
+User-Agent: go-mailer/0.1
+
+This is a multi-part message in MIME format.
+--xxxxxx
+Content-Transfer-Encoding: quoted-printable
+Content-Type: text/plain; charset=UTF-8
+
+value: 42
+
+
+--xxxxxx
+Content-Transfer-Encoding: quoted-printable
+Content-Type: text/html; charset=UTF-8
+
+<p>value: 42</p>
+
+--xxxxxx--
+`, "\n", "\r\n", -1)
+	if diffs := diffStr(expected, s); diffs != "" {
+		t.Errorf("unexpected output:\n%s", diffs)
+	}
+}
+
+func TestMail_TemplateMultipartMixed(t *testing.T) {
+	dir, cleanup := setupTestEnv(t, map[string]string{
+		"testmsg.en.md":  "value: {{.value}}",
+		"attachment.gif": "GIF89abcdef",
+	})
+	defer cleanup()
+
+	m, err := New(&Config{
+		SenderAddr: "me@localhost",
+	})
+	if err != nil {
+		t.Fatalf("New(): %v", err)
+	}
+
+	tpl, err := template.New("testmsg", "en", map[string]interface{}{
+		"value": 42,
+	})
+	if err != nil {
+		t.Fatalf("template.New(): %v", err)
+	}
+	txt1 := message.NewText("text/plain", tpl.Text())
+
+	att1, err := message.NewAttachment("attachment.gif", "", filepath.Join(dir, "attachment.gif"))
+	if err != nil {
+		t.Fatalf("message.NewAttachment(): %v", err)
+	}
+
+	mm := message.NewMultiPart("multipart/mixed", txt1, att1)
+
+	msg, err := m.WithEnvelope(mm, "you@localhost", "Hello")
+	if err != nil {
+		t.Fatalf("Mailer.WithEnvelope(): %v", err)
+	}
+
+	s, err := m.Render(msg)
+	if err != nil {
+		t.Fatalf("Mailer.Render(): %v", err)
+	}
+
+	expected := strings.Replace(`Content-Type: multipart/mixed; boundary="xxxxxx"
+Date: Fri, 21 Nov 1997 09:55:06 -0600
+From: <me@localhost>
+Message-Id: <xxxxxx@localhost>
+Mime-Version: 1.0
+Subject: Hello
+To: <you@localhost>
+User-Agent: go-mailer/0.1
+
+This is a multi-part message in MIME format.
+--xxxxxx
+Content-Transfer-Encoding: quoted-printable
+Content-Type: text/plain; charset=UTF-8
+
+value: 42
+
+
+--xxxxxx
+Content-Disposition: attachment; filename="attachment.gif"
+Content-Transfer-Encoding: base64
+Content-Type: image/gif; name="attachment.gif"
+
+R0lGODlhYmNk
+--xxxxxx--
+`, "\n", "\r\n", -1)
+	if diffs := diffStr(expected, s); diffs != "" {
+		t.Errorf("unexpected output:\n%s", diffs)
+	}
+}
+
+func TestMail_PGP(t *testing.T) {
+	dir, cleanup := setupTestEnv(t, map[string]string{
+		"testmsg.en.md": "value: {{.value}}",
+		"secretkey":     testPGPKey,
+	})
+	defer cleanup()
+
+	m, err := New(&Config{
+		SenderAddr:     "me@localhost",
+		SigningKeyFile: filepath.Join(dir, "secretkey"),
+		SigningKeyID:   testPGPKeyID,
+	})
+	if err != nil {
+		t.Fatalf("New(): %v", err)
+	}
+
+	tpl, err := template.New("testmsg", "en", map[string]interface{}{
+		"value": 42,
+	})
+	if err != nil {
+		t.Fatalf("template.New(): %v", err)
+	}
+	txt := message.NewText("text/plain", tpl.Text())
+
+	msg, err := m.WithEnvelope(txt, "you@localhost", "Hello")
+	if err != nil {
+		t.Fatalf("Mailer.Envelope(): %v", err)
+	}
+
+	s, err := m.Render(msg)
+	if err != nil {
+		t.Fatalf("Mailer.Render(): %v", err)
+	}
+
+	// It's hard to actually verify the signature reliably (we
+	// should use some third-party method for that) - let's just
+	// check that there *is* a signature...
+	if !strings.Contains(s, "-----BEGIN PGP SIGNATURE-----") {
+		t.Error("the message does not seem to contain a signature")
+	}
+	t.Logf("\n%s", s)
+}
+
+var (
+	testPGPKeyID = "CB20487E357C7966"
+	testPGPKey   = `-----BEGIN PGP PRIVATE KEY BLOCK-----
+
+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
+=bX39
+-----END PGP PRIVATE KEY BLOCK-----
+`
+)
+
+func diffStr(a, b string) string {
+	al := strings.Split(strings.Replace(a, "\r", "", -1), "\n")
+	bl := strings.Split(strings.Replace(b, "\r", "", -1), "\n")
+	return cmp.Diff(al, bl)
+}
+
+func init() {
+	message.RandomBoundaryFn = func() string {
+		return "xxxxxx"
+	}
+	currentTimeFn = func() time.Time {
+		return time.Date(1997, 11, 21, 9, 55, 6, 0, time.FixedZone("", -6*60*60))
+	}
+}
diff --git a/mail/mdtext/text.go b/mail/mdtext/text.go
new file mode 100644
index 0000000000000000000000000000000000000000..4bafae12113dc0bb55bc45f72b620da4ec3bd3cb
--- /dev/null
+++ b/mail/mdtext/text.go
@@ -0,0 +1,215 @@
+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) {
+}
diff --git a/mail/mdtext/text_test.go b/mail/mdtext/text_test.go
new file mode 100644
index 0000000000000000000000000000000000000000..87af0d559cbb6328f9cc99ee8aebeaca3c83c19c
--- /dev/null
+++ b/mail/mdtext/text_test.go
@@ -0,0 +1,146 @@
+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)
+}
diff --git a/mail/message/message.go b/mail/message/message.go
new file mode 100644
index 0000000000000000000000000000000000000000..5e466b39fe531f7eeebac3da11e4ddf3db3f6b97
--- /dev/null
+++ b/mail/message/message.go
@@ -0,0 +1,226 @@
+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)},
+			"Content-Disposition":       []string{fmt.Sprintf("attachment; filename=\"%s\"", filename)},
+			"Content-Transfer-Encoding": []string{"base64"},
+		},
+		Body: buf.Bytes(),
+	}, nil
+}
+
+// Add a sub-Part to this object.
+func (p *Part) Add(subp *Part) error {
+	if !p.isMultipart() {
+		return errors.New("not a multipart container")
+	}
+	p.Subparts = append(p.Subparts, subp)
+	return nil
+}
+
+func (p *Part) isMultipart() bool {
+	return strings.HasPrefix(p.Header.Get("Content-Type"), "multipart/")
+}
+
+type strList []string
+
+func (l strList) Len() int           { return len(l) }
+func (l strList) Swap(i, j int)      { l[i], l[j] = l[j], l[i] }
+func (l strList) Less(i, j int) bool { return l[i] < l[j] }
+
+func (p *Part) writeHeader(w io.Writer) {
+	// Sort the keys for stable output.
+	var keys []string
+	for k := range p.Header {
+		keys = append(keys, k)
+	}
+	sort.Sort(strList(keys))
+
+	for _, k := range keys {
+		for _, v := range p.Header[k] {
+			fmt.Fprintf(w, "%s: %s\r\n", k, v)
+		}
+	}
+	io.WriteString(w, "\r\n") // nolint
+}
+
+func (p *Part) render(w io.Writer, writeHeader bool) error {
+	if writeHeader {
+		p.writeHeader(w)
+	}
+	if _, err := w.Write(p.Body); err != nil {
+		return err
+	}
+	if p.isMultipart() {
+		mw := multipart.NewWriter(w)
+		if err := mw.SetBoundary(p.boundary); err != nil {
+			return err
+		}
+		for _, sub := range p.Subparts {
+			pw, err := mw.CreatePart(sub.Header)
+			if err != nil {
+				return err
+			}
+			if err := sub.render(pw, false); err != nil {
+				return err
+			}
+		}
+		mw.Close()
+	}
+	return nil
+}
+
+// Render the message to an io.Writer.
+func (p *Part) Render(w io.Writer) error {
+	return p.render(w, true)
+}
+
+// func (p *Part) String() string {
+// 	var buf bytes.Buffer
+// 	if err := p.render(&buf, true); err != nil {
+// 		return "<error>"
+// 	}
+// 	return buf.String()
+// }
+
+// RandomBoundaryFn points at the function used to generate MIME
+// boundaries (by default RandomBoundary). Allows us to stub it out
+// for testing.
+var RandomBoundaryFn func() string = RandomBoundary
+
+// RandomBoundary returns a pseudorandom sequence of bytes that is
+// suitable for a MIME boundary.
+func RandomBoundary() string {
+	buf := make([]byte, 30)
+	_, err := io.ReadFull(rand.Reader, buf)
+	if err != nil {
+		panic(err)
+	}
+	return fmt.Sprintf("%x", buf)
+}
+
+func quopri(b []byte) []byte {
+	var buf bytes.Buffer
+	qp := quotedprintable.NewWriter(&buf)
+	qp.Write(b) // nolint
+	qp.Close()
+	return buf.Bytes()
+}
diff --git a/mail/message/message_test.go b/mail/message/message_test.go
new file mode 100644
index 0000000000000000000000000000000000000000..32ecffd07965fa92015e3a8e6319733958518f29
--- /dev/null
+++ b/mail/message/message_test.go
@@ -0,0 +1,10 @@
+package message
+
+import "testing"
+
+func TestRandomBoundary(t *testing.T) {
+	s := RandomBoundary()
+	if len(s) < 30 {
+		t.Errorf("boundary too short: %s", s)
+	}
+}
diff --git a/mail/pgp/pgp.go b/mail/pgp/pgp.go
new file mode 100644
index 0000000000000000000000000000000000000000..3b1abb22a21a4b27d09cae262b3fd55e524400f1
--- /dev/null
+++ b/mail/pgp/pgp.go
@@ -0,0 +1,154 @@
+package pgp
+
+import (
+	"bytes"
+	"encoding/base64"
+	"errors"
+	"fmt"
+	"net/mail"
+	"net/textproto"
+	"os"
+	"strconv"
+
+	"golang.org/x/crypto/openpgp"
+
+	"git.autistici.org/ai3/go-common/mail/message"
+)
+
+// Signer is a message.Middleware implementation that can
+// transparently sign MIME messages and turn them into PGP/MIME
+// messages.
+type Signer struct {
+	sender     *mail.Address
+	signKey    *openpgp.Entity
+	encodedKey string
+}
+
+// NewSigner creates a new Signer. The key ID can be the empty string,
+// in which case the first usable key found in the keyring will be
+// automatically selected. If specified, the key ID must be in
+// long-form hex format.
+func NewSigner(keyFile, keyID string, sender *mail.Address) (*Signer, error) {
+	signKey, err := loadPGPKey(keyFile, keyID)
+	if err != nil {
+		return nil, err
+	}
+
+	var buf bytes.Buffer
+	basew := base64.NewEncoder(base64.StdEncoding, &buf)
+	if err := signKey.PrimaryKey.Serialize(basew); err != nil {
+		return nil, err
+	}
+
+	return &Signer{
+		sender:     sender,
+		signKey:    signKey,
+		encodedKey: buf.String(),
+	}, nil
+}
+
+func loadPGPKey(path, keyID string) (*openpgp.Entity, error) {
+	f, err := os.Open(path)
+	if err != nil {
+		return nil, err
+	}
+	defer f.Close()
+
+	keyring, err := openpgp.ReadKeyRing(f)
+	if err != nil {
+		f.Seek(0, 0) // nolint
+		keyring, err = openpgp.ReadArmoredKeyRing(f)
+		if err != nil {
+			return nil, err
+		}
+	}
+
+	// The key ID must be converted to uint64.
+	uKeyID, err := strconv.ParseUint(keyID, 16, 64)
+	if err != nil {
+		return nil, fmt.Errorf("error parsing key ID: %v", err)
+	}
+
+	var key *openpgp.Key
+	if keyID != "" {
+		if keys := keyring.KeysById(uKeyID); len(keys) > 0 {
+			key = &keys[0]
+		}
+	} else {
+		if keys := keyring.DecryptionKeys(); len(keys) > 0 {
+			key = &keys[0]
+		}
+	}
+	if key == nil {
+		return nil, errors.New("unable to find key in keyring")
+	}
+
+	return key.Entity, nil
+}
+
+func (s *Signer) pgpSign(data []byte) ([]byte, error) {
+	var buf bytes.Buffer
+	if err := openpgp.ArmoredDetachSign(&buf, s.signKey, bytes.NewReader(data), nil); err != nil {
+		return nil, err
+	}
+	return buf.Bytes(), nil
+}
+
+// Split headers that go inside the signed message and those that
+// should stay outside (on the wrapped message).
+func (s *Signer) pgpSplitHeaders(hdr textproto.MIMEHeader) (textproto.MIMEHeader, textproto.MIMEHeader) {
+	inner := make(textproto.MIMEHeader)
+	outer := make(textproto.MIMEHeader)
+	outer.Set("Openpgp", "preference=signencrypt")
+	outer.Set("Autocrypt", fmt.Sprintf("addr=%s; keydata=%s", s.sender.Address, s.encodedKey))
+
+	for k, vv := range hdr {
+		switch k {
+		case "Content-Type", "Content-Transfer-Encoding", "Content-Disposition", "Content-Description":
+			inner[k] = vv
+		case "From", "To", "Subject", "Message-Id":
+			inner[k] = vv
+			outer[k] = vv
+		default:
+			outer[k] = vv
+		}
+	}
+	return inner, outer
+}
+
+// Process a message.Part, signing it with our PGP key and creating a
+// PGP/MIME message. Implements the message.Middleware interface.
+func (s *Signer) Process(p *message.Part) (*message.Part, error) {
+	// Split the headers and apply PGP headers on the container.
+	// Modify the Part before signing it!
+	innerHdr, outerHdr := s.pgpSplitHeaders(p.Header)
+	p.Header = innerHdr
+
+	// We need to serialize the message in order to sign it.
+	var buf bytes.Buffer
+	if err := p.Render(&buf); err != nil {
+		return nil, err
+	}
+
+	signature, err := s.pgpSign(buf.Bytes())
+	if err != nil {
+		return nil, err
+	}
+
+	wrap := message.NewMultiPart(
+		"multipart/signed; micalg=pgp-sha256; protocol=\"application/pgp-signature\"",
+		p,
+		&message.Part{
+			Header: textproto.MIMEHeader{
+				"Content-Type":        []string{"application/pgp-signature; name=\"signature.asc\""},
+				"Content-Description": []string{"OpenPGP digital signature"},
+				"Content-Disposition": []string{"attachment; filename=\"signature.asc\""},
+			},
+			Body: signature,
+		},
+	)
+	for k, vv := range outerHdr {
+		wrap.Header[k] = vv
+	}
+	return wrap, nil
+}
diff --git a/mail/template/template.go b/mail/template/template.go
new file mode 100644
index 0000000000000000000000000000000000000000..f586baff654e1e49b578fde6caa5920480ced945
--- /dev/null
+++ b/mail/template/template.go
@@ -0,0 +1,110 @@
+package template
+
+import (
+	"bytes"
+	"errors"
+	"fmt"
+	"os"
+	"path/filepath"
+	"sync"
+	"text/template"
+
+	"git.autistici.org/ai3/go-common/mail/mdtext"
+	bf "gopkg.in/russross/blackfriday.v2"
+)
+
+var (
+	// TemplateDirectory points at the directory containing templates.
+	TemplateDirectory = "/etc/ai/templates/mail"
+
+	// DefaultLanguage is the fallback language.
+	DefaultLanguage = "en"
+
+	// Global, lazily-initialized, shared template registry.
+	templates      *template.Template
+	templateLoadMx sync.Mutex
+
+	// Line width of email plain text bodies.
+	emailLineWidth = 75
+)
+
+func init() {
+	if d := os.Getenv("MAIL_TEMPLATE_DIR"); d != "" {
+		TemplateDirectory = d
+	}
+}
+
+func loadTemplates() (err error) {
+	templateLoadMx.Lock()
+	defer templateLoadMx.Unlock()
+	if templates != nil {
+		return
+	}
+	templates, err = template.ParseGlob(filepath.Join(TemplateDirectory, "*.??.md"))
+	return
+}
+
+// SetTemplateDirectory can be used to (re)set the TemplateDirectory
+// once the program has started, so it's mostly useful for tests.
+func SetTemplateDirectory(d string) {
+	templateLoadMx.Lock()
+	templates = nil
+	TemplateDirectory = d
+	templateLoadMx.Unlock()
+}
+
+func findTemplate(name, lang string) *template.Template {
+	if lang == "" {
+		lang = DefaultLanguage
+	}
+	tpl := templates.Lookup(fmt.Sprintf("%s.%s.md", name, lang))
+	if tpl == nil && lang != DefaultLanguage {
+		return findTemplate(name, DefaultLanguage)
+	}
+	return tpl
+}
+
+// Template represents a templated message body.
+type Template struct {
+	body []byte
+}
+
+// New loads a template with the specified name and language,
+// and renders it with the given values.
+//
+// Templates are Markdown files loaded from the TemplateDirectory
+// (which can be overridden at runtime by setting the environment
+// variable MAIL_TEMPLATE_DIR), and must follow the <name>.<lang>.md
+// naming pattern. Such templates can then be rendered to plain text
+// or HTML.
+//
+// If a template with the desired language does not exist, we fall
+// back to using DefaultLanguage.
+func New(name, lang string, values map[string]interface{}) (*Template, error) {
+	if err := loadTemplates(); err != nil {
+		return nil, err
+	}
+	tpl := findTemplate(name, lang)
+	if tpl == nil {
+		return nil, errors.New("template not found")
+	}
+
+	var buf bytes.Buffer
+	if err := tpl.Execute(&buf, values); err != nil {
+		return nil, err
+	}
+
+	return &Template{
+		body: buf.Bytes(),
+	}, nil
+}
+
+// Text renders the template body to plain text.
+func (t *Template) Text() []byte {
+	return bf.Run(t.body, bf.WithRenderer(mdtext.NewTextRenderer(emailLineWidth)))
+}
+
+// HTML renders the template body to HTML.
+func (t *Template) HTML() []byte {
+	return bf.Run(t.body)
+}