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-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-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: "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-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-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"}),
 	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.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-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-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.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.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"}),
 	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")
 	visitorMessageDailyLimit := c.Int("visitor-message-daily-limit")
 	visitorEmailLimitBurst := c.Int("visitor-email-limit-burst")
 	visitorEmailLimitBurst := c.Int("visitor-email-limit-burst")
 	visitorEmailLimitReplenish := c.Duration("visitor-email-limit-replenish")
 	visitorEmailLimitReplenish := c.Duration("visitor-email-limit-replenish")
-	visitorSMSDailyLimit := c.Int("visitor-sms-daily-limit")
 	visitorCallDailyLimit := c.Int("visitor-call-daily-limit")
 	visitorCallDailyLimit := c.Int("visitor-call-daily-limit")
 	behindProxy := c.Bool("behind-proxy")
 	behindProxy := c.Bool("behind-proxy")
 	stripeSecretKey := c.String("stripe-secret-key")
 	stripeSecretKey := c.String("stripe-secret-key")
@@ -336,7 +334,6 @@ func execServe(c *cli.Context) error {
 	conf.VisitorMessageDailyLimit = visitorMessageDailyLimit
 	conf.VisitorMessageDailyLimit = visitorMessageDailyLimit
 	conf.VisitorEmailLimitBurst = visitorEmailLimitBurst
 	conf.VisitorEmailLimitBurst = visitorEmailLimitBurst
 	conf.VisitorEmailLimitReplenish = visitorEmailLimitReplenish
 	conf.VisitorEmailLimitReplenish = visitorEmailLimitReplenish
-	conf.VisitorSMSDailyLimit = visitorSMSDailyLimit
 	conf.VisitorCallDailyLimit = visitorCallDailyLimit
 	conf.VisitorCallDailyLimit = visitorCallDailyLimit
 	conf.VisitorSubscriberRateLimiting = visitorSubscriberRateLimiting
 	conf.VisitorSubscriberRateLimiting = visitorSubscriberRateLimiting
 	conf.BehindProxy = behindProxy
 	conf.BehindProxy = behindProxy

+ 0 - 8
cmd/tier.go

@@ -18,7 +18,6 @@ const (
 	defaultMessageLimit             = 5000
 	defaultMessageLimit             = 5000
 	defaultMessageExpiryDuration    = "12h"
 	defaultMessageExpiryDuration    = "12h"
 	defaultEmailLimit               = 20
 	defaultEmailLimit               = 20
-	defaultSMSLimit                 = 10
 	defaultCallLimit                = 10
 	defaultCallLimit                = 10
 	defaultReservationLimit         = 3
 	defaultReservationLimit         = 3
 	defaultAttachmentFileSizeLimit  = "15M"
 	defaultAttachmentFileSizeLimit  = "15M"
@@ -50,7 +49,6 @@ var cmdTier = &cli.Command{
 				&cli.Int64Flag{Name: "message-limit", Value: defaultMessageLimit, Usage: "daily message limit"},
 				&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.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: "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: "call-limit", Value: defaultCallLimit, Usage: "daily phone call limit"},
 				&cli.Int64Flag{Name: "reservation-limit", Value: defaultReservationLimit, Usage: "topic reservation 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"},
 				&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.Int64Flag{Name: "message-limit", Usage: "daily message limit"},
 				&cli.StringFlag{Name: "message-expiry-duration", Usage: "duration after which messages are deleted"},
 				&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: "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: "call-limit", Usage: "daily phone call limit"},
 				&cli.Int64Flag{Name: "reservation-limit", Usage: "topic reservation limit"},
 				&cli.Int64Flag{Name: "reservation-limit", Usage: "topic reservation limit"},
 				&cli.StringFlag{Name: "attachment-file-size-limit", Usage: "per-attachment file size 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"),
 		MessageLimit:             c.Int64("message-limit"),
 		MessageExpiryDuration:    messageExpiryDuration,
 		MessageExpiryDuration:    messageExpiryDuration,
 		EmailLimit:               c.Int64("email-limit"),
 		EmailLimit:               c.Int64("email-limit"),
-		SMSLimit:                 c.Int64("sms-limit"),
 		CallLimit:                c.Int64("call-limit"),
 		CallLimit:                c.Int64("call-limit"),
 		ReservationLimit:         c.Int64("reservation-limit"),
 		ReservationLimit:         c.Int64("reservation-limit"),
 		AttachmentFileSizeLimit:  attachmentFileSizeLimit,
 		AttachmentFileSizeLimit:  attachmentFileSizeLimit,
@@ -275,9 +271,6 @@ func execTierChange(c *cli.Context) error {
 	if c.IsSet("email-limit") {
 	if c.IsSet("email-limit") {
 		tier.EmailLimit = c.Int64("email-limit")
 		tier.EmailLimit = c.Int64("email-limit")
 	}
 	}
-	if c.IsSet("sms-limit") {
-		tier.SMSLimit = c.Int64("sms-limit")
-	}
 	if c.IsSet("call-limit") {
 	if c.IsSet("call-limit") {
 		tier.CallLimit = c.Int64("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 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, "- 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, "- 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, "- Phone call limit: %d\n", tier.CallLimit)
 	fmt.Fprintf(c.App.ErrWriter, "- Reservation limit: %d\n", tier.ReservationLimit)
 	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))
 	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>
   <figcaption>Publishing a message via e-mail</figcaption>
 </figure>
 </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
 ## Phone calls
 _Supported on:_ :material-android: :material-apple: :material-firefox:
 _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)"
 === "Command line (curl)"
     ```
     ```
     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
         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 CLI"
     ```
     ```
     ntfy publish \
     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"
     ``` http
     ``` http
     POST /alerts HTTP/1.1
     POST /alerts HTTP/1.1
     Host: ntfy.sh
     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"
     ``` javascript
     ``` javascript
     fetch('https://ntfy.sh/alerts', {
     fetch('https://ntfy.sh/alerts', {
         method: 'POST',
         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: { 
         headers: { 
             'Email': 'phil@example.com',
             'Email': 'phil@example.com',
             'Tags': 'warning,skull,backup-host,ssh-login',
             '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>
   <figcaption>E-mail notification</figcaption>
 </figure>
 </figure>
 
 
-
 ## Authentication
 ## Authentication
 Depending on whether the server is configured to support [access control](config.md#access-control), some topics
 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.
 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
 	DefaultVisitorMessageDailyLimit             = 0
 	DefaultVisitorEmailLimitBurst               = 16
 	DefaultVisitorEmailLimitBurst               = 16
 	DefaultVisitorEmailLimitReplenish           = time.Hour
 	DefaultVisitorEmailLimitReplenish           = time.Hour
-	DefaultVisitorSMSDailyLimit                 = 10
 	DefaultVisitorCallDailyLimit                = 10
 	DefaultVisitorCallDailyLimit                = 10
 	DefaultVisitorAccountCreationLimitBurst     = 3
 	DefaultVisitorAccountCreationLimitBurst     = 3
 	DefaultVisitorAccountCreationLimitReplenish = 24 * time.Hour
 	DefaultVisitorAccountCreationLimitReplenish = 24 * time.Hour
@@ -130,7 +129,6 @@ type Config struct {
 	VisitorMessageDailyLimit             int
 	VisitorMessageDailyLimit             int
 	VisitorEmailLimitBurst               int
 	VisitorEmailLimitBurst               int
 	VisitorEmailLimitReplenish           time.Duration
 	VisitorEmailLimitReplenish           time.Duration
-	VisitorSMSDailyLimit                 int
 	VisitorCallDailyLimit                int
 	VisitorCallDailyLimit                int
 	VisitorAccountCreationLimitBurst     int
 	VisitorAccountCreationLimitBurst     int
 	VisitorAccountCreationLimitReplenish time.Duration
 	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}
 	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}
 	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}
 	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}
 	errHTTPNotFound                                  = &errHTTP{40401, http.StatusNotFound, "page not found", "", nil}
 	errHTTPUnauthorized                              = &errHTTP{40101, http.StatusUnauthorized, "unauthorized", "https://ntfy.sh/docs/publish/#authentication", 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}
 	errHTTPForbidden                                 = &errHTTP{40301, http.StatusForbidden, "forbidden", "https://ntfy.sh/docs/publish/#authentication", nil}
 	errHTTPConflictUserExists                        = &errHTTP{40901, http.StatusConflict, "conflict: user already exists", "", 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}
 	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}
 	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}
 	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}
 	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}
 	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}
 	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}
 	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
 	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}
 	errHTTPInternalError                             = &errHTTP{50001, http.StatusInternalServerError, "internal server error", "", nil}
 	errHTTPInternalErrorInvalidPath                  = &errHTTP{50002, http.StatusInternalServerError, "internal server error: invalid path", "", 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}
 	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,
 		EnableLogin:        s.config.EnableLogin,
 		EnableSignup:       s.config.EnableSignup,
 		EnableSignup:       s.config.EnableSignup,
 		EnablePayments:     s.config.StripeSecretKey != "",
 		EnablePayments:     s.config.StripeSecretKey != "",
-		EnableSMS:          s.config.TwilioAccount != "",
 		EnableCalls:        s.config.TwilioAccount != "",
 		EnableCalls:        s.config.TwilioAccount != "",
 		EnableReservations: s.config.EnableReservations,
 		EnableReservations: s.config.EnableReservations,
 		BillingContact:     s.config.BillingContact,
 		BillingContact:     s.config.BillingContact,
@@ -676,7 +675,7 @@ func (s *Server) handlePublishInternal(r *http.Request, v *visitor) (*message, e
 		return nil, err
 		return nil, err
 	}
 	}
 	m := newDefaultMessage(t.ID, "")
 	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 {
 	if e != nil {
 		return nil, e.With(t)
 		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)
 		return nil, errHTTPTooManyRequestsLimitMessages.With(t)
 	} else if email != "" && !vrate.EmailAllowed() {
 	} else if email != "" && !vrate.EmailAllowed() {
 		return nil, errHTTPTooManyRequestsLimitEmails.With(t)
 		return nil, errHTTPTooManyRequestsLimitEmails.With(t)
-	} else if sms != "" && !vrate.SMSAllowed() {
-		return nil, errHTTPTooManyRequestsLimitSMS.With(t)
 	} else if call != "" && !vrate.CallAllowed() {
 	} else if call != "" && !vrate.CallAllowed() {
 		return nil, errHTTPTooManyRequestsLimitCalls.With(t)
 		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 != "" {
 		if s.smtpSender != nil && email != "" {
 			go s.sendEmail(v, m, email)
 			go s.sendEmail(v, m, email)
 		}
 		}
-		if s.config.TwilioAccount != "" && sms != "" {
-			go s.sendSMS(v, r, m, sms)
-		}
 		if s.config.TwilioAccount != "" && call != "" {
 		if s.config.TwilioAccount != "" && call != "" {
 			go s.callPhone(v, r, m, 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")
 	cache = readBoolParam(r, true, "x-cache", "cache")
 	firebase = readBoolParam(r, true, "x-firebase", "firebase")
 	firebase = readBoolParam(r, true, "x-firebase", "firebase")
 	m.Title = maybeDecodeHeader(readParam(r, "x-title", "title", "t"))
 	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 attach != "" {
 		if !urlRegex.MatchString(attach) {
 		if !urlRegex.MatchString(attach) {
-			return false, false, "", "", "", false, errHTTPBadRequestAttachmentURLInvalid
+			return false, false, "", "", false, errHTTPBadRequestAttachmentURLInvalid
 		}
 		}
 		m.Attachment.URL = attach
 		m.Attachment.URL = attach
 		if m.Attachment.Name == "" {
 		if m.Attachment.Name == "" {
@@ -883,25 +877,19 @@ func (s *Server) parsePublishParams(r *http.Request, m *message) (cache bool, fi
 	}
 	}
 	if icon != "" {
 	if icon != "" {
 		if !urlRegex.MatchString(icon) {
 		if !urlRegex.MatchString(icon) {
-			return false, false, "", "", "", false, errHTTPBadRequestIconURLInvalid
+			return false, false, "", "", false, errHTTPBadRequestIconURLInvalid
 		}
 		}
 		m.Icon = icon
 		m.Icon = icon
 	}
 	}
 	email = readParam(r, "x-email", "x-e-mail", "email", "e-mail", "mail", "e")
 	email = readParam(r, "x-email", "x-e-mail", "email", "e-mail", "mail", "e")
 	if s.smtpSender == nil && email != "" {
 	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")
 	call = readParam(r, "x-call", "call")
 	if call != "" && s.config.TwilioAccount == "" {
 	if call != "" && s.config.TwilioAccount == "" {
-		return false, false, "", "", "", false, errHTTPBadRequestTwilioDisabled
+		return false, false, "", "", false, errHTTPBadRequestTwilioDisabled
 	} else if call != "" && !phoneNumberRegex.MatchString(call) {
 	} 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")
 	messageStr := strings.ReplaceAll(readParam(r, "x-message", "message", "m"), "\\n", "\n")
 	if messageStr != "" {
 	if messageStr != "" {
@@ -910,7 +898,7 @@ func (s *Server) parsePublishParams(r *http.Request, m *message) (cache bool, fi
 	var e error
 	var e error
 	m.Priority, e = util.ParsePriority(readParam(r, "x-priority", "priority", "prio", "p"))
 	m.Priority, e = util.ParsePriority(readParam(r, "x-priority", "priority", "prio", "p"))
 	if e != nil {
 	if e != nil {
-		return false, false, "", "", "", false, errHTTPBadRequestPriorityInvalid
+		return false, false, "", "", false, errHTTPBadRequestPriorityInvalid
 	}
 	}
 	m.Tags = readCommaSeparatedParam(r, "x-tags", "tags", "tag", "ta")
 	m.Tags = readCommaSeparatedParam(r, "x-tags", "tags", "tag", "ta")
 	for i, t := range m.Tags {
 	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")
 	delayStr := readParam(r, "x-delay", "delay", "x-at", "at", "x-in", "in")
 	if delayStr != "" {
 	if delayStr != "" {
 		if !cache {
 		if !cache {
-			return false, false, "", "", "", false, errHTTPBadRequestDelayNoCache
+			return false, false, "", "", false, errHTTPBadRequestDelayNoCache
 		}
 		}
 		if email != "" {
 		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())
 		delay, err := util.ParseFutureTime(delayStr, time.Now())
 		if err != nil {
 		if err != nil {
-			return false, false, "", "", "", false, errHTTPBadRequestDelayCannotParse
+			return false, false, "", "", false, errHTTPBadRequestDelayCannotParse
 		} else if delay.Unix() < time.Now().Add(s.config.MinDelay).Unix() {
 		} 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() {
 		} 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()
 		m.Time = delay.Unix()
 	}
 	}
@@ -938,7 +926,7 @@ func (s *Server) parsePublishParams(r *http.Request, m *message) (cache bool, fi
 	if actionsStr != "" {
 	if actionsStr != "" {
 		m.Actions, e = parseActions(actionsStr)
 		m.Actions, e = parseActions(actionsStr)
 		if e != nil {
 		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!
 	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
 		cache = false
 		email = ""
 		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.
 // 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,
 			Messages:                 limits.MessageLimit,
 			MessagesExpiryDuration:   int64(limits.MessageExpiryDuration.Seconds()),
 			MessagesExpiryDuration:   int64(limits.MessageExpiryDuration.Seconds()),
 			Emails:                   limits.EmailLimit,
 			Emails:                   limits.EmailLimit,
-			SMS:                      limits.SMSLimit,
 			Calls:                    limits.CallLimit,
 			Calls:                    limits.CallLimit,
 			Reservations:             limits.ReservationsLimit,
 			Reservations:             limits.ReservationsLimit,
 			AttachmentTotalSize:      limits.AttachmentTotalSizeLimit,
 			AttachmentTotalSize:      limits.AttachmentTotalSizeLimit,
@@ -69,8 +68,6 @@ func (s *Server) handleAccountGet(w http.ResponseWriter, r *http.Request, v *vis
 			MessagesRemaining:            stats.MessagesRemaining,
 			MessagesRemaining:            stats.MessagesRemaining,
 			Emails:                       stats.Emails,
 			Emails:                       stats.Emails,
 			EmailsRemaining:              stats.EmailsRemaining,
 			EmailsRemaining:              stats.EmailsRemaining,
-			SMS:                          stats.SMS,
-			SMSRemaining:                 stats.SMSRemaining,
 			Calls:                        stats.Calls,
 			Calls:                        stats.Calls,
 			CallsRemaining:               stats.CallsRemaining,
 			CallsRemaining:               stats.CallsRemaining,
 			Reservations:                 stats.Reservations,
 			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
 	// Check user is allowed to add phone numbers
 	if u == nil || (u.IsUser() && u.Tier == nil) {
 	if u == nil || (u.IsUser() && u.Tier == nil) {
 		return errHTTPUnauthorized
 		return errHTTPUnauthorized
-	} else if u.IsUser() && u.Tier.SMSLimit == 0 && u.Tier.CallLimit == 0 {
+	} else if u.IsUser() && u.Tier.CallLimit == 0 {
 		return errHTTPUnauthorized
 		return errHTTPUnauthorized
 	}
 	}
 	// Actually add the unverified number, and send verification
 	// 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")
 		Debug("Adding phone number, and sending verification")
 	if err := s.userManager.AddPhoneNumber(u.ID, req.Number); err != nil {
 	if err := s.userManager.AddPhoneNumber(u.ID, req.Number); err != nil {
+		if err == user.ErrPhoneNumberExists {
+			return errHTTPConflictPhoneNumberExists
+		}
 		return err
 		return err
 	}
 	}
 	if err := s.verifyPhone(v, r, req.Number); err != nil {
 	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) {
 	if !phoneNumberRegex.MatchString(req.Number) {
 		return errHTTPBadRequestPhoneNumberInvalid
 		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
 	// Get phone numbers, and check if it's in the list
 	phoneNumbers, err := s.userManager.PhoneNumbers(u.ID)
 	phoneNumbers, err := s.userManager.PhoneNumbers(u.ID)
 	if err != nil {
 	if err != nil {
@@ -581,7 +577,7 @@ func (s *Server) handleAccountPhoneNumberVerify(w http.ResponseWriter, r *http.R
 	}
 	}
 	found := false
 	found := false
 	for _, phoneNumber := range phoneNumbers {
 	for _, phoneNumber := range phoneNumbers {
-		if phoneNumber.Number == req.Number && phoneNumber.Verified {
+		if phoneNumber.Number == req.Number && !phoneNumber.Verified {
 			found = true
 			found = true
 			break
 			break
 		}
 		}

+ 0 - 2
server/server_payments.go

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

+ 17 - 14
server/server_twilio.go

@@ -15,7 +15,6 @@ import (
 )
 )
 
 
 const (
 const (
-	twilioMessageEndpoint     = "Messages.json"
 	twilioMessageFooterFormat = "This message was sent by %s via %s"
 	twilioMessageFooterFormat = "This message was sent by %s via %s"
 	twilioCallEndpoint        = "Calls.json"
 	twilioCallEndpoint        = "Calls.json"
 	twilioCallFormat          = `
 	twilioCallFormat          = `
@@ -32,15 +31,6 @@ const (
 </Response>`
 </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) {
 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)))
 	body := fmt.Sprintf(twilioCallFormat, xmlEscapeText(m.Topic), xmlEscapeText(m.Message), xmlEscapeText(s.messageFooter(v.User(), m)))
 	data := url.Values{}
 	data := url.Values{}
@@ -85,25 +75,38 @@ func (s *Server) checkVerifyPhone(v *visitor, r *http.Request, phoneNumber, code
 	data := url.Values{}
 	data := url.Values{}
 	data.Set("To", phoneNumber)
 	data.Set("To", phoneNumber)
 	data.Set("Code", code)
 	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()))
 	req, err := http.NewRequest(http.MethodPost, requestURL, strings.NewReader(data.Encode()))
 	if err != nil {
 	if err != nil {
 		return err
 		return err
 	}
 	}
 	req.Header.Set("Authorization", util.BasicAuth(s.config.TwilioAccount, s.config.TwilioAuthToken))
 	req.Header.Set("Authorization", util.BasicAuth(s.config.TwilioAccount, s.config.TwilioAuthToken))
 	req.Header.Add("Content-Type", "application/x-www-form-urlencoded")
 	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)
 	resp, err := http.DefaultClient.Do(req)
 	if err != nil {
 	if err != nil {
 		return err
 		return err
 	} else if resp.StatusCode != http.StatusOK {
 	} 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)
 	response, err := io.ReadAll(resp.Body)
 	if err != nil {
 	if err != nil {
 		return err
 		return err
 	}
 	}
-
-	ev := logvr(v, r).Tag(tagTwilio)
 	if ev.IsTrace() {
 	if ev.IsTrace() {
 		ev.Field("twilio_response", string(response)).Trace("Received successful Twilio phone verification response")
 		ev.Field("twilio_response", string(response)).Trace("Received successful Twilio phone verification response")
 	} else if ev.IsDebug() {
 	} else if ev.IsDebug() {

+ 0 - 1
server/types.go

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

+ 27 - 28
user/manager.go

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

+ 2 - 2
user/types.go

@@ -91,7 +91,6 @@ type Tier struct {
 	MessageLimit             int64         // Daily message limit
 	MessageLimit             int64         // Daily message limit
 	MessageExpiryDuration    time.Duration // Cache duration for messages
 	MessageExpiryDuration    time.Duration // Cache duration for messages
 	EmailLimit               int64         // Daily email limit
 	EmailLimit               int64         // Daily email limit
-	SMSLimit                 int64         // Daily SMS limit
 	CallLimit                int64         // Daily phone call limit
 	CallLimit                int64         // Daily phone call limit
 	ReservationLimit         int64         // Number of topic reservations allowed by user
 	ReservationLimit         int64         // Number of topic reservations allowed by user
 	AttachmentFileSizeLimit  int64         // Max file size per file (bytes)
 	AttachmentFileSizeLimit  int64         // Max file size per file (bytes)
@@ -138,7 +137,6 @@ type NotificationPrefs struct {
 type Stats struct {
 type Stats struct {
 	Messages int64
 	Messages int64
 	Emails   int64
 	Emails   int64
-	SMS      int64
 	Calls    int64
 	Calls    int64
 }
 }
 
 
@@ -285,8 +283,10 @@ var (
 	ErrUnauthorized        = errors.New("unauthorized")
 	ErrUnauthorized        = errors.New("unauthorized")
 	ErrInvalidArgument     = errors.New("invalid argument")
 	ErrInvalidArgument     = errors.New("invalid argument")
 	ErrUserNotFound        = errors.New("user not found")
 	ErrUserNotFound        = errors.New("user not found")
+	ErrUserExists          = errors.New("user already exists")
 	ErrTierNotFound        = errors.New("tier not found")
 	ErrTierNotFound        = errors.New("tier not found")
 	ErrTokenNotFound       = errors.New("token not found")
 	ErrTokenNotFound       = errors.New("token not found")
 	ErrPhoneNumberNotFound = errors.New("phone number not found")
 	ErrPhoneNumberNotFound = errors.New("phone number not found")
 	ErrTooManyReservations = errors.New("new tier has lower reservation limit")
 	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_signup: true,
     enable_payments: true,
     enable_payments: true,
     enable_reservations: true,
     enable_reservations: true,
-    enable_sms: true,
     enable_calls: true,
     enable_calls: true,
     billing_contact: "",
     billing_contact: "",
     disallowed_topics: ["docs", "static", "file", "app", "account", "settings", "signup", "login", "v1"]
     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_label": "Email",
   "publish_dialog_email_placeholder": "Address to forward the notification to, e.g. phil@example.com",
   "publish_dialog_email_placeholder": "Address to forward the notification to, e.g. phil@example.com",
   "publish_dialog_email_reset": "Remove email forward",
   "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_label": "Phone call",
   "publish_dialog_call_placeholder": "Phone number to call with the message, e.g. +12223334444",
   "publish_dialog_call_placeholder": "Phone number to call with the message, e.g. +12223334444",
   "publish_dialog_call_reset": "Remove phone call",
   "publish_dialog_call_reset": "Remove phone call",
@@ -144,7 +141,6 @@
   "publish_dialog_other_features": "Other features:",
   "publish_dialog_other_features": "Other features:",
   "publish_dialog_chip_click_label": "Click URL",
   "publish_dialog_chip_click_label": "Click URL",
   "publish_dialog_chip_email_label": "Forward to email",
   "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_call_label": "Phone call",
   "publish_dialog_chip_attach_url_label": "Attach file by URL",
   "publish_dialog_chip_attach_url_label": "Attach file by URL",
   "publish_dialog_chip_attach_file_label": "Attach local file",
   "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_confirm_password_label": "Confirm password",
   "account_basics_password_dialog_button_submit": "Change password",
   "account_basics_password_dialog_button_submit": "Change password",
   "account_basics_password_dialog_current_password_incorrect": "Password incorrect",
   "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_title": "Usage",
   "account_usage_of_limit": "of {{limit}}",
   "account_usage_of_limit": "of {{limit}}",
   "account_usage_unlimited": "Unlimited",
   "account_usage_unlimited": "Unlimited",
@@ -211,8 +209,6 @@
   "account_basics_tier_manage_billing_button": "Manage billing",
   "account_basics_tier_manage_billing_button": "Manage billing",
   "account_usage_messages_title": "Published messages",
   "account_usage_messages_title": "Published messages",
   "account_usage_emails_title": "Emails sent",
   "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_title": "Phone calls made",
   "account_usage_calls_none": "No phone calls can be made with this account",
   "account_usage_calls_none": "No phone calls can be made with this account",
   "account_usage_reservations_title": "Reserved topics",
   "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_messages_other": "{{messages}} daily messages",
   "account_upgrade_dialog_tier_features_emails_one": "{{emails}} daily email",
   "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_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_one": "{{calls}} daily phone calls",
   "account_upgrade_dialog_tier_features_calls_other": "{{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",
   "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 {
 import {
     Alert,
     Alert,
     CardActions,
     CardActions,
-    CardContent,
+    CardContent, Chip,
     FormControl,
     FormControl,
     LinearProgress,
     LinearProgress,
     Link,
     Link,
@@ -52,6 +52,7 @@ import MenuItem from "@mui/material/MenuItem";
 import DialogContentText from "@mui/material/DialogContentText";
 import DialogContentText from "@mui/material/DialogContentText";
 import {IncorrectPasswordError, UnauthorizedError} from "../app/errors";
 import {IncorrectPasswordError, UnauthorizedError} from "../app/errors";
 import {ProChip} from "./SubscriptionPopup";
 import {ProChip} from "./SubscriptionPopup";
+import AddIcon from "@mui/icons-material/Add";
 
 
 const Account = () => {
 const Account = () => {
     if (!session.exists()) {
     if (!session.exists()) {
@@ -80,6 +81,7 @@ const Basics = () => {
             <PrefGroup>
             <PrefGroup>
                 <Username/>
                 <Username/>
                 <ChangePassword/>
                 <ChangePassword/>
+                <PhoneNumbers/>
                 <AccountType/>
                 <AccountType/>
             </PrefGroup>
             </PrefGroup>
         </Card>
         </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 Stats = () => {
     const { t } = useTranslation();
     const { t } = useTranslation();
     const { account } = useContext(AccountContext);
     const { account } = useContext(AccountContext);
@@ -380,23 +416,6 @@ const Stats = () => {
                         value={account.role === Role.USER ? normalize(account.stats.emails, account.limits.emails) : 100}
                         value={account.role === Role.USER ? normalize(account.stats.emails, account.limits.emails) : 100}
                     />
                     />
                 </Pref>
                 </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) &&
                 {(account.role === Role.ADMIN || account.limits.calls > 0) &&
                     <Pref title={
                     <Pref title={
                         <>
                         <>
@@ -410,7 +429,7 @@ const Stats = () => {
                         </div>
                         </div>
                         <LinearProgress
                         <LinearProgress
                             variant="determinate"
                             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>
                     </Pref>
                 }
                 }
@@ -439,11 +458,6 @@ const Stats = () => {
                         <em>{t("account_usage_reservations_none")}</em>
                         <em>{t("account_usage_reservations_none")}</em>
                     </Pref>
                     </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 &&
                 {config.enable_calls && account.role === Role.USER && account.limits.calls === 0 &&
                     <Pref title={<>{t("account_usage_calls_title")}{config.enable_payments && <ProChip/>}</>}>
                     <Pref title={<>{t("account_usage_calls_title")}{config.enable_payments && <ProChip/>}</>}>
                         <em>{t("account_usage_calls_none")}</em>
                         <em>{t("account_usage_calls_none")}</em>