Procházet zdrojové kódy

Update web app with SMS and calls stuff

binwiederhier před 2 roky
rodič
revize
eb0805a470

+ 4 - 0
docs/releases.md

@@ -1180,6 +1180,10 @@ and the [ntfy Android app](https://github.com/binwiederhier/ntfy-android/release
 
 
 ## ntfy server v2.5.0 (UNRELEASED)
 ## ntfy server v2.5.0 (UNRELEASED)
 
 
+**Features:**
+
+* Support for SMS and voice calls using Twilio (no ticket)
+
 **Bug fixes + maintenance:**
 **Bug fixes + maintenance:**
 
 
 * Removed old ntfy website from ntfy entirely (no ticket)
 * Removed old ntfy website from ntfy entirely (no ticket)

+ 2 - 0
server/server.go

@@ -529,6 +529,8 @@ 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 != "",
 		EnableReservations: s.config.EnableReservations,
 		EnableReservations: s.config.EnableReservations,
 		BillingContact:     s.config.BillingContact,
 		BillingContact:     s.config.BillingContact,
 		DisallowedTopics:   s.config.DisallowedTopics,
 		DisallowedTopics:   s.config.DisallowedTopics,

+ 4 - 0
server/server_payments.go

@@ -68,6 +68,8 @@ 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,
 				Reservations:             freeTier.ReservationsLimit,
 				Reservations:             freeTier.ReservationsLimit,
 				AttachmentTotalSize:      freeTier.AttachmentTotalSizeLimit,
 				AttachmentTotalSize:      freeTier.AttachmentTotalSizeLimit,
 				AttachmentFileSize:       freeTier.AttachmentFileSizeLimit,
 				AttachmentFileSize:       freeTier.AttachmentFileSizeLimit,
@@ -96,6 +98,8 @@ 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,
 				Reservations:             tier.ReservationLimit,
 				Reservations:             tier.ReservationLimit,
 				AttachmentTotalSize:      tier.AttachmentTotalSizeLimit,
 				AttachmentTotalSize:      tier.AttachmentTotalSizeLimit,
 				AttachmentFileSize:       tier.AttachmentFileSizeLimit,
 				AttachmentFileSize:       tier.AttachmentFileSizeLimit,

+ 6 - 5
server/server_twilio.go

@@ -6,6 +6,7 @@ import (
 	"fmt"
 	"fmt"
 	"github.com/prometheus/client_golang/prometheus"
 	"github.com/prometheus/client_golang/prometheus"
 	"heckel.io/ntfy/log"
 	"heckel.io/ntfy/log"
+	"heckel.io/ntfy/user"
 	"heckel.io/ntfy/util"
 	"heckel.io/ntfy/util"
 	"io"
 	"io"
 	"net/http"
 	"net/http"
@@ -32,7 +33,7 @@ const (
 )
 )
 
 
 func (s *Server) sendSMS(v *visitor, r *http.Request, m *message, to string) {
 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(m))
+	body := fmt.Sprintf("%s\n\n--\n%s", m.Message, s.messageFooter(v.User(), m))
 	data := url.Values{}
 	data := url.Values{}
 	data.Set("From", s.config.TwilioFromNumber)
 	data.Set("From", s.config.TwilioFromNumber)
 	data.Set("To", to)
 	data.Set("To", to)
@@ -41,7 +42,7 @@ func (s *Server) sendSMS(v *visitor, r *http.Request, m *message, to string) {
 }
 }
 
 
 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(m)))
+	body := fmt.Sprintf(twilioCallFormat, xmlEscapeText(m.Topic), xmlEscapeText(m.Message), xmlEscapeText(s.messageFooter(v.User(), m)))
 	data := url.Values{}
 	data := url.Values{}
 	data.Set("From", s.config.TwilioFromNumber)
 	data.Set("From", s.config.TwilioFromNumber)
 	data.Set("To", to)
 	data.Set("To", to)
@@ -97,11 +98,11 @@ func (s *Server) performTwilioRequestInternal(endpoint string, data url.Values)
 	return string(response), nil
 	return string(response), nil
 }
 }
 
 
-func (s *Server) messageFooter(m *message) string {
+func (s *Server) messageFooter(u *user.User, m *message) string { // u may be nil!
 	topicURL := s.config.BaseURL + "/" + m.Topic
 	topicURL := s.config.BaseURL + "/" + m.Topic
 	sender := m.Sender.String()
 	sender := m.Sender.String()
-	if m.User != "" {
-		sender = fmt.Sprintf("%s (%s)", m.User, m.Sender)
+	if u != nil {
+		sender = fmt.Sprintf("%s (%s)", u.Name, m.Sender)
 	}
 	}
 	return fmt.Sprintf(twilioMessageFooterFormat, sender, util.ShortTopicURL(topicURL))
 	return fmt.Sprintf(twilioMessageFooterFormat, sender, util.ShortTopicURL(topicURL))
 }
 }

+ 96 - 0
server/server_twilio_test.go

@@ -2,6 +2,8 @@ package server
 
 
 import (
 import (
 	"github.com/stretchr/testify/require"
 	"github.com/stretchr/testify/require"
+	"heckel.io/ntfy/user"
+	"heckel.io/ntfy/util"
 	"io"
 	"io"
 	"net/http"
 	"net/http"
 	"net/http/httptest"
 	"net/http/httptest"
@@ -27,6 +29,7 @@ func TestServer_Twilio_SMS(t *testing.T) {
 	c.TwilioAccount = "AC1234567890"
 	c.TwilioAccount = "AC1234567890"
 	c.TwilioAuthToken = "AAEAA1234567890"
 	c.TwilioAuthToken = "AAEAA1234567890"
 	c.TwilioFromNumber = "+1234567890"
 	c.TwilioFromNumber = "+1234567890"
+	c.VisitorSMSDailyLimit = 1
 	s := newTestServer(t, c)
 	s := newTestServer(t, c)
 
 
 	response := request(t, s, "POST", "/mytopic", "test", map[string]string{
 	response := request(t, s, "POST", "/mytopic", "test", map[string]string{
@@ -38,6 +41,56 @@ func TestServer_Twilio_SMS(t *testing.T) {
 	})
 	})
 }
 }
 
 
+func TestServer_Twilio_SMS_With_User(t *testing.T) {
+	var called atomic.Bool
+	twilioServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+		if called.Load() {
+			t.Fatal("Should be only called once")
+		}
+		body, err := io.ReadAll(r.Body)
+		require.Nil(t, err)
+		require.Equal(t, "/2010-04-01/Accounts/AC1234567890/Messages.json", r.URL.Path)
+		require.Equal(t, "Basic QUMxMjM0NTY3ODkwOkFBRUFBMTIzNDU2Nzg5MA==", r.Header.Get("Authorization"))
+		require.Equal(t, "Body=test%0A%0A--%0AThis+message+was+sent+by+phil+%289.9.9.9%29+via+ntfy.sh%2Fmytopic&From=%2B1234567890&To=%2B11122233344", string(body))
+		called.Store(true)
+	}))
+	defer twilioServer.Close()
+
+	c := newTestConfigWithAuthFile(t)
+	c.BaseURL = "https://ntfy.sh"
+	c.TwilioBaseURL = twilioServer.URL
+	c.TwilioAccount = "AC1234567890"
+	c.TwilioAuthToken = "AAEAA1234567890"
+	c.TwilioFromNumber = "+1234567890"
+	s := newTestServer(t, c)
+
+	// Add tier and user
+	require.Nil(t, s.userManager.AddTier(&user.Tier{
+		Code:         "pro",
+		MessageLimit: 10,
+		SMSLimit:     1,
+	}))
+	require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleUser))
+	require.Nil(t, s.userManager.ChangeTier("phil", "pro"))
+
+	// Do request with user
+	response := request(t, s, "POST", "/mytopic", "test", map[string]string{
+		"Authorization": util.BasicAuth("phil", "phil"),
+		"SMS":           "+11122233344",
+	})
+	require.Equal(t, "test", toMessage(t, response.Body.String()).Message)
+	waitFor(t, func() bool {
+		return called.Load()
+	})
+
+	// Second one should fail due to rate limits
+	response = request(t, s, "POST", "/mytopic", "test", map[string]string{
+		"Authorization": util.BasicAuth("phil", "phil"),
+		"SMS":           "+11122233344",
+	})
+	require.Equal(t, 42910, toHTTPError(t, response.Body.String()).Code)
+}
+
 func TestServer_Twilio_Call(t *testing.T) {
 func TestServer_Twilio_Call(t *testing.T) {
 	var called atomic.Bool
 	var called atomic.Bool
 	twilioServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
 	twilioServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
@@ -55,6 +108,7 @@ func TestServer_Twilio_Call(t *testing.T) {
 	c.TwilioAccount = "AC1234567890"
 	c.TwilioAccount = "AC1234567890"
 	c.TwilioAuthToken = "AAEAA1234567890"
 	c.TwilioAuthToken = "AAEAA1234567890"
 	c.TwilioFromNumber = "+1234567890"
 	c.TwilioFromNumber = "+1234567890"
+	c.VisitorCallDailyLimit = 1
 	s := newTestServer(t, c)
 	s := newTestServer(t, c)
 
 
 	body := `this message has
 	body := `this message has
@@ -69,6 +123,48 @@ and "quotes and other 'quotes`
 	})
 	})
 }
 }
 
 
+func TestServer_Twilio_Call_With_User(t *testing.T) {
+	var called atomic.Bool
+	twilioServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+		if called.Load() {
+			t.Fatal("Should be only called once")
+		}
+		body, err := io.ReadAll(r.Body)
+		require.Nil(t, err)
+		require.Equal(t, "/2010-04-01/Accounts/AC1234567890/Calls.json", r.URL.Path)
+		require.Equal(t, "Basic QUMxMjM0NTY3ODkwOkFBRUFBMTIzNDU2Nzg5MA==", r.Header.Get("Authorization"))
+		require.Equal(t, "From=%2B1234567890&To=%2B11122233344&Twiml=%0A%3CResponse%3E%0A%09%3CPause+length%3D%221%22%2F%3E%0A%09%3CSay%3EYou+have+a+message+from+notify+on+topic+mytopic.+Message%3A%3C%2FSay%3E%0A%09%3CPause+length%3D%221%22%2F%3E%0A%09%3CSay%3Ehi+there%3C%2FSay%3E%0A%09%3CPause+length%3D%221%22%2F%3E%0A%09%3CSay%3EEnd+message.%3C%2FSay%3E%0A%09%3CPause+length%3D%221%22%2F%3E%0A%09%3CSay%3EThis+message+was+sent+by+phil+%289.9.9.9%29+via+127.0.0.1%3A12345%2Fmytopic%3C%2FSay%3E%0A%09%3CPause+length%3D%221%22%2F%3E%0A%3C%2FResponse%3E", string(body))
+		called.Store(true)
+	}))
+	defer twilioServer.Close()
+
+	c := newTestConfigWithAuthFile(t)
+	c.TwilioBaseURL = twilioServer.URL
+	c.TwilioAccount = "AC1234567890"
+	c.TwilioAuthToken = "AAEAA1234567890"
+	c.TwilioFromNumber = "+1234567890"
+	s := newTestServer(t, c)
+
+	// Add tier and user
+	require.Nil(t, s.userManager.AddTier(&user.Tier{
+		Code:         "pro",
+		MessageLimit: 10,
+		CallLimit:    1,
+	}))
+	require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleUser))
+	require.Nil(t, s.userManager.ChangeTier("phil", "pro"))
+
+	// Do the thing
+	response := request(t, s, "POST", "/mytopic", "hi there", map[string]string{
+		"authorization": util.BasicAuth("phil", "phil"),
+		"x-call":        "+11122233344",
+	})
+	require.Equal(t, "hi there", toMessage(t, response.Body.String()).Message)
+	waitFor(t, func() bool {
+		return called.Load()
+	})
+}
+
 func TestServer_Twilio_Call_InvalidNumber(t *testing.T) {
 func TestServer_Twilio_Call_InvalidNumber(t *testing.T) {
 	c := newTestConfig(t)
 	c := newTestConfig(t)
 	c.TwilioBaseURL = "https://127.0.0.1"
 	c.TwilioBaseURL = "https://127.0.0.1"

+ 2 - 0
server/types.go

@@ -351,6 +351,8 @@ 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"`
 	EnableReservations bool     `json:"enable_reservations"`
 	EnableReservations bool     `json:"enable_reservations"`
 	BillingContact     string   `json:"billing_contact"`
 	BillingContact     string   `json:"billing_contact"`
 	DisallowedTopics   []string `json:"disallowed_topics"`
 	DisallowedTopics   []string `json:"disallowed_topics"`

+ 8 - 6
user/manager.go

@@ -127,26 +127,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.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_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
 		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.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_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
 		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.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_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
 		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.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_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
 		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 = ?
@@ -927,11 +927,11 @@ func (a *Manager) readUser(rows *sql.Rows) (*User, error) {
 	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 messages, emails, sms, calls int64
-	var messagesLimit, messagesExpiryDuration, emailsLimit, reservationsLimit, attachmentFileSizeLimit, attachmentTotalSizeLimit, attachmentExpiryDuration, attachmentBandwidthLimit, stripeSubscriptionPaidUntil, stripeSubscriptionCancelAt, deleted sql.NullInt64
+	var messagesLimit, messagesExpiryDuration, emailsLimit, smsLimit, 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, &reservationsLimit, &attachmentFileSizeLimit, &attachmentTotalSizeLimit, &attachmentExpiryDuration, &attachmentBandwidthLimit, &stripeMonthlyPriceID, &stripeYearlyPriceID); err != nil {
+	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 {
 		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
@@ -971,6 +971,8 @@ 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,
 			ReservationLimit:         reservationsLimit.Int64,
 			ReservationLimit:         reservationsLimit.Int64,
 			AttachmentFileSizeLimit:  attachmentFileSizeLimit.Int64,
 			AttachmentFileSizeLimit:  attachmentFileSizeLimit.Int64,
 			AttachmentTotalSizeLimit: attachmentTotalSizeLimit.Int64,
 			AttachmentTotalSizeLimit: attachmentTotalSizeLimit.Int64,

+ 3 - 1
web/public/config.js

@@ -6,12 +6,14 @@
 // During web development, you may change values here for rapid testing.
 // During web development, you may change values here for rapid testing.
 
 
 var config = {
 var config = {
-    base_url: window.location.origin, // Change to test against a different server
+    base_url: "http://127.0.0.1:2586",// FIXME window.location.origin, // Change to test against a different server
     app_root: "/app",
     app_root: "/app",
     enable_login: true,
     enable_login: true,
     enable_signup: true,
     enable_signup: true,
     enable_payments: true,
     enable_payments: true,
     enable_reservations: true,
     enable_reservations: true,
+    enable_sms: 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"]
 };
 };

+ 18 - 0
web/public/static/langs/en.json

@@ -127,6 +127,12 @@
   "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_placeholder": "Phone number to call with the message, e.g. +12223334444",
+  "publish_dialog_call_reset": "Remove phone call",
   "publish_dialog_attach_label": "Attachment URL",
   "publish_dialog_attach_label": "Attachment URL",
   "publish_dialog_attach_placeholder": "Attach file by URL, e.g. https://f-droid.org/F-Droid.apk",
   "publish_dialog_attach_placeholder": "Attach file by URL, e.g. https://f-droid.org/F-Droid.apk",
   "publish_dialog_attach_reset": "Remove attachment URL",
   "publish_dialog_attach_reset": "Remove attachment URL",
@@ -138,6 +144,8 @@
   "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_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",
   "publish_dialog_chip_delay_label": "Delay delivery",
   "publish_dialog_chip_delay_label": "Delay delivery",
@@ -203,6 +211,10 @@
   "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_none": "No phone calls can be made with this account",
   "account_usage_reservations_title": "Reserved topics",
   "account_usage_reservations_title": "Reserved topics",
   "account_usage_reservations_none": "No reserved topics for this account",
   "account_usage_reservations_none": "No reserved topics for this account",
   "account_usage_attachment_storage_title": "Attachment storage",
   "account_usage_attachment_storage_title": "Attachment storage",
@@ -232,6 +244,12 @@
   "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_other": "{{calls}} daily phone calls",
+  "account_upgrade_dialog_tier_features_no_calls": "No daily phone calls",
   "account_upgrade_dialog_tier_features_attachment_file_size": "{{filesize}} per file",
   "account_upgrade_dialog_tier_features_attachment_file_size": "{{filesize}} per file",
   "account_upgrade_dialog_tier_features_attachment_total_size": "{{totalsize}} total storage",
   "account_upgrade_dialog_tier_features_attachment_total_size": "{{totalsize}} total storage",
   "account_upgrade_dialog_tier_price_per_month": "month",
   "account_upgrade_dialog_tier_price_per_month": "month",

+ 4 - 2
web/src/app/utils.js

@@ -206,10 +206,12 @@ export const formatBytes = (bytes, decimals = 2) => {
 }
 }
 
 
 export const formatNumber = (n) => {
 export const formatNumber = (n) => {
-    if (n % 1000 === 0) {
+    if (n === 0) {
+        return n;
+    } else if (n % 1000 === 0) {
         return `${n/1000}k`;
         return `${n/1000}k`;
     }
     }
-    return n;
+    return n.toLocaleString();
 }
 }
 
 
 export const formatPrice = (n) => {
 export const formatPrice = (n) => {

+ 66 - 21
web/src/components/Account.js

@@ -51,6 +51,7 @@ import {ContentCopy, Public} from "@mui/icons-material";
 import MenuItem from "@mui/material/MenuItem";
 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";
 
 
 const Account = () => {
 const Account = () => {
     if (!session.exists()) {
     if (!session.exists()) {
@@ -337,23 +338,18 @@ const Stats = () => {
                 {t("account_usage_title")}
                 {t("account_usage_title")}
             </Typography>
             </Typography>
             <PrefGroup>
             <PrefGroup>
-                <Pref title={t("account_usage_reservations_title")}>
-                    {(account.role === Role.ADMIN || account.limits.reservations > 0) &&
-                        <>
-                            <div>
-                                <Typography variant="body2" sx={{float: "left"}}>{account.stats.reservations}</Typography>
-                                <Typography variant="body2" sx={{float: "right"}}>{account.role === Role.USER ? t("account_usage_of_limit", {limit: account.limits.reservations}) : t("account_usage_unlimited")}</Typography>
-                            </div>
-                            <LinearProgress
-                                variant="determinate"
-                                value={account.role === Role.USER && account.limits.reservations > 0 ? normalize(account.stats.reservations, account.limits.reservations) : 100}
-                            />
-                        </>
-                    }
-                    {account.role === Role.USER && account.limits.reservations === 0 &&
-                        <em>{t("account_usage_reservations_none")}</em>
-                    }
-                </Pref>
+                {(account.role === Role.ADMIN || account.limits.reservations > 0) &&
+                    <Pref title={t("account_usage_reservations_title")}>
+                        <div>
+                            <Typography variant="body2" sx={{float: "left"}}>{account.stats.reservations.toLocaleString()}</Typography>
+                            <Typography variant="body2" sx={{float: "right"}}>{account.role === Role.USER ? t("account_usage_of_limit", { limit: account.limits.reservations.toLocaleString() }) : t("account_usage_unlimited")}</Typography>
+                        </div>
+                        <LinearProgress
+                            variant="determinate"
+                            value={account.role === Role.USER && account.limits.reservations > 0 ? normalize(account.stats.reservations, account.limits.reservations) : 100}
+                        />
+                    </Pref>
+                }
                 <Pref title={
                 <Pref title={
                     <>
                     <>
                         {t("account_usage_messages_title")}
                         {t("account_usage_messages_title")}
@@ -361,8 +357,8 @@ const Stats = () => {
                     </>
                     </>
                 }>
                 }>
                     <div>
                     <div>
-                        <Typography variant="body2" sx={{float: "left"}}>{account.stats.messages}</Typography>
-                        <Typography variant="body2" sx={{float: "right"}}>{account.role === Role.USER ? t("account_usage_of_limit", { limit: account.limits.messages }) : t("account_usage_unlimited")}</Typography>
+                        <Typography variant="body2" sx={{float: "left"}}>{account.stats.messages.toLocaleString()}</Typography>
+                        <Typography variant="body2" sx={{float: "right"}}>{account.role === Role.USER ? t("account_usage_of_limit", { limit: account.limits.messages.toLocaleString() }) : t("account_usage_unlimited")}</Typography>
                     </div>
                     </div>
                     <LinearProgress
                     <LinearProgress
                         variant="determinate"
                         variant="determinate"
@@ -376,14 +372,48 @@ const Stats = () => {
                     </>
                     </>
                 }>
                 }>
                     <div>
                     <div>
-                        <Typography variant="body2" sx={{float: "left"}}>{account.stats.emails}</Typography>
-                        <Typography variant="body2" sx={{float: "right"}}>{account.role === Role.USER ? t("account_usage_of_limit", { limit: account.limits.emails }) : t("account_usage_unlimited")}</Typography>
+                        <Typography variant="body2" sx={{float: "left"}}>{account.stats.emails.toLocaleString()}</Typography>
+                        <Typography variant="body2" sx={{float: "right"}}>{account.role === Role.USER ? t("account_usage_of_limit", { limit: account.limits.emails.toLocaleString() }) : t("account_usage_unlimited")}</Typography>
                     </div>
                     </div>
                     <LinearProgress
                     <LinearProgress
                         variant="determinate"
                         variant="determinate"
                         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) &&
+                    <Pref title={
+                        <>
+                            {t("account_usage_calls_title")}
+                            <Tooltip title={t("account_usage_limits_reset_daily")}><span><InfoIcon/></span></Tooltip>
+                        </>
+                    }>
+                        <div>
+                            <Typography variant="body2" sx={{float: "left"}}>{account.stats.calls.toLocaleString()}</Typography>
+                            <Typography variant="body2" sx={{float: "right"}}>{account.role === Role.USER ? t("account_usage_of_limit", { limit: account.limits.calls.toLocaleString() }) : t("account_usage_unlimited")}</Typography>
+                        </div>
+                        <LinearProgress
+                            variant="determinate"
+                            value={account.role === Role.USER && account.limits.sms > 0 ? normalize(account.stats.calls, account.limits.calls) : 100}
+                        />
+                    </Pref>
+                }
                 <Pref
                 <Pref
                     alignTop
                     alignTop
                     title={t("account_usage_attachment_storage_title")}
                     title={t("account_usage_attachment_storage_title")}
@@ -404,6 +434,21 @@ const Stats = () => {
                         value={account.role === Role.USER ? normalize(account.stats.attachment_total_size, account.limits.attachment_total_size) : 100}
                         value={account.role === Role.USER ? normalize(account.stats.attachment_total_size, account.limits.attachment_total_size) : 100}
                     />
                     />
                 </Pref>
                 </Pref>
+                {config.enable_reservations && account.role === Role.USER && account.limits.reservations === 0 &&
+                    <Pref title={<>{t("account_usage_reservations_title")}{config.enable_payments && <ProChip/>}</>}>
+                        <em>{t("account_usage_reservations_none")}</em>
+                    </Pref>
+                }
+                {config.enable_sms && account.role === Role.USER && account.limits.sms === 0 &&
+                    <Pref title={<>{t("account_usage_sms_title")}{config.enable_payments && <ProChip/>}</>}>
+                        <em>{t("account_usage_sms_none")}</em>
+                    </Pref>
+                }
+                {config.enable_calls && account.role === Role.USER && account.limits.calls === 0 &&
+                    <Pref title={<>{t("account_usage_calls_title")}{config.enable_payments && <ProChip/>}</>}>
+                        <em>{t("account_usage_calls_none")}</em>
+                    </Pref>
+                }
             </PrefGroup>
             </PrefGroup>
             {account.role === Role.USER && account.limits.basis === LimitBasis.IP &&
             {account.role === Role.USER && account.limits.basis === LimitBasis.IP &&
                 <Typography variant="body1">
                 <Typography variant="body1">

+ 54 - 0
web/src/components/PublishDialog.js

@@ -45,6 +45,8 @@ const PublishDialog = (props) => {
     const [filename, setFilename] = useState("");
     const [filename, setFilename] = useState("");
     const [filenameEdited, setFilenameEdited] = useState(false);
     const [filenameEdited, setFilenameEdited] = useState(false);
     const [email, setEmail] = useState("");
     const [email, setEmail] = useState("");
+    const [sms, setSms] = useState("");
+    const [call, setCall] = useState("");
     const [delay, setDelay] = useState("");
     const [delay, setDelay] = useState("");
     const [publishAnother, setPublishAnother] = useState(false);
     const [publishAnother, setPublishAnother] = useState(false);
 
 
@@ -52,6 +54,8 @@ const PublishDialog = (props) => {
     const [showClickUrl, setShowClickUrl] = useState(false);
     const [showClickUrl, setShowClickUrl] = useState(false);
     const [showAttachUrl, setShowAttachUrl] = useState(false);
     const [showAttachUrl, setShowAttachUrl] = useState(false);
     const [showEmail, setShowEmail] = useState(false);
     const [showEmail, setShowEmail] = useState(false);
+    const [showSms, setShowSms] = useState(false);
+    const [showCall, setShowCall] = useState(false);
     const [showDelay, setShowDelay] = useState(false);
     const [showDelay, setShowDelay] = useState(false);
 
 
     const showAttachFile = !!attachFile && !showAttachUrl;
     const showAttachFile = !!attachFile && !showAttachUrl;
@@ -124,6 +128,12 @@ const PublishDialog = (props) => {
         if (email.trim()) {
         if (email.trim()) {
             url.searchParams.append("email", email.trim());
             url.searchParams.append("email", email.trim());
         }
         }
+        if (sms.trim()) {
+            url.searchParams.append("sms", sms.trim());
+        }
+        if (call.trim()) {
+            url.searchParams.append("call", call.trim());
+        }
         if (delay.trim()) {
         if (delay.trim()) {
             url.searchParams.append("delay", delay.trim());
             url.searchParams.append("delay", delay.trim());
         }
         }
@@ -406,6 +416,48 @@ const PublishDialog = (props) => {
                             />
                             />
                         </ClosableRow>
                         </ClosableRow>
                     }
                     }
+                    {showSms &&
+                        <ClosableRow disabled={disabled} closeLabel={t("publish_dialog_sms_reset")} onClose={() => {
+                            setSms("");
+                            setShowSms(false);
+                        }}>
+                            <TextField
+                                margin="dense"
+                                label={t("publish_dialog_sms_label")}
+                                placeholder={t("publish_dialog_sms_placeholder")}
+                                value={sms}
+                                onChange={ev => setSms(ev.target.value)}
+                                disabled={disabled}
+                                type="tel"
+                                variant="standard"
+                                fullWidth
+                                inputProps={{
+                                    "aria-label": t("publish_dialog_sms_label")
+                                }}
+                            />
+                        </ClosableRow>
+                    }
+                    {showCall &&
+                        <ClosableRow disabled={disabled} closeLabel={t("publish_dialog_call_reset")} onClose={() => {
+                            setCall("");
+                            setShowCall(false);
+                        }}>
+                            <TextField
+                                margin="dense"
+                                label={t("publish_dialog_call_label")}
+                                placeholder={t("publish_dialog_call_placeholder")}
+                                value={call}
+                                onChange={ev => setCall(ev.target.value)}
+                                disabled={disabled}
+                                type="tel"
+                                variant="standard"
+                                fullWidth
+                                inputProps={{
+                                    "aria-label": t("publish_dialog_call_label")
+                                }}
+                            />
+                        </ClosableRow>
+                    }
                     {showAttachUrl &&
                     {showAttachUrl &&
                         <ClosableRow disabled={disabled} closeLabel={t("publish_dialog_attach_reset")} onClose={() => {
                         <ClosableRow disabled={disabled} closeLabel={t("publish_dialog_attach_reset")} onClose={() => {
                             setAttachUrl("");
                             setAttachUrl("");
@@ -510,6 +562,8 @@ const PublishDialog = (props) => {
                     <div>
                     <div>
                         {!showClickUrl && <Chip clickable disabled={disabled} label={t("publish_dialog_chip_click_label")} aria-label={t("publish_dialog_chip_click_label")} onClick={() => setShowClickUrl(true)} sx={{marginRight: 1, marginBottom: 1}}/>}
                         {!showClickUrl && <Chip clickable disabled={disabled} label={t("publish_dialog_chip_click_label")} aria-label={t("publish_dialog_chip_click_label")} onClick={() => setShowClickUrl(true)} sx={{marginRight: 1, marginBottom: 1}}/>}
                         {!showEmail && <Chip clickable disabled={disabled} label={t("publish_dialog_chip_email_label")} aria-label={t("publish_dialog_chip_email_label")} onClick={() => setShowEmail(true)} sx={{marginRight: 1, marginBottom: 1}}/>}
                         {!showEmail && <Chip clickable disabled={disabled} label={t("publish_dialog_chip_email_label")} aria-label={t("publish_dialog_chip_email_label")} onClick={() => setShowEmail(true)} sx={{marginRight: 1, marginBottom: 1}}/>}
+                        {!showSms && <Chip clickable disabled={disabled} label={t("publish_dialog_chip_sms_label")} aria-label={t("publish_dialog_chip_sms_label")} onClick={() => setShowSms(true)} sx={{marginRight: 1, marginBottom: 1}}/>}
+                        {!showCall && <Chip clickable disabled={disabled} label={t("publish_dialog_chip_call_label")} aria-label={t("publish_dialog_chip_call_label")} onClick={() => setShowCall(true)} sx={{marginRight: 1, marginBottom: 1}}/>}
                         {!showAttachUrl && !showAttachFile && <Chip clickable disabled={disabled} label={t("publish_dialog_chip_attach_url_label")} aria-label={t("publish_dialog_chip_attach_url_label")} onClick={() => setShowAttachUrl(true)} sx={{marginRight: 1, marginBottom: 1}}/>}
                         {!showAttachUrl && !showAttachFile && <Chip clickable disabled={disabled} label={t("publish_dialog_chip_attach_url_label")} aria-label={t("publish_dialog_chip_attach_url_label")} onClick={() => setShowAttachUrl(true)} sx={{marginRight: 1, marginBottom: 1}}/>}
                         {!showAttachFile && !showAttachUrl && <Chip clickable disabled={disabled} label={t("publish_dialog_chip_attach_file_label")} aria-label={t("publish_dialog_chip_attach_file_label")} onClick={() => handleAttachFileClick()} sx={{marginRight: 1, marginBottom: 1}}/>}
                         {!showAttachFile && !showAttachUrl && <Chip clickable disabled={disabled} label={t("publish_dialog_chip_attach_file_label")} aria-label={t("publish_dialog_chip_attach_file_label")} onClick={() => handleAttachFileClick()} sx={{marginRight: 1, marginBottom: 1}}/>}
                         {!showDelay && <Chip clickable disabled={disabled} label={t("publish_dialog_chip_delay_label")} aria-label={t("publish_dialog_chip_delay_label")} onClick={() => setShowDelay(true)} sx={{marginRight: 1, marginBottom: 1}}/>}
                         {!showDelay && <Chip clickable disabled={disabled} label={t("publish_dialog_chip_delay_label")} aria-label={t("publish_dialog_chip_delay_label")} onClick={() => setShowDelay(true)} sx={{marginRight: 1, marginBottom: 1}}/>}

+ 2 - 2
web/src/components/SubscriptionPopup.js

@@ -277,14 +277,14 @@ const LimitReachedChip = () => {
     );
     );
 };
 };
 
 
-const ProChip = () => {
+export const ProChip = () => {
     const { t } = useTranslation();
     const { t } = useTranslation();
     return (
     return (
         <Chip
         <Chip
             label={"ntfy Pro"}
             label={"ntfy Pro"}
             variant="outlined"
             variant="outlined"
             color="primary"
             color="primary"
-            sx={{ opacity: 0.8, borderWidth: "2px", height: "24px", marginLeft: "5px" }}
+            sx={{ opacity: 0.8, fontWeight: "bold", borderWidth: "2px", height: "24px", marginLeft: "5px" }}
         />
         />
     );
     );
 };
 };

+ 5 - 2
web/src/components/UpgradeDialog.js

@@ -298,11 +298,14 @@ const TierCard = (props) => {
                         </div>
                         </div>
                         <List dense>
                         <List dense>
                             {tier.limits.reservations > 0 && <Feature>{t("account_upgrade_dialog_tier_features_reservations", { reservations: tier.limits.reservations, count: tier.limits.reservations })}</Feature>}
                             {tier.limits.reservations > 0 && <Feature>{t("account_upgrade_dialog_tier_features_reservations", { reservations: tier.limits.reservations, count: tier.limits.reservations })}</Feature>}
-                            {tier.limits.reservations === 0 && <NoFeature>{t("account_upgrade_dialog_tier_features_no_reservations")}</NoFeature>}
                             <Feature>{t("account_upgrade_dialog_tier_features_messages", { messages: formatNumber(tier.limits.messages), count: tier.limits.messages })}</Feature>
                             <Feature>{t("account_upgrade_dialog_tier_features_messages", { messages: formatNumber(tier.limits.messages), count: tier.limits.messages })}</Feature>
                             <Feature>{t("account_upgrade_dialog_tier_features_emails", { emails: formatNumber(tier.limits.emails), count: tier.limits.emails })}</Feature>
                             <Feature>{t("account_upgrade_dialog_tier_features_emails", { emails: formatNumber(tier.limits.emails), count: tier.limits.emails })}</Feature>
+                            {tier.limits.sms > 0 && <Feature>{t("account_upgrade_dialog_tier_features_sms", { sms: formatNumber(tier.limits.sms), count: tier.limits.sms })}</Feature>}
+                            {tier.limits.calls > 0 && <Feature>{t("account_upgrade_dialog_tier_features_calls", { calls: formatNumber(tier.limits.calls), count: tier.limits.calls })}</Feature>}
                             <Feature>{t("account_upgrade_dialog_tier_features_attachment_file_size", { filesize: formatBytes(tier.limits.attachment_file_size, 0) })}</Feature>
                             <Feature>{t("account_upgrade_dialog_tier_features_attachment_file_size", { filesize: formatBytes(tier.limits.attachment_file_size, 0) })}</Feature>
-                            <Feature>{t("account_upgrade_dialog_tier_features_attachment_total_size", { totalsize: formatBytes(tier.limits.attachment_total_size, 0) })}</Feature>
+                            {tier.limits.reservations === 0 && <NoFeature>{t("account_upgrade_dialog_tier_features_no_reservations")}</NoFeature>}
+                            {tier.limits.sms === 0 && <NoFeature>{t("account_upgrade_dialog_tier_features_no_sms")}</NoFeature>}
+                            {tier.limits.calls === 0 && <NoFeature>{t("account_upgrade_dialog_tier_features_no_calls")}</NoFeature>}
                         </List>
                         </List>
                         {tier.prices && props.interval === SubscriptionInterval.MONTH &&
                         {tier.prices && props.interval === SubscriptionInterval.MONTH &&
                             <Typography variant="body2" color="gray">
                             <Typography variant="body2" color="gray">