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) +}