Merge remote-tracking branch 'origin/main' into feature/public-forms

This commit is contained in:
Matthew Meszaros
2026-09-01 01:17:54 -07:00
46 changed files with 1637 additions and 279 deletions
+3
View File
@@ -0,0 +1,3 @@
{
"skipReview": "AUTOMATIC"
}
+1 -1
View File
@@ -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:
+2
View File
@@ -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.
+1
View File
@@ -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)
+1 -1
View File
@@ -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:
@@ -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. |
+2 -2
View File
@@ -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).
<Callout type="warn" title="Don't push past the defaults casually">
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.
+16 -3
View File
@@ -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.
<Callout type="warn" title="Do not jump a new mailbox to a high cap">
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.
</Callout>
### Keeping a copy of sent mail
+7
View File
@@ -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`.
<Callout type="info" title="Segments and categories">
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.
</Callout>
@@ -68,4 +74,5 @@ Categories are labels you put on a contact. Segments are rules that read those l
<Card title="Contacts and CRM" href="/guides/contacts-crm/" />
<Card title="Campaigns" href="/guides/campaigns/" />
<Card title="Analytics" href="/guides/analytics/" />
<Card title="Segments API reference" href="/api/reference/contacts/#segments" />
</Cards>
+3 -3
View File
@@ -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
@@ -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' },
],
+80 -3
View File
@@ -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) {
+6
View File
@@ -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
+1 -1
View File
@@ -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."),
+25 -16
View File
@@ -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
+231
View File
@@ -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
}
+196
View File
@@ -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)
}
}
+20 -2
View File
@@ -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.
+1 -1
View File
@@ -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.
+6 -7
View File
@@ -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/<ownerID>/<id>), 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/<ownerID>/<id>.
// 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,
+6 -29
View File
@@ -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), "")
}
+8 -2
View File
@@ -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
+8 -3
View File
@@ -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 2100 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.")
+9
View File
@@ -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))
}
@@ -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);
@@ -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);
+3
View File
@@ -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.
+29 -6
View File
@@ -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 {
+1 -1
View File
@@ -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))
+26
View File
@@ -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 := `
+10 -7
View File
@@ -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/<ownerID>/<id>), 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)
+7 -5
View File
@@ -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")
@@ -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.
@@ -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() {
</div>
{s.description && <p className="text-[12px] text-slate-500 mt-0.5">{s.description}</p>}
<ConditionSummary conditions={s.conditions} match={s.match} specs={fields.data ?? []} included={s.included_count} excluded={s.excluded_count} />
<SegmentIdChip id={s.id} />
</div>
<div className="flex items-center gap-1.5 shrink-0">
<button
@@ -134,6 +135,39 @@ function SegmentDetail() {
);
}
// The segment's ID, click to copy: it is what the API takes (segments CRUD,
// members, contact search segment_ids), so integrators need it at hand.
function SegmentIdChip({ id }: { id: string }) {
const [copied, setCopied] = React.useState(false);
async function copy() {
try {
await navigator.clipboard.writeText(id);
setCopied(true);
window.setTimeout(() => setCopied(false), 1500);
} catch {
toast.error("Could not copy");
}
}
return (
<>
<button
type="button"
onClick={copy}
aria-label={copied ? "Segment ID copied" : "Copy segment ID"}
title="Copy segment ID"
className="mt-1 inline-flex items-center gap-1.5 max-w-full font-mono text-[10.5px] text-slate-400 hover:text-slate-700 transition-colors"
>
<span className="uppercase tracking-[0.14em] font-sans font-medium text-[9.5px]">ID</span>
<span className="truncate">{id}</span>
{copied ? <CheckIcon className="w-3 h-3 text-emerald-600 shrink-0" /> : <CopyIcon className="w-3 h-3 shrink-0" />}
</button>
<span className="sr-only" role="status" aria-live="polite">
{copied ? "Segment ID copied" : ""}
</span>
</>
);
}
// 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 }) {
@@ -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() {
<PopoverMenuItem onSelect={guarded(() => openEdit(s))}>Edit conditions</PopoverMenuItem>
<PopoverMenuItem onSelect={campaignGuarded(() => setCampaignFor(s))}>Add to campaign</PopoverMenuItem>
<PopoverMenuItem onSelect={guarded(() => duplicate(s))}>Duplicate</PopoverMenuItem>
<PopoverMenuItem onSelect={() => copyId(s)}>Copy segment ID</PopoverMenuItem>
<PopoverMenuSeparator />
<PopoverMenuItem onSelect={guarded(() => askDelete(s))}>Delete</PopoverMenuItem>
</PopoverMenuContent>
@@ -716,13 +716,13 @@ function SendingStep({ draft, patch }: { draft: Draft; patch: (p: Partial<Draft>
<div className="min-w-0">
<p className="text-[12.5px] text-slate-900 font-medium">Daily limit per mailbox</p>
<p className="text-[11px] text-slate-500 mt-0.5 leading-relaxed">
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.
</p>
</div>
<NumberInput
value={draft.dailyLimit}
min={3}
max={100}
max={5000}
onChange={(v) => patch({ dailyLimit: v })}
className="w-24 shrink-0"
/>
@@ -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<React.SetStateAction<Campaign>>;
@@ -61,6 +61,7 @@ export function SendingAccountsSection({
setExplicitAccounts: React.Dispatch<React.SetStateAction<string[]>>;
}) {
const dailyInvalid = newCampaign.daily_limit < DAILY_MIN || newCampaign.daily_limit > DAILY_MAX;
const dailyHigh = !dailyInvalid && newCampaign.daily_limit > 100;
return (
<div className="space-y-4">
<div>
@@ -86,10 +87,12 @@ export function SendingAccountsSection({
suffix="emails / day"
className="w-48"
/>
<p className={`text-[11px] mt-1.5 ${dailyInvalid ? "text-rose-500" : "text-slate-400"}`}>
<p className={`text-[11px] mt-1.5 ${dailyInvalid ? "text-rose-500" : dailyHigh ? "text-amber-600" : "text-slate-400"}`}>
{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 3050/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.`}
</p>
</div>
</div>
@@ -140,7 +140,7 @@ export function RotationRampSection({
<NumberInput
value={newCampaign.ramp_start}
min={1}
max={500}
max={5000}
onChange={(v) => setNewCampaign((bef) => ({ ...bef, ramp_start: v }))}
suffix="/ day"
className="w-36"
@@ -162,7 +162,7 @@ export function RotationRampSection({
<NumberInput
value={newCampaign.ramp_ceiling}
min={1}
max={500}
max={5000}
onChange={(v) => setNewCampaign((bef) => ({ ...bef, ramp_ceiling: v }))}
suffix="/ day"
className="w-36"
@@ -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<HTMLDivElement>(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}
</button>
{open && (
<div className="absolute left-0 md:left-auto md:right-0 top-7 z-50 w-64 max-w-[min(256px,calc(100vw-2rem))] p-2.5 rounded-md border border-slate-200 bg-white shadow-lg">
<div
className={`absolute ${align === "right" ? "right-0" : "left-0"} top-7 z-50 w-64 max-w-[min(256px,calc(100vw-2rem))] p-2.5 rounded-md border border-slate-200 bg-white shadow-lg`}
>
<div className="grid grid-cols-2 gap-2">
<div>
<label className="block text-[10px] uppercase tracking-[0.12em] font-medium text-slate-500 mb-1">
@@ -976,7 +982,8 @@ function EventRow({
<span className="text-slate-400 uppercase tracking-[0.1em] text-[10px] font-medium pt-px">
{k}
</span>
<span className="text-slate-700 min-w-0 break-words whitespace-pre-wrap">
{/* wrap-anywhere: break-words leaves grid min-content wide, so long URLs overflowed the card */}
<span className="text-slate-700 min-w-0 wrap-anywhere whitespace-pre-wrap">
{typeof v === "string" ? (
<Highlight text={v} q={highlight} />
) : (
+94 -7
View File
@@ -57,6 +57,11 @@ import useEmailTrackingDomain from "@/lib/api/hooks/app/emails/useEmailTrackingD
import useVerifyEmailTrackingDomain from "@/lib/api/hooks/app/emails/useVerifyEmailTrackingDomain";
import type { AppError } from "@/lib/api/client/normalizeError";
import buildError from "@/lib/helper/buildError";
import { useQueryClient } from "@tanstack/react-query";
import reauthEmailOAuth from "@/lib/api/client/app/emails/reauthEmailOAuth";
import onboardOAuthFinish from "@/lib/api/client/app/emails/onboardOAuthFinish";
import { openEmailOAuthPopup } from "@/lib/emails/emailOAuthPopup";
import UpdateCredentialsDialog from "./UpdateCredentialsDialog";
import EmailEditor from "../EmailEditor";
import SendingBehaviorTab from "./SendingBehaviorTab";
import SyncStatusCard from "./SyncStatusCard";
@@ -90,7 +95,7 @@ function RampHoldNotice({ hold }: { hold: import("@/lib/api/models/app/analytics
const hours = Math.max(0, Math.round((new Date(hold.resumes_at).getTime() - Date.now()) / 3_600_000));
const resumesIn = hours > 0 ? ` for about ${hours} more ${hours === 1 ? "hour" : "hours"}` : "";
return (
<div className="px-5 pb-4">
<div className="px-5 py-4">
<div className="rounded-md border border-amber-200 bg-amber-50 px-3 py-2.5 flex items-start gap-2">
<AlertTriangleIcon className="w-3.5 h-3.5 mt-px shrink-0 text-amber-600" />
<div className="min-w-0">
@@ -147,7 +152,7 @@ function LifecycleNotice({
onError: (e) => toast.error(buildError(e as unknown as AppError)),
});
return (
<div className="px-5 pb-4">
<div className="px-5 py-4">
<div className="rounded-md border border-slate-200 bg-slate-50 px-3 py-2.5 flex items-start gap-2">
<PauseIcon className="w-3.5 h-3.5 mt-px shrink-0 text-slate-500" />
<div className="min-w-0 flex-1">
@@ -208,7 +213,7 @@ function SendHoldControl({ mailboxId, state }: { mailboxId: string; state?: impo
// A cold cap below the configured one reads as a bug unless it says why.
function ColdRampNotice({ ramp }: { ramp: import("@/lib/api/models/app/analytics/AccountStatus").ColdRampInfo }) {
return (
<div className="px-5 pb-4">
<div className="px-5 py-4">
<div className="rounded-md border border-sky-200 bg-sky-50 px-3 py-2.5 flex items-start gap-2">
<GaugeIcon className="w-3.5 h-3.5 mt-px shrink-0 text-sky-600" />
<div className="min-w-0">
@@ -264,7 +269,7 @@ function FieldShell({ label, hint, children }: { label: string; hint?: string; c
);
}
function NumField({ value, onChange, suffix }: { value: number; onChange: (v: number) => void; suffix?: string }) {
function NumField({ value, onChange, suffix, max }: { value: number; onChange: (v: number) => void; suffix?: string; max?: number }) {
// Themed number field with our own steppers, no native spinner.
return (
<NumberInput
@@ -272,6 +277,7 @@ function NumField({ value, onChange, suffix }: { value: number; onChange: (v: nu
onChange={onChange}
suffix={suffix}
min={0}
max={max}
align="right"
className="w-full h-9"
/>
@@ -487,6 +493,75 @@ function Detail({ mailbox, onClose, initialTab = "overview", canWarmup = true }:
/* ── Overview ─────────────────────── */
// Credential-class error codes a reconnect fixes (mirror of the backend's
// errx.CredentialMailErrorCodes). Any of these gets the reconnect button.
const CREDENTIAL_ERROR_CODES = new Set([
"GOOGLE_AUTHENTICATION_FAILED",
"AUTHENTICATION_FAILED",
"AUTHORIZATION_FAILED",
"INVALID_CREDENTIALS",
]);
// The missing re-verify button of issue #274. OAuth mailboxes re-run the
// provider consent in a popup; SMTP/IMAP mailboxes get a replacement-
// credentials dialog. Either way the backend renews the stored credential,
// clears the error, and reactivates the mailbox.
function ReconnectAction({ mailbox }: { mailbox: Inbox }) {
const qc = useQueryClient();
const [busy, setBusy] = useState(false);
const [credsOpen, setCredsOpen] = useState(false);
const oauth = mailbox.provider === "gmail" || mailbox.provider === "outlook";
const providerLabel = mailbox.provider === "gmail" ? "Google" : "Microsoft";
const reauth = async () => {
if (busy) return;
setBusy(true);
try {
const { url, state } = await reauthEmailOAuth(mailbox.id);
const { code } = await openEmailOAuthPopup(url, state);
await onboardOAuthFinish(code, state);
toast.success("Mailbox re-authorized. It's back online.");
qc.invalidateQueries({ queryKey: ["emails", "list"] });
qc.invalidateQueries({ queryKey: ["analytics", "accounts"] });
} catch (e) {
toast.error(e instanceof Error ? e.message : buildError(e as AppError));
} finally {
setBusy(false);
}
};
return (
<div className="mt-2">
{oauth ? (
<button
type="button"
onClick={() => void reauth()}
disabled={busy}
className="h-7 px-2.5 rounded-md bg-rose-600 hover:bg-rose-700 text-white text-[12px] font-medium inline-flex items-center gap-1.5 transition-colors disabled:opacity-60"
>
{busy ? <Loading className="!w-3 h-3 text-white" /> : <ShieldCheckIcon className="w-3 h-3" />}
{busy ? "Waiting for authorization…" : `Re-authorize with ${providerLabel}`}
</button>
) : (
<button
type="button"
onClick={() => setCredsOpen(true)}
className="h-7 px-2.5 rounded-md bg-rose-600 hover:bg-rose-700 text-white text-[12px] font-medium inline-flex items-center gap-1.5 transition-colors"
>
<ShieldCheckIcon className="w-3 h-3" />
Update credentials
</button>
)}
<UpdateCredentialsDialog
mailboxId={mailbox.id}
mailboxEmail={mailbox.email}
open={credsOpen}
onClose={() => setCredsOpen(false)}
/>
</div>
);
}
function OverviewTab({ status, loading, mailbox }: { status?: import("@/lib/api/models/app/analytics/AccountStatus").default; loading: boolean; mailbox: Inbox }) {
const health = status?.health;
const usage = status?.daily_usage;
@@ -506,6 +581,10 @@ function OverviewTab({ status, loading, mailbox }: { status?: import("@/lib/api/
: { bar: "bg-rose-500", text: "text-rose-600", icon: AlertCircleIcon };
const HealthIcon = healthTone.icon;
// One reconnect button per drawer, on the first credential-class error;
// every such error is fixed by the same reconnect.
const firstCredentialErrorId = status?.errors?.find((e) => CREDENTIAL_ERROR_CODES.has(e.error_code))?.id;
const synced = mailbox.last_synced_at ? new Date(mailbox.last_synced_at) : null;
return (
@@ -587,6 +666,7 @@ function OverviewTab({ status, loading, mailbox }: { status?: import("@/lib/api/
<div className="text-[12px] font-medium text-rose-800">{e.title}</div>
<div className="text-[11px] text-rose-700/90 mt-0.5 leading-relaxed">{e.message}</div>
{e.action_required && <div className="text-[11px] text-rose-900 mt-1 font-medium">{e.action_required}</div>}
{e.id === firstCredentialErrorId && <ReconnectAction mailbox={mailbox} />}
</div>
))}
</div>
@@ -1481,14 +1561,21 @@ function SettingsTab({ form, update, mailbox }: { form: Inbox; update: (p: Parti
<div className="px-5 py-5 space-y-5">
<Eyebrow>Sending limits</Eyebrow>
<FieldShell label="Daily campaign cap" hint="Max cold-campaign emails per day. Default 50; raise only with good reputation.">
<NumField value={form.campaign_limit} onChange={(v) => update({ campaign_limit: v })} suffix="emails / day" />
<FieldShell label="Daily campaign cap" hint="Max cold-campaign emails per day, up to 5,000. Default 50; raise only with good reputation.">
<NumField value={form.campaign_limit} onChange={(v) => update({ campaign_limit: v })} suffix="emails / day" max={5000} />
{form.campaign_limit > 100 && (
<p className="text-[11px] text-amber-600 mt-1 leading-relaxed">
Well above the 3050/day safe band for cold outreach. Caps this high need a warmed,
established mailbox and a provider that allows the volume (Google Workspace tops out at
2,000/day). Deliverability damage shows up as spam placement, not as errors.
</p>
)}
</FieldShell>
<FieldShell
label="Minimum gap"
hint={`Smallest delay between two sends from this mailbox — currently ${formatGap(form.min_wait_time)}. Overridden while Sending behaviour is on, which draws a fresh delay for every send.`}
>
<NumField value={form.min_wait_time} onChange={(v) => update({ min_wait_time: v })} suffix="seconds" />
<NumField value={form.min_wait_time} onChange={(v) => update({ min_wait_time: v })} suffix="seconds" max={86400} />
</FieldShell>
</div>
@@ -0,0 +1,274 @@
// Replacement-credentials dialog for an SMTP/IMAP mailbox whose password
// changed (issue #274). Same fields and validation as the connect form in
// AddEmailModal, minus name/email, which a reconnect never changes. The
// backend verifies the credentials against a live worker before storing, then
// reactivates the mailbox and clears its credential errors.
import React from "react";
import { AnimatePresence, motion } from "framer-motion";
import { CheckIcon, InboxIcon, KeyRoundIcon, Loader2Icon, SendIcon, XIcon } from "lucide-react";
import toast from "react-hot-toast";
import { useQueryClient } from "@tanstack/react-query";
import { TextInput } from "@/components/ui/field";
import type { AppError } from "@/lib/api/client/normalizeError";
import buildError from "@/lib/helper/buildError";
import updateEmailCredentials from "@/lib/api/client/app/emails/updateEmailCredentials";
export default function UpdateCredentialsDialog({
mailboxId,
mailboxEmail,
open,
onClose,
}: {
mailboxId: string;
mailboxEmail: string;
open: boolean;
onClose: () => void;
}) {
const qc = useQueryClient();
const [imapHost, setImapHost] = React.useState("");
const [imapPort, setImapPort] = React.useState("993");
const [imapUser, setImapUser] = React.useState(mailboxEmail);
const [imapPass, setImapPass] = React.useState("");
const [smtpHost, setSmtpHost] = React.useState("");
const [smtpPort, setSmtpPort] = React.useState("587");
const [smtpUser, setSmtpUser] = React.useState(mailboxEmail);
const [smtpPass, setSmtpPass] = React.useState("");
const [sameCreds, setSameCreds] = React.useState(true);
const [submitting, setSubmitting] = React.useState(false);
// Reset when reopened so a cancelled attempt never leaks a typed password.
React.useEffect(() => {
if (open) {
setImapHost("");
setImapPort("993");
setImapUser(mailboxEmail);
setImapPass("");
setSmtpHost("");
setSmtpPort("587");
setSmtpUser(mailboxEmail);
setSmtpPass("");
setSameCreds(true);
setSubmitting(false);
}
}, [open, mailboxEmail]);
React.useEffect(() => {
if (!open) return;
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") onClose();
};
document.addEventListener("keydown", onKey);
return () => document.removeEventListener("keydown", onKey);
}, [open, onClose]);
function valid() {
if (!imapHost.trim() || !imapPort.trim() || !imapUser.trim() || !imapPass) return false;
if (!smtpHost.trim() || !smtpPort.trim()) return false;
if (!sameCreds && (!smtpUser.trim() || !smtpPass)) return false;
const p = Number(smtpPort);
if (p !== 465 && p !== 587) return false;
return true;
}
async function submit() {
if (submitting || !valid()) return;
setSubmitting(true);
const smtp = sameCreds
? { username: imapUser.trim(), password: imapPass, host: smtpHost.trim(), port: Number(smtpPort) }
: { username: smtpUser.trim(), password: smtpPass, host: smtpHost.trim(), port: Number(smtpPort) };
try {
await toast.promise(
updateEmailCredentials(mailboxId, smtp, {
username: imapUser.trim(),
password: imapPass,
host: imapHost.trim(),
port: Number(imapPort),
}),
{
loading: "Verifying credentials…",
success: "Credentials updated. The mailbox is back online.",
error: (e: AppError) => buildError(e),
},
);
qc.invalidateQueries({ queryKey: ["emails", "list"] });
qc.invalidateQueries({ queryKey: ["analytics", "accounts"] });
onClose();
} catch {
/* surfaced by toast */
} finally {
setSubmitting(false);
}
}
return (
<AnimatePresence>
{open && (
<motion.div
key="overlay"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.15 }}
onMouseDown={onClose}
className="fixed inset-0 z-[120] flex items-center justify-center bg-slate-900/30 backdrop-blur-[2px] px-4"
>
<motion.div
key="card"
initial={{ y: 8, opacity: 0 }}
animate={{ y: 0, opacity: 1 }}
exit={{ y: 8, opacity: 0 }}
transition={{ duration: 0.16 }}
onMouseDown={(e) => e.stopPropagation()}
className="w-full max-w-[480px] 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]"
>
<div className="h-12 px-3 border-b border-slate-200 flex items-center gap-2.5 shrink-0">
<span className="text-[10px] uppercase tracking-[0.14em] text-slate-400 font-medium">Mailbox</span>
<div className="h-4 w-px bg-slate-200" />
<span className="text-[12px] text-slate-600 truncate">Update credentials for {mailboxEmail}</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">
<Section title="IMAP" sub="Incoming, usually 993" icon={<InboxIcon className="w-3.5 h-3.5" />}>
<Field label="Server">
<HostPortInput host={imapHost} onHost={setImapHost} hostPlaceholder="imap.example.com" port={imapPort} onPort={setImapPort} portPlaceholder="993" />
</Field>
<Field label="Username">
<TextInput value={imapUser} onChange={setImapUser} placeholder={mailboxEmail} />
</Field>
<Field label="Password">
<TextInput value={imapPass} onChange={setImapPass} placeholder="New app password" type="password" />
</Field>
</Section>
<Section title="SMTP" sub="Outgoing, 465 or 587" icon={<SendIcon className="w-3.5 h-3.5" />}>
<Field label="Server">
<HostPortInput host={smtpHost} onHost={setSmtpHost} hostPlaceholder="smtp.example.com" port={smtpPort} onPort={setSmtpPort} portPlaceholder="587" />
</Field>
<label className="flex items-center gap-2 pl-[76px] pt-0.5 cursor-pointer">
<input
type="checkbox"
checked={sameCreds}
onChange={(e) => setSameCreds(e.target.checked)}
className="size-3.5 rounded border-slate-300 accent-slate-900"
/>
<span className="text-[11.5px] text-slate-600">Use the same login as IMAP</span>
</label>
<AnimatePresence initial={false}>
{!sameCreds && (
<motion.div
key="smtp-creds"
initial={{ height: 0, opacity: 0 }}
animate={{ height: "auto", opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
transition={{ duration: 0.2, ease: [0.32, 0.72, 0, 1] }}
className="overflow-hidden"
>
<div className="space-y-2 pt-2">
<Field label="Username">
<TextInput value={smtpUser} onChange={setSmtpUser} placeholder={mailboxEmail} />
</Field>
<Field label="Password">
<TextInput value={smtpPass} onChange={setSmtpPass} placeholder="New app password" type="password" />
</Field>
</div>
</motion.div>
)}
</AnimatePresence>
</Section>
</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 shrink-0">
<div className="flex items-center gap-1.5 text-[11px] text-slate-500 min-w-0 flex-1">
<KeyRoundIcon className="w-3 h-3 shrink-0" />
<span className="truncate">Verified against your server before saving.</span>
</div>
<motion.button
type="button"
onClick={submit}
disabled={!valid() || submitting}
whileTap={valid() && !submitting ? { scale: 0.97 } : undefined}
className="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"
>
{submitting ? <Loader2Icon className="w-3 h-3 animate-spin" /> : <CheckIcon className="w-3 h-3" />}
Update credentials
</motion.button>
</div>
</motion.div>
</motion.div>
)}
</AnimatePresence>
);
}
function Section({ title, sub, icon, children }: { title: string; sub: string; icon: React.ReactNode; children: React.ReactNode }) {
return (
<div className="px-4 py-3 border-b border-slate-200/60 last:border-b-0 min-w-0">
<div className="flex items-center gap-1.5 mb-2 min-w-0">
<span className="text-slate-500 shrink-0">{icon}</span>
<span className="text-[10px] uppercase tracking-[0.14em] text-slate-400 font-medium shrink-0">{title}</span>
<div className="h-3 w-px bg-slate-200 shrink-0" />
<span className="text-[11.5px] text-slate-500 truncate min-w-0">{sub}</span>
</div>
<div className="space-y-2 min-w-0">{children}</div>
</div>
);
}
function Field({ label, children }: { label: string; children: React.ReactNode }) {
return (
<div className="flex items-center gap-3 min-w-0">
<span className="text-[10px] uppercase tracking-[0.14em] text-slate-400 font-medium w-16 shrink-0">{label}</span>
<div className="flex-1 min-w-0">{children}</div>
</div>
);
}
// One bordered field holding host (flex) and port (fixed) with a hairline
// divider, same as the connect form's server rows.
function HostPortInput({
host,
onHost,
hostPlaceholder,
port,
onPort,
portPlaceholder,
}: {
host: string;
onHost: (v: string) => void;
hostPlaceholder: string;
port: string;
onPort: (v: string) => void;
portPlaceholder: string;
}) {
return (
<div className="flex items-stretch h-7 rounded-md border border-slate-200 bg-white focus-within:border-sky-400 focus-within:ring-2 focus-within:ring-sky-100 transition-colors min-w-0 overflow-hidden">
<input
value={host}
onChange={(e) => onHost(e.target.value)}
placeholder={hostPlaceholder}
className="flex-1 min-w-0 px-2.5 bg-transparent outline-none text-[12.5px] text-slate-900 placeholder:text-slate-400"
/>
<div className="w-px bg-slate-200 shrink-0" />
<input
value={port}
onChange={(e) => onPort(e.target.value)}
placeholder={portPlaceholder}
inputMode="numeric"
className="w-14 shrink-0 px-2 bg-slate-50/60 outline-none text-[12.5px] text-slate-900 placeholder:text-slate-400 tabular-nums text-center"
/>
</div>
);
}
+261 -144
View File
@@ -607,31 +607,32 @@ function Section({ section, first = false }: { section: NavSection; first?: bool
* Anatomy:
*
*
* LIVE 42 of 50 / day status dot + label + cap pace
* 8 mailboxes sending now mailbox composition
* optional 24h sparkline
* 128 of 400 sent today hero number (scrubs on hover)
* capacity meter (today vs cap)
* 14-day area sparkline
* 8 5 3 mailboxes · active · unread
*
*
* Reads as ambient telemetry: even when idle, it tells you "system is
* up, n mailboxes ready." Clicking jumps to analytics. The dot pulses
* when at least one mailbox is actively warming or sending.
* Reads as ambient telemetry: even when idle, it tells you "n mailboxes,
* n sent today." Clicking jumps to analytics; hovering a day on the
* sparkline swaps the hero number to that day. There is deliberately no
* LIVE/OFFLINE status row: the numbers ticking realtime already say the
* system is up, so the panel spends its pixels on the data instead.
*
* Data sources at this layer:
* - useAppStore.emails mailbox count, active count
* - useAppStore.connectionStatus online/offline state
* - useDashboard("30d") daily_trend today's sent volume + the sparkline
* (shares the dashboard page's query cache; realtime invalidation keeps
* it current)
*
* The capacity denominator is a derived cap based on mailbox count × 50
* (default cold cap from internal/config/constants.go).
* The capacity denominator sums each mailbox's configured campaign_limit
* (default 50/day, from internal/config/constants.go).
*/
function LivePanel() {
const emails = useAppStore((s) => s.emails);
const connection = useAppStore((s) => s.connectionStatus);
const latencyMs = useAppStore((s) => s.wsLatencyMs);
const unseenCount = useAppStore((s) => s.unseenCount);
const dash = useDashboard("30d");
const [hovered, setHovered] = useState<number | null>(null);
const { active, mailboxes, capacity } = useMemo(() => {
const m = emails.length;
@@ -639,167 +640,283 @@ function LivePanel() {
const st = mailboxDisplayStatus(e);
return st === "healthy" || st === "warming";
}).length;
return { active: a, mailboxes: m, capacity: m * 50 };
// Capacity = the sum of each mailbox's configured daily campaign
// limit (default 50/day), not a flat count × 50 — a tuned-down or
// raised mailbox should move the meter's denominator.
const cap = emails.reduce((sum, e) => sum + (e.campaign_limit ?? 50), 0);
return { active: a, mailboxes: m, capacity: cap };
}, [emails]);
const { sentToday, trend } = useMemo(() => {
const days = dash.data?.daily_trend ?? [];
const todayKey = new Date().toISOString().slice(0, 10);
const today = days.find((d) => d.date?.slice(0, 10) === todayKey);
return {
sentToday: today?.sent ?? 0,
trend: days.slice(-14).map((d) => d.sent),
};
// daily_trend only contains days that had sends; rebuild a continuous
// last-14-days axis (zero-filling the gaps) so the sparkline's x
// spacing is honest — otherwise a quiet week would be silently
// squeezed out and two distant days would read as adjacent.
const byDate = new Map(
(dash.data?.daily_trend ?? []).map((d) => [d.date?.slice(0, 10), d.sent]),
);
const out: { date: string; sent: number }[] = [];
const now = new Date();
for (let i = 13; i >= 0; i--) {
const d = new Date(now);
d.setUTCDate(now.getUTCDate() - i);
const key = d.toISOString().slice(0, 10);
out.push({ date: key, sent: byDate.get(key) ?? 0 });
}
return { sentToday: out[out.length - 1].sent, trend: out };
}, [dash.data]);
const live = connection === "connected";
// Connected == green, always. When quiet we say READY (not the old "IDLE",
// which with a gray dot read as "not connected"); when a mailbox is warming
// or sending we say LIVE and pulse. Only a real disconnect is gray.
const label =
connection === "disconnected"
? "OFFLINE"
: connection === "connecting"
? "CONNECTING"
: active > 0
? "LIVE"
: "READY";
const dotClass =
connection === "disconnected"
? "bg-slate-300"
: connection === "connecting"
? "bg-amber-500"
: "bg-emerald-500";
const labelTone =
connection === "disconnected"
? "text-slate-400"
: connection === "connecting"
? "text-amber-600"
: "text-emerald-600";
// Latency bucketing: <100ms great, <300ms okay, ≥300ms poor.
const latencyTone =
latencyMs == null
? "text-slate-400"
: latencyMs < 100
? "text-emerald-600"
: latencyMs < 300
? "text-amber-600"
: "text-red-500";
const scrub = hovered != null ? trend[hovered] : undefined;
const pct = capacity > 0 ? Math.min(100, (sentToday / capacity) * 100) : 0;
return (
<Link
to="/app/analytics"
className="group block mx-2 mt-2 mb-3 rounded-md bg-white/80 hover:bg-white border border-slate-200/70 hover:border-slate-300 px-2.5 py-2 transition-colors"
className="group block mx-2 mt-2 mb-3 rounded-md bg-white/80 hover:bg-white border border-slate-200/70 hover:border-slate-300 pt-2 overflow-hidden transition-colors"
>
<div className="flex items-center gap-1.5">
<span className="relative inline-flex shrink-0">
<span
className={cn(
"w-1.5 h-1.5 rounded-full",
dotClass,
connection === "connecting" && "animate-pulse",
)}
/>
{/* Active mailboxes ping; a quiet-but-connected workspace gets a
slow breathing glow so "READY" reads alive, not stuck. */}
{live && active > 0 ? (
<span className="absolute inset-0 rounded-full bg-emerald-500/40 animate-ping" />
) : live ? (
<span className="absolute -inset-[3px] rounded-full bg-emerald-400/50 status-breathe" />
) : null}
</span>
<span
className={cn(
"text-[10px] uppercase tracking-[0.14em] font-semibold",
labelTone,
)}
>
{label}
</span>
<span
className={cn(
"ml-auto font-mono text-[10px] tabular-nums",
latencyTone,
)}
title={latencyMs != null ? `Websocket roundtrip` : "Not connected"}
>
{latencyMs != null ? `${latencyMs}ms` : "—"}
</span>
</div>
<div className="mt-1.5 flex items-baseline gap-1.5">
<span className="text-[15px] text-slate-900 tabular-nums leading-none">
{mailboxes}
</span>
<span className="text-[11px] text-slate-500">
{mailboxes === 1 ? "mailbox" : "mailboxes"}
</span>
{active > 0 && (
<span className="ml-auto text-[10.5px] text-emerald-600 tabular-nums">
{active} active
</span>
{/* Hero: today's sends against the derived daily cap. While the
sparkline is being scrubbed it shows the hovered day instead. */}
<div className="px-2.5 flex items-baseline gap-1.5 whitespace-nowrap">
{scrub ? (
<>
<span className="text-[19px] font-semibold text-slate-900 leading-none">
{scrub.sent.toLocaleString()}
</span>
<span className="text-[10.5px] text-slate-500">
sent {formatTrendDay(scrub.date)}
</span>
</>
) : (
<>
<AnimatedNumber
value={sentToday}
className="text-[19px] font-semibold text-slate-900 leading-none"
/>
<span className="text-[10.5px] text-slate-500">
{capacity > 0
? `of ${capacity.toLocaleString()} sent today`
: "sent today"}
</span>
</>
)}
</div>
<div className="mt-1.5 flex items-center justify-between gap-2 text-[10.5px]">
<span className="text-slate-400">Inbox</span>
{/* Capacity meter: same-ramp track so the unfilled part still reads
as "room left today", not as a broken bar. */}
<div
className="mt-1.5 px-2.5"
title={
capacity > 0
? `${sentToday} of ${capacity} daily capacity used`
: "Connect a mailbox to start sending"
}
>
<div className="h-1 rounded-full bg-sky-100 overflow-hidden">
<div
className="h-full rounded-full bg-sky-500 transition-[width] duration-700 ease-out"
style={{ width: `${pct}%` }}
/>
</div>
</div>
<Sparkline points={trend} hovered={hovered} onHover={setHovered} />
{/* Glance chips: mailboxes · active senders · unread inbox. Icons
carry the labels (title attrs spell them out) so this stays one
quiet row instead of two label/value text lines. */}
<div className="border-t border-slate-100 px-2.5 py-1.5 flex items-center gap-3 text-[10.5px]">
<span
className="inline-flex items-center gap-1 text-slate-500"
title={`${mailboxes} ${mailboxes === 1 ? "mailbox" : "mailboxes"} connected`}
>
<MailIcon className="w-3 h-3 text-slate-400" />
<span className="font-mono tabular-nums">{mailboxes}</span>
</span>
{active > 0 && (
<span
className="inline-flex items-center gap-1 text-emerald-600"
title={`${active} warming or sending`}
>
<span className="w-1.5 h-1.5 rounded-full bg-emerald-500" />
<span className="font-mono tabular-nums">{active}</span>
</span>
)}
<span
className={cn(
"font-mono tabular-nums",
"ml-auto inline-flex items-center gap-1",
unseenCount > 0 ? "text-sky-600" : "text-slate-400",
)}
title={`${unseenCount} unread in inbox`}
>
{unseenCount > 99 ? "99+" : unseenCount} unread
<InboxIcon className="w-3 h-3" />
<span className="font-mono tabular-nums">
{unseenCount > 99 ? "99+" : unseenCount}
</span>
</span>
</div>
<div className="mt-1 flex items-center justify-between gap-2 text-[10.5px]">
<span className="text-slate-400">Today</span>
<span
className={cn(
"font-mono tabular-nums",
sentToday > 0 ? "text-slate-600" : "text-slate-400",
)}
>
{sentToday}/{capacity || "—"}
</span>
</div>
<Sparkline values={trend} />
</Link>
);
}
/** "2026-08-30" → "Aug 30" for the sparkline scrub readout. */
function formatTrendDay(iso: string): string {
const d = new Date(iso);
return Number.isNaN(d.getTime())
? iso
: d.toLocaleDateString(undefined, { month: "short", day: "numeric", timeZone: "UTC" });
}
// Sparkline geometry. Width matches the card's inner width (sidebar w-64
// minus mx-2 and borders) so preserveAspectRatio="none" barely distorts
// the dots; side padding keeps markers clear of the overflow-hidden edges.
const SPARK_W = 238;
const SPARK_H = 34;
const SPARK_PAD_X = 6;
const SPARK_PAD_TOP = 6;
const SPARK_PAD_BOTTOM = 3;
/**
* Sparkline 14 thin vertical bars, the last two weeks of send volume
* from the dashboard daily trend, normalized to the busiest day. Days
* with volume render sky; empty days stay a faint slate baseline.
* Sparkline the last two weeks of send volume as a smooth area line
* (Catmull-Rom smoothing, gradient wash under the stroke, end-of-series
* dot with a surface ring). Full-bleed across the card; the chips row's
* top border underneath doubles as the baseline. Invisible per-day hit
* columns report the hovered day via onHover so the hero number above
* scrubs with the cursor.
*/
function Sparkline({ values }: { values: number[] }) {
const bars = useMemo(() => {
const padded =
values.length >= 14
? values.slice(-14)
: [...Array.from({ length: 14 - values.length }, () => 0), ...values];
const max = Math.max(...padded, 1);
return padded.map((v) => Math.round((v / max) * 100));
}, [values]);
function Sparkline({
points,
hovered,
onHover,
}: {
points: { date: string; sent: number }[];
hovered: number | null;
onHover: (i: number | null) => void;
}) {
const { linePath, areaPath, dots, hasVolume } = useMemo(() => {
const n = points.length;
const baseY = SPARK_H - SPARK_PAD_BOTTOM;
if (n < 2) {
return {
linePath: "",
areaPath: "",
dots: [] as { x: number; y: number }[],
hasVolume: false,
};
}
const max = Math.max(...points.map((p) => p.sent), 1);
const span = SPARK_W - SPARK_PAD_X * 2;
const usable = baseY - SPARK_PAD_TOP;
const pts = points.map((p, i) => ({
x: SPARK_PAD_X + (i / (n - 1)) * span,
y: baseY - (p.sent / max) * usable,
}));
// Catmull-Rom → cubic bezier; control ys are clamped so a spike next
// to a flat run never overshoots the frame.
const clamp = (y: number) =>
Math.min(baseY, Math.max(SPARK_PAD_TOP, y));
let d = `M ${pts[0].x} ${pts[0].y}`;
for (let i = 0; i < n - 1; i++) {
const p0 = pts[i - 1] ?? pts[i];
const p1 = pts[i];
const p2 = pts[i + 1];
const p3 = pts[i + 2] ?? p2;
const c1x = p1.x + (p2.x - p0.x) / 6;
const c1y = clamp(p1.y + (p2.y - p0.y) / 6);
const c2x = p2.x - (p3.x - p1.x) / 6;
const c2y = clamp(p2.y - (p3.y - p1.y) / 6);
d += ` C ${c1x} ${c1y}, ${c2x} ${c2y}, ${p2.x} ${p2.y}`;
}
return {
linePath: d,
areaPath: `${d} L ${pts[n - 1].x} ${baseY} L ${pts[0].x} ${baseY} Z`,
dots: pts,
hasVolume: points.some((p) => p.sent > 0),
};
}, [points]);
const n = points.length;
const step = n > 1 ? (SPARK_W - SPARK_PAD_X * 2) / (n - 1) : 0;
const hoverDot = hovered != null ? dots[hovered] : undefined;
const endDot = dots[dots.length - 1];
return (
<div className="mt-2 flex items-end gap-0.5 h-4">
{bars.map((v, i) => (
<div
key={i}
className={cn(
"flex-1 rounded-sm transition-colors",
v > 0
? "bg-sky-300 group-hover:bg-sky-400"
: "bg-slate-200 group-hover:bg-slate-300",
)}
style={{ height: `${Math.max(8, v)}%`, minHeight: "2px" }}
<svg
viewBox={`0 0 ${SPARK_W} ${SPARK_H}`}
preserveAspectRatio="none"
aria-hidden
className={cn(
"mt-1 block w-full h-[34px]",
hasVolume ? "text-sky-500" : "text-slate-300",
)}
onMouseLeave={() => onHover(null)}
>
<defs>
<linearGradient id="livepanel-spark-fill" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor="currentColor" stopOpacity="0.18" />
<stop offset="100%" stopColor="currentColor" stopOpacity="0.02" />
</linearGradient>
</defs>
{linePath && hasVolume && (
<path d={areaPath} fill="url(#livepanel-spark-fill)" />
)}
{linePath && (
<path
d={linePath}
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
vectorEffect="non-scaling-stroke"
/>
))}
</div>
)}
{/* Hover scrub: hairline + marker on the hovered day. */}
{hoverDot && (
<>
<line
x1={hoverDot.x}
y1={SPARK_PAD_TOP - 4}
x2={hoverDot.x}
y2={SPARK_H - SPARK_PAD_BOTTOM}
className="stroke-slate-200"
strokeWidth="1"
vectorEffect="non-scaling-stroke"
/>
<circle
cx={hoverDot.x}
cy={hoverDot.y}
r="3"
fill="currentColor"
className="stroke-white"
strokeWidth="1.5"
/>
</>
)}
{/* End-of-series marker (today), ringed in the surface color. */}
{endDot && hovered == null && (
<circle
cx={endDot.x}
cy={endDot.y}
r="2.5"
fill="currentColor"
className="stroke-white"
strokeWidth="1.5"
/>
)}
{/* Invisible per-day hit columns driving the scrub. */}
{n >= 2 &&
points.map((_, i) => (
<rect
key={i}
x={SPARK_PAD_X + i * step - step / 2}
y={0}
width={step}
height={SPARK_H}
fill="transparent"
onMouseEnter={() => onHover(i)}
/>
))}
</svg>
);
}
+83 -22
View File
@@ -18,32 +18,93 @@ export default function useFlipPlacement(
React.useLayoutEffect(() => {
if (!open) return;
const trigger = triggerRef.current;
if (!trigger) return;
const rect = trigger.getBoundingClientRect();
let clipBottom = window.innerHeight;
let clipTop = 0;
let el: HTMLElement | null = trigger.parentElement;
while (el) {
const s = getComputedStyle(el);
const overflow = `${s.overflow} ${s.overflowY} ${s.overflowX}`;
if (/(auto|scroll|hidden)/.test(overflow)) {
const ar = el.getBoundingClientRect();
if (ar.bottom < clipBottom) clipBottom = ar.bottom;
if (ar.top > clipTop) clipTop = ar.top;
const measure = () => {
const trigger = triggerRef.current;
if (!trigger) return;
const rect = trigger.getBoundingClientRect();
let clipBottom = window.innerHeight;
let clipTop = 0;
let el: HTMLElement | null = trigger.parentElement;
while (el) {
const s = getComputedStyle(el);
const overflow = `${s.overflow} ${s.overflowY} ${s.overflowX}`;
if (/(auto|scroll|hidden)/.test(overflow)) {
const ar = el.getBoundingClientRect();
if (ar.bottom < clipBottom) clipBottom = ar.bottom;
if (ar.top > clipTop) clipTop = ar.top;
}
el = el.parentElement;
}
el = el.parentElement;
}
const spaceBelow = clipBottom - rect.bottom;
const spaceAbove = rect.top - clipTop;
if (spaceBelow < estimatedHeight && spaceAbove > spaceBelow) {
setPlacement("top");
} else {
setPlacement("bottom");
}
const spaceBelow = clipBottom - rect.bottom;
const spaceAbove = rect.top - clipTop;
if (spaceBelow < estimatedHeight && spaceAbove > spaceBelow) {
setPlacement("top");
} else {
setPlacement("bottom");
}
};
measure();
// A resize reflows the trigger while the popup stays open.
window.addEventListener("resize", measure);
return () => window.removeEventListener("resize", measure);
}, [open, triggerRef, estimatedHeight]);
return placement;
}
/**
* Horizontal counterpart: picks the trigger edge (left or right) the
* popup hangs from, based on room inside the nearest clipping ancestor.
*/
export function useFlipAlignment(
triggerRef: React.RefObject<HTMLElement | null>,
open: boolean,
estimatedWidth: number,
): "left" | "right" {
const [alignment, setAlignment] = React.useState<"left" | "right">("left");
React.useLayoutEffect(() => {
if (!open) return;
const measure = () => {
const trigger = triggerRef.current;
if (!trigger) return;
const rect = trigger.getBoundingClientRect();
let clipRight = window.innerWidth;
let clipLeft = 0;
let el: HTMLElement | null = trigger.parentElement;
while (el) {
const s = getComputedStyle(el);
const overflow = `${s.overflow} ${s.overflowY} ${s.overflowX}`;
if (/(auto|scroll|hidden)/.test(overflow)) {
const ar = el.getBoundingClientRect();
if (ar.right < clipRight) clipRight = ar.right;
if (ar.left > clipLeft) clipLeft = ar.left;
}
el = el.parentElement;
}
// left-aligned grows rightward from the trigger's left edge;
// right-aligned grows leftward from its right edge.
const spaceRight = clipRight - rect.left;
const spaceLeft = rect.right - clipLeft;
if (spaceRight < estimatedWidth && spaceLeft > spaceRight) {
setAlignment("right");
} else {
setAlignment("left");
}
};
measure();
// A resize reflows the wrapping toolbar while the popup stays open.
window.addEventListener("resize", measure);
return () => window.removeEventListener("resize", measure);
}, [open, triggerRef, estimatedWidth]);
return alignment;
}
@@ -0,0 +1,13 @@
import Request from "../../Request";
import type { OAuthStartResponse } from "./onboardOAuthStart";
// Starts an OAuth round trip that renews an existing mailbox's tokens after
// the provider invalidated them (password change, revoked grant). The finish
// leg is the ordinary onboardOAuthFinish with the returned state.
export default async function reauthEmailOAuth(mailboxId: string): Promise<OAuthStartResponse> {
return await Request<OAuthStartResponse>({
method: "POST",
url: `/emails/onboarding/oauth/reauth/${mailboxId}`,
authorization: true,
});
}
@@ -0,0 +1,19 @@
import Request from "../../Request";
import type Inbox from "@/lib/api/models/app/emails/Inbox";
import type Service from "@/lib/api/models/app/emails/Service";
// Replaces an SMTP/IMAP mailbox's credentials after a password change. The
// backend validates them against a live worker before storing, then
// reactivates the mailbox and clears its credential errors.
export default async function updateEmailCredentials(
mailboxId: string,
smtp: Service,
imap: Service,
): Promise<Inbox> {
return await Request<Inbox>({
method: "PUT",
url: `/emails/onboarding/smtp-imap/${mailboxId}`,
data: { smtp, imap },
authorization: true,
});
}
+98
View File
@@ -0,0 +1,98 @@
// Drives the mailbox OAuth popup outside AddEmailModal (the reconnect flow in
// the mailbox drawer). Opens the provider authorization URL in a centered
// popup; the backend's /addresses/<provider>/callback page postMessages
// {type:"email_oauth_callback", code, state} back to this opener; we resolve
// with them so the caller can finish the handshake.
import { API_URL, APP_URL } from "@/lib/information";
export interface EmailOAuthPopupResult {
code: string;
state: string;
}
interface EmailOAuthCallbackMessage {
type: "email_oauth_callback";
provider: string;
code: string;
state: string;
error: string;
}
// originOf normalises a configured base URL to a bare origin. APP_URL and
// API_URL may carry a trailing slash or a path; event.origin never does.
function originOf(value: string | undefined): string | null {
if (!value) return null;
try {
return new URL(value, window.location.href).origin;
} catch {
return null;
}
}
// The bridge page is served by the API so the registered redirect_uri stays
// stable, which means event.origin can be API_URL's origin on split-domain
// deployments. The real replay protection is the single-use state match.
function allowedCallbackOrigins(): string[] {
return [originOf(APP_URL), originOf(API_URL), window.location.origin].filter(
(o): o is string => Boolean(o),
);
}
export function openEmailOAuthPopup(authUrl: string, expectedState: string): Promise<EmailOAuthPopupResult> {
return new Promise((resolve, reject) => {
const width = 520;
const height = 640;
const left = window.screenX + Math.max(0, (window.outerWidth - width) / 2);
const top = window.screenY + Math.max(0, (window.outerHeight - height) / 2);
const popup = window.open(
authUrl,
"warmbly_email_oauth",
`width=${width},height=${height},left=${left},top=${top},menubar=no,toolbar=no,location=yes`,
);
if (!popup) {
reject(new Error("Popup blocked. Allow popups for this site and try again."));
return;
}
popup.focus();
let settled = false;
const cleanup = () => {
window.removeEventListener("message", onMessage);
window.clearInterval(closedTimer);
};
const onMessage = (event: MessageEvent) => {
if (event.origin && !allowedCallbackOrigins().includes(event.origin)) return;
const data = event.data as EmailOAuthCallbackMessage | undefined;
if (!data || data.type !== "email_oauth_callback") return;
if (data.state !== expectedState) return;
settled = true;
cleanup();
try {
popup.close();
} catch {
/* ignore */
}
if (data.error) {
reject(new Error(data.error === "access_denied" ? "Authorization was cancelled." : `Provider error: ${data.error}`));
return;
}
if (data.code) {
resolve({ code: data.code, state: data.state });
return;
}
reject(new Error("Authorization was cancelled."));
};
window.addEventListener("message", onMessage);
// Detect a manually-closed popup so the caller's promise doesn't hang.
const closedTimer = window.setInterval(() => {
if (popup.closed && !settled) {
cleanup();
reject(new Error("Authorization window was closed before finishing."));
}
}, 600);
});
}