diff --git a/go.mod b/go.mod
index 3747554..9a0f035 100644
--- a/go.mod
+++ b/go.mod
@@ -11,7 +11,7 @@ require (
github.com/valyala/bytebufferpool v1.0.0 // indirect
github.com/valyala/fasttemplate v1.2.2 // indirect
golang.org/x/crypto v0.26.0
- golang.org/x/net v0.28.0 // indirect
+ golang.org/x/net v0.28.0
golang.org/x/sys v0.24.0 // indirect
golang.org/x/text v0.17.0 // indirect
golang.org/x/time v0.6.0 // indirect
@@ -65,6 +65,7 @@ require (
)
require (
+ github.com/domodwyer/mailyak/v3 v3.6.2
github.com/golang-jwt/jwt/v5 v5.2.1
github.com/grpc-ecosystem/grpc-gateway/v2 v2.22.0
github.com/improbable-eng/grpc-web v0.15.0
diff --git a/go.sum b/go.sum
index e39acf0..8d2bde6 100644
--- a/go.sum
+++ b/go.sum
@@ -52,6 +52,8 @@ github.com/desertbit/timer v0.0.0-20180107155436-c41aec40b27f/go.mod h1:xH/i4TFM
github.com/desertbit/timer v1.0.1 h1:yRpYNn5Vaaj6QXecdLMPMJsW81JLiI1eokUft5nBmeo=
github.com/desertbit/timer v1.0.1/go.mod h1:htRrYeY5V/t4iu1xCJ5XsQvp4xve8QulXXctAzxqcwE=
github.com/dgrijalva/jwt-go v3.2.0+incompatible/go.mod h1:E3ru+11k8xSBh+hMPgOLZmtrrCbhqsmaPHjLKYnJCaQ=
+github.com/domodwyer/mailyak/v3 v3.6.2 h1:x3tGMsyFhTCaxp6ycgR0FE/bu5QiNp+hetUuCOBXMn8=
+github.com/domodwyer/mailyak/v3 v3.6.2/go.mod h1:lOm/u9CyCVWHeaAmHIdF4RiKVxKUT/H5XX10lIKAL6c=
github.com/dustin/go-humanize v0.0.0-20171111073723-bb3d318650d4/go.mod h1:HtrtbFcZ19U5GC7JDqmcUSB87Iq5E25KnS6fMYU6eOk=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
diff --git a/internal/mailer/html2text.go b/internal/mailer/html2text.go
new file mode 100644
index 0000000..0bf07a1
--- /dev/null
+++ b/internal/mailer/html2text.go
@@ -0,0 +1,103 @@
+package mailer
+
+import (
+ "regexp"
+ "strings"
+
+ "golang.org/x/net/html"
+)
+
+var whitespaceRegex = regexp.MustCompile(`\s+`)
+
+// Very rudimentary auto HTML to Text mail body converter.
+//
+// Caveats:
+// - This method doesn't check for correctness of the HTML document.
+// - Links will be converted to "[text](url)" format.
+// - List items (
) are prefixed with "- ".
+// - Indentation is stripped (both tabs and spaces).
+// - Trailing spaces are preserved.
+// - Multiple consequence newlines are collapsed as one unless multiple
tags are used.
+func html2Text(htmlDocument string) (string, error) {
+ doc, err := html.Parse(strings.NewReader(htmlDocument))
+ if err != nil {
+ return "", err
+ }
+
+ var builder strings.Builder
+ var canAddNewLine bool
+
+ // see https://pkg.go.dev/golang.org/x/net/html#Parse
+ var f func(*html.Node, *strings.Builder)
+ f = func(n *html.Node, activeBuilder *strings.Builder) {
+ isLink := n.Type == html.ElementNode && n.Data == "a"
+
+ if isLink {
+ var linkBuilder strings.Builder
+ activeBuilder = &linkBuilder
+ } else if activeBuilder == nil {
+ activeBuilder = &builder
+ }
+
+ switch n.Type {
+ case html.TextNode:
+ txt := whitespaceRegex.ReplaceAllString(n.Data, " ")
+
+ // the prev node has new line so it is safe to trim the indentation
+ if !canAddNewLine {
+ txt = strings.TrimLeft(txt, " ")
+ }
+
+ if txt != "" {
+ activeBuilder.WriteString(txt)
+ canAddNewLine = true
+ }
+ case html.ElementNode:
+ if n.Data == "br" {
+ // always write new lines when
tag is used
+ activeBuilder.WriteString("\r\n")
+ canAddNewLine = false
+ }
+ // prefix list items with dash
+ if n.Data == "li" {
+ activeBuilder.WriteString("- ")
+ }
+ }
+
+ for c := n.FirstChild; c != nil; c = c.NextSibling {
+ if c.Type != html.ElementNode {
+ f(c, activeBuilder)
+ }
+ }
+
+ // format links as [label](href)
+ if isLink {
+ linkTxt := strings.TrimSpace(activeBuilder.String())
+ if linkTxt == "" {
+ linkTxt = "LINK"
+ }
+
+ builder.WriteString("[")
+ builder.WriteString(linkTxt)
+ builder.WriteString("]")
+
+ // link href attr extraction
+ for _, a := range n.Attr {
+ if a.Key == "href" {
+ if a.Val != "" {
+ builder.WriteString("(")
+ builder.WriteString(a.Val)
+ builder.WriteString(")")
+ }
+ break
+ }
+ }
+
+ activeBuilder.Reset()
+ }
+ }
+
+ f(doc, &builder)
+
+ return strings.TrimSpace(builder.String()), nil
+}
diff --git a/internal/mailer/mailer.go b/internal/mailer/mailer.go
new file mode 100644
index 0000000..b64da89
--- /dev/null
+++ b/internal/mailer/mailer.go
@@ -0,0 +1,43 @@
+package mailer
+
+import (
+ "io"
+ "net/mail"
+)
+
+// Message defines a generic email message struct.
+type Message struct {
+ From mail.Address `json:"from"`
+ To []mail.Address `json:"to"`
+ Bcc []mail.Address `json:"bcc"`
+ Cc []mail.Address `json:"cc"`
+ Subject string `json:"subject"`
+ HTML string `json:"html"`
+ Text string `json:"text"`
+ Headers map[string]string `json:"headers"`
+ Attachments map[string]io.Reader `json:"attachments"`
+}
+
+// Mailer defines a base mail client interface.
+type Mailer interface {
+ // Send sends an email with the provided Message.
+ Send(message *Message) error
+}
+
+// addressesToStrings converts the provided address to a list of serialized RFC 5322 strings.
+//
+// To export only the email part of mail.Address, you can set withName to false.
+func addressesToStrings(addresses []mail.Address, withName bool) []string {
+ result := make([]string, len(addresses))
+
+ for i, addr := range addresses {
+ if withName && addr.Name != "" {
+ result[i] = addr.String()
+ } else {
+ // keep only the email part to avoid wrapping in angle-brackets
+ result[i] = addr.Address
+ }
+ }
+
+ return result
+}
diff --git a/internal/mailer/sendmail.go b/internal/mailer/sendmail.go
new file mode 100644
index 0000000..6826a03
--- /dev/null
+++ b/internal/mailer/sendmail.go
@@ -0,0 +1,78 @@
+package mailer
+
+import (
+ "bytes"
+ "errors"
+ "mime"
+ "net/http"
+ "os/exec"
+ "strings"
+)
+
+var _ Mailer = (*Sendmail)(nil)
+
+// Sendmail implements [mailer.Mailer] interface and defines a mail
+// client that sends emails via the "sendmail" *nix command.
+//
+// This client is usually recommended only for development and testing.
+type Sendmail struct {
+}
+
+// Send implements `mailer.Mailer` interface.
+func (c *Sendmail) Send(m *Message) error {
+ toAddresses := addressesToStrings(m.To, false)
+
+ headers := make(http.Header)
+ headers.Set("Subject", mime.QEncoding.Encode("utf-8", m.Subject))
+ headers.Set("From", m.From.String())
+ headers.Set("Content-Type", "text/html; charset=UTF-8")
+ headers.Set("To", strings.Join(toAddresses, ","))
+
+ cmdPath, err := findSendmailPath()
+ if err != nil {
+ return err
+ }
+
+ var buffer bytes.Buffer
+
+ // write
+ // ---
+ if err := headers.Write(&buffer); err != nil {
+ return err
+ }
+ if _, err := buffer.Write([]byte("\r\n")); err != nil {
+ return err
+ }
+ if m.HTML != "" {
+ if _, err := buffer.Write([]byte(m.HTML)); err != nil {
+ return err
+ }
+ } else {
+ if _, err := buffer.Write([]byte(m.Text)); err != nil {
+ return err
+ }
+ }
+ // ---
+
+ sendmail := exec.Command(cmdPath, strings.Join(toAddresses, ","))
+ sendmail.Stdin = &buffer
+
+ return sendmail.Run()
+}
+
+func findSendmailPath() (string, error) {
+ options := []string{
+ "/usr/sbin/sendmail",
+ "/usr/bin/sendmail",
+ "sendmail",
+ }
+
+ for _, option := range options {
+ path, err := exec.LookPath(option)
+ if err == nil {
+ return path, err
+ }
+ }
+
+ return "", errors.New("failed to locate a sendmail executable path")
+}
diff --git a/internal/mailer/smtp.go b/internal/mailer/smtp.go
new file mode 100644
index 0000000..58bbade
--- /dev/null
+++ b/internal/mailer/smtp.go
@@ -0,0 +1,196 @@
+package mailer
+
+import (
+ "errors"
+ "fmt"
+ "net/smtp"
+ "strings"
+
+ "github.com/domodwyer/mailyak/v3"
+ "github.com/google/uuid"
+)
+
+var _ Mailer = (*SmtpClient)(nil)
+
+const (
+ SmtpAuthPlain = "PLAIN"
+ SmtpAuthLogin = "LOGIN"
+)
+
+// Deprecated: Use directly the SmtpClient struct literal.
+//
+// NewSmtpClient creates new SmtpClient with the provided configuration.
+func NewSmtpClient(
+ host string,
+ port int,
+ username string,
+ password string,
+ tls bool,
+) *SmtpClient {
+ return &SmtpClient{
+ Host: host,
+ Port: port,
+ Username: username,
+ Password: password,
+ Tls: tls,
+ }
+}
+
+// SmtpClient defines a SMTP mail client structure that implements
+// `mailer.Mailer` interface.
+type SmtpClient struct {
+ Host string
+ Port int
+ Username string
+ Password string
+ Tls bool
+
+ // SMTP auth method to use
+ // (if not explicitly set, defaults to "PLAIN")
+ AuthMethod string
+
+ // LocalName is optional domain name used for the EHLO/HELO exchange
+ // (if not explicitly set, defaults to "localhost").
+ //
+ // This is required only by some SMTP servers, such as Gmail SMTP-relay.
+ LocalName string
+}
+
+// Send implements `mailer.Mailer` interface.
+func (c *SmtpClient) Send(m *Message) error {
+ var smtpAuth smtp.Auth
+ if c.Username != "" || c.Password != "" {
+ switch c.AuthMethod {
+ case SmtpAuthLogin:
+ smtpAuth = &smtpLoginAuth{c.Username, c.Password}
+ default:
+ smtpAuth = smtp.PlainAuth("", c.Username, c.Password, c.Host)
+ }
+ }
+
+ // create mail instance
+ var yak *mailyak.MailYak
+ if c.Tls {
+ var tlsErr error
+ yak, tlsErr = mailyak.NewWithTLS(fmt.Sprintf("%s:%d", c.Host, c.Port), smtpAuth, nil)
+ if tlsErr != nil {
+ return tlsErr
+ }
+ } else {
+ yak = mailyak.New(fmt.Sprintf("%s:%d", c.Host, c.Port), smtpAuth)
+ }
+
+ if c.LocalName != "" {
+ yak.LocalName(c.LocalName)
+ }
+
+ if m.From.Name != "" {
+ yak.FromName(m.From.Name)
+ }
+ yak.From(m.From.Address)
+ yak.Subject(m.Subject)
+ yak.HTML().Set(m.HTML)
+
+ if m.Text == "" {
+ // try to generate a plain text version of the HTML
+ if plain, err := html2Text(m.HTML); err == nil {
+ yak.Plain().Set(plain)
+ }
+ } else {
+ yak.Plain().Set(m.Text)
+ }
+
+ if len(m.To) > 0 {
+ yak.To(addressesToStrings(m.To, true)...)
+ }
+
+ if len(m.Bcc) > 0 {
+ yak.Bcc(addressesToStrings(m.Bcc, true)...)
+ }
+
+ if len(m.Cc) > 0 {
+ yak.Cc(addressesToStrings(m.Cc, true)...)
+ }
+
+ // add attachements (if any)
+ for name, data := range m.Attachments {
+ yak.Attach(name, data)
+ }
+
+ // add custom headers (if any)
+ var hasMessageId bool
+ for k, v := range m.Headers {
+ if strings.EqualFold(k, "Message-ID") {
+ hasMessageId = true
+ }
+ yak.AddHeader(k, v)
+ }
+ if !hasMessageId {
+ // add a default message id if missing
+ fromParts := strings.Split(m.From.Address, "@")
+ if len(fromParts) == 2 {
+ yak.AddHeader("Message-ID", fmt.Sprintf("<%s@%s>",
+ uuid.New().String(),
+ fromParts[1],
+ ))
+ }
+ }
+
+ return yak.Send()
+}
+
+// -------------------------------------------------------------------
+// AUTH LOGIN
+// -------------------------------------------------------------------
+
+var _ smtp.Auth = (*smtpLoginAuth)(nil)
+
+// smtpLoginAuth defines an AUTH that implements the LOGIN authentication mechanism.
+//
+// AUTH LOGIN is obsolete[1] but some mail services like outlook requires it [2].
+//
+// NB!
+// It will only send the credentials if the connection is using TLS or is connected to localhost.
+// Otherwise authentication will fail with an error, without sending the credentials.
+//
+// [1]: https://github.com/golang/go/issues/40817
+// [2]: https://support.microsoft.com/en-us/office/outlook-com-no-longer-supports-auth-plain-authentication-07f7d5e9-1697-465f-84d2-4513d4ff0145?ui=en-us&rs=en-us&ad=us
+type smtpLoginAuth struct {
+ username, password string
+}
+
+// Start initializes an authentication with the server.
+//
+// It is part of the [smtp.Auth] interface.
+func (a *smtpLoginAuth) Start(server *smtp.ServerInfo) (string, []byte, error) {
+ // Must have TLS, or else localhost server.
+ // Note: If TLS is not true, then we can't trust ANYTHING in ServerInfo.
+ // In particular, it doesn't matter if the server advertises LOGIN auth.
+ // That might just be the attacker saying
+ // "it's ok, you can trust me with your password."
+ if !server.TLS && !isLocalhost(server.Name) {
+ return "", nil, errors.New("unencrypted connection")
+ }
+
+ return "LOGIN", nil, nil
+}
+
+// Next "continues" the auth process by feeding the server with the requested data.
+//
+// It is part of the [smtp.Auth] interface.
+func (a *smtpLoginAuth) Next(fromServer []byte, more bool) ([]byte, error) {
+ if more {
+ switch strings.ToLower(string(fromServer)) {
+ case "username:":
+ return []byte(a.username), nil
+ case "password:":
+ return []byte(a.password), nil
+ }
+ }
+
+ return nil, nil
+}
+
+func isLocalhost(name string) bool {
+ return name == "localhost" || name == "127.0.0.1" || name == "::1"
+}
diff --git a/internal/mailer/smtp_test.go b/internal/mailer/smtp_test.go
new file mode 100644
index 0000000..072ae14
--- /dev/null
+++ b/internal/mailer/smtp_test.go
@@ -0,0 +1,164 @@
+package mailer
+
+import (
+ "net/smtp"
+ "testing"
+)
+
+func TestLoginAuthStart(t *testing.T) {
+ auth := smtpLoginAuth{username: "test", password: "123456"}
+
+ scenarios := []struct {
+ name string
+ serverInfo *smtp.ServerInfo
+ expectError bool
+ }{
+ {
+ "localhost without tls",
+ &smtp.ServerInfo{TLS: false, Name: "localhost"},
+ false,
+ },
+ {
+ "localhost with tls",
+ &smtp.ServerInfo{TLS: true, Name: "localhost"},
+ false,
+ },
+ {
+ "127.0.0.1 without tls",
+ &smtp.ServerInfo{TLS: false, Name: "127.0.0.1"},
+ false,
+ },
+ {
+ "127.0.0.1 with tls",
+ &smtp.ServerInfo{TLS: false, Name: "127.0.0.1"},
+ false,
+ },
+ {
+ "::1 without tls",
+ &smtp.ServerInfo{TLS: false, Name: "::1"},
+ false,
+ },
+ {
+ "::1 with tls",
+ &smtp.ServerInfo{TLS: false, Name: "::1"},
+ false,
+ },
+ {
+ "non-localhost without tls",
+ &smtp.ServerInfo{TLS: false, Name: "example.com"},
+ true,
+ },
+ {
+ "non-localhost with tls",
+ &smtp.ServerInfo{TLS: true, Name: "example.com"},
+ false,
+ },
+ }
+
+ for _, s := range scenarios {
+ method, resp, err := auth.Start(s.serverInfo)
+
+ hasErr := err != nil
+ if hasErr != s.expectError {
+ t.Fatalf("[%s] Expected hasErr %v, got %v", s.name, s.expectError, hasErr)
+ }
+
+ if hasErr {
+ continue
+ }
+
+ if len(resp) != 0 {
+ t.Fatalf("[%s] Expected empty data response, got %v", s.name, resp)
+ }
+
+ if method != "LOGIN" {
+ t.Fatalf("[%s] Expected LOGIN, got %v", s.name, method)
+ }
+ }
+}
+
+func TestLoginAuthNext(t *testing.T) {
+ auth := smtpLoginAuth{username: "test", password: "123456"}
+
+ {
+ // example|false
+ r1, err := auth.Next([]byte("example:"), false)
+ if err != nil {
+ t.Fatalf("[example|false] Unexpected error %v", err)
+ }
+ if len(r1) != 0 {
+ t.Fatalf("[example|false] Expected empty part, got %v", r1)
+ }
+
+ // example|true
+ r2, err := auth.Next([]byte("example:"), true)
+ if err != nil {
+ t.Fatalf("[example|true] Unexpected error %v", err)
+ }
+ if len(r2) != 0 {
+ t.Fatalf("[example|true] Expected empty part, got %v", r2)
+ }
+ }
+
+ // ---------------------------------------------------------------
+
+ {
+ // username:|false
+ r1, err := auth.Next([]byte("username:"), false)
+ if err != nil {
+ t.Fatalf("[username|false] Unexpected error %v", err)
+ }
+ if len(r1) != 0 {
+ t.Fatalf("[username|false] Expected empty part, got %v", r1)
+ }
+
+ // username:|true
+ r2, err := auth.Next([]byte("username:"), true)
+ if err != nil {
+ t.Fatalf("[username|true] Unexpected error %v", err)
+ }
+ if str := string(r2); str != auth.username {
+ t.Fatalf("[username|true] Expected %s, got %s", auth.username, str)
+ }
+
+ // uSeRnAmE:|true
+ r3, err := auth.Next([]byte("uSeRnAmE:"), true)
+ if err != nil {
+ t.Fatalf("[uSeRnAmE|true] Unexpected error %v", err)
+ }
+ if str := string(r3); str != auth.username {
+ t.Fatalf("[uSeRnAmE|true] Expected %s, got %s", auth.username, str)
+ }
+ }
+
+ // ---------------------------------------------------------------
+
+ {
+ // password:|false
+ r1, err := auth.Next([]byte("password:"), false)
+ if err != nil {
+ t.Fatalf("[password|false] Unexpected error %v", err)
+ }
+ if len(r1) != 0 {
+ t.Fatalf("[password|false] Expected empty part, got %v", r1)
+ }
+
+ // password:|true
+ r2, err := auth.Next([]byte("password:"), true)
+ if err != nil {
+ t.Fatalf("[password|true] Unexpected error %v", err)
+ }
+ if str := string(r2); str != auth.password {
+ t.Fatalf("[password|true] Expected %s, got %s", auth.password, str)
+ }
+
+ // pAsSwOrD:|true
+ r3, err := auth.Next([]byte("pAsSwOrD:"), true)
+ if err != nil {
+ t.Fatalf("[pAsSwOrD|true] Unexpected error %v", err)
+ }
+ if str := string(r3); str != auth.password {
+ t.Fatalf("[pAsSwOrD|true] Expected %s, got %s", auth.password, str)
+ }
+ }
+}