diff --git a/.greptile/config.json b/.greptile/config.json new file mode 100644 index 00000000..34c493dc --- /dev/null +++ b/.greptile/config.json @@ -0,0 +1,3 @@ +{ + "skipReview": "AUTOMATIC" +} diff --git a/AGENTS.md b/AGENTS.md index 735c59b0..5cfb19e5 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -316,7 +316,7 @@ These are the current built-in defaults and guardrails: - default warmup start per mailbox: `10` emails/day - default warmup ceiling per mailbox: `40` emails/day - default warmup ramp: `+1` email/day -- `campaign_limit` updates are validated up to `100` max +- `campaign_limit` updates are validated up to `config.LimitMax` (`5000`); the dashboard warns above `100` Relevant code: diff --git a/cmd/backend/main.go b/cmd/backend/main.go index 57f9383c..e6fa18a2 100644 --- a/cmd/backend/main.go +++ b/cmd/backend/main.go @@ -1168,6 +1168,8 @@ func main() { } analyticsRepository := repository.NewAnalyticsRepository(primaryDB) emailAccountErrorRepository := repository.NewEmailAccountErrorRepository(primaryDB) + // A successful mailbox reconnect resolves the credential errors it fixed. + emailService.WireAccountErrors(emailAccountErrorRepository) analyticsService = analytics.NewService(analyticsRepository, emailRepostory, campaignRepostory, emailAccountErrorRepository, warmupRepository) // A mailbox out of cold rotation says so in its drawer; an active one // needs no notice. diff --git a/docs/content/docs/api/endpoints.mdx b/docs/content/docs/api/endpoints.mdx index 14455423..25a40144 100644 --- a/docs/content/docs/api/endpoints.mdx +++ b/docs/content/docs/api/endpoints.mdx @@ -325,6 +325,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/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) - `POST /getaway` (websocket bootstrap) diff --git a/docs/content/docs/api/reference/contacts.mdx b/docs/content/docs/api/reference/contacts.mdx index e98f6d84..a72fae15 100644 --- a/docs/content/docs/api/reference/contacts.mdx +++ b/docs/content/docs/api/reference/contacts.mdx @@ -982,7 +982,7 @@ Auth: **Scope** `READ_CRM` · **Org permission** `view_contacts` ## Segments -Segments are saved contact audiences: a list of conditions plus per-contact manual overrides. Membership is evaluated live on every read, so a segment never needs rebuilding. Every segment endpoint takes the contact scopes, except enrolling into a campaign, which writes leads and takes `WRITE_CAMPAIGNS`. +Segments are saved contact audiences: a list of conditions plus per-contact manual overrides. Membership is evaluated live on every read, so a segment never needs rebuilding. Every segment endpoint takes the contact scopes, except enrolling into a campaign, which writes leads and takes `WRITE_CAMPAIGNS`. Endpoints that operate on an existing segment address it by its `id`; besides `GET /segments`, the dashboard shows that ID on the segment page header (click to copy) and in the row menu of the Segments tab. A segment object: diff --git a/docs/content/docs/api/reference/mailboxes.mdx b/docs/content/docs/api/reference/mailboxes.mdx index 624e2b5a..5c25f118 100644 --- a/docs/content/docs/api/reference/mailboxes.mdx +++ b/docs/content/docs/api/reference/mailboxes.mdx @@ -146,7 +146,7 @@ Auth: **Scope** `WRITE_EMAILS` · **Org permission** `manage_emails` | `signature_sync` | boolean | no | Keep the signature synced from the provider. | | `signature_code` | boolean | no | Treat the HTML signature as raw code. | | `status` | string | no | `active`, `inactive`, or `revoked`. | -| `campaign_limit` | integer | no | Daily cold-campaign cap for this mailbox (validated up to `100`). | +| `campaign_limit` | integer | no | Daily cold-campaign cap for this mailbox, `0` to `5000`. Default `50`; `30` to `50`/day is the safe cold-outreach band. | | `min_wait_time` | integer | no | Minimum seconds between sends. | | `reply_to` | string | no | Reply-to address. | | `timezone` | string | no | The mailbox's own IANA zone, such as `America/Denver`. Its sending behaviour and business-hours window are evaluated in this zone. Send an empty string to clear it, which leaves only the campaign's own window applying. | diff --git a/docs/content/docs/guides/campaigns.mdx b/docs/content/docs/guides/campaigns.mdx index 3bf04f83..a8ba120d 100644 --- a/docs/content/docs/guides/campaigns.mdx +++ b/docs/content/docs/guides/campaigns.mdx @@ -45,9 +45,9 @@ Warmbly is mailbox-first: safe volume is the sum of each mailbox's budget, not o | --- | --- | --- | | Per-mailbox cold cap | `50`/day | Hard ceiling for cold mail from one mailbox | | Minimum gap | `600s` | Shortest spacing between two sends from one mailbox, always enforced | -| Campaign daily limit | Per campaign | A per-mailbox cap for this campaign, validated `3` to `100` | +| Campaign daily limit | Per campaign | A per-mailbox cap for this campaign, validated `3` to `5000` | -The campaign daily limit applies as a minimum against the cold cap, so it can lower a mailbox's volume but never raise it above `50`/day. +The campaign daily limit applies as a minimum against the cold cap, so it can lower a mailbox's volume but never raise it above the mailbox's own daily cap (default `50`/day). Anything above `50`/day per cold mailbox needs positive reputation signals and a low complaint rate behind it. Adding mailboxes is safer than forcing a few to send more. diff --git a/docs/content/docs/guides/mailboxes.mdx b/docs/content/docs/guides/mailboxes.mdx index dfba1a92..72a908f7 100644 --- a/docs/content/docs/guides/mailboxes.mdx +++ b/docs/content/docs/guides/mailboxes.mdx @@ -15,7 +15,7 @@ Open **Accounts** and choose **Add account**. | Outlook / Microsoft 365 | OAuth (`outlook`) | Recommended. No password stored. Runs on Microsoft Graph | | Any other server | IMAP + SMTP (`smtp_imap`) | Custom domains, self-hosted, or providers without OAuth | -**OAuth** sends you to your provider's consent screen and returns a token instead of a password. Both OAuth providers use the provider's native API, never IMAP or SMTP, so consent asks to send mail and to read and organize your mailbox. It survives password changes and needs no app passwords or server settings. +**OAuth** sends you to your provider's consent screen and returns a token instead of a password. Both OAuth providers use the provider's native API, never IMAP or SMTP, so consent asks to send mail and to read and organize your mailbox. It needs no app passwords or server settings. Note that Google revokes Gmail tokens when the account's password changes, so a password change there means [re-authorizing the mailbox](#reconnecting-an-account) once. **IMAP / SMTP** needs host, port, username, and password for each direction: @@ -32,6 +32,17 @@ With two-factor authentication on, generate an app password in your provider's s **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. +## Reconnecting an account + +When the provider stops accepting a mailbox's stored credential (a password change, a revoked grant, an expired app password), the mailbox is taken out of sending and syncing and its drawer shows the reason under **Needs attention**, with the fix right on the error: + +- **Gmail and Outlook**: a **Re-authorize** button re-runs the provider consent in a popup, preselecting the mailbox's own address. The consent must be for that same address; signing in with a different account is refused instead of quietly connecting the wrong mailbox. +- **SMTP / IMAP**: an **Update credentials** button opens a form for the new password (or new host and port). The replacement is validated against your server before it is saved, the same as at connect time. + +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. + ## What gets synced Connecting a mailbox does two things: it imports the mailbox's recent history, and from then on it follows new mail as it arrives. Both are the same on every provider. @@ -52,13 +63,15 @@ Set these on the mailbox's **Settings** tab. They apply to the next scheduled se | Control | Default | Range | |---------|---------|-------| -| Daily campaign cap | `50`/day | `0` to `100` | +| Daily campaign cap | `50`/day | `0` to `5000` | | Minimum gap between sends | `600s` (10 minutes) | A hard floor, with jitter added on top | The default of `50` is deliberately conservative. `30` to `50`/day is the normal safe band; a fresh mailbox should start at `10` to `20` and ramp. Raise the cap only for a mailbox with proven reputation and low complaint and bounce rates. +The range goes up to `5000` so a high-capacity mailbox (a warmed Google Workspace account allows `2000`/day, Microsoft 365 more) is not artificially blocked, and the dashboard shows a warning on anything above `100`. A high cap only raises the ceiling: the campaign's own daily limit, the ramp, sending behaviour, and your workspace's daily send limit all still apply, and the smallest one wins. The minimum gap is a throughput bound of its own: at the default `600s` a mailbox tops out around `144` sends in a `24`-hour window, so a cap above that only takes effect together with a shorter gap. + - A new mailbox has no reputation. Sudden high volume from a cold inbox is one of the fastest ways to land in spam. + A new mailbox has no reputation. Sudden high volume from a cold inbox is one of the fastest ways to land in spam. Scaling cold outreach means adding mailboxes, not cranking one mailbox's cap. ### Keeping a copy of sent mail diff --git a/docs/content/docs/guides/segments.mdx b/docs/content/docs/guides/segments.mdx index c274c237..ba84cdbc 100644 --- a/docs/content/docs/guides/segments.mdx +++ b/docs/content/docs/guides/segments.mdx @@ -52,6 +52,12 @@ Sequences can pin as well: the **Add to segment** and **Remove from segment** ac - **Duplicate**: the segment menu copies a definition to start a variation from. - **Search and export**: the contact search and export accept `segment_ids`, so anything that takes a contact filter can be scoped to a segment. +## Segments in the API + +Everything above can be driven from the [API](/api/reference/contacts/#segments): list, create, update and delete segments, pin contacts in or out, look up a contact's segments, and enrol a segment into a campaign. Contact search and export take `segment_ids` to scope any contact query to a segment, so an external system (a signup form, a CRM sync) can keep a segment current and let campaigns pick it up from there. + +API calls address a segment by its ID. It is shown at the bottom of the segment page header (click it to copy), in the **Copy segment ID** entry of a segment's row menu on the Segments tab, and in every segment the API returns. Reads take the `READ_CONTACTS` key scope, writes `WRITE_CONTACTS`, and enrolling into a campaign `WRITE_CAMPAIGNS`. + Categories are labels you put on a contact. Segments are rules that read those labels (and everything else) to decide who belongs. Use a category to mark a fact about a contact, and a segment to describe an audience. @@ -68,4 +74,5 @@ Categories are labels you put on a contact. Segments are rules that read those l + diff --git a/docs/content/docs/learn/deliverability.mdx b/docs/content/docs/learn/deliverability.mdx index a9d9db5c..aa74ea63 100644 --- a/docs/content/docs/learn/deliverability.mdx +++ b/docs/content/docs/learn/deliverability.mdx @@ -76,7 +76,7 @@ Microsoft does not publish a complaint threshold; their position is that Exchang Reputation does not live at the worker. It lives at the IP, the domain, and the From mailbox. A worker that holds 200 mailboxes is 200 reputations, not one. Reasoning about a worker as a unit (`worker.cap = 5000`) ignores where the actual reputation signal accrues. -Warmbly's defaults reflect this: the cold-send cap is per mailbox (50/day by default, raisable to at most 100/day with positive reputation evidence) and the per-send gap is per mailbox (600 seconds). A worker's outbound budget is computed as `Σ mailbox.coldBudget` over its assigned mailboxes. The system surfaces a concentration warning when a shared worker holds more than ~10 actively-sending cold mailboxes at default settings (~500 cold sends/day), because past that point a single bad mailbox poisons the worker's IP for everyone else on it. +Warmbly's defaults reflect this: the cold-send cap is per mailbox (50/day by default; the setting accepts up to 5,000/day for genuinely high-capacity mailboxes, but anything past 100 warrants positive reputation evidence) and the per-send gap is per mailbox (600 seconds). A worker's outbound budget is computed as `Σ mailbox.coldBudget` over its assigned mailboxes. The system surfaces a concentration warning when a shared worker holds more than ~10 actively-sending cold mailboxes at default settings (~500 cold sends/day), because past that point a single bad mailbox poisons the worker's IP for everyone else on it. Scaling volume up means adding mailboxes, not raising per-mailbox caps. A campaign that wants 5,000 cold sends/day should be assigned to 100+ healthy mailboxes at the 50/day default, not 5 mailboxes pushed to 1,000/day. The first plan is invisible to any anomaly heuristic; the second is the textbook example. @@ -90,7 +90,7 @@ Scaling volume up means adding mailboxes, not raising per-mailbox caps. A campai 1. Use a sub-domain for cold sending (outreach.acme.com). Reputation issues stay scoped. 2. Configure SPF, DKIM and DMARC on the sub-domain. Start at `p=none`, then quarantine after 2 weeks. 3. Warm every cold sending mailbox for at least 3 weeks before campaigns start. -4. Cap each mailbox at 50 cold emails per day (up to 100 only with proven reputation) with a 10-minute minimum gap. +4. Cap each mailbox at 50 cold emails per day (higher only with proven reputation) with a 10-minute minimum gap. 5. Spread campaign sends across multiple mailboxes. Do not concentrate volume. 6. Plain text by default. No tracking pixels unless you need opens. 7. Two or three follow-ups, not eight. @@ -102,7 +102,7 @@ The sub-domain isolation matters because reputation cascades up the organisation The DMARC ramp matters: start at `p=none; rua=mailto:dmarc@yourdomain`, watch reports for 14 days to confirm no third-party services were quietly relying on your domain (this is the most common surprise), then move to `p=quarantine; pct=10` for a week, then increase pct to 100, then `p=reject`. Skipping `pct` ramping is how legitimate mail gets quarantined by an aggressive DMARC change. -The 50/day default per-mailbox cap (raisable to 100/day only with positive reputation evidence) and the 600-second gap are not magic numbers. They are derived from observing that mailboxes which send a new message every 10+ minutes look indistinguishable from human typing cadence to the receiver, and from the empirical observation that Gmail's anomaly model starts flagging mailboxes sending more than ~150/day from a previously low baseline. +The 50/day default per-mailbox cap (raisable only with positive reputation evidence) and the 600-second gap are not magic numbers. They are derived from observing that mailboxes which send a new message every 10+ minutes look indistinguishable from human typing cadence to the receiver, and from the empirical observation that Gmail's anomaly model starts flagging mailboxes sending more than ~150/day from a previously low baseline. ## What to measure diff --git a/integrations/zapier/src/resources/mailboxes.ts b/integrations/zapier/src/resources/mailboxes.ts index cd901880..ffa9142e 100644 --- a/integrations/zapier/src/resources/mailboxes.ts +++ b/integrations/zapier/src/resources/mailboxes.ts @@ -142,7 +142,7 @@ const updateMailbox = { inputFields: [ mailboxField, { key: 'name', label: 'Display name', type: 'string' }, - { key: 'campaign_limit', label: 'Daily campaign cap', type: 'integer', helpText: '3 to 100.' }, + { key: 'campaign_limit', label: 'Daily campaign cap', type: 'integer', helpText: '0 to 5000. Default 50; 30 to 50 per day is the safe cold-outreach band.' }, { key: 'min_wait_time', label: 'Minimum gap between sends (seconds)', type: 'integer' }, { key: 'reply_to', label: 'Reply-to address', type: 'string' }, ], diff --git a/internal/api/handler/email_onboarding.go b/internal/api/handler/email_onboarding.go index a82102a4..e4a03688 100644 --- a/internal/api/handler/email_onboarding.go +++ b/internal/api/handler/email_onboarding.go @@ -4,6 +4,7 @@ import ( "net/http" "github.com/gin-gonic/gin" + "github.com/google/uuid" "github.com/warmbly/warmbly/internal/api/middleware" "github.com/warmbly/warmbly/internal/errx" "github.com/warmbly/warmbly/internal/models" @@ -60,18 +61,94 @@ func (h *Handler) FinishEmailOAuth(c *gin.Context) { return } - acc, xerr := h.EmailService.OAuthFinish(c.Request.Context(), userIDStr, req.Code, req.State) + acc, reauthed, xerr := h.EmailService.OAuthFinish(c.Request.Context(), userIDStr, req.Code, req.State) if xerr != nil { errx.Handle(c, xerr) return } - h.auditOrg(c, models.AuditActionConnect, models.AuditEntityEmailAccount, &acc.ID, nil, map[string]string{ + // A reauth round trip updated an existing mailbox rather than creating one. + action, status := models.AuditActionConnect, http.StatusCreated + if reauthed { + action, status = models.AuditActionUpdate, http.StatusOK + } + h.auditOrg(c, action, models.AuditEntityEmailAccount, &acc.ID, nil, map[string]string{ "provider": acc.Provider, "email": acc.Email, }) - c.JSON(http.StatusCreated, acc) + c.JSON(status, acc) +} + +// ReauthEmailOAuth starts an OAuth round trip that renews the tokens of an +// existing mailbox after the provider invalidated them (issue #274). The +// finish leg is the ordinary FinishEmailOAuth. +func (h *Handler) ReauthEmailOAuth(c *gin.Context) { + userID := middleware.GetUserID(c) + orgID := middleware.GetOrganizationID(c) + if orgID == nil { + errx.Handle(c, errx.ErrNoOrganization) + return + } + + id, err := uuid.Parse(c.Param("id")) + if err != nil { + errx.Handle(c, errx.ErrUuid) + return + } + + resp, xerr := h.EmailService.OAuthReauth(c.Request.Context(), userID, orgID, id) + if xerr != nil { + errx.Handle(c, xerr) + return + } + + c.JSON(http.StatusOK, resp) +} + +// OnboardingSMTPIMAPCredentials carries replacement credentials for an +// existing SMTP/IMAP mailbox; the account fields never change on a reauth. +type OnboardingSMTPIMAPCredentials struct { + SMTP *models.Service `json:"smtp"` + IMAP *models.Service `json:"imap"` +} + +// UpdateEmailSMTPIMAP replaces an SMTP/IMAP mailbox's credentials after a +// password change, validating them live before storing, and reactivates it. +func (h *Handler) UpdateEmailSMTPIMAP(c *gin.Context) { + orgID := middleware.GetOrganizationID(c) + if orgID == nil { + errx.Handle(c, errx.ErrNoOrganization) + return + } + + id, err := uuid.Parse(c.Param("id")) + if err != nil { + errx.Handle(c, errx.ErrUuid) + return + } + + var req OnboardingSMTPIMAPCredentials + if err := c.ShouldBindJSON(&req); err != nil { + errx.Handle(c, errx.ErrInvalid) + return + } + + acc, xerr := h.EmailService.UpdateSMTPIMAPCredentials(c.Request.Context(), orgID, id, &models.SmtpImap{ + SMTP: req.SMTP, + IMAP: req.IMAP, + }) + if xerr != nil { + errx.Handle(c, xerr) + return + } + + h.auditOrg(c, models.AuditActionUpdate, models.AuditEntityEmailAccount, &acc.ID, nil, map[string]string{ + "provider": "smtp_imap", + "email": acc.Email, + }) + + c.JSON(http.StatusOK, acc) } func (h *Handler) ConnectEmailSMTPIMAP(c *gin.Context) { diff --git a/internal/api/routes.go b/internal/api/routes.go index 7afdcf3b..87f1ab16 100644 --- a/internal/api/routes.go +++ b/internal/api/routes.go @@ -432,6 +432,12 @@ func Run( onboardingEmails.POST("/oauth/start", h.StartEmailOAuth) onboardingEmails.POST("/oauth/finish", h.FinishEmailOAuth) onboardingEmails.POST("/smtp-imap", h.ConnectEmailSMTPIMAP) + // 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 + // manage-emails bar as PATCH /emails/:id. + onboardingEmails.POST("/oauth/reauth/:id", m.RequireOrganization(), m.RequirePermission(models.PermManageEmails), h.ReauthEmailOAuth) + onboardingEmails.PUT("/smtp-imap/:id", m.RequireOrganization(), m.RequirePermission(models.PermManageEmails), h.UpdateEmailSMTPIMAP) } // Integration OAuth handshake is JWT-only — it writes user-encrypted diff --git a/internal/app/aitools/tools_mailboxes.go b/internal/app/aitools/tools_mailboxes.go index f9da56e6..77a467e7 100644 --- a/internal/app/aitools/tools_mailboxes.go +++ b/internal/app/aitools/tools_mailboxes.go @@ -37,7 +37,7 @@ func (d Deps) registerMailboxTools(r *Registry) { "name": strProp("Display name."), "reply_to": strProp("Reply-to address."), "status": enumProp("Mailbox status.", "active", "inactive"), - "campaign_limit": intProp("Max cold-campaign emails per day for this mailbox."), + "campaign_limit": intProp("Max cold-campaign emails per day for this mailbox, 0 to 5000. Default 50; 30-50/day is the safe cold-outreach band."), "min_wait_time": intProp("Minimum seconds between sends."), "warmup": boolProp("Enable or disable warmup."), "warmup_base": intProp("Warmup starting emails/day."), diff --git a/internal/app/email/onboarding.go b/internal/app/email/onboarding.go index 553319db..2ee127d4 100644 --- a/internal/app/email/onboarding.go +++ b/internal/app/email/onboarding.go @@ -100,48 +100,57 @@ func (s *emailService) guardInboxLimit(ctx context.Context, orgID *uuid.UUID) *e return errx.ErrEmailOnboardInboxLimit } -// OAuthFinish validates the state, exchanges the code for tokens, fetches the inbox owner, -// and persists a new email account. -func (s *emailService) OAuthFinish(ctx context.Context, userID, code, state string) (*models.Email, *errx.Error) { +// OAuthFinish validates the state, exchanges the code for tokens, fetches the +// inbox owner, and persists a new email account — or, when the state carries an +// account id (OAuthReauth), renews that mailbox's tokens in place instead. +func (s *emailService) OAuthFinish(ctx context.Context, userID, code, state string) (*models.Email, bool, *errx.Error) { if code = strings.TrimSpace(code); code == "" { - return nil, errx.ErrEmailOnboardCode + return nil, false, errx.ErrEmailOnboardCode } if state = strings.TrimSpace(state); state == "" { - return nil, errx.ErrEmailOnboardState + return nil, false, errx.ErrEmailOnboardState } sess, xerr := s.takeOnboardingState(ctx, state) if xerr != nil { - return nil, xerr + return nil, false, xerr } if sess.UserID != userID { - return nil, errx.ErrEmailOnboardState + return nil, false, errx.ErrEmailOnboardState } - if xerr := s.guardInboxLimit(ctx, sess.OrganizationID); xerr != nil { - return nil, xerr + // A reauth adds no mailbox, so an org over its inbox cap can still fix one. + if sess.EmailAccountID == nil { + if xerr := s.guardInboxLimit(ctx, sess.OrganizationID); xerr != nil { + return nil, false, xerr + } } provider := models.InboxProvider(sess.Provider) cfg, xerr := s.oauthConfigFor(provider) if xerr != nil { - return nil, xerr + return nil, false, xerr } tok, err := cfg.Exchange(ctx, code) if err != nil { - return nil, errx.ErrEmailOnboardExchange + return nil, false, errx.ErrEmailOnboardExchange } owner, xerr := fetchInboxOwner(ctx, provider, tok.AccessToken) if xerr != nil { - return nil, xerr + return nil, false, xerr + } + + if sess.EmailAccountID != nil { + acc, xerr := s.finishReauth(ctx, sess, provider, tok, owner) + return acc, true, xerr } if exists, xerr := s.emailRepository.ExistsForUser(ctx, userID, owner.Email); xerr != nil { - return nil, xerr + return nil, false, xerr } else if exists { - return nil, errx.ErrEmailOnboardAlreadyExists + return nil, false, errx.ErrEmailOnboardAlreadyExists } name := strings.TrimSpace(owner.Name) @@ -150,7 +159,7 @@ func (s *emailService) OAuthFinish(ctx context.Context, userID, code, state stri } if xerr := s.guardMailboxThrottle(ctx, sess.OrganizationID); xerr != nil { - return nil, xerr + return nil, false, xerr } acc, xerr := s.emailRepository.NewOauthAccount(ctx, userID, models.NewOauthAccount{ @@ -170,7 +179,7 @@ func (s *emailService) OAuthFinish(ctx context.Context, userID, code, state stri // immediately; the reconciler is the fallback if this fails. s.loadAccountBestEffort(ctx, acc.ID) } - return acc, xerr + return acc, false, xerr } // OnboardSMTPIMAP validates the supplied SMTP/IMAP credentials against a live worker, then diff --git a/internal/app/email/reauth.go b/internal/app/email/reauth.go new file mode 100644 index 00000000..23660230 --- /dev/null +++ b/internal/app/email/reauth.go @@ -0,0 +1,231 @@ +package email + +// Reconnecting a broken mailbox (issue #274): a provider-side credential change +// (password reset, revoked grant, expired app password) deactivates the account +// and leaves an error row behind. The flows here renew the credential in place, +// resolve exactly the errors that credential caused, and put the mailbox back +// to work — never creating a second account for the same address. + +import ( + "context" + "strings" + + "github.com/getsentry/sentry-go" + "github.com/google/uuid" + "github.com/rs/zerolog/log" + "github.com/warmbly/warmbly/internal/errx" + "github.com/warmbly/warmbly/internal/models" + "github.com/warmbly/warmbly/internal/pkg/crypt" + "golang.org/x/oauth2" +) + +// OAuthReauth issues an authorization URL that renews an existing mailbox's +// tokens. Same round trip as OAuthStart, but the state carries the account id +// so the finish leg updates in place instead of connecting a duplicate. +func (s *emailService) OAuthReauth(ctx context.Context, userID string, orgID *uuid.UUID, accountID uuid.UUID) (*models.EmailOnboardingStartResponse, *errx.Error) { + if orgID == nil { + return nil, errx.ErrNoOrganization + } + + account, xerr := s.emailRepository.Get(ctx, orgID.String(), accountID.String()) + if xerr != nil { + return nil, xerr + } + if account == nil { + return nil, errx.ErrNotFound + } + + provider := models.InboxProvider(account.Provider) + if provider == models.InboxProviderSMTPIMAP { + return nil, errx.ErrEmailReauthProvider + } + // A cloud-managed mailbox has no local token row to renew; its sign-in + // lives on Warmbly Cloud. + if s.cloudLink != nil { + if m, err := s.cloudLink.GetByAccount(ctx, accountID); err == nil && m != nil && m.Managed { + return nil, errx.ErrEmailReauthCloudManaged + } + } + + cfg, xerr := s.oauthConfigFor(provider) + if xerr != nil { + return nil, xerr + } + + state, err := crypt.Nonce() + if err != nil { + sentry.CaptureException(err) + return nil, errx.InternalError() + } + + if xerr := s.saveOnboardingState(ctx, state, &models.EmailOnboardingState{ + UserID: userID, + OrganizationID: orgID, + Provider: string(provider), + Nonce: state, + EmailAccountID: &accountID, + }); xerr != nil { + return nil, xerr + } + + url := cfg.AuthCodeURL( + state, + oauth2.AccessTypeOffline, + oauth2.ApprovalForce, // force refresh_token issuance on reconnect + // Preselect the mailbox being renewed in the provider's picker. + oauth2.SetAuthURLParam("login_hint", account.Email), + ) + return &models.EmailOnboardingStartResponse{URL: url, State: state}, nil +} + +// finishReauth lands a reauth round trip: same-address check, token rewrite, +// error resolution, reactivation. +func (s *emailService) finishReauth(ctx context.Context, sess *models.EmailOnboardingState, provider models.InboxProvider, tok *oauth2.Token, owner *inboxOwner) (*models.Email, *errx.Error) { + account, xerr := s.emailRepository.GetByID(ctx, *sess.EmailAccountID) + if xerr != nil { + return nil, xerr + } + if account == nil || sess.OrganizationID == nil || account.OrganizationID == nil || *account.OrganizationID != *sess.OrganizationID { + return nil, errx.ErrNotFound + } + if models.InboxProvider(account.Provider) != provider { + return nil, errx.ErrEmailOnboardProvider + } + + // The consent must be for this mailbox's own address: tokens for any other + // account would read as connected and then fail every send and sync. + if !strings.EqualFold(strings.TrimSpace(owner.Email), strings.TrimSpace(account.Email)) { + return nil, errx.ErrEmailReauthWrongAccount + } + + // A repeat consent may omit the refresh token; keep the stored one rather + // than blanking the row. Writing without one would seal an empty string + // over the stored token and end all future access-token refreshes, so a + // failed fallback read refuses the reauth instead. + refresh := tok.RefreshToken + if refresh == "" { + creds, cerr := s.emailRepository.GetOAuthCredentials(ctx, account.ID) + if cerr != nil { + return nil, cerr + } + if creds == nil || creds.RefreshToken == "" { + return nil, errx.ErrEmailReauthNoRefreshToken + } + refresh = creds.RefreshToken + } + + if err := s.emailRepository.RefreshBoxToken(ctx, account.ID, tok.AccessToken, refresh, tok.Expiry); err != nil { + return nil, errx.InternalError() + } + + return s.reconnectAccount(ctx, account.ID) +} + +// UpdateSMTPIMAPCredentials is the SMTP/IMAP counterpart of the OAuth reauth: +// validate the replacement credentials against a live worker, store them, and +// put the mailbox back to work. +func (s *emailService) UpdateSMTPIMAPCredentials(ctx context.Context, orgID *uuid.UUID, accountID uuid.UUID, creds *models.SmtpImap) (*models.Email, *errx.Error) { + if orgID == nil { + return nil, errx.ErrNoOrganization + } + + // GetByID, not the org-scoped Get: the reconnect tail needs the owner's + // user id, which Get does not select. Tenancy is enforced right below. + account, xerr := s.emailRepository.GetByID(ctx, accountID) + if xerr != nil { + return nil, xerr + } + if account == nil || account.OrganizationID == nil || *account.OrganizationID != *orgID { + return nil, errx.ErrNotFound + } + if models.InboxProvider(account.Provider) != models.InboxProviderSMTPIMAP { + return nil, errx.ErrEmailReauthOAuthOnly + } + + if xerr := validateSMTPIMAPCredentials(creds); xerr != nil { + return nil, xerr + } + + if s.workerAssignment == nil { + return nil, errx.ErrEmailOnboardNoWorker + } + // Any healthy worker can run the one-shot validation handshake, same as at + // connect time; tier only matters for placement. + w, werr := s.workerAssignment.SelectSharedWorker(ctx, false) + if werr != nil || w == nil { + w, werr = s.workerAssignment.SelectSharedWorker(ctx, true) + } + if werr != nil || w == nil { + return nil, errx.ErrEmailOnboardNoWorker + } + if xerr := s.ValidateCredentials(ctx, *orgID, w.ID.String(), creds); xerr != nil { + return nil, xerr + } + + if err := s.emailRepository.ReplaceSMTPIMAPCredentials(ctx, accountID, creds); err != nil { + return nil, errx.InternalError() + } + + return s.reconnectAccount(ctx, accountID) +} + +// reconnectAccount is the shared tail of both reconnect flows: reactivate, +// then resolve the credential errors the new secret just fixed — Update +// carries the status through pool membership, the worker, and the realtime +// fanout. Errors resolve only after a successful reactivation, or a failed +// Update would clear the banner (and its reconnect button) while the mailbox +// stays broken. It loads the row itself because the owner-scoped Update needs +// user_id, which not every caller's read path selects. +func (s *emailService) reconnectAccount(ctx context.Context, accountID uuid.UUID) (*models.Email, *errx.Error) { + account, xerr := s.emailRepository.GetByID(ctx, accountID) + if xerr != nil { + return nil, xerr + } + if account == nil { + return nil, errx.ErrNotFound + } + status := "active" + updated, xerr := s.Update(ctx, account.UserID, account.ID.String(), &models.UpdateEmail{Status: &status}) + if xerr != nil { + return nil, xerr + } + s.resolveCredentialErrors(ctx, account.ID) + return updated, nil +} + +// resolveCredentialErrors clears the credential-class error rows; unrelated +// errors (domain auth, sync fair use) stay visible because a reconnect does +// not fix them. +func (s *emailService) resolveCredentialErrors(ctx context.Context, accountID uuid.UUID) { + if s.accountErrors == nil { + return + } + codes := make([]string, 0, len(errx.CredentialMailErrorCodes)) + for _, c := range errx.CredentialMailErrorCodes { + codes = append(codes, string(c)) + } + if xerr := s.accountErrors.ResolveByCodes(ctx, accountID, codes, "reconnect"); xerr != nil { + log.Warn().Str("account_id", accountID.String()).Str("error", xerr.Message).Msg("could not resolve credential errors after reconnect") + } +} + +// validateSMTPIMAPCredentials checks a replacement credential set: same bar as +// validateSMTPIMAPInput minus the account fields, which a reauth never changes. +func validateSMTPIMAPCredentials(creds *models.SmtpImap) *errx.Error { + if creds == nil || creds.SMTP == nil || creds.IMAP == nil { + return errx.ErrEmailCredentialsRequired + } + if strings.TrimSpace(creds.SMTP.Host) == "" { + return errx.ErrEmailSMTPHost + } + if creds.SMTP.Port != 465 && creds.SMTP.Port != 587 { + return errx.ErrEmailSMTPPort + } + if strings.TrimSpace(creds.IMAP.Host) == "" { + return errx.ErrEmailIMAPHost + } + if creds.IMAP.Port <= 0 { + return errx.ErrEmailIMAPPort + } + return nil +} diff --git a/internal/app/email/reauth_test.go b/internal/app/email/reauth_test.go new file mode 100644 index 00000000..242c6de0 --- /dev/null +++ b/internal/app/email/reauth_test.go @@ -0,0 +1,196 @@ +package email + +import ( + "context" + "testing" + "time" + + "github.com/google/uuid" + "github.com/warmbly/warmbly/internal/errx" + "github.com/warmbly/warmbly/internal/models" + "github.com/warmbly/warmbly/internal/repository" + "golang.org/x/oauth2" +) + +// stubReauthRepo serves one mailbox and records the reconnect writes. +type stubReauthRepo struct { + repository.EmailRepository + + account *models.Email + storedRefresh string + updateErr *errx.Error + + wroteAccess string + wroteRefresh string + updated *models.UpdateEmail +} + +func (s *stubReauthRepo) GetByID(ctx context.Context, emailAccountID uuid.UUID) (*models.Email, *errx.Error) { + return s.account, nil +} + +func (s *stubReauthRepo) Get(ctx context.Context, orgID, emailAccountID string) (*models.Email, *errx.Error) { + // The real org-scoped Get does not select user_id or organization_id; + // mimic that so a caller depending on them fails here too (it did once). + partial := *s.account + partial.UserID = "" + partial.OrganizationID = nil + return &partial, nil +} + +func (s *stubReauthRepo) GetOAuthCredentials(ctx context.Context, emailAccountID uuid.UUID) (*repository.OAuthCredentials, *errx.Error) { + return &repository.OAuthCredentials{RefreshToken: s.storedRefresh}, nil +} + +func (s *stubReauthRepo) RefreshBoxToken(ctx context.Context, id uuid.UUID, accessToken, refreshToken string, expiresAt time.Time) error { + s.wroteAccess = accessToken + s.wroteRefresh = refreshToken + return nil +} + +func (s *stubReauthRepo) Update(ctx context.Context, userID, emailAccountID string, udata *models.UpdateEmail) (*models.Email, *errx.Error) { + if s.updateErr != nil { + return nil, s.updateErr + } + s.updated = udata + return s.account, nil +} + +// stubErrorsRepo records which error codes a reconnect resolved. +type stubErrorsRepo struct { + repository.EmailAccountErrorRepository + + resolved []string +} + +func (s *stubErrorsRepo) ResolveByCodes(ctx context.Context, accountID uuid.UUID, codes []string, resolvedBy string) *errx.Error { + s.resolved = append(s.resolved, codes...) + return nil +} + +func reauthFixture(provider, email string) (*emailService, *stubReauthRepo, *stubErrorsRepo, *models.EmailOnboardingState) { + org := uuid.New() + accountID := uuid.New() + repo := &stubReauthRepo{ + account: &models.Email{ + ID: accountID, + UserID: uuid.NewString(), + OrganizationID: &org, + Email: email, + Provider: provider, + Status: "inactive", + }, + storedRefresh: "stored-refresh", + } + errs := &stubErrorsRepo{} + svc := &emailService{emailRepository: repo, accountErrors: errs} + sess := &models.EmailOnboardingState{ + UserID: repo.account.UserID, + OrganizationID: &org, + Provider: provider, + EmailAccountID: &accountID, + } + return svc, repo, errs, sess +} + +func TestFinishReauth_WrongAccountIsRefused(t *testing.T) { + svc, repo, _, sess := reauthFixture("gmail", "owner@example.com") + + tok := &oauth2.Token{AccessToken: "new-access", RefreshToken: "new-refresh"} + _, xerr := svc.finishReauth(context.Background(), sess, models.InboxProviderGoogle, tok, &inboxOwner{Email: "somebody-else@example.com"}) + if xerr != errx.ErrEmailReauthWrongAccount { + t.Fatalf("expected ErrEmailReauthWrongAccount, got %v", xerr) + } + if repo.wroteAccess != "" || repo.updated != nil { + t.Fatalf("a refused reauth must write nothing (access %q, update %v)", repo.wroteAccess, repo.updated) + } +} + +func TestFinishReauth_UpdatesTokensResolvesErrorsAndReactivates(t *testing.T) { + svc, repo, errs, sess := reauthFixture("gmail", "owner@example.com") + + tok := &oauth2.Token{AccessToken: "new-access", RefreshToken: "new-refresh"} + // The consent address matches case-insensitively, as addresses do. + if _, xerr := svc.finishReauth(context.Background(), sess, models.InboxProviderGoogle, tok, &inboxOwner{Email: "Owner@Example.com"}); xerr != nil { + t.Fatalf("finishReauth: %v", xerr) + } + if repo.wroteAccess != "new-access" || repo.wroteRefresh != "new-refresh" { + t.Fatalf("tokens not written: access %q refresh %q", repo.wroteAccess, repo.wroteRefresh) + } + if repo.updated == nil || repo.updated.Status == nil || *repo.updated.Status != "active" { + t.Fatalf("reauth must reactivate the mailbox, got %+v", repo.updated) + } + want := map[string]bool{} + for _, c := range errx.CredentialMailErrorCodes { + want[string(c)] = true + } + for _, c := range errs.resolved { + delete(want, c) + } + if len(errs.resolved) == 0 || len(want) != 0 { + t.Fatalf("credential errors not resolved: got %v", errs.resolved) + } +} + +func TestFinishReauth_KeepsStoredRefreshTokenWhenProviderOmitsIt(t *testing.T) { + svc, repo, _, sess := reauthFixture("gmail", "owner@example.com") + + tok := &oauth2.Token{AccessToken: "new-access"} // no refresh token on repeat consent + if _, xerr := svc.finishReauth(context.Background(), sess, models.InboxProviderGoogle, tok, &inboxOwner{Email: "owner@example.com"}); xerr != nil { + t.Fatalf("finishReauth: %v", xerr) + } + if repo.wroteRefresh != "stored-refresh" { + t.Fatalf("stored refresh token must be kept, got %q", repo.wroteRefresh) + } +} + +func TestFinishReauth_RefusesWhenNoRefreshTokenAnywhere(t *testing.T) { + svc, repo, _, sess := reauthFixture("gmail", "owner@example.com") + repo.storedRefresh = "" + + tok := &oauth2.Token{AccessToken: "new-access"} // provider omitted it, nothing stored + _, xerr := svc.finishReauth(context.Background(), sess, models.InboxProviderGoogle, tok, &inboxOwner{Email: "owner@example.com"}) + if xerr != errx.ErrEmailReauthNoRefreshToken { + t.Fatalf("expected ErrEmailReauthNoRefreshToken, got %v", xerr) + } + if repo.wroteAccess != "" { + t.Fatalf("must not seal an empty refresh token over the stored row") + } +} + +func TestFinishReauth_KeepsErrorsWhenReactivationFails(t *testing.T) { + svc, repo, errs, sess := reauthFixture("gmail", "owner@example.com") + repo.updateErr = errx.InternalError() + + tok := &oauth2.Token{AccessToken: "new-access", RefreshToken: "new-refresh"} + _, xerr := svc.finishReauth(context.Background(), sess, models.InboxProviderGoogle, tok, &inboxOwner{Email: "owner@example.com"}) + if xerr == nil { + t.Fatal("expected the failed reactivation to surface") + } + // The banner (and its reconnect button) must survive a failed reactivation. + if len(errs.resolved) != 0 { + t.Fatalf("errors must stay unresolved when reactivation fails, resolved %v", errs.resolved) + } +} + +func TestOAuthReauth_RefusesSMTPIMAPMailboxes(t *testing.T) { + svc, repo, _, _ := reauthFixture("smtp_imap", "owner@example.com") + + _, xerr := svc.OAuthReauth(context.Background(), repo.account.UserID, repo.account.OrganizationID, repo.account.ID) + if xerr != errx.ErrEmailReauthProvider { + t.Fatalf("expected ErrEmailReauthProvider, got %v", xerr) + } +} + +func TestUpdateSMTPIMAPCredentials_RefusesOAuthMailboxes(t *testing.T) { + svc, repo, _, _ := reauthFixture("gmail", "owner@example.com") + + creds := &models.SmtpImap{ + SMTP: &models.Service{Host: "smtp.example.com", Port: 587}, + IMAP: &models.Service{Host: "imap.example.com", Port: 993}, + } + _, xerr := svc.UpdateSMTPIMAPCredentials(context.Background(), repo.account.OrganizationID, repo.account.ID, creds) + if xerr != errx.ErrEmailReauthOAuthOnly { + t.Fatalf("expected ErrEmailReauthOAuthOnly, got %v", xerr) + } +} diff --git a/internal/app/email/service.go b/internal/app/email/service.go index e06e8e58..bd880b47 100644 --- a/internal/app/email/service.go +++ b/internal/app/email/service.go @@ -59,10 +59,18 @@ type EmailService interface { RefreshDomainAuth(ctx context.Context, orgID, emailAccountID string) (*dnsauth.Result, *errx.Error) Delete(ctx context.Context, userID, emailAccountID string) *errx.Error - // Onboarding flow + // Onboarding flow. OAuthFinish's second return is true when the round + // trip renewed an existing mailbox (OAuthReauth) rather than connecting + // a new one, so the handler can audit and answer accordingly. 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, *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) + // 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) + // UpdateSMTPIMAPCredentials validates replacement credentials against a + // live worker, stores them, and puts the mailbox back to work. + UpdateSMTPIMAPCredentials(ctx context.Context, orgID *uuid.UUID, accountID uuid.UUID, creds *models.SmtpImap) (*models.Email, *errx.Error) // Optional: wire in the webhook dispatcher after construction. Once // set, account-lifecycle events fan out to customer webhook endpoints. @@ -84,6 +92,9 @@ type EmailService interface { WirePoolLink(repo repository.PoolLinkRepository) // WireCloudLink marks managed mailboxes, which ship to the worker without a credential. WireCloudLink(repo repository.CloudLinkRepository) + // WireAccountErrors lets a successful reconnect resolve the credential + // errors it just fixed, which is what clears the mailbox's error banner. + WireAccountErrors(repo repository.EmailAccountErrorRepository) // Brokered OAuth (cloud side): consent on this deployment's OAuth app for a linked instance. OAuthAuthorizeURL(provider models.InboxProvider, state string) (string, *errx.Error) OAuthConnectWithCode(ctx context.Context, userID string, orgID *uuid.UUID, provider models.InboxProvider, code string) (*models.Email, *errx.Error) @@ -132,6 +143,13 @@ type emailService struct { orgRiskRepo repository.OrgRiskRepository // lifecycleRepo backs the owner's hold; without it SetSendHold refuses. lifecycleRepo repository.SendLifecycleRepository + // accountErrors is resolved-on-reconnect error state. Optional/nil-safe. + accountErrors repository.EmailAccountErrorRepository +} + +// WireAccountErrors attaches the mailbox error log so reconnects can resolve it. +func (s *emailService) WireAccountErrors(repo repository.EmailAccountErrorRepository) { + s.accountErrors = repo } // WireLifecycle attaches the cold-sending lifecycle. diff --git a/internal/app/unibox/backfill_body_text.go b/internal/app/unibox/backfill_body_text.go index 950bd00b..0ad67408 100644 --- a/internal/app/unibox/backfill_body_text.go +++ b/internal/app/unibox/backfill_body_text.go @@ -53,7 +53,7 @@ func (s *uniboxService) StartBodyTextBackfill(ctx context.Context) { for _, t := range targets { cursor = t.ID - body, gerr := s.GetBody(ctx, t.UserID, t.ID) + body, gerr := s.GetBody(ctx, t.UserID, t.EmailID, t.ID) if gerr != nil { // A missing blob is expected here: fixtures and mail synced // before body storage existed have no object to read. diff --git a/internal/app/unibox/email.go b/internal/app/unibox/email.go index 7833d41e..9f25d4dc 100644 --- a/internal/app/unibox/email.go +++ b/internal/app/unibox/email.go @@ -19,10 +19,9 @@ func (s *uniboxService) GetByID( var snippet string var fixtureMessage bool - // ownerID is the mailbox owner's user_id. The S3 body key is built from it - // (emails//), so the body must be fetched under the owner even - // when a different teammate opens the message via the org-scoped read. - var ownerID uuid.UUID + // The body's object-storage key is built from the mailbox owner and + // account, not the caller, who may be any teammate on the org-scoped read. + var ownerID, accountID uuid.UUID // Fetch email data by id index { @@ -32,6 +31,7 @@ func (s *uniboxService) GetByID( return nil, errx.InternalError() } ownerID = owner + accountID = msg.EmailID resp.ID = msg.ID resp.GmailID = msg.GmailID resp.UID = msg.UID @@ -58,10 +58,9 @@ func (s *uniboxService) GetByID( fixtureMessage = isFixtureMessage(msg.MessageID) } - // Fetch body from s3 storage. Keyed by the mailbox OWNER's user_id, not the - // caller's: the key is emails//. + // Fetch body from object storage under the mailbox owner and account. { - out, err := s.GetBody(ctx, ownerID, id) + out, err := s.GetBody(ctx, ownerID, accountID, id) if err != nil { // A missing blob is a degraded read, not a broken endpoint: mail // synced before body storage existed, or a blob that never landed, diff --git a/internal/app/unibox/storage.go b/internal/app/unibox/storage.go index f059c42d..8dd88df3 100644 --- a/internal/app/unibox/storage.go +++ b/internal/app/unibox/storage.go @@ -1,23 +1,21 @@ package unibox import ( - "bytes" "context" "github.com/google/uuid" + "github.com/warmbly/warmbly/internal/config" "github.com/warmbly/warmbly/internal/pkg/emsg" ) -func GetEmailKey(userID, id uuid.UUID) string { - return "emails/" + userID.String() + "/" + id.String() - -} - +// GetBody reads a message's full body blob. The key must match what the +// worker's StoreBody writes; any other key finds nothing and the message +// degrades to its one-line snippet. func (s *uniboxService) GetBody( ctx context.Context, - userID, id uuid.UUID, + userID, emailID, id uuid.UUID, ) (*emsg.EmailBlob, error) { - key := GetEmailKey(userID, id) + key := config.StorageEndpointEmailBody(userID, emailID, id) body, err := s.blob.Get(ctx, key) if err != nil { return nil, err @@ -31,24 +29,3 @@ func (s *uniboxService) GetBody( return obj, nil } - -func (s *uniboxService) PutBody( - ctx context.Context, - userID, id uuid.UUID, - plainText string, - htmlText string, -) error { - key := GetEmailKey(userID, id) - - blob := &emsg.EmailBlob{ - PlainText: []byte(plainText), - HTMLBody: []byte(htmlText), - } - - body, err := blob.EncodeBinary() - if err != nil { - return err - } - - return s.blob.Put(ctx, key, bytes.NewReader(body), "") -} diff --git a/internal/config/constants.go b/internal/config/constants.go index 215deeb6..2e79faf3 100644 --- a/internal/config/constants.go +++ b/internal/config/constants.go @@ -3,8 +3,14 @@ package config const ( DefaultColor = "#c4c8cf" Domain = "warmbly.com" - LimitMin = 10 - LimitMax = 200 + // LimitMin/LimitMax bound every per-mailbox and per-campaign daily send + // cap the API will store. 5000 covers real provider ceilings (Google + // Workspace 2000/day, M365 10000 recipients/day); the safe cold band + // stays 30-50/day and is steered by defaults, warnings and the advisor. + LimitMin = 0 + LimitMax = 5000 + + CampaignDailyLimitMin = 3 CampaignLimitDefault = 50 MinWaitTimeDefault = 600 diff --git a/internal/errx/common.go b/internal/errx/common.go index f1b91891..27377592 100644 --- a/internal/errx/common.go +++ b/internal/errx/common.go @@ -121,6 +121,11 @@ var ( 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.") + ErrEmailReauthCloudManaged = New(Conflict, "Warmbly Cloud holds this mailbox's sign-in. Reconnect it from your cloud workspace instead.") + ErrEmailReauthNoRefreshToken = New(BadRequest, "The provider did not return a refresh token and none is stored. Please try re-authorizing again.") ErrEmailSMTPHost = New(BadRequest, "SMTP host is required.") ErrEmailSMTPPort = New(BadRequest, "SMTP port must be 465 or 587.") ErrEmailIMAPHost = New(BadRequest, "IMAP host is required.") @@ -131,8 +136,8 @@ var ( ErrEmailName = New(BadRequest, "Invalid name. Must be 2–100 characters and contain only letters, numbers, spaces, '-', '.', or '’'.") ErrEmailSignaturePlain = New(BadRequest, "Plain email signature is too long.") ErrEmailSignatureHTML = New(BadRequest, "HTML email signature is too long.") - ErrEmailMinWaitTime = New(BadRequest, "Minimum time gap between emails must be between 0 and 86400 minutes.") - ErrEmailCampaignLimit = New(BadRequest, "Campaign limit must be between 0 and 100.") + ErrEmailMinWaitTime = New(BadRequest, "Minimum time gap between emails must be between 0 and 86400 seconds.") + ErrEmailCampaignLimit = New(BadRequest, fmt.Sprintf("Campaign limit must be between %d and %d.", config.LimitMin, config.LimitMax)) ErrEmailTimezone = New(BadRequest, "Invalid timezone. Use an IANA name such as Europe/London or America/Denver, or leave it empty to follow the campaign.") ErrEmailWarmupBase = New(BadRequest, "Warmup base must be between 0 and 100.") ErrEmailWarmupMax = New(BadRequest, "Warmup max amount must be between 0 and 100.") @@ -148,7 +153,7 @@ var ( // Campaign ErrCampaignName = New(BadRequest, "Campaign name length must be between 3 and 50 characters.") ErrCampaignDescription = New(BadRequest, "Campaign description length must be below 300 characters.") - ErrCampaignDailyLimit = New(BadRequest, "Daily limit must be between 3 and 10000000.") + ErrCampaignDailyLimit = New(BadRequest, fmt.Sprintf("Daily limit must be between %d and %d.", config.CampaignDailyLimitMin, config.LimitMax)) ErrCampaignStartDate = New(BadRequest, "Start date cannot be in the past. Pick today or later, or clear it (null) to start right away.") ErrCampaignEndDate = New(BadRequest, "End date must be in the future.") ErrCampaignLimit = New(BadRequest, "You reached your limit for campaigns, please try again later.") diff --git a/internal/errx/email.go b/internal/errx/email.go index c02b240e..9d38cb8a 100644 --- a/internal/errx/email.go +++ b/internal/errx/email.go @@ -66,6 +66,15 @@ const ( MailErrorCodeAccountSuspended MailErrorCode = "ACCOUNT_SUSPENDED" ) +// CredentialMailErrorCodes are the credential-class errors a successful +// mailbox re-authorization or SMTP/IMAP credential update fixes. +var CredentialMailErrorCodes = []MailErrorCode{ + MailErrorCodeGoogleAuth, + MailErrorCodeAuthenticationFailed, + MailErrorCodeAuthorizationFailed, + MailErrorCodeInvalidCredentials, +} + var MailErrorCodeGoogleUnknown = func(code int) MailErrorCode { return MailErrorCode(fmt.Sprintf("Unknown (%d)", code)) } diff --git a/internal/infrastructure/db/migrations/000113_raise_ramp_caps.down.sql b/internal/infrastructure/db/migrations/000113_raise_ramp_caps.down.sql new file mode 100644 index 00000000..6af9dce2 --- /dev/null +++ b/internal/infrastructure/db/migrations/000113_raise_ramp_caps.down.sql @@ -0,0 +1,11 @@ +-- Clamp any rows above the old ceiling before restoring the tighter checks. +UPDATE campaigns +SET ramp_start = LEAST(ramp_start, 100), + ramp_ceiling = LEAST(ramp_ceiling, 100) +WHERE ramp_start > 100 OR ramp_ceiling > 100; +ALTER TABLE campaigns DROP CONSTRAINT campaigns_ramp_start_check; +ALTER TABLE campaigns + ADD CONSTRAINT campaigns_ramp_start_check CHECK (ramp_start >= 1 AND ramp_start <= 100); +ALTER TABLE campaigns DROP CONSTRAINT campaigns_ramp_ceiling_check; +ALTER TABLE campaigns + ADD CONSTRAINT campaigns_ramp_ceiling_check CHECK (ramp_ceiling >= 1 AND ramp_ceiling <= 100); diff --git a/internal/infrastructure/db/migrations/000113_raise_ramp_caps.up.sql b/internal/infrastructure/db/migrations/000113_raise_ramp_caps.up.sql new file mode 100644 index 00000000..88fbdda6 --- /dev/null +++ b/internal/infrastructure/db/migrations/000113_raise_ramp_caps.up.sql @@ -0,0 +1,7 @@ +-- Ramp start/ceiling follow the raised send-cap ceiling (config.LimitMax = 5000). +ALTER TABLE campaigns DROP CONSTRAINT campaigns_ramp_start_check; +ALTER TABLE campaigns + ADD CONSTRAINT campaigns_ramp_start_check CHECK (ramp_start >= 1 AND ramp_start <= 5000); +ALTER TABLE campaigns DROP CONSTRAINT campaigns_ramp_ceiling_check; +ALTER TABLE campaigns + ADD CONSTRAINT campaigns_ramp_ceiling_check CHECK (ramp_ceiling >= 1 AND ramp_ceiling <= 5000); diff --git a/internal/models/email.go b/internal/models/email.go index 82ca51e6..d8b3d1fa 100644 --- a/internal/models/email.go +++ b/internal/models/email.go @@ -194,6 +194,9 @@ type EmailOnboardingState struct { OrganizationID *uuid.UUID `json:"organization_id,omitempty"` Provider string `json:"provider"` Nonce string `json:"nonce"` + // EmailAccountID marks a re-authorization round trip: the finish leg + // renews this mailbox's tokens instead of connecting a new one. + EmailAccountID *uuid.UUID `json:"email_account_id,omitempty"` } // EmailOnboardingStartResponse is returned from POST /emails/onboarding/oauth/start. diff --git a/internal/repository/pg_campaign.go b/internal/repository/pg_campaign.go index b9e318fe..ec8f72e7 100644 --- a/internal/repository/pg_campaign.go +++ b/internal/repository/pg_campaign.go @@ -1013,8 +1013,8 @@ func (r *campaignRepository) Update(ctx context.Context, userID, campaignID stri argPos++ } if data.RampStart != nil { - if *data.RampStart < 1 || *data.RampStart > 100 { - return nil, errx.New(errx.BadRequest, "ramp start must be between 1 and 100") + if *data.RampStart < 1 || *data.RampStart > config.LimitMax { + return nil, errx.New(errx.BadRequest, fmt.Sprintf("ramp start must be between 1 and %d", config.LimitMax)) } setClauses = append(setClauses, fmt.Sprintf("%s = $%d", "ramp_start", argPos)) args = append(args, *data.RampStart) @@ -1029,15 +1029,38 @@ func (r *campaignRepository) Update(ctx context.Context, userID, campaignID stri argPos++ } if data.RampCeiling != nil { - if *data.RampCeiling < 1 || *data.RampCeiling > 100 { - return nil, errx.New(errx.BadRequest, "ramp ceiling must be between 1 and 100") + if *data.RampCeiling < 1 || *data.RampCeiling > config.LimitMax { + return nil, errx.New(errx.BadRequest, fmt.Sprintf("ramp ceiling must be between 1 and %d", config.LimitMax)) } setClauses = append(setClauses, fmt.Sprintf("%s = $%d", "ramp_ceiling", argPos)) args = append(args, *data.RampCeiling) argPos++ } - if data.RampStart != nil && data.RampCeiling != nil && *data.RampStart > *data.RampCeiling { - return nil, errx.New(errx.BadRequest, "ramp start cannot exceed ramp ceiling") + // start <= ceiling must hold on the EFFECTIVE pair: a partial update is + // checked against the stored counterpart or it could persist an invalid pair. + if data.RampStart != nil || data.RampCeiling != nil { + start, ceiling := 0, 0 + if data.RampStart == nil || data.RampCeiling == nil { + err := r.DB.QueryRow(ctx, + "SELECT ramp_start, ramp_ceiling FROM campaigns WHERE user_id = $1 AND id = $2", + userID, campaignID).Scan(&start, &ceiling) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return nil, errx.ErrNotFound + } + db.CaptureError(err, "", nil, "queryrow") + return nil, errx.InternalError() + } + } + if data.RampStart != nil { + start = *data.RampStart + } + if data.RampCeiling != nil { + ceiling = *data.RampCeiling + } + if start > ceiling { + return nil, errx.New(errx.BadRequest, "ramp start cannot exceed ramp ceiling") + } } if data.ESPMatchMode != nil { if err := validate.CampaignESPMatchMode(*data.ESPMatchMode); err != nil { diff --git a/internal/repository/pg_email.go b/internal/repository/pg_email.go index a4409234..0e12b07d 100644 --- a/internal/repository/pg_email.go +++ b/internal/repository/pg_email.go @@ -845,7 +845,7 @@ func (r *emailRepository) Update(ctx context.Context, userID, emailAccountID str } } if udata.CampaignLimit != nil { - if *udata.CampaignLimit < 0 || *udata.CampaignLimit > 100 { + if *udata.CampaignLimit < config.LimitMin || *udata.CampaignLimit > config.LimitMax { return nil, errx.ErrEmailCampaignLimit } setClauses = append(setClauses, fmt.Sprintf("%s = $%d", "campaign_limit", argPos)) diff --git a/internal/repository/pg_email_error.go b/internal/repository/pg_email_error.go index dd350b4c..28450b05 100644 --- a/internal/repository/pg_email_error.go +++ b/internal/repository/pg_email_error.go @@ -50,6 +50,9 @@ type EmailAccountErrorRepository interface { GetByUserID(ctx context.Context, userID uuid.UUID, limit int) ([]EmailAccountError, *errx.Error) Resolve(ctx context.Context, errorID uuid.UUID, resolvedBy string) *errx.Error ResolveByMethod(ctx context.Context, accountID uuid.UUID, method string) *errx.Error + // ResolveByCodes resolves the account's unresolved errors carrying any of + // the given codes, for a flow that just fixed that class of error. + ResolveByCodes(ctx context.Context, accountID uuid.UUID, codes []string, resolvedBy string) *errx.Error ResolveAllForAccount(ctx context.Context, accountID uuid.UUID, resolvedBy string) *errx.Error } @@ -221,6 +224,29 @@ func (r *emailAccountErrorRepository) ResolveByMethod(ctx context.Context, accou return nil } +// ResolveByCodes resolves the account's unresolved errors carrying any of the given codes +func (r *emailAccountErrorRepository) ResolveByCodes(ctx context.Context, accountID uuid.UUID, codes []string, resolvedBy string) *errx.Error { + if len(codes) == 0 { + return nil + } + + query := ` + UPDATE email_account_errors + SET resolved_at = NOW(), resolved_by = $1 + WHERE email_account_id = $2 + AND error_code = ANY($3::text[]) + AND resolved_at IS NULL + ` + + _, err := r.DB.Exec(ctx, query, resolvedBy, accountID, codes) + if err != nil { + db.CaptureError(err, query, []any{resolvedBy, accountID, codes}, "exec") + return errx.InternalError() + } + + return nil +} + // ResolveAllForAccount resolves all unresolved errors for an email account func (r *emailAccountErrorRepository) ResolveAllForAccount(ctx context.Context, accountID uuid.UUID, resolvedBy string) *errx.Error { query := ` diff --git a/internal/repository/pg_unibox.go b/internal/repository/pg_unibox.go index d985be88..33a9351f 100644 --- a/internal/repository/pg_unibox.go +++ b/internal/repository/pg_unibox.go @@ -84,10 +84,12 @@ type UniboxRepository interface { } // UniboxBodyTarget is one message awaiting a search-text backfill. UserID is -// the mailbox owner, which is what the body's object-storage key is built from. +// the mailbox owner and EmailID the mailbox account; the body's object-storage +// key is built from both. type UniboxBodyTarget struct { - ID uuid.UUID - UserID uuid.UUID + ID uuid.UUID + UserID uuid.UUID + EmailID uuid.UUID } type uniboxRepository struct { @@ -253,8 +255,9 @@ func (r *uniboxRepository) GetByID(ctx context.Context, userID, id uuid.UUID) (* // GetByIDForOrg reads a single message scoped to the org's mailboxes (not the // caller's user_id), mirroring GetByThread, so a non-owner teammate who sees a // message in the org-scoped list can open it. It also returns the row's owner -// user_id: the S3 body key is built from the owner (emails//), so -// the caller must fetch the body under the owner, not under itself. +// user_id: the body's object-storage key is built from the owner (and the +// row's email_id), so the caller must fetch the body under the owner, not +// under itself. func (r *uniboxRepository) GetByIDForOrg(ctx context.Context, orgID, id uuid.UUID) (*models.EmailMessageStoreData, uuid.UUID, error) { query := fmt.Sprintf(` SELECT user_id, %s @@ -1148,7 +1151,7 @@ func (r *uniboxRepository) Overview(ctx context.Context, orgID uuid.UUID) (*mode // out to be empty and stays that way. func (r *uniboxRepository) ListMissingBodyText(ctx context.Context, afterID uuid.UUID, limit int) ([]UniboxBodyTarget, error) { query := ` - SELECT id, user_id + SELECT id, user_id, email_id FROM unibox_emails WHERE body_text = '' AND id > $1 ORDER BY id @@ -1163,7 +1166,7 @@ func (r *uniboxRepository) ListMissingBodyText(ctx context.Context, afterID uuid out := make([]UniboxBodyTarget, 0, limit) for rows.Next() { var t UniboxBodyTarget - if err := rows.Scan(&t.ID, &t.UserID); err != nil { + if err := rows.Scan(&t.ID, &t.UserID, &t.EmailID); err != nil { return nil, err } out = append(out, t) diff --git a/internal/utils/validate/campaign.go b/internal/utils/validate/campaign.go index 754fdcec..8da82b60 100644 --- a/internal/utils/validate/campaign.go +++ b/internal/utils/validate/campaign.go @@ -1,9 +1,11 @@ package validate import ( + "fmt" "time" "github.com/warmbly/warmbly/internal/bitmask" + "github.com/warmbly/warmbly/internal/config" "github.com/warmbly/warmbly/internal/errx" "github.com/warmbly/warmbly/internal/models" ) @@ -24,7 +26,7 @@ func CampaignDescription(description string) *errx.Error { } func CampaignDailyLimit(val int) *errx.Error { - if val < 3 || val > 100 { + if val < config.CampaignDailyLimitMin || val > config.LimitMax { return errx.ErrCampaignDailyLimit } return nil @@ -113,14 +115,14 @@ func CampaignSenderWeight(w int) *errx.Error { // min(daily_limit, ramp_ceiling, per-mailbox cap), so a ceiling above the // daily limit can only be clamped down, never over-send. func CampaignRamp(start, increment, ceiling int) *errx.Error { - if start < 1 || start > 100 { - return errx.New(errx.BadRequest, "ramp start must be between 1 and 100") + if start < 1 || start > config.LimitMax { + return errx.New(errx.BadRequest, fmt.Sprintf("ramp start must be between 1 and %d", config.LimitMax)) } if increment < 0 || increment > 100 { return errx.New(errx.BadRequest, "ramp increment must be between 0 and 100") } - if ceiling < 1 || ceiling > 100 { - return errx.New(errx.BadRequest, "ramp ceiling must be between 1 and 100") + if ceiling < 1 || ceiling > config.LimitMax { + return errx.New(errx.BadRequest, fmt.Sprintf("ramp ceiling must be between 1 and %d", config.LimitMax)) } if start > ceiling { return errx.New(errx.BadRequest, "ramp start cannot exceed ramp ceiling") diff --git a/web/src/app/app/campaigns/[id]/preferences/page.tsx b/web/src/app/app/campaigns/[id]/preferences/page.tsx index 4234c49b..6f20c11f 100644 --- a/web/src/app/app/campaigns/[id]/preferences/page.tsx +++ b/web/src/app/app/campaigns/[id]/preferences/page.tsx @@ -28,7 +28,7 @@ import useCampaignSenders from "@/lib/api/hooks/app/campaigns/useCampaignSenders import useReplaceCampaignSenders from "@/lib/api/hooks/app/campaigns/useReplaceCampaignSenders"; const DAILY_MIN = 3; -const DAILY_MAX = 100; +const DAILY_MAX = 5000; // One scrolling page — every section stacks in order and the left nav is a // scrollspy over these ids. diff --git a/web/src/app/app/contacts/segments/[id]/page.tsx b/web/src/app/app/contacts/segments/[id]/page.tsx index 827e829b..2e41856a 100644 --- a/web/src/app/app/contacts/segments/[id]/page.tsx +++ b/web/src/app/app/contacts/segments/[id]/page.tsx @@ -3,7 +3,7 @@ import React from "react"; import { Link, useNavigate, useParams } from "react-router-dom"; -import { ArrowLeftIcon, ChevronDownIcon, MegaphoneIcon, PencilIcon, Trash2Icon } from "lucide-react"; +import { ArrowLeftIcon, CheckIcon, ChevronDownIcon, CopyIcon, MegaphoneIcon, PencilIcon, Trash2Icon } from "lucide-react"; import toast from "react-hot-toast"; import ContactsTable from "@/components/app/contacts/ContactsTable"; @@ -94,6 +94,7 @@ function SegmentDetail() { {s.description &&

{s.description}

} +
+ + {copied ? "Segment ID copied" : ""} + + + ); +} + // Pinned contacts. Excluded ones never show in the member list, so this is // the only place they can be seen and released. function OverridesPanel({ segment }: { segment: Segment }) { diff --git a/web/src/app/app/contacts/segments/page.tsx b/web/src/app/app/contacts/segments/page.tsx index d16cd008..8160cb09 100644 --- a/web/src/app/app/contacts/segments/page.tsx +++ b/web/src/app/app/contacts/segments/page.tsx @@ -92,6 +92,16 @@ function SegmentsList() { setEditorOpen(true); } + // The ID is what the segments API takes; copying needs no write permission. + async function copyId(s: Segment) { + try { + await navigator.clipboard.writeText(s.id); + toast.success("Segment ID copied"); + } catch { + toast.error("Could not copy"); + } + } + function askDelete(s: Segment) { confirm.show(`Delete the segment "${s.name}"? Contacts themselves are kept.`, async () => { try { @@ -202,6 +212,7 @@ function SegmentsList() { openEdit(s))}>Edit conditions setCampaignFor(s))}>Add to campaign duplicate(s))}>Duplicate + copyId(s)}>Copy segment ID askDelete(s))}>Delete diff --git a/web/src/components/app/campaigns/NewCampaignDialog.tsx b/web/src/components/app/campaigns/NewCampaignDialog.tsx index f5627912..9d5d469d 100644 --- a/web/src/components/app/campaigns/NewCampaignDialog.tsx +++ b/web/src/components/app/campaigns/NewCampaignDialog.tsx @@ -716,13 +716,13 @@ function SendingStep({ draft, patch }: { draft: Draft; patch: (p: Partial

Daily limit per mailbox

- 3 to 100. Stay near 50 until the mailboxes have proven their reputation. + 3 to 5,000. Stay near 50 until the mailboxes have proven their reputation.

patch({ dailyLimit: v })} className="w-24 shrink-0" /> diff --git a/web/src/components/app/campaigns/preferences/CampaignAppearance.tsx b/web/src/components/app/campaigns/preferences/CampaignAppearance.tsx index 3a050142..4f181973 100644 --- a/web/src/components/app/campaigns/preferences/CampaignAppearance.tsx +++ b/web/src/components/app/campaigns/preferences/CampaignAppearance.tsx @@ -10,7 +10,7 @@ import SenderSelector from "./SenderSelector"; import { SettingRow, Toggle } from "./components/CampaignPreferenceBoolBox"; const DAILY_MIN = 3; -const DAILY_MAX = 100; +const DAILY_MAX = 5000; type SetCampaign = React.Dispatch>; @@ -61,6 +61,7 @@ export function SendingAccountsSection({ setExplicitAccounts: React.Dispatch>; }) { const dailyInvalid = newCampaign.daily_limit < DAILY_MIN || newCampaign.daily_limit > DAILY_MAX; + const dailyHigh = !dailyInvalid && newCampaign.daily_limit > 100; return (
@@ -86,10 +87,12 @@ export function SendingAccountsSection({ suffix="emails / day" className="w-48" /> -

+

{dailyInvalid ? `Must be between ${DAILY_MIN} and ${DAILY_MAX}.` - : `${DAILY_MIN}–${DAILY_MAX}. Default 50 — stay conservative until reputation is proven.`} + : dailyHigh + ? "Well above the 30–50/day safe cold-outreach band. Every mailbox in the pool needs the reputation and provider capacity to carry this." + : `${DAILY_MIN}–${DAILY_MAX}. Default 50 — stay conservative until reputation is proven.`}

diff --git a/web/src/components/app/campaigns/preferences/CampaignEmails.tsx b/web/src/components/app/campaigns/preferences/CampaignEmails.tsx index fdf5a570..21b29cd2 100644 --- a/web/src/components/app/campaigns/preferences/CampaignEmails.tsx +++ b/web/src/components/app/campaigns/preferences/CampaignEmails.tsx @@ -140,7 +140,7 @@ export function RotationRampSection({ setNewCampaign((bef) => ({ ...bef, ramp_start: v }))} suffix="/ day" className="w-36" @@ -162,7 +162,7 @@ export function RotationRampSection({ setNewCampaign((bef) => ({ ...bef, ramp_ceiling: v }))} suffix="/ day" className="w-36" diff --git a/web/src/components/app/contacts/contact-edit/ActivityTab.tsx b/web/src/components/app/contacts/contact-edit/ActivityTab.tsx index fa1b0ac4..800ada8d 100644 --- a/web/src/components/app/contacts/contact-edit/ActivityTab.tsx +++ b/web/src/components/app/contacts/contact-edit/ActivityTab.tsx @@ -58,6 +58,7 @@ import type { } from "@/lib/api/models/app/contacts/ContactCampaignState"; import type { LeadStatus } from "@/lib/api/models/app/contacts/Contact"; import useClickOutside from "@/hooks/useClickOutside"; +import { useFlipAlignment } from "@/hooks/useFlipPlacement"; import { fmtAbsolute, fmtRelative } from "./format"; type FilterId = @@ -757,6 +758,9 @@ function DateRange({ const [open, setOpen] = React.useState(false); const ref = React.useRef(null); useClickOutside(ref, () => setOpen(false)); + // The trigger wraps anywhere along the toolbar row, so the panel side is + // measured, not fixed: a fixed right-0 clipped it against the drawer edge. + const align = useFlipAlignment(ref, open, 256); const active = !!from || !!to; const label = active @@ -786,7 +790,9 @@ function DateRange({ {label} {open && ( -
+