mirror of
https://github.com/warmbly/warmbly.git
synced 2026-09-13 08:05:05 +00:00
Merge pull request #330 from warmbly/feature/mailbox-fair-use-allowance
Unlimited mailboxes under fair use, bulk CSV connect, and atomic attachment quota (#326)
This commit is contained in:
+10
-4
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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.
|
||||
</Callout>
|
||||
|
||||
**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
|
||||
|
||||
|
||||
@@ -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.
|
||||
</Callout>
|
||||
|
||||
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
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -29,7 +29,6 @@ type Resource string
|
||||
|
||||
const (
|
||||
ResourceCampaign Resource = "campaign"
|
||||
ResourceMailbox Resource = "mailbox"
|
||||
ResourceOrg Resource = "org"
|
||||
ResourceScheduledSend Resource = "scheduled_send"
|
||||
)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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."},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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.
|
||||
|
||||
+21
-2
@@ -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))
|
||||
}
|
||||
|
||||
@@ -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.")
|
||||
|
||||
+57
-10
@@ -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"`
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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, `
|
||||
|
||||
+20
-17
@@ -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,
|
||||
|
||||
@@ -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.'],
|
||||
|
||||
@@ -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
|
||||
<Stat
|
||||
label="Daily sends"
|
||||
value={
|
||||
subLimits.data?.max_emails_per_day != null
|
||||
? subLimits.data.max_emails_per_day.toLocaleString()
|
||||
limits?.daily_campaign_limit != null
|
||||
? limits.daily_campaign_limit.toLocaleString()
|
||||
: plan.sendsPerDay === Number.POSITIVE_INFINITY
|
||||
? "Custom"
|
||||
: plan.sendsPerDay.toLocaleString()
|
||||
}
|
||||
sub="per mailbox pool"
|
||||
sub="across the workspace"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -255,33 +259,51 @@ export default function OverviewTab({ onChangePlan }: { onChangePlan: () => void
|
||||
</Link>
|
||||
}
|
||||
>
|
||||
{orgLimits.isPending && subLimits.isPending ? (
|
||||
{orgLimits.isPending ? (
|
||||
<div className="h-32 rounded bg-slate-100 animate-pulse" />
|
||||
) : (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-x-8 gap-y-4">
|
||||
<UsageMeter
|
||||
label="Mailboxes"
|
||||
hint="Connected sending and warmup mailboxes"
|
||||
current={usage?.email_accounts.total ?? orgLimits.data?.current_emails ?? 0}
|
||||
max={subLimits.data?.max_email_accounts}
|
||||
hint={mailboxHint(mailboxes)}
|
||||
current={mailboxes?.used ?? counts?.email_accounts ?? 0}
|
||||
max={mailboxes?.allowance}
|
||||
unmetered="unlimited"
|
||||
/>
|
||||
<UsageMeter
|
||||
label="Sends today"
|
||||
hint="Campaign emails sent today against the workspace's daily allowance"
|
||||
current={counts?.emails_sent_today ?? 0}
|
||||
max={limits?.daily_campaign_limit}
|
||||
/>
|
||||
<UsageMeter
|
||||
label="Contacts"
|
||||
hint="Recipient records stored in this workspace"
|
||||
current={orgLimits.data?.current_contacts ?? usage?.contacts.total ?? 0}
|
||||
max={orgLimits.data?.max_contacts ?? subLimits.data?.max_contacts}
|
||||
current={counts?.total_contacts ?? usage?.contacts.total ?? 0}
|
||||
max={limits?.max_contacts}
|
||||
/>
|
||||
<UsageMeter
|
||||
label="Campaigns"
|
||||
hint="Campaigns created in this workspace"
|
||||
current={orgLimits.data?.current_campaigns ?? usage?.campaigns.total ?? 0}
|
||||
max={orgLimits.data?.max_campaigns ?? subLimits.data?.max_campaigns}
|
||||
current={counts?.total_campaigns ?? usage?.campaigns.total ?? 0}
|
||||
max={limits?.max_campaigns}
|
||||
/>
|
||||
<UsageMeter
|
||||
label="Team members"
|
||||
hint="Seats used on this workspace"
|
||||
current={orgLimits.data?.current_members ?? 0}
|
||||
max={orgLimits.data?.max_members ?? subLimits.data?.max_team_members}
|
||||
current={counts?.total_members ?? 0}
|
||||
max={limits?.max_team_members}
|
||||
/>
|
||||
<UsageMeter
|
||||
label="Attachment storage"
|
||||
hint={
|
||||
storage?.over_quota
|
||||
? "Over the quota after a plan change: existing files keep sending, new uploads wait until you are back under"
|
||||
: "Files attached to campaign steps, across every campaign"
|
||||
}
|
||||
current={storage?.used_bytes ?? 0}
|
||||
max={storage?.limit_bytes}
|
||||
format={formatBytes}
|
||||
/>
|
||||
<UsageMeter
|
||||
label="Sends this period"
|
||||
@@ -373,16 +395,49 @@ function Stat({ label, value, sub }: { label: string; value: string; sub?: strin
|
||||
);
|
||||
}
|
||||
|
||||
// mailboxHint explains the number rather than only stating it, because the
|
||||
// allowance is fair use and the reader should know what raises it.
|
||||
function mailboxHint(a: OrganizationLimits["mailboxes"] | undefined): string {
|
||||
if (!a) return "Connected sending and warmup mailboxes";
|
||||
switch (a.basis) {
|
||||
case "fair_use":
|
||||
return a.sends_per_mailbox <= 1
|
||||
? "Fair use: one mailbox for every send a day your plan includes"
|
||||
: `Fair use: one mailbox for every ${a.sends_per_mailbox} sends a day your plan includes`;
|
||||
case "override":
|
||||
return "Raised for this workspace by an approved request";
|
||||
case "free":
|
||||
return "Free workspace allowance; a paid plan holds one mailbox for every send a day it includes";
|
||||
case "plan":
|
||||
return "Set by your plan";
|
||||
default:
|
||||
return "No cap on connected mailboxes";
|
||||
}
|
||||
}
|
||||
|
||||
function formatBytes(n: number): string {
|
||||
if (n >= 1 << 30) return `${(n / (1 << 30)).toFixed(n >= 10 * (1 << 30) ? 0 : 1)} GB`;
|
||||
if (n >= 1 << 20) return `${Math.round(n / (1 << 20))} MB`;
|
||||
if (n >= 1024) return `${Math.round(n / 1024)} KB`;
|
||||
return `${n} B`;
|
||||
}
|
||||
|
||||
function UsageMeter({
|
||||
label,
|
||||
hint,
|
||||
current,
|
||||
max,
|
||||
unmetered = "unmetered",
|
||||
format,
|
||||
}: {
|
||||
label: string;
|
||||
hint: string;
|
||||
current: number;
|
||||
max?: number | null;
|
||||
/** The word shown instead of a cap when there is none. */
|
||||
unmetered?: string;
|
||||
/** Renders both numbers; defaults to a plain count. */
|
||||
format?: (n: number) => string;
|
||||
}) {
|
||||
const capped = typeof max === "number" && max > 0 && Number.isFinite(max);
|
||||
const pct = capped ? Math.min(100, Math.round((current / (max as number)) * 100)) : 0;
|
||||
@@ -392,9 +447,9 @@ function UsageMeter({
|
||||
<div className="flex items-baseline justify-between gap-2 mb-1">
|
||||
<span className="text-[12px] text-slate-700 font-medium">{label}</span>
|
||||
<span className="text-[11.5px] font-mono tabular-nums text-slate-700">
|
||||
<AnimatedNumber value={current} />
|
||||
{format ? format(current) : <AnimatedNumber value={current} />}
|
||||
<span className="text-slate-400">
|
||||
{capped ? ` / ${(max as number).toLocaleString()}` : " / unmetered"}
|
||||
{capped ? ` / ${format ? format(max as number) : (max as number).toLocaleString()}` : ` / ${unmetered}`}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,578 @@
|
||||
// BulkConnectPanel — the CSV route into the connect-mailbox modal.
|
||||
//
|
||||
// Parses the file in the browser, shows what it found, then streams the rows
|
||||
// through POST /emails/onboarding/smtp-imap/bulk in small batches so a
|
||||
// 3,000 row file reports live progress instead of one request that times out.
|
||||
// Every row ends as connected, skipped (already here) or failed with a reason,
|
||||
// and the failed rows come back as a CSV with the columns the user uploaded
|
||||
// plus an error column, so they can fix and re-upload just those. Re-uploading
|
||||
// is safe: a mailbox that is already connected is skipped, never doubled.
|
||||
//
|
||||
// Rows past the workspace's allowance are refused by the server without
|
||||
// dialling anything; the preview says so before the run starts and offers the
|
||||
// request-more dialog.
|
||||
|
||||
import React from "react";
|
||||
import { motion } from "framer-motion";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import toast from "react-hot-toast";
|
||||
import {
|
||||
AlertTriangleIcon,
|
||||
CheckCircle2Icon,
|
||||
DownloadIcon,
|
||||
FileSpreadsheetIcon,
|
||||
Loader2Icon,
|
||||
RotateCcwIcon,
|
||||
UploadIcon,
|
||||
XIcon,
|
||||
} from "lucide-react";
|
||||
import { DitherMeter } from "@/components/ui/dither";
|
||||
import addEmailsBulk, { BULK_CONNECT_BATCH } from "@/lib/api/client/app/emails/addEmailsBulk";
|
||||
import useMailboxAllowance from "@/lib/api/hooks/app/emails/useMailboxAllowance";
|
||||
import type { BulkConnectRow } from "@/lib/api/models/app/emails/BulkConnect";
|
||||
import { downloadBlob } from "@/lib/api/client/app/contacts/exportContacts";
|
||||
import { downloadTemplate, failedRowsCSV, parseBulkFile, type BulkRow } from "./bulkConnectCsv";
|
||||
import type { AppError } from "@/lib/api/client/normalizeError";
|
||||
import buildError from "@/lib/helper/buildError";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
// ---- the panel ------------------------------------------------------------
|
||||
|
||||
type Step = "upload" | "preview" | "run" | "result";
|
||||
|
||||
export default function BulkConnectPanel({
|
||||
onDone,
|
||||
onAllowance,
|
||||
}: {
|
||||
onDone: () => void;
|
||||
/** Opens the mailbox allowance dialog. */
|
||||
onAllowance: () => void;
|
||||
}) {
|
||||
const qc = useQueryClient();
|
||||
const allowance = useMailboxAllowance();
|
||||
const [step, setStep] = React.useState<Step>("upload");
|
||||
const [filename, setFilename] = React.useState("");
|
||||
const [columns, setColumns] = React.useState<string[]>([]);
|
||||
const [rows, setRows] = React.useState<BulkRow[]>([]);
|
||||
const [parsing, setParsing] = React.useState(false);
|
||||
const [dragging, setDragging] = React.useState(false);
|
||||
// The run: which rows are still to go, and the ask to stop after the
|
||||
// batch in flight. A ref because the loop reads it between awaits.
|
||||
const cancelRef = React.useRef(false);
|
||||
const [cancelling, setCancelling] = React.useState(false);
|
||||
const [startedAt, setStartedAt] = React.useState<number>(0);
|
||||
const fileInput = React.useRef<HTMLInputElement>(null);
|
||||
|
||||
const ready = rows.filter((r) => r.status === "ready" || r.status === "pending").length;
|
||||
const invalid = rows.filter((r) => r.status === "invalid").length;
|
||||
const remaining = allowance.data?.remaining ?? null;
|
||||
const full = remaining !== null && remaining <= 0;
|
||||
const overBy = remaining !== null && ready > remaining ? ready - remaining : 0;
|
||||
|
||||
async function onFile(file: File) {
|
||||
setParsing(true);
|
||||
try {
|
||||
const parsed = await parseBulkFile(file);
|
||||
setFilename(file.name);
|
||||
setColumns(parsed.columns);
|
||||
setRows(parsed.rows);
|
||||
setStep("preview");
|
||||
} catch (err) {
|
||||
toast.error((err as Error).message);
|
||||
} finally {
|
||||
setParsing(false);
|
||||
}
|
||||
}
|
||||
|
||||
// Sends every "ready" row in batches, updating the table as answers come
|
||||
// back. The list behind the modal refreshes after each batch so
|
||||
// teammates watching it see the mailboxes arrive.
|
||||
async function run() {
|
||||
if (ready === 0) return;
|
||||
cancelRef.current = false;
|
||||
setCancelling(false);
|
||||
setStartedAt(Date.now());
|
||||
setStep("run");
|
||||
setRows((prev) => prev.map((r) => (r.status === "ready" ? { ...r, status: "pending" } : r)));
|
||||
|
||||
const queue = rows.map((r, i) => ({ r, i })).filter(({ r }) => r.status === "ready");
|
||||
for (let at = 0; at < queue.length; at += BULK_CONNECT_BATCH) {
|
||||
if (cancelRef.current) break;
|
||||
const batch = queue.slice(at, at + BULK_CONNECT_BATCH);
|
||||
let answers: BulkConnectRow[];
|
||||
try {
|
||||
const res = await addEmailsBulk(batch.map(({ r }) => r.account!));
|
||||
answers = res.data;
|
||||
} catch (err) {
|
||||
const msg = buildError(err as AppError);
|
||||
answers = batch.map((_, j) => ({ row: j, email: batch[j].r.account!.email, status: "failed", code: "request_failed", message: msg }));
|
||||
}
|
||||
setRows((prev) => {
|
||||
const next = [...prev];
|
||||
for (const a of answers) {
|
||||
const target = batch[a.row];
|
||||
if (!target) continue;
|
||||
next[target.i] = { ...next[target.i], status: a.status, code: a.code, message: a.message };
|
||||
}
|
||||
return next;
|
||||
});
|
||||
qc.invalidateQueries({ queryKey: ["emails"] });
|
||||
qc.invalidateQueries({ queryKey: ["analytics", "accounts"] });
|
||||
}
|
||||
// Anything still pending was never sent because of a cancel.
|
||||
setRows((prev) => prev.map((r) => (r.status === "pending" ? { ...r, status: "ready" } : r)));
|
||||
setStep("result");
|
||||
}
|
||||
|
||||
function retryFailed() {
|
||||
setRows((prev) =>
|
||||
prev.map((r) => (r.status === "failed" && r.account ? { ...r, status: "ready", code: undefined, message: undefined } : r)),
|
||||
);
|
||||
setStep("preview");
|
||||
}
|
||||
|
||||
function reset() {
|
||||
setRows([]);
|
||||
setColumns([]);
|
||||
setFilename("");
|
||||
setStep("upload");
|
||||
}
|
||||
|
||||
if (step === "upload") {
|
||||
return (
|
||||
<div className="p-4 space-y-3">
|
||||
<div
|
||||
onDragOver={(e) => {
|
||||
e.preventDefault();
|
||||
setDragging(true);
|
||||
}}
|
||||
onDragLeave={() => setDragging(false)}
|
||||
onDrop={(e) => {
|
||||
e.preventDefault();
|
||||
setDragging(false);
|
||||
const f = e.dataTransfer.files?.[0];
|
||||
if (f) void onFile(f);
|
||||
}}
|
||||
onClick={() => fileInput.current?.click()}
|
||||
className={cn(
|
||||
"rounded-md border border-dashed p-6 flex flex-col items-center justify-center text-center cursor-pointer transition-colors",
|
||||
dragging ? "border-sky-400 bg-sky-50" : "border-slate-300 hover:border-slate-400 hover:bg-slate-50/60",
|
||||
)}
|
||||
>
|
||||
<input
|
||||
ref={fileInput}
|
||||
type="file"
|
||||
accept=".csv,text/csv"
|
||||
className="hidden"
|
||||
onChange={(e) => {
|
||||
const f = e.target.files?.[0];
|
||||
if (f) void onFile(f);
|
||||
e.target.value = "";
|
||||
}}
|
||||
/>
|
||||
{parsing ? (
|
||||
<Loader2Icon className="w-5 h-5 text-slate-400 animate-spin" />
|
||||
) : (
|
||||
<UploadIcon className="w-5 h-5 text-slate-400" />
|
||||
)}
|
||||
<p className="text-[13px] font-medium text-slate-900 mt-2">Drop a CSV here, or click to choose one</p>
|
||||
<p className="text-[11.5px] text-slate-500 mt-0.5">
|
||||
One mailbox per row. Any provider that speaks SMTP and IMAP.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="rounded-md border border-slate-200 bg-slate-50/40 p-3">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="text-[10px] uppercase tracking-[0.14em] text-slate-500 font-medium">Columns</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={downloadTemplate}
|
||||
className="h-6 px-2 rounded text-[11px] text-slate-700 hover:text-slate-900 hover:bg-slate-100 inline-flex items-center gap-1 transition-colors"
|
||||
>
|
||||
<DownloadIcon className="w-3 h-3" />
|
||||
Download template
|
||||
</button>
|
||||
</div>
|
||||
<p className="text-[11.5px] text-slate-600 leading-relaxed mt-1.5">
|
||||
<code className="font-mono text-[11px]">email</code>, <code className="font-mono text-[11px]">smtp_host</code> and{" "}
|
||||
<code className="font-mono text-[11px]">imap_host</code> 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 <code className="font-mono text-[11px]">password</code> column covers both legs when
|
||||
they share a login.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<AllowanceNote allowance={allowance.data} onAllowance={onAllowance} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (step === "preview") {
|
||||
const sample = rows.slice(0, 6);
|
||||
return (
|
||||
<div className="flex flex-col min-h-0">
|
||||
<div className="p-4 space-y-3">
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<FileSpreadsheetIcon className="w-4 h-4 text-slate-500 shrink-0" />
|
||||
<span className="text-[12.5px] text-slate-900 font-medium truncate">{filename}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={reset}
|
||||
className="ml-auto h-6 px-2 rounded text-[11px] text-slate-500 hover:text-slate-900 hover:bg-slate-100 inline-flex items-center gap-1 transition-colors shrink-0"
|
||||
>
|
||||
<XIcon className="w-3 h-3" />
|
||||
Choose another file
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
<StatCard label="Rows" value={rows.length} accent="slate" />
|
||||
<StatCard label="Ready" value={ready} accent="emerald" />
|
||||
<StatCard label="Invalid" value={invalid} accent={invalid > 0 ? "red" : "slate"} />
|
||||
</div>
|
||||
|
||||
{full ? (
|
||||
<Banner tone="red" title="Your mailbox allowance is full">
|
||||
Nothing in this file can be connected until the allowance is raised.{" "}
|
||||
<button type="button" onClick={onAllowance} className="underline font-medium">
|
||||
Request more
|
||||
</button>{" "}
|
||||
and come back to this file; it can be re-uploaded as it is.
|
||||
</Banner>
|
||||
) : overBy > 0 ? (
|
||||
<Banner tone="amber" title={`Room for ${remaining!.toLocaleString()} more mailboxes, ${ready.toLocaleString()} in this file`}>
|
||||
The first {remaining!.toLocaleString()} rows will be connected and the remaining{" "}
|
||||
{overBy.toLocaleString()} listed as failed so you can{" "}
|
||||
<button type="button" onClick={onAllowance} className="underline font-medium">
|
||||
request more
|
||||
</button>{" "}
|
||||
and re-upload only those.
|
||||
</Banner>
|
||||
) : null}
|
||||
|
||||
{invalid > 0 && (
|
||||
<Banner tone="amber" title={`${invalid.toLocaleString()} rows cannot be sent`}>
|
||||
They are missing something the connect needs. They are left out of the run and included in
|
||||
the failed rows download at the end, with the reason.
|
||||
</Banner>
|
||||
)}
|
||||
|
||||
<RowsTable rows={sample} more={rows.length - sample.length} />
|
||||
</div>
|
||||
|
||||
<div className="px-4 py-2.5 border-t border-slate-200 bg-slate-50/60 flex items-center gap-2 min-w-0 sticky bottom-0">
|
||||
<div className="text-[11px] text-slate-500 min-w-0 flex-1 truncate">
|
||||
Every credential is verified against its server before it is saved.
|
||||
</div>
|
||||
<motion.button
|
||||
type="button"
|
||||
onClick={() => void run()}
|
||||
disabled={ready === 0 || full}
|
||||
whileTap={ready > 0 && !full ? { scale: 0.97 } : undefined}
|
||||
className={cn(
|
||||
"shrink-0 h-7 px-3 rounded-md text-[12px] font-medium inline-flex items-center gap-1.5 transition-colors",
|
||||
"bg-slate-900 hover:bg-slate-800 text-white disabled:opacity-50 disabled:cursor-not-allowed",
|
||||
)}
|
||||
>
|
||||
<UploadIcon className="w-3 h-3" />
|
||||
Connect {ready.toLocaleString()} {ready === 1 ? "mailbox" : "mailboxes"}
|
||||
</motion.button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const connected = rows.filter((r) => r.status === "connected").length;
|
||||
const skipped = rows.filter((r) => r.status === "skipped").length;
|
||||
const failed = rows.filter((r) => r.status === "failed").length;
|
||||
const pending = rows.filter((r) => r.status === "pending").length;
|
||||
// `ready` already includes pending rows, so count the untouched ones alone.
|
||||
const notStarted = rows.filter((r) => r.status === "ready").length;
|
||||
const total = notStarted + connected + skipped + failed + pending;
|
||||
|
||||
if (step === "run") {
|
||||
const done = connected + skipped + failed;
|
||||
const frac = total > 0 ? done / total : 0;
|
||||
return (
|
||||
<div className="p-4 space-y-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<Loader2Icon className="w-5 h-5 text-sky-600 animate-spin shrink-0" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-[13.5px] text-slate-900 font-semibold">
|
||||
Connecting {total.toLocaleString()} {total === 1 ? "mailbox" : "mailboxes"}
|
||||
</p>
|
||||
<p className="text-[11.5px] text-slate-500 leading-snug mt-0.5">
|
||||
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.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="flex items-baseline justify-between gap-2 mb-1">
|
||||
<span className="text-[12px] text-slate-700 font-medium">Progress</span>
|
||||
<span className="text-[11.5px] font-mono tabular-nums text-slate-700">
|
||||
{done.toLocaleString()}
|
||||
<span className="text-slate-400"> / {total.toLocaleString()}</span>
|
||||
</span>
|
||||
</div>
|
||||
<DitherMeter frac={frac} tone="sky" height={6} />
|
||||
</div>
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
<StatCard label="Connected" value={connected} accent="emerald" />
|
||||
<StatCard label="Skipped" value={skipped} accent="slate" />
|
||||
<StatCard label="Failed" value={failed} accent={failed > 0 ? "red" : "slate"} />
|
||||
</div>
|
||||
<div className="flex items-center justify-end">
|
||||
<button
|
||||
type="button"
|
||||
disabled={cancelling}
|
||||
onClick={() => {
|
||||
cancelRef.current = true;
|
||||
setCancelling(true);
|
||||
}}
|
||||
className="h-7 px-3 rounded-md border border-slate-200 text-[12px] text-slate-700 hover:bg-slate-50 inline-flex items-center gap-1.5 transition-colors disabled:opacity-50"
|
||||
>
|
||||
<XIcon className="w-3 h-3" />
|
||||
{cancelling ? "Stopping after this batch…" : "Stop"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// result
|
||||
const failedRows = rows.filter((r) => r.status === "failed" || r.status === "invalid");
|
||||
const notSent = rows.filter((r) => r.status === "ready").length;
|
||||
const canRetry = rows.some((r) => r.status === "failed" && r.account);
|
||||
const elapsed = startedAt ? Date.now() - startedAt : 0;
|
||||
|
||||
function downloadFailed() {
|
||||
if (failedRows.length === 0) return;
|
||||
const csv = failedRowsCSV(failedRows, columns);
|
||||
downloadBlob(
|
||||
new Blob(["" + csv], { type: "text/csv;charset=utf-8" }),
|
||||
filename.replace(/\.[^.]+$/, "") + "-failed.csv",
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col min-h-0">
|
||||
<div className="p-4 space-y-3">
|
||||
<div className="flex items-center gap-3">
|
||||
{failedRows.length === 0 && notSent === 0 ? (
|
||||
<CheckCircle2Icon className="w-7 h-7 text-emerald-600 shrink-0" />
|
||||
) : (
|
||||
<AlertTriangleIcon className="w-7 h-7 text-amber-600 shrink-0" />
|
||||
)}
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-[13.5px] text-slate-900 font-semibold">
|
||||
{notSent > 0
|
||||
? "Stopped early"
|
||||
: failedRows.length === 0
|
||||
? "All mailboxes connected"
|
||||
: "Finished with some failures"}
|
||||
</p>
|
||||
<p className="text-[11.5px] text-slate-500 leading-snug mt-0.5">
|
||||
{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)}` : ""}.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
<StatCard label="Connected" value={connected} accent="emerald" />
|
||||
<StatCard label="Skipped" value={skipped} accent="slate" />
|
||||
<StatCard label="Failed" value={failedRows.length} accent={failedRows.length > 0 ? "red" : "slate"} />
|
||||
</div>
|
||||
|
||||
{failedRows.some((r) => r.code === "mailbox_allowance_reached") && (
|
||||
<Banner tone="amber" title="Some rows were past your mailbox allowance">
|
||||
They were not tried.{" "}
|
||||
<button type="button" onClick={onAllowance} className="underline font-medium">
|
||||
Request more
|
||||
</button>
|
||||
, then re-upload the failed rows download; everything already connected is skipped.
|
||||
</Banner>
|
||||
)}
|
||||
|
||||
{failedRows.length > 0 && (
|
||||
<div className="rounded-md border border-slate-200 overflow-hidden">
|
||||
<div className="px-3 h-9 border-b border-slate-200 bg-slate-50/60 flex items-center gap-2">
|
||||
<span className="text-[11px] uppercase tracking-[0.14em] text-slate-500 font-medium">
|
||||
Did not connect
|
||||
</span>
|
||||
<span className="text-[11px] text-slate-500">{failedRows.length.toLocaleString()}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={downloadFailed}
|
||||
title="The rows as you uploaded them, minus passwords, plus an error column"
|
||||
className="ml-auto h-6 px-2 rounded text-[11px] text-slate-700 hover:text-slate-900 hover:bg-slate-100 inline-flex items-center gap-1 transition-colors"
|
||||
>
|
||||
<DownloadIcon className="w-3 h-3" />
|
||||
Download failed rows
|
||||
</button>
|
||||
</div>
|
||||
<div className="max-h-52 overflow-y-auto">
|
||||
<table className="w-full text-left">
|
||||
<thead className="bg-white sticky top-0">
|
||||
<tr className="border-b border-slate-100">
|
||||
<th className="px-3 py-1.5 text-[10px] font-medium text-slate-400 uppercase tracking-[0.14em] w-12">Line</th>
|
||||
<th className="px-3 py-1.5 text-[10px] font-medium text-slate-400 uppercase tracking-[0.14em]">Email</th>
|
||||
<th className="px-3 py-1.5 text-[10px] font-medium text-slate-400 uppercase tracking-[0.14em]">Reason</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{failedRows.slice(0, 200).map((r) => (
|
||||
<tr key={r.line} className="border-b border-slate-100 last:border-b-0">
|
||||
<td className="px-3 py-1.5 text-[11px] text-slate-500 font-mono">{r.line}</td>
|
||||
<td className="px-3 py-1.5 text-[11.5px] text-slate-700 truncate max-w-[200px]">
|
||||
{r.raw.email || <span className="text-slate-300">—</span>}
|
||||
</td>
|
||||
<td className="px-3 py-1.5 text-[11.5px] text-slate-700 leading-snug">
|
||||
{r.problem ?? r.message ?? r.code}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="px-4 py-2.5 border-t border-slate-200 bg-slate-50/60 flex items-center gap-2 min-w-0 sticky bottom-0">
|
||||
<div className="text-[11px] text-slate-500 min-w-0 flex-1 truncate">
|
||||
{failedRows.length > 0
|
||||
? "Retry keeps the passwords in memory. The download leaves them out; add them back before re-uploading."
|
||||
: "Warmup and sync start on every new mailbox right away."}
|
||||
</div>
|
||||
{(canRetry || notSent > 0) && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={retryFailed}
|
||||
className="shrink-0 h-7 px-3 rounded-md border border-slate-200 text-[12px] text-slate-700 hover:bg-slate-50 inline-flex items-center gap-1.5 transition-colors"
|
||||
>
|
||||
<RotateCcwIcon className="w-3 h-3" />
|
||||
{notSent > 0 ? "Continue" : "Retry failed"}
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={onDone}
|
||||
className="shrink-0 h-7 px-3 rounded-md bg-slate-900 hover:bg-slate-800 text-white text-[12px] font-medium inline-flex items-center gap-1.5 transition-colors"
|
||||
>
|
||||
<CheckCircle2Icon className="w-3 h-3" />
|
||||
Done
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---- pieces ---------------------------------------------------------------
|
||||
|
||||
function AllowanceNote({
|
||||
allowance,
|
||||
onAllowance,
|
||||
}: {
|
||||
allowance: ReturnType<typeof useMailboxAllowance>["data"];
|
||||
onAllowance: () => void;
|
||||
}) {
|
||||
if (!allowance || allowance.allowance == null) return null;
|
||||
const remaining = allowance.remaining ?? 0;
|
||||
return (
|
||||
<p className="text-[11.5px] text-slate-500 leading-relaxed">
|
||||
This workspace holds {allowance.used.toLocaleString()} of {allowance.allowance.toLocaleString()} mailboxes,
|
||||
so {remaining.toLocaleString()} more fit right now.{" "}
|
||||
<button type="button" onClick={onAllowance} className="underline text-slate-700 hover:text-slate-900">
|
||||
{remaining === 0 ? "Request more before uploading" : "Need more?"}
|
||||
</button>
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
function RowsTable({ rows, more }: { rows: BulkRow[]; more: number }) {
|
||||
return (
|
||||
<div className="rounded-md border border-slate-200 overflow-hidden">
|
||||
<table className="w-full text-left">
|
||||
<thead className="bg-slate-50/60">
|
||||
<tr className="border-b border-slate-200">
|
||||
<th className="px-3 py-1.5 text-[10px] font-medium text-slate-400 uppercase tracking-[0.14em] w-12">Line</th>
|
||||
<th className="px-3 py-1.5 text-[10px] font-medium text-slate-400 uppercase tracking-[0.14em]">Email</th>
|
||||
<th className="hidden md:table-cell px-3 py-1.5 text-[10px] font-medium text-slate-400 uppercase tracking-[0.14em]">SMTP</th>
|
||||
<th className="hidden md:table-cell px-3 py-1.5 text-[10px] font-medium text-slate-400 uppercase tracking-[0.14em]">IMAP</th>
|
||||
<th className="px-3 py-1.5 text-[10px] font-medium text-slate-400 uppercase tracking-[0.14em]">Status</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.map((r) => (
|
||||
<tr key={r.line} className="border-b border-slate-100 last:border-b-0">
|
||||
<td className="px-3 py-1.5 text-[11px] text-slate-500 font-mono">{r.line}</td>
|
||||
<td className="px-3 py-1.5 text-[11.5px] text-slate-800 truncate max-w-[180px]">{r.raw.email}</td>
|
||||
<td className="hidden md:table-cell px-3 py-1.5 text-[11.5px] text-slate-600 truncate max-w-[160px]">
|
||||
{r.account ? `${r.account.smtp.host}:${r.account.smtp.port}` : "—"}
|
||||
</td>
|
||||
<td className="hidden md:table-cell px-3 py-1.5 text-[11.5px] text-slate-600 truncate max-w-[160px]">
|
||||
{r.account ? `${r.account.imap.host}:${r.account.imap.port}` : "—"}
|
||||
</td>
|
||||
<td className="px-3 py-1.5 text-[11.5px]">
|
||||
{r.status === "invalid" ? (
|
||||
<span className="text-red-600" title={r.problem}>
|
||||
{r.problem}
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-emerald-600">Ready</span>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
{more > 0 && (
|
||||
<div className="px-3 py-1.5 text-[11px] text-slate-400 border-t border-slate-100 bg-slate-50/40">
|
||||
and {more.toLocaleString()} more
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Banner({ tone, title, children }: { tone: "amber" | "red"; title: string; children: React.ReactNode }) {
|
||||
const cls =
|
||||
tone === "red"
|
||||
? "border-red-200 bg-red-50 text-red-900 [&_p]:text-red-800/90"
|
||||
: "border-amber-200 bg-amber-50 text-amber-900 [&_p]:text-amber-800/90";
|
||||
return (
|
||||
<div className={cn("rounded-md border px-3 py-2.5 flex items-start gap-2", cls)}>
|
||||
<AlertTriangleIcon className="w-3.5 h-3.5 mt-px shrink-0 opacity-80" />
|
||||
<div className="min-w-0">
|
||||
<p className="text-[12.5px] font-medium !text-current">{title}</p>
|
||||
<p className="text-[11.5px] leading-relaxed mt-0.5">{children}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function StatCard({ label, value, accent }: { label: string; value: number; accent: "emerald" | "slate" | "red" }) {
|
||||
const ring = {
|
||||
emerald: "ring-emerald-200 bg-emerald-50 text-emerald-700",
|
||||
slate: "ring-slate-200 bg-slate-50 text-slate-700",
|
||||
red: "ring-red-200 bg-red-50 text-red-700",
|
||||
}[accent];
|
||||
return (
|
||||
<div className={`rounded-md ring-1 p-2.5 ${ring}`}>
|
||||
<div className="text-[10px] uppercase tracking-[0.14em] font-medium opacity-75">{label}</div>
|
||||
<div className="text-[18px] font-semibold tabular-nums mt-0.5">{value.toLocaleString()}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function durationText(ms: number): string {
|
||||
if (ms < 1000) return `${ms} ms`;
|
||||
const sec = ms / 1000;
|
||||
if (sec < 60) return `${sec.toFixed(0)} s`;
|
||||
return `${(sec / 60).toFixed(1)} min`;
|
||||
}
|
||||
@@ -0,0 +1,489 @@
|
||||
// MailboxAllowanceDialog — what opens when a workspace runs into its mailbox
|
||||
// allowance, and what "Request more" links to before it does.
|
||||
//
|
||||
// Every paid plan holds unlimited mailboxes under a fair-use allowance: one
|
||||
// mailbox for every N daily sends the plan includes (N is 1 today). This dialog explains that
|
||||
// number, offers the plan that raises it, and takes a limit-increase request
|
||||
// inline so nobody has to leave the connect flow to ask. A free workspace is
|
||||
// pointed at a plan instead, because that is its only path to more.
|
||||
//
|
||||
// Layered above the connect modal: it marks itself data-floating so the modal
|
||||
// underneath ignores Escape while it is up, and stops both mousedown and
|
||||
// click. The portal keeps it in the modal's React tree, so without that a
|
||||
// click anywhere in here would reach the modal's backdrop handler and close
|
||||
// the whole connect flow.
|
||||
|
||||
import React from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { Link } from "react-router-dom";
|
||||
import { AnimatePresence, motion } from "framer-motion";
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import toast from "react-hot-toast";
|
||||
import {
|
||||
ArrowRightIcon,
|
||||
ArrowUpRightIcon,
|
||||
CheckIcon,
|
||||
ClockIcon,
|
||||
Loader2Icon,
|
||||
MailboxIcon,
|
||||
SparklesIcon,
|
||||
XIcon,
|
||||
} from "lucide-react";
|
||||
import { useUpgradeDialog } from "@/hooks/context/upgrade";
|
||||
import { useConfirm } from "@/hooks/context/confirm";
|
||||
import { useCurrentOrg } from "@/stores/useAppStore";
|
||||
import { DitherMeter, type DitherTone } from "@/components/ui/dither";
|
||||
import { NumberInput } from "@/components/ui/field";
|
||||
import submitLimitRequest from "@/lib/api/client/app/organizations/submitLimitRequest";
|
||||
import cancelLimitRequest from "@/lib/api/client/app/organizations/cancelLimitRequest";
|
||||
import { MAILBOX_ALLOWANCE_KEY } from "@/lib/api/hooks/app/emails/useMailboxAllowance";
|
||||
import type MailboxAllowance from "@/lib/api/models/app/emails/MailboxAllowance";
|
||||
import type { AppError } from "@/lib/api/client/normalizeError";
|
||||
import buildError from "@/lib/helper/buildError";
|
||||
import { PAID_PLANS, PLAN_CATALOG, getPlan, planOrder, type PlanID } from "@/lib/plans";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const EASE = [0.22, 1, 0.36, 1] as const;
|
||||
|
||||
/** Mailboxes a catalogue plan holds under fair use; null for unlimited. */
|
||||
function planMailboxes(id: PlanID, sendsPerMailbox: number): number | null {
|
||||
const sends = PLAN_CATALOG[id].sendsPerDay;
|
||||
if (!Number.isFinite(sends) || sends <= 0) return null;
|
||||
return Math.ceil(sends / Math.max(1, sendsPerMailbox));
|
||||
}
|
||||
|
||||
/** The cheapest paid plan above the current one that holds more mailboxes. */
|
||||
function nextPlanWithMore(a: MailboxAllowance, current: PlanID): PlanID | null {
|
||||
const have = a.allowance ?? Number.POSITIVE_INFINITY;
|
||||
for (const id of PAID_PLANS) {
|
||||
if (planOrder(id) <= planOrder(current)) continue;
|
||||
const n = planMailboxes(id, a.sends_per_mailbox);
|
||||
if (n == null || n > have) return id;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** "one mailbox per daily send", or "one mailbox for every N daily sends". */
|
||||
function perSend(n: number): string {
|
||||
return n <= 1 ? "one mailbox for every send a day" : `one mailbox for every ${n} sends a day`;
|
||||
}
|
||||
|
||||
/** A sensible starting number for a request: half again, on a round figure. */
|
||||
function suggestedRequest(allowance: number): number {
|
||||
const raw = Math.ceil(allowance * 1.5);
|
||||
const step = raw >= 1000 ? 500 : raw >= 100 ? 50 : 10;
|
||||
return Math.ceil(raw / step) * step;
|
||||
}
|
||||
|
||||
export default function MailboxAllowanceDialog({
|
||||
open,
|
||||
onClose,
|
||||
allowance,
|
||||
reached,
|
||||
currentPlan,
|
||||
}: {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
allowance: MailboxAllowance | undefined;
|
||||
/** True when a connect was just refused, which changes the title. */
|
||||
reached?: boolean;
|
||||
/** The workspace's plan id from useFeatureAccess. */
|
||||
currentPlan: PlanID;
|
||||
}) {
|
||||
const cardRef = React.useRef<HTMLDivElement>(null);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!open) return;
|
||||
const previous = document.activeElement as HTMLElement | null;
|
||||
cardRef.current?.focus();
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key !== "Escape") return;
|
||||
// The confirm owns Escape while it is up.
|
||||
if (document.querySelector("[role='alertdialog']")) return;
|
||||
e.stopPropagation();
|
||||
onClose();
|
||||
};
|
||||
document.addEventListener("keydown", onKey, true);
|
||||
return () => {
|
||||
document.removeEventListener("keydown", onKey, true);
|
||||
previous?.focus?.();
|
||||
};
|
||||
}, [open, onClose]);
|
||||
|
||||
return createPortal(
|
||||
<AnimatePresence>
|
||||
{open && (
|
||||
<motion.div
|
||||
key="allowance-overlay"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
transition={{ duration: 0.15 }}
|
||||
onMouseDown={(e) => {
|
||||
e.stopPropagation();
|
||||
onClose();
|
||||
}}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="fixed inset-0 z-[140] flex items-center justify-center bg-slate-900/40 backdrop-blur-[2px] px-4"
|
||||
>
|
||||
<motion.div
|
||||
key="allowance-card"
|
||||
ref={cardRef}
|
||||
tabIndex={-1}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label="Mailbox allowance"
|
||||
data-floating
|
||||
data-nested-modal
|
||||
initial={{ y: 10, opacity: 0, scale: 0.98 }}
|
||||
animate={{ y: 0, opacity: 1, scale: 1 }}
|
||||
exit={{ y: 10, opacity: 0, scale: 0.98 }}
|
||||
transition={{ duration: 0.2, ease: EASE }}
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
className="w-full max-w-[540px] rounded-lg bg-white border border-slate-200 shadow-[0_24px_48px_-12px_rgba(15,23,42,0.22),0_8px_16px_-8px_rgba(15,23,42,0.12)] overflow-hidden flex flex-col max-h-[88dvh] outline-none"
|
||||
>
|
||||
<div className="h-12 px-4 border-b border-slate-200 flex items-center gap-2.5 shrink-0">
|
||||
<MailboxIcon className="w-3.5 h-3.5 text-slate-500" />
|
||||
<span className="text-[10px] uppercase tracking-[0.14em] text-slate-400 font-medium">
|
||||
Mailbox allowance
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
aria-label="Close"
|
||||
className="ml-auto size-7 rounded-md text-slate-500 hover:text-slate-900 hover:bg-slate-100 inline-flex items-center justify-center transition-colors"
|
||||
>
|
||||
<XIcon className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex-1 min-h-0 overflow-y-auto">
|
||||
{allowance ? (
|
||||
<Body allowance={allowance} reached={!!reached} currentPlan={currentPlan} onClose={onClose} />
|
||||
) : (
|
||||
<div className="p-6 flex items-center justify-center text-slate-400">
|
||||
<Loader2Icon className="w-4 h-4 animate-spin" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>,
|
||||
document.body,
|
||||
);
|
||||
}
|
||||
|
||||
function Body({
|
||||
allowance: a,
|
||||
reached,
|
||||
currentPlan,
|
||||
onClose,
|
||||
}: {
|
||||
allowance: MailboxAllowance;
|
||||
reached: boolean;
|
||||
currentPlan: PlanID;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const upgrade = useUpgradeDialog();
|
||||
const cap = a.allowance;
|
||||
const full = cap != null && (a.remaining ?? 0) <= 0;
|
||||
const pct = cap ? Math.min(100, Math.round((a.used / cap) * 100)) : 0;
|
||||
const tone: DitherTone = pct >= 90 ? "rose" : pct >= 70 ? "amber" : "sky";
|
||||
const planLabel = a.plan_name || getPlan(currentPlan).label;
|
||||
const next = a.paid ? nextPlanWithMore(a, currentPlan) : null;
|
||||
|
||||
const title = reached
|
||||
? full
|
||||
? "You've reached your mailbox allowance"
|
||||
: "This connect would pass your mailbox allowance"
|
||||
: cap == null
|
||||
? "This workspace holds unlimited mailboxes"
|
||||
: "Your mailbox allowance";
|
||||
|
||||
function openPlans(feature: string, minPlan: PlanID, blurb: string) {
|
||||
onClose();
|
||||
upgrade.open({
|
||||
feature,
|
||||
minPlan,
|
||||
blurb,
|
||||
bullets: PAID_PLANS.filter((id) => id !== "enterprise").map((id) => {
|
||||
const n = planMailboxes(id, a.sends_per_mailbox);
|
||||
return `${PLAN_CATALOG[id].label}: ${n == null ? "unlimited" : n.toLocaleString()} mailboxes`;
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-4 space-y-4">
|
||||
<div>
|
||||
<h2 className="text-[15px] font-semibold text-slate-900 leading-snug">{title}</h2>
|
||||
<p className="text-[12.5px] text-slate-600 mt-1 leading-relaxed">
|
||||
<Explanation a={a} planLabel={planLabel} />
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{cap != null && (
|
||||
<div className="rounded-md border border-slate-200 p-3">
|
||||
<div className="flex items-baseline justify-between gap-2 mb-1.5">
|
||||
<span className="text-[12px] text-slate-700 font-medium">Connected mailboxes</span>
|
||||
<span className="text-[11.5px] font-mono tabular-nums text-slate-700">
|
||||
{a.used.toLocaleString()}
|
||||
<span className="text-slate-400"> / {cap.toLocaleString()}</span>
|
||||
</span>
|
||||
</div>
|
||||
<DitherMeter frac={pct / 100} tone={tone} height={4} />
|
||||
<div className="text-[10.5px] text-slate-400 mt-1.5">
|
||||
{a.basis === "fair_use" && a.plan_daily_sends != null
|
||||
? `${a.plan_daily_sends.toLocaleString()} sends a day on ${planLabel}, ${perSend(a.sends_per_mailbox)}`
|
||||
: a.basis === "override"
|
||||
? "Raised for this workspace by an approved request"
|
||||
: a.basis === "free"
|
||||
? "Free workspace"
|
||||
: `Set by ${planLabel}`}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!a.paid ? (
|
||||
<div className="rounded-md border border-sky-200 bg-sky-50 p-3">
|
||||
<p className="text-[12.5px] font-medium text-sky-900">A paid plan holds one mailbox for every send a day it includes</p>
|
||||
<ul className="mt-1.5 space-y-0.5">
|
||||
{PAID_PLANS.filter((id) => id !== "enterprise").map((id) => {
|
||||
const n = planMailboxes(id, a.sends_per_mailbox);
|
||||
return (
|
||||
<li key={id} className="text-[12px] text-sky-800 flex items-center gap-1.5">
|
||||
<CheckIcon className="w-3 h-3 text-sky-600 shrink-0" />
|
||||
<span className="font-medium">{PLAN_CATALOG[id].label}</span>
|
||||
<span className="text-sky-700/80">
|
||||
{PLAN_CATALOG[id].sendsPerDay.toLocaleString()} sends a day, {n == null ? "unlimited" : n.toLocaleString()} mailboxes
|
||||
</span>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
openPlans(
|
||||
"More mailboxes",
|
||||
"starter",
|
||||
`A free workspace holds ${cap?.toLocaleString() ?? "10"} mailboxes. A paid plan holds one for every send a day it includes, and more on request.`,
|
||||
)
|
||||
}
|
||||
className="mt-2.5 h-7 px-3 rounded-md bg-slate-900 hover:bg-slate-800 text-white text-[12px] font-medium inline-flex items-center gap-1.5 transition-colors"
|
||||
>
|
||||
<SparklesIcon className="w-3 h-3" />
|
||||
Choose a plan
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{next && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
openPlans(
|
||||
"More mailboxes",
|
||||
next,
|
||||
`${PLAN_CATALOG[next].label} includes ${PLAN_CATALOG[next].sendsPerDay.toLocaleString()} sends a day, which holds ${planMailboxes(next, a.sends_per_mailbox)?.toLocaleString() ?? "unlimited"} mailboxes under fair use.`,
|
||||
)
|
||||
}
|
||||
className="w-full rounded-md border border-slate-200 hover:border-slate-300 hover:bg-slate-50 p-3 text-left flex items-center gap-3 transition-colors group"
|
||||
>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="text-[12.5px] font-medium text-slate-900">
|
||||
Move to {PLAN_CATALOG[next].label} for{" "}
|
||||
{planMailboxes(next, a.sends_per_mailbox)?.toLocaleString() ?? "unlimited"} mailboxes
|
||||
</div>
|
||||
<div className="text-[11.5px] text-slate-500 mt-0.5">
|
||||
{PLAN_CATALOG[next].sendsPerDay.toLocaleString()} sends a day. Prorated, takes effect immediately.
|
||||
</div>
|
||||
</div>
|
||||
<ArrowUpRightIcon className="w-4 h-4 text-slate-300 group-hover:text-slate-500 shrink-0" />
|
||||
</button>
|
||||
)}
|
||||
{cap != null && <RequestSection a={a} cap={cap} />}
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className="flex items-center justify-between gap-2 pt-1">
|
||||
<Link
|
||||
to="/app/settings/limits"
|
||||
onClick={onClose}
|
||||
className="text-[11.5px] text-slate-500 hover:text-slate-900 inline-flex items-center gap-1 transition-colors"
|
||||
>
|
||||
All limit requests
|
||||
<ArrowRightIcon className="w-3 h-3" />
|
||||
</Link>
|
||||
<a
|
||||
href="https://docs.warmbly.com/guides/mailboxes/#mailbox-allowance"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="text-[11.5px] text-slate-500 hover:text-slate-900 inline-flex items-center gap-1 transition-colors"
|
||||
>
|
||||
How fair use works
|
||||
<ArrowUpRightIcon className="w-3 h-3" />
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Explanation({ a, planLabel }: { a: MailboxAllowance; planLabel: string }) {
|
||||
switch (a.basis) {
|
||||
case "unlimited":
|
||||
return <>There is no cap on mailboxes here. Connect as many as you need.</>;
|
||||
case "free":
|
||||
return (
|
||||
<>
|
||||
A free workspace holds up to {a.allowance?.toLocaleString()} mailboxes for warmup. Sending, and mailboxes
|
||||
without a fixed cap, come with a plan.
|
||||
</>
|
||||
);
|
||||
case "override":
|
||||
return (
|
||||
<>
|
||||
This workspace's allowance was raised to {a.allowance?.toLocaleString()} mailboxes by an approved
|
||||
request. Ask again when you need more.
|
||||
</>
|
||||
);
|
||||
case "plan":
|
||||
return <>{planLabel} holds {a.allowance?.toLocaleString()} mailboxes. Ask for more below or move to a bigger plan.</>;
|
||||
default:
|
||||
return (
|
||||
<>
|
||||
Mailboxes are unlimited on {planLabel} under fair use: {perSend(a.sends_per_mailbox)} the plan
|
||||
includes. {a.plan_daily_sends?.toLocaleString()} sends a day holds {a.allowance?.toLocaleString()}{" "}
|
||||
mailboxes, far more than safe sending ever needs. Need more? Ask below and we usually answer within a
|
||||
business day.
|
||||
</>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function RequestSection({ a, cap }: { a: MailboxAllowance; cap: number }) {
|
||||
const qc = useQueryClient();
|
||||
const confirm = useConfirm();
|
||||
const org = useCurrentOrg();
|
||||
const [requested, setRequested] = React.useState<number>(suggestedRequest(cap));
|
||||
const [reason, setReason] = React.useState("");
|
||||
const pending = a.pending_request ?? null;
|
||||
|
||||
const refresh = () => {
|
||||
qc.invalidateQueries({ queryKey: MAILBOX_ALLOWANCE_KEY });
|
||||
qc.invalidateQueries({ queryKey: ["organizations"] });
|
||||
qc.invalidateQueries({ queryKey: ["app", "organizations"] });
|
||||
};
|
||||
|
||||
const submit = useMutation({
|
||||
mutationFn: () =>
|
||||
submitLimitRequest(org!.id, { field: "max_email_accounts", requested, reason: reason.trim() }),
|
||||
onSuccess: () => {
|
||||
toast.success("Request sent. We usually answer within a business day.");
|
||||
setReason("");
|
||||
refresh();
|
||||
},
|
||||
onError: (e: AppError) => toast.error(buildError(e)),
|
||||
});
|
||||
|
||||
const cancel = useMutation({
|
||||
mutationFn: (id: string) => cancelLimitRequest(id),
|
||||
onSuccess: () => {
|
||||
toast.success("Request withdrawn");
|
||||
refresh();
|
||||
},
|
||||
onError: (e: AppError) => toast.error(buildError(e)),
|
||||
});
|
||||
|
||||
if (pending) {
|
||||
return (
|
||||
<div className="rounded-md border border-amber-200 bg-amber-50 p-3">
|
||||
<div className="flex items-start gap-2.5">
|
||||
<ClockIcon className="w-3.5 h-3.5 text-amber-600 mt-0.5 shrink-0" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-[12.5px] font-medium text-amber-900">
|
||||
You asked for {pending.requested.toLocaleString()} mailboxes on{" "}
|
||||
{new Date(pending.submitted_at).toLocaleDateString()}
|
||||
</p>
|
||||
<p className="text-[11.5px] text-amber-800/90 mt-0.5 leading-relaxed">
|
||||
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.
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
disabled={cancel.isPending}
|
||||
onClick={() =>
|
||||
confirm.show("Withdraw this request? You can submit a new one at any time.", async () => {
|
||||
await cancel.mutateAsync(pending.id);
|
||||
})
|
||||
}
|
||||
className="mt-2 h-6 px-2 rounded text-[11px] text-amber-900 hover:bg-amber-100 inline-flex items-center gap-1 transition-colors disabled:opacity-50"
|
||||
>
|
||||
Withdraw request
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const valid = Number.isInteger(requested) && requested > cap && reason.trim().length >= 10;
|
||||
|
||||
return (
|
||||
<form
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
if (!valid || !org?.id) return;
|
||||
submit.mutate();
|
||||
}}
|
||||
className="rounded-md border border-slate-200 p-3 space-y-2.5"
|
||||
>
|
||||
<div>
|
||||
<p className="text-[12.5px] font-medium text-slate-900">Request an increase</p>
|
||||
<p className="text-[11.5px] text-slate-500 mt-0.5 leading-relaxed">
|
||||
Keep your plan and ask for a higher allowance. Say how many mailboxes you need and what you are
|
||||
sending, so review takes one pass.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-[10px] uppercase tracking-[0.14em] text-slate-400 font-medium w-20 shrink-0">
|
||||
Mailboxes
|
||||
</span>
|
||||
<NumberInput min={cap + 1} value={requested} onChange={setRequested} className="w-32" />
|
||||
<span className="text-[11px] text-slate-400">now {cap.toLocaleString()}</span>
|
||||
</div>
|
||||
<div className="flex items-start gap-3">
|
||||
<span className="text-[10px] uppercase tracking-[0.14em] text-slate-400 font-medium w-20 shrink-0 pt-1.5">
|
||||
Why
|
||||
</span>
|
||||
<textarea
|
||||
value={reason}
|
||||
onChange={(e) => setReason(e.target.value)}
|
||||
rows={2}
|
||||
placeholder="Onboarding 40 client domains, 25 mailboxes each, at 5 to 10 sends a day per mailbox."
|
||||
className="flex-1 min-w-0 rounded-md border border-slate-200 bg-white px-2.5 py-1.5 text-[12.5px] text-slate-900 placeholder:text-slate-400 outline-none focus:border-sky-400 focus:ring-2 focus:ring-sky-100 transition-colors resize-none"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="text-[10.5px] text-slate-400">
|
||||
{requested <= cap
|
||||
? `Ask for more than ${cap.toLocaleString()}`
|
||||
: reason.trim().length < 10
|
||||
? "A sentence on what you are sending helps"
|
||||
: "Reviewed by a person, usually within a business day"}
|
||||
</span>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={!valid || submit.isPending || !org?.id}
|
||||
className={cn(
|
||||
"h-7 px-3 rounded-md text-[12px] font-medium inline-flex items-center gap-1.5 transition-colors",
|
||||
"bg-slate-900 hover:bg-slate-800 text-white disabled:opacity-50 disabled:cursor-not-allowed",
|
||||
)}
|
||||
>
|
||||
{submit.isPending ? <Loader2Icon className="w-3 h-3 animate-spin" /> : <ArrowRightIcon className="w-3 h-3" />}
|
||||
Send request
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,221 @@
|
||||
// bulkConnectCsv — reading and writing the CSV the bulk mailbox import uses.
|
||||
// Pure functions, kept apart from the panel so it stays a component-only file.
|
||||
|
||||
import Papa from "papaparse";
|
||||
import type AddEmail from "@/lib/api/models/app/emails/AddEmail";
|
||||
import {
|
||||
defaultImapSecurity,
|
||||
defaultSmtpSecurity,
|
||||
validPort,
|
||||
type MailSecurity,
|
||||
} from "@/lib/api/models/app/emails/Service";
|
||||
import { downloadBlob } from "@/lib/api/client/app/contacts/exportContacts";
|
||||
|
||||
export const TEMPLATE_COLUMNS = [
|
||||
"email",
|
||||
"name",
|
||||
"smtp_host",
|
||||
"smtp_port",
|
||||
"smtp_user",
|
||||
"smtp_password",
|
||||
"smtp_security",
|
||||
"imap_host",
|
||||
"imap_port",
|
||||
"imap_user",
|
||||
"imap_password",
|
||||
"imap_security",
|
||||
] as const;
|
||||
|
||||
const TEMPLATE_EXAMPLE = [
|
||||
"alex@company.com",
|
||||
"Alex Rivera",
|
||||
"smtp.company.com",
|
||||
"587",
|
||||
"alex@company.com",
|
||||
"app-password",
|
||||
"starttls",
|
||||
"imap.company.com",
|
||||
"993",
|
||||
"alex@company.com",
|
||||
"app-password",
|
||||
"tls",
|
||||
];
|
||||
|
||||
/** Header spellings we accept, mapped onto the template's names. */
|
||||
const ALIASES: Record<string, string> = {
|
||||
email: "email",
|
||||
address: "email",
|
||||
email_address: "email",
|
||||
name: "name",
|
||||
display_name: "name",
|
||||
from_name: "name",
|
||||
sender_name: "name",
|
||||
first_name: "first_name",
|
||||
last_name: "last_name",
|
||||
smtp_host: "smtp_host",
|
||||
smtp_server: "smtp_host",
|
||||
smtp_port: "smtp_port",
|
||||
smtp_user: "smtp_user",
|
||||
smtp_username: "smtp_user",
|
||||
smtp_login: "smtp_user",
|
||||
smtp_password: "smtp_password",
|
||||
smtp_pass: "smtp_password",
|
||||
smtp_security: "smtp_security",
|
||||
smtp_encryption: "smtp_security",
|
||||
imap_host: "imap_host",
|
||||
imap_server: "imap_host",
|
||||
imap_port: "imap_port",
|
||||
imap_user: "imap_user",
|
||||
imap_username: "imap_user",
|
||||
imap_login: "imap_user",
|
||||
imap_password: "imap_password",
|
||||
imap_pass: "imap_password",
|
||||
imap_security: "imap_security",
|
||||
imap_encryption: "imap_security",
|
||||
// One password or login for both legs, the usual case.
|
||||
password: "password",
|
||||
app_password: "password",
|
||||
username: "username",
|
||||
user: "username",
|
||||
login: "username",
|
||||
};
|
||||
|
||||
function normaliseHeader(h: string): string {
|
||||
const key = h.trim().toLowerCase().replace(/[\s-]+/g, "_");
|
||||
return ALIASES[key] ?? key;
|
||||
}
|
||||
|
||||
function parseSecurity(raw: string | undefined, port: number, leg: "smtp" | "imap"): MailSecurity | null {
|
||||
const v = (raw ?? "").trim().toLowerCase();
|
||||
if (v === "") return leg === "smtp" ? defaultSmtpSecurity(port) : defaultImapSecurity(port);
|
||||
if (v === "tls" || v === "ssl" || v === "ssl/tls" || v === "implicit") return "tls";
|
||||
if (v === "starttls" || v === "start_tls" || v === "start-tls") return "starttls";
|
||||
return null;
|
||||
}
|
||||
|
||||
function nameFromEmail(email: string): string {
|
||||
const local = email.split("@")[0] ?? "";
|
||||
return local
|
||||
.split(/[._-]+/)
|
||||
.filter(Boolean)
|
||||
.map((w) => w.charAt(0).toUpperCase() + w.slice(1))
|
||||
.join(" ");
|
||||
}
|
||||
|
||||
/** One line of the file after normalisation. */
|
||||
export interface BulkRow {
|
||||
/** 1-based line in the file, counting the header. */
|
||||
line: number;
|
||||
raw: Record<string, string>;
|
||||
account: AddEmail | null;
|
||||
/** Why the row cannot be sent at all; set only for invalid rows. */
|
||||
problem?: string;
|
||||
status: "ready" | "invalid" | "pending" | "connected" | "skipped" | "failed";
|
||||
code?: string;
|
||||
message?: string;
|
||||
}
|
||||
|
||||
const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||
|
||||
function buildRow(line: number, raw: Record<string, string>): BulkRow {
|
||||
const get = (k: string) => (raw[k] ?? "").trim();
|
||||
const email = get("email");
|
||||
const invalid = (problem: string): BulkRow => ({ line, raw, account: null, problem, status: "invalid" });
|
||||
|
||||
if (!email) return invalid("Missing email address");
|
||||
if (!EMAIL_RE.test(email)) return invalid("Not a valid email address");
|
||||
|
||||
let name = get("name");
|
||||
if (!name) name = [get("first_name"), get("last_name")].filter(Boolean).join(" ");
|
||||
if (!name) name = nameFromEmail(email);
|
||||
|
||||
const sharedUser = get("username") || email;
|
||||
const sharedPass = get("password");
|
||||
|
||||
const smtpHost = get("smtp_host");
|
||||
const imapHost = get("imap_host");
|
||||
if (!smtpHost) return invalid("Missing smtp_host");
|
||||
if (!imapHost) return invalid("Missing imap_host");
|
||||
|
||||
const smtpPort = Number(get("smtp_port") || "587");
|
||||
const imapPort = Number(get("imap_port") || "993");
|
||||
if (!validPort(smtpPort)) return invalid("smtp_port must be between 1 and 65535");
|
||||
if (!validPort(imapPort)) return invalid("imap_port must be between 1 and 65535");
|
||||
|
||||
const smtpPass = get("smtp_password") || sharedPass;
|
||||
const imapPass = get("imap_password") || sharedPass;
|
||||
if (!imapPass) return invalid("Missing imap_password (or a shared password column)");
|
||||
if (!smtpPass) return invalid("Missing smtp_password (or a shared password column)");
|
||||
|
||||
const smtpSecurity = parseSecurity(raw.smtp_security, smtpPort, "smtp");
|
||||
const imapSecurity = parseSecurity(raw.imap_security, imapPort, "imap");
|
||||
if (!smtpSecurity) return invalid("smtp_security must be tls or starttls");
|
||||
if (!imapSecurity) return invalid("imap_security must be tls or starttls");
|
||||
|
||||
return {
|
||||
line,
|
||||
raw,
|
||||
status: "ready",
|
||||
account: {
|
||||
email,
|
||||
name,
|
||||
smtp: {
|
||||
host: smtpHost,
|
||||
port: smtpPort,
|
||||
username: get("smtp_user") || sharedUser,
|
||||
password: smtpPass,
|
||||
security: smtpSecurity,
|
||||
},
|
||||
imap: {
|
||||
host: imapHost,
|
||||
port: imapPort,
|
||||
username: get("imap_user") || sharedUser,
|
||||
password: imapPass,
|
||||
security: imapSecurity,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function parseBulkFile(file: File): Promise<{ rows: BulkRow[]; columns: string[] }> {
|
||||
return new Promise((resolve, reject) => {
|
||||
Papa.parse<Record<string, string>>(file, {
|
||||
header: true,
|
||||
skipEmptyLines: "greedy",
|
||||
transformHeader: normaliseHeader,
|
||||
complete: (res) => {
|
||||
const columns = (res.meta.fields ?? []).filter((c) => c !== "");
|
||||
if (!columns.includes("email")) {
|
||||
reject(new Error("The file needs an email column. Download the template to see the expected headers."));
|
||||
return;
|
||||
}
|
||||
const rows = res.data.map((raw, i) => buildRow(i + 2, raw));
|
||||
if (rows.length === 0) {
|
||||
reject(new Error("The file has a header but no rows."));
|
||||
return;
|
||||
}
|
||||
resolve({ rows, columns });
|
||||
},
|
||||
error: (err) => reject(new Error(err.message || "Could not read the file.")),
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export function downloadTemplate() {
|
||||
const csv = Papa.unparse([TEMPLATE_COLUMNS as unknown as string[], TEMPLATE_EXAMPLE]);
|
||||
downloadBlob(new Blob(["" + csv], { type: "text/csv;charset=utf-8" }), "warmbly-mailboxes-template.csv");
|
||||
}
|
||||
|
||||
/** Columns never written to a download: a CSV in a Downloads folder is
|
||||
* synced, backed up and shared far more than the user intends. */
|
||||
const SECRET_COLUMNS = new Set(["password", "smtp_password", "imap_password"]);
|
||||
|
||||
/** The rows that did not connect, with the user's own columns (minus
|
||||
* passwords) plus the reason. */
|
||||
export function failedRowsCSV(rows: BulkRow[], columns: string[]): string {
|
||||
const keep = columns.filter((c) => !SECRET_COLUMNS.has(c));
|
||||
const header = [...keep, "error"];
|
||||
const body = rows.map((r) => [...keep.map((c) => r.raw[c] ?? ""), r.problem ?? r.message ?? r.code ?? ""]);
|
||||
return Papa.unparse([header, ...body]);
|
||||
}
|
||||
|
||||
@@ -9,6 +9,11 @@
|
||||
// provider picker ──► gmail OAuth popup ─┐
|
||||
// ──► outlook OAuth popup ─┼─► /emails/onboarding/oauth/finish
|
||||
// ──► smtp/imap form ──────────► /emails/onboarding/smtp-imap
|
||||
// ──► CSV bulk import ─────────► /emails/onboarding/smtp-imap/bulk
|
||||
//
|
||||
// Every path can run into the workspace's mailbox allowance; that answer
|
||||
// (code mailbox_allowance_reached) opens MailboxAllowanceDialog instead of a
|
||||
// toast, and the picker shows the allowance up front so it is never a surprise.
|
||||
//
|
||||
// OAuth popup posts {type:"email_oauth_callback", code, state} back here
|
||||
// via window.postMessage; we then call OAuth-finish with the user's bearer.
|
||||
@@ -23,6 +28,7 @@ import {
|
||||
CheckIcon,
|
||||
ChevronRightIcon,
|
||||
CloudIcon,
|
||||
FileSpreadsheetIcon,
|
||||
InboxIcon,
|
||||
KeyRoundIcon,
|
||||
Loader2Icon,
|
||||
@@ -58,8 +64,20 @@ import useCloudPool from "@/hooks/useCloudPool";
|
||||
import type { CloudOAuthDoneMessage } from "@/app/cloud-oauth/done/page";
|
||||
import { Google, Outlook } from "@/components/svg";
|
||||
import { cn } from "@/lib/utils";
|
||||
import useFeatureAccess from "@/hooks/useFeatureAccess";
|
||||
import useMailboxAllowance from "@/lib/api/hooks/app/emails/useMailboxAllowance";
|
||||
import { allowanceFull } from "@/lib/api/models/app/emails/MailboxAllowance";
|
||||
import type MailboxAllowance from "@/lib/api/models/app/emails/MailboxAllowance";
|
||||
import MailboxAllowanceDialog from "@/components/app/emails/MailboxAllowanceDialog";
|
||||
import BulkConnectPanel from "@/components/app/emails/BulkConnectPanel";
|
||||
import { DitherMeter, type DitherTone } from "@/components/ui/dither";
|
||||
|
||||
type View = "pick" | "gmail" | "outlook" | "smtp_imap";
|
||||
type View = "pick" | "gmail" | "outlook" | "smtp_imap" | "bulk";
|
||||
|
||||
/** The one answer every connect path shares: open the allowance dialog. */
|
||||
function isAllowanceError(e: unknown): boolean {
|
||||
return (e as AppError | undefined)?.code === "mailbox_allowance_reached";
|
||||
}
|
||||
type OAuthProvider = "gmail" | "outlook";
|
||||
|
||||
interface OAuthCallbackMessage {
|
||||
@@ -132,12 +150,32 @@ export default function AddEmailModal() {
|
||||
const pool = useCloudPool();
|
||||
const viaCloud = pool.connected;
|
||||
|
||||
// The allowance is read while the modal is open so the picker can show it
|
||||
// and a refused connect can explain itself. Fetched from the same query
|
||||
// the mailbox list invalidates, so it is live.
|
||||
const access = useFeatureAccess();
|
||||
const allowance = useMailboxAllowance(user.addEmail);
|
||||
const [allowanceOpen, setAllowanceOpen] = React.useState(false);
|
||||
const [allowanceReached, setAllowanceReached] = React.useState(false);
|
||||
const openAllowance = React.useCallback((reached = false) => {
|
||||
setAllowanceReached(reached);
|
||||
setAllowanceOpen(true);
|
||||
}, []);
|
||||
// Route a refused connect to the dialog; everything else stays a toast.
|
||||
const onConnectError = React.useCallback(
|
||||
(e: unknown) => {
|
||||
if (isAllowanceError(e)) openAllowance(true);
|
||||
},
|
||||
[openAllowance],
|
||||
);
|
||||
|
||||
// Reset when the modal closes.
|
||||
React.useEffect(() => {
|
||||
if (!user.addEmail) {
|
||||
setView("pick");
|
||||
setOauthBusy(null);
|
||||
setNotConfigured(null);
|
||||
setAllowanceOpen(false);
|
||||
pendingState.current = null;
|
||||
pendingCloud.current = null;
|
||||
}
|
||||
@@ -171,11 +209,13 @@ export default function AddEmailModal() {
|
||||
success: "Mailbox connected. Warmbly Cloud warms it from now on.",
|
||||
error: (e: AppError) => buildError(e),
|
||||
},
|
||||
).finally(() => setOauthBusy(null));
|
||||
)
|
||||
.catch(onConnectError)
|
||||
.finally(() => setOauthBusy(null));
|
||||
}
|
||||
window.addEventListener("message", onMessage);
|
||||
return () => window.removeEventListener("message", onMessage);
|
||||
}, [qc, user]);
|
||||
}, [qc, user, onConnectError]);
|
||||
|
||||
// Listen for the OAuth popup's postMessage. We only honour messages from an
|
||||
// origin we own and whose state matches the one we issued, which is what
|
||||
@@ -211,11 +251,13 @@ export default function AddEmailModal() {
|
||||
success: "Mailbox connected",
|
||||
error: (e: AppError) => buildError(e),
|
||||
},
|
||||
).finally(() => setOauthBusy(null));
|
||||
)
|
||||
.catch(onConnectError)
|
||||
.finally(() => setOauthBusy(null));
|
||||
}
|
||||
window.addEventListener("message", onMessage);
|
||||
return () => window.removeEventListener("message", onMessage);
|
||||
}, [qc, user]);
|
||||
}, [qc, user, onConnectError]);
|
||||
|
||||
async function startOAuth(provider: OAuthProvider) {
|
||||
if (oauthBusy) return;
|
||||
@@ -234,6 +276,10 @@ export default function AddEmailModal() {
|
||||
} catch (err) {
|
||||
pendingCloud.current = null;
|
||||
setOauthBusy(null);
|
||||
if (isAllowanceError(err)) {
|
||||
openAllowance(true);
|
||||
return;
|
||||
}
|
||||
toast.error(buildError(err as AppError));
|
||||
}
|
||||
return;
|
||||
@@ -255,6 +301,10 @@ export default function AddEmailModal() {
|
||||
setNotConfigured(provider);
|
||||
return;
|
||||
}
|
||||
if (isAllowanceError(e)) {
|
||||
openAllowance(true);
|
||||
return;
|
||||
}
|
||||
toast.error(buildError(e));
|
||||
}
|
||||
}
|
||||
@@ -278,7 +328,10 @@ export default function AddEmailModal() {
|
||||
exit={{ y: 8, opacity: 0 }}
|
||||
transition={{ duration: 0.16 }}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="w-full max-w-[560px] rounded-lg bg-white border border-slate-200 shadow-[0_24px_48px_-12px_rgba(15,23,42,0.18),0_8px_16px_-8px_rgba(15,23,42,0.1)] overflow-hidden flex flex-col max-h-[88dvh]"
|
||||
className={cn(
|
||||
"w-full rounded-lg bg-white border border-slate-200 shadow-[0_24px_48px_-12px_rgba(15,23,42,0.18),0_8px_16px_-8px_rgba(15,23,42,0.1)] overflow-hidden flex flex-col max-h-[88dvh] transition-[max-width] duration-200",
|
||||
view === "bulk" ? "max-w-[760px]" : "max-w-[560px]",
|
||||
)}
|
||||
>
|
||||
<Header
|
||||
view={view}
|
||||
@@ -298,14 +351,20 @@ export default function AddEmailModal() {
|
||||
transition={{ duration: 0.18, ease: [0.32, 0.72, 0, 1] }}
|
||||
>
|
||||
{view === "pick" && (
|
||||
<PickProvider
|
||||
onPick={setView}
|
||||
viaCloud={viaCloud}
|
||||
onAdopted={() => {
|
||||
qc.invalidateQueries({ queryKey: ["emails", "list"] });
|
||||
user.setAddEmail(false);
|
||||
}}
|
||||
/>
|
||||
<>
|
||||
<AllowanceStrip
|
||||
allowance={allowance.data}
|
||||
onOpen={() => openAllowance(allowanceFull(allowance.data))}
|
||||
/>
|
||||
<PickProvider
|
||||
onPick={setView}
|
||||
viaCloud={viaCloud}
|
||||
onAdopted={() => {
|
||||
qc.invalidateQueries({ queryKey: ["emails", "list"] });
|
||||
user.setAddEmail(false);
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
{view === "gmail" && (
|
||||
notConfigured === "gmail" ? (
|
||||
@@ -337,18 +396,97 @@ export default function AddEmailModal() {
|
||||
qc.invalidateQueries({ queryKey: ["emails", "list"] });
|
||||
user.setAddEmail(false);
|
||||
}}
|
||||
onError={onConnectError}
|
||||
/>
|
||||
)}
|
||||
{view === "bulk" && (
|
||||
<BulkConnectPanel
|
||||
onDone={() => {
|
||||
qc.invalidateQueries({ queryKey: ["emails", "list"] });
|
||||
user.setAddEmail(false);
|
||||
}}
|
||||
onAllowance={() => openAllowance(allowanceFull(allowance.data))}
|
||||
/>
|
||||
)}
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
</motion.div>
|
||||
<MailboxAllowanceDialog
|
||||
open={allowanceOpen}
|
||||
onClose={() => setAllowanceOpen(false)}
|
||||
allowance={allowance.data}
|
||||
reached={allowanceReached}
|
||||
currentPlan={access.plan}
|
||||
/>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
);
|
||||
}
|
||||
|
||||
// AllowanceStrip — the workspace's mailbox allowance at the top of the
|
||||
// picker: quiet while there is plenty of room, a warning near the cap, and a
|
||||
// clear "full" state that leads to the request dialog rather than letting the
|
||||
// user type credentials that will be refused.
|
||||
function AllowanceStrip({ allowance: a, onOpen }: { allowance: MailboxAllowance | undefined; onOpen: () => void }) {
|
||||
if (!a || a.allowance == null) return null;
|
||||
const cap = a.allowance;
|
||||
const remaining = a.remaining ?? 0;
|
||||
const pct = Math.min(100, Math.round((a.used / cap) * 100));
|
||||
const full = remaining <= 0;
|
||||
const near = !full && (pct >= 80 || remaining <= 5);
|
||||
if (!full && !near) {
|
||||
return (
|
||||
<div className="px-4 py-2 border-b border-slate-200/60 flex items-center gap-2 text-[11px] text-slate-500">
|
||||
<span className="font-mono tabular-nums text-slate-700">
|
||||
{a.used.toLocaleString()} / {cap.toLocaleString()}
|
||||
</span>
|
||||
<span>mailboxes on this workspace</span>
|
||||
{a.pending_request && <span className="text-amber-600">· increase requested</span>}
|
||||
<button type="button" onClick={onOpen} className="ml-auto underline hover:text-slate-900 transition-colors">
|
||||
Need more?
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
const tone: DitherTone = full ? "rose" : "amber";
|
||||
return (
|
||||
<div className={cn("px-4 py-2.5 border-b", full ? "bg-rose-50/60 border-rose-200/60" : "bg-amber-50/60 border-amber-200/60")}>
|
||||
<div className="flex items-baseline justify-between gap-2 mb-1">
|
||||
<span className={cn("text-[12px] font-medium", full ? "text-rose-900" : "text-amber-900")}>
|
||||
{full
|
||||
? "Every mailbox slot is used"
|
||||
: `${remaining.toLocaleString()} ${remaining === 1 ? "slot" : "slots"} left`}
|
||||
</span>
|
||||
<span className="text-[11px] font-mono tabular-nums text-slate-700">
|
||||
{a.used.toLocaleString()} / {cap.toLocaleString()}
|
||||
</span>
|
||||
</div>
|
||||
<DitherMeter frac={pct / 100} tone={tone} height={4} />
|
||||
<div className="flex items-center justify-between gap-2 mt-1.5">
|
||||
<span className={cn("text-[11px]", full ? "text-rose-800/90" : "text-amber-800/90")}>
|
||||
{a.pending_request
|
||||
? `Increase to ${a.pending_request.requested.toLocaleString()} requested, pending review`
|
||||
: full
|
||||
? "New connects are refused until the allowance is raised"
|
||||
: "Ask for more before a large batch"}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onOpen}
|
||||
className={cn(
|
||||
"h-6 px-2 rounded text-[11px] font-medium transition-colors shrink-0",
|
||||
full ? "bg-rose-600 hover:bg-rose-700 text-white" : "bg-amber-600 hover:bg-amber-700 text-white",
|
||||
)}
|
||||
>
|
||||
{a.pending_request ? "View request" : full ? "Get more mailboxes" : "Request more"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Header({
|
||||
view,
|
||||
onBack,
|
||||
@@ -363,6 +501,7 @@ function Header({
|
||||
gmail: "Gmail or Google Workspace",
|
||||
outlook: "Outlook or Microsoft 365",
|
||||
smtp_imap: "Any provider via SMTP / IMAP",
|
||||
bulk: "Many mailboxes from one CSV",
|
||||
};
|
||||
return (
|
||||
<div className="h-12 px-3 border-b border-slate-200 flex items-center gap-2.5 shrink-0">
|
||||
@@ -498,6 +637,13 @@ function PickProvider({ onPick, viaCloud, onAdopted }: { onPick: (v: View) => vo
|
||||
sub: "Any provider with manual host, port, and app password.",
|
||||
tone: "neutral",
|
||||
},
|
||||
{
|
||||
key: "bulk",
|
||||
icon: <FileSpreadsheetIcon className="w-4 h-4 text-slate-700" />,
|
||||
title: "Bulk import from CSV",
|
||||
sub: "Hundreds or thousands of SMTP / IMAP mailboxes in one go, with a report of anything that failed.",
|
||||
tone: "neutral",
|
||||
},
|
||||
];
|
||||
return (
|
||||
<div className="divide-y divide-slate-200/60">
|
||||
@@ -652,7 +798,7 @@ function Scope({ children }: { children: React.ReactNode }) {
|
||||
);
|
||||
}
|
||||
|
||||
function SmtpImapPanel({ onDone }: { onDone: () => void }) {
|
||||
function SmtpImapPanel({ onDone, onError }: { onDone: () => void; onError: (e: unknown) => void }) {
|
||||
const [name, setName] = React.useState("");
|
||||
const [email, setEmail] = React.useState("");
|
||||
|
||||
@@ -748,8 +894,9 @@ function SmtpImapPanel({ onDone }: { onDone: () => void }) {
|
||||
},
|
||||
);
|
||||
onDone();
|
||||
} catch {
|
||||
/* surfaced by toast */
|
||||
} catch (e) {
|
||||
// Surfaced by the toast, except a full allowance, which gets its dialog.
|
||||
onError(e);
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
|
||||
@@ -98,7 +98,9 @@ export default async function Request<T>(config: AuthRequestConfig): Promise<T>
|
||||
// popup explaining the missing permission (or plan). Reads that 403 are
|
||||
// intentionally left to page-level gating (locked surfaces / NoAccess),
|
||||
// so we only surface this for mutating methods.
|
||||
if (appErr?.status === 403 && typeof window !== "undefined") {
|
||||
// A full mailbox allowance is a 403 with its own code and its own
|
||||
// dialog (MailboxAllowanceDialog), so it is not a permission problem.
|
||||
if (appErr?.status === 403 && appErr.code !== "mailbox_allowance_reached" && typeof window !== "undefined") {
|
||||
const method = String(config.method ?? "get").toUpperCase();
|
||||
if (method !== "GET" && method !== "HEAD") {
|
||||
window.dispatchEvent(
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
import Request from "../../Request";
|
||||
import type AddEmail from "@/lib/api/models/app/emails/AddEmail";
|
||||
import type { BulkConnectResult } from "@/lib/api/models/app/emails/BulkConnect";
|
||||
|
||||
/** The server caps a batch at this many rows; mirrors config.MailboxBulkBatchMax. */
|
||||
export const BULK_CONNECT_BATCH = 50;
|
||||
|
||||
export default async function addEmailsBulk(accounts: AddEmail[]): Promise<BulkConnectResult> {
|
||||
return await Request<BulkConnectResult>({
|
||||
method: "POST",
|
||||
url: `/emails/onboarding/smtp-imap/bulk`,
|
||||
data: { accounts },
|
||||
authorization: true,
|
||||
// A batch dials every credential against a worker before answering.
|
||||
timeout: 180_000,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import Request from "../../Request";
|
||||
import type MailboxAllowance from "@/lib/api/models/app/emails/MailboxAllowance";
|
||||
|
||||
export default async function getMailboxAllowance(): Promise<MailboxAllowance> {
|
||||
return await Request<MailboxAllowance>({
|
||||
method: "GET",
|
||||
url: `/emails/allowance`,
|
||||
authorization: true,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import getMailboxAllowance from "@/lib/api/client/app/emails/getMailboxAllowance";
|
||||
|
||||
/** Under the ["emails"] prefix so every mailbox event refreshes it live. */
|
||||
export const MAILBOX_ALLOWANCE_KEY = ["emails", "allowance"] as const;
|
||||
|
||||
export default function useMailboxAllowance(enabled = true) {
|
||||
return useQuery({
|
||||
queryKey: MAILBOX_ALLOWANCE_KEY,
|
||||
queryFn: getMailboxAllowance,
|
||||
enabled,
|
||||
staleTime: 15_000,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import type MailboxAllowance from "./MailboxAllowance";
|
||||
import type AddEmail from "./AddEmail";
|
||||
|
||||
export type BulkConnectStatus = "connected" | "skipped" | "failed";
|
||||
|
||||
/** One row's answer from POST /emails/onboarding/smtp-imap/bulk. */
|
||||
export interface BulkConnectRow {
|
||||
/** Index into the batch that was sent, as the caller numbered it. */
|
||||
row: number;
|
||||
email: string;
|
||||
status: BulkConnectStatus;
|
||||
code?: string;
|
||||
message?: string;
|
||||
id?: string;
|
||||
}
|
||||
|
||||
export interface BulkConnectResult {
|
||||
data: BulkConnectRow[];
|
||||
summary: { total: number; connected: number; skipped: number; failed: number };
|
||||
allowance?: MailboxAllowance;
|
||||
}
|
||||
|
||||
export interface BulkConnectRequest {
|
||||
accounts: AddEmail[];
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import type LimitIncreaseRequest from "@/lib/api/models/app/organizations/LimitIncreaseRequest";
|
||||
|
||||
/** Where the allowance comes from; mirrors models.MailboxAllowanceBasis. */
|
||||
export type MailboxAllowanceBasis = "unlimited" | "free" | "override" | "plan" | "fair_use";
|
||||
|
||||
/** GET /emails/allowance: how many mailboxes the workspace holds and may hold. */
|
||||
export default interface MailboxAllowance {
|
||||
used: number;
|
||||
/** null means unlimited. */
|
||||
allowance: number | null;
|
||||
/** allowance minus used, never negative; null when unlimited. */
|
||||
remaining: number | null;
|
||||
basis: MailboxAllowanceBasis;
|
||||
/** The fair-use divisor: one mailbox for every this many daily sends. */
|
||||
sends_per_mailbox: number;
|
||||
plan_daily_sends?: number;
|
||||
plan_name?: string;
|
||||
paid: boolean;
|
||||
pending_request?: LimitIncreaseRequest | null;
|
||||
}
|
||||
|
||||
/** True when the allowance is a real cap and it is full. */
|
||||
export function allowanceFull(a: MailboxAllowance | undefined): boolean {
|
||||
return !!a && a.allowance != null && (a.remaining ?? 0) <= 0;
|
||||
}
|
||||
@@ -1,10 +1,28 @@
|
||||
import type MailboxAllowance from "@/lib/api/models/app/emails/MailboxAllowance";
|
||||
|
||||
/** GET /organization/current/limits: what the server enforces beside live counts.
|
||||
* A null or absent limit is unmetered. */
|
||||
export default interface OrganizationLimits {
|
||||
max_members: number
|
||||
max_campaigns: number
|
||||
max_emails: number
|
||||
max_contacts: number
|
||||
current_members: number
|
||||
current_campaigns: number
|
||||
current_emails: number
|
||||
current_contacts: number
|
||||
limits: {
|
||||
max_campaigns?: number | null;
|
||||
max_active_campaigns?: number | null;
|
||||
max_team_members?: number | null;
|
||||
max_email_accounts?: number | null;
|
||||
max_contacts?: number | null;
|
||||
daily_campaign_limit?: number | null;
|
||||
};
|
||||
counts: {
|
||||
total_campaigns: number;
|
||||
active_campaigns: number;
|
||||
total_contacts: number;
|
||||
total_members: number;
|
||||
email_accounts: number;
|
||||
emails_sent_today: number;
|
||||
};
|
||||
mailboxes: MailboxAllowance;
|
||||
storage: {
|
||||
used_bytes: number;
|
||||
limit_bytes: number;
|
||||
over_quota?: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user