diff --git a/mail/format.go b/mail/format.go new file mode 100644 index 00000000..e68ec8ea --- /dev/null +++ b/mail/format.go @@ -0,0 +1,95 @@ +package mail + +import ( + _ "embed" // required by go:embed + "encoding/json" + "fmt" + "mime" + "strings" + "time" + + "heckel.io/ntfy/v2/model" + "heckel.io/ntfy/v2/util" +) + +var ( + //go:embed "mailer_emoji_map.json" + emojisJSON string + + // emojiMap maps ntfy tag names to emoji, parsed once from the embedded JSON in init + emojiMap map[string]string +) + +func init() { + if err := json.Unmarshal([]byte(emojisJSON), &emojiMap); err != nil { + panic("mail: invalid embedded emoji map: " + err.Error()) + } +} + +func formatMail(baseURL, senderIP, from, to string, m *model.Message) (string, error) { + topicURL := baseURL + "/" + m.Topic + subject := m.Title + if subject == "" { + subject = m.Message + } + subject = strings.ReplaceAll(strings.ReplaceAll(subject, "\r", ""), "\n", " ") + message := m.Message + trailer := "" + if len(m.Tags) > 0 { + emojis, tags := toEmojis(m.Tags) + if len(emojis) > 0 { + subject = strings.Join(emojis, " ") + " " + subject + } + if len(tags) > 0 { + trailer = "Tags: " + strings.Join(tags, ", ") + } + } + if m.Priority != 0 && m.Priority != 3 { + priority, err := util.PriorityString(m.Priority) + if err != nil { + return "", err + } + if trailer != "" { + trailer += "\n" + } + trailer += fmt.Sprintf("Priority: %s", priority) + } + if trailer != "" { + message += "\n\n" + trailer + } + date := time.Unix(m.Time, 0).UTC().Format(time.RFC1123Z) + subject = mime.BEncoding.Encode("utf-8", subject) + body := `From: "{shortTopicURL}" <{from}> +To: {to} +Date: {date} +Subject: {subject} +Content-Type: text/plain; charset="utf-8" + +{message} + +-- +This message was sent by {ip} at {time} via {topicURL}` + body = strings.ReplaceAll(body, "{from}", from) + body = strings.ReplaceAll(body, "{to}", to) + body = strings.ReplaceAll(body, "{date}", date) + body = strings.ReplaceAll(body, "{subject}", subject) + body = strings.ReplaceAll(body, "{message}", message) + body = strings.ReplaceAll(body, "{topicURL}", topicURL) + body = strings.ReplaceAll(body, "{shortTopicURL}", util.ShortTopicURL(topicURL)) + body = strings.ReplaceAll(body, "{time}", time.Unix(m.Time, 0).UTC().Format(time.RFC1123)) + body = strings.ReplaceAll(body, "{ip}", senderIP) + return body, nil +} + +func toEmojis(tags []string) (emojisOut []string, tagsOut []string) { + tagsOut = make([]string, 0) + emojisOut = make([]string, 0) + for _, t := range tags { + if emoji, ok := emojiMap[t]; ok { + emojisOut = append(emojisOut, emoji) + } else { + tagsOut = append(tagsOut, t) + } + } + return +} diff --git a/server/smtp_sender_test.go b/mail/format_test.go similarity index 99% rename from server/smtp_sender_test.go rename to mail/format_test.go index 4f97b128..4c4c4613 100644 --- a/server/smtp_sender_test.go +++ b/mail/format_test.go @@ -1,4 +1,4 @@ -package server +package mail import ( "testing" diff --git a/server/mailer_emoji_map.json b/mail/mailer_emoji_map.json similarity index 100% rename from server/mailer_emoji_map.json rename to mail/mailer_emoji_map.json diff --git a/mail/sender.go b/mail/sender.go index 3215c332..5463285f 100644 --- a/mail/sender.go +++ b/mail/sender.go @@ -6,66 +6,98 @@ import ( "net" "net/smtp" "strings" + "sync" "time" "heckel.io/ntfy/v2/log" + "heckel.io/ntfy/v2/model" ) const ( + tagMail = "mail" + emailVerificationSubject = "Verify your email for ntfy" passwordResetSubject = "Reset your ntfy password" ) // Config holds the SMTP configuration for the mail sender type Config struct { + BaseURL string // ntfy base URL, used to build topic URLs in notification emails SMTPAddr string // SMTP server address (host:port) SMTPUser string // SMTP auth username SMTPPass string // SMTP auth password From string // Sender email address } -// Sender sends emails via SMTP, including the magic-link emails for email verification and -// password reset. Pending verification/reset state lives in the database (see user.Manager), -// not in this struct. -type Sender struct { - config *Config +// Sender sends all of ntfy's outgoing email: notification emails (the email-on-publish feature) +// as well as the magic-link emails for email verification and password reset. realSender is the +// SMTP-backed implementation; tests inject a fake. +type Sender interface { + SendNotification(to string, m *model.Message, senderIP string) error + Counts() (total int64, success int64, failure int64) + SendEmailVerification(to, link string) error + SendPasswordReset(to, link string) error +} + +// realSender is the SMTP-backed implementation of Sender. Pending verification/reset state lives +// in the database (see user.Manager), not in this struct. +type realSender struct { + config *Config + success int64 + failure int64 + mu sync.Mutex } // NewSender creates a new mail Sender with the given SMTP config -func NewSender(config *Config) *Sender { - return &Sender{config: config} +func NewSender(config *Config) Sender { + return &realSender{config: config} } -// Addr returns the SMTP server address -func (s *Sender) Addr() string { - return s.config.SMTPAddr -} - -// User returns the SMTP username -func (s *Sender) User() string { - return s.config.SMTPUser -} - -// From returns the sender email address -func (s *Sender) From() string { - return s.config.From -} - -// SendRaw sends a raw email message via SMTP -func (s *Sender) SendRaw(to string, message []byte) error { - host, _, err := net.SplitHostPort(s.config.SMTPAddr) +// SendNotification formats a ntfy message into a notification email and sends it via SMTP. It +// tracks success/failure counts, exposed via Counts (used for the server stats). +func (s *realSender) SendNotification(to string, m *model.Message, senderIP string) error { + message, err := formatMail(s.config.BaseURL, senderIP, s.config.From, to, m) if err != nil { + s.count(false) return err } - var auth smtp.Auth - if s.config.SMTPUser != "" { - auth = smtp.PlainAuth("", s.config.SMTPUser, s.config.SMTPPass, host) - } - return smtp.SendMail(s.config.SMTPAddr, auth, s.config.From, []string{to}, message) + log.Tag(tagMail).Field("email_to", to).Debug("Sending notification email") + err = s.sendRaw(to, []byte(message)) + s.count(err == nil) + return err } -// Send sends a plain text email via SMTP -func (s *Sender) Send(to, subject, body string) error { +// Counts returns the number of notification emails sent, broken down into total, success and failure +func (s *realSender) Counts() (total int64, success int64, failure int64) { + s.mu.Lock() + defer s.mu.Unlock() + return s.success + s.failure, s.success, s.failure +} + +// SendEmailVerification sends an email containing a magic link to verify ownership of the +// recipient address. The link carries a one-time token validated against the database. +func (s *realSender) SendEmailVerification(to, link string) error { + body := fmt.Sprintf(`Click the link below to verify this email address for your ntfy account: + +%s + +This link expires in 24 hours. If you did not request this, you can safely ignore this email.`, link) + return s.send(to, emailVerificationSubject, body) +} + +// SendPasswordReset sends an email containing a magic link to set a new password. The link +// carries a one-time token validated against the database. +func (s *realSender) SendPasswordReset(to, link string) error { + body := fmt.Sprintf(`Click the link below to set a new password for your ntfy account: + +%s + +This link expires in 1 hour. If you did not request this, you can safely ignore this email -- your password will not change.`, link) + return s.send(to, passwordResetSubject, body) +} + +// send sends a plain text email via SMTP +func (s *realSender) send(to, subject, body string) error { date := time.Now().UTC().Format(time.RFC1123Z) encodedSubject := mime.BEncoding.Encode("utf-8", subject) message := `From: ntfy <{from}> @@ -80,28 +112,29 @@ Content-Type: text/plain; charset="utf-8" message = strings.ReplaceAll(message, "{date}", date) message = strings.ReplaceAll(message, "{subject}", encodedSubject) message = strings.ReplaceAll(message, "{body}", body) - log.Tag("mail").Field("email_to", to).Debug("Sending email") - return s.SendRaw(to, []byte(message)) + log.Tag(tagMail).Field("email_to", to).Debug("Sending email") + return s.sendRaw(to, []byte(message)) } -// SendEmailVerification sends an email containing a magic link to verify ownership of the -// recipient address. The link carries a one-time token validated against the database. -func (s *Sender) SendEmailVerification(to, link string) error { - body := fmt.Sprintf(`Click the link below to verify this email address for your ntfy account: - -%s - -This link expires in 24 hours. If you did not request this, you can safely ignore this email.`, link) - return s.Send(to, emailVerificationSubject, body) +// sendRaw sends a raw email message via SMTP +func (s *realSender) sendRaw(to string, message []byte) error { + host, _, err := net.SplitHostPort(s.config.SMTPAddr) + if err != nil { + return err + } + var auth smtp.Auth + if s.config.SMTPUser != "" { + auth = smtp.PlainAuth("", s.config.SMTPUser, s.config.SMTPPass, host) + } + return smtp.SendMail(s.config.SMTPAddr, auth, s.config.From, []string{to}, message) } -// SendPasswordReset sends an email containing a magic link to set a new password. The link -// carries a one-time token validated against the database. -func (s *Sender) SendPasswordReset(to, link string) error { - body := fmt.Sprintf(`Click the link below to set a new password for your ntfy account: - -%s - -This link expires in 1 hour. If you did not request this, you can safely ignore this email -- your password will not change.`, link) - return s.Send(to, passwordResetSubject, body) +func (s *realSender) count(ok bool) { + s.mu.Lock() + defer s.mu.Unlock() + if ok { + s.success++ + } else { + s.failure++ + } } diff --git a/server/server.go b/server/server.go index ade5aebe..db338a22 100644 --- a/server/server.go +++ b/server/server.go @@ -48,31 +48,30 @@ import ( // Server is the main server, providing the UI and API for ntfy type Server struct { - config *Config - db *db.DB // Shared PostgreSQL connection pool (with optional replicas), nil when using SQLite - httpServer *http.Server - httpsServer *http.Server - httpMetricsServer *http.Server - httpProfileServer *http.Server - unixListener net.Listener - smtpServer *smtp.Server - smtpServerBackend *smtpBackend - notificationMailer messageMailer - accountMailer magicLinkMailer - topics map[string]*topic - visitors map[string]*visitor // ip: or user: - firebaseClient *firebaseClient - messages int64 // Total number of messages (persisted if messageCache enabled) - messagesHistory []int64 // Last n values of the messages counter, used to determine rate - userManager *user.Manager // Might be nil! - messageCache *message.Cache // Database that stores the messages - webPush *webpush.Store // Database that stores web push subscriptions - attachment *attachment.Store // Attachment store (file system or S3) - stripe stripeAPI // Stripe API, can be replaced with a mock - priceCache *util.LookupCache[map[string]int64] // Stripe price ID -> price as cents (USD implied!) - metricsHandler http.Handler // Handles /metrics if enable-metrics set, and listen-metrics-http not set - closeChan chan bool - mu sync.RWMutex + config *Config + db *db.DB // Shared PostgreSQL connection pool (with optional replicas), nil when using SQLite + httpServer *http.Server + httpsServer *http.Server + httpMetricsServer *http.Server + httpProfileServer *http.Server + unixListener net.Listener + smtpServer *smtp.Server + smtpServerBackend *smtpBackend + mailer mail.Sender + topics map[string]*topic + visitors map[string]*visitor // ip: or user: + firebaseClient *firebaseClient + messages int64 // Total number of messages (persisted if messageCache enabled) + messagesHistory []int64 // Last n values of the messages counter, used to determine rate + userManager *user.Manager // Might be nil! + messageCache *message.Cache // Database that stores the messages + webPush *webpush.Store // Database that stores web push subscriptions + attachment *attachment.Store // Attachment store (file system or S3) + stripe stripeAPI // Stripe API, can be replaced with a mock + priceCache *util.LookupCache[map[string]int64] // Stripe price ID -> price as cents (USD implied!) + metricsHandler http.Handler // Handles /metrics if enable-metrics set, and listen-metrics-http not set + closeChan chan bool + mu sync.RWMutex } // handleFunc extends the normal http.HandlerFunc to be able to easily return errors @@ -184,17 +183,15 @@ const ( // New instantiates a new Server. It creates the cache and adds a Firebase // subscriber (if configured). func New(conf *Config) (*Server, error) { - var notificationMailer messageMailer - var accountEmailer magicLinkMailer // Stays untyped-nil when SMTP is unconfigured, so ensureEmailsEnabled gates correctly + var sender mail.Sender if conf.SMTPSenderAddr != "" { - sender := mail.NewSender(&mail.Config{ + sender = mail.NewSender(&mail.Config{ + BaseURL: conf.BaseURL, SMTPAddr: conf.SMTPSenderAddr, SMTPUser: conf.SMTPSenderUser, SMTPPass: conf.SMTPSenderPass, From: conf.SMTPSenderFrom, }) - notificationMailer = ¬ificationSender{config: conf, sender: sender} - accountEmailer = sender } var stripe stripeAPI if payments.Available && conf.StripeSecretKey != "" { @@ -293,20 +290,19 @@ func New(conf *Config) (*Server, error) { firebaseClient = newFirebaseClient(sender, auther) } s := &Server{ - config: conf, - db: pool, - messageCache: messageCache, - webPush: wp, - attachment: attachmentStore, - firebaseClient: firebaseClient, - notificationMailer: notificationMailer, - accountMailer: accountEmailer, - topics: topics, - userManager: userManager, - messages: messages, - messagesHistory: []int64{messages}, - visitors: make(map[string]*visitor), - stripe: stripe, + config: conf, + db: pool, + messageCache: messageCache, + webPush: wp, + attachment: attachmentStore, + firebaseClient: firebaseClient, + mailer: sender, + topics: topics, + userManager: userManager, + messages: messages, + messagesHistory: []int64{messages}, + visitors: make(map[string]*visitor), + stripe: stripe, } s.priceCache = util.NewLookupCache(s.fetchStripePrices, conf.StripePriceCacheDuration) return s, nil @@ -974,7 +970,7 @@ func (s *Server) handlePublishInternal(r *http.Request, v *visitor) (*model.Mess if s.firebaseClient != nil && firebase { go s.sendToFirebase(v, m) } - if s.notificationMailer != nil && email != "" { + if s.mailer != nil && email != "" { go s.sendEmail(v, m, email) } if s.config.TwilioAccount != "" && call != "" { @@ -1136,7 +1132,7 @@ func (s *Server) sendToFirebase(v *visitor, m *model.Message) { func (s *Server) sendEmail(v *visitor, m *model.Message, email string) { logvm(v, m).Tag(tagEmail).Field("email", email).Info("Sending email to %s", email) - if err := s.notificationMailer.Send(v, m, email); err != nil { + if err := s.mailer.SendNotification(email, m, v.ip.String()); err != nil { logvm(v, m).Tag(tagEmail).Field("email", email).Err(err).Warn("Unable to send email to %s: %v", email, err.Error()) minc(metricEmailsPublishedFailure) return @@ -1236,7 +1232,7 @@ func (s *Server) parsePublishParams(r *http.Request, m *model.Message) (cache bo if email != "" && !emailAddressRegex.MatchString(email) && !toBool(email) { return false, false, "", "", "", false, "", errHTTPBadRequestEmailAddressInvalid } - if s.notificationMailer == nil && email != "" { + if s.mailer == nil && email != "" { return false, false, "", "", "", false, "", errHTTPBadRequestEmailDisabled } call = readParam(r, "x-call", "call") diff --git a/server/server_account.go b/server/server_account.go index 79c43866..c7ba5335 100644 --- a/server/server_account.go +++ b/server/server_account.go @@ -54,7 +54,7 @@ func (s *Server) handleAccountCreate(w http.ResponseWriter, r *http.Request, v * // If an email was provided and email sending is configured, start verification (best-effort). // The address becomes the primary email on verify (the new account has no primary yet); a // failure to send must not fail signup, so we only log it. - if newAccount.Email != "" && s.accountMailer != nil { + if newAccount.Email != "" && s.mailer != nil { if u, err := s.userManager.User(newAccount.Username); err != nil { logvr(v, r).Tag(tagAccount).Err(err).Warn("Failed to load new user for email verification") } else if err := s.enqueueEmailVerification(u.ID, newAccount.Email); err != nil { @@ -175,7 +175,7 @@ func (s *Server) handleAccountGet(w http.ResponseWriter, r *http.Request, v *vis response.PhoneNumbers = phoneNumbers } } - if s.accountMailer != nil { + if s.mailer != nil { emails, err := s.userManager.Emails(u.ID) if err != nil { return err @@ -788,7 +788,7 @@ func (s *Server) enqueueEmailVerification(userID, email string) error { return err } link := s.config.BaseURL + webAppEmailVerifyPathPrefix + token - return s.accountMailer.SendEmailVerification(email, link) + return s.mailer.SendEmailVerification(email, link) } // handleAccountPasswordResetRequest starts a password reset (POST /v1/account/password/reset/request, @@ -814,7 +814,7 @@ func (s *Server) handleAccountPasswordResetRequest(w http.ResponseWriter, r *htt } else { link := s.config.BaseURL + webAppPasswordResetPathPrefix + token logvr(v, r).Tag(tagAccount).Field("user_id", userID).Info("Sending password reset link") - if err := s.accountMailer.SendPasswordReset(email, link); err != nil { + if err := s.mailer.SendPasswordReset(email, link); err != nil { logvr(v, r).Tag(tagAccount).Err(err).Warn("Failed to send password reset email") } } diff --git a/server/server_account_email_test.go b/server/server_account_email_test.go index f2ca2d3a..a71d5979 100644 --- a/server/server_account_email_test.go +++ b/server/server_account_email_test.go @@ -7,12 +7,13 @@ import ( "testing" "github.com/stretchr/testify/require" + "heckel.io/ntfy/v2/model" "heckel.io/ntfy/v2/user" "heckel.io/ntfy/v2/util" ) -// captureMailer is a fake magicLinkMailer that records the magic links it is asked to send, so -// tests can "click" them without a real SMTP server. +// captureMailer is a fake mailer that records the magic links it is asked to send, so tests can +// "click" them without a real SMTP server. The notification side is a no-op. type captureMailer struct { verifyLinks map[string]string // email -> verification link resetLinks map[string]string // email -> reset link @@ -32,6 +33,14 @@ func (c *captureMailer) SendPasswordReset(to, link string) error { return nil } +func (c *captureMailer) SendNotification(to string, m *model.Message, senderIP string) error { + return nil +} + +func (c *captureMailer) Counts() (total int64, success int64, failure int64) { + return 0, 0, 0 +} + // newEmailTestServer creates a server with email sending "enabled" (SMTP + base-url configured) // and a capturing mailer injected, plus a tier-less user "ben" logged in via basic auth. func newEmailTestServer(t *testing.T, databaseURL string) (*Server, *captureMailer, map[string]string) { @@ -41,7 +50,7 @@ func newEmailTestServer(t *testing.T, databaseURL string) (*Server, *captureMail conf.BaseURL = "https://ntfy.example.com" s := newTestServer(t, conf) mailer := newCaptureMailer() - s.accountMailer = mailer + s.mailer = mailer require.Nil(t, s.userManager.AddUser("ben", "ben", user.RoleUser, false)) auth := map[string]string{"Authorization": util.BasicAuth("ben", "ben")} return s, mailer, auth @@ -283,7 +292,7 @@ func TestAccount_Signup_WithEmail_SendsVerification(t *testing.T) { conf.BaseURL = "https://ntfy.example.com" s := newTestServer(t, conf) mailer := newCaptureMailer() - s.accountMailer = mailer + s.mailer = mailer defer s.closeDatabases() // Sign up with an optional email -> account created and a verification link sent @@ -310,7 +319,7 @@ func TestAccount_Signup_WithoutEmail_NoSend(t *testing.T) { conf.BaseURL = "https://ntfy.example.com" s := newTestServer(t, conf) mailer := newCaptureMailer() - s.accountMailer = mailer + s.mailer = mailer defer s.closeDatabases() // No email -> account created, nothing sent @@ -335,7 +344,7 @@ func TestAccount_Email_ProvisionedNoPrimary(t *testing.T) { conf.AuthUsers = []*user.User{{Name: "prov", Hash: hash, Role: user.RoleUser}} s := newTestServer(t, conf) mailer := newCaptureMailer() - s.accountMailer = mailer + s.mailer = mailer defer s.closeDatabases() auth := map[string]string{"Authorization": util.BasicAuth("prov", "provpass")} @@ -364,7 +373,7 @@ func TestAccount_PasswordReset_ProvisionedUserNoSend(t *testing.T) { } s := newTestServer(t, conf) mailer := newCaptureMailer() - s.accountMailer = mailer + s.mailer = mailer defer s.closeDatabases() // Give the provisioned user a verified primary email anyway diff --git a/server/server_account_test.go b/server/server_account_test.go index b74aac0d..3c4f1787 100644 --- a/server/server_account_test.go +++ b/server/server_account_test.go @@ -151,7 +151,7 @@ func TestAccount_Get_Anonymous(t *testing.T) { conf.VisitorAttachmentTotalSizeLimit = 5123 conf.AttachmentFileSizeLimit = 512 s := newTestServer(t, conf) - s.notificationMailer = &testMailer{} + s.mailer = &testMailer{} defer s.closeDatabases() rr := request(t, s, "GET", "/v1/account", "", nil) diff --git a/server/server_manager.go b/server/server_manager.go index e2fb66e9..51a46078 100644 --- a/server/server_manager.go +++ b/server/server_manager.go @@ -54,8 +54,8 @@ func (s *Server) execManager() { receivedMailTotal, receivedMailSuccess, receivedMailFailure = s.smtpServerBackend.Counts() } var sentMailTotal, sentMailSuccess, sentMailFailure int64 - if s.notificationMailer != nil { - sentMailTotal, sentMailSuccess, sentMailFailure = s.notificationMailer.Counts() + if s.mailer != nil { + sentMailTotal, sentMailSuccess, sentMailFailure = s.mailer.Counts() } // Users diff --git a/server/server_middleware.go b/server/server_middleware.go index 7f76ae4c..cc77b6df 100644 --- a/server/server_middleware.go +++ b/server/server_middleware.go @@ -105,7 +105,7 @@ func (s *Server) ensureCallsEnabled(next handleFunc) handleFunc { func (s *Server) ensureEmailsEnabled(next handleFunc) handleFunc { return func(w http.ResponseWriter, r *http.Request, v *visitor) error { - if s.accountMailer == nil || s.userManager == nil { + if s.mailer == nil || s.userManager == nil { return errHTTPNotFound } return next(w, r, v) diff --git a/server/server_payments.go b/server/server_payments.go index 56a0026b..76216bbe 100644 --- a/server/server_payments.go +++ b/server/server_payments.go @@ -252,7 +252,7 @@ func (s *Server) handleAccountBillingSubscriptionCreateSuccess(w http.ResponseWr // collision (or any other skip), the generic "no recovery email set" warning on the account page // nudges the user to add one. This is best-effort: failures are logged, never surfaced. func (s *Server) maybeEnqueueBillingEmailVerification(r *http.Request, v *visitor, userID, billingEmail string) { - if s.accountMailer == nil || s.config.BaseURL == "" || billingEmail == "" || !emailAddressRegex.MatchString(billingEmail) { + if s.mailer == nil || s.config.BaseURL == "" || billingEmail == "" || !emailAddressRegex.MatchString(billingEmail) { return } emails, err := s.userManager.Emails(userID) diff --git a/server/server_payments_email_test.go b/server/server_payments_email_test.go index ff7727da..42a7aa43 100644 --- a/server/server_payments_email_test.go +++ b/server/server_payments_email_test.go @@ -45,7 +45,7 @@ func newCheckoutEmailTestServer(t *testing.T, databaseURL string) (*Server, *cap c.SMTPSenderFrom = "noreply@example.com" s := newTestServer(t, c) mailer := newCaptureMailer() - s.accountMailer = mailer + s.mailer = mailer require.Nil(t, s.userManager.AddTier(&user.Tier{ ID: "ti_123", Code: "starter", StripeMonthlyPriceID: "price_1234", MessageLimit: 100, MessageExpiryDuration: time.Hour, })) diff --git a/server/server_test.go b/server/server_test.go index 04a4a8da..ea47dacb 100644 --- a/server/server_test.go +++ b/server/server_test.go @@ -740,7 +740,7 @@ func TestServer_PublishMessageInHeaderWithNewlines(t *testing.T) { func TestServer_PublishInvalidTopic(t *testing.T) { forEachBackend(t, func(t *testing.T, databaseURL string) { s := newTestServer(t, newTestConfig(t, databaseURL)) - s.notificationMailer = &testMailer{} + s.mailer = &testMailer{} response := request(t, s, "PUT", "/docs", "fail", nil) require.Equal(t, 40010, toHTTPError(t, response.Body.String()).Code) }) @@ -1231,7 +1231,7 @@ func TestServer_StatsResetter_MessageLimiter_EmailsLimiter(t *testing.T) { c := newTestConfigWithAuthFile(t, databaseURL) s := newTestServer(t, c) - s.notificationMailer = &testMailer{} + s.mailer = &testMailer{} // Publish some messages, and check stats for i := 0; i < 3; i++ { @@ -1319,7 +1319,7 @@ type testMailer struct { mu sync.Mutex } -func (t *testMailer) Send(v *visitor, m *model.Message, to string) error { +func (t *testMailer) SendNotification(to string, m *model.Message, senderIP string) error { t.mu.Lock() defer t.mu.Unlock() t.count++ @@ -1336,6 +1336,10 @@ func (t *testMailer) Count() int { return t.count } +func (t *testMailer) SendEmailVerification(to, link string) error { return nil } + +func (t *testMailer) SendPasswordReset(to, link string) error { return nil } + func TestServer_PublishTooManyRequests_Defaults(t *testing.T) { forEachBackend(t, func(t *testing.T, databaseURL string) { s := newTestServer(t, newTestConfig(t, databaseURL)) @@ -1461,7 +1465,7 @@ func TestServer_PublishTooManyRequests_ShortReplenish(t *testing.T) { func TestServer_PublishTooManyEmails_Defaults(t *testing.T) { forEachBackend(t, func(t *testing.T, databaseURL string) { s := newTestServer(t, newTestConfig(t, databaseURL)) - s.notificationMailer = &testMailer{} + s.mailer = &testMailer{} for i := 0; i < 16; i++ { response := request(t, s, "PUT", "/mytopic", fmt.Sprintf("message %d", i), map[string]string{ "E-Mail": "test@example.com", @@ -1481,7 +1485,7 @@ func TestServer_PublishTooManyEmails_Replenish(t *testing.T) { c := newTestConfig(t, databaseURL) c.VisitorEmailLimitReplenish = 500 * time.Millisecond s := newTestServer(t, c) - s.notificationMailer = &testMailer{} + s.mailer = &testMailer{} for i := 0; i < 16; i++ { response := request(t, s, "PUT", "/mytopic", fmt.Sprintf("message %d", i), map[string]string{ "E-Mail": "test@example.com", @@ -1509,7 +1513,7 @@ func TestServer_PublishTooManyEmails_Replenish(t *testing.T) { func TestServer_PublishDelayedEmail_Fail(t *testing.T) { forEachBackend(t, func(t *testing.T, databaseURL string) { s := newTestServer(t, newTestConfig(t, databaseURL)) - s.notificationMailer = &testMailer{} + s.mailer = &testMailer{} response := request(t, s, "PUT", "/mytopic", "fail", map[string]string{ "E-Mail": "test@example.com", "Delay": "20 min", @@ -1546,7 +1550,7 @@ func TestServer_PublishEmailNoMailer_Fail(t *testing.T) { func TestServer_PublishEmailAddressInvalid(t *testing.T) { forEachBackend(t, func(t *testing.T, databaseURL string) { s := newTestServer(t, newTestConfig(t, databaseURL)) - s.notificationMailer = &testMailer{} + s.mailer = &testMailer{} addresses := []string{ "test@example.com, other@example.com", "invalidaddress", @@ -1572,7 +1576,7 @@ func TestServer_PublishEmailVerify_VerifiedAddress(t *testing.T) { conf := newTestConfigWithAuthFile(t, databaseURL) conf.SMTPSenderVerify = true s := newTestServer(t, conf) - s.notificationMailer = &testMailer{} + s.mailer = &testMailer{} defer s.closeDatabases() require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleUser, false)) @@ -1602,7 +1606,7 @@ func TestServer_PublishEmailVerify_BoolValue(t *testing.T) { conf := newTestConfigWithAuthFile(t, databaseURL) conf.SMTPSenderVerify = true s := newTestServer(t, conf) - s.notificationMailer = &testMailer{} + s.mailer = &testMailer{} defer s.closeDatabases() require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleUser, false)) @@ -1631,7 +1635,7 @@ func TestServer_PublishEmailVerify_BoolValue(t *testing.T) { func TestServer_PublishEmailVerify_BoolValue_NoVerify(t *testing.T) { forEachBackend(t, func(t *testing.T, databaseURL string) { s := newTestServer(t, newTestConfig(t, databaseURL)) - s.notificationMailer = &testMailer{} + s.mailer = &testMailer{} // "yes" without smtp-sender-verify should fail with invalid address response := request(t, s, "PUT", "/mytopic", "hi", map[string]string{ @@ -1647,7 +1651,7 @@ func TestServer_PublishEmailVerify_Anonymous(t *testing.T) { conf := newTestConfigWithAuthFile(t, databaseURL) conf.SMTPSenderVerify = true s := newTestServer(t, conf) - s.notificationMailer = &testMailer{} + s.mailer = &testMailer{} defer s.closeDatabases() // Anonymous user should be rejected @@ -1664,7 +1668,7 @@ func TestServer_PublishEmailVerify_NoVerifiedEmails(t *testing.T) { conf := newTestConfigWithAuthFile(t, databaseURL) conf.SMTPSenderVerify = true s := newTestServer(t, conf) - s.notificationMailer = &testMailer{} + s.mailer = &testMailer{} defer s.closeDatabases() require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleUser, false)) @@ -1682,7 +1686,7 @@ func TestServer_PublishEmailVerify_NoVerifiedEmails(t *testing.T) { func TestServer_PublishEmailVerify_Disabled_Backwards_Compatible(t *testing.T) { forEachBackend(t, func(t *testing.T, databaseURL string) { s := newTestServer(t, newTestConfig(t, databaseURL)) - s.notificationMailer = &testMailer{} + s.mailer = &testMailer{} // Without smtp-sender-verify, any email address should work (backwards compatible) response := request(t, s, "PUT", "/mytopic", "hi", map[string]string{ @@ -2139,7 +2143,7 @@ func TestServer_PublishAsJSON_WithEmail(t *testing.T) { t.Parallel() mailer := &testMailer{} s := newTestServer(t, newTestConfig(t, databaseURL)) - s.notificationMailer = mailer + s.mailer = mailer body := `{"topic":"mytopic","message":"A message","email":"phil@example.com"}` response := request(t, s, "PUT", "/", body, nil) require.Equal(t, 200, response.Code) diff --git a/server/smtp_sender.go b/server/smtp_sender.go deleted file mode 100644 index 9ef7c000..00000000 --- a/server/smtp_sender.go +++ /dev/null @@ -1,160 +0,0 @@ -package server - -import ( - _ "embed" // required by go:embed - "encoding/json" - "fmt" - "mime" - "strings" - "sync" - "time" - - "heckel.io/ntfy/v2/log" - "heckel.io/ntfy/v2/mail" - "heckel.io/ntfy/v2/model" - "heckel.io/ntfy/v2/util" -) - -// messageMailer sends notification emails (the email-on-publish feature). It formats a ntfy -// message into an email. Implemented by *notificationSender; tests inject testMailer. -type messageMailer interface { - Send(v *visitor, m *model.Message, to string) error - Counts() (total int64, success int64, failure int64) -} - -// magicLinkMailer sends the magic-link emails for email verification and password reset. -// *mail.Sender implements it; tests inject a fake to capture the generated links. -type magicLinkMailer interface { - SendEmailVerification(to, link string) error - SendPasswordReset(to, link string) error -} - -// notificationSender adapts a *mail.Sender for notification emails: it formats a model.Message -// into an email and tracks success/failure counts. -type notificationSender struct { - config *Config - sender *mail.Sender - success int64 - failure int64 - mu sync.Mutex -} - -func (s *notificationSender) Send(v *visitor, m *model.Message, to string) error { - return s.withCount(v, m, func() error { - message, err := formatMail(s.config.BaseURL, v.ip.String(), s.sender.From(), to, m) - if err != nil { - return err - } - ev := logvm(v, m). - Tag(tagEmail). - Fields(log.Context{ - "email_via": s.sender.Addr(), - "email_user": s.sender.User(), - "email_to": to, - }) - if ev.IsTrace() { - ev.Field("email_body", message).Trace("Sending email") - } - ev.Info("Sending email") - return s.sender.SendRaw(to, []byte(message)) - }) -} - -func (s *notificationSender) Counts() (total int64, success int64, failure int64) { - s.mu.Lock() - defer s.mu.Unlock() - return s.success + s.failure, s.success, s.failure -} - -func (s *notificationSender) withCount(v *visitor, m *model.Message, fn func() error) error { - err := fn() - s.mu.Lock() - defer s.mu.Unlock() - if err != nil { - logvm(v, m).Err(err).Debug("Sending mail failed") - s.failure++ - } else { - s.success++ - } - return err -} - -func formatMail(baseURL, senderIP, from, to string, m *model.Message) (string, error) { - topicURL := baseURL + "/" + m.Topic - subject := m.Title - if subject == "" { - subject = m.Message - } - subject = strings.ReplaceAll(strings.ReplaceAll(subject, "\r", ""), "\n", " ") - message := m.Message - trailer := "" - if len(m.Tags) > 0 { - emojis, tags, err := toEmojis(m.Tags) - if err != nil { - return "", err - } - if len(emojis) > 0 { - subject = strings.Join(emojis, " ") + " " + subject - } - if len(tags) > 0 { - trailer = "Tags: " + strings.Join(tags, ", ") - } - } - if m.Priority != 0 && m.Priority != 3 { - priority, err := util.PriorityString(m.Priority) - if err != nil { - return "", err - } - if trailer != "" { - trailer += "\n" - } - trailer += fmt.Sprintf("Priority: %s", priority) - } - if trailer != "" { - message += "\n\n" + trailer - } - date := time.Unix(m.Time, 0).UTC().Format(time.RFC1123Z) - subject = mime.BEncoding.Encode("utf-8", subject) - body := `From: "{shortTopicURL}" <{from}> -To: {to} -Date: {date} -Subject: {subject} -Content-Type: text/plain; charset="utf-8" - -{message} - --- -This message was sent by {ip} at {time} via {topicURL}` - body = strings.ReplaceAll(body, "{from}", from) - body = strings.ReplaceAll(body, "{to}", to) - body = strings.ReplaceAll(body, "{date}", date) - body = strings.ReplaceAll(body, "{subject}", subject) - body = strings.ReplaceAll(body, "{message}", message) - body = strings.ReplaceAll(body, "{topicURL}", topicURL) - body = strings.ReplaceAll(body, "{shortTopicURL}", util.ShortTopicURL(topicURL)) - body = strings.ReplaceAll(body, "{time}", time.Unix(m.Time, 0).UTC().Format(time.RFC1123)) - body = strings.ReplaceAll(body, "{ip}", senderIP) - return body, nil -} - -var ( - //go:embed "mailer_emoji_map.json" - emojisJSON string -) - -func toEmojis(tags []string) (emojisOut []string, tagsOut []string, err error) { - var emojiMap map[string]string - if err = json.Unmarshal([]byte(emojisJSON), &emojiMap); err != nil { - return nil, nil, err - } - tagsOut = make([]string, 0) - emojisOut = make([]string, 0) - for _, t := range tags { - if emoji, ok := emojiMap[t]; ok { - emojisOut = append(emojisOut, emoji) - } else { - tagsOut = append(tagsOut, t) - } - } - return -}