diff --git a/cmd/backend/main.go b/cmd/backend/main.go
index a9fcbcb0..03bc6021 100644
--- a/cmd/backend/main.go
+++ b/cmd/backend/main.go
@@ -724,6 +724,13 @@ func main() {
// trial start (planRepo + creditService, both already constructed above).
trialService = trial.NewService(subscriptionRepository, userRepostory, planRepository, creditService)
featureGateService = feature.NewService(subscriptionRepository, planRepository)
+ // An approved daily-send increase must raise what is enforced, not
+ // only what the dashboard shows.
+ if g, ok := featureGateService.(interface {
+ WireLimitOverrides(feature.LimitOverrideReader)
+ }); ok {
+ g.WireLimitOverrides(organizationRepository)
+ }
workerAssignmentService = worker.NewAssignmentService(workerRepository, subscriptionRepository, planRepository)
subscriptionService = subscription.NewService(subscriptionRepository, planRepository)
// dailyThrottleService needs the cache that's constructed
@@ -1196,10 +1203,9 @@ func main() {
)
// Fan out email-account lifecycle events to customer webhooks.
emailService.WireWebhooks(webhookService)
- // Same wire-after-construct pattern for the daily throttle —
- // only the prod backend has a real cache; jobs / tests build
- // emailService without one.
- emailService.WireThrottle(dailyThrottleService)
+ // Every connect path checks the workspace's mailbox allowance
+ // (fair use for paid plans, the free cap otherwise).
+ emailService.WireMailboxAllowance(organizationService)
// Seed Graph delta cursors when the reconciler reloads mailboxes.
emailService.WireGraphDelta(repository.NewEmailGraphDeltaRepository(primaryDB))
// The Gmail equivalent: without it a reloaded mailbox re-bootstraps its
diff --git a/docs/content/docs/api/endpoints.mdx b/docs/content/docs/api/endpoints.mdx
index 4ca5bb6f..f7108dab 100644
--- a/docs/content/docs/api/endpoints.mdx
+++ b/docs/content/docs/api/endpoints.mdx
@@ -23,6 +23,7 @@ All paths below are relative to the versioned base URL `https://api.warmbly.com/
| GET | `/emails/:id` | `READ_EMAILS` |
| PATCH | `/emails/:id` | `WRITE_EMAILS` |
| PATCH | `/emails/tags` | `WRITE_EMAILS` |
+| GET | `/emails/allowance` | `READ_EMAILS` |
| GET | `/emails/:id/track` | `READ_EMAILS` |
| PATCH | `/emails/:id/track` | `WRITE_EMAILS` |
| POST | `/emails/:id/track/verify` | `WRITE_EMAILS` |
@@ -49,6 +50,9 @@ All paths below are relative to the versioned base URL `https://api.warmbly.com/
| PATCH | `/campaigns/:id` | `WRITE_CAMPAIGNS` |
| DELETE | `/campaigns/:id` | `WRITE_CAMPAIGNS` |
| POST | `/campaigns/:id/duplicate` | `WRITE_CAMPAIGNS` |
+| GET | `/campaigns/:id/attachments` | `READ_CAMPAIGNS` |
+| POST | `/campaigns/:id/attachments` | `WRITE_CAMPAIGNS` |
+| DELETE | `/campaigns/:id/attachments/:attachmentId` | `WRITE_CAMPAIGNS` |
| GET | `/campaigns/:id/segments` | `READ_CAMPAIGNS` |
| PUT | `/campaigns/:id/segments` | `WRITE_CAMPAIGNS` |
| GET | `/campaigns/:id/advanced` | `READ_CAMPAIGNS` |
@@ -202,6 +206,8 @@ The `/unibox/drafts` endpoints hold autosaved compose drafts, scoped to the call
`POST /emails/:id/hold` keeps a mailbox out of campaign sending until `POST /emails/:id/release` puts it back; warmup is unaffected and the automatic rest logic never releases a hold. `release` is also the manual exit for a mailbox that is `resting` automatically. Both are bodyless and idempotent, so they take no `Idempotency-Key`. See [holding a mailbox yourself](/guides/mailboxes/#holding-a-mailbox-yourself).
+`GET /emails/allowance` reports how many mailboxes the workspace holds (`used`), how many it may hold (`allowance`, `null` for unlimited), `remaining`, and the `basis` of the number: `fair_use` (the plan's daily sends divided by `sends_per_mailbox`), `plan`, `override` (an approved request), `free`, or `unlimited`. `pending_request` is the open limit-increase request for mailboxes, if any. Every connect path refuses with `mailbox_allowance_reached` once `remaining` is `0`. See [mailbox allowance](/guides/mailboxes/#mailbox-allowance).
+
`PATCH /emails/:id` accepts `save_to_sent` (boolean) on SMTP/IMAP mailboxes: when true, which is the default, the worker files a copy of each outbound message in the mailbox's Sent folder. It has no effect on Gmail and Outlook mailboxes, whose APIs file their own copy. See [keeping a copy of sent mail](/guides/mailboxes/#keeping-a-copy-of-sent-mail).
`GET /unibox` and `GET /unibox/thread` return message previews: each row carries `snippet`, a one-line summary, not the message body. Read a full message with `GET /unibox/:id`, which returns `body_plain` plus `body_html`. The HTML is sanitized before it leaves the API (scripts, event handlers, embedded frames, and unsafe URL schemes are removed), so it is safe to render, and links carry `target="_blank"` with `rel="noopener"`. `body_truncated` is `true` on the rare message whose stored body could not be read, where `body_plain` falls back to the snippet.
@@ -347,6 +353,7 @@ These never accept an API key. They depend on a human-bound session: billing flo
- `POST /auth/logout`, `POST /auth/logout-all`, `GET /auth/me`, `PATCH /auth/me/onboarding`
- `POST /auth/me/avatar`, `DELETE /auth/me/avatar`
- `POST /emails/onboarding/oauth/start`, `POST /emails/onboarding/oauth/finish`, `POST /emails/onboarding/smtp-imap`
+- `POST /emails/onboarding/smtp-imap/bulk` (up to `50` SMTP/IMAP rows in `accounts`, answered `200` with a per-row `status` of `connected`, `skipped` or `failed` and a `code`; rows past the workspace's [mailbox allowance](/guides/mailboxes/#mailbox-allowance) fail with `mailbox_allowance_reached` before any credential is dialled. Naturally retry-safe: an already connected mailbox is `skipped`, so it takes no `Idempotency-Key`)
- `POST /emails/onboarding/oauth/reauth/:id`, `PUT /emails/onboarding/smtp-imap/:id` (reconnect an existing mailbox after a credential change; JWT permission `MANAGE_EMAILS`)
- `GET /oauth/authorize/details`, `POST /oauth/authorize` (the consent flow: a human approves a third-party app)
- `GET /oauth/authorized-apps`, `DELETE /oauth/authorized-apps/:id` (apps the user has authorized)
diff --git a/docs/content/docs/api/error-codes.mdx b/docs/content/docs/api/error-codes.mdx
index 24b7539e..cc00b000 100644
--- a/docs/content/docs/api/error-codes.mdx
+++ b/docs/content/docs/api/error-codes.mdx
@@ -26,10 +26,10 @@ All errors follow this structure:
| Code | Error | Description |
|------|-------|-------------|
-| 400 | Bad Request | Invalid request syntax or parameters |
+| 400 | Bad Request | Invalid request syntax or parameters, or a quota that would be passed (`storage_limit_reached`) |
| 401 | Unauthorized | Missing or invalid authentication |
| 402 | Payment Required | Out of AI credits (`insufficient_credits`) |
-| 403 | Forbidden | Authenticated but lacks permission |
+| 403 | Forbidden | Authenticated but lacks permission, or the workspace's mailbox allowance is full (`mailbox_allowance_reached`) |
| 404 | Not Found | Resource doesn't exist |
| 409 | Conflict | Resource already exists |
| 422 | Unprocessable | Validation failed |
@@ -317,6 +317,41 @@ A `503` whose `code` is `mailbox_provider_not_configured` is not transient and r
- Or connect the mailbox over SMTP and IMAP instead, which needs no configuration
- Full walkthrough: [connect mailboxes](/development/deployment-guide/#connect-mailboxes)
+#### `mailbox_allowance_reached`
+
+A `403` whose `code` is `mailbox_allowance_reached` comes from every path that connects a mailbox: `POST /emails/onboarding/oauth/start`, `POST /emails/onboarding/oauth/finish`, `POST /emails/onboarding/smtp-imap`, and per row inside `POST /emails/onboarding/smtp-imap/bulk`. It is not a permission problem: the workspace holds its whole [mailbox allowance](/guides/mailboxes/#mailbox-allowance), which on a paid plan is one mailbox for every send a day the plan includes, and `10` on a free workspace. Nothing was connected.
+
+```json
+{
+ "error": "Forbidden",
+ "message": "This workspace holds 15000 of its 15000 mailboxes. Request an increase, or move to a plan with more daily sends.",
+ "code": "mailbox_allowance_reached",
+ "request_id": "4bbbd1b2-8f86-47dd-8a7f-9476501ad20e"
+}
+```
+
+**How to fix:**
+- Read `GET /emails/allowance` first: `remaining` says how many connects will succeed, and `pending_request` whether an increase is already asked for
+- Submit a limit-increase request for `max_email_accounts` via `POST /organization/:orgId/limit-requests`, or move to a plan with more daily sends. An approved request raises the allowance immediately; retry the connect then
+- Reconnecting an existing mailbox never returns this code
+
+#### `storage_limit_reached`
+
+A `400` whose `code` is `storage_limit_reached` comes from `POST /campaigns/:id/attachments` and from a campaign duplicate that would copy attachments. The workspace's attachment storage, summed across every campaign, would pass its quota. The check and the write happen together under a per-workspace lock, so two uploads racing for the last of the quota cannot both get in. Nothing was stored.
+
+```json
+{
+ "error": "Bad Request",
+ "message": "Storage limit reached: 51190 MB of 51200 MB used, 12 MB to add. Remove attachments or upgrade your plan.",
+ "code": "storage_limit_reached",
+ "request_id": "4bbbd1b2-8f86-47dd-8a7f-9476501ad20e"
+}
+```
+
+**How to fix:**
+- `GET /organization/current/limits` reports `storage.used_bytes` and `storage.limit_bytes`, and `storage.over_quota` when a plan change left the workspace above the quota. Existing attachments keep sending either way
+- Delete attachments you no longer need (`DELETE /campaigns/:id/attachments/:attachmentId`), or move to a paid plan for the larger quota
+
#### `mailbox_worker_unreachable`
A `503` whose `code` is `mailbox_worker_unreachable` comes from `DELETE /emails/{id}`. Disconnecting a mailbox has to reach the machine that syncs it before the record goes, because once the record is gone nothing can tell that machine to stop. When the instruction cannot be delivered, nothing is removed and the mailbox is left exactly as it was.
diff --git a/docs/content/docs/guides/billing.mdx b/docs/content/docs/guides/billing.mdx
index 8c76c04d..fd74506a 100644
--- a/docs/content/docs/guides/billing.mdx
+++ b/docs/content/docs/guides/billing.mdx
@@ -16,6 +16,8 @@ Every hosted workspace starts free. The free workspace warms up to 10 mailboxes
Annual billing is 20% off the monthly price. The same lineup is on the [pricing page](https://warmbly.com/pricing).
+Unlimited mailboxes means exactly that, under a fair-use allowance of one mailbox for every send a day the plan includes: Grow holds `3,000`, Business `15,000`, and either can be raised on request. See [mailbox allowance](/guides/mailboxes/#mailbox-allowance).
+
## Upgrading from a locked feature
When you open something your plan does not include, the dashboard opens a full-screen plan chooser instead of sending you to settings. It names the feature, highlights the plan that unlocks it, and lets you pick monthly or annual billing.
@@ -36,10 +38,10 @@ Only the workspace owner can change the plan. Other members see the same compari
- The plan card shows the plan, its status, the price, and the renewal or end date. Change plan opens the same chooser the locked features use.
- Cancel plan schedules the cancellation for the end of the paid period. The plan stays fully active until then, and Resume clears it with one click. Nothing is lost either way: the workspace returns to the free tier and keeps its mailboxes, warmup and settings.
-- Usage and limits shows live counts against the limits the server actually enforces, for mailboxes, contacts, campaigns, team members, sends this period, and API calls. A meter turns amber past 70% and red past 90%. A meter with no cap means that limit is unmetered on your plan.
+- Usage and limits shows live counts against the limits the server actually enforces, for mailboxes, sends today, contacts, campaigns, team members, attachment storage, sends this period, and API calls. A meter turns amber past 70% and red past 90%. A meter with no cap means that limit is unmetered on your plan. The mailbox meter says where its number comes from (fair use, an approved request, or the free allowance), and the storage meter says so when a plan change left the workspace over its quota: existing attachments keep sending, and new uploads wait until you are back under.
- Banners appear only when something needs a decision: a failed payment, a scheduled cancellation, or a trial about to end.
-Need a single limit raised without moving plan? Request an increase from **Settings > Limits**, linked from the usage section. An operator reviews it and the approved limit takes effect on the workspace.
+Need a single limit raised without moving plan? Request an increase from **Settings > Limits**, linked from the usage section, or straight from the connect dialog when it is mailboxes you need. An operator reviews it and the approved limit takes effect on the workspace, including the daily send allowance the sender enforces.
**Plans** compares the lineup as full plan cards, with the best value plan highlighted. On a paid plan each card also prices the switch before you commit, showing what is due today and when the next bill lands. Promo codes and the codes this workspace has redeemed live here too.
diff --git a/docs/content/docs/guides/mailboxes.mdx b/docs/content/docs/guides/mailboxes.mdx
index 87fcbde5..d53d9c01 100644
--- a/docs/content/docs/guides/mailboxes.mdx
+++ b/docs/content/docs/guides/mailboxes.mdx
@@ -41,7 +41,56 @@ With two-factor authentication on, generate an app password in your provider's s
Credentials and both connections are validated when you add the account, so wrong settings fail immediately rather than silently at send time. Tokens and credentials are sealed with envelope encryption before they touch storage.
-**Limits**: `200` mailboxes per workspace by default (higher allowances come through a reviewed limit-increase request), and `5` new mailboxes per workspace per day as an abuse guardrail that resets daily. Spread large onboarding batches across days.
+## Mailbox allowance
+
+Mailboxes are unlimited on every paid plan. What keeps that honest is a fair-use allowance derived from the plan's sending volume: one mailbox for every send a day the plan includes.
+
+| Plan | Sends per day | Mailboxes |
+| --- | --- | --- |
+| Free workspace | none | `10` |
+| Starter | `150` | `150` |
+| Grow | `3,000` | `3,000` |
+| Business | `15,000` | `15,000` |
+| Enterprise | custom | as many as the volume needs |
+
+That is deliberately far more than safe sending ever needs: at the recommended `30` to `50` sends a day per mailbox, a Business workspace fills its volume with a few hundred mailboxes and still has room for tens of thousands. The allowance exists so that it is never the reason to run a mailbox hotter. A plan whose daily sends are uncapped holds unlimited mailboxes, and a self-hosted instance without billing never counts.
+
+The connect dialog shows the allowance up front: a quiet count while there is room, a warning near the cap, and a clear full state that leads to the request flow instead of letting you type credentials that would be refused. When a connect is refused the answer is the same dialog, not an error toast.
+
+**Getting more.** Two paths, both in the dialog:
+
+- **Move to a bigger plan.** The dialog names the next plan and how many mailboxes it holds; the change is prorated and takes effect immediately.
+- **Request an increase.** Keep your plan and ask for a higher allowance with a sentence on what you are sending. An operator reviews it, usually within a business day, and the new allowance applies to the workspace straight away. The dialog shows the open request while it is pending and lets you withdraw it. History lives under **Settings > Limits**.
+
+Nothing is ever removed for being over the allowance. A workspace that moves to a smaller plan keeps every mailbox sending and warming; it simply cannot add more until it is back under, or the allowance is raised.
+
+There is no daily cap on how many mailboxes you connect: a Business workspace can connect thousands in one afternoon, and the [bulk import](#connecting-many-mailboxes-at-once) exists for exactly that.
+
+## Connecting many mailboxes at once
+
+Pick **Bulk import from CSV** in the connect dialog to connect any number of SMTP and IMAP mailboxes from one file. Gmail and Outlook mailboxes sign in one at a time, because each needs its own consent.
+
+**The file.** One mailbox per row. `email`, `smtp_host` and `imap_host` are required, plus a password; everything else has a default.
+
+| Column | Default |
+| --- | --- |
+| `email` | required |
+| `name` | derived from the address (`alex.rivera@` becomes Alex Rivera) |
+| `smtp_host`, `imap_host` | required |
+| `smtp_port`, `imap_port` | `587` and `993` |
+| `smtp_user`, `imap_user` | the address, or a shared `username` column |
+| `smtp_password`, `imap_password` | a shared `password` column |
+| `smtp_security`, `imap_security` | inferred from the port (`465` and `993` are `tls`, `587` and `143` are `starttls`) |
+
+The dialog offers a template with these headers, and accepts the common spellings (`smtp_server`, `app_password`, `login`, and so on).
+
+**What happens.** The file is read in your browser and checked before anything is sent: rows missing something the connect needs are listed with the reason and left out of the run. The preview also says how many rows fit your allowance; if the file is larger, the first rows that fit are connected and the rest are reported as failed so you can request more and re-upload only those.
+
+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. **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.
## Reconnecting an account
@@ -52,7 +101,7 @@ When the provider stops accepting a mailbox's stored credential (a password chan
A successful reconnect stores the new credential, clears the authentication error, reactivates the mailbox on its existing worker, and it resumes syncing from where it stopped. Nothing else changes: settings, history, warmup progress, and campaign membership all stay.
-Reconnecting never counts against the mailbox limit or the daily connect guardrail, so a workspace at its cap can still fix a broken mailbox. A mailbox whose sign-in is held by Warmbly Cloud is reconnected from your cloud workspace instead; the button says so if you try locally.
+Reconnecting never counts against the mailbox allowance, so a workspace at its cap can still fix a broken mailbox. A mailbox whose sign-in is held by Warmbly Cloud is reconnected from your cloud workspace instead; the button says so if you try locally.
## What gets synced
diff --git a/docs/content/docs/guides/sequences.mdx b/docs/content/docs/guides/sequences.mdx
index 3ae103ac..a34576f7 100644
--- a/docs/content/docs/guides/sequences.mdx
+++ b/docs/content/docs/guides/sequences.mdx
@@ -28,7 +28,7 @@ The Preview tab renders through the real send engine, so merge fields, condition
Apply a saved template to a step, or save a step as a reusable one from the composer. Templates carry their subject and body and live in your shared library.
-Upload attachments for a step below its composer by dragging and dropping files or clicking the upload area. A file uploaded there is sent with every email from that step and from no other step, and can be removed in the same place. Files attached to the campaign itself rather than to a step, which can only be added through the API, ride every step: they are listed under **Sent with every step** so each step shows everything it carries. Attachments count against your workspace storage limit, and removing a step deletes the files scoped to it.
+Upload attachments for a step below its composer by dragging and dropping files or clicking the upload area. A file uploaded there is sent with every email from that step and from no other step, and can be removed in the same place. Files attached to the campaign itself rather than to a step, which can only be added through the API, ride every step: they are listed under **Sent with every step** so each step shows everything it carries. Attachments count against your workspace storage limit, shown under **Settings > Billing**; an upload that would pass it is refused with `storage_limit_reached`, and two uploads racing for the last of the quota cannot both get in. Removing a step deletes the files scoped to it.
### Action steps
diff --git a/internal/api/handler/attachment.go b/internal/api/handler/attachment.go
index 7068a580..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)
@@ -129,7 +142,9 @@ func (h *Handler) UploadCampaignAttachment(c *gin.Context) {
return
}
- // Plan-based overall storage quota (org-wide).
+ // Plan-based overall storage quota (org-wide). This read is only a fast
+ // refusal before the bytes are copied to storage; the check that counts
+ // is CreateWithinQuota below, which runs under the org's quota lock.
limit, xerr := h.FeatureGateService.GetStorageLimitBytes(c.Request.Context(), orgID)
if xerr != nil {
errx.JSON(c, xerr)
@@ -141,8 +156,7 @@ func (h *Handler) UploadCampaignAttachment(c *gin.Context) {
return
}
if used+fh.Size > limit {
- errx.JSON(c, errx.New(errx.BadRequest, fmt.Sprintf(
- "storage limit reached (%d MB of %d MB used) — remove attachments or upgrade your plan", mb(used), mb(limit))))
+ errx.JSON(c, errx.StorageLimitReached(used, limit, fh.Size))
return
}
@@ -178,11 +192,30 @@ func (h *Handler) UploadCampaignAttachment(c *gin.Context) {
MimeType: mimeType,
S3Key: key,
}
- if err := h.AttachmentRepo.Create(c.Request.Context(), att); err != nil {
- _ = h.Storage.Delete(c.Request.Context(), key) // best-effort cleanup
+ // 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. 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.deleteObjectDetached(c.Request.Context(), key)
errx.JSON(c, errx.InternalError())
return
}
+ if !created {
+ h.deleteObjectDetached(c.Request.Context(), key)
+ errx.JSON(c, errx.StorageLimitReached(used, limit, fh.Size))
+ return
+ }
h.auditOrg(c, models.AuditActionCreate, models.AuditEntityCampaign, &att.ID, nil, map[string]string{
"scope": "attachment", "campaign_id": campaignID.String(), "filename": filename,
diff --git a/internal/api/handler/email_onboarding.go b/internal/api/handler/email_onboarding.go
index e4a03688..719ac303 100644
--- a/internal/api/handler/email_onboarding.go
+++ b/internal/api/handler/email_onboarding.go
@@ -1,11 +1,13 @@
package handler
import (
+ "fmt"
"net/http"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"github.com/warmbly/warmbly/internal/api/middleware"
+ "github.com/warmbly/warmbly/internal/config"
"github.com/warmbly/warmbly/internal/errx"
"github.com/warmbly/warmbly/internal/models"
)
@@ -183,3 +185,72 @@ func (h *Handler) ConnectEmailSMTPIMAP(c *gin.Context) {
c.JSON(http.StatusCreated, acc)
}
+
+// OnboardingSMTPIMAPBulkRequest carries up to config.MailboxBulkBatchMax rows.
+type OnboardingSMTPIMAPBulkRequest struct {
+ Accounts []OnboardingSMTPIMAPRequest `json:"accounts"`
+}
+
+// ConnectEmailSMTPIMAPBulk is POST /emails/onboarding/smtp-imap/bulk: the
+// dashboard's CSV import streams a file through it in batches. The answer is
+// always 200 with a per-row status, so one bad row never hides the others.
+// Re-sending a batch is safe: a mailbox that is already connected is skipped.
+func (h *Handler) ConnectEmailSMTPIMAPBulk(c *gin.Context) {
+ userIDStr := middleware.GetUserID(c)
+ orgID := middleware.GetOrganizationID(c)
+ if orgID == nil {
+ errx.Handle(c, errx.ErrNoOrganization)
+ return
+ }
+
+ var req OnboardingSMTPIMAPBulkRequest
+ if err := c.ShouldBindJSON(&req); err != nil {
+ errx.Handle(c, errx.ErrInvalid)
+ return
+ }
+ if len(req.Accounts) == 0 {
+ errx.Handle(c, errx.New(errx.BadRequest, "accounts must carry at least one row"))
+ return
+ }
+ if len(req.Accounts) > config.MailboxBulkBatchMax {
+ errx.Handle(c, errx.New(errx.BadRequest, fmt.Sprintf("accounts may carry at most %d rows per request", config.MailboxBulkBatchMax)))
+ return
+ }
+
+ rows := make([]models.NewSMTPIMAPAccount, len(req.Accounts))
+ for i, a := range req.Accounts {
+ rows[i] = models.NewSMTPIMAPAccount{Email: a.Email, Name: a.Name, SMTP: a.SMTP, IMAP: a.IMAP}
+ }
+
+ result := h.EmailService.OnboardSMTPIMAPBulk(c.Request.Context(), userIDStr, orgID, rows)
+ for _, r := range result.Data {
+ if r.Status != models.MailboxBulkConnected || r.ID == nil {
+ continue
+ }
+ h.auditOrg(c, models.AuditActionConnect, models.AuditEntityEmailAccount, r.ID, nil, map[string]string{
+ "provider": "smtp_imap",
+ "email": r.Email,
+ "bulk": "true",
+ })
+ }
+
+ c.JSON(http.StatusOK, result)
+}
+
+// GetMailboxAllowance is GET /emails/allowance: how many mailboxes the
+// workspace holds, how many it may hold and why, and any open request for
+// more. The dashboard reads it before a connect so the answer is never a
+// surprise after the credentials were typed.
+func (h *Handler) GetMailboxAllowance(c *gin.Context) {
+ orgID := middleware.GetOrganizationID(c)
+ if orgID == nil {
+ errx.Handle(c, errx.ErrNoOrganization)
+ return
+ }
+ a, xerr := h.OrganizationService.MailboxAllowance(c.Request.Context(), *orgID)
+ if xerr != nil {
+ errx.Handle(c, xerr)
+ return
+ }
+ c.JSON(http.StatusOK, a)
+}
diff --git a/internal/api/handler/organization.go b/internal/api/handler/organization.go
index 7fa113d8..e02359e9 100644
--- a/internal/api/handler/organization.go
+++ b/internal/api/handler/organization.go
@@ -493,7 +493,10 @@ func (h *Handler) GetMyPendingInvitations(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"data": invitations})
}
-// GetOrganizationLimits returns the organization's limits and current usage
+// GetOrganizationLimits returns the limits the server actually enforces for
+// the workspace (plan, then any approved override) beside the live counts,
+// plus the mailbox allowance and attachment storage, which have no plan
+// column of their own. A nil limit is unmetered.
func (h *Handler) GetOrganizationLimits(c *gin.Context) {
orgID := middleware.GetOrganizationID(c)
if orgID == nil {
@@ -501,7 +504,7 @@ func (h *Handler) GetOrganizationLimits(c *gin.Context) {
return
}
- limits, xerr := h.OrganizationService.GetOrganizationLimits(c.Request.Context(), *orgID)
+ limits, xerr := h.OrganizationService.GetEffectiveLimits(c.Request.Context(), *orgID)
if xerr != nil {
errx.JSON(c, xerr)
return
@@ -513,8 +516,33 @@ func (h *Handler) GetOrganizationLimits(c *gin.Context) {
return
}
+ mailboxes, xerr := h.OrganizationService.MailboxAllowance(c.Request.Context(), *orgID)
+ if xerr != nil {
+ errx.JSON(c, xerr)
+ return
+ }
+
+ // Storage is reported here because nothing else does: a workspace that
+ // dropped to a smaller plan is over its quota with no upload refused yet.
+ storage := gin.H{"used_bytes": int64(0), "limit_bytes": int64(0)}
+ if h.FeatureGateService != nil && h.AttachmentRepo != nil {
+ limit, xerr := h.FeatureGateService.GetStorageLimitBytes(c.Request.Context(), *orgID)
+ if xerr != nil {
+ errx.JSON(c, xerr)
+ return
+ }
+ used, err := h.AttachmentRepo.SumStorageUsedByOrg(c.Request.Context(), *orgID)
+ if err != nil {
+ errx.JSON(c, errx.InternalError())
+ return
+ }
+ storage = gin.H{"used_bytes": used, "limit_bytes": limit, "over_quota": used > limit}
+ }
+
c.JSON(http.StatusOK, gin.H{
- "limits": limits,
- "counts": counts,
+ "limits": limits,
+ "counts": counts,
+ "mailboxes": mailboxes,
+ "storage": storage,
})
}
diff --git a/internal/api/routes.go b/internal/api/routes.go
index ad0076b5..d4aa56ec 100644
--- a/internal/api/routes.go
+++ b/internal/api/routes.go
@@ -411,6 +411,8 @@ func Run(
// Bulk tag add/remove across many mailboxes (set semantics,
// naturally idempotent). Static path beside /:id like /verify.
emails.PATCH("/tags", m.RequireAccess(models.PermManageEmails, models.APIPermWriteEmails), h.BulkTagEmails)
+ // How many mailboxes the workspace holds and may hold, and why.
+ emails.GET("/allowance", m.RequireOrganization(), m.RequireAccess(models.PermManageEmails, models.APIPermReadEmails), h.GetMailboxAllowance)
emails.GET("/:id/track", m.RequireAccess(models.PermViewCampaigns, models.APIPermReadEmails), middleware.RequireAPIKeyEmailAccountParam("id"), h.GetEmailTrackingDomain)
emails.PATCH("/:id/track", m.RequireAccess(models.PermManageEmails, models.APIPermWriteEmails), middleware.RequireAPIKeyEmailAccountParam("id"), h.UpdateEmailTrackingDomain)
// Write-scoped like the auth-check refresh: persisting the
@@ -449,6 +451,9 @@ func Run(
onboardingEmails.POST("/oauth/start", h.StartEmailOAuth)
onboardingEmails.POST("/oauth/finish", h.FinishEmailOAuth)
onboardingEmails.POST("/smtp-imap", h.ConnectEmailSMTPIMAP)
+ // The CSV import: up to MailboxBulkBatchMax rows per call, answered
+ // per row. Same bar as a single connect.
+ onboardingEmails.POST("/smtp-imap/bulk", h.ConnectEmailSMTPIMAPBulk)
// Reconnect flows for an existing mailbox whose credential the
// provider invalidated (issue #274). They mutate an existing
// org asset, so unlike first connect they sit behind the same
diff --git a/internal/app/campaign/handlers.go b/internal/app/campaign/handlers.go
index e0956d74..ec283b10 100644
--- a/internal/app/campaign/handlers.go
+++ b/internal/app/campaign/handlers.go
@@ -223,23 +223,37 @@ func (s *campaignService) Duplicate(ctx context.Context, orgID, userID uuid.UUID
}
newID := uuid.New()
- copied, cleanup, xerr := s.copyAttachments(ctx, orgID, cID, newID)
+ copied, storageLimit, cleanup, xerr := s.copyAttachments(ctx, orgID, cID, newID)
if xerr != nil {
return nil, xerr
}
campaign, err := s.campaignRepository.Duplicate(ctx, repository.DuplicateCampaignInput{
- SourceID: cID,
- NewID: newID,
- UserID: userID,
- Name: name,
- Attachments: copied,
+ SourceID: cID,
+ NewID: newID,
+ UserID: userID,
+ Name: name,
+ Attachments: copied,
+ OrganizationID: orgID,
+ StorageLimit: storageLimit,
})
if err != nil {
cleanup()
if errors.Is(err, errx.ErrResourceNotFound) {
return nil, errx.ErrNotFound
}
+ if errors.Is(err, repository.ErrStorageQuotaExceeded) {
+ var adding int64
+ for _, att := range copied {
+ adding += att.Size
+ }
+ used, _ := s.attachmentRepo.SumStorageUsedByOrg(ctx, orgID)
+ var limit int64
+ if storageLimit != nil {
+ limit, _ = storageLimit(ctx)
+ }
+ return nil, errx.StorageLimitReached(used, limit, adding)
+ }
return nil, errx.InternalError()
}
@@ -269,41 +283,49 @@ func (s *campaignService) Duplicate(ctx context.Context, orgID, userID uuid.UUID
}
// copyAttachments writes a copy of every attachment object of src under dst
-// and returns the rows to insert plus a best-effort undo for when the copy
-// transaction fails. The copies count against the organization's storage
-// quota exactly like an upload would. 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, func(), *errx.Error) {
+// and returns the rows to insert, the storage limit the insert must respect,
+// and a best-effort undo for when the copy transaction fails. The copies
+// count against the organization's storage quota exactly like an upload
+// would: the read here only refuses a hopeless copy before any bytes move,
+// 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, repository.StorageLimitFunc, func(), *errx.Error) {
noop := func() {}
if s.attachmentRepo == nil || s.storage == nil {
- return nil, noop, nil
+ return nil, nil, noop, nil
}
sources, err := s.attachmentRepo.ListByCampaign(ctx, src)
if err != nil {
- return nil, noop, errx.InternalError()
+ return nil, nil, noop, errx.InternalError()
}
if len(sources) == 0 {
- return nil, noop, nil
+ return nil, nil, noop, nil
}
+ var limit repository.StorageLimitFunc
if s.featureGate != nil {
- limit, xerr := s.featureGate.GetStorageLimitBytes(ctx, orgID)
+ l, xerr := s.featureGate.GetStorageLimitBytes(ctx, orgID)
if xerr != nil {
- return nil, noop, xerr
+ return nil, nil, noop, xerr
+ }
+ 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, noop, errx.InternalError()
+ return nil, nil, noop, errx.InternalError()
}
var adding int64
for _, att := range sources {
adding += att.Size
}
- if used+adding > limit {
- const mb = 1024 * 1024
- return nil, noop, errx.New(errx.BadRequest, fmt.Sprintf(
- "duplicating would exceed your storage limit (%d MB of %d MB used, %d MB of attachments to copy): remove attachments or upgrade your plan",
- used/mb, limit/mb, adding/mb))
+ if used+adding > l {
+ return nil, nil, noop, errx.StorageLimitReached(used, l, adding)
}
}
@@ -324,9 +346,13 @@ func (s *campaignService) copyAttachments(ctx context.Context, orgID, src, dst u
att.S3Key = key
copied = append(copied, att)
}
- return copied, func() {
+ 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/dailythrottle/service.go b/internal/app/dailythrottle/service.go
index 55db5627..dda10650 100644
--- a/internal/app/dailythrottle/service.go
+++ b/internal/app/dailythrottle/service.go
@@ -29,7 +29,6 @@ type Resource string
const (
ResourceCampaign Resource = "campaign"
- ResourceMailbox Resource = "mailbox"
ResourceOrg Resource = "org"
ResourceScheduledSend Resource = "scheduled_send"
)
diff --git a/internal/app/email/broker.go b/internal/app/email/broker.go
index 76a9a9fb..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)
@@ -51,11 +52,9 @@ func (s *emailService) OAuthConnectWithCode(ctx context.Context, userID string,
if name == "" {
name = deriveNameFromEmail(owner.Email)
}
- if xerr := s.guardMailboxThrottle(ctx, orgID); xerr != nil {
- return nil, xerr
- }
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
new file mode 100644
index 00000000..9c8c2006
--- /dev/null
+++ b/internal/app/email/bulk.go
@@ -0,0 +1,156 @@
+package email
+
+import (
+ "context"
+ "errors"
+ "strings"
+ "sync"
+
+ "github.com/google/uuid"
+ "github.com/warmbly/warmbly/internal/config"
+ "github.com/warmbly/warmbly/internal/errx"
+ "github.com/warmbly/warmbly/internal/models"
+)
+
+// OnboardSMTPIMAPBulk connects up to config.MailboxBulkBatchMax mailboxes.
+//
+// The allowance is resolved once up front: rows past what fits are answered
+// with mailbox_allowance_reached without dialling anything, and the rows that
+// do fit are validated concurrently. Each of those still runs the single
+// connect path, so a row is never connected twice and every side effect of a
+// single connect (worker load, warmup pool, webhook) happens per mailbox.
+func (s *emailService) OnboardSMTPIMAPBulk(ctx context.Context, userID string, orgID *uuid.UUID, rows []models.NewSMTPIMAPAccount) *models.MailboxBulkResult {
+ res := &models.MailboxBulkResult{Data: make([]models.MailboxBulkRow, len(rows))}
+ res.Summary.Total = len(rows)
+ if len(rows) == 0 {
+ return res
+ }
+
+ fail := func(i int, xerr *errx.Error) {
+ res.Data[i] = models.MailboxBulkRow{
+ Row: i, Email: rows[i].Email, Status: models.MailboxBulkFailed,
+ Code: bulkCode(xerr), Message: xerr.Message,
+ }
+ }
+
+ // A batch-wide refusal (no org, allowance unreadable) fails every row the
+ // same way rather than pretending some rows were tried.
+ remaining := len(rows)
+ var allowance *models.MailboxAllowance
+ if orgID == nil {
+ for i := range rows {
+ fail(i, errx.ErrNoOrganization)
+ }
+ res.Summary.Failed = len(rows)
+ return res
+ }
+ if s.allowance != nil {
+ a, xerr := s.allowance.MailboxAllowance(ctx, *orgID)
+ if xerr != nil {
+ for i := range rows {
+ fail(i, xerr)
+ }
+ res.Summary.Failed = len(rows)
+ return res
+ }
+ allowance = a
+ if a.Remaining != nil && *a.Remaining < remaining {
+ remaining = *a.Remaining
+ }
+ }
+
+ // 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 {
+ key := strings.ToLower(strings.TrimSpace(rows[i].Email))
+ if key != "" && seen[key] {
+ fail(i, errx.NewWithIdentifier(errx.BadRequest, "duplicate_row", "This address appears earlier in the same file."))
+ 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
+ if allowance != nil {
+ used, paid = allowance.Used, allowance.Paid
+ if allowance.Allowance != nil {
+ limit = *allowance.Allowance
+ }
+ }
+ fail(i, errx.MailboxAllowanceReached(used, limit, paid))
+ continue
+ }
+ eligible = append(eligible, i)
+ }
+
+ var mu sync.Mutex
+ var wg sync.WaitGroup
+ sem := make(chan struct{}, config.MailboxBulkConcurrency)
+ for _, i := range eligible {
+ wg.Add(1)
+ go func(i int) {
+ defer wg.Done()
+ sem <- struct{}{}
+ defer func() { <-sem }()
+
+ row := rows[i]
+ acc, xerr := s.OnboardSMTPIMAP(ctx, userID, orgID, &row)
+ mu.Lock()
+ defer mu.Unlock()
+ switch {
+ case xerr == nil:
+ res.Data[i] = models.MailboxBulkRow{Row: i, Email: acc.Email, Status: models.MailboxBulkConnected, ID: &acc.ID}
+ case errors.Is(xerr, errx.ErrEmailOnboardAlreadyExists):
+ res.Data[i] = models.MailboxBulkRow{
+ Row: i, Email: row.Email, Status: models.MailboxBulkSkipped,
+ Code: "already_connected", Message: xerr.Message,
+ }
+ default:
+ res.Data[i] = models.MailboxBulkRow{
+ Row: i, Email: row.Email, Status: models.MailboxBulkFailed,
+ Code: bulkCode(xerr), Message: xerr.Message,
+ }
+ }
+ }(i)
+ }
+ wg.Wait()
+
+ for _, r := range res.Data {
+ switch r.Status {
+ case models.MailboxBulkConnected:
+ res.Summary.Connected++
+ case models.MailboxBulkSkipped:
+ res.Summary.Skipped++
+ default:
+ res.Summary.Failed++
+ }
+ }
+ if s.allowance != nil {
+ if a, xerr := s.allowance.MailboxAllowance(ctx, *orgID); xerr == nil {
+ res.Allowance = a
+ }
+ }
+ return res
+}
+
+// bulkCode is the stable per-row code: the error's own identifier when it
+// has one, otherwise the generic one for its HTTP class.
+func bulkCode(xerr *errx.Error) string {
+ if xerr == nil {
+ return ""
+ }
+ return xerr.ResponseCode()
+}
diff --git a/internal/app/email/onboarding.go b/internal/app/email/onboarding.go
index 36e37fe9..54b11aeb 100644
--- a/internal/app/email/onboarding.go
+++ b/internal/app/email/onboarding.go
@@ -11,8 +11,6 @@ import (
"github.com/getsentry/sentry-go"
"github.com/google/uuid"
- "github.com/warmbly/warmbly/internal/app/dailythrottle"
- "github.com/warmbly/warmbly/internal/config"
"github.com/warmbly/warmbly/internal/errx"
"github.com/warmbly/warmbly/internal/infrastructure/pubsub"
"github.com/warmbly/warmbly/internal/models"
@@ -30,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,47 +55,42 @@ func (s *emailService) OAuthStart(ctx context.Context, userID string, orgID *uui
return &models.EmailOnboardingStartResponse{URL: url, State: state}, nil
}
-// guardMailboxThrottle bounds new-mailbox connection rate per org per
-// day so abuse paths (or accidents) can't connect 200 mailboxes in
-// one tab session. The budget is keyed by org, so a request without
-// one is refused rather than exempted. The check fires only on the
-// actual create paths, not on OAuthStart, so retrying a failed flow
-// doesn't consume the day's budget.
-func (s *emailService) guardMailboxThrottle(ctx context.Context, orgID *uuid.UUID) *errx.Error {
+// 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) 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.throttle == nil {
- return nil
- }
- return s.throttle.CheckAndIncrement(ctx, *orgID, dailythrottle.ResourceMailbox, config.DailyThrottleNewMailboxes)
-}
-
-// guardInboxLimit enforces the per-org inbox cap for free-trial users.
-// Returns nil (allowed) for paid orgs and for trial orgs under the cap.
-// Trial orgs that have already connected one inbox get
-// ErrEmailOnboardInboxLimit; orgs without an active subscription or trial
-// get ErrEmailOnboardTrialExpired. The cap is counted per org, so no org
-// means the cap cannot be applied and the connect is refused.
-func (s *emailService) guardInboxLimit(ctx context.Context, orgID *uuid.UUID) *errx.Error {
- if orgID == nil {
- return errx.ErrNoOrganization
+ if s.allowance != nil {
+ a, xerr := s.allowance.MailboxAllowance(ctx, *orgID)
+ if xerr != nil {
+ return nil, xerr
+ }
+ if a.CanAdd(1) {
+ return a, nil
+ }
+ 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.ErrEmailOnboardInboxLimit
+ return nil, errx.MailboxAllowanceReached(count, models.FreeWorkspaceMailboxLimit, false)
}
// OAuthFinish validates the state, exchanges the code for tokens, fetches the
@@ -120,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)
@@ -158,12 +154,9 @@ func (s *emailService) OAuthFinish(ctx context.Context, userID, code, state stri
name = deriveNameFromEmail(owner.Email)
}
- if xerr := s.guardMailboxThrottle(ctx, sess.OrganizationID); xerr != nil {
- return nil, false, xerr
- }
-
acc, xerr := s.emailRepository.NewOauthAccount(ctx, userID, models.NewOauthAccount{
OrganizationID: sess.OrganizationID,
+ Allowance: allowance,
Provider: provider,
Name: name,
Email: owner.Email,
@@ -189,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
}
@@ -222,11 +216,8 @@ func (s *emailService) OnboardSMTPIMAP(ctx context.Context, userID string, orgID
return nil, xerr
}
- if xerr := s.guardMailboxThrottle(ctx, orgID); xerr != nil {
- return nil, xerr
- }
-
data.OrganizationID = orgID
+ data.Allowance = allowance
acc, xerr := s.emailRepository.NewSMTPIMAPAccount(ctx, userID, *data)
if xerr != nil {
diff --git a/internal/app/email/service.go b/internal/app/email/service.go
index bd880b47..34b7b5f0 100644
--- a/internal/app/email/service.go
+++ b/internal/app/email/service.go
@@ -8,7 +8,6 @@ import (
"github.com/google/uuid"
"github.com/warmbly/warmbly/internal/app/cipher"
- "github.com/warmbly/warmbly/internal/app/dailythrottle"
"github.com/warmbly/warmbly/internal/app/feature"
warmupapp "github.com/warmbly/warmbly/internal/app/warmup"
"github.com/warmbly/warmbly/internal/app/webhook"
@@ -65,6 +64,10 @@ type EmailService interface {
OAuthStart(ctx context.Context, userID string, orgID *uuid.UUID, provider models.InboxProvider) (*models.EmailOnboardingStartResponse, *errx.Error)
OAuthFinish(ctx context.Context, userID, code, state string) (*models.Email, bool, *errx.Error)
OnboardSMTPIMAP(ctx context.Context, userID string, orgID *uuid.UUID, data *models.NewSMTPIMAPAccount) (*models.Email, *errx.Error)
+ // OnboardSMTPIMAPBulk connects many SMTP/IMAP mailboxes in one call and
+ // answers per row, so one bad password never fails the file. Rows past the
+ // workspace's allowance are refused before any credential is dialled.
+ OnboardSMTPIMAPBulk(ctx context.Context, userID string, orgID *uuid.UUID, rows []models.NewSMTPIMAPAccount) *models.MailboxBulkResult
// OAuthReauth starts an OAuth round trip that renews the tokens of an
// existing Gmail/Outlook mailbox after the provider invalidated them.
OAuthReauth(ctx context.Context, userID string, orgID *uuid.UUID, accountID uuid.UUID) (*models.EmailOnboardingStartResponse, *errx.Error)
@@ -75,7 +78,9 @@ type EmailService interface {
// Optional: wire in the webhook dispatcher after construction. Once
// set, account-lifecycle events fan out to customer webhook endpoints.
WireWebhooks(w webhook.Service)
- WireThrottle(t dailythrottle.Service)
+ // WireMailboxAllowance attaches the allowance resolver every connect path
+ // checks. Without it the feature gate's free-or-paid split stands in.
+ WireMailboxAllowance(src MailboxAllowanceSource)
// WireGraphDelta attaches the Graph delta-cursor repository so the worker
// reconciler can seed a mailbox's saved cursors when loading it.
WireGraphDelta(repo repository.EmailGraphDeltaRepository)
@@ -124,7 +129,7 @@ type emailService struct {
r *cache.Cache
oauthInbox *config.Oauth2Inbox
workerAssignment worker.WorkerAssignmentService
- throttle dailythrottle.Service
+ allowance MailboxAllowanceSource
graphDelta repository.EmailGraphDeltaRepository
historyID repository.EmailHistoryIDRepository
syncState repository.EmailSyncStateRepository
@@ -210,11 +215,15 @@ func (s *emailService) WirePoolLink(repo repository.PoolLinkRepository) {
s.poolLink = repo
}
-// WireThrottle attaches the daily-creation throttle after construction
-// so callers without a Redis cache (jobs, tests) need not provide one.
-// When unset, guardMailboxThrottle is a no-op.
-func (s *emailService) WireThrottle(t dailythrottle.Service) {
- s.throttle = t
+// MailboxAllowanceSource answers how many mailboxes a workspace may hold.
+// Satisfied by the organization service; injected post-construction so this
+// package needs no import of it.
+type MailboxAllowanceSource interface {
+ MailboxAllowance(ctx context.Context, orgID uuid.UUID) (*models.MailboxAllowance, *errx.Error)
+}
+
+func (s *emailService) WireMailboxAllowance(src MailboxAllowanceSource) {
+ s.allowance = src
}
// WireWebhooks attaches the webhook dispatcher after construction. Done
diff --git a/internal/app/feature/gate.go b/internal/app/feature/gate.go
index 879c2078..971ad471 100644
--- a/internal/app/feature/gate.go
+++ b/internal/app/feature/gate.go
@@ -86,6 +86,10 @@ type featureGateService struct {
selfHost bool
// poolLink entitles a linked workspace to warm without a paid plan; nil when not wired.
poolLink PoolLinkReader
+ // overrides is the per-org limit override row, so an approved daily-send
+ // increase raises what the sender enforces and not only what the
+ // dashboard shows. Nil when not wired.
+ overrides LimitOverrideReader
}
// PoolLinkReader answers whether a workspace has a live self-hosted link.
@@ -93,6 +97,12 @@ type PoolLinkReader interface {
HasActiveLink(ctx context.Context, orgID uuid.UUID) bool
}
+// LimitOverrideReader reads the operator override row for an organization.
+// Satisfied by the organization repository.
+type LimitOverrideReader interface {
+ GetOrganizationLimitOverrides(ctx context.Context, orgID uuid.UUID) (*models.OrganizationLimitOverrides, error)
+}
+
func NewService(subRepo repository.SubscriptionRepository, planRepo repository.PlanRepository) FeatureGateService {
return &featureGateService{
subRepo: subRepo,
@@ -104,6 +114,21 @@ func NewService(subRepo repository.SubscriptionRepository, planRepo repository.P
// WirePoolLink attaches the pool-link entitlement after construction.
func (s *featureGateService) WirePoolLink(r PoolLinkReader) { s.poolLink = r }
+// WireLimitOverrides attaches the override reader after construction.
+func (s *featureGateService) WireLimitOverrides(r LimitOverrideReader) { s.overrides = r }
+
+// dailyOverride is the operator-granted daily send cap, or 0 when none.
+func (s *featureGateService) dailyOverride(ctx context.Context, orgID uuid.UUID) int {
+ if s.overrides == nil {
+ return 0
+ }
+ o, err := s.overrides.GetOrganizationLimitOverrides(ctx, orgID)
+ if err != nil || o == nil {
+ return 0
+ }
+ return o.DailyCampaignLimit
+}
+
// CanSendCampaignEmail checks if an organization can send campaign emails
func (s *featureGateService) CanSendCampaignEmail(ctx context.Context, orgID uuid.UUID) (bool, *errx.Error) {
if s.selfHost {
@@ -204,8 +229,11 @@ func (s *featureGateService) GetDailyEmailLimit(ctx context.Context, orgID uuid.
return FreeTierDailyEmailLimit, nil
}
- // Paid users = plan limit or unlimited
+ // Paid users = approved override, else plan limit, else unlimited
if sub.HasPaidSubscription() {
+ if ov := s.dailyOverride(ctx, orgID); ov > 0 {
+ return ov, nil
+ }
plan, err := s.planRepo.GetByID(ctx, sub.PlanID)
if err != nil || plan == nil {
return UnlimitedEmails, nil // Default to unlimited if plan not found
@@ -262,7 +290,9 @@ func (s *featureGateService) GetSubscriptionStatus(ctx context.Context, orgID uu
if status.IsInFreeTrial && !status.IsPaidSubscriber {
status.DailyEmailLimit = FreeTierDailyEmailLimit
} else if status.IsPaidSubscriber {
- if plan != nil && plan.DailyCampaignLimit != nil {
+ if ov := s.dailyOverride(ctx, orgID); ov > 0 {
+ status.DailyEmailLimit = ov
+ } else if plan != nil && plan.DailyCampaignLimit != nil {
status.DailyEmailLimit = *plan.DailyCampaignLimit
} else {
status.DailyEmailLimit = UnlimitedEmails
diff --git a/internal/app/instanceconfig/limits.go b/internal/app/instanceconfig/limits.go
index d26315a1..ca5dfcd1 100644
--- a/internal/app/instanceconfig/limits.go
+++ b/internal/app/instanceconfig/limits.go
@@ -4,6 +4,7 @@ import (
"strconv"
"github.com/warmbly/warmbly/internal/config"
+ "github.com/warmbly/warmbly/internal/models"
)
// LimitEntry is one number an operator may be looking for.
@@ -47,10 +48,18 @@ func Limits() []LimitGroup {
{"Warmup ramp", "+" + n(config.WarmupIncreaseDefault), "emails/day", "Added each day while the mailbox stays healthy."},
},
},
+ {
+ Title: "Mailbox allowance",
+ Entries: []LimitEntry{
+ {"Fair-use sends per mailbox", n(config.FairUseSendsPerMailbox), "sends/day", "A paid plan holds one mailbox for every this many daily sends it includes; a plan with no daily cap holds unlimited mailboxes."},
+ {"Free workspace mailboxes", n(models.FreeWorkspaceMailboxLimit), "per organization", "Without a paid plan."},
+ {"Bulk connect batch", n(config.MailboxBulkBatchMax), "rows/request", "The most SMTP/IMAP rows one bulk connect call carries; the dashboard streams a CSV through batches of this size."},
+ {"Bulk connect concurrency", n(config.MailboxBulkConcurrency), "validations", "Credentials dialled at the same time within one batch."},
+ },
+ },
{
Title: "Organization hard caps",
Entries: []LimitEntry{
- {"Connected mailboxes", n(config.HardCapMailboxes), "per organization", "The backstop when neither a plan nor an override sets one."},
{"Campaigns created", n(config.HardCapCampaignsTotal), "per organization", "Total campaigns ever created."},
{"Active campaigns", n(config.HardCapCampaignsActive), "per organization", "Running at the same time."},
{"Team members", n(config.HardCapTeamMembers), "seats", "Members in one organization."},
@@ -62,7 +71,6 @@ func Limits() []LimitGroup {
Title: "Daily creation throttles",
Entries: []LimitEntry{
{"New campaigns", n(config.DailyThrottleNewCampaigns), "per organization/day", "Resets at UTC midnight. Not raisable per organization."},
- {"Newly connected mailboxes", n(config.DailyThrottleNewMailboxes), "per organization/day", "Resets at UTC midnight."},
{"New workspaces", n(config.DailyThrottleNewOrgs), "per owner/day", "Resets at UTC midnight."},
},
},
diff --git a/internal/app/organization/service.go b/internal/app/organization/service.go
index 149c5324..7a14b3c7 100644
--- a/internal/app/organization/service.go
+++ b/internal/app/organization/service.go
@@ -97,7 +97,9 @@ type OrganizationService interface {
// Limit checks
CanAddMember(ctx context.Context, orgID uuid.UUID) (bool, *errx.Error)
CanAddCampaign(ctx context.Context, orgID uuid.UUID) (bool, *errx.Error)
- CanAddEmailAccount(ctx context.Context, orgID uuid.UUID) (bool, *errx.Error)
+ // MailboxAllowance resolves how many mailboxes the workspace may hold and
+ // why; every connect path checks it and GET /emails/allowance returns it.
+ MailboxAllowance(ctx context.Context, orgID uuid.UUID) (*models.MailboxAllowance, *errx.Error)
GetCampaignCounts(ctx context.Context, orgID uuid.UUID) (total int, active int, err *errx.Error)
GetOrganizationLimits(ctx context.Context, orgID uuid.UUID) (*models.OrganizationLimits, *errx.Error)
GetOrganizationCounts(ctx context.Context, orgID uuid.UUID) (*models.OrganizationCounts, *errx.Error)
@@ -907,25 +909,90 @@ func (s *organizationService) CanAddCampaign(ctx context.Context, orgID uuid.UUI
return true, nil
}
-// CanAddEmailAccount checks if the organization can add more email accounts based on plan limits
-func (s *organizationService) CanAddEmailAccount(ctx context.Context, orgID uuid.UUID) (bool, *errx.Error) {
- limits, err := s.GetEffectiveLimits(ctx, orgID)
+// MailboxAllowance resolves the workspace's mailbox allowance. Resolution:
+//
+// 1. no billing provider: unlimited
+// 2. an operator override: the override
+// 3. no paid subscription: FreeWorkspaceMailboxLimit
+// 4. the plan's explicit mailbox column, when it carries one
+// 5. the plan's daily sends divided by FairUseSendsPerMailbox
+// 6. a plan with no daily send cap: unlimited
+//
+// The count includes every connected mailbox, so a workspace that dropped to
+// a smaller plan simply cannot add until it is back under; nothing is removed.
+func (s *organizationService) MailboxAllowance(ctx context.Context, orgID uuid.UUID) (*models.MailboxAllowance, *errx.Error) {
+ count, err := s.orgRepo.GetEmailAccountCount(ctx, orgID)
if err != nil {
- return false, err
+ sentry.CaptureException(err)
+ return nil, errx.New(errx.Internal, "failed to get email account count")
+ }
+ a := &models.MailboxAllowance{Used: count, SendsPerMailbox: config.FairUseSendsPerMailbox}
+
+ if config.BillingProvider() == "none" {
+ a.Basis = models.MailboxAllowanceUnlimited
+ a.Paid = true
+ return a, nil
}
- // No limit set = unlimited
- if limits == nil || limits.MaxEmailAccounts == nil {
- return true, nil
+ sub, serr := s.subRepo.GetByOrganizationID(ctx, orgID)
+ if serr != nil {
+ sentry.CaptureException(serr)
+ return nil, errx.New(errx.Internal, "failed to get subscription")
+ }
+ a.Paid = sub != nil && sub.HasPaidSubscription()
+ if sub != nil && sub.Plan != nil {
+ if sub.Plan.Name != nil {
+ a.PlanName = *sub.Plan.Name
+ }
+ if sub.Plan.DailyCampaignLimit != nil && *sub.Plan.DailyCampaignLimit > 0 {
+ v := *sub.Plan.DailyCampaignLimit
+ a.PlanDailySends = &v
+ }
}
- count, xerr := s.orgRepo.GetEmailAccountCount(ctx, orgID)
+ override, xerr := s.GetLimitOverrides(ctx, orgID)
if xerr != nil {
- sentry.CaptureException(xerr)
- return false, errx.New(errx.Internal, "failed to get email account count")
+ return nil, xerr
}
- return count < *limits.MaxEmailAccounts, nil
+ set := func(v int, basis models.MailboxAllowanceBasis) {
+ a.Allowance = &v
+ rem := v - count
+ if rem < 0 {
+ rem = 0
+ }
+ a.Remaining = &rem
+ a.Basis = basis
+ }
+
+ switch {
+ case override != nil && override.MaxEmailAccounts > 0:
+ set(override.MaxEmailAccounts, models.MailboxAllowanceOverride)
+ case !a.Paid:
+ set(models.FreeWorkspaceMailboxLimit, models.MailboxAllowanceFree)
+ case sub.Plan != nil && sub.Plan.MaxEmailAccounts != nil && *sub.Plan.MaxEmailAccounts > 0:
+ set(*sub.Plan.MaxEmailAccounts, models.MailboxAllowancePlan)
+ case a.PlanDailySends != nil:
+ set((*a.PlanDailySends+config.FairUseSendsPerMailbox-1)/config.FairUseSendsPerMailbox, models.MailboxAllowanceFairUse)
+ default:
+ a.Basis = models.MailboxAllowanceUnlimited
+ }
+
+ // The open request, so the dashboard can show "asked for 5,000, pending"
+ // instead of offering a form that would be refused as a duplicate.
+ if a.Allowance != nil {
+ rows, rerr := s.orgRepo.ListLimitRequestsForOrg(ctx, orgID)
+ if rerr != nil {
+ sentry.CaptureException(rerr)
+ }
+ for i := range rows {
+ if rows[i].Field == "max_email_accounts" && rows[i].Status == models.LimitRequestStatusPending {
+ a.PendingRequest = &rows[i]
+ break
+ }
+ }
+ }
+ return a, nil
}
// GetCampaignCounts returns total and active campaign counts
@@ -1136,9 +1203,10 @@ func (s *organizationService) SetLimitOverrides(ctx context.Context, orgID uuid.
// 2. plan != nil → use plan column
// 3. otherwise → fall back to the product-level hard cap
//
-// Never returns nil values: even an "unlimited" plan is bounded by the
-// product hard caps in config/constants.go. Admins can raise individual
-// caps per-org by writing an override.
+// Every field but mailboxes is never nil: an "unlimited" plan is bounded by
+// the product hard caps in config/constants.go. Mailboxes follow
+// MailboxAllowance instead, where nil really means unlimited. Admins can
+// raise individual caps per-org by writing an override.
func (s *organizationService) GetEffectiveLimits(ctx context.Context, orgID uuid.UUID) (*models.OrganizationLimits, *errx.Error) {
plan, err := s.GetOrganizationLimits(ctx, orgID)
if err != nil {
@@ -1148,6 +1216,10 @@ func (s *organizationService) GetEffectiveLimits(ctx context.Context, orgID uuid
if err != nil {
return nil, err
}
+ mailboxes, err := s.MailboxAllowance(ctx, orgID)
+ if err != nil {
+ return nil, err
+ }
resolve := func(overrideVal int, planVal *int, hardCap int) *int {
if overrideVal > 0 {
@@ -1161,12 +1233,11 @@ func (s *organizationService) GetEffectiveLimits(ctx context.Context, orgID uuid
return &v
}
- var ovMaxCampaigns, ovMaxActive, ovMaxMembers, ovMaxEmails, ovMaxContacts, ovDaily int
+ var ovMaxCampaigns, ovMaxActive, ovMaxMembers, ovMaxContacts, ovDaily int
if override != nil {
ovMaxCampaigns = override.MaxCampaigns
ovMaxActive = override.MaxActiveCampaigns
ovMaxMembers = override.MaxTeamMembers
- ovMaxEmails = override.MaxEmailAccounts
ovMaxContacts = override.MaxContacts
ovDaily = override.DailyCampaignLimit
}
@@ -1180,7 +1251,7 @@ func (s *organizationService) GetEffectiveLimits(ctx context.Context, orgID uuid
MaxCampaigns: resolve(ovMaxCampaigns, planLimits.MaxCampaigns, config.HardCapCampaignsTotal),
MaxActiveCampaigns: resolve(ovMaxActive, planLimits.MaxActiveCampaigns, config.HardCapCampaignsActive),
MaxTeamMembers: resolve(ovMaxMembers, planLimits.MaxTeamMembers, config.HardCapTeamMembers),
- MaxEmailAccounts: resolve(ovMaxEmails, planLimits.MaxEmailAccounts, config.HardCapMailboxes),
+ MaxEmailAccounts: mailboxes.Allowance,
MaxContacts: resolve(ovMaxContacts, planLimits.MaxContacts, config.HardCapContacts),
DailyCampaignLimit: resolve(ovDaily, planLimits.DailyCampaignLimit, config.HardCapDailyCampaignSends),
}, nil
@@ -1199,10 +1270,12 @@ func (s *organizationService) WebhookDispatchLimit(ctx context.Context, orgID uu
if err != nil || eff == nil {
return limit
}
- if eff.MaxEmailAccounts != nil {
- if scaled := *eff.MaxEmailAccounts * config.WebhookDispatchPerMailboxPerMinute; scaled > limit {
- limit = scaled
- }
+ if eff.MaxEmailAccounts == nil {
+ // Unlimited mailboxes: the ceiling is the only bound left.
+ return config.WebhookDispatchMaxPerMinute
+ }
+ if scaled := *eff.MaxEmailAccounts * config.WebhookDispatchPerMailboxPerMinute; scaled > limit {
+ limit = scaled
}
if limit > config.WebhookDispatchMaxPerMinute {
limit = config.WebhookDispatchMaxPerMinute
@@ -1273,6 +1346,9 @@ func (s *organizationService) SubmitLimitIncreaseRequest(ctx context.Context, or
if xerr != nil {
return nil, xerr
}
+ if req.Field == "max_email_accounts" && effective.MaxEmailAccounts == nil {
+ return nil, errx.New(errx.BadRequest, "this workspace already holds unlimited mailboxes")
+ }
current := limitFieldEffective(req.Field, effective)
if req.Requested <= current {
return nil, errx.New(errx.BadRequest, "requested value must exceed current effective limit")
diff --git a/internal/config/constants.go b/internal/config/constants.go
index 6308e31a..3912bce3 100644
--- a/internal/config/constants.go
+++ b/internal/config/constants.go
@@ -238,10 +238,9 @@ const (
WarmupVerifyHeader = "X-Mailtrace-Verify"
// Product-level hard caps. These are the backstop for plans that
- // advertise "unlimited" — marketing can keep saying unlimited, but
- // the runtime never grants truly unbounded usage. Each cap is the
- // floor that GetEffectiveLimits falls back to when both the
- // per-org override and the plan column are unset.
+ // advertise "unlimited" on campaigns, seats, contacts and daily sends.
+ // Each cap is the floor that GetEffectiveLimits falls back to when both
+ // the per-org override and the plan column are unset.
//
// Admins can grant strictly larger caps per-org through the
// override flow when there is a legitimate business reason. Growth
@@ -250,15 +249,31 @@ const (
// acknowledging the new ceiling.
//
// These numbers are deliberately generous enough that ordinary use
- // never trips them, and conservative enough that "I want to spin up
- // 5,000 mailboxes overnight" can't happen without explicit approval.
- HardCapMailboxes = 200 // total connected mailboxes per org
+ // never trips them. Mailboxes are not in this list: see
+ // FairUseSendsPerMailbox below.
HardCapCampaignsTotal = 500 // total campaigns ever created
HardCapCampaignsActive = 50 // simultaneously active campaigns
HardCapTeamMembers = 100 // seats per org
HardCapContacts = 1_000_000 // contacts per org
HardCapDailyCampaignSends = 1000 // campaign emails per org per day
+ // Mailboxes have no hard cap. A paid workspace's allowance is fair use
+ // derived from the daily sends its plan includes: one mailbox for every
+ // FairUseSendsPerMailbox sends a day. At 1 a 15,000/day plan holds 15,000
+ // mailboxes, one per daily send, which is deliberately far more than safe
+ // sending ever needs: the allowance must never be the reason a customer
+ // runs a mailbox hotter. A plan with no daily send cap holds unlimited
+ // mailboxes, and an approved limit-increase request raises the allowance
+ // for one workspace.
+ FairUseSendsPerMailbox = 1
+
+ // Bulk connect: how many SMTP/IMAP rows one request may carry, and how
+ // many of them are validated against a worker at the same time. The
+ // dashboard streams a CSV through batches of this size so a 3,000 row
+ // file shows live progress instead of one request that times out.
+ MailboxBulkBatchMax = 50
+ MailboxBulkConcurrency = 8
+
// Daily creation throttles. The total caps above stop "you have
// 5000 campaigns on this org" — the throttles below stop "you
// created 1000 campaigns today on a fresh unlimited account."
@@ -270,7 +285,6 @@ const (
// because the per-day shape protects abuse posture rather than
// product utility.
DailyThrottleNewCampaigns = 20 // new campaigns per org per day
- DailyThrottleNewMailboxes = 5 // newly connected mailboxes per org per day
// Pool link: mailboxes a self-hosted instance may enroll in the hosted
// warmup pool without a paid pool plan, and the handshake lifetimes.
diff --git a/internal/errx/common.go b/internal/errx/common.go
index eef701fc..28bebd90 100644
--- a/internal/errx/common.go
+++ b/internal/errx/common.go
@@ -119,8 +119,6 @@ var (
ErrEmailOnboardUserInfo = New(BadRequest, "Could not read account details from the provider.")
ErrEmailOnboardAlreadyExists = New(Conflict, "This email account is already connected.")
ErrEmailOnboardNoWorker = New(ServiceUnavailable, "No mailbox workers are available right now. Please try again shortly.")
- ErrEmailOnboardInboxLimit = New(Forbidden, "A free workspace holds up to 10 mailboxes. Subscribe to add more.")
- ErrEmailOnboardTrialExpired = New(Forbidden, "A free workspace holds up to 10 mailboxes. Subscribe to add more.")
ErrEmailReauthProvider = New(BadRequest, "This mailbox connects with SMTP/IMAP credentials. Update its credentials instead of re-authorizing.")
ErrEmailReauthOAuthOnly = New(BadRequest, "This mailbox signs in with OAuth. Re-authorize it instead of entering credentials.")
ErrEmailReauthWrongAccount = New(Conflict, "The account you signed in with is not this mailbox's address. Sign in with the mailbox's own account and try again.")
@@ -193,3 +191,24 @@ var (
ErrAdvisorFixForbidden = New(Forbidden, "You can see this recommendation but don't have permission to apply the change it makes.")
ErrAdvisorNoAgentFix = New(BadRequest, "This recommendation needs a person: there's no change an agent can safely make for it.")
)
+
+// MailboxAllowanceReached is the refusal every connect path returns when the
+// workspace holds its whole allowance. The identifier is stable so the
+// dashboard can open the request-more flow instead of showing the text.
+func MailboxAllowanceReached(used, allowance int, paid bool) *Error {
+ if !paid {
+ return NewWithIdentifier(Forbidden, "mailbox_allowance_reached",
+ fmt.Sprintf("A free workspace holds up to %d mailboxes. Choose a plan to add more.", allowance))
+ }
+ return NewWithIdentifier(Forbidden, "mailbox_allowance_reached",
+ fmt.Sprintf("This workspace holds %d of its %d mailboxes. Request an increase, or move to a plan with more daily sends.", used, allowance))
+}
+
+// StorageLimitReached is the refusal for an attachment upload or copy that
+// would take the workspace past its storage quota.
+func StorageLimitReached(usedBytes, limitBytes, addingBytes int64) *Error {
+ const mb = 1024 * 1024
+ return NewWithIdentifier(BadRequest, "storage_limit_reached",
+ fmt.Sprintf("Storage limit reached: %d MB of %d MB used, %d MB to add. Remove attachments or upgrade your plan.",
+ usedBytes/mb, limitBytes/mb, addingBytes/mb))
+}
diff --git a/internal/errx/errx.go b/internal/errx/errx.go
index a3b9cda8..6c777c90 100644
--- a/internal/errx/errx.go
+++ b/internal/errx/errx.go
@@ -44,6 +44,10 @@ func (e *Error) identifier() string {
return codeToIdentifier[e.Code]
}
+// ResponseCode is the machine-readable `code` this error answers with, for
+// callers that embed errors in a body of their own (per-row results).
+func (e *Error) ResponseCode() string { return e.identifier() }
+
// --- Predefined errors (exported) ---
var (
ErrUnauthorized = New(Unauthorized, "Token not found.")
diff --git a/internal/models/email.go b/internal/models/email.go
index b0761c77..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.
@@ -339,3 +345,44 @@ type BulkEmailTags struct {
AddTags []string `json:"add_tags" binding:"max=100"`
RemoveTags []string `json:"remove_tags" binding:"max=100"`
}
+
+// MailboxBulkRowStatus is the per-row outcome of a bulk SMTP/IMAP connect.
+type MailboxBulkRowStatus string
+
+const (
+ // MailboxBulkConnected: the mailbox was validated and connected.
+ MailboxBulkConnected MailboxBulkRowStatus = "connected"
+ // MailboxBulkSkipped: the mailbox was already connected, so re-uploading
+ // a file is safe.
+ MailboxBulkSkipped MailboxBulkRowStatus = "skipped"
+ // MailboxBulkFailed: the row was refused; Code says why.
+ MailboxBulkFailed MailboxBulkRowStatus = "failed"
+)
+
+// MailboxBulkRow is one row's answer. Row echoes the caller's own row number
+// so the dashboard can hand back the failed lines of the file it uploaded.
+type MailboxBulkRow struct {
+ Row int `json:"row"`
+ Email string `json:"email"`
+ Status MailboxBulkRowStatus `json:"status"`
+ Code string `json:"code,omitempty"`
+ Message string `json:"message,omitempty"`
+ ID *uuid.UUID `json:"id,omitempty"`
+}
+
+// MailboxBulkSummary counts the batch.
+type MailboxBulkSummary struct {
+ Total int `json:"total"`
+ Connected int `json:"connected"`
+ Skipped int `json:"skipped"`
+ Failed int `json:"failed"`
+}
+
+// MailboxBulkResult is the answer to POST /emails/onboarding/smtp-imap/bulk.
+type MailboxBulkResult struct {
+ Data []MailboxBulkRow `json:"data"`
+ Summary MailboxBulkSummary `json:"summary"`
+ // Allowance is the workspace's mailbox allowance after the batch, so the
+ // dashboard can say how many more rows will fit without another call.
+ Allowance *MailboxAllowance `json:"allowance,omitempty"`
+}
diff --git a/internal/models/organization.go b/internal/models/organization.go
index e2948935..22da0b08 100644
--- a/internal/models/organization.go
+++ b/internal/models/organization.go
@@ -351,6 +351,56 @@ type UpdateOrgOverridesRequest struct {
Notes *string `json:"notes,omitempty"`
}
+// MailboxAllowanceBasis says where a workspace's mailbox allowance comes from,
+// so the dashboard can explain the number rather than only state it.
+type MailboxAllowanceBasis string
+
+const (
+ // MailboxAllowanceUnlimited: no billing provider, or a plan with no daily
+ // send cap. Allowance is nil.
+ MailboxAllowanceUnlimited MailboxAllowanceBasis = "unlimited"
+ // MailboxAllowanceFree: an unsubscribed workspace, FreeWorkspaceMailboxLimit.
+ MailboxAllowanceFree MailboxAllowanceBasis = "free"
+ // MailboxAllowanceOverride: an operator-approved limit-increase request.
+ MailboxAllowanceOverride MailboxAllowanceBasis = "override"
+ // MailboxAllowancePlan: the plan carries an explicit mailbox column.
+ MailboxAllowancePlan MailboxAllowanceBasis = "plan"
+ // MailboxAllowanceFairUse: the plan's daily sends divided by
+ // config.FairUseSendsPerMailbox.
+ MailboxAllowanceFairUse MailboxAllowanceBasis = "fair_use"
+)
+
+// MailboxAllowance is how many mailboxes a workspace may hold and why. It is
+// what every connect path checks and what GET /emails/allowance returns.
+type MailboxAllowance struct {
+ // Used is the number of mailboxes connected right now.
+ Used int `json:"used"`
+ // Allowance is the cap; nil means unlimited.
+ Allowance *int `json:"allowance"`
+ // Remaining is Allowance minus Used, never negative; nil when unlimited.
+ Remaining *int `json:"remaining"`
+ Basis MailboxAllowanceBasis `json:"basis"`
+ // SendsPerMailbox is the fair-use divisor, so the dashboard can say
+ // "one mailbox per daily send" with the real number.
+ SendsPerMailbox int `json:"sends_per_mailbox"`
+ // PlanDailySends is the plan's daily send cap when it has one.
+ PlanDailySends *int `json:"plan_daily_sends,omitempty"`
+ PlanName string `json:"plan_name,omitempty"`
+ // Paid is false for a free workspace, whose path to more mailboxes is a
+ // plan rather than a request.
+ Paid bool `json:"paid"`
+ // PendingRequest is the open limit-increase request for mailboxes, if any.
+ PendingRequest *LimitIncreaseRequest `json:"pending_request,omitempty"`
+}
+
+// CanAdd reports whether n more mailboxes fit.
+func (a *MailboxAllowance) CanAdd(n int) bool {
+ if a == nil || a.Allowance == nil {
+ return true
+ }
+ return a.Used+n <= *a.Allowance
+}
+
// LimitRequestStatus mirrors the postgres enum from migration 000046.
type LimitRequestStatus string
diff --git a/internal/repository/pg_attachment.go b/internal/repository/pg_attachment.go
index 79f43f28..750d9b3f 100644
--- a/internal/repository/pg_attachment.go
+++ b/internal/repository/pg_attachment.go
@@ -29,6 +29,38 @@ type AttachmentRepository interface {
// SumStorageUsedByOrg totals the bytes of every attachment owned by the org
// (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 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.
+func LockStorageQuota(ctx context.Context, tx pgx.Tx, orgID uuid.UUID) error {
+ _, err := tx.Exec(ctx, `SELECT pg_advisory_xact_lock(hashtext('campaign_attachments'), hashtext($1::text))`, orgID.String())
+ return err
+}
+
+// storageUsedTx is SumStorageUsedByOrg inside a transaction.
+func storageUsedTx(ctx context.Context, tx pgx.Tx, orgID uuid.UUID) (int64, error) {
+ var total int64
+ err := tx.QueryRow(ctx, `
+ SELECT COALESCE(SUM(ca.size), 0)
+ FROM campaign_attachments ca
+ JOIN campaigns c ON c.id = ca.campaign_id
+ WHERE c.organization_id = $1
+ `, orgID).Scan(&total)
+ return total, err
}
type attachmentRepository struct {
@@ -57,6 +89,38 @@ func (r *attachmentRepository) Create(ctx context.Context, att *models.CampaignA
), att)
}
+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, 0, err
+ }
+ defer func() { _ = tx.Rollback(ctx) }()
+
+ if err := LockStorageQuota(ctx, tx, orgID); err != nil {
+ 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, limit, err
+ }
+ 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)
+ VALUES ($1, $2, $3, $4, $5, $6, $7)
+ RETURNING `+attachmentCols,
+ att.CampaignID, att.SequenceID, att.UserID, att.Filename, att.Size, att.MimeType, att.S3Key,
+ ), att); err != nil {
+ return false, used, limit, err
+ }
+ return true, used + att.Size, limit, tx.Commit(ctx)
+}
+
func (r *attachmentRepository) GetByID(ctx context.Context, id uuid.UUID) (*models.CampaignAttachment, error) {
a := &models.CampaignAttachment{}
err := scanAttachment(r.DB.QueryRow(ctx, `SELECT `+attachmentCols+` FROM campaign_attachments WHERE id = $1`, id), a)
diff --git a/internal/repository/pg_campaign_lifecycle.go b/internal/repository/pg_campaign_lifecycle.go
index b59cb998..ac2d7ed3 100644
--- a/internal/repository/pg_campaign_lifecycle.go
+++ b/internal/repository/pg_campaign_lifecycle.go
@@ -3,6 +3,7 @@ package repository
import (
"context"
"encoding/json"
+ "errors"
"fmt"
"github.com/google/uuid"
@@ -21,8 +22,18 @@ type DuplicateCampaignInput struct {
UserID uuid.UUID
Name string
Attachments []models.CampaignAttachment
+ // 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
+// would take the organization past StorageLimitBytes. Nothing is written.
+var ErrStorageQuotaExceeded = errors.New("storage quota exceeded")
+
// Delete removes a campaign and everything that only means something inside
// it. The pending tasks parked for the campaign (its wakeup chain and any
// not-yet-dispatched sends) go in the same transaction: campaign_tasks only
@@ -162,6 +173,29 @@ func (r *campaignRepository) Duplicate(ctx context.Context, in DuplicateCampaign
return nil, err
}
+ 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")
+ return nil, err
+ }
+ var adding int64
+ for _, att := range in.Attachments {
+ adding += att.Size
+ }
+ if used+adding > limit {
+ return nil, fmt.Errorf("%w: %d of %d bytes used, %d to add", ErrStorageQuotaExceeded, used, limit, adding)
+ }
+ }
+
const insertAttachment = `
INSERT INTO campaign_attachments (campaign_id, sequence_id, user_id, filename, size, mime_type, s3_key)
VALUES ($1, $2, $3, $4, $5, $6, $7)
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/internal/seed/admin.go b/internal/seed/admin.go
index adebfac0..3bce84c7 100644
--- a/internal/seed/admin.go
+++ b/internal/seed/admin.go
@@ -19,7 +19,7 @@ func seedAdminAudit(ctx context.Context, pool *pgxpool.Pool, _ *Result) error {
{uuid.MustParse("00000000-0000-0000-0000-0000000000f1"), "user.banned", "user", UserViewerID, `{"reason":"seed example"}`},
{uuid.MustParse("00000000-0000-0000-0000-0000000000f2"), "user.unbanned", "user", UserViewerID, `{"reason":"seed example"}`},
{uuid.MustParse("00000000-0000-0000-0000-0000000000f3"), "worker.activated", "worker", WorkerFreeID, `{}`},
- {uuid.MustParse("00000000-0000-0000-0000-0000000000f4"), "plan.created", "plan", PlanEnterpriseID, `{"name":"Enterprise"}`},
+ {uuid.MustParse("00000000-0000-0000-0000-0000000000f4"), "plan.created", "plan", PlanEnterpriseID, `{"name":"Business"}`},
}
for _, e := range entries {
_, err := pool.Exec(ctx, `
diff --git a/internal/seed/plans.go b/internal/seed/plans.go
index e29ec7d3..4d2e18f5 100644
--- a/internal/seed/plans.go
+++ b/internal/seed/plans.go
@@ -65,38 +65,41 @@ func seedPlans(ctx context.Context, pool *pgxpool.Pool, r *Result) error {
maxTeamMembers: intPtr(1), maxEmailAccounts: intPtr(2),
monthlyCredits: 50,
},
+ // Paid plans mirror the pricing page: mailboxes are unlimited on every
+ // one (max_email_accounts nil, the fair-use allowance derives from the
+ // daily sends), and the daily sends are the marketed numbers.
{
- id: PlanStarterID, name: "Starter", maxContacts: 1_000, dailyEmails: 100,
- ai: false, accountLimit: 3, price: 29, discounted: 29,
+ id: PlanStarterID, name: "Starter", maxContacts: 1_000, dailyEmails: 150,
+ ai: false, accountLimit: 0, price: 29, discounted: 29,
duration: DurationMonthID, savings: 0, public: true,
- dedicatedWorkers: 0, dailyCampaignLimit: intPtr(100),
+ dedicatedWorkers: 0, dailyCampaignLimit: intPtr(150),
maxCampaigns: intPtr(5), maxActiveCampaigns: intPtr(2),
- maxTeamMembers: intPtr(2), maxEmailAccounts: intPtr(3),
+ maxTeamMembers: intPtr(2), maxEmailAccounts: nil,
monthlyCredits: 250,
},
{
- id: PlanProMonthlyID, name: "Pro", maxContacts: 25_000, dailyEmails: 1_000,
- ai: true, accountLimit: 20, price: 99, discounted: 99,
+ id: PlanProMonthlyID, name: "Grow", maxContacts: 25_000, dailyEmails: 3_000,
+ ai: true, accountLimit: 0, price: 89, discounted: 89,
duration: DurationMonthID, savings: 0, public: true,
- dedicatedWorkers: 1, dailyCampaignLimit: intPtr(1_000),
+ dedicatedWorkers: 0, dailyCampaignLimit: intPtr(3_000),
maxCampaigns: intPtr(50), maxActiveCampaigns: intPtr(20),
- maxTeamMembers: intPtr(10), maxEmailAccounts: intPtr(20),
+ maxTeamMembers: intPtr(10), maxEmailAccounts: nil,
monthlyCredits: 2_000,
},
{
- id: PlanProYearlyID, name: "Pro (Annual)", maxContacts: 25_000, dailyEmails: 1_000,
- ai: true, accountLimit: 20, price: 1188, discounted: 990,
- duration: DurationYearID, savings: 17, public: true,
- dedicatedWorkers: 1, dailyCampaignLimit: intPtr(1_000),
+ id: PlanProYearlyID, name: "Grow (Annual)", maxContacts: 25_000, dailyEmails: 3_000,
+ ai: true, accountLimit: 0, price: 1068, discounted: 852,
+ duration: DurationYearID, savings: 20, public: true,
+ dedicatedWorkers: 0, dailyCampaignLimit: intPtr(3_000),
maxCampaigns: intPtr(50), maxActiveCampaigns: intPtr(20),
- maxTeamMembers: intPtr(10), maxEmailAccounts: intPtr(20),
+ maxTeamMembers: intPtr(10), maxEmailAccounts: nil,
monthlyCredits: 2_000,
},
{
- id: PlanEnterpriseID, name: "Enterprise", maxContacts: 1_000_000, dailyEmails: 10_000,
- ai: true, accountLimit: 500, price: 0, discounted: 0,
- duration: DurationMonthID, savings: 0, public: false,
- dedicatedWorkers: 3, dailyCampaignLimit: intPtr(10_000),
+ id: PlanEnterpriseID, name: "Business", maxContacts: 1_000_000, dailyEmails: 15_000,
+ ai: true, accountLimit: 0, price: 329, discounted: 329,
+ duration: DurationMonthID, savings: 0, public: true,
+ dedicatedWorkers: 1, dailyCampaignLimit: intPtr(15_000),
maxCampaigns: nil, maxActiveCampaigns: nil,
maxTeamMembers: nil, maxEmailAccounts: nil,
monthlyCredits: 25_000,
diff --git a/site/src/pages/pricing.astro b/site/src/pages/pricing.astro
index b53fd1e0..b4dca6fd 100644
--- a/site/src/pages/pricing.astro
+++ b/site/src/pages/pricing.astro
@@ -419,6 +419,7 @@ const headers = ['Starter', 'Grow', 'Business', 'Enterprise'];
{[
['Do you charge per email sent?', 'No. Each plan includes a fixed daily send budget. Unused volume does not carry over.'],
['Is warmup metered separately?', 'No. Warmup is unlimited on every hosted plan and runs against the premium pool.'],
+ ['Is there really no mailbox limit?', 'Mailboxes are unlimited on every paid plan under fair use: one mailbox for every send a day your plan includes, so Grow holds 3,000 and Business holds 15,000. That is far more than safe sending ever needs. Need more? Ask from the connect dialog and we usually answer within a business day. Connect them one at a time or import thousands from a CSV.'],
['What does self-hosting cost?', 'Nothing. The platform is Apache 2.0 and free to run on your own servers, with unlimited mailboxes. The only paid part is the warmup pool link: free for 10 mailboxes, $15 a month for unlimited.'],
['Can a self-hosted instance use the warmup pool?', 'Yes, once Warmbly Cloud launches. Open Settings, Warmbly Cloud on your instance, approve the code here, and pick the mailboxes to enroll. Warmbly runs their warmup in the shared pool; sending, contacts and inbox stay on your server.'],
['Is there a per-seat charge?', 'No. Add as many teammates as you want. Roles and audit log unlock on Business.'],
diff --git a/web/src/app/app/settings/billing/OverviewTab.tsx b/web/src/app/app/settings/billing/OverviewTab.tsx
index 50b24b06..a2d1f132 100644
--- a/web/src/app/app/settings/billing/OverviewTab.tsx
+++ b/web/src/app/app/settings/billing/OverviewTab.tsx
@@ -2,9 +2,10 @@
// every control that acts on the subscription.
//
// Everything here reads real server state rather than the marketing catalog:
-// limits come from /subscription/limits and /organization/limits, capability
-// flags from /subscription/features, the trial countdown from
-// /subscription/trial. The catalog is only used for labels and colours.
+// limits, counts, the mailbox allowance and storage come from
+// /organization/current/limits, capability flags from /subscription/features,
+// the trial countdown from /subscription/trial. The catalog is only used for
+// labels and colours.
import React from "react";
import { Link } from "react-router-dom";
@@ -27,7 +28,6 @@ import useFeatureAccess from "@/hooks/useFeatureAccess";
import useUpgradeFlow from "@/hooks/useUpgradeFlow";
import { useConfirm } from "@/hooks/context/confirm";
import useSubscription from "@/lib/api/hooks/app/subscription/useSubscription";
-import useSubscriptionLimits from "@/lib/api/hooks/app/subscription/useSubscriptionLimits";
import useTrialStatus from "@/lib/api/hooks/app/subscription/useTrialStatus";
import useCancelSubscription from "@/lib/api/hooks/app/subscription/useCancelSubscription";
import useOrganizationLimits from "@/lib/api/hooks/app/organizations/useOrganizationLimits";
@@ -36,14 +36,18 @@ import type { AppError } from "@/lib/api/client/normalizeError";
import buildError from "@/lib/helper/buildError";
import { AnimatedNumber, DitherMeter, type DitherTone } from "@/components/ui/dither";
import { PLAN_ACCENT_CLASSES, getPlan } from "@/lib/plans";
+import type OrganizationLimits from "@/lib/api/models/app/organizations/OrganizationLimits";
import { Section } from "../_components/SectionShell";
export default function OverviewTab({ onChangePlan }: { onChangePlan: () => void }) {
const access = useFeatureAccess();
const sub = useSubscription();
const trial = useTrialStatus();
- const subLimits = useSubscriptionLimits();
const orgLimits = useOrganizationLimits();
+ const limits = orgLimits.data?.limits;
+ const counts = orgLimits.data?.counts;
+ const mailboxes = orgLimits.data?.mailboxes;
+ const storage = orgLimits.data?.storage;
const usage = useUsageOverview().data;
const cancel = useCancelSubscription();
const flow = useUpgradeFlow();
@@ -157,13 +161,13 @@ export default function OverviewTab({ onChangePlan }: { onChangePlan: () => void
Drop a CSV here, or click to choose one
++ One mailbox per row. Any provider that speaks SMTP and IMAP. +
+
+ email, smtp_host and{" "}
+ imap_host are required, plus a password. Everything else
+ has a default: ports 587 and 993, security from the port, the address as the login, the name from
+ the address. One password column covers both legs when
+ they share a login.
+
+ Connecting {total.toLocaleString()} {total === 1 ? "mailbox" : "mailboxes"} +
++ Each one is verified against its server, so this takes a few seconds per mailbox. You can keep + this open in the background; the list fills in live. +
++ {notSent > 0 + ? "Stopped early" + : failedRows.length === 0 + ? "All mailboxes connected" + : "Finished with some failures"} +
++ {connected.toLocaleString()} connected + {skipped > 0 ? `, ${skipped.toLocaleString()} already here` : ""} + {failedRows.length > 0 ? `, ${failedRows.length.toLocaleString()} did not connect` : ""} + {notSent > 0 ? `, ${notSent.toLocaleString()} not attempted` : ""} + {elapsed > 0 ? ` in ${durationText(elapsed)}` : ""}. +
+| Line | +Reason | +|
|---|---|---|
| {r.line} | ++ {r.raw.email || —} + | ++ {r.problem ?? r.message ?? r.code} + | +
+ This workspace holds {allowance.used.toLocaleString()} of {allowance.allowance.toLocaleString()} mailboxes, + so {remaining.toLocaleString()} more fit right now.{" "} + +
+ ); +} + +function RowsTable({ rows, more }: { rows: BulkRow[]; more: number }) { + return ( +| Line | +SMTP | +IMAP | +Status | +|
|---|---|---|---|---|
| {r.line} | +{r.raw.email} | ++ {r.account ? `${r.account.smtp.host}:${r.account.smtp.port}` : "—"} + | ++ {r.account ? `${r.account.imap.host}:${r.account.imap.port}` : "—"} + | ++ {r.status === "invalid" ? ( + + {r.problem} + + ) : ( + Ready + )} + | +
{title}
+{children}
+
+
A paid plan holds one mailbox for every send a day it includes
++ You asked for {pending.requested.toLocaleString()} mailboxes on{" "} + {new Date(pending.submitted_at).toLocaleDateString()} +
++ It is waiting for review. Once approved the new allowance applies here straight away, and + anything you could not connect in the meantime goes through on a retry. +
+ +