Merge origin/main into feature/open-tracking-device-client

This commit is contained in:
Matthew Meszaros
2026-09-23 02:06:04 -07:00
31 changed files with 835 additions and 124 deletions
+13 -5
View File
@@ -92,7 +92,7 @@ Returns a `data` array of contacts plus a `pagination` envelope. Paginate by pas
}
```
Every contact carries its address verification: `verification_status` (`valid`, `risky`, `invalid`, or `unknown`), `verification_sub_status` (`catch_all`, `disposable`, `role`, `spamtrap`, `mailbox_full`, `no_mx`, `syntax`, `undisclosed`, or empty), `verification_source` (`probe` for the built-in check, `provider` for a connected verification service, `imported` for a verdict that came with the contact, `manual` for one a member set, empty when never checked), `verification_provider` (who produced it), `verification_reason`, `verification_checked_at`, and `verification_confidence` (0 to 100, scored from the check plus what real mail to the address showed; see [what real mail teaches the check](/guides/deliverability/#what-real-mail-teaches-the-check)). Campaigns never send to `invalid`, and send to `risky` only when their `risky_emails` setting is on.
Every contact carries its address verification: `verification_status` (`valid`, `risky`, `invalid`, or `unknown`), `verification_sub_status` (`catch_all`, `disposable`, `role`, `spamtrap`, `mailbox_full`, `no_mx`, `syntax`, `undisclosed`, or empty), `verification_source` (`probe` for the built-in check, `provider` for a connected verification service, `imported` for a verdict that came with the contact, `manual` for one a member set, empty when never checked), `verification_provider` (who produced it), `verification_reason`, `verification_checked_at`, `verification_requested_at` (set while a re-check a member asked for is waiting to run; the verdict stands until it lands), and `verification_confidence` (0 to 100, scored from the check plus what real mail to the address showed; see [what real mail teaches the check](/guides/deliverability/#what-real-mail-teaches-the-check)). Campaigns never send to `invalid`, and send to `risky` only when their `risky_emails` setting is on.
When the search filters by exactly one campaign, each contact additionally carries a `campaign_lead` object with its processing state inside that campaign (`status`, `sent`, `opened`, `machine_opened`, `clicked`, `replied`, `bounced`, `current_step`, `sender`, `last_activity_at`, `hold` when held, and `failure_reason` when failed). `sender` is the mailbox address the lead's whole sequence sends from, fixed when its first email went out and absent until then. `opened` counts steps opened by a person; steps fetched automatically by a mail client (Apple Mail Privacy Protection and similar) are in `machine_opened` instead, matching the machine opens the analytics summary reports. The `status` derivation, highest priority first, is `unsubscribed` (not subscribed), then `bounced`, `replied`, `failed` (a step could not be sent after every retry; `failure_reason` carries the sending worker's reason), `completed` (every email step sent, no reply), `paused` (the lead's flow is held, by an out-of-office auto-reply or by hand; the `hold` object carries `since`, `until`, `reason` and `source`), `active` (some steps sent, more to send), `undeliverable` (pre-send verification refused the address, so the campaign skips the lead and never sends to it), and `pending` (queued, nothing sent). A step counts as sent only once the sending worker has delivered it to the mailbox provider; a send the worker could not complete is retried on the campaign's next pass and never shows as sent. The `lead_status` filter narrows to one of these buckets.
@@ -473,7 +473,7 @@ Reports which verifier checks this workspace's addresses and the contacts by ver
}
```
`provider` is `builtin`, `millionverifier`, or `cleanmylist`. `credits` is the connected service's remaining balance when available (omitted for CleanMyList); `provider_error` is set instead when the service is connected but unusable (a rejected key, no credits), in which case the built-in check is in use. `builtin_ready` says whether the built-in mailbox probe can run on this instance. `pending` is the share of `unknown` nobody has checked yet.
`provider` is `builtin`, `millionverifier`, or `cleanmylist`. `credits` is the connected service's remaining balance when available (omitted for CleanMyList); `provider_error` is set instead when the service is connected but unusable (a rejected key, no credits), in which case the built-in check is in use. `builtin_ready` says whether the built-in mailbox probe can run on this instance. `pending` counts contacts nobody has checked yet plus those with a re-check queued.
## Verify or override contacts
@@ -485,7 +485,7 @@ Queues a fresh check of the listed contacts, or records a manual verdict on them
| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `action` | string | Yes | `verify` queues a re-check (each contact updates as its verdict lands); `mark_deliverable` records `valid`; `mark_undeliverable` records `invalid`. Manual verdicts are never re-checked automatically. Anything else is rejected with `invalid_action`. |
| `action` | string | Yes | `verify` queues a re-check ahead of the background backlog, whatever the contact's current verdict, its age, or the evidence behind it; the verdict stays in force until the new one lands, and each contact updates as it does. `mark_deliverable` records `valid`; `mark_undeliverable` records `invalid`, and either answers a re-check still waiting. Manual verdicts are never re-checked unless asked. Anything else is rejected with `invalid_action`. |
| `contacts` | string[] | No | Contact ids, up to the bulk maximum per request. |
| `all`, `filters`, `exclude` | — | No | Select by filter instead; see [selecting contacts](#selecting-contacts-for-a-bulk-action). |
| `campaign_id` | string | No | Instead of, or as well as, a selection: every lead of this campaign that verification refused. |
@@ -495,9 +495,17 @@ At least one contact must be selected (`no_contacts`). Marking leads deliverable
### Response
```json
{ "affected": 512, "action": "verify", "queued": true }
{
"affected": 512,
"action": "verify",
"queued": true,
"verifier": "millionverifier",
"verifier_label": "MillionVerifier"
}
```
For `verify`, `verifier` names who runs the checks (`builtin`, `millionverifier`, or `cleanmylist`) and `verifier_label` its display name. When a verifier is connected but cannot be used right now, `verifier` is `builtin` and `verifier_error` says why (the same sentence as the overview's `provider_error`).
## Look up a contact by email
`GET /contacts/lookup`
@@ -535,7 +543,7 @@ Auth: **Scope** `READ_CONTACTS` · **Org permission** `view_contacts`
`GET /contacts/:id`
Returns the hydrated contact 360 payload: the contact plus an engagement summary, when present suppression state, and a `verification` object explaining the verdict: `status`, `confidence`, `reasons` (sentences, strongest first), `decisive` (true when real mail rather than a check decided the status), and `evidence`, the observations it was scored from, newest first, each `{ "kind", "detail", "observed_at" }` with `kind` one of `delivered`, `opened`, `clicked`, `replied`, `auto_replied`, `bounced_recipient`, `bounced_other`. Engagement and suppression counts are org-scoped; they are returned only when an organization is selected.
Returns the hydrated contact 360 payload: the contact plus an engagement summary, when present suppression state, and a `verification` object explaining the verdict: `status`, `confidence`, `reasons` (sentences, strongest first), `decisive` (true when real mail rather than a check decided the status), `source` and `provider` (who produced the last verdict, as on the contact) with `provider_label` (the verifier's display name, such as `MillionVerifier`), `check_status` (what that check itself answered, which differs from `status` when real mail decided otherwise), `checked_at`, `requested_at` (set while a re-check is waiting), and `evidence`, the observations it was scored from, newest first, each `{ "kind", "detail", "observed_at" }` with `kind` one of `delivered`, `opened`, `clicked`, `replied`, `auto_replied`, `bounced_recipient`, `bounced_other`. Engagement and suppression counts are org-scoped; they are returned only when an organization is selected.
Auth: **Scope** `READ_CONTACTS` · **Org permission** `view_contacts`
+8 -2
View File
@@ -123,7 +123,7 @@ Every address gets one of four verdicts. **Deliverable** and **unverified** are
Verdicts come from the following sources, and the source is shown on the contact:
- **The built-in check**, included on every plan: syntax, the domain's mail server, known disposable domains, and a mailbox probe against the recipient's server. It only records an address as undeliverable when that server rejects the address itself; a server that rejects the check for its own reasons (a policy block, a rate limit, a greeting it does not accept) leaves the address unverified. Microsoft 365 and Yahoo answer every probe the same way, so their addresses stay unverified rather than being called deliverable. The check also watches itself: when it starts rejecting an unusual share of addresses, its rejections are filed as unverified for an hour instead of being trusted, because a real list is never mostly dead. Self-hosted instances should set `EMAIL_VERIFY_HELO_HOST`, described in the [configuration reference](/development/configuration/#pre-send-verification).
- **MillionVerifier**, pay as you go: connect it once under Integrations with your own API key, and every check from then on uses your MillionVerifier credits (one per address) instead of the built-in probe. Its verdicts cover the catch-all and Microsoft cases the probe cannot. When the balance runs out or the key stops working, the connection is marked and the built-in check takes over until it is fixed. Nothing else changes.
- **MillionVerifier**: connect it once under Integrations with your own API key, and every check from then on uses your MillionVerifier credits (one per address) instead of the built-in probe. Pay-as-you-go credits and a subscription's renewing allowance both count. Its verdicts cover the catch-all and Microsoft cases the probe cannot. When the balance runs out or the key stops working, the connection is marked and the built-in check takes over until it is fixed. Nothing else changes.
- **CleanMyList**: connect a workspace API key under Integrations to verify new contacts and re-check existing ones through your CleanMyList account. Checks use plan allowance first, then credits. The built-in check takes over when verification fails. The balance stays in CleanMyList because its API does not expose it. See [setup instructions](/guides/integrations/#connect-cleanmylist).
A workspace uses one of these at a time. Connecting a second service is refused until the first is disconnected, so a check never moves to a different bill on its own.
@@ -139,11 +139,17 @@ A check sees an address once. The platform sees what happens after every send, a
Every contact carries a **confidence** (0 to 100) next to its verdict, and the contact drawer's Deliverability card lists the reasons in plain words with the observations behind them. Newer evidence outranks older: a bounce after a reply makes the address undeliverable, a reply after a bounce makes it deliverable again, and old observations fade over months rather than switching off.
A paid verifier's answer is newer evidence too. When MillionVerifier or CleanMyList says an address is deliverable or undeliverable more than 30 days after the last real mail to it, its answer stands, because mailboxes get closed and a fresh check about one outranks a stale sign of life. Inside those 30 days, real mail still wins. The built-in probe never outranks real mail.
Verdicts age: an address is checked again after 90 days (30 for an inconclusive verdict), because mailboxes get created and closed, but one that real mail reached in the last six months is left alone. A verdict a teammate set by hand never expires and is never outvoted by evidence.
### When verification is wrong
Select the contacts and choose **Verify** in the selection bar: **Re-verify** queues a fresh check, and each row's mark updates as its verdict lands; **Mark deliverable** overrides the verdict for a list you know is good. Both are also available on a campaign's Leads tab.
Open a contact and press **Re-verify** on its Deliverability card, or select several and choose **Verify** in the selection bar: **Re-verify** queues a fresh check through the verifier the workspace uses, and **Mark deliverable** overrides the verdict for a list you know is good. Both are also available on a campaign's Leads tab.
A re-verify runs ahead of every other check, whatever the contact's history: a recent verdict, recent mail, or a verdict a teammate set by hand. The current verdict stays in force until the new one lands, so campaigns keep routing on it meanwhile, and the card shows **Re-verifying with MillionVerifier** until the result appears on its own. If the connected verifier cannot be used at that moment (no credits left, a rejected key), the toast and the card say so and the built-in check runs instead.
The Deliverability card always says who produced the verdict and when: **Verified with MillionVerifier**, **Checked by Warmbly's built-in check**, **Imported from ZeroBounce**, or **Marked by a teammate**. When real mail decided otherwise, it also says what the check itself answered.
A campaign never quietly finishes because of verification. When every remaining lead has been refused, the campaign shows **needs verification** and pauses, with two buttons on it: **Re-verify leads** checks them again and resumes sending automatically as soon as any pass, and **Send anyway** marks them deliverable and resumes at once. The [launch check](/guides/campaigns/#adding-leads) likewise offers **Launch anyway** when a list is refused for its projected bounce rate, for a list verified elsewhere.
+1 -1
View File
@@ -35,7 +35,7 @@ Zapier, Make, and n8n connect in one click with nothing stored on the integratio
</Callout>
<Callout type="info" title="MillionVerifier is checked before it is saved">
The key is tested against your MillionVerifier account when you connect, so a mistyped key is refused rather than silently leaving contacts on the built-in check. From then on every new contact, and every re-verify, spends one credit there. When the balance runs out or the key is revoked, the connection is marked and the built-in check covers until it is fixed. See [address verification](/guides/deliverability/#address-verification).
The key is tested against your MillionVerifier account when you connect, so a mistyped key is refused rather than silently leaving contacts on the built-in check. From then on every new contact, and every re-verify, spends one credit there, from pay-as-you-go credits or a subscription's renewing allowance. Each verdict it produces reads **Verified with MillionVerifier** on the contact. When the balance runs out or the key is revoked, the connection is marked and the built-in check covers until it is fixed. See [address verification](/guides/deliverability/#address-verification).
</Callout>
**Credentials** (OAuth tokens, pasted keys, webhook URLs) are sealed with envelope encryption before touching the database. Only non-secret display details like an account name or Salesforce instance host are stored in the clear, so the dashboard can label the connection.
+23
View File
@@ -24540,6 +24540,29 @@
],
"format": "date-time"
},
"verification_source": {
"type": "string",
"enum": [
"",
"probe",
"provider",
"imported",
"manual"
],
"description": "Who produced the verdict: the built-in check, a connected verification service, an imported result, or a teammate. Empty when never checked."
},
"verification_provider": {
"type": "string",
"description": "The verifier or imported vocabulary behind the verdict, such as builtin or millionverifier."
},
"verification_requested_at": {
"type": [
"string",
"null"
],
"format": "date-time",
"description": "Set while a re-check a member asked for is waiting to run; the verdict stands until it lands."
},
"esp_provider": {
"type": "string",
"description": "Recipient ESP derived from the domain: '' | gmail | outlook | other."
+20 -10
View File
@@ -65,7 +65,7 @@ func (e *Evidence) Rescore(ctx context.Context, contactID uuid.UUID) {
if err != nil {
return
}
scored := emailverify.Score(verdict, toEvidence(rows), time.Now().UTC())
scored := emailverify.Score(verdict.Verdict, toEvidence(rows), time.Now().UTC())
reason := ""
if len(scored.Reasons) > 0 {
reason = scored.Reasons[0]
@@ -73,7 +73,7 @@ func (e *Evidence) Rescore(ctx context.Context, contactID uuid.UUID) {
if err := e.repo.SetScore(ctx, contactID, string(scored.Status), scored.Confidence, reason, scored.LastPositiveAt, scored.Decisive); err != nil {
return
}
if scored.Decisive && scored.Status != verdict.Status && e.onChange != nil {
if scored.Decisive && scored.Status != verdict.Stored && e.onChange != nil {
e.onChange(ctx, contactID)
}
}
@@ -91,14 +91,24 @@ func (e *Evidence) Explain(ctx context.Context, contactID uuid.UUID) *models.Con
if err != nil {
return nil
}
scored := emailverify.Score(verdict, toEvidence(rows), time.Now().UTC())
return &models.ContactVerificationDetail{
Status: string(scored.Status),
Confidence: scored.Confidence,
Reasons: scored.Reasons,
Decisive: scored.Decisive,
Evidence: rows,
scored := emailverify.Score(verdict.Verdict, toEvidence(rows), time.Now().UTC())
detail := &models.ContactVerificationDetail{
Status: string(scored.Status),
Confidence: scored.Confidence,
Reasons: scored.Reasons,
Decisive: scored.Decisive,
Evidence: rows,
Source: verdict.Source,
Provider: verdict.Provider,
ProviderLabel: emailverify.ProviderLabel(verdict.Provider),
CheckStatus: string(verdict.Status),
RequestedAt: verdict.RequestedAt,
}
if !verdict.CheckedAt.IsZero() {
at := verdict.CheckedAt
detail.CheckedAt = &at
}
return detail
}
// CreditCleanDeliveries turns sends that never bounced into evidence, then
@@ -127,7 +137,7 @@ func (e *Evidence) Apply(ctx context.Context, contactID uuid.UUID, res emailveri
if res.Provider != "" && res.Provider != emailverify.ProviderBuiltin {
source = models.VerificationSourceProvider
}
verdict := emailverify.Verdict{Status: res.Status, Source: source, CheckedAt: res.CheckedAt}
verdict := emailverify.Verdict{Status: res.Status, Source: source, Provider: res.Provider, CheckedAt: res.CheckedAt}
var rows []models.ContactVerificationEvidence
if e != nil && e.repo != nil {
rows, _ = e.repo.ListForContact(ctx, contactID)
+114
View File
@@ -0,0 +1,114 @@
package emailverify
import (
"context"
"net/http"
"net/http/httptest"
"sync"
"testing"
"time"
"github.com/google/uuid"
"github.com/warmbly/warmbly/internal/errx"
"github.com/warmbly/warmbly/internal/models"
verify "github.com/warmbly/warmbly/internal/pkg/emailverify"
"github.com/warmbly/warmbly/internal/repository"
)
// requestRepo holds one contact a member asked to re-verify.
type requestRepo struct {
repository.ContactRepository
org, id uuid.UUID
requested *time.Time
mu sync.Mutex
stored *verify.Result
check verify.Status
cleared *time.Time
}
func (r *requestRepo) RequestContactsVerification(_ context.Context, orgID uuid.UUID, ids []uuid.UUID) (int, *errx.Error) {
if orgID != r.org || len(ids) != 1 || ids[0] != r.id {
return 0, nil
}
now := time.Now().UTC()
r.requested = &now
return 1, nil
}
func (r *requestRepo) ListVerificationCandidates(context.Context, int) ([]repository.VerificationCandidate, *errx.Error) {
if r.requested == nil {
return nil, nil
}
return []repository.VerificationCandidate{{ID: r.id, OrganizationID: r.org, Email: "dana@acme.com", RequestedAt: r.requested}}, nil
}
func (r *requestRepo) UpdateContactVerification(_ context.Context, _ uuid.UUID, res verify.Result, check verify.Status, requestedAt *time.Time) *errx.Error {
r.mu.Lock()
defer r.mu.Unlock()
r.stored, r.check, r.cleared = &res, check, requestedAt
return nil
}
func millionVerifierServer(t *testing.T, credits string) *httptest.Server {
t.Helper()
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/api/v3/credits" {
_, _ = w.Write([]byte(credits))
return
}
_, _ = w.Write([]byte(`{"email":"dana@acme.com","result":"invalid","resultcode":6,"subresult":"user_unknown","role":false,"error":""}`))
}))
t.Cleanup(srv.Close)
return srv
}
// Re-verify has to reach the connected verifier and say so, and the check has
// to hand the request it answers back to be cleared.
func TestReverifyRunsThroughTheConnectedVerifier(t *testing.T) {
srv := millionVerifierServer(t, `{"credits":0,"renewing_credits":900}`)
conn := uuid.New()
source := &providerSource{provider: &Provider{Name: verify.ProviderMillionVerifier, Label: "MillionVerifier", ConnectionID: &conn, Client: verify.NewMillionVerifier("key", srv.URL)}}
repo := &requestRepo{org: uuid.New(), id: uuid.New()}
s := NewService(repo, Options{Builtin: builtinVerifier{}, Providers: source})
resp, xerr := s.Request(context.Background(), repo.org, models.ContactVerificationRequest{
ContactSelection: models.ContactSelection{Contacts: []string{repo.id.String()}},
Action: models.ContactVerificationActionVerify,
})
if xerr != nil || resp.Affected != 1 || !resp.Queued {
t.Fatalf("request = %+v, %v", resp, xerr)
}
if resp.Verifier != verify.ProviderMillionVerifier || resp.VerifierLabel != "MillionVerifier" || resp.VerifierError != "" {
t.Fatalf("a subscription account was not used: %+v", resp)
}
if n, xerr := s.VerifyPending(context.Background(), 10); xerr != nil || n != 1 {
t.Fatalf("pass = %d, %v", n, xerr)
}
if repo.stored == nil || repo.stored.Provider != verify.ProviderMillionVerifier || repo.stored.Status != verify.StatusInvalid {
t.Fatalf("stored = %+v", repo.stored)
}
if repo.check != verify.StatusInvalid || repo.cleared == nil || !repo.cleared.Equal(*repo.requested) {
t.Fatalf("check status %q, cleared %v, requested %v", repo.check, repo.cleared, repo.requested)
}
}
// A connected verifier that cannot be used is named in the response, so the
// member knows the built-in check is running instead.
func TestReverifySaysWhyTheVerifierIsPassedOver(t *testing.T) {
srv := millionVerifierServer(t, `{"credits":0,"renewing_credits":0}`)
conn := uuid.New()
source := &providerSource{provider: &Provider{Name: verify.ProviderMillionVerifier, Label: "MillionVerifier", ConnectionID: &conn, Client: verify.NewMillionVerifier("key", srv.URL)}}
repo := &requestRepo{org: uuid.New(), id: uuid.New()}
s := NewService(repo, Options{Builtin: builtinVerifier{}, Providers: source})
resp, xerr := s.Request(context.Background(), repo.org, models.ContactVerificationRequest{
ContactSelection: models.ContactSelection{Contacts: []string{repo.id.String()}},
Action: models.ContactVerificationActionVerify,
})
if xerr != nil || resp.Verifier != verify.ProviderBuiltin || resp.VerifierError == "" {
t.Fatalf("response = %+v, %v", resp, xerr)
}
}
+23 -6
View File
@@ -416,9 +416,11 @@ func (s *service) verifyOrgBatch(ctx context.Context, orgID uuid.UUID, cands []r
go func(c repository.VerificationCandidate) {
defer wg.Done()
defer func() { <-sem }()
// What real mail showed outranks what the check says.
res := s.evidence.Apply(ctx, c.ID, verify(ctx, c.Email))
if xerr := s.repo.UpdateContactVerification(ctx, c.ID, res); xerr != nil {
// What real mail showed outranks what the check says; the check's
// own answer is kept beside it for the drawer.
checked := verify(ctx, c.Email)
res := s.evidence.Apply(ctx, c.ID, checked)
if xerr := s.repo.UpdateContactVerification(ctx, c.ID, res, checked.Status, c.RequestedAt); xerr != nil {
// Skip this one; a transient DB error shouldn't abort the whole pass.
return
}
@@ -458,11 +460,12 @@ func (s *service) Request(ctx context.Context, orgID uuid.UUID, req models.Conta
resp := &models.ContactVerificationResponse{Action: req.Action}
switch req.Action {
case models.ContactVerificationActionVerify:
n, xerr := s.repo.ResetContactsVerification(ctx, orgID, ids)
n, xerr := s.repo.RequestContactsVerification(ctx, orgID, ids)
if xerr != nil {
return nil, xerr
}
resp.Affected, resp.Queued = n, true
resp.Verifier, resp.VerifierLabel, resp.VerifierError = s.verifierFor(ctx, orgID)
s.Kick()
case models.ContactVerificationActionMarkDeliverable:
n, xerr := s.repo.SetContactsVerification(ctx, orgID, ids, models.ContactVerificationWrite{
@@ -495,6 +498,20 @@ func (s *service) Request(ctx context.Context, orgID uuid.UUID, req models.Conta
return resp, nil
}
// verifierFor names who will run the org's next check, and why a connected
// provider is being passed over when it is.
func (s *service) verifierFor(ctx context.Context, orgID uuid.UUID) (name, label, problem string) {
name, label = emailverify.ProviderBuiltin, emailverify.ProviderLabel(emailverify.ProviderBuiltin)
p := s.providerFor(ctx, orgID)
if p == nil {
return name, label, ""
}
if _, err := s.providerUsable(ctx, p); err != nil {
return name, label, providerErrorText(p.Label, err)
}
return p.Name, p.Label, ""
}
func (s *service) Overview(ctx context.Context, orgID uuid.UUID) (*models.VerificationOverview, *errx.Error) {
counts, xerr := s.repo.VerificationCounts(ctx, orgID)
if xerr != nil {
@@ -527,9 +544,9 @@ func providerErrorText(label string, err error) string {
}
switch {
case errors.Is(err, emailverify.ErrProviderUnconfirmed):
return label + " has not had its account email confirmed yet. Confirm it, then reconnect."
return label + " has not had its account email confirmed yet. Confirm it, then reconnect; the built-in check is used meanwhile."
case errors.Is(err, emailverify.ErrProviderKey):
return label + " rejected the API key. Reconnect it with a current key."
return label + " rejected the API key. Reconnect it with a current key; the built-in check is used meanwhile."
case errors.Is(err, emailverify.ErrProviderCredits):
return "The " + label + " account has no allowance or credits left. Top it up to keep using it; the built-in check is used meanwhile."
default:
@@ -0,0 +1,6 @@
ALTER TABLE public.contacts
DROP CONSTRAINT IF EXISTS contacts_verification_check_status_check;
ALTER TABLE public.contacts
DROP COLUMN IF EXISTS verification_check_status,
DROP COLUMN IF EXISTS verification_requested_at;
@@ -0,0 +1,11 @@
-- A queued re-verify beside the verdict it replaces, and what the last check
-- itself said before real mail was weighed in ('' for older verdicts).
ALTER TABLE public.contacts
ADD COLUMN IF NOT EXISTS verification_requested_at timestamptz,
ADD COLUMN IF NOT EXISTS verification_check_status text NOT NULL DEFAULT '';
-- Every existing row holds the default, so validating would only buy a full
-- scan under ACCESS EXCLUSIVE; NOT VALID is still enforced on every write.
ALTER TABLE public.contacts
ADD CONSTRAINT contacts_verification_check_status_check
CHECK (verification_check_status IN ('', 'valid', 'risky', 'invalid', 'unknown')) NOT VALID;
@@ -0,0 +1 @@
DROP INDEX CONCURRENTLY IF EXISTS idx_contacts_verification_requested;
@@ -0,0 +1,5 @@
-- Member-requested checks are picked before the backlog, through this index.
-- Built concurrently, on its own, because contacts is a live table.
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_contacts_verification_requested
ON contacts (verification_requested_at)
WHERE verification_requested_at IS NOT NULL;
+20 -1
View File
@@ -50,6 +50,9 @@ type Contact struct {
// 100, scored from the last check plus what real mail to the address
// showed (deliveries, opens, replies, bounces).
VerificationConfidence int `json:"verification_confidence"`
// VerificationRequestedAt is set while a member-requested re-check waits
// to run; the verdict above stands until it lands.
VerificationRequestedAt *time.Time `json:"verification_requested_at,omitempty"`
// Recipient ESP/provider, derived in the control plane from the recipient
// domain (never an MX dial on the send hot path). '' | 'gmail' | 'outlook'
@@ -246,7 +249,7 @@ type ContactsCounts struct {
}
// ContactVerificationCounts is the org's contacts by verification status.
// Pending is the subset of Unknown nobody has checked yet.
// Pending counts contacts never checked plus those with a re-check queued.
type ContactVerificationCounts struct {
Valid int `json:"valid"`
Risky int `json:"risky"`
@@ -263,6 +266,16 @@ type ContactVerificationDetail struct {
// Decisive is true when real mail, not a check, decided the status.
Decisive bool `json:"decisive"`
Evidence []ContactVerificationEvidence `json:"evidence"`
// Source and Provider name who produced the last check or verdict, and
// ProviderLabel is the verifier's display name ("MillionVerifier").
Source string `json:"source"`
Provider string `json:"provider"`
ProviderLabel string `json:"provider_label,omitempty"`
// CheckStatus is what that check said before real mail was weighed in.
CheckStatus string `json:"check_status"`
CheckedAt *time.Time `json:"checked_at,omitempty"`
// RequestedAt is set while a member-requested re-check waits to run.
RequestedAt *time.Time `json:"requested_at,omitempty"`
}
// ContactVerificationEvidence is one observed fact about the mailbox.
@@ -336,6 +349,12 @@ type ContactVerificationResponse struct {
// Queued is true for the verify action: the check runs in the background
// and each contact updates live as its verdict lands.
Queued bool `json:"queued"`
// Verifier and VerifierLabel name who runs a queued check ("builtin" or
// the connected provider). VerifierError says why a connected provider
// cannot be used right now, in which case the built-in check runs instead.
Verifier string `json:"verifier,omitempty"`
VerifierLabel string `json:"verifier_label,omitempty"`
VerifierError string `json:"verifier_error,omitempty"`
}
// VerificationOverview is what Settings shows about address verification.
+8 -4
View File
@@ -53,8 +53,11 @@ type mvSingleResponse struct {
}
type mvCreditsResponse struct {
Credits int `json:"credits"`
Error string `json:"error"`
Credits int `json:"credits"`
// RenewingCredits is a subscription's allowance, which real-time checks
// also draw on; an account living on one reports zero Credits.
RenewingCredits int `json:"renewing_credits"`
Error string `json:"error"`
}
// Verify implements Verifier. An API failure is never an "invalid" verdict:
@@ -133,7 +136,8 @@ func (m *MillionVerifier) Account(ctx context.Context) (*int, error) {
return &n, err
}
// Credits returns the account's remaining credits, and validates the key.
// Credits returns the credits a real-time check can spend (pay-as-you-go plus
// a subscription's renewing allowance), and validates the key.
func (m *MillionVerifier) Credits(ctx context.Context) (int, error) {
q := url.Values{}
q.Set("api", m.apiKey)
@@ -160,7 +164,7 @@ func (m *MillionVerifier) Credits(ctx context.Context) (int, error) {
if out.Error != "" {
return 0, m.accountError(nil, out.Error)
}
return out.Credits, nil
return out.Credits + out.RenewingCredits, nil
}
func (m *MillionVerifier) accountError(res *Result, msg string) error {
@@ -53,6 +53,15 @@ func TestMillionVerifierMapsResults(t *testing.T) {
t.Fatalf("no credits = %+v, %v", res, err)
}
// A subscription account reports its allowance apart from bought credits.
sub := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_, _ = w.Write([]byte(`{"credits":0,"bulk_credits":0,"renewing_credits":5000,"plan":3}`))
}))
defer sub.Close()
if n, err := NewMillionVerifier("key", sub.URL).Account(context.Background()); err != nil || n == nil || *n != 5000 {
t.Fatalf("subscription allowance = %v, %v", n, err)
}
bad := NewMillionVerifier("wrong", srv.URL)
if _, err := bad.Credits(context.Background()); !errors.Is(err, ErrProviderKey) {
t.Fatalf("bad key = %v", err)
+32 -10
View File
@@ -33,7 +33,10 @@ type Evidence struct {
type Verdict struct {
Status Status
// Source is the contacts.verification_source value.
Source string
Source string
// Provider is the contacts.verification_provider value, so a reason can
// name the verifier.
Provider string
CheckedAt time.Time
}
@@ -73,13 +76,19 @@ func verdictBase(v Verdict) (int, string) {
case "manual":
return 95, "marked by a teammate"
case "provider":
switch v.Status {
case StatusValid, StatusInvalid:
return 88, "checked by the verification service"
case StatusRisky:
return 55, "flagged by the verification service"
name := ProviderLabel(v.Provider)
if name == "" {
name = "the verification service"
}
return 30, "the verification service could not decide"
switch v.Status {
case StatusValid:
return 88, "verified deliverable with " + name
case StatusInvalid:
return 88, name + " reported the address undeliverable"
case StatusRisky:
return 55, "flagged risky by " + name
}
return 30, name + " could not decide"
case "imported":
switch v.Status {
case StatusValid, StatusInvalid:
@@ -106,15 +115,20 @@ func verdictBase(v Verdict) (int, string) {
// the address is deliverable regardless of what a check said.
const PositiveDecisiveScore = 20.0
// ProviderOutranksEvidenceAfter is how much newer than all real mail a paid verifier's verdict must be to stand over it.
const ProviderOutranksEvidenceAfter = 30 * 24 * time.Hour
// Score derives an address's status and confidence from its last verdict and
// the evidence ledger. Rules, in order:
//
// 1. A manual verdict wins outright.
// 2. A bounce naming the recipient, newer than every positive observation,
// 2. A paid verifier's valid or invalid verdict newer than every observation
// by ProviderOutranksEvidenceAfter stands.
// 3. A bounce naming the recipient, newer than every positive observation,
// makes the address undeliverable.
// 3. Enough positive evidence (a reply, a click, a human open, or repeated
// 4. Enough positive evidence (a reply, a click, a human open, or repeated
// clean deliveries) makes it deliverable, whatever a probe said.
// 4. Otherwise the verdict stands, with the evidence nudging confidence.
// 5. Otherwise the verdict stands, with the evidence nudging confidence.
//
// Absence of engagement never appears here: nothing in the ledger says "did
// not open", and Score has no input for it.
@@ -161,11 +175,19 @@ func Score(v Verdict, evidence []Evidence, now time.Time) Scored {
}
out.LastPositiveAt = lastPositive
lastSeen := lastPositive
if lastNegative.After(lastSeen) {
lastSeen = lastNegative
}
switch {
case v.Source == "manual":
out.Reasons = append([]string{baseReason}, reasons...)
out.Confidence = clamp(base + int(positive/4))
return out
case v.Source == "provider" && (v.Status == StatusValid || v.Status == StatusInvalid) &&
!lastSeen.IsZero() && v.CheckedAt.Sub(lastSeen) > ProviderOutranksEvidenceAfter:
out.Reasons = append([]string{baseReason, "real mail to it is older than this check (last " + humanAge(now.Sub(lastSeen)) + ")"}, reasons...)
return out
case !lastNegative.IsZero() && lastNegative.After(lastPositive):
out.Status = StatusInvalid
out.Decisive = true
+31
View File
@@ -53,6 +53,37 @@ func TestScoreNewerBounceWinsOverOlderEngagement(t *testing.T) {
}
}
func TestScoreFreshProviderVerdictOutranksStaleMail(t *testing.T) {
now := time.Now()
gone := Verdict{Status: StatusInvalid, Source: "provider", Provider: ProviderMillionVerifier, CheckedAt: now}
delivered := func(age time.Duration) []Evidence {
out := []Evidence{}
for i := 0; i < 4; i++ {
out = append(out, Evidence{Kind: EvidenceDelivered, Detail: string(rune('a' + i)), ObservedAt: now.Add(-age - time.Duration(i)*time.Hour)})
}
return out
}
// Four clean deliveries five months before the check: the mailbox has
// since closed, and the verifier's newer answer stands.
stale := Score(gone, delivered(150*24*time.Hour), now)
if stale.Status != StatusInvalid || stale.Decisive {
t.Fatalf("stale mail overrode a fresh verifier answer: %+v", stale)
}
if stale.Reasons[0] != "MillionVerifier reported the address undeliverable" {
t.Fatalf("the verifier is not named: %q", stale.Reasons[0])
}
// The same deliveries last week still outrank it.
recent := Score(gone, delivered(7*24*time.Hour), now)
if recent.Status != StatusValid || !recent.Decisive {
t.Fatalf("recent mail lost to the verifier: %+v", recent)
}
// The built-in probe never outranks real mail, however old.
probe := Score(Verdict{Status: StatusInvalid, Source: "probe", CheckedAt: now}, delivered(150*24*time.Hour), now)
if probe.Status != StatusValid {
t.Fatalf("the probe outranked real mail: %+v", probe)
}
}
func TestScoreManualWinsOutright(t *testing.T) {
now := time.Now()
got := Score(Verdict{Status: StatusValid, Source: "manual"}, []Evidence{{Kind: EvidenceBouncedRecipient, ObservedAt: now}}, now)
+26
View File
@@ -153,6 +153,32 @@ var vocabProviderAliases = map[string]string{
"warmbly": ProviderBuiltin, "builtin": ProviderBuiltin,
}
// providerLabels are the names a member reads for each verifier.
var providerLabels = map[string]string{
ProviderBuiltin: "Warmbly's built-in check",
ProviderMillionVerifier: "MillionVerifier",
ProviderCleanMyList: "CleanMyList",
"zerobounce": "ZeroBounce",
"neverbounce": "NeverBounce",
"bouncer": "Bouncer",
"kickbox": "Kickbox",
"emailable": "Emailable",
"debounce": "DeBounce",
"clearout": "Clearout",
"emaillistverify": "EmailListVerify",
}
// ProviderLabel is the display name of a verifier, or "" for one it does not know.
func ProviderLabel(name string) string {
if l, ok := providerLabels[name]; ok {
return l
}
if k, ok := KnownVocabulary(name); ok {
return providerLabels[k]
}
return ""
}
func vocabKey(raw string) string {
k := strings.ToLower(strings.TrimSpace(raw))
return strings.NewReplacer(" ", "", "_", "", "-", "", ".", "").Replace(k)
@@ -0,0 +1,183 @@
package repository
import (
"context"
"testing"
"time"
"github.com/google/uuid"
"github.com/warmbly/warmbly/internal/models"
"github.com/warmbly/warmbly/internal/pkg/emailverify"
)
// A member's re-verify has to reach the verifier whatever the contact's
// history: a verdict fresh from a paid provider, real mail seen last week, a
// manual verdict. It also has to leave the current verdict standing until
// the new one lands, so campaigns keep routing on it meanwhile.
//
// WARMBLY_TEST_DB=postgres://warmbly:warmbly@localhost:15432/<db>?sslmode=disable \
// go test ./internal/repository/ -run LiveVerificationRequest -v
func TestLiveVerificationRequestReachesTheVerifier(t *testing.T) {
handle, pool := liveContactDB(t)
requireSchemaVersion(t, pool, 199)
f := newSharedOrgFixture(t, pool)
ctx := context.Background()
repo := &contactRepository{DB: handle}
evidence := &verificationEvidenceRepository{DB: handle}
exec := func(sql string, args ...any) {
t.Helper()
if _, err := pool.Exec(ctx, sql, args...); err != nil {
t.Fatalf("fixture: %v", err)
}
}
contact := func(status, source, provider string) uuid.UUID {
t.Helper()
id := uuid.New()
exec(`INSERT INTO contacts (id, user_id, organization_id, email, first_name, last_name, company, phone,
custom_fields, updated_at, created_at,
verification_status, verification_provider, verification_source, verification_checked_at,
verification_evidence_at)
VALUES ($1, $2, $3, $4, 'Ada', 'Ng', '', '', '{}'::jsonb, NOW(), NOW(),
$5, $6, $7, NOW() - INTERVAL '1 day', NOW() - INTERVAL '7 days')`,
id, f.owner, f.org, "request-"+id.String()[:8]+"@test.local", status, provider, source)
t.Cleanup(func() {
_, _ = pool.Exec(context.Background(), `DELETE FROM contacts WHERE id = $1`, id)
})
return id
}
candidate := func(id uuid.UUID) *VerificationCandidate {
t.Helper()
got, xerr := repo.ListVerificationCandidates(ctx, 500)
if xerr != nil {
t.Fatalf("list candidates: %v", xerr)
}
for i := range got {
if got[i].ID == id {
return &got[i]
}
}
return nil
}
status := func(id uuid.UUID) (string, string, *time.Time) {
t.Helper()
var s, check string
var requested *time.Time
if err := pool.QueryRow(ctx, `SELECT verification_status, verification_check_status, verification_requested_at FROM contacts WHERE id = $1`, id).
Scan(&s, &check, &requested); err != nil {
t.Fatalf("read contact: %v", err)
}
return s, check, requested
}
// Checked yesterday by the paid verifier, with real mail seen last week:
// the scheduler has no reason to touch it on its own.
paid := contact("valid", "provider", "millionverifier")
manual := contact("valid", "manual", "manual")
if candidate(paid) != nil || candidate(manual) != nil {
t.Fatal("a fresh verdict is a candidate nobody asked for")
}
n, xerr := repo.RequestContactsVerification(ctx, f.org, []uuid.UUID{paid, manual})
if xerr != nil || n != 2 {
t.Fatalf("request = %d, %v", n, xerr)
}
if s, _, requested := status(paid); s != "valid" || requested == nil {
t.Fatalf("the request replaced the verdict (%q) or was not recorded (%v)", s, requested)
}
c := candidate(paid)
if c == nil || c.RequestedAt == nil {
t.Fatal("a requested re-check is not a candidate")
}
if candidate(manual) == nil {
t.Fatal("a requested re-check of a manual verdict is not a candidate")
}
// A request that lands while the check runs is newer than it and stays.
asked := *c.RequestedAt
exec(`UPDATE contacts SET verification_requested_at = NOW() + INTERVAL '1 second' WHERE id = $1`, paid)
res := emailverify.Result{Status: emailverify.StatusValid, Provider: emailverify.ProviderMillionVerifier, Reason: "real mail"}
if xerr := repo.UpdateContactVerification(ctx, paid, res, emailverify.StatusInvalid, &asked); xerr != nil {
t.Fatalf("update: %v", xerr)
}
if _, check, requested := status(paid); requested == nil || check != "invalid" {
t.Fatalf("a newer request was cleared (%v) or the check's own answer was lost (%q)", requested, check)
}
// The check that answers the current request clears it.
c = candidate(paid)
if xerr := repo.UpdateContactVerification(ctx, paid, res, emailverify.StatusInvalid, c.RequestedAt); xerr != nil {
t.Fatalf("update: %v", xerr)
}
if _, _, requested := status(paid); requested != nil {
t.Fatal("the answered request was not cleared")
}
if candidate(paid) != nil {
t.Fatal("an answered request is still a candidate")
}
// Scoring starts from what the check said, not from the stored override.
v, err := evidence.Verdict(ctx, paid)
if err != nil || v.Status != emailverify.StatusInvalid || v.Stored != emailverify.StatusValid || v.Provider != emailverify.ProviderMillionVerifier {
t.Fatalf("verdict = %+v, %v", v, err)
}
// A verdict a member sets answers any re-check still waiting.
if _, xerr := repo.SetContactsVerification(ctx, f.org, []uuid.UUID{manual}, models.ContactVerificationWrite{
Status: "valid", Reason: "marked", Provider: "manual", Source: models.VerificationSourceManual,
}); xerr != nil {
t.Fatalf("set: %v", xerr)
}
if _, _, requested := status(manual); requested != nil {
t.Fatal("a manual verdict left the re-check waiting")
}
// A manual verdict set while a requested check runs is not overwritten.
if _, xerr := repo.RequestContactsVerification(ctx, f.org, []uuid.UUID{manual}); xerr != nil {
t.Fatalf("request: %v", xerr)
}
inFlight := candidate(manual)
if _, xerr := repo.SetContactsVerification(ctx, f.org, []uuid.UUID{manual}, models.ContactVerificationWrite{
Status: "invalid", Reason: "marked", Provider: "manual", Source: models.VerificationSourceManual,
}); xerr != nil {
t.Fatalf("set: %v", xerr)
}
_ = repo.UpdateContactVerification(ctx, manual, res, emailverify.StatusValid, inFlight.RequestedAt)
if s, _, _ := status(manual); s != "invalid" {
t.Fatalf("a check that was in flight overwrote the manual verdict with %q", s)
}
// A lapsed override falls back to what the row says the check answered,
// even when the rescore read an older check.
exec(`UPDATE contacts SET verification_status = 'valid', verification_check_status = 'invalid' WHERE id = $1`, paid)
if err := evidence.SetScore(ctx, paid, "valid", 40, "stale read", time.Time{}, false); err != nil {
t.Fatalf("set score: %v", err)
}
if s, _, _ := status(paid); s != "invalid" {
t.Fatalf("a non-decisive rescore wrote %q over the check's own verdict", s)
}
// Requests take at most half a batch while the backlog has work.
var queued []uuid.UUID
for i := 0; i < 3; i++ {
queued = append(queued, contact("valid", "provider", "millionverifier"))
}
fresh := contact("unknown", "", "")
exec(`UPDATE contacts SET verification_checked_at = NULL, verification_evidence_at = NULL WHERE id = $1`, fresh)
if _, xerr := repo.RequestContactsVerification(ctx, f.org, queued); xerr != nil {
t.Fatalf("request: %v", xerr)
}
got, xerr := repo.ListVerificationCandidates(ctx, 2)
if xerr != nil || len(got) != 2 {
t.Fatalf("batch = %v, %v", got, xerr)
}
if got[0].RequestedAt == nil || got[1].RequestedAt != nil {
t.Fatalf("a bulk re-verify took the whole batch from the backlog: %+v", got)
}
// Another workspace cannot queue checks on this one's contacts.
if n, _ := repo.RequestContactsVerification(ctx, uuid.New(), []uuid.UUID{paid}); n != 0 {
t.Fatalf("a foreign organization queued %d checks", n)
}
}
+83 -40
View File
@@ -41,17 +41,21 @@ type ContactRepository interface {
// the outcome of a verify pass; ListUnverifiedContacts returns contacts that
// have never been conclusively checked (status 'unknown', never verified) so
// the batch scheduler can work them off a cap per tick.
UpdateContactVerification(ctx context.Context, contactID uuid.UUID, res emailverify.Result) *errx.Error
// ListVerificationCandidates returns contacts due for a check: never
// checked, or checked long enough ago that the verdict has aged out.
// Manual verdicts are never candidates. Oldest first.
// checkStatus is what the check said before evidence was applied, and
// requestedAt the member request it answers, if any.
UpdateContactVerification(ctx context.Context, contactID uuid.UUID, res emailverify.Result, checkStatus emailverify.Status, requestedAt *time.Time) *errx.Error
// ListVerificationCandidates returns contacts due for a check: those a
// member asked to re-check first, then never checked, or checked long
// enough ago that the verdict has aged out. Manual verdicts are only
// candidates on request. Oldest first.
ListVerificationCandidates(ctx context.Context, limit int) ([]VerificationCandidate, *errx.Error)
// SetContactsVerification stores one verdict on many of the org's contacts
// (a manual "mark deliverable"). Returns how many rows changed.
SetContactsVerification(ctx context.Context, orgID uuid.UUID, ids []uuid.UUID, w models.ContactVerificationWrite) (int, *errx.Error)
// ResetContactsVerification clears the verdict so the scheduler checks the
// contacts again on its next pass. Returns how many rows changed.
ResetContactsVerification(ctx context.Context, orgID uuid.UUID, ids []uuid.UUID) (int, *errx.Error)
// RequestContactsVerification queues a re-check of the org's listed
// contacts ahead of the backlog, leaving their current verdict standing
// until it lands. Returns how many rows changed.
RequestContactsVerification(ctx context.Context, orgID uuid.UUID, ids []uuid.UUID) (int, *errx.Error)
// UndeliverableLeadIDs lists the campaign's leads verification refused.
UndeliverableLeadIDs(ctx context.Context, orgID, campaignID uuid.UUID) ([]uuid.UUID, *errx.Error)
// VerificationCounts is the org's contacts by verdict.
@@ -589,7 +593,7 @@ func (r *contactRepository) GetByID(ctx context.Context, contactID uuid.UUID) (*
c.custom_fields, c.subscribed, c.updated_at, c.created_at,
c.verification_status, c.verification_reason, c.is_catch_all, c.verification_checked_at,
c.verification_source, c.verification_provider, c.verification_sub_status, c.verification_confidence,
c.esp_provider, c.esp_resolved_at
c.verification_requested_at, c.esp_provider, c.esp_resolved_at
FROM contacts c
WHERE c.id = $1
`
@@ -601,7 +605,7 @@ func (r *contactRepository) GetByID(ctx context.Context, contactID uuid.UUID) (*
&contact.UpdatedAt, &contact.CreatedAt,
&contact.VerificationStatus, &contact.VerificationReason, &contact.IsCatchAll, &contact.VerificationCheckedAt,
&contact.VerificationSource, &contact.VerificationProvider, &contact.VerificationSubStatus, &contact.VerificationConfidence,
&contact.ESPProvider, &contact.ESPResolvedAt,
&contact.VerificationRequestedAt, &contact.ESPProvider, &contact.ESPResolvedAt,
)
if err != nil {
if err == pgx.ErrNoRows {
@@ -640,7 +644,7 @@ func (r *contactRepository) SetSubscribedByEmail(ctx context.Context, orgID uuid
return err
}
func (r *contactRepository) UpdateContactVerification(ctx context.Context, contactID uuid.UUID, res emailverify.Result) *errx.Error {
func (r *contactRepository) UpdateContactVerification(ctx context.Context, contactID uuid.UUID, res emailverify.Result, checkStatus emailverify.Status, requestedAt *time.Time) *errx.Error {
status := string(res.Status)
if status == "" {
status = string(emailverify.StatusUnknown)
@@ -657,7 +661,12 @@ func (r *contactRepository) UpdateContactVerification(ctx context.Context, conta
if provider == emailverify.ProviderBuiltin {
source = models.VerificationSourceProbe
}
if checkStatus == "" {
checkStatus = emailverify.Status(status)
}
// Only the request this check answers is cleared, and a manual verdict set
// while it ran is not overwritten.
query := `
UPDATE contacts
SET verification_status = $2,
@@ -668,10 +677,15 @@ func (r *contactRepository) UpdateContactVerification(ctx context.Context, conta
verification_provider = $7,
verification_sub_status = $8,
verification_confidence = $9,
verification_check_status = $10,
verification_requested_at = CASE
WHEN verification_requested_at IS NOT DISTINCT FROM $11::timestamptz THEN NULL
ELSE verification_requested_at END,
updated_at = NOW()
WHERE id = $1
AND (verification_source <> 'manual' OR ($11::timestamptz IS NOT NULL AND verification_requested_at IS NOT NULL))
`
params := []any{contactID, status, res.Reason, res.IsCatchAll, checkedAt, source, provider, string(res.SubStatus), res.Confidence}
params := []any{contactID, status, res.Reason, res.IsCatchAll, checkedAt, source, provider, string(res.SubStatus), res.Confidence, string(checkStatus), requestedAt}
cmd, err := r.DB.Exec(ctx, query, params...)
if err != nil {
db.CaptureError(err, query, params, "exec")
@@ -688,17 +702,17 @@ type VerificationCandidate struct {
ID uuid.UUID
OrganizationID uuid.UUID
Email string
// Requested is true when a member asked for this check (the verdict was
// reset), so it is worth spending a paid credit on even when the
// organization has none to spare.
Requested bool
// RequestedAt is the member request this check answers; nil for the
// scheduler's own backlog.
RequestedAt *time.Time
}
// ListVerificationCandidates returns up to `limit` contacts due for a check.
// Never-checked contacts come first (a reset counts as never checked), then
// verdicts older than their shelf life: an unknown verdict is retried after
// config.VerificationUnknownRecheckDays, everything else after
// config.VerificationRecheckDays. Manual verdicts are never re-checked.
// A member's request comes first and is answered whatever the verdict, its
// age or the evidence behind it: they asked. Then never-checked contacts,
// then verdicts older than their shelf life: an unknown verdict is retried
// after config.VerificationUnknownRecheckDays, everything else after
// config.VerificationRecheckDays. Manual verdicts are never re-checked unasked.
//
// A built-in verdict that predates a connected verifier is reopened once, so
// connecting one actually reaches the addresses it was connected for.
@@ -706,10 +720,27 @@ func (r *contactRepository) ListVerificationCandidates(ctx context.Context, limi
if limit <= 0 {
limit = 100
}
// Requests take at most half a batch first, so one workspace's bulk
// re-verify cannot hold every other workspace's new contacts back.
requestedQuery := `
SELECT c.id, c.organization_id, c.email, c.verification_requested_at
FROM contacts c
WHERE c.verification_requested_at IS NOT NULL
AND c.organization_id IS NOT NULL
ORDER BY c.verification_requested_at ASC, c.id
LIMIT $1 OFFSET $2
`
out := make([]VerificationCandidate, 0, limit)
if xerr := r.scanVerificationCandidates(ctx, requestedQuery, []any{(limit + 1) / 2, 0}, &out); xerr != nil {
return nil, xerr
}
requested := len(out)
query := `
SELECT c.id, c.organization_id, c.email, c.verification_checked_at IS NULL
SELECT c.id, c.organization_id, c.email, c.verification_requested_at
FROM contacts c
WHERE c.organization_id IS NOT NULL
AND c.verification_requested_at IS NULL
AND c.verification_source <> 'manual'
-- Real mail seen recently excuses the address from a check.
AND (c.verification_evidence_at IS NULL OR c.verification_evidence_at < NOW() - make_interval(days => $4))
@@ -742,30 +773,41 @@ func (r *contactRepository) ListVerificationCandidates(ctx context.Context, limi
providers = append(providers, string(p))
}
params := []any{
limit, config.VerificationUnknownRecheckDays, config.VerificationRecheckDays,
limit - len(out), config.VerificationUnknownRecheckDays, config.VerificationRecheckDays,
config.VerificationEvidenceFreshDays, providers, emailverify.ProviderBuiltin,
}
if xerr := r.scanVerificationCandidates(ctx, query, params, &out); xerr != nil {
return nil, xerr
}
// Room the backlog left goes back to requests.
if len(out) < limit && requested == (limit+1)/2 {
if xerr := r.scanVerificationCandidates(ctx, requestedQuery, []any{limit - len(out), requested}, &out); xerr != nil {
return nil, xerr
}
}
return out, nil
}
func (r *contactRepository) scanVerificationCandidates(ctx context.Context, query string, params []any, out *[]VerificationCandidate) *errx.Error {
rows, err := r.DB.Query(ctx, query, params...)
if err != nil {
db.CaptureError(err, query, params, "query")
return nil, errx.InternalError()
return errx.InternalError()
}
defer rows.Close()
out := make([]VerificationCandidate, 0, limit)
for rows.Next() {
var c VerificationCandidate
if err := rows.Scan(&c.ID, &c.OrganizationID, &c.Email, &c.Requested); err != nil {
if err := rows.Scan(&c.ID, &c.OrganizationID, &c.Email, &c.RequestedAt); err != nil {
db.CaptureError(err, "", nil, "ListVerificationCandidates scan")
return nil, errx.InternalError()
return errx.InternalError()
}
out = append(out, c)
*out = append(*out, c)
}
if err := rows.Err(); err != nil {
db.CaptureError(err, "", nil, "ListVerificationCandidates rows")
return nil, errx.InternalError()
return errx.InternalError()
}
return out, nil
return nil
}
// SetContactsVerification writes one verdict onto the org's listed contacts.
@@ -782,6 +824,9 @@ func (r *contactRepository) SetContactsVerification(ctx context.Context, orgID u
verification_source = $7,
is_catch_all = ($4 = 'catch_all'),
verification_checked_at = NOW(),
verification_check_status = '',
-- A verdict a member set answers any re-check still waiting.
verification_requested_at = NULL,
updated_at = NOW()
WHERE organization_id = $1 AND id = ANY($2)
`
@@ -794,21 +839,16 @@ func (r *contactRepository) SetContactsVerification(ctx context.Context, orgID u
return int(cmd.RowsAffected()), nil
}
// ResetContactsVerification returns the org's listed contacts to "never
// checked" so the next scheduler pass picks them up first.
func (r *contactRepository) ResetContactsVerification(ctx context.Context, orgID uuid.UUID, ids []uuid.UUID) (int, *errx.Error) {
// RequestContactsVerification queues a re-check of the org's listed contacts.
// The verdict is left in place, so campaigns keep routing on it until the
// new one lands.
func (r *contactRepository) RequestContactsVerification(ctx context.Context, orgID uuid.UUID, ids []uuid.UUID) (int, *errx.Error) {
if len(ids) == 0 {
return 0, nil
}
query := `
UPDATE contacts
SET verification_status = 'unknown',
verification_sub_status = '',
verification_reason = 'verification requested',
verification_provider = '',
verification_source = '',
is_catch_all = false,
verification_checked_at = NULL,
SET verification_requested_at = NOW(),
updated_at = NOW()
WHERE organization_id = $1 AND id = ANY($2)
`
@@ -864,7 +904,7 @@ func (r *contactRepository) VerificationCounts(ctx context.Context, orgID uuid.U
COUNT(*) FILTER (WHERE verification_status = 'risky'),
COUNT(*) FILTER (WHERE verification_status = 'invalid'),
COUNT(*) FILTER (WHERE verification_status NOT IN ('valid','risky','invalid')),
COUNT(*) FILTER (WHERE verification_checked_at IS NULL)
COUNT(*) FILTER (WHERE verification_checked_at IS NULL OR verification_requested_at IS NOT NULL)
FROM contacts
WHERE organization_id = $1
`
@@ -1514,6 +1554,7 @@ func (r *contactRepository) Search(
c.custom_fields, c.subscribed, c.updated_at, c.created_at,
c.verification_status, c.verification_reason, c.is_catch_all, c.verification_checked_at,
c.verification_source, c.verification_provider, c.verification_sub_status, c.verification_confidence,
c.verification_requested_at,
COALESCE(
(
SELECT json_agg(json_build_object('id', cam.id, 'name', cam.name))
@@ -1599,6 +1640,7 @@ func (r *contactRepository) Search(
&c.UpdatedAt, &c.CreatedAt,
&c.VerificationStatus, &c.VerificationReason, &c.IsCatchAll, &c.VerificationCheckedAt,
&c.VerificationSource, &c.VerificationProvider, &c.VerificationSubStatus, &c.VerificationConfidence,
&c.VerificationRequestedAt,
&campaignsJSON, &categoriesJSON, &leadProgressJSON,
&sortValue,
); err != nil {
@@ -2205,6 +2247,7 @@ func (r *contactRepository) Update(ctx context.Context, userID, contactID string
"verification_provider = ''",
"is_catch_all = false",
"verification_checked_at = NULL",
"verification_check_status = ''",
"verification_confidence = 0",
"verification_evidence_at = NULL",
// The ledger the verdict is scored from is wiped below, and
+32 -13
View File
@@ -26,8 +26,8 @@ type VerificationEvidenceRepository interface {
// ListForContact returns the contact's evidence, newest first.
ListForContact(ctx context.Context, contactID uuid.UUID) ([]models.ContactVerificationEvidence, error)
// Verdict reads the contact's current check verdict for scoring.
Verdict(ctx context.Context, contactID uuid.UUID) (emailverify.Verdict, error)
// SetScore writes the derived status and confidence.
Verdict(ctx context.Context, contactID uuid.UUID) (ContactVerdict, error)
// SetScore writes the derived status and confidence; a lapsed override falls back to the check's status.
SetScore(ctx context.Context, contactID uuid.UUID, status string, confidence int, reason string, lastPositive time.Time, decisive bool) error
// CreditCleanDeliveries records a delivered observation for every campaign
// step sent at least `window` ago that never bounced and is not yet in
@@ -35,6 +35,14 @@ type VerificationEvidenceRepository interface {
CreditCleanDeliveries(ctx context.Context, window time.Duration, limit int) ([]uuid.UUID, error)
}
// ContactVerdict is a contact's last verdict (the check's own status) and any re-check waiting on it.
type ContactVerdict struct {
emailverify.Verdict
// Stored is verification_status as saved, after any override from real mail.
Stored emailverify.Status
RequestedAt *time.Time
}
type verificationEvidenceRepository struct {
DB *db.DB
}
@@ -98,19 +106,26 @@ func (r *verificationEvidenceRepository) ListForContact(ctx context.Context, con
return out, rows.Err()
}
func (r *verificationEvidenceRepository) Verdict(ctx context.Context, contactID uuid.UUID) (emailverify.Verdict, error) {
var v emailverify.Verdict
var status, source string
func (r *verificationEvidenceRepository) Verdict(ctx context.Context, contactID uuid.UUID) (ContactVerdict, error) {
var v ContactVerdict
var stored, status, source, provider string
var checked *time.Time
query := `SELECT verification_status, verification_source, verification_checked_at FROM contacts WHERE id = $1`
if err := r.DB.QueryRow(ctx, query, contactID).Scan(&status, &source, &checked); err != nil {
// The check's own status only means something for a check; an imported or
// manual verdict is its own status.
query := `
SELECT verification_status,
CASE WHEN verification_source IN ('probe', 'provider') AND verification_check_status <> ''
THEN verification_check_status ELSE verification_status END,
verification_source, verification_provider, verification_checked_at, verification_requested_at
FROM contacts WHERE id = $1`
if err := r.DB.QueryRow(ctx, query, contactID).Scan(&stored, &status, &source, &provider, &checked, &v.RequestedAt); err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return v, errx.ErrNotFound
}
db.CaptureError(err, query, []any{contactID}, "queryrow")
return v, err
}
v.Status, v.Source = emailverify.Status(status), source
v.Stored, v.Status, v.Source, v.Provider = emailverify.Status(stored), emailverify.Status(status), source, provider
if checked != nil {
v.CheckedAt = *checked
}
@@ -122,15 +137,19 @@ func (r *verificationEvidenceRepository) SetScore(ctx context.Context, contactID
if !lastPositive.IsZero() {
lp = &lastPositive
}
// A decisive score from real mail replaces the status and says so in the
// reason; otherwise only the confidence moves and the check's own reason
// stays.
// Decisive real mail sets the status; otherwise a lapsed override falls back
// to the check's own status, read from the row so a check landing meanwhile wins.
query := `
UPDATE contacts
SET verification_confidence = $2,
verification_evidence_at = COALESCE($3, verification_evidence_at),
verification_status = CASE WHEN $5 THEN $4 ELSE verification_status END,
verification_reason = CASE WHEN $5 THEN $6 ELSE verification_reason END,
verification_status = CASE WHEN $5 THEN $4
WHEN verification_source IN ('probe', 'provider') AND verification_check_status <> '' THEN verification_check_status
ELSE verification_status END,
verification_reason = CASE WHEN $5 THEN $6
WHEN verification_source IN ('probe', 'provider') AND verification_check_status <> ''
AND verification_check_status <> verification_status THEN $6
ELSE verification_reason END,
updated_at = NOW()
WHERE id = $1
`
@@ -11,6 +11,7 @@ import toast from "react-hot-toast";
import { useConfirm } from "@/hooks/context/confirm";
import PermissionButton from "@/components/ui/PermissionButton";
import { useRequestContactVerification } from "@/lib/api/hooks/app/contacts/useContactVerification";
import { reverifyNotice } from "@/lib/api/client/app/contacts/verification";
import useStartCampaign from "@/lib/api/hooks/app/campaigns/useStartCampaign";
import type { AppError } from "@/lib/api/client/normalizeError";
import buildError from "@/lib/helper/buildError";
@@ -31,7 +32,9 @@ export default function UndeliverableBanner({
setAction("verify");
try {
const res = await request.mutateAsync({ campaign_id: campaignId, action: "verify" });
toast.success(`Re-checking ${res.affected} ${res.affected === 1 ? "lead" : "leads"}. Sending resumes as soon as any pass.`);
const notice = reverifyNotice(res, "lead", "leads");
if (notice.warn) toast(notice.text, { icon: "⚠️" });
else toast.success(`${notice.text}. Sending resumes as soon as any pass.`);
} catch (e) {
toast.error(buildError(e as AppError));
} finally {
@@ -46,6 +46,7 @@ import useSearchContacts from "@/lib/api/hooks/app/contacts/useSearchContacts";
import type SearchContacts from "@/lib/api/models/app/contacts/SearchContacts";
import useDeleteContacts from "@/lib/api/hooks/app/contacts/useDeleteContacts";
import { useRequestContactVerification } from "@/lib/api/hooks/app/contacts/useContactVerification";
import { reverifyNotice } from "@/lib/api/client/app/contacts/verification";
import { useBatchResearch } from "@/lib/api/hooks/app/contacts/useContactResearch";
import useIntegrationConnections from "@/lib/api/hooks/app/integrations/useIntegrationConnections";
import { usePushContacts } from "@/lib/api/hooks/app/integrations/usePushContacts";
@@ -470,12 +471,14 @@ export default function ContactsTable({
function bulkVerify() {
if (selectionCount === 0) return;
confirm?.show(
`Re-verify ${selectionCount.toLocaleString()} ${selectionCount === 1 ? "address" : "addresses"}? Verdicts land in the background${
`Re-verify ${selectionCount.toLocaleString()} ${selectionCount === 1 ? "address" : "addresses"}? Current verdicts stand until the new ones land in the background${
selectionCount > 50 ? " over the next few minutes" : ""
}.`,
async () => {
const res = await verification.mutateAsync({ ...selection, action: "verify" });
toast.success(`Re-checking ${res.affected.toLocaleString()} ${res.affected === 1 ? "address" : "addresses"}`);
const notice = reverifyNotice(res, "address", "addresses");
if (notice.warn) toast(notice.text, { icon: "⚠️" });
else toast.success(notice.text);
clearSelection();
},
);
@@ -1,5 +1,5 @@
import { describe, expect, it } from "vitest";
import { verificationTitle } from "./VerificationBadge";
import { verificationSourceLabel, verificationTitle } from "./VerificationBadge";
describe("verification provider attribution", () => {
it.each(["CleanMyList", "MillionVerifier"])("names %s on provider verdicts", (name) => {
@@ -7,7 +7,7 @@ describe("verification provider attribution", () => {
verification_status: "valid",
verification_source: "provider",
verification_provider: name.toLowerCase(),
})).toBe(`Deliverable · checked by ${name}`);
})).toBe(`Deliverable · verified with ${name}`);
});
it("keeps the generic label for a verifier it has no name for", () => {
@@ -15,6 +15,21 @@ describe("verification provider attribution", () => {
verification_status: "valid",
verification_source: "provider",
verification_provider: "some-service",
})).toBe("Deliverable · checked by a verification service");
})).toBe("Deliverable · verified with a verification service");
});
it("says when a re-check is waiting on the current verdict", () => {
expect(verificationTitle({
verification_status: "invalid",
verification_source: "provider",
verification_provider: "millionverifier",
verification_requested_at: "2026-09-22T10:00:00Z",
})).toBe("Undeliverable · verified with MillionVerifier · re-check queued");
});
it("names the built-in check and an imported vocabulary", () => {
expect(verificationSourceLabel("probe", "builtin")).toBe("checked by Warmbly's built-in check");
expect(verificationSourceLabel("imported", "zerobounce", "ZeroBounce")).toBe("imported from ZeroBounce");
expect(verificationSourceLabel("", "")).toBe("");
});
});
@@ -27,28 +27,35 @@ const SUB_LABEL: Record<string, string> = {
};
const SOURCE_LABEL: Record<string, string> = {
probe: "checked by Warmbly",
provider: "checked by a verification service",
probe: "checked by Warmbly's built-in check",
provider: "verified with a verification service",
imported: "imported with the list",
manual: "marked by a teammate",
};
export function verificationTitle(c: Pick<Contact, "verification_status" | "verification_sub_status" | "verification_source" | "verification_provider" | "verification_confidence">): string {
// Who produced a verdict, in the words a member reads: "verified with
// MillionVerifier". Only a verifier we have a name for is named; anything else
// keeps the generic label rather than showing a raw identifier.
export function verificationSourceLabel(source?: string, provider?: string, providerLabel?: string): string {
if (!source || !SOURCE_LABEL[source]) return "";
const name = providerLabel || PROVIDER_LABELS[provider as IntegrationProvider];
if (source === "provider" && name) return `verified with ${name}`;
if (source === "imported" && provider && provider !== "imported" && provider !== "builtin") {
return `imported from ${name || provider}`;
}
return SOURCE_LABEL[source];
}
type TitleFields = "verification_status" | "verification_sub_status" | "verification_source" | "verification_provider" | "verification_confidence" | "verification_requested_at";
export function verificationTitle(c: Pick<Contact, TitleFields>): string {
const status = c.verification_status ?? "unknown";
const meta = META[status] ?? META.unknown;
const parts: string[] = [c.verification_confidence ? `${meta.label} (${c.verification_confidence}% sure)` : meta.label];
if (c.verification_sub_status && SUB_LABEL[c.verification_sub_status]) parts.push(SUB_LABEL[c.verification_sub_status]);
if (c.verification_source && SOURCE_LABEL[c.verification_source]) {
// Only a verifier we have a name for is named; anything else keeps the
// generic label rather than showing a raw identifier.
const providerName = PROVIDER_LABELS[c.verification_provider as IntegrationProvider];
const src = c.verification_source === "provider" && providerName
? `checked by ${providerName}`
: c.verification_source === "imported" && c.verification_provider && c.verification_provider !== "imported"
? `imported from ${c.verification_provider}`
: SOURCE_LABEL[c.verification_source];
parts.push(src);
}
const src = verificationSourceLabel(c.verification_source, c.verification_provider);
if (src) parts.push(src);
if (c.verification_requested_at) parts.push("re-check queued");
return parts.join(" · ");
}
@@ -56,13 +63,13 @@ export default function VerificationBadge({
contact,
className,
}: {
contact: Pick<Contact, "verification_status" | "verification_sub_status" | "verification_source" | "verification_provider" | "verification_checked_at" | "verification_confidence">;
contact: Pick<Contact, TitleFields | "verification_checked_at">;
className?: string;
}) {
const status = contact.verification_status ?? "unknown";
const meta = META[status] ?? META.unknown;
const Icon = meta.Icon;
const pending = status === "unknown" && !contact.verification_checked_at;
const pending = !!contact.verification_requested_at || (status === "unknown" && !contact.verification_checked_at);
return (
<AnimatePresence mode="popLayout" initial={false}>
<motion.span
@@ -71,7 +78,7 @@ export default function VerificationBadge({
animate={{ scale: 1, opacity: 1 }}
exit={{ scale: 0.4, opacity: 0 }}
transition={{ type: "spring", duration: 0.35, bounce: 0.45 }}
title={pending ? "Verification queued" : verificationTitle(contact)}
title={pending && !contact.verification_checked_at ? "Verification queued" : verificationTitle(contact)}
aria-label={verificationTitle(contact)}
className={cn("inline-flex shrink-0", meta.tone, pending && "animate-[spin_3s_linear_infinite]", className)}
>
@@ -42,6 +42,7 @@ export interface ContactRow {
verification_provider?: string;
verification_checked_at?: string | null;
verification_confidence?: number;
verification_requested_at?: string | null;
created_at: Date;
updated_at?: Date;
}
@@ -92,7 +92,7 @@ export default function OverviewTab({
)}
<Section title="Deliverability">
<VerificationCard detail={detail?.verification} loading={detailLoading} />
<VerificationCard contactId={contact.id} detail={detail?.verification} loading={detailLoading} />
</Section>
<Section title="Engagement">
@@ -1,22 +1,34 @@
// The "why" behind a contact's deliverability verdict: an animated
// confidence ring, the reasons in plain words, and the observations they
// were scored from. Absence of engagement is never listed, because it is not
// evidence of anything.
// confidence ring, who produced it, the reasons in plain words, and the
// observations they were scored from. Absence of engagement is never listed,
// because it is not evidence of anything.
import { AnimatePresence, motion } from "framer-motion";
import { Link } from "react-router-dom";
import toast from "react-hot-toast";
import {
AlertTriangleIcon,
BadgeCheckIcon,
CircleDashedIcon,
Loader2Icon,
MailCheckIcon,
MailOpenIcon,
MailWarningIcon,
MousePointerClickIcon,
RefreshCcwIcon,
ReplyIcon,
ShieldCheckIcon,
ShieldXIcon,
} from "lucide-react";
import type { ContactVerificationDetail, VerificationEvidenceKind } from "@/lib/api/models/app/contacts/ContactDetail";
import { fmtRelative } from "./format";
import { useContactVerification, useRequestContactVerification } from "@/lib/api/hooks/app/contacts/useContactVerification";
import { reverifyNotice } from "@/lib/api/client/app/contacts/verification";
import { PROVIDER_LABELS, type IntegrationProvider } from "@/lib/api/models/app/integrations/Integration";
import type { AppError } from "@/lib/api/client/normalizeError";
import buildError from "@/lib/helper/buildError";
import { useWriteGuard } from "@/hooks/usePermission";
import { verificationSourceLabel } from "../VerificationBadge";
import { fmtAbsolute, fmtRelative } from "./format";
import { cn } from "@/lib/utils";
const STATUS = {
@@ -37,12 +49,19 @@ const EVIDENCE: Record<VerificationEvidenceKind, { label: string; Icon: typeof M
};
export default function VerificationCard({
contactId,
detail,
loading,
}: {
contactId: string;
detail?: ContactVerificationDetail | null;
loading: boolean;
}) {
const write = useWriteGuard("MANAGE_CONTACTS");
const request = useRequestContactVerification();
const pending = request.isPending || !!detail?.requested_at;
const { data: overview } = useContactVerification(pending);
if (loading && !detail) {
return <div className="h-20 rounded-md border border-slate-200 bg-slate-50 animate-pulse" />;
}
@@ -53,6 +72,31 @@ export default function VerificationCard({
const c = 2 * Math.PI * r;
const pct = Math.max(0, Math.min(100, detail.confidence));
const source = verificationSourceLabel(detail.source, detail.provider, detail.provider_label);
// What the check said, when real mail decided otherwise.
const checkSaid =
detail.check_status && detail.check_status !== detail.status && (detail.source === "provider" || detail.source === "probe")
? STATUS[detail.check_status]?.label.toLowerCase()
: "";
// Named only once the overview says who runs checks, so the card never
// flashes the wrong verifier while it loads.
const runner = !overview
? ""
: overview.provider !== "builtin" && !overview.provider_error
? PROVIDER_LABELS[overview.provider as IntegrationProvider] ?? overview.provider
: "Warmbly's built-in check";
async function reverify() {
try {
const res = await request.mutateAsync({ contacts: [contactId], action: "verify" });
const notice = reverifyNotice(res, "address", "addresses");
if (notice.warn) toast(notice.text, { icon: "⚠️" });
else toast.success(notice.text);
} catch (err) {
toast.error(buildError(err as AppError));
}
}
return (
<div className="rounded-md border border-slate-200 bg-white overflow-hidden">
<div className="px-3 py-2.5 flex items-center gap-3">
@@ -84,14 +128,34 @@ export default function VerificationCard({
</motion.span>
</div>
<div className="min-w-0 flex-1">
<div className="flex items-baseline gap-1.5">
<div className="flex items-center gap-1.5">
<span className={cn("text-[13px] font-semibold", meta.text)}>{meta.label}</span>
<span className="text-[11px] text-slate-400 tabular-nums">{pct}% sure</span>
{detail.decisive && (
<span className="ml-auto text-[10px] uppercase tracking-[0.12em] text-slate-400 font-medium">
<span className="hidden sm:inline text-[10px] uppercase tracking-[0.12em] text-slate-400 font-medium">
from real mail
</span>
)}
<button
type="button"
onClick={(e) => write.guard(() => void reverify())(e)}
disabled={pending}
title={pending ? "A re-check is already queued" : "Check this address again now"}
className="ml-auto shrink-0 h-6 px-2 rounded-md border border-slate-200 bg-white text-[11px] font-medium text-slate-700 hover:bg-slate-50 hover:border-slate-300 inline-flex items-center gap-1 transition-colors disabled:opacity-60 disabled:cursor-default"
>
{pending ? <Loader2Icon className="w-3 h-3 animate-spin" /> : <RefreshCcwIcon className="w-3 h-3" />}
{pending ? "Checking" : detail.checked_at ? "Re-verify" : "Verify now"}
</button>
</div>
<div className="mt-0.5 flex items-center gap-1 text-[11px] text-slate-500 min-w-0">
{detail.source === "provider" ? (
<BadgeCheckIcon className="w-3 h-3 shrink-0 text-sky-600" />
) : null}
<span className="truncate" title={detail.checked_at ? fmtAbsolute(detail.checked_at) : undefined}>
{source ? source.charAt(0).toUpperCase() + source.slice(1) : "Not checked yet"}
{checkSaid ? `, which said ${checkSaid}` : ""}
{detail.checked_at ? ` · ${fmtRelative(detail.checked_at)}` : ""}
</span>
</div>
<ul className="mt-0.5 space-y-0.5">
<AnimatePresence initial={false}>
@@ -110,6 +174,36 @@ export default function VerificationCard({
</ul>
</div>
</div>
<AnimatePresence initial={false}>
{pending && (
<motion.div
key="pending"
initial={{ height: 0, opacity: 0 }}
animate={{ height: "auto", opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
transition={{ type: "spring", duration: 0.35, bounce: 0.1 }}
className="overflow-hidden"
>
<div className="border-t border-sky-100 bg-sky-50/70 px-3 py-2 flex items-start gap-2 text-[11.5px] text-sky-800">
<Loader2Icon className="w-3 h-3 mt-0.5 shrink-0 animate-spin" />
<span className="leading-snug">
Re-verifying{runner ? ` with ${runner}` : ""}. The current verdict stands until the result lands, which updates here on its own.
</span>
</div>
{overview?.provider_error && (
<div className="border-t border-amber-100 bg-amber-50/70 px-3 py-2 flex items-start gap-2 text-[11.5px] text-amber-800">
<AlertTriangleIcon className="w-3 h-3 mt-0.5 shrink-0" />
<span className="leading-snug">
{overview.provider_error}{" "}
<Link to="/app/integrations" className="underline underline-offset-2 hover:text-amber-900">
Integrations
</Link>
</span>
</div>
)}
</motion.div>
)}
</AnimatePresence>
{detail.evidence.length > 0 && (
<div className="border-t border-slate-100 divide-y divide-slate-100">
{detail.evidence.slice(0, 6).map((e, i) => {
@@ -29,6 +29,23 @@ export interface VerificationResponse {
affected: number;
action: VerificationAction;
queued: boolean;
// Who runs a queued check: "builtin" or the connected provider, with its
// display name. verifier_error says why a connected provider is passed
// over right now, in which case the built-in check runs instead.
verifier?: string;
verifier_label?: string;
verifier_error?: string;
}
// The toast after queueing a re-check: who runs it, and a warning when the
// connected verifier cannot be used.
export function reverifyNotice(res: VerificationResponse, noun: string, nouns: string): { text: string; warn: boolean } {
const what = `${res.affected.toLocaleString()} ${res.affected === 1 ? noun : nouns}`;
if (res.verifier_error) return { text: `Queued ${what}. ${res.verifier_error}`, warn: true };
if (res.verifier && res.verifier !== "builtin" && res.verifier_label) {
return { text: `Re-verifying ${what} with ${res.verifier_label}`, warn: false };
}
return { text: `Re-checking ${what}`, warn: false };
}
export async function getContactVerification(): Promise<VerificationOverview> {
@@ -117,6 +117,9 @@ export default interface Contact {
// How sure the platform is of the status, 0 to 100, scored from the last
// check plus what real mail to the address showed.
verification_confidence?: number;
// Set while a re-check a member asked for waits to run; the verdict
// above stands until it lands.
verification_requested_at?: string | null;
is_catch_all?: boolean;
// Present only in the campaign Leads view (single-campaign search). Drives
@@ -79,6 +79,17 @@ export interface ContactVerificationDetail {
// True when real mail, not a check, decided the status.
decisive: boolean;
evidence: ContactVerificationEvidence[];
// Who produced the last verdict: "probe", "provider", "imported",
// "manual", or "" when never checked. provider_label is the verifier's
// display name ("MillionVerifier") when there is one.
source: "" | "probe" | "provider" | "imported" | "manual";
provider: string;
provider_label?: string;
// What that check said, before real mail was weighed against it.
check_status: "" | "valid" | "risky" | "invalid" | "unknown";
checked_at?: string | null;
// Set while a re-check a member asked for waits to run.
requested_at?: string | null;
}
export default interface ContactDetail extends Contact {
@@ -19,8 +19,8 @@ export interface ContactsCounts {
verification?: ContactVerificationCounts;
}
// Org contacts by verification verdict. pending is the subset of unknown
// nobody has checked yet.
// Org contacts by verification verdict. pending counts contacts never checked
// plus those with a re-check queued.
export interface ContactVerificationCounts {
valid: number;
risky: number;