Adding upstream version 0.28.1.
Signed-off-by: Daniel Baumann <daniel@debian.org>
This commit is contained in:
parent
88f1d47ab6
commit
e28c88ef14
933 changed files with 194711 additions and 0 deletions
118
tools/mailer/html2text.go
Normal file
118
tools/mailer/html2text.go
Normal file
|
@ -0,0 +1,118 @@
|
|||
package mailer
|
||||
|
||||
import (
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"github.com/pocketbase/pocketbase/tools/list"
|
||||
"golang.org/x/net/html"
|
||||
)
|
||||
|
||||
var whitespaceRegex = regexp.MustCompile(`\s+`)
|
||||
|
||||
var tagsToSkip = []string{
|
||||
"style", "script", "iframe", "applet", "object", "svg", "img",
|
||||
"button", "form", "textarea", "input", "select", "option", "template",
|
||||
}
|
||||
|
||||
var inlineTags = []string{
|
||||
"a", "span", "small", "strike", "strong",
|
||||
"sub", "sup", "em", "b", "u", "i",
|
||||
}
|
||||
|
||||
// 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 (<li>) are prefixed with "- ".
|
||||
// - Indentation is stripped (both tabs and spaces).
|
||||
// - Trailing spaces are preserved.
|
||||
// - Multiple consequence newlines are collapsed as one unless multiple <br> 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 <br> tag is used
|
||||
activeBuilder.WriteString("\r\n")
|
||||
canAddNewLine = false
|
||||
} else if canAddNewLine && !list.ExistInSlice(n.Data, inlineTags) {
|
||||
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 || !list.ExistInSlice(c.Data, tagsToSkip) {
|
||||
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
|
||||
}
|
131
tools/mailer/html2text_test.go
Normal file
131
tools/mailer/html2text_test.go
Normal file
|
@ -0,0 +1,131 @@
|
|||
package mailer
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestHTML2Text(t *testing.T) {
|
||||
scenarios := []struct {
|
||||
html string
|
||||
expected string
|
||||
}{
|
||||
{
|
||||
"",
|
||||
"",
|
||||
},
|
||||
{
|
||||
"ab c",
|
||||
"ab c",
|
||||
},
|
||||
{
|
||||
"<!-- test html comment -->",
|
||||
"",
|
||||
},
|
||||
{
|
||||
"<!-- test html comment --> a ",
|
||||
"a",
|
||||
},
|
||||
{
|
||||
"<span>a</span>b<span>c</span>",
|
||||
"abc",
|
||||
},
|
||||
{
|
||||
`<a href="a/b/c">test</span>`,
|
||||
"[test](a/b/c)",
|
||||
},
|
||||
{
|
||||
`<a href="">test</span>`,
|
||||
"[test]",
|
||||
},
|
||||
{
|
||||
"<span>a</span> <span>b</span>",
|
||||
"a b",
|
||||
},
|
||||
{
|
||||
"<span>a</span> b <span>c</span>",
|
||||
"a b c",
|
||||
},
|
||||
{
|
||||
"<span>a</span> b <div>c</div>",
|
||||
"a b \r\nc",
|
||||
},
|
||||
{
|
||||
`
|
||||
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Strict//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-strict.dtd">
|
||||
<html xmlns="http://www.w3.org/1999/xhtml">
|
||||
<head>
|
||||
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8" />
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1" />
|
||||
<style>
|
||||
body {
|
||||
padding: 0;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<!-- test html comment -->
|
||||
<style>
|
||||
body {
|
||||
padding: 0;
|
||||
}
|
||||
</style>
|
||||
<div class="wrapper">
|
||||
<div class="content">
|
||||
<p>Lorem ipsum</p>
|
||||
<p>Dolor sit amet</p>
|
||||
<p>
|
||||
<a href="a/b/c">Verify</a>
|
||||
</p>
|
||||
<br>
|
||||
<p>
|
||||
<a href="a/b/c"><strong>Verify2.1</strong> <strong>Verify2.2</strong></a>
|
||||
</p>
|
||||
<br>
|
||||
<br>
|
||||
<div>
|
||||
<div>
|
||||
<div>
|
||||
<ul>
|
||||
<li>ul.test1</li>
|
||||
<li>ul.test2</li>
|
||||
<li>ul.test3</li>
|
||||
</ul>
|
||||
<ol>
|
||||
<li>ol.test1</li>
|
||||
<li>ol.test2</li>
|
||||
<li>ol.test3</li>
|
||||
</ol>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<select>
|
||||
<option>Option 1</option>
|
||||
<option>Option 2</option>
|
||||
</select>
|
||||
<textarea>test</textarea>
|
||||
<input type="text" value="test" />
|
||||
<button>test</button>
|
||||
<p>
|
||||
Thanks,<br/>
|
||||
PocketBase team
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
`,
|
||||
"Lorem ipsum \r\nDolor sit amet \r\n[Verify](a/b/c) \r\n[Verify2.1 Verify2.2](a/b/c) \r\n\r\n- ul.test1 \r\n- ul.test2 \r\n- ul.test3 \r\n- ol.test1 \r\n- ol.test2 \r\n- ol.test3 \r\nThanks,\r\nPocketBase team",
|
||||
},
|
||||
}
|
||||
|
||||
for i, s := range scenarios {
|
||||
result, err := html2Text(s.html)
|
||||
if err != nil {
|
||||
t.Errorf("(%d) Unexpected error %v", i, err)
|
||||
}
|
||||
|
||||
if result != s.expected {
|
||||
t.Errorf("(%d) Expected \n(%q)\n%v,\n\ngot:\n\n(%q)\n%v", i, s.expected, s.expected, result, result)
|
||||
}
|
||||
}
|
||||
}
|
72
tools/mailer/mailer.go
Normal file
72
tools/mailer/mailer.go
Normal file
|
@ -0,0 +1,72 @@
|
|||
package mailer
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"io"
|
||||
"net/mail"
|
||||
|
||||
"github.com/gabriel-vasile/mimetype"
|
||||
"github.com/pocketbase/pocketbase/tools/hook"
|
||||
)
|
||||
|
||||
// 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"`
|
||||
InlineAttachments map[string]io.Reader `json:"inlineAttachments"`
|
||||
}
|
||||
|
||||
// Mailer defines a base mail client interface.
|
||||
type Mailer interface {
|
||||
// Send sends an email with the provided Message.
|
||||
Send(message *Message) error
|
||||
}
|
||||
|
||||
// SendInterceptor is optional interface for registering mail send hooks.
|
||||
type SendInterceptor interface {
|
||||
OnSend() *hook.Hook[*SendEvent]
|
||||
}
|
||||
|
||||
type SendEvent struct {
|
||||
hook.Event
|
||||
Message *Message
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
// detectReaderMimeType reads the first couple bytes of the reader to detect its MIME type.
|
||||
//
|
||||
// Returns a new combined reader from the partial read + the remaining of the original reader.
|
||||
func detectReaderMimeType(r io.Reader) (io.Reader, string, error) {
|
||||
readCopy := new(bytes.Buffer)
|
||||
|
||||
mime, err := mimetype.DetectReader(io.TeeReader(r, readCopy))
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
|
||||
return io.MultiReader(readCopy, r), mime.String(), nil
|
||||
}
|
77
tools/mailer/mailer_test.go
Normal file
77
tools/mailer/mailer_test.go
Normal file
|
@ -0,0 +1,77 @@
|
|||
package mailer
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"net/mail"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestAddressesToStrings(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
scenarios := []struct {
|
||||
withName bool
|
||||
addresses []mail.Address
|
||||
expected []string
|
||||
}{
|
||||
{
|
||||
true,
|
||||
[]mail.Address{{Name: "John Doe", Address: "test1@example.com"}, {Name: "Jane Doe", Address: "test2@example.com"}},
|
||||
[]string{`"John Doe" <test1@example.com>`, `"Jane Doe" <test2@example.com>`},
|
||||
},
|
||||
{
|
||||
true,
|
||||
[]mail.Address{{Name: "John Doe", Address: "test1@example.com"}, {Address: "test2@example.com"}},
|
||||
[]string{`"John Doe" <test1@example.com>`, `test2@example.com`},
|
||||
},
|
||||
{
|
||||
false,
|
||||
[]mail.Address{{Name: "John Doe", Address: "test1@example.com"}, {Name: "Jane Doe", Address: "test2@example.com"}},
|
||||
[]string{`test1@example.com`, `test2@example.com`},
|
||||
},
|
||||
}
|
||||
|
||||
for _, s := range scenarios {
|
||||
t.Run(fmt.Sprintf("%v_%v", s.withName, s.addresses), func(t *testing.T) {
|
||||
result := addressesToStrings(s.addresses, s.withName)
|
||||
|
||||
if len(s.expected) != len(result) {
|
||||
t.Fatalf("Expected\n%v\ngot\n%v", s.expected, result)
|
||||
}
|
||||
|
||||
for k, v := range s.expected {
|
||||
if v != result[k] {
|
||||
t.Fatalf("Expected %d address %q, got %q", k, v, result[k])
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDetectReaderMimeType(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
str := "#!/bin/node\n" + strings.Repeat("a", 10000) // ensure that it is large enough to remain after the signature sniffing
|
||||
|
||||
r, mime, err := detectReaderMimeType(strings.NewReader(str))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
expectedMime := "text/javascript"
|
||||
if mime != expectedMime {
|
||||
t.Fatalf("Expected mime %q, got %q", expectedMime, mime)
|
||||
}
|
||||
|
||||
raw, err := io.ReadAll(r)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
rawStr := string(raw)
|
||||
|
||||
if rawStr != str {
|
||||
t.Fatalf("Expected content\n%s\ngot\n%s", str, rawStr)
|
||||
}
|
||||
}
|
99
tools/mailer/sendmail.go
Normal file
99
tools/mailer/sendmail.go
Normal file
|
@ -0,0 +1,99 @@
|
|||
package mailer
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"mime"
|
||||
"net/http"
|
||||
"os/exec"
|
||||
"strings"
|
||||
|
||||
"github.com/pocketbase/pocketbase/tools/hook"
|
||||
)
|
||||
|
||||
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 {
|
||||
onSend *hook.Hook[*SendEvent]
|
||||
}
|
||||
|
||||
// OnSend implements [mailer.SendInterceptor] interface.
|
||||
func (c *Sendmail) OnSend() *hook.Hook[*SendEvent] {
|
||||
if c.onSend == nil {
|
||||
c.onSend = &hook.Hook[*SendEvent]{}
|
||||
}
|
||||
return c.onSend
|
||||
}
|
||||
|
||||
// Send implements [mailer.Mailer] interface.
|
||||
func (c *Sendmail) Send(m *Message) error {
|
||||
if c.onSend != nil {
|
||||
return c.onSend.Trigger(&SendEvent{Message: m}, func(e *SendEvent) error {
|
||||
return c.send(e.Message)
|
||||
})
|
||||
}
|
||||
|
||||
return c.send(m)
|
||||
}
|
||||
|
||||
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")
|
||||
}
|
211
tools/mailer/smtp.go
Normal file
211
tools/mailer/smtp.go
Normal file
|
@ -0,0 +1,211 @@
|
|||
package mailer
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/smtp"
|
||||
"strings"
|
||||
|
||||
"github.com/domodwyer/mailyak/v3"
|
||||
"github.com/pocketbase/pocketbase/tools/hook"
|
||||
"github.com/pocketbase/pocketbase/tools/security"
|
||||
)
|
||||
|
||||
var _ Mailer = (*SMTPClient)(nil)
|
||||
|
||||
const (
|
||||
SMTPAuthPlain = "PLAIN"
|
||||
SMTPAuthLogin = "LOGIN"
|
||||
)
|
||||
|
||||
// SMTPClient defines a SMTP mail client structure that implements
|
||||
// `mailer.Mailer` interface.
|
||||
type SMTPClient struct {
|
||||
onSend *hook.Hook[*SendEvent]
|
||||
|
||||
TLS bool
|
||||
Port int
|
||||
Host string
|
||||
Username string
|
||||
Password string
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
// OnSend implements [mailer.SendInterceptor] interface.
|
||||
func (c *SMTPClient) OnSend() *hook.Hook[*SendEvent] {
|
||||
if c.onSend == nil {
|
||||
c.onSend = &hook.Hook[*SendEvent]{}
|
||||
}
|
||||
return c.onSend
|
||||
}
|
||||
|
||||
// Send implements [mailer.Mailer] interface.
|
||||
func (c *SMTPClient) Send(m *Message) error {
|
||||
if c.onSend != nil {
|
||||
return c.onSend.Trigger(&SendEvent{Message: m}, func(e *SendEvent) error {
|
||||
return c.send(e.Message)
|
||||
})
|
||||
}
|
||||
|
||||
return c.send(m)
|
||||
}
|
||||
|
||||
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 regular attachements (if any)
|
||||
for name, data := range m.Attachments {
|
||||
r, mime, err := detectReaderMimeType(data)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
yak.AttachWithMimeType(name, r, mime)
|
||||
}
|
||||
|
||||
// add inline attachments (if any)
|
||||
for name, data := range m.InlineAttachments {
|
||||
r, mime, err := detectReaderMimeType(data)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
yak.AttachInlineWithMimeType(name, r, mime)
|
||||
}
|
||||
|
||||
// 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>",
|
||||
security.PseudorandomString(15),
|
||||
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"
|
||||
}
|
166
tools/mailer/smtp_test.go
Normal file
166
tools/mailer/smtp_test.go
Normal file
|
@ -0,0 +1,166 @@
|
|||
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 {
|
||||
t.Run(s.name, func(t *testing.T) {
|
||||
method, resp, err := auth.Start(s.serverInfo)
|
||||
|
||||
hasErr := err != nil
|
||||
if hasErr != s.expectError {
|
||||
t.Fatalf("Expected hasErr %v, got %v", s.expectError, hasErr)
|
||||
}
|
||||
|
||||
if hasErr {
|
||||
return
|
||||
}
|
||||
|
||||
if len(resp) != 0 {
|
||||
t.Fatalf("Expected empty data response, got %v", resp)
|
||||
}
|
||||
|
||||
if method != "LOGIN" {
|
||||
t.Fatalf("Expected LOGIN, got %v", 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)
|
||||
}
|
||||
}
|
||||
}
|
Loading…
Add table
Add a link
Reference in a new issue