Rename plan->tier, topics->reservations, more tests, more todos

This commit is contained in:
binwiederhier 2023-01-07 21:04:13 -05:00
parent df512d0ba2
commit 1f54adad71
14 changed files with 298 additions and 134 deletions

View file

@ -502,7 +502,7 @@ func (c *messageCache) AttachmentsExpired() ([]string, error) {
return ids, nil
}
func (c *messageCache) MarkAttachmentsDeleted(ids []string) error {
func (c *messageCache) MarkAttachmentsDeleted(ids ...string) error {
tx, err := c.db.Begin()
if err != nil {
return err

View file

@ -57,8 +57,9 @@ import (
- visitor with/without user
- plan-based message expiry
- plan-based attachment expiry
Docs:
- "expires" field in message
Refactor:
- rename TopicsLimit -> ReservationsLimit
- rename /access -> /reservation
Later:
- Password reset
@ -544,8 +545,8 @@ func (s *Server) handlePublishWithoutResponse(r *http.Request, v *visitor) (*mes
if v.user != nil {
m.User = v.user.Name
}
if v.user != nil && v.user.Plan != nil {
m.Expires = time.Now().Unix() + v.user.Plan.MessagesExpiryDuration
if v.user != nil && v.user.Tier != nil {
m.Expires = time.Now().Unix() + v.user.Tier.MessagesExpiryDuration
} else {
m.Expires = time.Now().Add(s.config.CacheDuration).Unix()
}
@ -822,8 +823,8 @@ func (s *Server) handleBodyAsAttachment(r *http.Request, v *visitor, m *message,
return errHTTPBadRequestAttachmentsDisallowed
}
var attachmentExpiryDuration time.Duration
if v.user != nil && v.user.Plan != nil {
attachmentExpiryDuration = time.Duration(v.user.Plan.AttachmentExpiryDuration) * time.Second
if v.user != nil && v.user.Tier != nil {
attachmentExpiryDuration = time.Duration(v.user.Tier.AttachmentExpiryDuration) * time.Second
} else {
attachmentExpiryDuration = s.config.AttachmentExpiryDuration
}
@ -1240,13 +1241,16 @@ func (s *Server) execManager() {
if s.fileCache != nil {
ids, err := s.messageCache.AttachmentsExpired()
if err != nil {
log.Warn("Error retrieving expired attachments: %s", err.Error())
log.Warn("Manager: Error retrieving expired attachments: %s", err.Error())
} else if len(ids) > 0 {
if err := s.fileCache.Remove(ids...); err != nil {
log.Warn("Error deleting attachments: %s", err.Error())
if log.IsDebug() {
log.Debug("Manager: Deleting attachments %s", strings.Join(ids, ", "))
}
if err := s.messageCache.MarkAttachmentsDeleted(ids); err != nil {
log.Warn("Error marking attachments deleted: %s", err.Error())
if err := s.fileCache.Remove(ids...); err != nil {
log.Warn("Manager: Error deleting attachments: %s", err.Error())
}
if err := s.messageCache.MarkAttachmentsDeleted(ids...); err != nil {
log.Warn("Manager: Error marking attachments deleted: %s", err.Error())
}
} else {
log.Debug("Manager: No expired attachments to delete")

View file

@ -50,8 +50,8 @@ func (s *Server) handleAccountGet(w http.ResponseWriter, _ *http.Request, v *vis
MessagesRemaining: stats.MessagesRemaining,
Emails: stats.Emails,
EmailsRemaining: stats.EmailsRemaining,
Topics: stats.Topics,
TopicsRemaining: stats.TopicsRemaining,
Reservations: stats.Reservations,
ReservationsRemaining: stats.ReservationsRemaining,
AttachmentTotalSize: stats.AttachmentTotalSize,
AttachmentTotalSizeRemaining: stats.AttachmentTotalSizeRemaining,
},
@ -60,7 +60,7 @@ func (s *Server) handleAccountGet(w http.ResponseWriter, _ *http.Request, v *vis
Messages: stats.MessagesLimit,
MessagesExpiryDuration: stats.MessagesExpiryDuration,
Emails: stats.EmailsLimit,
Topics: stats.TopicsLimit,
Reservations: stats.ReservationsLimit,
AttachmentTotalSize: stats.AttachmentTotalSizeLimit,
AttachmentFileSize: stats.AttachmentFileSizeLimit,
AttachmentExpiryDuration: stats.AttachmentExpiryDuration,
@ -80,19 +80,19 @@ func (s *Server) handleAccountGet(w http.ResponseWriter, _ *http.Request, v *vis
response.Subscriptions = v.user.Prefs.Subscriptions
}
}
if v.user.Plan != nil {
response.Plan = &apiAccountPlan{
Code: v.user.Plan.Code,
Upgradeable: v.user.Plan.Upgradeable,
if v.user.Tier != nil {
response.Tier = &apiAccountTier{
Code: v.user.Tier.Code,
Upgradeable: v.user.Tier.Upgradeable,
}
} else if v.user.Role == user.RoleAdmin {
response.Plan = &apiAccountPlan{
Code: string(user.PlanUnlimited),
response.Tier = &apiAccountTier{
Code: string(user.TierUnlimited),
Upgradeable: false,
}
} else {
response.Plan = &apiAccountPlan{
Code: string(user.PlanDefault),
response.Tier = &apiAccountTier{
Code: string(user.TierDefault),
Upgradeable: true,
}
}
@ -112,8 +112,8 @@ func (s *Server) handleAccountGet(w http.ResponseWriter, _ *http.Request, v *vis
} else {
response.Username = user.Everyone
response.Role = string(user.RoleAnonymous)
response.Plan = &apiAccountPlan{
Code: string(user.PlanNone),
response.Tier = &apiAccountTier{
Code: string(user.TierNone),
Upgradeable: true,
}
}
@ -340,7 +340,7 @@ func (s *Server) handleAccountAccessAdd(w http.ResponseWriter, r *http.Request,
if err != nil {
return errHTTPBadRequestPermissionInvalid
}
if v.user.Plan == nil {
if v.user.Tier == nil {
return errHTTPUnauthorized // FIXME there should always be a plan!
}
if err := s.userManager.CheckAllowAccess(v.user.Name, req.Topic); err != nil {
@ -354,7 +354,7 @@ func (s *Server) handleAccountAccessAdd(w http.ResponseWriter, r *http.Request,
reservations, err := s.userManager.ReservationsCount(v.user.Name)
if err != nil {
return err
} else if reservations >= v.user.Plan.TopicsLimit {
} else if reservations >= v.user.Tier.ReservationsLimit {
return errHTTPTooManyRequestsLimitReservations
}
}

View file

@ -1,7 +1,6 @@
package server
import (
"database/sql"
"fmt"
"github.com/stretchr/testify/require"
"heckel.io/ntfy/user"
@ -343,7 +342,7 @@ func TestAccount_Delete_Not_Allowed(t *testing.T) {
require.Equal(t, 401, rr.Code)
}
func TestAccount_Reservation_Add_User_No_Plan_Failure(t *testing.T) {
func TestAccount_Reservation_AddWithoutTierFails(t *testing.T) {
conf := newTestConfigWithAuthFile(t)
conf.EnableSignup = true
s := newTestServer(t, conf)
@ -357,7 +356,7 @@ func TestAccount_Reservation_Add_User_No_Plan_Failure(t *testing.T) {
require.Equal(t, 401, rr.Code)
}
func TestAccount_Reservation_Add_Admin_Success(t *testing.T) {
func TestAccount_Reservation_AddAdminSuccess(t *testing.T) {
conf := newTestConfigWithAuthFile(t)
conf.EnableSignup = true
s := newTestServer(t, conf)
@ -370,7 +369,7 @@ func TestAccount_Reservation_Add_Admin_Success(t *testing.T) {
require.Equal(t, 40026, toHTTPError(t, rr.Body.String()).Code)
}
func TestAccount_Reservation_Add_Remove_User_With_Plan_Success(t *testing.T) {
func TestAccount_Reservation_AddRemoveUserWithTierSuccess(t *testing.T) {
conf := newTestConfigWithAuthFile(t)
conf.EnableSignup = true
s := newTestServer(t, conf)
@ -379,17 +378,19 @@ func TestAccount_Reservation_Add_Remove_User_With_Plan_Success(t *testing.T) {
rr := request(t, s, "POST", "/v1/account", `{"username":"phil", "password":"mypass"}`, nil)
require.Equal(t, 200, rr.Code)
// Create a plan (hack!)
db, err := sql.Open("sqlite3", conf.AuthFile)
require.Nil(t, err)
_, err = db.Exec(`
INSERT INTO plan (id, code, messages_limit, messages_expiry_duration, emails_limit, attachment_file_size_limit, attachment_total_size_limit, attachment_expiry_duration, topics_limit)
VALUES (1, 'testplan', 10, 86400, 10, 10, 10, 10800, 2);
UPDATE user SET plan_id = 1 WHERE user = 'phil';
`)
require.Nil(t, err)
// Create a tier
require.Nil(t, s.userManager.CreateTier(&user.Tier{
Code: "pro",
Upgradeable: false,
MessagesLimit: 123,
MessagesExpiryDuration: 86400,
EmailsLimit: 32,
ReservationsLimit: 2,
AttachmentFileSizeLimit: 1231231,
AttachmentTotalSizeLimit: 123123,
AttachmentExpiryDuration: 10800,
}))
require.Nil(t, s.userManager.ChangeTier("phil", "pro"))
// Reserve two topics
rr = request(t, s, "POST", "/v1/account/access", `{"topic": "mytopic", "everyone":"deny-all"}`, map[string]string{
@ -420,6 +421,14 @@ func TestAccount_Reservation_Add_Remove_User_With_Plan_Success(t *testing.T) {
})
require.Equal(t, 200, rr.Code)
account, _ := util.UnmarshalJSON[apiAccountResponse](io.NopCloser(rr.Body))
require.Equal(t, "pro", account.Tier.Code)
require.Equal(t, int64(123), account.Limits.Messages)
require.Equal(t, int64(86400), account.Limits.MessagesExpiryDuration)
require.Equal(t, int64(32), account.Limits.Emails)
require.Equal(t, int64(2), account.Limits.Reservations)
require.Equal(t, int64(1231231), account.Limits.AttachmentFileSize)
require.Equal(t, int64(123123), account.Limits.AttachmentTotalSize)
require.Equal(t, int64(10800), account.Limits.AttachmentExpiryDuration)
require.Equal(t, 2, len(account.Reservations))
require.Equal(t, "another", account.Reservations[0].Topic)
require.Equal(t, "write-only", account.Reservations[0].Everyone)
@ -441,27 +450,21 @@ func TestAccount_Reservation_Add_Remove_User_With_Plan_Success(t *testing.T) {
require.Equal(t, "mytopic", account.Reservations[0].Topic)
}
func TestAccount_Reservation_Add_Access_By_Anonymous_Fails(t *testing.T) {
func TestAccount_Reservation_PublishByAnonymousFails(t *testing.T) {
conf := newTestConfigWithAuthFile(t)
conf.AuthDefault = user.PermissionReadWrite
conf.EnableSignup = true
s := newTestServer(t, conf)
// Create user
// Create user with tier
rr := request(t, s, "POST", "/v1/account", `{"username":"phil", "password":"mypass"}`, nil)
require.Equal(t, 200, rr.Code)
// Create a plan (hack!)
db, err := sql.Open("sqlite3", conf.AuthFile)
require.Nil(t, err)
_, err = db.Exec(`
INSERT INTO plan (id, code, messages_limit, messages_expiry_duration, emails_limit, attachment_file_size_limit, attachment_total_size_limit, attachment_expiry_duration, topics_limit)
VALUES (1, 'testplan', 10, 86400, 10, 10, 10, 10800, 2);
UPDATE user SET plan_id = 1 WHERE user = 'phil';
`)
require.Nil(t, err)
require.Nil(t, s.userManager.CreateTier(&user.Tier{
Code: "pro",
ReservationsLimit: 2,
}))
require.Nil(t, s.userManager.ChangeTier("phil", "pro"))
// Reserve a topic
rr = request(t, s, "POST", "/v1/account/access", `{"topic": "mytopic", "everyone":"deny-all"}`, map[string]string{

View file

@ -1090,6 +1090,34 @@ func TestServer_PublishAsJSON_Invalid(t *testing.T) {
require.Equal(t, 400, response.Code)
}
func TestServer_PublishWithTierBasedMessageLimitAndExpiry(t *testing.T) {
c := newTestConfigWithAuthFile(t)
s := newTestServer(t, c)
// Create tier with certain limits
require.Nil(t, s.userManager.CreateTier(&user.Tier{
Code: "test",
MessagesLimit: 5,
MessagesExpiryDuration: 1, // Second
}))
require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleUser))
require.Nil(t, s.userManager.ChangeTier("phil", "test"))
// Publish to reach message limit
for i := 0; i < 5; i++ {
response := request(t, s, "PUT", "/mytopic", fmt.Sprintf("this is message %d", i+1), map[string]string{
"Authorization": util.BasicAuth("phil", "phil"),
})
require.Equal(t, 200, response.Code)
msg := toMessage(t, response.Body.String())
require.True(t, msg.Expires < time.Now().Unix()+5)
}
response := request(t, s, "PUT", "/mytopic", "this is too much", map[string]string{
"Authorization": util.BasicAuth("phil", "phil"),
})
require.Equal(t, 413, response.Code)
}
func TestServer_PublishAttachment(t *testing.T) {
content := util.RandomString(5000) // > 4096
s := newTestServer(t, newTestConfig(t))
@ -1271,7 +1299,7 @@ func TestServer_PublishAttachmentAndPrune(t *testing.T) {
require.Equal(t, 200, response.Code)
require.Equal(t, content, response.Body.String())
// DeleteMessages and makes sure it's gone
// Prune and makes sure it's gone
time.Sleep(time.Second) // Sigh ...
s.execManager()
require.NoFileExists(t, file)
@ -1279,6 +1307,99 @@ func TestServer_PublishAttachmentAndPrune(t *testing.T) {
require.Equal(t, 404, response.Code)
}
func TestServer_PublishAttachmentWithTierBasedExpiry(t *testing.T) {
content := util.RandomString(5000) // > 4096
c := newTestConfigWithAuthFile(t)
c.AttachmentExpiryDuration = time.Millisecond // Hack
s := newTestServer(t, c)
// Create tier with certain limits
sevenDaysInSeconds := int64(604800)
require.Nil(t, s.userManager.CreateTier(&user.Tier{
Code: "test",
MessagesExpiryDuration: sevenDaysInSeconds,
AttachmentFileSizeLimit: 50_000,
AttachmentTotalSizeLimit: 200_000,
AttachmentExpiryDuration: sevenDaysInSeconds, // 7 days
}))
require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleUser))
require.Nil(t, s.userManager.ChangeTier("phil", "test"))
// Publish and make sure we can retrieve it
response := request(t, s, "PUT", "/mytopic", content, map[string]string{
"Authorization": util.BasicAuth("phil", "phil"),
})
msg := toMessage(t, response.Body.String())
require.Contains(t, msg.Attachment.URL, "http://127.0.0.1:12345/file/")
require.True(t, msg.Attachment.Expires > time.Now().Unix()+sevenDaysInSeconds-30)
require.True(t, msg.Expires > time.Now().Unix()+sevenDaysInSeconds-30)
file := filepath.Join(s.config.AttachmentCacheDir, msg.ID)
require.FileExists(t, file)
path := strings.TrimPrefix(msg.Attachment.URL, "http://127.0.0.1:12345")
response = request(t, s, "GET", path, "", nil)
require.Equal(t, 200, response.Code)
require.Equal(t, content, response.Body.String())
// Prune and makes sure it's still there
time.Sleep(time.Second) // Sigh ...
s.execManager()
require.FileExists(t, file)
response = request(t, s, "GET", path, "", nil)
require.Equal(t, 200, response.Code)
}
func TestServer_PublishAttachmentWithTierBasedLimits(t *testing.T) {
smallFile := util.RandomString(20_000)
largeFile := util.RandomString(50_000)
c := newTestConfigWithAuthFile(t)
c.AttachmentFileSizeLimit = 20_000
c.VisitorAttachmentTotalSizeLimit = 40_000
s := newTestServer(t, c)
// Create tier with certain limits
require.Nil(t, s.userManager.CreateTier(&user.Tier{
Code: "test",
AttachmentFileSizeLimit: 50_000,
AttachmentTotalSizeLimit: 200_000,
}))
require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleUser))
require.Nil(t, s.userManager.ChangeTier("phil", "test"))
// Publish small file as anonymous
response := request(t, s, "PUT", "/mytopic", smallFile, nil)
msg := toMessage(t, response.Body.String())
require.Contains(t, msg.Attachment.URL, "http://127.0.0.1:12345/file/")
require.FileExists(t, filepath.Join(s.config.AttachmentCacheDir, msg.ID))
// Publish large file as anonymous
response = request(t, s, "PUT", "/mytopic", largeFile, nil)
require.Equal(t, 413, response.Code)
// Publish too large file as phil
response = request(t, s, "PUT", "/mytopic", largeFile+" a few more bytes", map[string]string{
"Authorization": util.BasicAuth("phil", "phil"),
})
require.Equal(t, 413, response.Code)
// Publish large file as phil (4x)
for i := 0; i < 4; i++ {
response = request(t, s, "PUT", "/mytopic", largeFile, map[string]string{
"Authorization": util.BasicAuth("phil", "phil"),
})
require.Equal(t, 200, response.Code)
msg = toMessage(t, response.Body.String())
require.Contains(t, msg.Attachment.URL, "http://127.0.0.1:12345/file/")
require.FileExists(t, filepath.Join(s.config.AttachmentCacheDir, msg.ID))
}
response = request(t, s, "PUT", "/mytopic", largeFile, map[string]string{
"Authorization": util.BasicAuth("phil", "phil"),
})
require.Equal(t, 413, response.Code)
}
func TestServer_PublishAttachmentBandwidthLimit(t *testing.T) {
content := util.RandomString(5000) // > 4096

View file

@ -235,17 +235,17 @@ type apiAccountTokenResponse struct {
Expires int64 `json:"expires"`
}
type apiAccountPlan struct {
type apiAccountTier struct {
Code string `json:"code"`
Upgradeable bool `json:"upgradeable"`
}
type apiAccountLimits struct {
Basis string `json:"basis"` // "ip", "role" or "plan"
Basis string `json:"basis"` // "ip", "role" or "tier"
Messages int64 `json:"messages"`
MessagesExpiryDuration int64 `json:"messages_expiry_duration"`
Emails int64 `json:"emails"`
Topics int64 `json:"topics"`
Reservations int64 `json:"reservations"`
AttachmentTotalSize int64 `json:"attachment_total_size"`
AttachmentFileSize int64 `json:"attachment_file_size"`
AttachmentExpiryDuration int64 `json:"attachment_expiry_duration"`
@ -256,8 +256,8 @@ type apiAccountStats struct {
MessagesRemaining int64 `json:"messages_remaining"`
Emails int64 `json:"emails"`
EmailsRemaining int64 `json:"emails_remaining"`
Topics int64 `json:"topics"`
TopicsRemaining int64 `json:"topics_remaining"`
Reservations int64 `json:"reservations"`
ReservationsRemaining int64 `json:"reservations_remaining"`
AttachmentTotalSize int64 `json:"attachment_total_size"`
AttachmentTotalSizeRemaining int64 `json:"attachment_total_size_remaining"`
}
@ -274,7 +274,7 @@ type apiAccountResponse struct {
Notification *user.NotificationPrefs `json:"notification,omitempty"`
Subscriptions []*user.Subscription `json:"subscriptions,omitempty"`
Reservations []*apiAccountReservation `json:"reservations,omitempty"`
Plan *apiAccountPlan `json:"plan,omitempty"`
Tier *apiAccountTier `json:"tier,omitempty"`
Limits *apiAccountLimits `json:"limits,omitempty"`
Stats *apiAccountStats `json:"stats,omitempty"`
}

View file

@ -42,7 +42,7 @@ type visitor struct {
}
type visitorInfo struct {
Basis string // "ip", "role" or "plan"
Basis string // "ip", "role" or "tier"
Messages int64
MessagesLimit int64
MessagesRemaining int64
@ -50,9 +50,9 @@ type visitorInfo struct {
Emails int64
EmailsLimit int64
EmailsRemaining int64
Topics int64
TopicsLimit int64
TopicsRemaining int64
Reservations int64
ReservationsLimit int64
ReservationsRemaining int64
AttachmentTotalSize int64
AttachmentTotalSizeLimit int64
AttachmentTotalSizeRemaining int64
@ -69,9 +69,9 @@ func newVisitor(conf *Config, messageCache *messageCache, userManager *user.Mana
} else {
accountLimiter = rate.NewLimiter(rate.Every(conf.VisitorAccountCreateLimitReplenish), conf.VisitorAccountCreateLimitBurst)
}
if user != nil && user.Plan != nil {
requestLimiter = rate.NewLimiter(dailyLimitToRate(user.Plan.MessagesLimit), conf.VisitorRequestLimitBurst)
emailsLimiter = rate.NewLimiter(dailyLimitToRate(user.Plan.EmailsLimit), conf.VisitorEmailLimitBurst)
if user != nil && user.Tier != nil {
requestLimiter = rate.NewLimiter(dailyLimitToRate(user.Tier.MessagesLimit), conf.VisitorRequestLimitBurst)
emailsLimiter = rate.NewLimiter(dailyLimitToRate(user.Tier.EmailsLimit), conf.VisitorEmailLimitBurst)
} else {
requestLimiter = rate.NewLimiter(rate.Every(conf.VisitorRequestLimitReplenish), conf.VisitorRequestLimitBurst)
emailsLimiter = rate.NewLimiter(rate.Every(conf.VisitorEmailLimitReplenish), conf.VisitorEmailLimitBurst)
@ -183,21 +183,21 @@ func (v *visitor) Info() (*visitorInfo, error) {
// All limits are zero!
info.MessagesExpiryDuration = 24 * 3600 // FIXME this is awful. Should be from the Unlimited plan
info.AttachmentExpiryDuration = 24 * 3600 // FIXME this is awful. Should be from the Unlimited plan
} else if v.user != nil && v.user.Plan != nil {
info.Basis = "plan"
info.MessagesLimit = v.user.Plan.MessagesLimit
info.MessagesExpiryDuration = v.user.Plan.MessagesExpiryDuration
info.EmailsLimit = v.user.Plan.EmailsLimit
info.TopicsLimit = v.user.Plan.TopicsLimit
info.AttachmentTotalSizeLimit = v.user.Plan.AttachmentTotalSizeLimit
info.AttachmentFileSizeLimit = v.user.Plan.AttachmentFileSizeLimit
info.AttachmentExpiryDuration = v.user.Plan.AttachmentExpiryDuration
} else if v.user != nil && v.user.Tier != nil {
info.Basis = "tier"
info.MessagesLimit = v.user.Tier.MessagesLimit
info.MessagesExpiryDuration = v.user.Tier.MessagesExpiryDuration
info.EmailsLimit = v.user.Tier.EmailsLimit
info.ReservationsLimit = v.user.Tier.ReservationsLimit
info.AttachmentTotalSizeLimit = v.user.Tier.AttachmentTotalSizeLimit
info.AttachmentFileSizeLimit = v.user.Tier.AttachmentFileSizeLimit
info.AttachmentExpiryDuration = v.user.Tier.AttachmentExpiryDuration
} else {
info.Basis = "ip"
info.MessagesLimit = replenishDurationToDailyLimit(v.config.VisitorRequestLimitReplenish)
info.MessagesExpiryDuration = int64(v.config.CacheDuration.Seconds())
info.EmailsLimit = replenishDurationToDailyLimit(v.config.VisitorEmailLimitReplenish)
info.TopicsLimit = 0 // FIXME
info.ReservationsLimit = 0 // FIXME
info.AttachmentTotalSizeLimit = v.config.VisitorAttachmentTotalSizeLimit
info.AttachmentFileSizeLimit = v.config.AttachmentFileSizeLimit
info.AttachmentExpiryDuration = int64(v.config.AttachmentExpiryDuration.Seconds())
@ -212,20 +212,19 @@ func (v *visitor) Info() (*visitorInfo, error) {
if err != nil {
return nil, err
}
var topics int64
var reservations int64
if v.user != nil && v.userManager != nil {
reservations, err := v.userManager.Reservations(v.user.Name) // FIXME dup call, move this to endpoint?
reservations, err = v.userManager.ReservationsCount(v.user.Name) // FIXME dup call, move this to endpoint?
if err != nil {
return nil, err
}
topics = int64(len(reservations))
}
info.Messages = messages
info.MessagesRemaining = zeroIfNegative(info.MessagesLimit - info.Messages)
info.Emails = emails
info.EmailsRemaining = zeroIfNegative(info.EmailsLimit - info.Emails)
info.Topics = topics
info.TopicsRemaining = zeroIfNegative(info.TopicsLimit - info.Topics)
info.Reservations = reservations
info.ReservationsRemaining = zeroIfNegative(info.ReservationsLimit - info.Reservations)
info.AttachmentTotalSize = attachmentsBytesUsed
info.AttachmentTotalSizeRemaining = zeroIfNegative(info.AttachmentTotalSizeLimit - info.AttachmentTotalSize)
return info, nil