binwiederhier 2 лет назад
Родитель
Сommit
f99159ee5b

+ 1 - 4
cmd/serve.go

@@ -71,7 +71,7 @@ var flagsServe = append(
 	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-')"}),
-	altsrc.NewStringFlag(&cli.StringFlag{Name: "twilio-account", Aliases: []string{"twilio_account"}, EnvVars: []string{"NTFY_TWILIO_ACCOUNT"}, Usage: "Twilio account SID, used for SMS and calling, e.g. AC123..."}),
+	altsrc.NewStringFlag(&cli.StringFlag{Name: "twilio-account", Aliases: []string{"twilio_account"}, EnvVars: []string{"NTFY_TWILIO_ACCOUNT"}, Usage: "Twilio account SID, used for phone calls, e.g. AC123..."}),
 	altsrc.NewStringFlag(&cli.StringFlag{Name: "twilio-auth-token", Aliases: []string{"twilio_auth_token"}, EnvVars: []string{"NTFY_TWILIO_AUTH_TOKEN"}, Usage: "Twilio auth token"}),
 	altsrc.NewStringFlag(&cli.StringFlag{Name: "twilio-from-number", Aliases: []string{"twilio_from_number"}, EnvVars: []string{"NTFY_TWILIO_FROM_NUMBER"}, Usage: "Twilio number to use for outgoing calls and text messages"}),
 	altsrc.NewStringFlag(&cli.StringFlag{Name: "twilio-verify-service", Aliases: []string{"twilio_verify_service"}, EnvVars: []string{"NTFY_TWILIO_VERIFY_SERVICE"}, Usage: "Twilio Verify service ID, used for phone number verification"}),
@@ -84,7 +84,6 @@ var flagsServe = append(
 	altsrc.NewStringFlag(&cli.StringFlag{Name: "visitor-request-limit-exempt-hosts", Aliases: []string{"visitor_request_limit_exempt_hosts"}, EnvVars: []string{"NTFY_VISITOR_REQUEST_LIMIT_EXEMPT_HOSTS"}, Value: "", Usage: "hostnames and/or IP addresses of hosts that will be exempt from the visitor request limit"}),
 	altsrc.NewIntFlag(&cli.IntFlag{Name: "visitor-message-daily-limit", Aliases: []string{"visitor_message_daily_limit"}, EnvVars: []string{"NTFY_VISITOR_MESSAGE_DAILY_LIMIT"}, Value: server.DefaultVisitorMessageDailyLimit, Usage: "max messages per visitor per day, derived from request limit if unset"}),
 	altsrc.NewIntFlag(&cli.IntFlag{Name: "visitor-email-limit-burst", Aliases: []string{"visitor_email_limit_burst"}, EnvVars: []string{"NTFY_VISITOR_EMAIL_LIMIT_BURST"}, Value: server.DefaultVisitorEmailLimitBurst, Usage: "initial limit of e-mails per visitor"}),
-	altsrc.NewIntFlag(&cli.IntFlag{Name: "visitor-sms-daily-limit", Aliases: []string{"visitor_sms_daily_limit"}, EnvVars: []string{"NTFY_VISITOR_SMS_DAILY_LIMIT"}, Value: server.DefaultVisitorSMSDailyLimit, Usage: "max number of SMS messages per visitor per day"}),
 	altsrc.NewIntFlag(&cli.IntFlag{Name: "visitor-call-daily-limit", Aliases: []string{"visitor_call_daily_limit"}, EnvVars: []string{"NTFY_VISITOR_CALL_DAILY_LIMIT"}, Value: server.DefaultVisitorCallDailyLimit, Usage: "max number of phone calls per visitor per day"}),
 	altsrc.NewDurationFlag(&cli.DurationFlag{Name: "visitor-email-limit-replenish", Aliases: []string{"visitor_email_limit_replenish"}, EnvVars: []string{"NTFY_VISITOR_EMAIL_LIMIT_REPLENISH"}, Value: server.DefaultVisitorEmailLimitReplenish, Usage: "interval at which burst limit is replenished (one per x)"}),
 	altsrc.NewBoolFlag(&cli.BoolFlag{Name: "visitor-subscriber-rate-limiting", Aliases: []string{"visitor_subscriber_rate_limiting"}, EnvVars: []string{"NTFY_VISITOR_SUBSCRIBER_RATE_LIMITING"}, Value: false, Usage: "enables subscriber-based rate limiting"}),
@@ -172,7 +171,6 @@ func execServe(c *cli.Context) error {
 	visitorMessageDailyLimit := c.Int("visitor-message-daily-limit")
 	visitorEmailLimitBurst := c.Int("visitor-email-limit-burst")
 	visitorEmailLimitReplenish := c.Duration("visitor-email-limit-replenish")
-	visitorSMSDailyLimit := c.Int("visitor-sms-daily-limit")
 	visitorCallDailyLimit := c.Int("visitor-call-daily-limit")
 	behindProxy := c.Bool("behind-proxy")
 	stripeSecretKey := c.String("stripe-secret-key")
@@ -336,7 +334,6 @@ func execServe(c *cli.Context) error {
 	conf.VisitorMessageDailyLimit = visitorMessageDailyLimit
 	conf.VisitorEmailLimitBurst = visitorEmailLimitBurst
 	conf.VisitorEmailLimitReplenish = visitorEmailLimitReplenish
-	conf.VisitorSMSDailyLimit = visitorSMSDailyLimit
 	conf.VisitorCallDailyLimit = visitorCallDailyLimit
 	conf.VisitorSubscriberRateLimiting = visitorSubscriberRateLimiting
 	conf.BehindProxy = behindProxy

+ 0 - 8
cmd/tier.go

@@ -18,7 +18,6 @@ const (
 	defaultMessageLimit             = 5000
 	defaultMessageExpiryDuration    = "12h"
 	defaultEmailLimit               = 20
-	defaultSMSLimit                 = 10
 	defaultCallLimit                = 10
 	defaultReservationLimit         = 3
 	defaultAttachmentFileSizeLimit  = "15M"
@@ -50,7 +49,6 @@ var cmdTier = &cli.Command{
 				&cli.Int64Flag{Name: "message-limit", Value: defaultMessageLimit, Usage: "daily message limit"},
 				&cli.StringFlag{Name: "message-expiry-duration", Value: defaultMessageExpiryDuration, Usage: "duration after which messages are deleted"},
 				&cli.Int64Flag{Name: "email-limit", Value: defaultEmailLimit, Usage: "daily email limit"},
-				&cli.Int64Flag{Name: "sms-limit", Value: defaultSMSLimit, Usage: "daily SMS limit"},
 				&cli.Int64Flag{Name: "call-limit", Value: defaultCallLimit, Usage: "daily phone call limit"},
 				&cli.Int64Flag{Name: "reservation-limit", Value: defaultReservationLimit, Usage: "topic reservation limit"},
 				&cli.StringFlag{Name: "attachment-file-size-limit", Value: defaultAttachmentFileSizeLimit, Usage: "per-attachment file size limit"},
@@ -95,7 +93,6 @@ Examples:
 				&cli.Int64Flag{Name: "message-limit", Usage: "daily message limit"},
 				&cli.StringFlag{Name: "message-expiry-duration", Usage: "duration after which messages are deleted"},
 				&cli.Int64Flag{Name: "email-limit", Usage: "daily email limit"},
-				&cli.Int64Flag{Name: "sms-limit", Usage: "daily SMS limit"},
 				&cli.Int64Flag{Name: "call-limit", Usage: "daily phone call limit"},
 				&cli.Int64Flag{Name: "reservation-limit", Usage: "topic reservation limit"},
 				&cli.StringFlag{Name: "attachment-file-size-limit", Usage: "per-attachment file size limit"},
@@ -221,7 +218,6 @@ func execTierAdd(c *cli.Context) error {
 		MessageLimit:             c.Int64("message-limit"),
 		MessageExpiryDuration:    messageExpiryDuration,
 		EmailLimit:               c.Int64("email-limit"),
-		SMSLimit:                 c.Int64("sms-limit"),
 		CallLimit:                c.Int64("call-limit"),
 		ReservationLimit:         c.Int64("reservation-limit"),
 		AttachmentFileSizeLimit:  attachmentFileSizeLimit,
@@ -275,9 +271,6 @@ func execTierChange(c *cli.Context) error {
 	if c.IsSet("email-limit") {
 		tier.EmailLimit = c.Int64("email-limit")
 	}
-	if c.IsSet("sms-limit") {
-		tier.SMSLimit = c.Int64("sms-limit")
-	}
 	if c.IsSet("call-limit") {
 		tier.CallLimit = c.Int64("call-limit")
 	}
@@ -371,7 +364,6 @@ func printTier(c *cli.Context, tier *user.Tier) {
 	fmt.Fprintf(c.App.ErrWriter, "- Message limit: %d\n", tier.MessageLimit)
 	fmt.Fprintf(c.App.ErrWriter, "- Message expiry duration: %s (%d seconds)\n", tier.MessageExpiryDuration.String(), int64(tier.MessageExpiryDuration.Seconds()))
 	fmt.Fprintf(c.App.ErrWriter, "- Email limit: %d\n", tier.EmailLimit)
-	fmt.Fprintf(c.App.ErrWriter, "- SMS limit: %d\n", tier.SMSLimit)
 	fmt.Fprintf(c.App.ErrWriter, "- Phone call limit: %d\n", tier.CallLimit)
 	fmt.Fprintf(c.App.ErrWriter, "- Reservation limit: %d\n", tier.ReservationLimit)
 	fmt.Fprintf(c.App.ErrWriter, "- Attachment file size limit: %s\n", util.FormatSize(tier.AttachmentFileSizeLimit))

+ 14 - 136
docs/publish.md

@@ -2695,169 +2695,48 @@ title `You've Got Mail` to topic `sometopic` (see [ntfy.sh/sometopic](https://nt
   <figcaption>Publishing a message via e-mail</figcaption>
 </figure>
 
-## Text message (SMS)
-_Supported on:_ :material-android: :material-apple: :material-firefox:
-
-You can forward messages as text message (SMS) by specifying a phone number a header. Similar to email notifications,
-this can be useful to blast-notify yourself on all possible channels, or to notify people that do not have the ntfy app
-installed on their phone.
-
-To forward a message as an SMS, pass a phone number in the `X-SMS` header (or its alias: `SMS`), prefixed with a plus sign
-and the country code, e.g. `+12223334444`.
-
-On ntfy.sh, this feature is only supported to [ntfy Pro](https://ntfy.sh/app) plans.
-
-=== "Command line (curl)"
-    ```
-    curl \
-        -H "SMS: +12223334444" \
-        -d "Your garage seems to be on fire 🔥. You should probably check that out, and call 0118 999 881 999 119 725 3." \
-        ntfy.sh/alerts
-    ```
-
-=== "ntfy CLI"
-    ```
-    ntfy publish \
-        --email=phil@example.com \
-        --tags=warning,skull,backup-host,ssh-login \
-        --priority=high \
-        alerts "Unknown login from 5.31.23.83 to backups.example.com"
-    ```
-
-=== "HTTP"
-    ``` http
-    POST /alerts HTTP/1.1
-    Host: ntfy.sh
-    Email: phil@example.com
-    Tags: warning,skull,backup-host,ssh-login
-    Priority: high
-
-    Unknown login from 5.31.23.83 to backups.example.com
-    ```
-
-=== "JavaScript"
-    ``` javascript
-    fetch('https://ntfy.sh/alerts', {
-        method: 'POST',
-        body: "Unknown login from 5.31.23.83 to backups.example.com",
-        headers: { 
-            'Email': 'phil@example.com',
-            'Tags': 'warning,skull,backup-host,ssh-login',
-            'Priority': 'high'
-        }
-    })
-    ```
-
-=== "Go"
-    ``` go
-    req, _ := http.NewRequest("POST", "https://ntfy.sh/alerts", 
-        strings.NewReader("Unknown login from 5.31.23.83 to backups.example.com"))
-    req.Header.Set("Email", "phil@example.com")
-    req.Header.Set("Tags", "warning,skull,backup-host,ssh-login")
-    req.Header.Set("Priority", "high")
-    http.DefaultClient.Do(req)
-    ```
-
-=== "PowerShell"
-    ``` powershell
-    $Request = @{
-      Method = "POST"
-      URI = "https://ntfy.sh/alerts"
-      Headers = @{
-        Title = "Low disk space alert"
-        Priority = "high"
-        Tags = "warning,skull,backup-host,ssh-login")
-        Email = "phil@example.com"
-      }
-      Body = "Unknown login from 5.31.23.83 to backups.example.com"
-    }
-    Invoke-RestMethod @Request
-    ```
-
-=== "Python"
-    ``` python
-    requests.post("https://ntfy.sh/alerts",
-        data="Unknown login from 5.31.23.83 to backups.example.com",
-        headers={ 
-            "Email": "phil@example.com",
-            "Tags": "warning,skull,backup-host,ssh-login",
-            "Priority": "high"
-        })
-    ```
-
-=== "PHP"
-    ``` php-inline
-    file_get_contents('https://ntfy.sh/alerts', false, stream_context_create([
-        'http' => [
-            'method' => 'POST',
-            'header' =>
-                "Content-Type: text/plain\r\n" .
-                "Email: phil@example.com\r\n" .
-                "Tags: warning,skull,backup-host,ssh-login\r\n" .
-                "Priority: high",
-            'content' => 'Unknown login from 5.31.23.83 to backups.example.com'
-        ]
-    ]));
-    ```
-
-Here's what that looks like in Google Mail:
-
-<figure markdown>
-  ![e-mail notification](static/img/screenshot-email.png){ width=600 }
-  <figcaption>E-mail notification</figcaption>
-</figure>
-
-
 ## Phone calls
 _Supported on:_ :material-android: :material-apple: :material-firefox:
 
-You can forward messages to e-mail by specifying an address in the header. This can be useful for messages that 
-you'd like to persist longer, or to blast-notify yourself on all possible channels. 
+You can use ntfy to call a phone and **read the message out loud using text-to-speech**, by specifying a phone number a header. 
+Similar to email notifications, this can be useful to blast-notify yourself on all possible channels, or to notify people that do not have 
+the ntfy app installed on their phone.
 
-Usage is easy: Simply pass the `X-Email` header (or any of its aliases: `X-E-mail`, `Email`, `E-mail`, `Mail`, or `e`).
-Only one e-mail address is supported.
+Phone numbers have to be previously verified (via the web app). To forward a message as a phone call, pass a phone number
+in the `X-Call` header (or its alias: `Call`), prefixed with a plus sign and the country code, e.g. `+12223334444`. You may
+also simply pass `yes` as a value if you only have one verified phone number.
 
-Since ntfy does not provide auth (yet), the rate limiting is pretty strict (see [limitations](#limitations)). In the 
-default configuration, you get **16 e-mails per visitor** (IP address) and then after that one per hour. On top of 
-that, your IP address appears in the e-mail body. This is to prevent abuse.
+On ntfy.sh, this feature is only supported to [ntfy Pro](https://ntfy.sh/app) plans.
 
 === "Command line (curl)"
     ```
     curl \
-        -H "Email: phil@example.com" \
-        -H "Tags: warning,skull,backup-host,ssh-login" \
-        -H "Priority: high" \
-        -d "Unknown login from 5.31.23.83 to backups.example.com" \
+        -H "Call: +12223334444" \
+        -d "Your garage seems to be on fire. You should probably check that out, and call 0118 999 881 999 119 725 3 for help." \
         ntfy.sh/alerts
-    curl -H "Email: phil@example.com" -d "You've Got Mail" 
-    curl -d "You've Got Mail" "ntfy.sh/alerts?email=phil@example.com"
     ```
 
 === "ntfy CLI"
     ```
     ntfy publish \
-        --email=phil@example.com \
-        --tags=warning,skull,backup-host,ssh-login \
-        --priority=high \
-        alerts "Unknown login from 5.31.23.83 to backups.example.com"
+        --call=+12223334444 \
+        alerts "Your garage seems to be on fire. You should probably check that out, and call 0118 999 881 999 119 725 3 for help."
     ```
 
 === "HTTP"
     ``` http
     POST /alerts HTTP/1.1
     Host: ntfy.sh
-    Email: phil@example.com
-    Tags: warning,skull,backup-host,ssh-login
-    Priority: high
+    Call: +12223334444
 
-    Unknown login from 5.31.23.83 to backups.example.com
+    Your garage seems to be on fire. You should probably check that out, and call 0118 999 881 999 119 725 3 for help.
     ```
 
 === "JavaScript"
     ``` javascript
     fetch('https://ntfy.sh/alerts', {
         method: 'POST',
-        body: "Unknown login from 5.31.23.83 to backups.example.com",
+        body: "Your garage seems to be on fire. You should probably check that out, and call 0118 999 881 999 119 725 3 for help.",
         headers: { 
             'Email': 'phil@example.com',
             'Tags': 'warning,skull,backup-host,ssh-login',
@@ -2925,7 +2804,6 @@ Here's what that looks like in Google Mail:
   <figcaption>E-mail notification</figcaption>
 </figure>
 
-
 ## Authentication
 Depending on whether the server is configured to support [access control](config.md#access-control), some topics
 may be read/write protected so that only users with the correct credentials can subscribe or publish to them.

+ 0 - 2
server/config.go

@@ -47,7 +47,6 @@ const (
 	DefaultVisitorMessageDailyLimit             = 0
 	DefaultVisitorEmailLimitBurst               = 16
 	DefaultVisitorEmailLimitReplenish           = time.Hour
-	DefaultVisitorSMSDailyLimit                 = 10
 	DefaultVisitorCallDailyLimit                = 10
 	DefaultVisitorAccountCreationLimitBurst     = 3
 	DefaultVisitorAccountCreationLimitReplenish = 24 * time.Hour
@@ -130,7 +129,6 @@ type Config struct {
 	VisitorMessageDailyLimit             int
 	VisitorEmailLimitBurst               int
 	VisitorEmailLimitReplenish           time.Duration
-	VisitorSMSDailyLimit                 int
 	VisitorCallDailyLimit                int
 	VisitorAccountCreationLimitBurst     int
 	VisitorAccountCreationLimitReplenish time.Duration

+ 5 - 4
server/errors.go

@@ -106,14 +106,16 @@ var (
 	errHTTPBadRequestNotAPaidUser                    = &errHTTP{40027, http.StatusBadRequest, "invalid request: not a paid user", "", nil}
 	errHTTPBadRequestBillingRequestInvalid           = &errHTTP{40028, http.StatusBadRequest, "invalid request: not a valid billing request", "", nil}
 	errHTTPBadRequestBillingSubscriptionExists       = &errHTTP{40029, http.StatusBadRequest, "invalid request: billing subscription already exists", "", nil}
-	errHTTPBadRequestTwilioDisabled                  = &errHTTP{40030, http.StatusBadRequest, "invalid request: SMS and calling is disabled", "https://ntfy.sh/docs/publish/#sms", nil}
-	errHTTPBadRequestPhoneNumberInvalid              = &errHTTP{40031, http.StatusBadRequest, "invalid request: phone number invalid", "https://ntfy.sh/docs/publish/#sms", nil}
+	errHTTPBadRequestTwilioDisabled                  = &errHTTP{40030, http.StatusBadRequest, "invalid request: Calling is disabled", "https://ntfy.sh/docs/publish/#phone-calls", nil}
+	errHTTPBadRequestPhoneNumberInvalid              = &errHTTP{40031, http.StatusBadRequest, "invalid request: phone number invalid", "https://ntfy.sh/docs/publish/#phone-calls", 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}
 	errHTTPConflictUserExists                        = &errHTTP{40901, http.StatusConflict, "conflict: user already exists", "", nil}
 	errHTTPConflictTopicReserved                     = &errHTTP{40902, http.StatusConflict, "conflict: access control entry for topic or topic pattern already exists", "", nil}
 	errHTTPConflictSubscriptionExists                = &errHTTP{40903, http.StatusConflict, "conflict: topic subscription already exists", "", nil}
+	errHTTPConflictPhoneNumberExists                 = &errHTTP{40904, http.StatusConflict, "conflict: phone number 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}
 	errHTTPEntityTooLargeJSONBody                    = &errHTTP{41303, http.StatusRequestEntityTooLarge, "JSON body too large", "", nil}
@@ -126,8 +128,7 @@ var (
 	errHTTPTooManyRequestsLimitReservations          = &errHTTP{42907, http.StatusTooManyRequests, "limit reached: too many topic reservations for this user", "", nil}
 	errHTTPTooManyRequestsLimitMessages              = &errHTTP{42908, http.StatusTooManyRequests, "limit reached: daily message quota reached", "https://ntfy.sh/docs/publish/#limitations", nil}
 	errHTTPTooManyRequestsLimitAuthFailure           = &errHTTP{42909, http.StatusTooManyRequests, "limit reached: too many auth failures", "https://ntfy.sh/docs/publish/#limitations", nil} // FIXME document limit
-	errHTTPTooManyRequestsLimitSMS                   = &errHTTP{42910, http.StatusTooManyRequests, "limit reached: daily SMS quota reached", "https://ntfy.sh/docs/publish/#limitations", nil}
-	errHTTPTooManyRequestsLimitCalls                 = &errHTTP{42911, http.StatusTooManyRequests, "limit reached: daily phone call quota reached", "https://ntfy.sh/docs/publish/#limitations", nil}
+	errHTTPTooManyRequestsLimitCalls                 = &errHTTP{42910, http.StatusTooManyRequests, "limit reached: daily phone call quota reached", "https://ntfy.sh/docs/publish/#limitations", nil}
 	errHTTPInternalError                             = &errHTTP{50001, http.StatusInternalServerError, "internal server error", "", nil}
 	errHTTPInternalErrorInvalidPath                  = &errHTTP{50002, http.StatusInternalServerError, "internal server error: invalid path", "", nil}
 	errHTTPInternalErrorMissingBaseURL               = &errHTTP{50003, http.StatusInternalServerError, "internal server error: base-url must be be configured for this feature", "https://ntfy.sh/docs/config/", nil}

+ 15 - 27
server/server.go

@@ -534,7 +534,6 @@ func (s *Server) handleWebConfig(w http.ResponseWriter, _ *http.Request, _ *visi
 		EnableLogin:        s.config.EnableLogin,
 		EnableSignup:       s.config.EnableSignup,
 		EnablePayments:     s.config.StripeSecretKey != "",
-		EnableSMS:          s.config.TwilioAccount != "",
 		EnableCalls:        s.config.TwilioAccount != "",
 		EnableReservations: s.config.EnableReservations,
 		BillingContact:     s.config.BillingContact,
@@ -676,7 +675,7 @@ func (s *Server) handlePublishInternal(r *http.Request, v *visitor) (*message, e
 		return nil, err
 	}
 	m := newDefaultMessage(t.ID, "")
-	cache, firebase, email, sms, call, unifiedpush, e := s.parsePublishParams(r, m)
+	cache, firebase, email, call, unifiedpush, e := s.parsePublishParams(r, m)
 	if e != nil {
 		return nil, e.With(t)
 	}
@@ -690,8 +689,6 @@ func (s *Server) handlePublishInternal(r *http.Request, v *visitor) (*message, e
 		return nil, errHTTPTooManyRequestsLimitMessages.With(t)
 	} else if email != "" && !vrate.EmailAllowed() {
 		return nil, errHTTPTooManyRequestsLimitEmails.With(t)
-	} else if sms != "" && !vrate.SMSAllowed() {
-		return nil, errHTTPTooManyRequestsLimitSMS.With(t)
 	} else if call != "" && !vrate.CallAllowed() {
 		return nil, errHTTPTooManyRequestsLimitCalls.With(t)
 	}
@@ -734,9 +731,6 @@ func (s *Server) handlePublishInternal(r *http.Request, v *visitor) (*message, e
 		if s.smtpSender != nil && email != "" {
 			go s.sendEmail(v, m, email)
 		}
-		if s.config.TwilioAccount != "" && sms != "" {
-			go s.sendSMS(v, r, m, sms)
-		}
 		if s.config.TwilioAccount != "" && call != "" {
 			go s.callPhone(v, r, m, call)
 		}
@@ -849,7 +843,7 @@ func (s *Server) forwardPollRequest(v *visitor, m *message) {
 	}
 }
 
-func (s *Server) parsePublishParams(r *http.Request, m *message) (cache bool, firebase bool, email, sms, call string, unifiedpush bool, err *errHTTP) {
+func (s *Server) parsePublishParams(r *http.Request, m *message) (cache bool, firebase bool, email, call string, unifiedpush bool, err *errHTTP) {
 	cache = readBoolParam(r, true, "x-cache", "cache")
 	firebase = readBoolParam(r, true, "x-firebase", "firebase")
 	m.Title = maybeDecodeHeader(readParam(r, "x-title", "title", "t"))
@@ -865,7 +859,7 @@ func (s *Server) parsePublishParams(r *http.Request, m *message) (cache bool, fi
 	}
 	if attach != "" {
 		if !urlRegex.MatchString(attach) {
-			return false, false, "", "", "", false, errHTTPBadRequestAttachmentURLInvalid
+			return false, false, "", "", false, errHTTPBadRequestAttachmentURLInvalid
 		}
 		m.Attachment.URL = attach
 		if m.Attachment.Name == "" {
@@ -883,25 +877,19 @@ func (s *Server) parsePublishParams(r *http.Request, m *message) (cache bool, fi
 	}
 	if icon != "" {
 		if !urlRegex.MatchString(icon) {
-			return false, false, "", "", "", false, errHTTPBadRequestIconURLInvalid
+			return false, false, "", "", false, errHTTPBadRequestIconURLInvalid
 		}
 		m.Icon = icon
 	}
 	email = readParam(r, "x-email", "x-e-mail", "email", "e-mail", "mail", "e")
 	if s.smtpSender == nil && email != "" {
-		return false, false, "", "", "", false, errHTTPBadRequestEmailDisabled
-	}
-	sms = readParam(r, "x-sms", "sms")
-	if sms != "" && s.config.TwilioAccount == "" {
-		return false, false, "", "", "", false, errHTTPBadRequestTwilioDisabled
-	} else if sms != "" && !phoneNumberRegex.MatchString(sms) {
-		return false, false, "", "", "", false, errHTTPBadRequestPhoneNumberInvalid
+		return false, false, "", "", false, errHTTPBadRequestEmailDisabled
 	}
 	call = readParam(r, "x-call", "call")
 	if call != "" && s.config.TwilioAccount == "" {
-		return false, false, "", "", "", false, errHTTPBadRequestTwilioDisabled
+		return false, false, "", "", false, errHTTPBadRequestTwilioDisabled
 	} else if call != "" && !phoneNumberRegex.MatchString(call) {
-		return false, false, "", "", "", false, errHTTPBadRequestPhoneNumberInvalid
+		return false, false, "", "", false, errHTTPBadRequestPhoneNumberInvalid
 	}
 	messageStr := strings.ReplaceAll(readParam(r, "x-message", "message", "m"), "\\n", "\n")
 	if messageStr != "" {
@@ -910,7 +898,7 @@ func (s *Server) parsePublishParams(r *http.Request, m *message) (cache bool, fi
 	var e error
 	m.Priority, e = util.ParsePriority(readParam(r, "x-priority", "priority", "prio", "p"))
 	if e != nil {
-		return false, false, "", "", "", false, errHTTPBadRequestPriorityInvalid
+		return false, false, "", "", false, errHTTPBadRequestPriorityInvalid
 	}
 	m.Tags = readCommaSeparatedParam(r, "x-tags", "tags", "tag", "ta")
 	for i, t := range m.Tags {
@@ -919,18 +907,18 @@ func (s *Server) parsePublishParams(r *http.Request, m *message) (cache bool, fi
 	delayStr := readParam(r, "x-delay", "delay", "x-at", "at", "x-in", "in")
 	if delayStr != "" {
 		if !cache {
-			return false, false, "", "", "", false, errHTTPBadRequestDelayNoCache
+			return false, false, "", "", false, errHTTPBadRequestDelayNoCache
 		}
 		if email != "" {
-			return false, false, "", "", "", false, errHTTPBadRequestDelayNoEmail // we cannot store the email address (yet)
+			return false, false, "", "", false, errHTTPBadRequestDelayNoEmail // we cannot store the email address (yet)
 		}
 		delay, err := util.ParseFutureTime(delayStr, time.Now())
 		if err != nil {
-			return false, false, "", "", "", false, errHTTPBadRequestDelayCannotParse
+			return false, false, "", "", false, errHTTPBadRequestDelayCannotParse
 		} else if delay.Unix() < time.Now().Add(s.config.MinDelay).Unix() {
-			return false, false, "", "", "", false, errHTTPBadRequestDelayTooSmall
+			return false, false, "", "", false, errHTTPBadRequestDelayTooSmall
 		} else if delay.Unix() > time.Now().Add(s.config.MaxDelay).Unix() {
-			return false, false, "", "", "", false, errHTTPBadRequestDelayTooLarge
+			return false, false, "", "", false, errHTTPBadRequestDelayTooLarge
 		}
 		m.Time = delay.Unix()
 	}
@@ -938,7 +926,7 @@ func (s *Server) parsePublishParams(r *http.Request, m *message) (cache bool, fi
 	if actionsStr != "" {
 		m.Actions, e = parseActions(actionsStr)
 		if e != nil {
-			return false, false, "", "", "", false, errHTTPBadRequestActionsInvalid.Wrap(e.Error())
+			return false, false, "", "", false, errHTTPBadRequestActionsInvalid.Wrap(e.Error())
 		}
 	}
 	unifiedpush = readBoolParam(r, false, "x-unifiedpush", "unifiedpush", "up") // see GET too!
@@ -952,7 +940,7 @@ func (s *Server) parsePublishParams(r *http.Request, m *message) (cache bool, fi
 		cache = false
 		email = ""
 	}
-	return cache, firebase, email, sms, call, unifiedpush, nil
+	return cache, firebase, email, call, unifiedpush, nil
 }
 
 // handlePublishBody consumes the PUT/POST body and decides whether the body is an attachment or the message.

+ 5 - 9
server/server_account.go

@@ -56,7 +56,6 @@ func (s *Server) handleAccountGet(w http.ResponseWriter, r *http.Request, v *vis
 			Messages:                 limits.MessageLimit,
 			MessagesExpiryDuration:   int64(limits.MessageExpiryDuration.Seconds()),
 			Emails:                   limits.EmailLimit,
-			SMS:                      limits.SMSLimit,
 			Calls:                    limits.CallLimit,
 			Reservations:             limits.ReservationsLimit,
 			AttachmentTotalSize:      limits.AttachmentTotalSizeLimit,
@@ -69,8 +68,6 @@ func (s *Server) handleAccountGet(w http.ResponseWriter, r *http.Request, v *vis
 			MessagesRemaining:            stats.MessagesRemaining,
 			Emails:                       stats.Emails,
 			EmailsRemaining:              stats.EmailsRemaining,
-			SMS:                          stats.SMS,
-			SMSRemaining:                 stats.SMSRemaining,
 			Calls:                        stats.Calls,
 			CallsRemaining:               stats.CallsRemaining,
 			Reservations:                 stats.Reservations,
@@ -542,7 +539,7 @@ func (s *Server) handleAccountPhoneNumberAdd(w http.ResponseWriter, r *http.Requ
 	// Check user is allowed to add phone numbers
 	if u == nil || (u.IsUser() && u.Tier == nil) {
 		return errHTTPUnauthorized
-	} else if u.IsUser() && u.Tier.SMSLimit == 0 && u.Tier.CallLimit == 0 {
+	} else if u.IsUser() && u.Tier.CallLimit == 0 {
 		return errHTTPUnauthorized
 	}
 	// Actually add the unverified number, and send verification
@@ -553,6 +550,9 @@ func (s *Server) handleAccountPhoneNumberAdd(w http.ResponseWriter, r *http.Requ
 		}).
 		Debug("Adding phone number, and sending verification")
 	if err := s.userManager.AddPhoneNumber(u.ID, req.Number); err != nil {
+		if err == user.ErrPhoneNumberExists {
+			return errHTTPConflictPhoneNumberExists
+		}
 		return err
 	}
 	if err := s.verifyPhone(v, r, req.Number); err != nil {
@@ -570,10 +570,6 @@ func (s *Server) handleAccountPhoneNumberVerify(w http.ResponseWriter, r *http.R
 	if !phoneNumberRegex.MatchString(req.Number) {
 		return errHTTPBadRequestPhoneNumberInvalid
 	}
-	// Check user is allowed to add phone numbers
-	if u == nil {
-		return errHTTPUnauthorized
-	}
 	// Get phone numbers, and check if it's in the list
 	phoneNumbers, err := s.userManager.PhoneNumbers(u.ID)
 	if err != nil {
@@ -581,7 +577,7 @@ func (s *Server) handleAccountPhoneNumberVerify(w http.ResponseWriter, r *http.R
 	}
 	found := false
 	for _, phoneNumber := range phoneNumbers {
-		if phoneNumber.Number == req.Number && phoneNumber.Verified {
+		if phoneNumber.Number == req.Number && !phoneNumber.Verified {
 			found = true
 			break
 		}

+ 0 - 2
server/server_payments.go

@@ -68,7 +68,6 @@ func (s *Server) handleBillingTiersGet(w http.ResponseWriter, _ *http.Request, _
 				Messages:                 freeTier.MessageLimit,
 				MessagesExpiryDuration:   int64(freeTier.MessageExpiryDuration.Seconds()),
 				Emails:                   freeTier.EmailLimit,
-				SMS:                      freeTier.SMSLimit,
 				Calls:                    freeTier.CallLimit,
 				Reservations:             freeTier.ReservationsLimit,
 				AttachmentTotalSize:      freeTier.AttachmentTotalSizeLimit,
@@ -98,7 +97,6 @@ func (s *Server) handleBillingTiersGet(w http.ResponseWriter, _ *http.Request, _
 				Messages:                 tier.MessageLimit,
 				MessagesExpiryDuration:   int64(tier.MessageExpiryDuration.Seconds()),
 				Emails:                   tier.EmailLimit,
-				SMS:                      tier.SMSLimit,
 				Calls:                    tier.CallLimit,
 				Reservations:             tier.ReservationLimit,
 				AttachmentTotalSize:      tier.AttachmentTotalSizeLimit,

+ 17 - 14
server/server_twilio.go

@@ -15,7 +15,6 @@ import (
 )
 
 const (
-	twilioMessageEndpoint     = "Messages.json"
 	twilioMessageFooterFormat = "This message was sent by %s via %s"
 	twilioCallEndpoint        = "Calls.json"
 	twilioCallFormat          = `
@@ -32,15 +31,6 @@ const (
 </Response>`
 )
 
-func (s *Server) sendSMS(v *visitor, r *http.Request, m *message, to string) {
-	body := fmt.Sprintf("%s\n\n--\n%s", m.Message, s.messageFooter(v.User(), m))
-	data := url.Values{}
-	data.Set("From", s.config.TwilioFromNumber)
-	data.Set("To", to)
-	data.Set("Body", body)
-	s.twilioMessagingRequest(v, r, m, metricSMSSentSuccess, metricSMSSentFailure, twilioMessageEndpoint, to, body, data)
-}
-
 func (s *Server) callPhone(v *visitor, r *http.Request, m *message, to string) {
 	body := fmt.Sprintf(twilioCallFormat, xmlEscapeText(m.Topic), xmlEscapeText(m.Message), xmlEscapeText(s.messageFooter(v.User(), m)))
 	data := url.Values{}
@@ -85,25 +75,38 @@ func (s *Server) checkVerifyPhone(v *visitor, r *http.Request, phoneNumber, code
 	data := url.Values{}
 	data.Set("To", phoneNumber)
 	data.Set("Code", code)
-	requestURL := fmt.Sprintf("%s/v2/Services/%s/VerificationCheck", s.config.TwilioVerifyBaseURL, s.config.TwilioAccount)
+	requestURL := fmt.Sprintf("%s/v2/Services/%s/VerificationCheck", s.config.TwilioVerifyBaseURL, s.config.TwilioVerifyService)
 	req, err := http.NewRequest(http.MethodPost, requestURL, strings.NewReader(data.Encode()))
 	if err != nil {
 		return err
 	}
 	req.Header.Set("Authorization", util.BasicAuth(s.config.TwilioAccount, s.config.TwilioAuthToken))
 	req.Header.Add("Content-Type", "application/x-www-form-urlencoded")
+	log.Fields(httpContext(req)).Field("http_body", data.Encode()).Info("Twilio call")
+	ev := logvr(v, r).
+		Tag(tagTwilio).
+		Field("twilio_to", phoneNumber)
 	resp, err := http.DefaultClient.Do(req)
 	if err != nil {
 		return err
 	} else if resp.StatusCode != http.StatusOK {
-		return
+		if ev.IsTrace() {
+			response, err := io.ReadAll(resp.Body)
+			if err != nil {
+				return err
+			}
+			ev.Field("twilio_response", string(response))
+		}
+		ev.Warn("Twilio phone verification failed with status code %d", resp.StatusCode)
+		if resp.StatusCode == http.StatusNotFound {
+			return errHTTPGonePhoneVerificationExpired
+		}
+		return errHTTPInternalError
 	}
 	response, err := io.ReadAll(resp.Body)
 	if err != nil {
 		return err
 	}
-
-	ev := logvr(v, r).Tag(tagTwilio)
 	if ev.IsTrace() {
 		ev.Field("twilio_response", string(response)).Trace("Received successful Twilio phone verification response")
 	} else if ev.IsDebug() {

+ 0 - 1
server/types.go

@@ -362,7 +362,6 @@ type apiConfigResponse struct {
 	EnableLogin        bool     `json:"enable_login"`
 	EnableSignup       bool     `json:"enable_signup"`
 	EnablePayments     bool     `json:"enable_payments"`
-	EnableSMS          bool     `json:"enable_sms"`
 	EnableCalls        bool     `json:"enable_calls"`
 	EnableReservations bool     `json:"enable_reservations"`
 	BillingContact     string   `json:"billing_contact"`

+ 6 - 30
server/visitor.go

@@ -56,7 +56,6 @@ type visitor struct {
 	requestLimiter      *rate.Limiter      // Rate limiter for (almost) all requests (including messages)
 	messagesLimiter     *util.FixedLimiter // Rate limiter for messages
 	emailsLimiter       *util.RateLimiter  // Rate limiter for emails
-	smsLimiter          *util.FixedLimiter // Rate limiter for SMS
 	callsLimiter        *util.FixedLimiter // Rate limiter for calls
 	subscriptionLimiter *util.FixedLimiter // Fixed limiter for active subscriptions (ongoing connections)
 	bandwidthLimiter    *util.RateLimiter  // Limiter for attachment bandwidth downloads
@@ -81,7 +80,6 @@ type visitorLimits struct {
 	EmailLimit               int64
 	EmailLimitBurst          int
 	EmailLimitReplenish      rate.Limit
-	SMSLimit                 int64
 	CallLimit                int64
 	ReservationsLimit        int64
 	AttachmentTotalSizeLimit int64
@@ -95,8 +93,6 @@ type visitorStats struct {
 	MessagesRemaining            int64
 	Emails                       int64
 	EmailsRemaining              int64
-	SMS                          int64
-	SMSRemaining                 int64
 	Calls                        int64
 	CallsRemaining               int64
 	Reservations                 int64
@@ -115,11 +111,10 @@ const (
 )
 
 func newVisitor(conf *Config, messageCache *messageCache, userManager *user.Manager, ip netip.Addr, user *user.User) *visitor {
-	var messages, emails, sms, calls int64
+	var messages, emails, calls int64
 	if user != nil {
 		messages = user.Stats.Messages
 		emails = user.Stats.Emails
-		sms = user.Stats.SMS
 		calls = user.Stats.Calls
 	}
 	v := &visitor{
@@ -134,13 +129,12 @@ func newVisitor(conf *Config, messageCache *messageCache, userManager *user.Mana
 		requestLimiter:      nil, // Set in resetLimiters
 		messagesLimiter:     nil, // Set in resetLimiters, may be nil
 		emailsLimiter:       nil, // Set in resetLimiters
-		smsLimiter:          nil, // Set in resetLimiters, may be nil
 		callsLimiter:        nil, // Set in resetLimiters, may be nil
 		bandwidthLimiter:    nil, // Set in resetLimiters
 		accountLimiter:      nil, // Set in resetLimiters, may be nil
 		authLimiter:         nil, // Set in resetLimiters, may be nil
 	}
-	v.resetLimitersNoLock(messages, emails, sms, calls, false)
+	v.resetLimitersNoLock(messages, emails, calls, false)
 	return v
 }
 
@@ -168,9 +162,6 @@ func (v *visitor) contextNoLock() log.Context {
 		fields["visitor_emails_remaining"] = info.Stats.EmailsRemaining
 	}
 	if v.config.TwilioAccount != "" {
-		fields["visitor_sms"] = info.Stats.SMS
-		fields["visitor_sms_limit"] = info.Limits.SMSLimit
-		fields["visitor_sms_remaining"] = info.Stats.SMSRemaining
 		fields["visitor_calls"] = info.Stats.Calls
 		fields["visitor_calls_limit"] = info.Limits.CallLimit
 		fields["visitor_calls_remaining"] = info.Stats.CallsRemaining
@@ -238,12 +229,6 @@ func (v *visitor) EmailAllowed() bool {
 	return v.emailsLimiter.Allow()
 }
 
-func (v *visitor) SMSAllowed() bool {
-	v.mu.RLock() // limiters could be replaced!
-	defer v.mu.RUnlock()
-	return v.smsLimiter.Allow()
-}
-
 func (v *visitor) CallAllowed() bool {
 	v.mu.RLock() // limiters could be replaced!
 	defer v.mu.RUnlock()
@@ -330,7 +315,6 @@ func (v *visitor) Stats() *user.Stats {
 	return &user.Stats{
 		Messages: v.messagesLimiter.Value(),
 		Emails:   v.emailsLimiter.Value(),
-		SMS:      v.smsLimiter.Value(),
 		Calls:    v.callsLimiter.Value(),
 	}
 }
@@ -340,7 +324,6 @@ func (v *visitor) ResetStats() {
 	defer v.mu.RUnlock()
 	v.emailsLimiter.Reset()
 	v.messagesLimiter.Reset()
-	v.smsLimiter.Reset()
 	v.callsLimiter.Reset()
 }
 
@@ -372,11 +355,11 @@ func (v *visitor) SetUser(u *user.User) {
 	shouldResetLimiters := v.user.TierID() != u.TierID() // TierID works with nil receiver
 	v.user = u                                           // u may be nil!
 	if shouldResetLimiters {
-		var messages, emails, sms, calls int64
+		var messages, emails, calls int64
 		if u != nil {
-			messages, emails, sms, calls = u.Stats.Messages, u.Stats.Emails, u.Stats.SMS, u.Stats.Calls
+			messages, emails, calls = u.Stats.Messages, u.Stats.Emails, u.Stats.Calls
 		}
-		v.resetLimitersNoLock(messages, emails, sms, calls, true)
+		v.resetLimitersNoLock(messages, emails, calls, true)
 	}
 }
 
@@ -391,12 +374,11 @@ func (v *visitor) MaybeUserID() string {
 	return ""
 }
 
-func (v *visitor) resetLimitersNoLock(messages, emails, sms, calls int64, enqueueUpdate bool) {
+func (v *visitor) resetLimitersNoLock(messages, emails, calls int64, enqueueUpdate bool) {
 	limits := v.limitsNoLock()
 	v.requestLimiter = rate.NewLimiter(limits.RequestLimitReplenish, limits.RequestLimitBurst)
 	v.messagesLimiter = util.NewFixedLimiterWithValue(limits.MessageLimit, messages)
 	v.emailsLimiter = util.NewRateLimiterWithValue(limits.EmailLimitReplenish, limits.EmailLimitBurst, emails)
-	v.smsLimiter = util.NewFixedLimiterWithValue(limits.SMSLimit, sms)
 	v.callsLimiter = util.NewFixedLimiterWithValue(limits.CallLimit, calls)
 	v.bandwidthLimiter = util.NewBytesLimiter(int(limits.AttachmentBandwidthLimit), oneDay)
 	if v.user == nil {
@@ -410,7 +392,6 @@ func (v *visitor) resetLimitersNoLock(messages, emails, sms, calls int64, enqueu
 		go v.userManager.EnqueueUserStats(v.user.ID, &user.Stats{
 			Messages: messages,
 			Emails:   emails,
-			SMS:      sms,
 			Calls:    calls,
 		})
 	}
@@ -440,7 +421,6 @@ func tierBasedVisitorLimits(conf *Config, tier *user.Tier) *visitorLimits {
 		EmailLimit:               tier.EmailLimit,
 		EmailLimitBurst:          util.MinMax(int(float64(tier.EmailLimit)*visitorEmailLimitBurstRate), conf.VisitorEmailLimitBurst, visitorEmailLimitBurstMax),
 		EmailLimitReplenish:      dailyLimitToRate(tier.EmailLimit),
-		SMSLimit:                 tier.SMSLimit,
 		CallLimit:                tier.CallLimit,
 		ReservationsLimit:        tier.ReservationLimit,
 		AttachmentTotalSizeLimit: tier.AttachmentTotalSizeLimit,
@@ -464,7 +444,6 @@ func configBasedVisitorLimits(conf *Config) *visitorLimits {
 		EmailLimit:               replenishDurationToDailyLimit(conf.VisitorEmailLimitReplenish), // Approximation!
 		EmailLimitBurst:          conf.VisitorEmailLimitBurst,
 		EmailLimitReplenish:      rate.Every(conf.VisitorEmailLimitReplenish),
-		SMSLimit:                 int64(conf.VisitorSMSDailyLimit),
 		CallLimit:                int64(conf.VisitorCallDailyLimit),
 		ReservationsLimit:        visitorDefaultReservationsLimit,
 		AttachmentTotalSizeLimit: conf.VisitorAttachmentTotalSizeLimit,
@@ -511,7 +490,6 @@ func (v *visitor) Info() (*visitorInfo, error) {
 func (v *visitor) infoLightNoLock() *visitorInfo {
 	messages := v.messagesLimiter.Value()
 	emails := v.emailsLimiter.Value()
-	sms := v.smsLimiter.Value()
 	calls := v.callsLimiter.Value()
 	limits := v.limitsNoLock()
 	stats := &visitorStats{
@@ -519,8 +497,6 @@ func (v *visitor) infoLightNoLock() *visitorInfo {
 		MessagesRemaining: zeroIfNegative(limits.MessageLimit - messages),
 		Emails:            emails,
 		EmailsRemaining:   zeroIfNegative(limits.EmailLimit - emails),
-		SMS:               sms,
-		SMSRemaining:      zeroIfNegative(limits.SMSLimit - sms),
 		Calls:             calls,
 		CallsRemaining:    zeroIfNegative(limits.CallLimit - calls),
 	}

+ 27 - 28
user/manager.go

@@ -6,6 +6,7 @@ import (
 	"encoding/json"
 	"errors"
 	"fmt"
+	"github.com/mattn/go-sqlite3"
 	_ "github.com/mattn/go-sqlite3" // SQLite driver
 	"github.com/stripe/stripe-go/v74"
 	"golang.org/x/crypto/bcrypt"
@@ -55,7 +56,6 @@ const (
 			messages_limit INT NOT NULL,
 			messages_expiry_duration INT NOT NULL,
 			emails_limit INT NOT NULL,
-			sms_limit INT NOT NULL,
 			calls_limit INT NOT NULL,
 			reservations_limit INT NOT NULL,
 			attachment_file_size_limit INT NOT NULL,
@@ -78,7 +78,6 @@ const (
 			sync_topic TEXT NOT NULL,
 			stats_messages INT NOT NULL DEFAULT (0),
 			stats_emails INT NOT NULL DEFAULT (0),
-			stats_sms INT NOT NULL DEFAULT (0),
 			stats_calls INT NOT NULL DEFAULT (0),
 			stripe_customer_id TEXT,
 			stripe_subscription_id TEXT,
@@ -135,26 +134,26 @@ const (
 	`
 
 	selectUserByIDQuery = `
-		SELECT u.id, u.user, u.pass, u.role, u.prefs, u.sync_topic, u.stats_messages, u.stats_emails, u.stats_sms, u.stats_calls, u.stripe_customer_id, u.stripe_subscription_id, u.stripe_subscription_status, u.stripe_subscription_interval, u.stripe_subscription_paid_until, u.stripe_subscription_cancel_at, deleted, t.id, t.code, t.name, t.messages_limit, t.messages_expiry_duration, t.emails_limit, t.sms_limit, t.calls_limit, t.reservations_limit, t.attachment_file_size_limit, t.attachment_total_size_limit, t.attachment_expiry_duration, t.attachment_bandwidth_limit, t.stripe_monthly_price_id, t.stripe_yearly_price_id
+		SELECT u.id, u.user, u.pass, u.role, u.prefs, u.sync_topic, u.stats_messages, u.stats_emails, u.stats_calls, u.stripe_customer_id, u.stripe_subscription_id, u.stripe_subscription_status, u.stripe_subscription_interval, u.stripe_subscription_paid_until, u.stripe_subscription_cancel_at, deleted, t.id, t.code, t.name, t.messages_limit, t.messages_expiry_duration, t.emails_limit, t.calls_limit, t.reservations_limit, t.attachment_file_size_limit, t.attachment_total_size_limit, t.attachment_expiry_duration, t.attachment_bandwidth_limit, t.stripe_monthly_price_id, t.stripe_yearly_price_id
 		FROM user u
 		LEFT JOIN tier t on t.id = u.tier_id
 		WHERE u.id = ?
 	`
 	selectUserByNameQuery = `
-		SELECT u.id, u.user, u.pass, u.role, u.prefs, u.sync_topic, u.stats_messages, u.stats_emails, u.stats_sms, u.stats_calls, u.stripe_customer_id, u.stripe_subscription_id, u.stripe_subscription_status, u.stripe_subscription_interval, u.stripe_subscription_paid_until, u.stripe_subscription_cancel_at, deleted, t.id, t.code, t.name, t.messages_limit, t.messages_expiry_duration, t.emails_limit, t.sms_limit, t.calls_limit, t.reservations_limit, t.attachment_file_size_limit, t.attachment_total_size_limit, t.attachment_expiry_duration, t.attachment_bandwidth_limit, t.stripe_monthly_price_id, t.stripe_yearly_price_id
+		SELECT u.id, u.user, u.pass, u.role, u.prefs, u.sync_topic, u.stats_messages, u.stats_emails, u.stats_calls, u.stripe_customer_id, u.stripe_subscription_id, u.stripe_subscription_status, u.stripe_subscription_interval, u.stripe_subscription_paid_until, u.stripe_subscription_cancel_at, deleted, t.id, t.code, t.name, t.messages_limit, t.messages_expiry_duration, t.emails_limit, t.calls_limit, t.reservations_limit, t.attachment_file_size_limit, t.attachment_total_size_limit, t.attachment_expiry_duration, t.attachment_bandwidth_limit, t.stripe_monthly_price_id, t.stripe_yearly_price_id
 		FROM user u
 		LEFT JOIN tier t on t.id = u.tier_id
 		WHERE user = ?
 	`
 	selectUserByTokenQuery = `
-		SELECT u.id, u.user, u.pass, u.role, u.prefs, u.sync_topic, u.stats_messages, u.stats_emails, u.stats_sms, u.stats_calls, u.stripe_customer_id, u.stripe_subscription_id, u.stripe_subscription_status, u.stripe_subscription_interval, u.stripe_subscription_paid_until, u.stripe_subscription_cancel_at, deleted, t.id, t.code, t.name, t.messages_limit, t.messages_expiry_duration, t.emails_limit, t.sms_limit, t.calls_limit, t.reservations_limit, t.attachment_file_size_limit, t.attachment_total_size_limit, t.attachment_expiry_duration, t.attachment_bandwidth_limit, t.stripe_monthly_price_id, t.stripe_yearly_price_id
+		SELECT u.id, u.user, u.pass, u.role, u.prefs, u.sync_topic, u.stats_messages, u.stats_emails, u.stats_calls, u.stripe_customer_id, u.stripe_subscription_id, u.stripe_subscription_status, u.stripe_subscription_interval, u.stripe_subscription_paid_until, u.stripe_subscription_cancel_at, deleted, t.id, t.code, t.name, t.messages_limit, t.messages_expiry_duration, t.emails_limit, t.calls_limit, t.reservations_limit, t.attachment_file_size_limit, t.attachment_total_size_limit, t.attachment_expiry_duration, t.attachment_bandwidth_limit, t.stripe_monthly_price_id, t.stripe_yearly_price_id
 		FROM user u
 		JOIN user_token tk on u.id = tk.user_id
 		LEFT JOIN tier t on t.id = u.tier_id
 		WHERE tk.token = ? AND (tk.expires = 0 OR tk.expires >= ?)
 	`
 	selectUserByStripeCustomerIDQuery = `
-		SELECT u.id, u.user, u.pass, u.role, u.prefs, u.sync_topic, u.stats_messages, u.stats_emails, u.stats_sms, u.stats_calls, u.stripe_customer_id, u.stripe_subscription_id, u.stripe_subscription_status, u.stripe_subscription_interval, u.stripe_subscription_paid_until, u.stripe_subscription_cancel_at, deleted, t.id, t.code, t.name, t.messages_limit, t.messages_expiry_duration, t.emails_limit, t.sms_limit, t.calls_limit, t.reservations_limit, t.attachment_file_size_limit, t.attachment_total_size_limit, t.attachment_expiry_duration, t.attachment_bandwidth_limit, t.stripe_monthly_price_id, t.stripe_yearly_price_id
+		SELECT u.id, u.user, u.pass, u.role, u.prefs, u.sync_topic, u.stats_messages, u.stats_emails, u.stats_calls, u.stripe_customer_id, u.stripe_subscription_id, u.stripe_subscription_status, u.stripe_subscription_interval, u.stripe_subscription_paid_until, u.stripe_subscription_cancel_at, deleted, t.id, t.code, t.name, t.messages_limit, t.messages_expiry_duration, t.emails_limit, t.calls_limit, t.reservations_limit, t.attachment_file_size_limit, t.attachment_total_size_limit, t.attachment_expiry_duration, t.attachment_bandwidth_limit, t.stripe_monthly_price_id, t.stripe_yearly_price_id
 		FROM user u
 		LEFT JOIN tier t on t.id = u.tier_id
 		WHERE u.stripe_customer_id = ?
@@ -185,8 +184,8 @@ const (
 	updateUserPassQuery          = `UPDATE user SET pass = ? WHERE user = ?`
 	updateUserRoleQuery          = `UPDATE user SET role = ? WHERE user = ?`
 	updateUserPrefsQuery         = `UPDATE user SET prefs = ? WHERE id = ?`
-	updateUserStatsQuery         = `UPDATE user SET stats_messages = ?, stats_emails = ?, stats_sms = ?, stats_calls = ? WHERE id = ?`
-	updateUserStatsResetAllQuery = `UPDATE user SET stats_messages = 0, stats_emails = 0, stats_sms = 0, stats_calls = 0`
+	updateUserStatsQuery         = `UPDATE user SET stats_messages = ?, stats_emails = ?, stats_calls = ? WHERE id = ?`
+	updateUserStatsResetAllQuery = `UPDATE user SET stats_messages = 0, stats_emails = 0, stats_calls = 0`
 	updateUserDeletedQuery       = `UPDATE user SET deleted = ? WHERE id = ?`
 	deleteUsersMarkedQuery       = `DELETE FROM user WHERE deleted < ?`
 	deleteUserQuery              = `DELETE FROM user WHERE user = ?`
@@ -274,25 +273,25 @@ const (
 	updatePhoneNumberVerifiedQuery = `UPDATE user_phone SET verified=1 WHERE user_id = ? AND phone_number = ?`
 
 	insertTierQuery = `
-		INSERT INTO tier (id, code, name, messages_limit, messages_expiry_duration, emails_limit, sms_limit, calls_limit, reservations_limit, attachment_file_size_limit, attachment_total_size_limit, attachment_expiry_duration, attachment_bandwidth_limit, stripe_monthly_price_id, stripe_yearly_price_id)
-		VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
+		INSERT INTO tier (id, code, name, messages_limit, messages_expiry_duration, emails_limit, calls_limit, reservations_limit, attachment_file_size_limit, attachment_total_size_limit, attachment_expiry_duration, attachment_bandwidth_limit, stripe_monthly_price_id, stripe_yearly_price_id)
+		VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
 	`
 	updateTierQuery = `
 		UPDATE tier
-		SET name = ?, messages_limit = ?, messages_expiry_duration = ?, emails_limit = ?, sms_limit = ?, calls_limit = ?, reservations_limit = ?, attachment_file_size_limit = ?, attachment_total_size_limit = ?, attachment_expiry_duration = ?, attachment_bandwidth_limit = ?, stripe_monthly_price_id = ?, stripe_yearly_price_id = ?
+		SET name = ?, messages_limit = ?, messages_expiry_duration = ?, emails_limit = ?, calls_limit = ?, reservations_limit = ?, attachment_file_size_limit = ?, attachment_total_size_limit = ?, attachment_expiry_duration = ?, attachment_bandwidth_limit = ?, stripe_monthly_price_id = ?, stripe_yearly_price_id = ?
 		WHERE code = ?
 	`
 	selectTiersQuery = `
-		SELECT id, code, name, messages_limit, messages_expiry_duration, emails_limit, sms_limit, calls_limit, reservations_limit, attachment_file_size_limit, attachment_total_size_limit, attachment_expiry_duration, attachment_bandwidth_limit, stripe_monthly_price_id, stripe_yearly_price_id
+		SELECT id, code, name, messages_limit, messages_expiry_duration, emails_limit, calls_limit, reservations_limit, attachment_file_size_limit, attachment_total_size_limit, attachment_expiry_duration, attachment_bandwidth_limit, stripe_monthly_price_id, stripe_yearly_price_id
 		FROM tier
 	`
 	selectTierByCodeQuery = `
-		SELECT id, code, name, messages_limit, messages_expiry_duration, emails_limit, sms_limit, calls_limit, reservations_limit, attachment_file_size_limit, attachment_total_size_limit, attachment_expiry_duration, attachment_bandwidth_limit, stripe_monthly_price_id, stripe_yearly_price_id
+		SELECT id, code, name, messages_limit, messages_expiry_duration, emails_limit, calls_limit, reservations_limit, attachment_file_size_limit, attachment_total_size_limit, attachment_expiry_duration, attachment_bandwidth_limit, stripe_monthly_price_id, stripe_yearly_price_id
 		FROM tier
 		WHERE code = ?
 	`
 	selectTierByPriceIDQuery = `
-		SELECT id, code, name, messages_limit, messages_expiry_duration, emails_limit, sms_limit, calls_limit, reservations_limit, attachment_file_size_limit, attachment_total_size_limit, attachment_expiry_duration, attachment_bandwidth_limit, stripe_monthly_price_id, stripe_yearly_price_id
+		SELECT id, code, name, messages_limit, messages_expiry_duration, emails_limit, calls_limit, reservations_limit, attachment_file_size_limit, attachment_total_size_limit, attachment_expiry_duration, attachment_bandwidth_limit, stripe_monthly_price_id, stripe_yearly_price_id
 		FROM tier
 		WHERE (stripe_monthly_price_id = ? OR stripe_yearly_price_id = ?)
 	`
@@ -410,9 +409,7 @@ const (
 
 	// 3 -> 4
 	migrate3To4UpdateQueries = `
-		ALTER TABLE tier ADD COLUMN sms_limit INT NOT NULL DEFAULT (0);
 		ALTER TABLE tier ADD COLUMN calls_limit INT NOT NULL DEFAULT (0);
-		ALTER TABLE user ADD COLUMN stats_sms INT NOT NULL DEFAULT (0);
 		ALTER TABLE user ADD COLUMN stats_calls INT NOT NULL DEFAULT (0);
 		CREATE TABLE IF NOT EXISTS user_phone (
 			user_id TEXT NOT NULL,
@@ -689,6 +686,9 @@ func (a *Manager) readPhoneNumber(rows *sql.Rows) (*PhoneNumber, error) {
 
 func (a *Manager) AddPhoneNumber(userID string, phoneNumber string) error {
 	if _, err := a.db.Exec(insertPhoneNumberQuery, userID, phoneNumber); err != nil {
+		if sqliteErr, ok := err.(sqlite3.Error); ok && sqliteErr.ExtendedCode == sqlite3.ErrConstraintUnique {
+			return ErrPhoneNumberExists
+		}
 		return err
 	}
 	return nil
@@ -783,11 +783,10 @@ func (a *Manager) writeUserStatsQueue() error {
 				"user_id":        userID,
 				"messages_count": update.Messages,
 				"emails_count":   update.Emails,
-				"sms_count":      update.SMS,
 				"calls_count":    update.Calls,
 			}).
 			Trace("Updating stats for user %s", userID)
-		if _, err := tx.Exec(updateUserStatsQuery, update.Messages, update.Emails, update.SMS, update.Calls, userID); err != nil {
+		if _, err := tx.Exec(updateUserStatsQuery, update.Messages, update.Emails, update.Calls, userID); err != nil {
 			return err
 		}
 	}
@@ -869,6 +868,9 @@ func (a *Manager) AddUser(username, password string, role Role) error {
 	userID := util.RandomStringPrefix(userIDPrefix, userIDLength)
 	syncTopic, now := util.RandomStringPrefix(syncTopicPrefix, syncTopicLength), time.Now().Unix()
 	if _, err = a.db.Exec(insertUserQuery, userID, username, hash, role, syncTopic, now); err != nil {
+		if sqliteErr, ok := err.(sqlite3.Error); ok && sqliteErr.ExtendedCode == sqlite3.ErrConstraintUnique {
+			return ErrUserExists
+		}
 		return err
 	}
 	return nil
@@ -996,12 +998,12 @@ func (a *Manager) readUser(rows *sql.Rows) (*User, error) {
 	defer rows.Close()
 	var id, username, hash, role, prefs, syncTopic string
 	var stripeCustomerID, stripeSubscriptionID, stripeSubscriptionStatus, stripeSubscriptionInterval, stripeMonthlyPriceID, stripeYearlyPriceID, tierID, tierCode, tierName sql.NullString
-	var messages, emails, sms, calls int64
-	var messagesLimit, messagesExpiryDuration, emailsLimit, smsLimit, callsLimit, reservationsLimit, attachmentFileSizeLimit, attachmentTotalSizeLimit, attachmentExpiryDuration, attachmentBandwidthLimit, stripeSubscriptionPaidUntil, stripeSubscriptionCancelAt, deleted sql.NullInt64
+	var messages, emails, calls int64
+	var messagesLimit, messagesExpiryDuration, emailsLimit, callsLimit, reservationsLimit, attachmentFileSizeLimit, attachmentTotalSizeLimit, attachmentExpiryDuration, attachmentBandwidthLimit, stripeSubscriptionPaidUntil, stripeSubscriptionCancelAt, deleted sql.NullInt64
 	if !rows.Next() {
 		return nil, ErrUserNotFound
 	}
-	if err := rows.Scan(&id, &username, &hash, &role, &prefs, &syncTopic, &messages, &emails, &sms, &calls, &stripeCustomerID, &stripeSubscriptionID, &stripeSubscriptionStatus, &stripeSubscriptionInterval, &stripeSubscriptionPaidUntil, &stripeSubscriptionCancelAt, &deleted, &tierID, &tierCode, &tierName, &messagesLimit, &messagesExpiryDuration, &emailsLimit, &smsLimit, &callsLimit, &reservationsLimit, &attachmentFileSizeLimit, &attachmentTotalSizeLimit, &attachmentExpiryDuration, &attachmentBandwidthLimit, &stripeMonthlyPriceID, &stripeYearlyPriceID); err != nil {
+	if err := rows.Scan(&id, &username, &hash, &role, &prefs, &syncTopic, &messages, &emails, &calls, &stripeCustomerID, &stripeSubscriptionID, &stripeSubscriptionStatus, &stripeSubscriptionInterval, &stripeSubscriptionPaidUntil, &stripeSubscriptionCancelAt, &deleted, &tierID, &tierCode, &tierName, &messagesLimit, &messagesExpiryDuration, &emailsLimit, &callsLimit, &reservationsLimit, &attachmentFileSizeLimit, &attachmentTotalSizeLimit, &attachmentExpiryDuration, &attachmentBandwidthLimit, &stripeMonthlyPriceID, &stripeYearlyPriceID); err != nil {
 		return nil, err
 	} else if err := rows.Err(); err != nil {
 		return nil, err
@@ -1016,7 +1018,6 @@ func (a *Manager) readUser(rows *sql.Rows) (*User, error) {
 		Stats: &Stats{
 			Messages: messages,
 			Emails:   emails,
-			SMS:      sms,
 			Calls:    calls,
 		},
 		Billing: &Billing{
@@ -1041,7 +1042,6 @@ func (a *Manager) readUser(rows *sql.Rows) (*User, error) {
 			MessageLimit:             messagesLimit.Int64,
 			MessageExpiryDuration:    time.Duration(messagesExpiryDuration.Int64) * time.Second,
 			EmailLimit:               emailsLimit.Int64,
-			SMSLimit:                 smsLimit.Int64,
 			CallLimit:                callsLimit.Int64,
 			ReservationLimit:         reservationsLimit.Int64,
 			AttachmentFileSizeLimit:  attachmentFileSizeLimit.Int64,
@@ -1348,7 +1348,7 @@ func (a *Manager) AddTier(tier *Tier) error {
 	if tier.ID == "" {
 		tier.ID = util.RandomStringPrefix(tierIDPrefix, tierIDLength)
 	}
-	if _, err := a.db.Exec(insertTierQuery, tier.ID, tier.Code, tier.Name, tier.MessageLimit, int64(tier.MessageExpiryDuration.Seconds()), tier.EmailLimit, tier.SMSLimit, tier.CallLimit, tier.ReservationLimit, tier.AttachmentFileSizeLimit, tier.AttachmentTotalSizeLimit, int64(tier.AttachmentExpiryDuration.Seconds()), tier.AttachmentBandwidthLimit, nullString(tier.StripeMonthlyPriceID), nullString(tier.StripeYearlyPriceID)); err != nil {
+	if _, err := a.db.Exec(insertTierQuery, tier.ID, tier.Code, tier.Name, tier.MessageLimit, int64(tier.MessageExpiryDuration.Seconds()), tier.EmailLimit, tier.CallLimit, tier.ReservationLimit, tier.AttachmentFileSizeLimit, tier.AttachmentTotalSizeLimit, int64(tier.AttachmentExpiryDuration.Seconds()), tier.AttachmentBandwidthLimit, nullString(tier.StripeMonthlyPriceID), nullString(tier.StripeYearlyPriceID)); err != nil {
 		return err
 	}
 	return nil
@@ -1356,7 +1356,7 @@ func (a *Manager) AddTier(tier *Tier) error {
 
 // UpdateTier updates a tier's properties in the database
 func (a *Manager) UpdateTier(tier *Tier) error {
-	if _, err := a.db.Exec(updateTierQuery, tier.Name, tier.MessageLimit, int64(tier.MessageExpiryDuration.Seconds()), tier.EmailLimit, tier.SMSLimit, tier.CallLimit, tier.ReservationLimit, tier.AttachmentFileSizeLimit, tier.AttachmentTotalSizeLimit, int64(tier.AttachmentExpiryDuration.Seconds()), tier.AttachmentBandwidthLimit, nullString(tier.StripeMonthlyPriceID), nullString(tier.StripeYearlyPriceID), tier.Code); err != nil {
+	if _, err := a.db.Exec(updateTierQuery, tier.Name, tier.MessageLimit, int64(tier.MessageExpiryDuration.Seconds()), tier.EmailLimit, tier.CallLimit, tier.ReservationLimit, tier.AttachmentFileSizeLimit, tier.AttachmentTotalSizeLimit, int64(tier.AttachmentExpiryDuration.Seconds()), tier.AttachmentBandwidthLimit, nullString(tier.StripeMonthlyPriceID), nullString(tier.StripeYearlyPriceID), tier.Code); err != nil {
 		return err
 	}
 	return nil
@@ -1425,11 +1425,11 @@ func (a *Manager) TierByStripePrice(priceID string) (*Tier, error) {
 func (a *Manager) readTier(rows *sql.Rows) (*Tier, error) {
 	var id, code, name string
 	var stripeMonthlyPriceID, stripeYearlyPriceID sql.NullString
-	var messagesLimit, messagesExpiryDuration, emailsLimit, smsLimit, callsLimit, reservationsLimit, attachmentFileSizeLimit, attachmentTotalSizeLimit, attachmentExpiryDuration, attachmentBandwidthLimit sql.NullInt64
+	var messagesLimit, messagesExpiryDuration, emailsLimit, callsLimit, reservationsLimit, attachmentFileSizeLimit, attachmentTotalSizeLimit, attachmentExpiryDuration, attachmentBandwidthLimit sql.NullInt64
 	if !rows.Next() {
 		return nil, ErrTierNotFound
 	}
-	if err := rows.Scan(&id, &code, &name, &messagesLimit, &messagesExpiryDuration, &emailsLimit, &smsLimit, &callsLimit, &reservationsLimit, &attachmentFileSizeLimit, &attachmentTotalSizeLimit, &attachmentExpiryDuration, &attachmentBandwidthLimit, &stripeMonthlyPriceID, &stripeYearlyPriceID); err != nil {
+	if err := rows.Scan(&id, &code, &name, &messagesLimit, &messagesExpiryDuration, &emailsLimit, &callsLimit, &reservationsLimit, &attachmentFileSizeLimit, &attachmentTotalSizeLimit, &attachmentExpiryDuration, &attachmentBandwidthLimit, &stripeMonthlyPriceID, &stripeYearlyPriceID); err != nil {
 		return nil, err
 	} else if err := rows.Err(); err != nil {
 		return nil, err
@@ -1442,7 +1442,6 @@ func (a *Manager) readTier(rows *sql.Rows) (*Tier, error) {
 		MessageLimit:             messagesLimit.Int64,
 		MessageExpiryDuration:    time.Duration(messagesExpiryDuration.Int64) * time.Second,
 		EmailLimit:               emailsLimit.Int64,
-		SMSLimit:                 smsLimit.Int64,
 		CallLimit:                callsLimit.Int64,
 		ReservationLimit:         reservationsLimit.Int64,
 		AttachmentFileSizeLimit:  attachmentFileSizeLimit.Int64,

+ 2 - 2
user/types.go

@@ -91,7 +91,6 @@ type Tier struct {
 	MessageLimit             int64         // Daily message limit
 	MessageExpiryDuration    time.Duration // Cache duration for messages
 	EmailLimit               int64         // Daily email limit
-	SMSLimit                 int64         // Daily SMS limit
 	CallLimit                int64         // Daily phone call limit
 	ReservationLimit         int64         // Number of topic reservations allowed by user
 	AttachmentFileSizeLimit  int64         // Max file size per file (bytes)
@@ -138,7 +137,6 @@ type NotificationPrefs struct {
 type Stats struct {
 	Messages int64
 	Emails   int64
-	SMS      int64
 	Calls    int64
 }
 
@@ -285,8 +283,10 @@ var (
 	ErrUnauthorized        = errors.New("unauthorized")
 	ErrInvalidArgument     = errors.New("invalid argument")
 	ErrUserNotFound        = errors.New("user not found")
+	ErrUserExists          = errors.New("user already exists")
 	ErrTierNotFound        = errors.New("tier not found")
 	ErrTokenNotFound       = errors.New("token not found")
 	ErrPhoneNumberNotFound = errors.New("phone number not found")
 	ErrTooManyReservations = errors.New("new tier has lower reservation limit")
+	ErrPhoneNumberExists   = errors.New("phone number already exists")
 )

+ 0 - 1
web/public/config.js

@@ -12,7 +12,6 @@ var config = {
     enable_signup: true,
     enable_payments: true,
     enable_reservations: true,
-    enable_sms: true,
     enable_calls: true,
     billing_contact: "",
     disallowed_topics: ["docs", "static", "file", "app", "account", "settings", "signup", "login", "v1"]

+ 2 - 9
web/public/static/langs/en.json

@@ -127,9 +127,6 @@
   "publish_dialog_email_label": "Email",
   "publish_dialog_email_placeholder": "Address to forward the notification to, e.g. phil@example.com",
   "publish_dialog_email_reset": "Remove email forward",
-  "publish_dialog_sms_label": "SMS",
-  "publish_dialog_sms_placeholder": "Phone number to send SMS to, e.g. +12223334444",
-  "publish_dialog_sms_reset": "Remove SMS message",
   "publish_dialog_call_label": "Phone call",
   "publish_dialog_call_placeholder": "Phone number to call with the message, e.g. +12223334444",
   "publish_dialog_call_reset": "Remove phone call",
@@ -144,7 +141,6 @@
   "publish_dialog_other_features": "Other features:",
   "publish_dialog_chip_click_label": "Click URL",
   "publish_dialog_chip_email_label": "Forward to email",
-  "publish_dialog_chip_sms_label": "Send SMS",
   "publish_dialog_chip_call_label": "Phone call",
   "publish_dialog_chip_attach_url_label": "Attach file by URL",
   "publish_dialog_chip_attach_file_label": "Attach local file",
@@ -190,6 +186,8 @@
   "account_basics_password_dialog_confirm_password_label": "Confirm password",
   "account_basics_password_dialog_button_submit": "Change password",
   "account_basics_password_dialog_current_password_incorrect": "Password incorrect",
+  "account_basics_phone_numbers_title": "Phone numbers",
+  "account_basics_phone_numbers_description": "For phone call notifications",
   "account_usage_title": "Usage",
   "account_usage_of_limit": "of {{limit}}",
   "account_usage_unlimited": "Unlimited",
@@ -211,8 +209,6 @@
   "account_basics_tier_manage_billing_button": "Manage billing",
   "account_usage_messages_title": "Published messages",
   "account_usage_emails_title": "Emails sent",
-  "account_usage_sms_title": "SMS sent",
-  "account_usage_sms_none": "No SMS can be sent with this account",
   "account_usage_calls_title": "Phone calls made",
   "account_usage_calls_none": "No phone calls can be made with this account",
   "account_usage_reservations_title": "Reserved topics",
@@ -244,9 +240,6 @@
   "account_upgrade_dialog_tier_features_messages_other": "{{messages}} daily messages",
   "account_upgrade_dialog_tier_features_emails_one": "{{emails}} daily email",
   "account_upgrade_dialog_tier_features_emails_other": "{{emails}} daily emails",
-  "account_upgrade_dialog_tier_features_sms_one": "{{sms}} daily SMS",
-  "account_upgrade_dialog_tier_features_sms_other": "{{sms}} daily SMS",
-  "account_upgrade_dialog_tier_features_no_sms": "No daily SMS",
   "account_upgrade_dialog_tier_features_calls_one": "{{calls}} daily phone calls",
   "account_upgrade_dialog_tier_features_calls_other": "{{calls}} daily phone calls",
   "account_upgrade_dialog_tier_features_no_calls": "No daily phone calls",

+ 38 - 24
web/src/components/Account.js

@@ -3,7 +3,7 @@ import {useContext, useState} from 'react';
 import {
     Alert,
     CardActions,
-    CardContent,
+    CardContent, Chip,
     FormControl,
     LinearProgress,
     Link,
@@ -52,6 +52,7 @@ import MenuItem from "@mui/material/MenuItem";
 import DialogContentText from "@mui/material/DialogContentText";
 import {IncorrectPasswordError, UnauthorizedError} from "../app/errors";
 import {ProChip} from "./SubscriptionPopup";
+import AddIcon from "@mui/icons-material/Add";
 
 const Account = () => {
     if (!session.exists()) {
@@ -80,6 +81,7 @@ const Basics = () => {
             <PrefGroup>
                 <Username/>
                 <ChangePassword/>
+                <PhoneNumbers/>
                 <AccountType/>
             </PrefGroup>
         </Card>
@@ -320,6 +322,40 @@ const AccountType = () => {
     )
 };
 
+const PhoneNumbers = () => {
+    const { t } = useTranslation();
+    const { account } = useContext(AccountContext);
+    const labelId = "prefPhoneNumbers";
+
+    const handleAdd = () => {
+
+    };
+
+    const handleClick = () => {
+
+    };
+
+    const handleDelete = () => {
+
+    };
+
+    return (
+        <Pref labelId={labelId} title={t("account_basics_phone_numbers_title")} description={t("account_basics_phone_numbers_description")}>
+            <div aria-labelledby={labelId}>
+                {account?.phone_numbers.map(p =>
+                    <Chip
+                        label={p.number}
+                        variant="outlined"
+                        onClick={() => navigator.clipboard.writeText(p.number)}
+                        onDelete={() => handleDelete(p.number)}
+                    />
+                )}
+                <IconButton onClick={() => handleAdd()}><AddIcon/></IconButton>
+            </div>
+        </Pref>
+    )
+};
+
 const Stats = () => {
     const { t } = useTranslation();
     const { account } = useContext(AccountContext);
@@ -380,23 +416,6 @@ const Stats = () => {
                         value={account.role === Role.USER ? normalize(account.stats.emails, account.limits.emails) : 100}
                     />
                 </Pref>
-                {(account.role === Role.ADMIN || account.limits.sms > 0) &&
-                    <Pref title={
-                        <>
-                            {t("account_usage_sms_title")}
-                            <Tooltip title={t("account_usage_limits_reset_daily")}><span><InfoIcon/></span></Tooltip>
-                        </>
-                    }>
-                        <div>
-                            <Typography variant="body2" sx={{float: "left"}}>{account.stats.sms.toLocaleString()}</Typography>
-                            <Typography variant="body2" sx={{float: "right"}}>{account.role === Role.USER ? t("account_usage_of_limit", { limit: account.limits.sms.toLocaleString() }) : t("account_usage_unlimited")}</Typography>
-                        </div>
-                        <LinearProgress
-                            variant="determinate"
-                            value={account.role === Role.USER && account.limits.sms > 0 ? normalize(account.stats.sms, account.limits.sms) : 100}
-                        />
-                    </Pref>
-                }
                 {(account.role === Role.ADMIN || account.limits.calls > 0) &&
                     <Pref title={
                         <>
@@ -410,7 +429,7 @@ const Stats = () => {
                         </div>
                         <LinearProgress
                             variant="determinate"
-                            value={account.role === Role.USER && account.limits.sms > 0 ? normalize(account.stats.calls, account.limits.calls) : 100}
+                            value={account.role === Role.USER && account.limits.calls > 0 ? normalize(account.stats.calls, account.limits.calls) : 100}
                         />
                     </Pref>
                 }
@@ -439,11 +458,6 @@ const Stats = () => {
                         <em>{t("account_usage_reservations_none")}</em>
                     </Pref>
                 }
-                {config.enable_sms && account.role === Role.USER && account.limits.sms === 0 &&
-                    <Pref title={<>{t("account_usage_sms_title")}{config.enable_payments && <ProChip/>}</>}>
-                        <em>{t("account_usage_sms_none")}</em>
-                    </Pref>
-                }
                 {config.enable_calls && account.role === Role.USER && account.limits.calls === 0 &&
                     <Pref title={<>{t("account_usage_calls_title")}{config.enable_payments && <ProChip/>}</>}>
                         <em>{t("account_usage_calls_none")}</em>