From bf46839fb7b859ccd0e7e20c63b03be214535167 Mon Sep 17 00:00:00 2001 From: Matthew Meszaros Date: Fri, 4 Sep 2026 21:11:25 -0700 Subject: [PATCH] feat: address the CodeRabbit review on the mailbox allowance work by reserving the mailbox slot inside the account insert transaction under a per-organization lock so concurrent connects cannot both take the last one, resolving the storage limit under the quota lock so a plan change cannot be raced past, deleting refused attachment objects on a context that outlives the request, settling already-connected rows before the bulk batch spends allowance, leaving password columns out of the failed-rows CSV, stopping click propagation from the portalled allowance dialog, and counting bulk progress once --- docs/content/docs/guides/mailboxes.mdx | 2 +- internal/api/handler/attachment.go | 31 ++++++++++++-- internal/app/campaign/handlers.go | 34 ++++++++++------ internal/app/email/broker.go | 4 +- internal/app/email/bulk.go | 15 ++++++- internal/app/email/onboarding.go | 40 +++++++++++-------- internal/models/email.go | 26 +++++++----- internal/repository/pg_attachment.go | 36 +++++++++++------ internal/repository/pg_campaign_lifecycle.go | 21 ++++++---- internal/repository/pg_email.go | 31 ++++++++++++++ .../app/app/settings/billing/OverviewTab.tsx | 2 +- .../app/emails/BulkConnectPanel.tsx | 8 ++-- .../app/emails/MailboxAllowanceDialog.tsx | 7 +++- .../components/app/emails/bulkConnectCsv.ts | 12 ++++-- 14 files changed, 193 insertions(+), 76 deletions(-) diff --git a/docs/content/docs/guides/mailboxes.mdx b/docs/content/docs/guides/mailboxes.mdx index bfe1dba7..d53d9c01 100644 --- a/docs/content/docs/guides/mailboxes.mdx +++ b/docs/content/docs/guides/mailboxes.mdx @@ -88,7 +88,7 @@ The dialog offers a template with these headers, and accepts the common spelling The run then streams the rows to the server in small batches. Every credential is verified against its own server before it is saved, the same as a single connect, so a large file takes a few seconds per mailbox; the dialog shows live progress and the mailbox list behind it fills in as they land, for everyone in the workspace. You can stop after the batch in flight. Closing the tab loses nothing that was already connected. -**When it is done** you see how many connected, how many were already here, and how many did not connect, with a reason per row. **Download failed rows** gives you those rows exactly as you uploaded them (passwords included) plus an `error` column, so you can fix and upload that file. Re-uploading is always safe: a mailbox that is already connected is skipped, never doubled. +**When it is done** you see how many connected, how many were already here, and how many did not connect, with a reason per row. **Retry failed** runs those rows again without leaving the dialog, passwords still in memory. **Download failed rows** gives you those rows as you uploaded them plus an `error` column, with the password columns left out so no credential lands in a Downloads folder; add them back before uploading the fixed file. Re-uploading is always safe: a mailbox that is already connected is skipped, never doubled. The same endpoint is available to the API as `POST /emails/onboarding/smtp-imap/bulk`, up to `50` rows per call, answered per row. diff --git a/internal/api/handler/attachment.go b/internal/api/handler/attachment.go index e782fe12..20152d86 100644 --- a/internal/api/handler/attachment.go +++ b/internal/api/handler/attachment.go @@ -7,6 +7,7 @@ package handler import ( "bytes" + "context" "fmt" "io" "net/http" @@ -14,6 +15,7 @@ import ( "strings" "time" + "github.com/getsentry/sentry-go" "github.com/gin-gonic/gin" "github.com/google/uuid" @@ -71,6 +73,17 @@ func (h *Handler) attachmentCampaign(c *gin.Context) (campaignID, orgID uuid.UUI return campaignID, *org, nil } +// deleteObjectDetached removes an object whose row was never written, on a +// bounded context that is not cancelled with the request: a client that gives +// up mid-upload must not leave bytes in storage that no quota counts. +func (h *Handler) deleteObjectDetached(ctx context.Context, key string) { + cleanup, cancel := context.WithTimeout(context.WithoutCancel(ctx), 15*time.Second) + defer cancel() + if err := h.Storage.Delete(cleanup, key); err != nil { + sentry.CaptureException(fmt.Errorf("attachment %s: cleanup after refused reservation: %w", key, err)) + } +} + // UploadCampaignAttachment — POST /campaigns/:id/attachments (multipart "file") func (h *Handler) UploadCampaignAttachment(c *gin.Context) { campaignID, orgID, xerr := h.attachmentCampaign(c) @@ -181,15 +194,25 @@ func (h *Handler) UploadCampaignAttachment(c *gin.Context) { } // The row is the reservation: it is written only if the total still fits // once this upload is counted, so concurrent uploads cannot interleave - // past the limit. A refused file is removed from storage again. - created, used, err := h.AttachmentRepo.CreateWithinQuota(c.Request.Context(), att, orgID, limit) + // past the limit. The limit is re-read under the lock so a plan change + // that lands between the pre-check and the insert is honored. A refused + // file is removed from storage again, on a context that outlives the + // request so a cancelled upload cannot strand the object. + limitFn := func(ctx context.Context) (int64, error) { + l, xerr := h.FeatureGateService.GetStorageLimitBytes(ctx, orgID) + if xerr != nil { + return 0, xerr + } + return l, nil + } + created, used, limit, err := h.AttachmentRepo.CreateWithinQuota(c.Request.Context(), att, orgID, limitFn) if err != nil { - _ = h.Storage.Delete(c.Request.Context(), key) // best-effort cleanup + h.deleteObjectDetached(c.Request.Context(), key) errx.JSON(c, errx.InternalError()) return } if !created { - _ = h.Storage.Delete(c.Request.Context(), key) + h.deleteObjectDetached(c.Request.Context(), key) errx.JSON(c, errx.StorageLimitReached(used, limit, fh.Size)) return } diff --git a/internal/app/campaign/handlers.go b/internal/app/campaign/handlers.go index f2bf2916..ec283b10 100644 --- a/internal/app/campaign/handlers.go +++ b/internal/app/campaign/handlers.go @@ -229,13 +229,13 @@ func (s *campaignService) Duplicate(ctx context.Context, orgID, userID uuid.UUID } campaign, err := s.campaignRepository.Duplicate(ctx, repository.DuplicateCampaignInput{ - SourceID: cID, - NewID: newID, - UserID: userID, - Name: name, - Attachments: copied, - OrganizationID: orgID, - StorageLimitBytes: storageLimit, + SourceID: cID, + NewID: newID, + UserID: userID, + Name: name, + Attachments: copied, + OrganizationID: orgID, + StorageLimit: storageLimit, }) if err != nil { cleanup() @@ -250,7 +250,7 @@ func (s *campaignService) Duplicate(ctx context.Context, orgID, userID uuid.UUID used, _ := s.attachmentRepo.SumStorageUsedByOrg(ctx, orgID) var limit int64 if storageLimit != nil { - limit = *storageLimit + limit, _ = storageLimit(ctx) } return nil, errx.StorageLimitReached(used, limit, adding) } @@ -290,7 +290,7 @@ func (s *campaignService) Duplicate(ctx context.Context, orgID, userID uuid.UUID // and the insert re-checks under the quota lock. An attachment whose bytes // cannot be read is reported and skipped rather than failing the whole // duplicate. -func (s *campaignService) copyAttachments(ctx context.Context, orgID, src, dst uuid.UUID) ([]models.CampaignAttachment, *int64, func(), *errx.Error) { +func (s *campaignService) copyAttachments(ctx context.Context, orgID, src, dst uuid.UUID) ([]models.CampaignAttachment, repository.StorageLimitFunc, func(), *errx.Error) { noop := func() {} if s.attachmentRepo == nil || s.storage == nil { return nil, nil, noop, nil @@ -303,13 +303,19 @@ func (s *campaignService) copyAttachments(ctx context.Context, orgID, src, dst u return nil, nil, noop, nil } - var limit *int64 + var limit repository.StorageLimitFunc if s.featureGate != nil { l, xerr := s.featureGate.GetStorageLimitBytes(ctx, orgID) if xerr != nil { return nil, nil, noop, xerr } - limit = &l + limit = func(ctx context.Context) (int64, error) { + v, xerr := s.featureGate.GetStorageLimitBytes(ctx, orgID) + if xerr != nil { + return 0, xerr + } + return v, nil + } used, err := s.attachmentRepo.SumStorageUsedByOrg(ctx, orgID) if err != nil { return nil, nil, noop, errx.InternalError() @@ -341,8 +347,12 @@ func (s *campaignService) copyAttachments(ctx context.Context, orgID, src, dst u copied = append(copied, att) } return copied, limit, func() { + // The undo must outlive a cancelled request, or the copies are left in + // storage with no row counting them. + cleanup, cancel := context.WithTimeout(context.WithoutCancel(ctx), 30*time.Second) + defer cancel() for _, att := range copied { - if err := s.storage.Delete(ctx, att.S3Key); err != nil { + if err := s.storage.Delete(cleanup, att.S3Key); err != nil { sentry.CaptureException(fmt.Errorf("campaign %s duplicate undo: object %s: %w", src, att.S3Key, err)) } } diff --git a/internal/app/email/broker.go b/internal/app/email/broker.go index 20b47226..2f294319 100644 --- a/internal/app/email/broker.go +++ b/internal/app/email/broker.go @@ -27,7 +27,8 @@ func (s *emailService) OAuthConnectWithCode(ctx context.Context, userID string, if code = strings.TrimSpace(code); code == "" { return nil, errx.ErrEmailOnboardCode } - if xerr := s.guardInboxLimit(ctx, orgID); xerr != nil { + allowance, xerr := s.guardInboxLimit(ctx, orgID) + if xerr != nil { return nil, xerr } cfg, xerr := s.oauthConfigFor(provider) @@ -53,6 +54,7 @@ func (s *emailService) OAuthConnectWithCode(ctx context.Context, userID string, } acc, xerr := s.emailRepository.NewOauthAccount(ctx, userID, models.NewOauthAccount{ OrganizationID: orgID, + Allowance: allowance, Provider: provider, Name: name, Email: owner.Email, diff --git a/internal/app/email/bulk.go b/internal/app/email/bulk.go index bc0522cd..9c8c2006 100644 --- a/internal/app/email/bulk.go +++ b/internal/app/email/bulk.go @@ -59,8 +59,9 @@ func (s *emailService) OnboardSMTPIMAPBulk(ctx context.Context, userID string, o } } - // Duplicates inside the file are refused before they compete for the - // allowance; the first occurrence is the one that gets connected. + // Duplicates inside the file and mailboxes that are already connected are + // settled before anything competes for the allowance, so a re-uploaded + // file never spends a slot on a row that would create nothing. seen := make(map[string]bool, len(rows)) eligible := make([]int, 0, len(rows)) for i := range rows { @@ -70,6 +71,16 @@ func (s *emailService) OnboardSMTPIMAPBulk(ctx context.Context, userID string, o continue } seen[key] = true + if exists, xerr := s.emailRepository.ExistsForUser(ctx, userID, strings.TrimSpace(rows[i].Email)); xerr != nil { + fail(i, xerr) + continue + } else if exists { + res.Data[i] = models.MailboxBulkRow{ + Row: i, Email: rows[i].Email, Status: models.MailboxBulkSkipped, + Code: "already_connected", Message: errx.ErrEmailOnboardAlreadyExists.Message, + } + continue + } if len(eligible) >= remaining { used, limit := 0, 0 paid := true diff --git a/internal/app/email/onboarding.go b/internal/app/email/onboarding.go index b3261783..54b11aeb 100644 --- a/internal/app/email/onboarding.go +++ b/internal/app/email/onboarding.go @@ -28,7 +28,7 @@ func (s *emailService) OAuthStart(ctx context.Context, userID string, orgID *uui // Refuse early so we don't waste an OAuth round-trip on a request // that the inbox-limit guard would reject after callback. - if xerr := s.guardInboxLimit(ctx, orgID); xerr != nil { + if _, xerr := s.guardInboxLimit(ctx, orgID); xerr != nil { return nil, xerr } @@ -57,38 +57,40 @@ func (s *emailService) OAuthStart(ctx context.Context, userID string, orgID *uui // guardInboxLimit refuses a connect that would take the workspace past its // mailbox allowance (fair use for paid plans, FreeWorkspaceMailboxLimit for -// free ones, unlimited without billing). The allowance is counted per org, so -// no org means it cannot be applied and the connect is refused. Without an -// allowance source wired, the feature gate's free-or-paid split stands in. -func (s *emailService) guardInboxLimit(ctx context.Context, orgID *uuid.UUID) *errx.Error { +// free ones, unlimited without billing) and returns the resolved allowance so +// the insert can enforce it again under the organization's lock. The +// allowance is counted per org, so no org means it cannot be applied and the +// connect is refused. Without an allowance source wired, the feature gate's +// free-or-paid split stands in and the insert is not re-checked. +func (s *emailService) guardInboxLimit(ctx context.Context, orgID *uuid.UUID) (*models.MailboxAllowance, *errx.Error) { if orgID == nil { - return errx.ErrNoOrganization + return nil, errx.ErrNoOrganization } if s.allowance != nil { a, xerr := s.allowance.MailboxAllowance(ctx, *orgID) if xerr != nil { - return xerr + return nil, xerr } if a.CanAdd(1) { - return nil + return a, nil } - return errx.MailboxAllowanceReached(a.Used, *a.Allowance, a.Paid) + return nil, errx.MailboxAllowanceReached(a.Used, *a.Allowance, a.Paid) } if s.featureGate == nil { - return nil + return nil, nil } count, xerr := s.emailRepository.CountForOrganization(ctx, *orgID) if xerr != nil { - return xerr + return nil, xerr } allowed, xerr := s.featureGate.CanAddInbox(ctx, *orgID, count) if xerr != nil { - return xerr + return nil, xerr } if allowed { - return nil + return nil, nil } - return errx.MailboxAllowanceReached(count, models.FreeWorkspaceMailboxLimit, false) + return nil, errx.MailboxAllowanceReached(count, models.FreeWorkspaceMailboxLimit, false) } // OAuthFinish validates the state, exchanges the code for tokens, fetches the @@ -111,10 +113,13 @@ func (s *emailService) OAuthFinish(ctx context.Context, userID, code, state stri } // A reauth adds no mailbox, so an org over its inbox cap can still fix one. + var allowance *models.MailboxAllowance if sess.EmailAccountID == nil { - if xerr := s.guardInboxLimit(ctx, sess.OrganizationID); xerr != nil { + a, xerr := s.guardInboxLimit(ctx, sess.OrganizationID) + if xerr != nil { return nil, false, xerr } + allowance = a } provider := models.InboxProvider(sess.Provider) @@ -151,6 +156,7 @@ func (s *emailService) OAuthFinish(ctx context.Context, userID, code, state stri acc, xerr := s.emailRepository.NewOauthAccount(ctx, userID, models.NewOauthAccount{ OrganizationID: sess.OrganizationID, + Allowance: allowance, Provider: provider, Name: name, Email: owner.Email, @@ -176,7 +182,8 @@ func (s *emailService) OnboardSMTPIMAP(ctx context.Context, userID string, orgID return nil, xerr } - if xerr := s.guardInboxLimit(ctx, orgID); xerr != nil { + allowance, xerr := s.guardInboxLimit(ctx, orgID) + if xerr != nil { return nil, xerr } @@ -210,6 +217,7 @@ func (s *emailService) OnboardSMTPIMAP(ctx context.Context, userID string, orgID } data.OrganizationID = orgID + data.Allowance = allowance acc, xerr := s.emailRepository.NewSMTPIMAPAccount(ctx, userID, *data) if xerr != nil { diff --git a/internal/models/email.go b/internal/models/email.go index 9a1e68c1..43b6400b 100644 --- a/internal/models/email.go +++ b/internal/models/email.go @@ -220,20 +220,26 @@ type Oauth2SmtpImap struct { type NewOauthAccount struct { OrganizationID *uuid.UUID - Provider InboxProvider - Name string - Email string - AccessToken string - RefreshToken string - ExpiresAt time.Time + // Allowance, when set, is enforced again inside the insert transaction + // under the organization's mailbox lock, so concurrent connects cannot + // both take the last slot. + Allowance *MailboxAllowance + Provider InboxProvider + Name string + Email string + AccessToken string + RefreshToken string + ExpiresAt time.Time } type NewSMTPIMAPAccount struct { OrganizationID *uuid.UUID - Name string - Email string - SMTP *Service - IMAP *Service + // Allowance: see NewOauthAccount. + Allowance *MailboxAllowance + Name string + Email string + SMTP *Service + IMAP *Service } // EmailOnboardingState is stored in Redis for the lifetime of an OAuth round trip. diff --git a/internal/repository/pg_attachment.go b/internal/repository/pg_attachment.go index cd242756..750d9b3f 100644 --- a/internal/repository/pg_attachment.go +++ b/internal/repository/pg_attachment.go @@ -30,13 +30,19 @@ type AttachmentRepository interface { // (joined through campaigns) — the basis for the per-plan storage quota. SumStorageUsedByOrg(ctx context.Context, orgID uuid.UUID) (int64, error) // CreateWithinQuota inserts the row only if the organization's total stays - // within limitBytes. The check and the insert share one transaction under - // the org's attachment lock (LockStorageQuota), so two uploads in flight - // cannot both read the same total and both pass (issue #326). Returns - // created=false and the total it saw when the file does not fit. - CreateWithinQuota(ctx context.Context, att *models.CampaignAttachment, orgID uuid.UUID, limitBytes int64) (created bool, used int64, err error) + // within the limit. The limit is resolved by limitFn AFTER the org's + // attachment lock (LockStorageQuota) is held, and the check and the insert + // share that transaction, so two uploads in flight cannot both read the + // same total and both pass, and a plan change cannot be raced past + // (issue #326). Returns created=false, the total it saw and the limit it + // applied when the file does not fit. + CreateWithinQuota(ctx context.Context, att *models.CampaignAttachment, orgID uuid.UUID, limitFn StorageLimitFunc) (created bool, used, limit int64, err error) } +// StorageLimitFunc resolves an organization's storage quota in bytes. It is +// called under the quota lock so the value cannot go stale before the insert. +type StorageLimitFunc func(ctx context.Context) (int64, error) + // LockStorageQuota serialises quota checks for one organization inside the // calling transaction. Every writer of campaign_attachments that checks the // quota takes it first, so the sum it reads cannot go stale before its insert. @@ -83,22 +89,26 @@ func (r *attachmentRepository) Create(ctx context.Context, att *models.CampaignA ), att) } -func (r *attachmentRepository) CreateWithinQuota(ctx context.Context, att *models.CampaignAttachment, orgID uuid.UUID, limitBytes int64) (bool, int64, error) { +func (r *attachmentRepository) CreateWithinQuota(ctx context.Context, att *models.CampaignAttachment, orgID uuid.UUID, limitFn StorageLimitFunc) (bool, int64, int64, error) { tx, err := r.DB.Begin(ctx) if err != nil { - return false, 0, err + return false, 0, 0, err } defer func() { _ = tx.Rollback(ctx) }() if err := LockStorageQuota(ctx, tx, orgID); err != nil { - return false, 0, err + return false, 0, 0, err + } + limit, err := limitFn(ctx) + if err != nil { + return false, 0, 0, err } used, err := storageUsedTx(ctx, tx, orgID) if err != nil { - return false, 0, err + return false, 0, limit, err } - if used+att.Size > limitBytes { - return false, used, nil + if used+att.Size > limit { + return false, used, limit, nil } if err := scanAttachment(tx.QueryRow(ctx, ` INSERT INTO campaign_attachments (campaign_id, sequence_id, user_id, filename, size, mime_type, s3_key) @@ -106,9 +116,9 @@ func (r *attachmentRepository) CreateWithinQuota(ctx context.Context, att *model RETURNING `+attachmentCols, att.CampaignID, att.SequenceID, att.UserID, att.Filename, att.Size, att.MimeType, att.S3Key, ), att); err != nil { - return false, used, err + return false, used, limit, err } - return true, used + att.Size, tx.Commit(ctx) + return true, used + att.Size, limit, tx.Commit(ctx) } func (r *attachmentRepository) GetByID(ctx context.Context, id uuid.UUID) (*models.CampaignAttachment, error) { diff --git a/internal/repository/pg_campaign_lifecycle.go b/internal/repository/pg_campaign_lifecycle.go index 204d63ac..ac2d7ed3 100644 --- a/internal/repository/pg_campaign_lifecycle.go +++ b/internal/repository/pg_campaign_lifecycle.go @@ -22,11 +22,12 @@ type DuplicateCampaignInput struct { UserID uuid.UUID Name string Attachments []models.CampaignAttachment - // OrganizationID and StorageLimitBytes make the copied attachments count - // against the quota inside the same transaction that inserts them. A nil - // limit skips the check. - OrganizationID uuid.UUID - StorageLimitBytes *int64 + // OrganizationID and StorageLimit make the copied attachments count + // against the quota inside the same transaction that inserts them; the + // limit is resolved under the quota lock. A nil StorageLimit skips the + // check. + OrganizationID uuid.UUID + StorageLimit StorageLimitFunc } // ErrStorageQuotaExceeded is returned by Duplicate when the copied attachments @@ -172,11 +173,15 @@ func (r *campaignRepository) Duplicate(ctx context.Context, in DuplicateCampaign return nil, err } - if len(in.Attachments) > 0 && in.StorageLimitBytes != nil { + if len(in.Attachments) > 0 && in.StorageLimit != nil { if err := LockStorageQuota(ctx, tx, in.OrganizationID); err != nil { db.CaptureError(err, "", nil, "exec") return nil, err } + limit, err := in.StorageLimit(ctx) + if err != nil { + return nil, err + } used, err := storageUsedTx(ctx, tx, in.OrganizationID) if err != nil { db.CaptureError(err, "", nil, "queryrow") @@ -186,8 +191,8 @@ func (r *campaignRepository) Duplicate(ctx context.Context, in DuplicateCampaign for _, att := range in.Attachments { adding += att.Size } - if used+adding > *in.StorageLimitBytes { - return nil, fmt.Errorf("%w: %d of %d bytes used, %d to add", ErrStorageQuotaExceeded, used, *in.StorageLimitBytes, adding) + if used+adding > limit { + return nil, fmt.Errorf("%w: %d of %d bytes used, %d to add", ErrStorageQuotaExceeded, used, limit, adding) } } diff --git a/internal/repository/pg_email.go b/internal/repository/pg_email.go index eb82a608..b8b6fd4f 100644 --- a/internal/repository/pg_email.go +++ b/internal/repository/pg_email.go @@ -305,6 +305,29 @@ func (r *emailRepository) CountForOrganization(ctx context.Context, orgID uuid.U return count, nil } +// reserveMailboxSlotTx is the final allowance check, run inside the insert +// transaction under a per-organization lock so two connects that both saw one +// slot free cannot both take it. The service's earlier read is for feedback; +// this is what enforces. +func reserveMailboxSlotTx(ctx context.Context, tx pgx.Tx, orgID *uuid.UUID, a *models.MailboxAllowance) *errx.Error { + if a == nil || a.Allowance == nil || orgID == nil { + return nil + } + if _, err := tx.Exec(ctx, `SELECT pg_advisory_xact_lock(hashtext('email_accounts'), hashtext($1::text))`, orgID.String()); err != nil { + db.CaptureError(err, "", nil, "exec") + return errx.InternalError() + } + var count int + if err := tx.QueryRow(ctx, `SELECT COUNT(*) FROM email_accounts WHERE organization_id = $1`, *orgID).Scan(&count); err != nil { + db.CaptureError(err, "", nil, "queryrow") + return errx.InternalError() + } + if count >= *a.Allowance { + return errx.MailboxAllowanceReached(count, *a.Allowance, a.Paid) + } + return nil +} + func (r *emailRepository) NewOauthAccount(ctx context.Context, userID string, data models.NewOauthAccount) (*models.Email, *errx.Error) { if data.Provider == models.InboxProviderSMTPIMAP { sentry.CaptureException(errors.New("invalid inbox provider")) @@ -331,6 +354,10 @@ func (r *emailRepository) NewOauthAccount(ctx context.Context, userID string, da } defer tx.Rollback(ctx) + if xerr := reserveMailboxSlotTx(ctx, tx, data.OrganizationID, data.Allowance); xerr != nil { + return nil, xerr + } + sigplain := utils.GetSignaturePlain(data.Name) sightml := utils.GetSignatureHTML(data.Name) @@ -462,6 +489,10 @@ func (r *emailRepository) NewSMTPIMAPAccount(ctx context.Context, userID string, } defer tx.Rollback(ctx) + if xerr := reserveMailboxSlotTx(ctx, tx, data.OrganizationID, data.Allowance); xerr != nil { + return nil, xerr + } + sigplain := utils.GetSignaturePlain(data.Name) sightml := utils.GetSignatureHTML(data.Name) diff --git a/web/src/app/app/settings/billing/OverviewTab.tsx b/web/src/app/app/settings/billing/OverviewTab.tsx index e642c67e..a2d1f132 100644 --- a/web/src/app/app/settings/billing/OverviewTab.tsx +++ b/web/src/app/app/settings/billing/OverviewTab.tsx @@ -407,7 +407,7 @@ function mailboxHint(a: OrganizationLimits["mailboxes"] | undefined): string { case "override": return "Raised for this workspace by an approved request"; case "free": - return "Free workspace allowance; every paid plan holds unlimited mailboxes"; + return "Free workspace allowance; a paid plan holds one mailbox for every send a day it includes"; case "plan": return "Set by your plan"; default: diff --git a/web/src/components/app/emails/BulkConnectPanel.tsx b/web/src/components/app/emails/BulkConnectPanel.tsx index 942e3654..0baf5cc7 100644 --- a/web/src/components/app/emails/BulkConnectPanel.tsx +++ b/web/src/components/app/emails/BulkConnectPanel.tsx @@ -286,7 +286,9 @@ export default function BulkConnectPanel({ const skipped = rows.filter((r) => r.status === "skipped").length; const failed = rows.filter((r) => r.status === "failed").length; const pending = rows.filter((r) => r.status === "pending").length; - const total = ready + connected + skipped + failed + pending; + // `ready` already includes pending rows, so count the untouched ones alone. + const notStarted = rows.filter((r) => r.status === "ready").length; + const total = notStarted + connected + skipped + failed + pending; if (step === "run") { const done = connected + skipped + failed; @@ -406,7 +408,7 @@ export default function BulkConnectPanel({