From 61dd788dace82b2e32ebbe0fac39b81edb086298 Mon Sep 17 00:00:00 2001 From: binwiederhier Date: Sun, 29 Mar 2026 22:47:38 -0400 Subject: [PATCH] WIP: Email verification --- cmd/serve.go | 5 + docs/config.md | 24 +++- docs/releases.md | 7 ++ docs/static/js/config-generator.js | 1 + mail/mail.go | 126 +++++++++++++++++++ server/config.go | 2 + server/errors.go | 4 + server/server.go | 32 ++++- server/server.yml | 7 ++ server/server_account.go | 102 +++++++++++++++ server/server_manager.go | 3 + server/server_middleware.go | 9 ++ server/types.go | 10 ++ user/manager.go | 50 ++++++++ user/manager_postgres.go | 8 ++ user/manager_postgres_schema.go | 45 ++++++- user/manager_sqlite.go | 8 ++ user/manager_sqlite_schema.go | 32 ++++- user/types.go | 7 ++ web/public/static/langs/en.json | 12 ++ web/src/app/AccountApi.js | 39 ++++++ web/src/app/utils.js | 2 + web/src/components/Account.jsx | 193 +++++++++++++++++++++++++++++ 23 files changed, 720 insertions(+), 8 deletions(-) create mode 100644 mail/mail.go diff --git a/cmd/serve.go b/cmd/serve.go index 2af1f389..d20242e2 100644 --- a/cmd/serve.go +++ b/cmd/serve.go @@ -71,6 +71,7 @@ var flagsServe = append( altsrc.NewStringFlag(&cli.StringFlag{Name: "smtp-sender-user", Aliases: []string{"smtp_sender_user"}, EnvVars: []string{"NTFY_SMTP_SENDER_USER"}, Usage: "SMTP user (if e-mail sending is enabled)"}), altsrc.NewStringFlag(&cli.StringFlag{Name: "smtp-sender-pass", Aliases: []string{"smtp_sender_pass"}, EnvVars: []string{"NTFY_SMTP_SENDER_PASS"}, Usage: "SMTP password (if e-mail sending is enabled)"}), altsrc.NewStringFlag(&cli.StringFlag{Name: "smtp-sender-from", Aliases: []string{"smtp_sender_from"}, EnvVars: []string{"NTFY_SMTP_SENDER_FROM"}, Usage: "SMTP sender address (if e-mail sending is enabled)"}), + altsrc.NewBoolFlag(&cli.BoolFlag{Name: "smtp-sender-email-verify", Aliases: []string{"smtp_sender_email_verify"}, EnvVars: []string{"NTFY_SMTP_SENDER_EMAIL_VERIFY"}, Value: false, Usage: "require verified email addresses for sending email notifications"}), altsrc.NewStringFlag(&cli.StringFlag{Name: "smtp-server-listen", Aliases: []string{"smtp_server_listen"}, EnvVars: []string{"NTFY_SMTP_SERVER_LISTEN"}, Usage: "SMTP server address (ip:port) for incoming emails, e.g. :25"}), altsrc.NewStringFlag(&cli.StringFlag{Name: "smtp-server-domain", Aliases: []string{"smtp_server_domain"}, EnvVars: []string{"NTFY_SMTP_SERVER_DOMAIN"}, Usage: "SMTP domain for incoming e-mail, e.g. ntfy.sh"}), altsrc.NewStringFlag(&cli.StringFlag{Name: "smtp-server-addr-prefix", Aliases: []string{"smtp_server_addr_prefix"}, EnvVars: []string{"NTFY_SMTP_SERVER_ADDR_PREFIX"}, Usage: "SMTP email address prefix for topics to prevent spam (e.g. 'ntfy-')"}), @@ -184,6 +185,7 @@ func execServe(c *cli.Context) error { smtpSenderUser := c.String("smtp-sender-user") smtpSenderPass := c.String("smtp-sender-pass") smtpSenderFrom := c.String("smtp-sender-from") + smtpSenderEmailVerify := c.Bool("smtp-sender-email-verify") smtpServerListen := c.String("smtp-server-listen") smtpServerDomain := c.String("smtp-server-domain") smtpServerAddrPrefix := c.String("smtp-server-addr-prefix") @@ -310,6 +312,8 @@ func execServe(c *cli.Context) error { return errors.New("if listen-https is set, both key-file and cert-file must be set") } else if smtpSenderAddr != "" && (baseURL == "" || smtpSenderFrom == "") { return errors.New("if smtp-sender-addr is set, base-url, and smtp-sender-from must also be set") + } else if smtpSenderEmailVerify && smtpSenderAddr == "" { + return errors.New("if smtp-sender-email-verify is set, smtp-sender-addr must also be set") } else if smtpServerListen != "" && smtpServerDomain == "" { return errors.New("if smtp-server-listen is set, smtp-server-domain must also be set") } else if attachmentCacheDir != "" && baseURL == "" { @@ -471,6 +475,7 @@ func execServe(c *cli.Context) error { conf.SMTPSenderUser = smtpSenderUser conf.SMTPSenderPass = smtpSenderPass conf.SMTPSenderFrom = smtpSenderFrom + conf.SMTPSenderEmailVerify = smtpSenderEmailVerify conf.SMTPServerListen = smtpServerListen conf.SMTPServerDomain = smtpServerDomain conf.SMTPServerAddrPrefix = smtpServerAddrPrefix diff --git a/docs/config.md b/docs/config.md index e7a98774..eb34382a 100644 --- a/docs/config.md +++ b/docs/config.md @@ -353,6 +353,13 @@ This generator helps you configure your self-hosted ntfy instance. It's not full +
+ + +
@@ -1031,7 +1038,21 @@ configured for `ntfy.sh`): smtp-sender-from: "ntfy@ntfy.sh" ``` -Please also refer to the [rate limiting](#rate-limiting) settings below, specifically `visitor-email-limit-burst` +By default, any user (including anonymous users) can send email notifications to any address. To require email +address verification, set `smtp-sender-email-verify` to `true`. When enabled, anonymous users cannot send emails, +and authenticated users can only send to email addresses they have verified in their account settings. Users can +also use `yes`/`true`/`1` as the `X-Email` value to send to their first verified address. + +=== "/etc/ntfy/server.yml (with email verification)" + ``` yaml + smtp-sender-addr: "email-smtp.us-east-2.amazonaws.com:587" + smtp-sender-user: "AKIDEADBEEFAFFE12345" + smtp-sender-pass: "Abd13Kf+sfAk2DzifjafldkThisIsNotARealKeyOMG." + smtp-sender-from: "ntfy@ntfy.sh" + smtp-sender-email-verify: true + ``` + +Please also refer to the [rate limiting](#rate-limiting) settings below, specifically `visitor-email-limit-burst` and `visitor-email-limit-burst`. Setting these conservatively is necessary to avoid abuse. ## E-mail publishing @@ -2200,6 +2221,7 @@ variable before running the `ntfy` command (e.g. `export NTFY_LISTEN_HTTP=:80`). | `smtp-sender-user` | `NTFY_SMTP_SENDER_USER` | *string* | - | SMTP user; only used if e-mail sending is enabled | | `smtp-sender-pass` | `NTFY_SMTP_SENDER_PASS` | *string* | - | SMTP password; only used if e-mail sending is enabled | | `smtp-sender-from` | `NTFY_SMTP_SENDER_FROM` | *e-mail address* | - | SMTP sender e-mail address; only used if e-mail sending is enabled | +| `smtp-sender-email-verify` | `NTFY_SMTP_SENDER_EMAIL_VERIFY` | *bool* | `false` | If true, require verified email addresses for email notifications; anonymous email sending is disabled | | `smtp-server-listen` | `NTFY_SMTP_SERVER_LISTEN` | `[ip]:port` | - | Defines the IP address and port the SMTP server will listen on, e.g. `:25` or `1.2.3.4:25` | | `smtp-server-domain` | `NTFY_SMTP_SERVER_DOMAIN` | *domain name* | - | SMTP server e-mail domain, e.g. `ntfy.sh` | | `smtp-server-addr-prefix` | `NTFY_SMTP_SERVER_ADDR_PREFIX` | *string* | - | Optional prefix for the e-mail addresses to prevent spam, e.g. `ntfy-` | diff --git a/docs/releases.md b/docs/releases.md index a8024923..8271f36b 100644 --- a/docs/releases.md +++ b/docs/releases.md @@ -110,6 +110,13 @@ if things are working (or not working). There is a [one-off migration tool](http * Preserve `
` line breaks in HTML-only emails received via SMTP ([#690](https://github.com/binwiederhier/ntfy/issues/690), [#1620](https://github.com/binwiederhier/ntfy/pull/1620), thanks to [@uzkikh](https://github.com/uzkikh) for the fix and to [@teastrainer](https://github.com/teastrainer) for reporting) +## ntfy Android v1.25.x (UNRELEASED) + +**Features:** + +* Add configurable "Alert when connection is lost" setting ([#1665](https://github.com/binwiederhier/ntfy/issues/1665), [#1662](https://github.com/binwiederhier/ntfy/issues/1662), [#1652](https://github.com/binwiederhier/ntfy/issues/1652), [#1655](https://github.com/binwiederhier/ntfy/issues/1655), thanks to [@tintamarre](https://github.com/tintamarre), [@sjozs](https://github.com/sjozs), [@TheRealOne78](https://github.com/TheRealOne78), and [@DAE51D](https://github.com/DAE51D) for reporting) +* Suppress connection alerts and stop foreground service when there is no network ([ntfy-android#165](https://github.com/binwiederhier/ntfy-android/pull/165), thanks to [@tintamarre](https://github.com/tintamarre) for the contribution) + ## ntfy Android v1.24.0 Released March 5, 2026 diff --git a/docs/static/js/config-generator.js b/docs/static/js/config-generator.js index 7c093ead..7e4c806f 100644 --- a/docs/static/js/config-generator.js +++ b/docs/static/js/config-generator.js @@ -125,6 +125,7 @@ { key: "smtp-sender-from", env: "NTFY_SMTP_SENDER_FROM", section: "smtp-out" }, { key: "smtp-sender-user", env: "NTFY_SMTP_SENDER_USER", section: "smtp-out" }, { key: "smtp-sender-pass", env: "NTFY_SMTP_SENDER_PASS", section: "smtp-out" }, + { key: "smtp-sender-email-verify", env: "NTFY_SMTP_SENDER_EMAIL_VERIFY", section: "smtp-out" }, { key: "smtp-server-listen", env: "NTFY_SMTP_SERVER_LISTEN", section: "smtp-in" }, { key: "smtp-server-domain", env: "NTFY_SMTP_SERVER_DOMAIN", section: "smtp-in" }, { key: "smtp-server-addr-prefix", env: "NTFY_SMTP_SERVER_ADDR_PREFIX", section: "smtp-in" }, diff --git a/mail/mail.go b/mail/mail.go new file mode 100644 index 00000000..dc26ced4 --- /dev/null +++ b/mail/mail.go @@ -0,0 +1,126 @@ +package mail + +import ( + "crypto/rand" + "fmt" + "math/big" + "mime" + "net" + "net/smtp" + "strings" + "sync" + "time" + + "heckel.io/ntfy/v2/log" +) + +const ( + verifyCodeExpiry = 10 * time.Minute + verifyCodeLength = 6 + verifyCodeSubject = "ntfy email verification" +) + +// Config holds the SMTP configuration for the mail sender +type Config struct { + 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 and manages email verification codes +type Sender struct { + config *Config + verifyCodes map[string]verifyCode // keyed by email + mu sync.Mutex +} + +type verifyCode struct { + code string + expires time.Time +} + +// NewSender creates a new mail Sender with the given SMTP config +func NewSender(config *Config) *Sender { + return &Sender{ + config: config, + verifyCodes: make(map[string]verifyCode), + } +} + +// Send sends a plain text email via SMTP +func (s *Sender) Send(to, subject, body string) 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) + } + date := time.Now().UTC().Format(time.RFC1123Z) + encodedSubject := mime.BEncoding.Encode("utf-8", subject) + message := `From: ntfy <{from}> +To: {to} +Date: {date} +Subject: {subject} +Content-Type: text/plain; charset="utf-8" + +{body}` + message = strings.ReplaceAll(message, "{from}", s.config.From) + message = strings.ReplaceAll(message, "{to}", to) + 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 smtp.SendMail(s.config.SMTPAddr, auth, s.config.From, []string{to}, []byte(message)) +} + +// SendVerification generates a 6-digit code, stores it in-memory, and sends a verification email +func (s *Sender) SendVerification(to string) error { + code, err := generateCode() + if err != nil { + return err + } + s.mu.Lock() + s.verifyCodes[to] = verifyCode{ + code: code, + expires: time.Now().Add(verifyCodeExpiry), + } + s.mu.Unlock() + body := fmt.Sprintf("Your ntfy email verification code is: %s\n\nThis code expires in 10 minutes.", code) + return s.Send(to, verifyCodeSubject, body) +} + +// CheckVerification checks if the code matches and hasn't expired. Removes the entry on success. +func (s *Sender) CheckVerification(email, code string) bool { + s.mu.Lock() + defer s.mu.Unlock() + vc, ok := s.verifyCodes[email] + if !ok || time.Now().After(vc.expires) || vc.code != code { + return false + } + delete(s.verifyCodes, email) + return true +} + +// ExpireVerificationCodes removes expired entries from the in-memory map +func (s *Sender) ExpireVerificationCodes() { + s.mu.Lock() + defer s.mu.Unlock() + now := time.Now() + for email, vc := range s.verifyCodes { + if now.After(vc.expires) { + delete(s.verifyCodes, email) + } + } +} + +func generateCode() (string, error) { + max := big.NewInt(1000000) // 0-999999 + n, err := rand.Int(rand.Reader, max) + if err != nil { + return "", err + } + return fmt.Sprintf("%06d", n.Int64()), nil +} diff --git a/server/config.go b/server/config.go index 8497b18e..0bd6bd32 100644 --- a/server/config.go +++ b/server/config.go @@ -135,6 +135,7 @@ type Config struct { SMTPSenderUser string SMTPSenderPass string SMTPSenderFrom string + SMTPSenderEmailVerify bool SMTPServerListen string SMTPServerDomain string SMTPServerAddrPrefix string @@ -239,6 +240,7 @@ func NewConfig() *Config { SMTPSenderUser: "", SMTPSenderPass: "", SMTPSenderFrom: "", + SMTPSenderEmailVerify: false, SMTPServerListen: "", SMTPServerDomain: "", SMTPServerAddrPrefix: "", diff --git a/server/errors.go b/server/errors.go index 77caf239..16acc9cd 100644 --- a/server/errors.go +++ b/server/errors.go @@ -143,6 +143,9 @@ var ( errHTTPBadRequestTemplateFileInvalid = &errHTTP{40048, http.StatusBadRequest, "invalid request: template file invalid", "https://ntfy.sh/docs/publish/#message-templating", nil} errHTTPBadRequestSequenceIDInvalid = &errHTTP{40049, http.StatusBadRequest, "invalid request: sequence ID invalid", "https://ntfy.sh/docs/publish/#updating-deleting-notifications", nil} errHTTPBadRequestEmailAddressInvalid = &errHTTP{40050, http.StatusBadRequest, "invalid request: invalid e-mail address", "https://ntfy.sh/docs/publish/#e-mail-notifications", nil} + errHTTPBadRequestEmailVerificationCodeInvalid = &errHTTP{40051, http.StatusBadRequest, "invalid request: email verification code invalid or expired", "", nil} + errHTTPBadRequestEmailAddressNotVerified = &errHTTP{40052, http.StatusBadRequest, "invalid request: email address not verified, or no matching verified email addresses found", "https://ntfy.sh/docs/publish/#e-mail-notifications", nil} + errHTTPBadRequestAnonymousEmailNotAllowed = &errHTTP{40053, http.StatusBadRequest, "invalid request: anonymous email sending is not allowed", "https://ntfy.sh/docs/publish/#e-mail-notifications", nil} errHTTPNotFound = &errHTTP{40401, http.StatusNotFound, "page not found", "", nil} errHTTPUnauthorized = &errHTTP{40101, http.StatusUnauthorized, "unauthorized", "https://ntfy.sh/docs/publish/#authentication", nil} errHTTPForbidden = &errHTTP{40301, http.StatusForbidden, "forbidden", "https://ntfy.sh/docs/publish/#authentication", nil} @@ -152,6 +155,7 @@ var ( errHTTPConflictPhoneNumberExists = &errHTTP{40904, http.StatusConflict, "conflict: phone number already exists", "", nil} errHTTPConflictProvisionedUserChange = &errHTTP{40905, http.StatusConflict, "conflict: cannot change or delete provisioned user", "", nil} errHTTPConflictProvisionedTokenChange = &errHTTP{40906, http.StatusConflict, "conflict: cannot change or delete provisioned token", "", nil} + errHTTPConflictEmailExists = &errHTTP{40907, http.StatusConflict, "conflict: email address already exists", "", nil} errHTTPGonePhoneVerificationExpired = &errHTTP{41001, http.StatusGone, "phone number verification expired or does not exist", "", nil} errHTTPEntityTooLargeAttachment = &errHTTP{41301, http.StatusRequestEntityTooLarge, "attachment too large, or bandwidth limit reached", "https://ntfy.sh/docs/publish/#limitations", nil} errHTTPEntityTooLargeMatrixRequest = &errHTTP{41302, http.StatusRequestEntityTooLarge, "Matrix request is larger than the max allowed length", "", nil} diff --git a/server/server.go b/server/server.go index ff631359..f265986b 100644 --- a/server/server.go +++ b/server/server.go @@ -36,6 +36,7 @@ import ( "heckel.io/ntfy/v2/db" "heckel.io/ntfy/v2/db/pg" "heckel.io/ntfy/v2/log" + "heckel.io/ntfy/v2/mail" "heckel.io/ntfy/v2/message" "heckel.io/ntfy/v2/model" "heckel.io/ntfy/v2/payments" @@ -57,6 +58,7 @@ type Server struct { smtpServer *smtp.Server smtpServerBackend *smtpBackend smtpSender mailer + mailSender *mail.Sender topics map[string]*topic visitors map[string]*visitor // ip: or user: firebaseClient *firebaseClient @@ -112,6 +114,8 @@ var ( apiAccountReservationPath = "/v1/account/reservation" apiAccountPhonePath = "/v1/account/phone" apiAccountPhoneVerifyPath = "/v1/account/phone/verify" + apiAccountEmailPath = "/v1/account/email" + apiAccountEmailVerifyPath = "/v1/account/email/verify" apiAccountBillingPortalPath = "/v1/account/billing/portal" apiAccountBillingWebhookPath = "/v1/account/billing/webhook" apiAccountBillingSubscriptionPath = "/v1/account/billing/subscription" @@ -173,8 +177,15 @@ const ( // subscriber (if configured). func New(conf *Config) (*Server, error) { var mailer mailer + var mailSender *mail.Sender if conf.SMTPSenderAddr != "" { mailer = &smtpSender{config: conf} + mailSender = mail.NewSender(&mail.Config{ + SMTPAddr: conf.SMTPSenderAddr, + SMTPUser: conf.SMTPSenderUser, + SMTPPass: conf.SMTPSenderPass, + From: conf.SMTPSenderFrom, + }) } var stripe stripeAPI if payments.Available && conf.StripeSecretKey != "" { @@ -278,6 +289,7 @@ func New(conf *Config) (*Server, error) { attachment: attachmentStore, firebaseClient: firebaseClient, smtpSender: mailer, + mailSender: mailSender, topics: topics, userManager: userManager, messages: messages, @@ -594,6 +606,12 @@ func (s *Server) handleInternal(w http.ResponseWriter, r *http.Request, v *visit return s.ensureUser(s.ensureCallsEnabled(s.withAccountSync(s.handleAccountPhoneNumberAdd)))(w, r, v) } else if r.Method == http.MethodDelete && r.URL.Path == apiAccountPhonePath { return s.ensureUser(s.ensureCallsEnabled(s.withAccountSync(s.handleAccountPhoneNumberDelete)))(w, r, v) + } else if r.Method == http.MethodPut && r.URL.Path == apiAccountEmailVerifyPath { + return s.ensureUser(s.ensureEmailsEnabled(s.withAccountSync(s.handleAccountEmailVerify)))(w, r, v) + } else if r.Method == http.MethodPut && r.URL.Path == apiAccountEmailPath { + return s.ensureUser(s.ensureEmailsEnabled(s.withAccountSync(s.handleAccountEmailAdd)))(w, r, v) + } else if r.Method == http.MethodDelete && r.URL.Path == apiAccountEmailPath { + return s.ensureUser(s.ensureEmailsEnabled(s.withAccountSync(s.handleAccountEmailDelete)))(w, r, v) } else if r.Method == http.MethodPost && apiWebPushPath == r.URL.Path { return s.ensureWebPushEnabled(s.limitRequests(s.handleWebPushUpdate))(w, r, v) } else if r.Method == http.MethodDelete && apiWebPushPath == r.URL.Path { @@ -865,9 +883,17 @@ func (s *Server) handlePublishInternal(r *http.Request, v *visitor) (*model.Mess return nil, errHTTPInsufficientStorageUnifiedPush.With(t) } else if !util.ContainsIP(s.config.VisitorRequestExemptPrefixes, v.ip) && !vrate.MessageAllowed() { return nil, errHTTPTooManyRequestsLimitMessages.With(t) - } else if email != "" && !vrate.EmailAllowed() { - return nil, errHTTPTooManyRequestsLimitEmails.With(t) - } else if call != "" { + } else if email != "" { + if !vrate.EmailAllowed() { + return nil, errHTTPTooManyRequestsLimitEmails.With(t) + } + var httpErr *errHTTP + email, httpErr = s.convertEmailAddress(v.User(), email) + if httpErr != nil { + return nil, httpErr.With(t) + } + } + if call != "" { var httpErr *errHTTP call, httpErr = s.convertPhoneNumber(v.User(), call) if httpErr != nil { diff --git a/server/server.yml b/server/server.yml index 9dc92968..471d5b88 100644 --- a/server/server.yml +++ b/server/server.yml @@ -199,6 +199,13 @@ # smtp-sender-user: # smtp-sender-pass: +# If set to true, only verified email recipients will receive email notifications. +# Anonymous users will not be able to send emails, and authenticated users must verify +# their email addresses first. Users can use "yes"/"true"/"1" as the email value to +# send to their first verified address. +# +# smtp-sender-email-verify: false + # If enabled, ntfy will launch a lightweight SMTP server for incoming messages. Once configured, users can send # emails to a topic e-mail address to publish messages to a topic. # diff --git a/server/server_account.go b/server/server_account.go index 7b719533..93859490 100644 --- a/server/server_account.go +++ b/server/server_account.go @@ -160,6 +160,15 @@ func (s *Server) handleAccountGet(w http.ResponseWriter, r *http.Request, v *vis response.PhoneNumbers = phoneNumbers } } + if s.mailSender != nil { + emails, err := s.userManager.Emails(u.ID) + if err != nil { + return err + } + if len(emails) > 0 { + response.Emails = emails + } + } } else { response.Username = user.Everyone response.Role = string(user.RoleAnonymous) @@ -606,6 +615,99 @@ func (s *Server) handleAccountPhoneNumberDelete(w http.ResponseWriter, r *http.R return s.writeJSON(w, newSuccessResponse()) } +func (s *Server) handleAccountEmailVerify(w http.ResponseWriter, r *http.Request, v *visitor) error { + u := v.User() + req, err := readJSONWithLimit[apiAccountEmailVerifyRequest](r.Body, jsonBodyBytesLimit, false) + if err != nil { + return err + } else if !emailAddressRegex.MatchString(req.Email) { + return errHTTPBadRequestEmailAddressInvalid + } + // Check user is allowed to add emails + if u == nil || (u.IsUser() && u.Tier == nil) { + return errHTTPUnauthorized + } else if u.IsUser() && u.Tier.EmailLimit == 0 { + return errHTTPUnauthorized + } + // Check if email already exists + emails, err := s.userManager.Emails(u.ID) + if err != nil { + return err + } else if util.Contains(emails, req.Email) { + return errHTTPConflictEmailExists + } + // Send verification email + logvr(v, r).Tag(tagAccount).Field("email", req.Email).Debug("Sending email verification") + if err := s.mailSender.SendVerification(req.Email); err != nil { + return err + } + return s.writeJSON(w, newSuccessResponse()) +} + +func (s *Server) handleAccountEmailAdd(w http.ResponseWriter, r *http.Request, v *visitor) error { + u := v.User() + req, err := readJSONWithLimit[apiAccountEmailAddRequest](r.Body, jsonBodyBytesLimit, false) + if err != nil { + return err + } + if !emailAddressRegex.MatchString(req.Email) { + return errHTTPBadRequestEmailAddressInvalid + } + if !s.mailSender.CheckVerification(req.Email, req.Code) { + return errHTTPBadRequestEmailVerificationCodeInvalid + } + logvr(v, r).Tag(tagAccount).Field("email", req.Email).Debug("Adding email as verified") + if err := s.userManager.AddEmail(u.ID, req.Email); err != nil { + return err + } + return s.writeJSON(w, newSuccessResponse()) +} + +func (s *Server) handleAccountEmailDelete(w http.ResponseWriter, r *http.Request, v *visitor) error { + u := v.User() + req, err := readJSONWithLimit[apiAccountEmailVerifyRequest](r.Body, jsonBodyBytesLimit, false) + if err != nil { + return err + } + if !emailAddressRegex.MatchString(req.Email) { + return errHTTPBadRequestEmailAddressInvalid + } + logvr(v, r).Tag(tagAccount).Field("email", req.Email).Debug("Deleting verified email") + if err := s.userManager.RemoveEmail(u.ID, req.Email); err != nil { + return err + } + return s.writeJSON(w, newSuccessResponse()) +} + +// convertEmailAddress checks the email address against the user's verified email list. +// If smtp-sender-email-verify is false (default), the email is passed through as-is for +// backwards compatibility. If true, the user must be authenticated and the email must be +// in their verified list. "yes"/"true"/"1" resolves to the first verified email. +func (s *Server) convertEmailAddress(u *user.User, email string) (string, *errHTTP) { + if !s.config.SMTPSenderEmailVerify { + return email, nil + } + if u == nil { + return "", errHTTPBadRequestAnonymousEmailNotAllowed + } + if s.userManager == nil { + return email, nil + } + emails, err := s.userManager.Emails(u.ID) + if err != nil { + return "", errHTTPInternalError + } + if len(emails) == 0 { + return "", errHTTPBadRequestEmailAddressNotVerified + } + if toBool(email) { + return emails[0], nil + } else if util.Contains(emails, email) { + return email, nil + } + return "", errHTTPBadRequestEmailAddressNotVerified +} + // publishSyncEventAsync kicks of a Go routine to publish a sync message to the user's sync topic func (s *Server) publishSyncEventAsync(v *visitor) { go func() { diff --git a/server/server_manager.go b/server/server_manager.go index 387ad2b8..5251e1aa 100644 --- a/server/server_manager.go +++ b/server/server_manager.go @@ -15,6 +15,9 @@ func (s *Server) execManager() { s.pruneAttachments() s.pruneMessages() s.pruneAndNotifyWebPushSubscriptions() + if s.mailSender != nil { + s.mailSender.ExpireVerificationCodes() + } // Message count messagesCached, err := s.messageCache.MessagesCount() diff --git a/server/server_middleware.go b/server/server_middleware.go index 17ae0963..07457f2f 100644 --- a/server/server_middleware.go +++ b/server/server_middleware.go @@ -103,6 +103,15 @@ 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.mailSender == nil || s.userManager == nil { + return errHTTPNotFound + } + return next(w, r, v) + } +} + func (s *Server) ensurePaymentsEnabled(next handleFunc) handleFunc { return func(w http.ResponseWriter, r *http.Request, v *visitor) error { if s.config.StripeSecretKey == "" || s.stripe == nil { diff --git a/server/types.go b/server/types.go index 77a3c33e..c9d4688d 100644 --- a/server/types.go +++ b/server/types.go @@ -226,6 +226,15 @@ type apiAccountPhoneNumberAddRequest struct { Code string `json:"code"` // Only set when adding a phone number } +type apiAccountEmailVerifyRequest struct { + Email string `json:"email"` +} + +type apiAccountEmailAddRequest struct { + Email string `json:"email"` + Code string `json:"code"` +} + type apiAccountTier struct { Code string `json:"code"` Name string `json:"name"` @@ -282,6 +291,7 @@ type apiAccountResponse struct { Reservations []*apiAccountReservation `json:"reservations,omitempty"` Tokens []*apiAccountTokenResponse `json:"tokens,omitempty"` PhoneNumbers []string `json:"phone_numbers,omitempty"` + Emails []string `json:"emails,omitempty"` Tier *apiAccountTier `json:"tier,omitempty"` Limits *apiAccountLimits `json:"limits,omitempty"` Stats *apiAccountStats `json:"stats,omitempty"` diff --git a/user/manager.go b/user/manager.go index 99bd705e..303c7a49 100644 --- a/user/manager.go +++ b/user/manager.go @@ -1294,6 +1294,56 @@ func (a *Manager) readPhoneNumber(rows *sql.Rows) (string, error) { return phoneNumber, nil } +// Emails returns all verified email addresses for the user with the given user ID +func (a *Manager) Emails(userID string) ([]string, error) { + rows, err := a.db.ReadOnly().Query(a.queries.selectEmails, userID) + if err != nil { + return nil, err + } + defer rows.Close() + emails := make([]string, 0) + for { + email, err := a.readEmail(rows) + if errors.Is(err, ErrEmailNotFound) { + break + } else if err != nil { + return nil, err + } + emails = append(emails, email) + } + return emails, nil +} + +// AddEmail adds a verified email address to the user with the given user ID +func (a *Manager) AddEmail(userID, email string) error { + if _, err := a.db.Exec(a.queries.insertEmail, userID, email); err != nil { + if isUniqueConstraintError(err) { + return ErrEmailExists + } + return err + } + return nil +} + +// RemoveEmail deletes a verified email address from the user with the given user ID +func (a *Manager) RemoveEmail(userID, email string) error { + _, err := a.db.Exec(a.queries.deleteEmail, userID, email) + return err +} + +func (a *Manager) readEmail(rows *sql.Rows) (string, error) { + var email string + if !rows.Next() { + return "", ErrEmailNotFound + } + if err := rows.Scan(&email); err != nil { + return "", err + } else if err := rows.Err(); err != nil { + return "", err + } + return email, nil +} + // ChangeBilling updates a user's billing fields func (a *Manager) ChangeBilling(username string, billing *Billing) error { if _, err := a.db.Exec(a.queries.updateBilling, nullString(billing.StripeCustomerID), nullString(billing.StripeSubscriptionID), nullString(string(billing.StripeSubscriptionStatus)), nullString(string(billing.StripeSubscriptionInterval)), nullInt64(billing.StripeSubscriptionPaidUntil.Unix()), nullInt64(billing.StripeSubscriptionCancelAt.Unix()), username); err != nil { diff --git a/user/manager_postgres.go b/user/manager_postgres.go index 77c35ece..efa9998e 100644 --- a/user/manager_postgres.go +++ b/user/manager_postgres.go @@ -208,6 +208,11 @@ const ( postgresInsertPhoneNumberQuery = `INSERT INTO user_phone (user_id, phone_number) VALUES ($1, $2)` postgresDeletePhoneNumberQuery = `DELETE FROM user_phone WHERE user_id = $1 AND phone_number = $2` + // Email queries + postgresSelectEmailsQuery = `SELECT email FROM user_email WHERE user_id = $1` + postgresInsertEmailQuery = `INSERT INTO user_email (user_id, email) VALUES ($1, $2)` + postgresDeleteEmailQuery = `DELETE FROM user_email WHERE user_id = $1 AND email = $2` + // Billing queries postgresUpdateBillingQuery = ` UPDATE "user" @@ -274,6 +279,9 @@ var postgresQueries = queries{ selectPhoneNumbers: postgresSelectPhoneNumbersQuery, insertPhoneNumber: postgresInsertPhoneNumberQuery, deletePhoneNumber: postgresDeletePhoneNumberQuery, + selectEmails: postgresSelectEmailsQuery, + insertEmail: postgresInsertEmailQuery, + deleteEmail: postgresDeleteEmailQuery, updateBilling: postgresUpdateBillingQuery, } diff --git a/user/manager_postgres_schema.go b/user/manager_postgres_schema.go index 3684c279..ba8502f2 100644 --- a/user/manager_postgres_schema.go +++ b/user/manager_postgres_schema.go @@ -72,6 +72,11 @@ const ( phone_number TEXT NOT NULL, PRIMARY KEY (user_id, phone_number) ); + CREATE TABLE IF NOT EXISTS user_email ( + user_id TEXT NOT NULL REFERENCES "user"(id) ON DELETE CASCADE, + email TEXT NOT NULL, + PRIMARY KEY (user_id, email) + ); CREATE TABLE IF NOT EXISTS schema_version ( store TEXT PRIMARY KEY, version INT NOT NULL @@ -84,21 +89,55 @@ const ( // Schema table management queries for Postgres const ( - postgresCurrentSchemaVersion = 6 + postgresCurrentSchemaVersion = 7 postgresSelectSchemaVersionQuery = `SELECT version FROM schema_version WHERE store = 'user'` postgresInsertSchemaVersionQuery = `INSERT INTO schema_version (store, version) VALUES ('user', $1)` ) +const ( + postgresMigrate6To7UpdateQueries = ` + CREATE TABLE IF NOT EXISTS user_email ( + user_id TEXT NOT NULL REFERENCES "user"(id) ON DELETE CASCADE, + email TEXT NOT NULL, + PRIMARY KEY (user_id, email) + ); + ` + postgresUpdateSchemaVersionQuery = `UPDATE schema_version SET version = $1 WHERE store = 'user'` +) + +var postgresMigrations = map[int]func(db *sql.DB) error{ + 6: postgresMigrateFrom6, +} + func setupPostgres(db *sql.DB) error { var schemaVersion int err := db.QueryRow(postgresSelectSchemaVersionQuery).Scan(&schemaVersion) if err != nil { return setupNewPostgres(db) } - if schemaVersion > postgresCurrentSchemaVersion { + if schemaVersion == postgresCurrentSchemaVersion { + return nil + } else if schemaVersion > postgresCurrentSchemaVersion { return fmt.Errorf("unexpected schema version: version %d is higher than current version %d", schemaVersion, postgresCurrentSchemaVersion) } - // Note: PostgreSQL migrations will be added when needed + for i := schemaVersion; i < postgresCurrentSchemaVersion; i++ { + fn, ok := postgresMigrations[i] + if !ok { + return fmt.Errorf("cannot find migration step from schema version %d to %d", i, i+1) + } else if err := fn(db); err != nil { + return err + } + } + return nil +} + +func postgresMigrateFrom6(db *sql.DB) error { + if _, err := db.Exec(postgresMigrate6To7UpdateQueries); err != nil { + return err + } + if _, err := db.Exec(postgresUpdateSchemaVersionQuery, 7); err != nil { + return err + } return nil } diff --git a/user/manager_sqlite.go b/user/manager_sqlite.go index e92c6349..8db75cad 100644 --- a/user/manager_sqlite.go +++ b/user/manager_sqlite.go @@ -207,6 +207,11 @@ const ( sqliteInsertPhoneNumberQuery = `INSERT INTO user_phone (user_id, phone_number) VALUES (?, ?)` sqliteDeletePhoneNumberQuery = `DELETE FROM user_phone WHERE user_id = ? AND phone_number = ?` + // Email queries + sqliteSelectEmailsQuery = `SELECT email FROM user_email WHERE user_id = ?` + sqliteInsertEmailQuery = `INSERT INTO user_email (user_id, email) VALUES (?, ?)` + sqliteDeleteEmailQuery = `DELETE FROM user_email WHERE user_id = ? AND email = ?` + // Billing queries sqliteUpdateBillingQuery = ` UPDATE user @@ -272,6 +277,9 @@ var sqliteQueries = queries{ selectPhoneNumbers: sqliteSelectPhoneNumbersQuery, insertPhoneNumber: sqliteInsertPhoneNumberQuery, deletePhoneNumber: sqliteDeletePhoneNumberQuery, + selectEmails: sqliteSelectEmailsQuery, + insertEmail: sqliteInsertEmailQuery, + deleteEmail: sqliteDeleteEmailQuery, updateBilling: sqliteUpdateBillingQuery, } diff --git a/user/manager_sqlite_schema.go b/user/manager_sqlite_schema.go index 01942163..6ee24f8c 100644 --- a/user/manager_sqlite_schema.go +++ b/user/manager_sqlite_schema.go @@ -85,6 +85,12 @@ const ( PRIMARY KEY (user_id, phone_number), FOREIGN KEY (user_id) REFERENCES user (id) ON DELETE CASCADE ); + CREATE TABLE IF NOT EXISTS user_email ( + user_id TEXT NOT NULL, + email TEXT NOT NULL, + PRIMARY KEY (user_id, email), + FOREIGN KEY (user_id) REFERENCES user (id) ON DELETE CASCADE + ); CREATE TABLE IF NOT EXISTS schemaVersion ( id INT PRIMARY KEY, version INT NOT NULL @@ -101,7 +107,7 @@ const ( // Schema version table management for SQLite const ( - sqliteCurrentSchemaVersion = 6 + sqliteCurrentSchemaVersion = 7 sqliteInsertSchemaVersionQuery = `INSERT INTO schemaVersion VALUES (1, ?)` sqliteUpdateSchemaVersionQuery = `UPDATE schemaVersion SET version = ? WHERE id = 1` sqliteSelectSchemaVersionQuery = `SELECT version FROM schemaVersion WHERE id = 1` @@ -220,6 +226,16 @@ const ( UPDATE user_access SET topic = REPLACE(topic, '_', '\_'); ` + // 6 -> 7 + sqliteMigrate6To7UpdateQueries = ` + CREATE TABLE IF NOT EXISTS user_email ( + user_id TEXT NOT NULL, + email TEXT NOT NULL, + PRIMARY KEY (user_id, email), + FOREIGN KEY (user_id) REFERENCES user (id) ON DELETE CASCADE + ); + ` + // 5 -> 6 sqliteMigrate5To6UpdateQueries = ` PRAGMA foreign_keys=off; @@ -322,6 +338,7 @@ var ( 3: sqliteMigrateFrom3, 4: sqliteMigrateFrom4, 5: sqliteMigrateFrom5, + 6: sqliteMigrateFrom6, } ) @@ -463,3 +480,16 @@ func sqliteMigrateFrom5(sqlDB *sql.DB) error { return nil }) } + +func sqliteMigrateFrom6(sqlDB *sql.DB) error { + log.Tag(tag).Info("Migrating user database schema: from 6 to 7") + return db.ExecTx(sqlDB, func(tx *sql.Tx) error { + if _, err := tx.Exec(sqliteMigrate6To7UpdateQueries); err != nil { + return err + } + if _, err := tx.Exec(sqliteUpdateSchemaVersionQuery, 7); err != nil { + return err + } + return nil + }) +} diff --git a/user/types.go b/user/types.go index 08c65220..d0d40e33 100644 --- a/user/types.go +++ b/user/types.go @@ -271,6 +271,8 @@ var ( ErrPhoneNumberNotFound = errors.New("phone number not found") ErrTooManyReservations = errors.New("new tier has lower reservation limit") ErrPhoneNumberExists = errors.New("phone number already exists") + ErrEmailNotFound = errors.New("email not found") + ErrEmailExists = errors.New("email already exists") ErrProvisionedUserChange = errors.New("cannot change or delete provisioned user") ErrProvisionedTokenChange = errors.New("cannot change or delete provisioned token") ) @@ -343,6 +345,11 @@ type queries struct { insertPhoneNumber string deletePhoneNumber string + // Email queries + selectEmails string + insertEmail string + deleteEmail string + // Billing queries updateBilling string } diff --git a/web/public/static/langs/en.json b/web/public/static/langs/en.json index 19fe2195..077d021c 100644 --- a/web/public/static/langs/en.json +++ b/web/public/static/langs/en.json @@ -215,6 +215,18 @@ "account_basics_phone_numbers_dialog_check_verification_button": "Confirm code", "account_basics_phone_numbers_dialog_channel_sms": "SMS", "account_basics_phone_numbers_dialog_channel_call": "Call", + "account_basics_emails_title": "Verified email recipients", + "account_basics_emails_description": "For email notifications", + "account_basics_emails_no_emails_yet": "No verified emails yet", + "account_basics_emails_copied_to_clipboard": "Email address copied to clipboard", + "account_basics_emails_dialog_title": "Add email address", + "account_basics_emails_dialog_description": "To receive email notifications, you need to add and verify at least one email address. A verification code will be sent to your email.", + "account_basics_emails_dialog_email_label": "Email address", + "account_basics_emails_dialog_email_placeholder": "e.g. user@example.com", + "account_basics_emails_dialog_verify_button": "Add email", + "account_basics_emails_dialog_code_label": "Verification code", + "account_basics_emails_dialog_code_placeholder": "e.g. 123456", + "account_basics_emails_dialog_check_verification_button": "Confirm", "account_basics_cannot_edit_or_delete_provisioned_user": "A provisioned user cannot be edited or deleted", "account_usage_title": "Usage", "account_usage_of_limit": "of {{limit}}", diff --git a/web/src/app/AccountApi.js b/web/src/app/AccountApi.js index d9380438..5b44391d 100644 --- a/web/src/app/AccountApi.js +++ b/web/src/app/AccountApi.js @@ -2,6 +2,8 @@ import i18n from "i18next"; import { accountBillingPortalUrl, accountBillingSubscriptionUrl, + accountEmailUrl, + accountEmailVerifyUrl, accountPasswordUrl, accountPhoneUrl, accountPhoneVerifyUrl, @@ -339,6 +341,43 @@ class AccountApi { }); } + async verifyEmail(email) { + const url = accountEmailVerifyUrl(config.base_url); + console.log(`[AccountApi] Sending email verification ${url}`); + await fetchOrThrow(url, { + method: "PUT", + headers: withBearerAuth({}, session.token()), + body: JSON.stringify({ + email, + }), + }); + } + + async addEmail(email, code) { + const url = accountEmailUrl(config.base_url); + console.log(`[AccountApi] Adding email with verification code ${url}`); + await fetchOrThrow(url, { + method: "PUT", + headers: withBearerAuth({}, session.token()), + body: JSON.stringify({ + email, + code, + }), + }); + } + + async deleteEmail(email) { + const url = accountEmailUrl(config.base_url); + console.log(`[AccountApi] Deleting email ${url}`); + await fetchOrThrow(url, { + method: "DELETE", + headers: withBearerAuth({}, session.token()), + body: JSON.stringify({ + email, + }), + }); + } + async sync() { try { if (!session.token()) { diff --git a/web/src/app/utils.js b/web/src/app/utils.js index 8e27365b..d6467eb7 100644 --- a/web/src/app/utils.js +++ b/web/src/app/utils.js @@ -34,6 +34,8 @@ export const accountBillingSubscriptionUrl = (baseUrl) => `${baseUrl}/v1/account export const accountBillingPortalUrl = (baseUrl) => `${baseUrl}/v1/account/billing/portal`; export const accountPhoneUrl = (baseUrl) => `${baseUrl}/v1/account/phone`; export const accountPhoneVerifyUrl = (baseUrl) => `${baseUrl}/v1/account/phone/verify`; +export const accountEmailUrl = (baseUrl) => `${baseUrl}/v1/account/email`; +export const accountEmailVerifyUrl = (baseUrl) => `${baseUrl}/v1/account/email/verify`; export const validUrl = (url) => url.match(/^https?:\/\/.+/); diff --git a/web/src/components/Account.jsx b/web/src/components/Account.jsx index 508d6de2..5b732719 100644 --- a/web/src/components/Account.jsx +++ b/web/src/components/Account.jsx @@ -84,6 +84,7 @@ const Basics = () => { + @@ -354,6 +355,198 @@ const AccountType = () => { ); }; +const VerifiedEmails = () => { + const { t } = useTranslation(); + const { account } = useContext(AccountContext); + const [dialogKey, setDialogKey] = useState(0); + const [dialogOpen, setDialogOpen] = useState(false); + const [snackOpen, setSnackOpen] = useState(false); + const labelId = "prefVerifiedEmails"; + + const handleDialogOpen = () => { + setDialogKey((prev) => prev + 1); + setDialogOpen(true); + }; + + const handleDialogClose = () => { + setDialogOpen(false); + }; + + const handleCopy = (email) => { + copyToClipboard(email); + setSnackOpen(true); + }; + + const handleDelete = async (email) => { + try { + await accountApi.deleteEmail(email); + } catch (e) { + console.log(`[Account] Error deleting email`, e); + if (e instanceof UnauthorizedError) { + await session.resetAndRedirect(routes.login); + } + } + }; + + if (!config.enable_emails) { + return null; + } + + if (account?.limits.emails === 0) { + return ( + + {t("account_basics_emails_title")} + {config.enable_payments && } + + } + description={t("account_basics_emails_description")} + > + {t("account_usage_emails_none")} + + ); + } + + return ( + +
+ {account?.emails?.map((email) => ( + + {email} + + } + variant="outlined" + onClick={() => handleCopy(email)} + onDelete={() => handleDelete(email)} + /> + ))} + {!account?.emails && {t("account_basics_emails_no_emails_yet")}} + + + +
+ + + setSnackOpen(false)} + message={t("account_basics_emails_copied_to_clipboard")} + /> + +
+ ); +}; + +const AddEmailDialog = (props) => { + const theme = useTheme(); + const { t } = useTranslation(); + const [error, setError] = useState(""); + const [email, setEmail] = useState(""); + const [code, setCode] = useState(""); + const [sending, setSending] = useState(false); + const [verificationCodeSent, setVerificationCodeSent] = useState(false); + const fullScreen = useMediaQuery(theme.breakpoints.down("sm")); + + const verifyEmail = async () => { + try { + setSending(true); + await accountApi.verifyEmail(email); + setVerificationCodeSent(true); + } catch (e) { + console.log(`[Account] Error sending email verification`, e); + if (e instanceof UnauthorizedError) { + await session.resetAndRedirect(routes.login); + } else { + setError(e.message); + } + } finally { + setSending(false); + } + }; + + const checkVerifyEmail = async () => { + try { + setSending(true); + await accountApi.addEmail(email, code); + props.onClose(); + } catch (e) { + console.log(`[Account] Error confirming email verification`, e); + if (e instanceof UnauthorizedError) { + await session.resetAndRedirect(routes.login); + } else { + setError(e.message); + } + } finally { + setSending(false); + } + }; + + const handleDialogSubmit = async () => { + if (!verificationCodeSent) { + await verifyEmail(); + } else { + await checkVerifyEmail(); + } + }; + + const handleCancel = () => { + if (verificationCodeSent) { + setVerificationCodeSent(false); + setCode(""); + } else { + props.onClose(); + } + }; + + return ( + + {t("account_basics_emails_dialog_title")} + + {t("account_basics_emails_dialog_description")} + {!verificationCodeSent && ( + setEmail(ev.target.value)} + fullWidth + variant="standard" + /> + )} + {verificationCodeSent && ( + setCode(ev.target.value)} + fullWidth + inputProps={{ inputMode: "numeric", pattern: "[0-9]*" }} + variant="standard" + /> + )} + + + + + + + ); +}; + const PhoneNumbers = () => { const { t } = useTranslation(); const { account } = useContext(AccountContext);