mirror of
https://github.com/warmbly/warmbly.git
synced 2026-08-23 08:00:28 +00:00
156 lines
4.6 KiB
Go
156 lines
4.6 KiB
Go
package notify
|
|
|
|
import (
|
|
"crypto/rand"
|
|
"encoding/hex"
|
|
"errors"
|
|
"fmt"
|
|
"mime"
|
|
"regexp"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
// buildMessage renders a complete RFC 5322 message. The SES path builds its own
|
|
// envelope, so this is the SMTP and log transports' shared formatter.
|
|
//
|
|
// It exists mostly for three things the old header string did not do: a Date
|
|
// and Message-ID header (mailbox providers penalize messages missing either),
|
|
// RFC 2047 encoding of non-ASCII display names and subjects, and a plain-text
|
|
// alternative alongside the HTML.
|
|
func buildMessage(fromName, fromAddress string, to, cc []string, replyTo, subject, html string) ([]byte, error) {
|
|
if err := rejectHeaderInjection(subject, replyTo, fromName, fromAddress); err != nil {
|
|
return nil, err
|
|
}
|
|
for _, addr := range append(append([]string{}, to...), cc...) {
|
|
if err := rejectHeaderInjection(addr); err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
|
|
boundary, err := randomToken(16)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
messageID, err := newMessageID(fromAddress)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
var b strings.Builder
|
|
writeHeader(&b, "From", formatAddress(fromName, fromAddress))
|
|
writeHeader(&b, "To", strings.Join(to, ", "))
|
|
if len(cc) > 0 {
|
|
writeHeader(&b, "Cc", strings.Join(cc, ", "))
|
|
}
|
|
if replyTo != "" {
|
|
writeHeader(&b, "Reply-To", replyTo)
|
|
}
|
|
writeHeader(&b, "Subject", mime.QEncoding.Encode("UTF-8", subject))
|
|
writeHeader(&b, "Date", time.Now().Format(time.RFC1123Z))
|
|
writeHeader(&b, "Message-ID", messageID)
|
|
writeHeader(&b, "MIME-Version", "1.0")
|
|
writeHeader(&b, "Content-Type", fmt.Sprintf("multipart/alternative; boundary=%q", boundary))
|
|
b.WriteString("\r\n")
|
|
|
|
b.WriteString("--" + boundary + "\r\n")
|
|
b.WriteString("Content-Type: text/plain; charset=\"UTF-8\"\r\n\r\n")
|
|
b.WriteString(normalizeCRLF(htmlToPlainText(html)))
|
|
b.WriteString("\r\n\r\n")
|
|
|
|
b.WriteString("--" + boundary + "\r\n")
|
|
b.WriteString("Content-Type: text/html; charset=\"UTF-8\"\r\n\r\n")
|
|
b.WriteString(normalizeCRLF(html))
|
|
b.WriteString("\r\n\r\n")
|
|
|
|
b.WriteString("--" + boundary + "--\r\n")
|
|
|
|
return []byte(b.String()), nil
|
|
}
|
|
|
|
func writeHeader(b *strings.Builder, name, value string) {
|
|
b.WriteString(name)
|
|
b.WriteString(": ")
|
|
b.WriteString(value)
|
|
b.WriteString("\r\n")
|
|
}
|
|
|
|
func formatAddress(name, address string) string {
|
|
if name == "" {
|
|
return address
|
|
}
|
|
return fmt.Sprintf("%s <%s>", mime.QEncoding.Encode("UTF-8", name), address)
|
|
}
|
|
|
|
// rejectHeaderInjection refuses values carrying a bare CR or LF. Subjects and
|
|
// display names reach here from user-controlled data (organization names, admin
|
|
// outreach), and a newline there splices arbitrary headers into the message.
|
|
func rejectHeaderInjection(values ...string) error {
|
|
for _, v := range values {
|
|
if strings.ContainsAny(v, "\r\n") {
|
|
return errors.New("notify: header value contains a line break")
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func newMessageID(fromAddress string) (string, error) {
|
|
token, err := randomToken(16)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
domain := "localhost"
|
|
if at := strings.LastIndex(fromAddress, "@"); at >= 0 && at+1 < len(fromAddress) {
|
|
domain = fromAddress[at+1:]
|
|
}
|
|
return fmt.Sprintf("<%s@%s>", token, domain), nil
|
|
}
|
|
|
|
func randomToken(n int) (string, error) {
|
|
buf := make([]byte, n)
|
|
if _, err := rand.Read(buf); err != nil {
|
|
return "", err
|
|
}
|
|
return hex.EncodeToString(buf), nil
|
|
}
|
|
|
|
// normalizeCRLF converts bare LF to CRLF as SMTP requires, without doubling the
|
|
// CR in text that already uses CRLF.
|
|
func normalizeCRLF(s string) string {
|
|
return strings.ReplaceAll(strings.ReplaceAll(s, "\r\n", "\n"), "\n", "\r\n")
|
|
}
|
|
|
|
var (
|
|
blockElements = regexp.MustCompile(`(?i)</(p|div|tr|h[1-6]|li|table|blockquote)>|<br\s*/?>`)
|
|
anchorTag = regexp.MustCompile(`(?is)<a\b[^>]*href=["']([^"']+)["'][^>]*>(.*?)</a>`)
|
|
dropElements = regexp.MustCompile(`(?is)<(script|style|head)\b.*?</(script|style|head)>`)
|
|
anyTag = regexp.MustCompile(`(?s)<[^>]*>`)
|
|
blankLineRun = regexp.MustCompile(`\n{3,}`)
|
|
trailingSpaces = regexp.MustCompile(`[ \t]+\n`)
|
|
)
|
|
|
|
// htmlToPlainText produces the text/plain alternative. It keeps link targets
|
|
// inline, because the auth mails whose text part matters most are the ones
|
|
// carrying a reset or invite URL.
|
|
func htmlToPlainText(html string) string {
|
|
s := dropElements.ReplaceAllString(html, "")
|
|
s = anchorTag.ReplaceAllString(s, "$2 <$1>")
|
|
s = blockElements.ReplaceAllString(s, "\n")
|
|
s = anyTag.ReplaceAllString(s, "")
|
|
|
|
replacer := strings.NewReplacer(
|
|
" ", " ",
|
|
"&", "&",
|
|
"<", "<",
|
|
">", ">",
|
|
""", `"`,
|
|
"'", "'",
|
|
"\r", "",
|
|
)
|
|
s = replacer.Replace(s)
|
|
|
|
s = trailingSpaces.ReplaceAllString(s, "\n")
|
|
s = blankLineRun.ReplaceAllString(s, "\n\n")
|
|
return strings.TrimSpace(s)
|
|
}
|