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 (
+
+ );
+};
+
const PhoneNumbers = () => {
const { t } = useTranslation();
const { account } = useContext(AccountContext);