123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340 |
- package server
- import (
- "bytes"
- "encoding/base64"
- "errors"
- "fmt"
- "github.com/emersion/go-smtp"
- "github.com/microcosm-cc/bluemonday"
- "io"
- "mime"
- "mime/multipart"
- "mime/quotedprintable"
- "net"
- "net/http"
- "net/http/httptest"
- "net/mail"
- "regexp"
- "strings"
- "sync"
- )
- var (
- errInvalidDomain = errors.New("invalid domain")
- errInvalidAddress = errors.New("invalid address")
- errInvalidTopic = errors.New("invalid topic")
- errTooManyRecipients = errors.New("too many recipients")
- errMultipartNestedTooDeep = errors.New("multipart message nested too deep")
- errUnsupportedContentType = errors.New("unsupported content type")
- )
- var (
- onlySpacesRegex = regexp.MustCompile(`(?m)^\s+$`)
- consecutiveNewLinesRegex = regexp.MustCompile(`\n{3,}`)
- )
- const (
- maxMultipartDepth = 2
- )
- // smtpBackend implements SMTP server methods.
- type smtpBackend struct {
- config *Config
- handler func(http.ResponseWriter, *http.Request)
- success int64
- failure int64
- mu sync.Mutex
- }
- var _ smtp.Backend = (*smtpBackend)(nil)
- var _ smtp.Session = (*smtpSession)(nil)
- func newMailBackend(conf *Config, handler func(http.ResponseWriter, *http.Request)) *smtpBackend {
- return &smtpBackend{
- config: conf,
- handler: handler,
- }
- }
- func (b *smtpBackend) NewSession(conn *smtp.Conn) (smtp.Session, error) {
- logem(conn).Debug("Incoming mail")
- return &smtpSession{backend: b, conn: conn}, nil
- }
- func (b *smtpBackend) Counts() (total int64, success int64, failure int64) {
- b.mu.Lock()
- defer b.mu.Unlock()
- return b.success + b.failure, b.success, b.failure
- }
- // smtpSession is returned after EHLO.
- type smtpSession struct {
- backend *smtpBackend
- conn *smtp.Conn
- topic string
- token string // If email address contains token, e.g. topic+token@domain
- basicAuth string // If SMTP AUTH PLAIN was used
- mu sync.Mutex
- }
- func (s *smtpSession) AuthPlain(username, password string) error {
- logem(s.conn).Field("smtp_username", username).Debug("AUTH PLAIN (with username %s)", username)
- s.mu.Lock()
- s.basicAuth = base64.StdEncoding.EncodeToString([]byte(fmt.Sprintf("%s:%s", username, password)))
- s.mu.Unlock()
- return nil
- }
- func (s *smtpSession) Mail(from string, opts *smtp.MailOptions) error {
- logem(s.conn).Field("smtp_mail_from", from).Debug("MAIL FROM: %s", from)
- return nil
- }
- func (s *smtpSession) Rcpt(to string) error {
- logem(s.conn).Field("smtp_rcpt_to", to).Debug("RCPT TO: %s", to)
- return s.withFailCount(func() error {
- token := ""
- conf := s.backend.config
- addressList, err := mail.ParseAddressList(to)
- if err != nil {
- return err
- } else if len(addressList) != 1 {
- return errTooManyRecipients
- }
- to = addressList[0].Address
- if !strings.HasSuffix(to, "@"+conf.SMTPServerDomain) {
- return errInvalidDomain
- }
- // Remove @ntfy.sh from end of email
- to = strings.TrimSuffix(to, "@"+conf.SMTPServerDomain)
- if conf.SMTPServerAddrPrefix != "" {
- if !strings.HasPrefix(to, conf.SMTPServerAddrPrefix) {
- return errInvalidAddress
- }
- // remove ntfy- from beginning of email
- to = strings.TrimPrefix(to, conf.SMTPServerAddrPrefix)
- }
- // If email contains token, split topic and token
- if strings.Contains(to, "+") {
- parts := strings.Split(to, "+")
- to = parts[0]
- token = parts[1]
- }
- if !topicRegex.MatchString(to) {
- return errInvalidTopic
- }
- s.mu.Lock()
- s.topic = to
- s.token = token
- s.mu.Unlock()
- return nil
- })
- }
- func (s *smtpSession) Data(r io.Reader) error {
- return s.withFailCount(func() error {
- conf := s.backend.config
- b, err := io.ReadAll(r) // Protected by MaxMessageBytes
- if err != nil {
- return err
- }
- ev := logem(s.conn)
- if ev.IsTrace() {
- ev.Field("smtp_data", string(b)).Trace("DATA")
- } else if ev.IsDebug() {
- ev.Field("smtp_data_len", len(b)).Debug("DATA")
- }
- msg, err := mail.ReadMessage(bytes.NewReader(b))
- if err != nil {
- return err
- }
- body, err := readMailBody(msg.Body, msg.Header)
- if err != nil {
- return err
- }
- body = strings.TrimSpace(body)
- if len(body) > conf.MessageSizeLimit {
- body = body[:conf.MessageSizeLimit]
- }
- m := newDefaultMessage(s.topic, body)
- subject := strings.TrimSpace(msg.Header.Get("Subject"))
- if subject != "" {
- dec := mime.WordDecoder{}
- subject, err := dec.DecodeHeader(subject)
- if err != nil {
- return err
- }
- m.Title = subject
- }
- if m.Title != "" && m.Message == "" {
- m.Message = m.Title // Flip them, this makes more sense
- m.Title = ""
- }
- if err := s.publishMessage(m); err != nil {
- return err
- }
- s.backend.mu.Lock()
- s.backend.success++
- s.backend.mu.Unlock()
- minc(metricEmailsReceivedSuccess)
- return nil
- })
- }
- func (s *smtpSession) publishMessage(m *message) error {
- // Extract remote address (for rate limiting)
- remoteAddr, _, err := net.SplitHostPort(s.conn.Conn().RemoteAddr().String())
- if err != nil {
- remoteAddr = s.conn.Conn().RemoteAddr().String()
- }
- // Call HTTP handler with fake HTTP request
- url := fmt.Sprintf("%s/%s", s.backend.config.BaseURL, m.Topic)
- req, err := http.NewRequest("POST", url, strings.NewReader(m.Message))
- req.RequestURI = "/" + m.Topic // just for the logs
- req.RemoteAddr = remoteAddr // rate limiting!!
- req.Header.Set("X-Forwarded-For", remoteAddr)
- if err != nil {
- return err
- }
- if m.Title != "" {
- req.Header.Set("Title", m.Title)
- }
- if s.token != "" {
- req.Header.Add("Authorization", "Bearer "+s.token)
- } else if s.basicAuth != "" {
- req.Header.Add("Authorization", "Basic "+s.basicAuth)
- }
- rr := httptest.NewRecorder()
- s.backend.handler(rr, req)
- if rr.Code != http.StatusOK {
- return errors.New("error: " + rr.Body.String())
- }
- return nil
- }
- func (s *smtpSession) Reset() {
- s.mu.Lock()
- s.topic = ""
- s.mu.Unlock()
- }
- func (s *smtpSession) Logout() error {
- s.mu.Lock()
- s.basicAuth = ""
- s.mu.Unlock()
- return nil
- }
- func (s *smtpSession) withFailCount(fn func() error) error {
- err := fn()
- s.backend.mu.Lock()
- defer s.backend.mu.Unlock()
- if err != nil {
- // Almost all of these errors are parse errors, and user input errors.
- // We do not want to spam the log with WARN messages.
- logem(s.conn).Err(err).Debug("Incoming mail error")
- s.backend.failure++
- minc(metricEmailsReceivedFailure)
- }
- return err
- }
- func readMailBody(body io.Reader, header mail.Header) (string, error) {
- if header.Get("Content-Type") == "" {
- return readPlainTextMailBody(body, header.Get("Content-Transfer-Encoding"))
- }
- contentType, params, err := mime.ParseMediaType(header.Get("Content-Type"))
- if err != nil {
- return "", err
- }
- canonicalContentType := strings.ToLower(contentType)
- if canonicalContentType == "text/plain" || canonicalContentType == "text/html" {
- return readTextMailBody(body, canonicalContentType, header.Get("Content-Transfer-Encoding"))
- } else if strings.HasPrefix(canonicalContentType, "multipart/") {
- return readMultipartMailBody(body, params)
- }
- return "", errUnsupportedContentType
- }
- func readMultipartMailBody(body io.Reader, params map[string]string) (string, error) {
- parts := make(map[string]string)
- if err := readMultipartMailBodyParts(body, params, 0, parts); err != nil && err != io.EOF {
- return "", err
- } else if s, ok := parts["text/plain"]; ok {
- return s, nil
- } else if s, ok := parts["text/html"]; ok {
- return s, nil
- }
- return "", io.EOF
- }
- func readMultipartMailBodyParts(body io.Reader, params map[string]string, depth int, parts map[string]string) error {
- if depth >= maxMultipartDepth {
- return errMultipartNestedTooDeep
- }
- mr := multipart.NewReader(body, params["boundary"])
- for {
- part, err := mr.NextPart()
- if err != nil { // may be io.EOF
- return err
- }
- partContentType, partParams, err := mime.ParseMediaType(part.Header.Get("Content-Type"))
- if err != nil {
- return err
- }
- canonicalPartContentType := strings.ToLower(partContentType)
- if canonicalPartContentType == "text/plain" || canonicalPartContentType == "text/html" {
- s, err := readTextMailBody(part, canonicalPartContentType, part.Header.Get("Content-Transfer-Encoding"))
- if err != nil {
- return err
- }
- parts[canonicalPartContentType] = s
- } else if strings.HasPrefix(strings.ToLower(partContentType), "multipart/") {
- if err := readMultipartMailBodyParts(part, partParams, depth+1, parts); err != nil {
- return err
- }
- }
- // Continue with next part
- }
- }
- func readTextMailBody(reader io.Reader, contentType, transferEncoding string) (string, error) {
- if contentType == "text/plain" {
- return readPlainTextMailBody(reader, transferEncoding)
- } else if contentType == "text/html" {
- return readHTMLMailBody(reader, transferEncoding)
- }
- return "", fmt.Errorf("unsupported content type: %s", contentType)
- }
- func readPlainTextMailBody(reader io.Reader, transferEncoding string) (string, error) {
- if strings.ToLower(transferEncoding) == "base64" {
- reader = base64.NewDecoder(base64.StdEncoding, reader)
- } else if strings.ToLower(transferEncoding) == "quoted-printable" {
- reader = quotedprintable.NewReader(reader)
- }
- body, err := io.ReadAll(reader)
- if err != nil {
- return "", err
- }
- return string(body), nil
- }
- func readHTMLMailBody(reader io.Reader, transferEncoding string) (string, error) {
- body, err := readPlainTextMailBody(reader, transferEncoding)
- if err != nil {
- return "", err
- }
- stripped := bluemonday.
- StrictPolicy().
- AddSpaceWhenStrippingTag(true).
- Sanitize(body)
- return removeExtraEmptyLines(stripped), nil
- }
- func removeExtraEmptyLines(s string) string {
- s = onlySpacesRegex.ReplaceAllString(s, "")
- s = consecutiveNewLinesRegex.ReplaceAllString(s, "\n\n")
- return s
- }
|