diff --git a/docs/content/docs/api/endpoints.mdx b/docs/content/docs/api/endpoints.mdx index 99193a613..e54700306 100644 --- a/docs/content/docs/api/endpoints.mdx +++ b/docs/content/docs/api/endpoints.mdx @@ -102,7 +102,7 @@ The three `/campaigns/:id/leads/:contactId/…` calls hold one contact's flow in |--------|------|----------------| | POST | `/contacts/search` | `READ_CONTACTS` | | GET | `/contacts/custom-fields` | `READ_CONTACTS` | -| GET | `/contacts/lookup` | `READ_CONTACTS` | +| GET | `/contacts/lookup` | `READ_CONTACTS` (plus `READ_UNIBOX` with `thread_id`) | | POST | `/contacts` | `WRITE_CONTACTS` | | DELETE | `/contacts` | `BULK_CONTACTS` | | PATCH | `/contacts` | `BULK_CONTACTS` | diff --git a/docs/content/docs/api/reference/contacts.mdx b/docs/content/docs/api/reference/contacts.mdx index e2e1e816f..f3e6b6ec1 100644 --- a/docs/content/docs/api/reference/contacts.mdx +++ b/docs/content/docs/api/reference/contacts.mdx @@ -643,11 +643,15 @@ For `verify`, `verifier` names who runs the checks (`builtin`, `millionverifier` Resolves a sender address to a contact in your organization. Returns `200` with `{"contact": null}` when nothing matches, so unknown senders render a clean empty state rather than a `404`. A display-name wrapped address (`Name `) is accepted and unwrapped. -Auth: **Scope** `READ_CONTACTS` · **Org permission** `view_contacts` +Pass `thread_id` to resolve a reply the way the unibox does. The address is tried first; when it is not a contact, the thread's campaign send answers instead, so a reply from an alias or a forwarded address resolves to the lead the campaign emailed. `match` says which one answered: `email` or `thread`. It is absent when `contact` is `null`. The thread is read only in the mailboxes an API key's email account allowlist permits, and an `account_id` outside it returns `403`. + +Auth: **Scope** `READ_CONTACTS` · **Org permission** `view_contacts`. With `thread_id`, also **Scope** `READ_UNIBOX` · **Org permission** `access_unibox`. | Parameter | In | Type | Description | | --- | --- | --- | --- | -| `email` | query | string | The email address to resolve (required). | +| `email` | query | string | The email address to resolve. Required unless `thread_id` is given. | +| `thread_id` | query | string | Optional. A unibox thread id (`thread_id` on a unibox message). | +| `account_id` | query | UUID | Optional. The mailbox holding the thread, to limit the thread match to it. A malformed id returns `400`. | ### Response @@ -667,7 +671,8 @@ Auth: **Scope** `READ_CONTACTS` · **Org permission** `view_contacts` "esp_provider": "gmail", "updated_at": "2026-06-10T12:00:00Z", "created_at": "2026-05-01T09:30:00Z" - } + }, + "match": "email" } ``` diff --git a/docs/content/docs/guides/unibox.mdx b/docs/content/docs/guides/unibox.mdx index 7ef56de42..a01560cfd 100644 --- a/docs/content/docs/guides/unibox.mdx +++ b/docs/content/docs/guides/unibox.mdx @@ -21,6 +21,8 @@ How wide the list can get depends on the window: the thread always keeps enough On a screen narrower than 1024px the contact panel is an overlay rather than a column, so it always starts closed there. Below 768px the list and the thread take turns on the full screen instead of sitting side by side. +The contact panel shows the person on the other side of the conversation: the first sender who is not one of the workspace's mailboxes. It finds their contact by address first. When that address is not a contact but the conversation answers one of your campaign emails (people often reply from an alias, a second domain or a colleague's account), the panel shows the lead that campaign wrote to, with a **Replied from** line naming the address the reply actually came from. Only when neither matches does it offer **Add as contact**. + ## Keyboard The conversation list takes the keyboard, so a triage pass never needs the mouse. Press `?` anywhere in Warmbly for the full list; these are the ones that act on the inbox. diff --git a/docs/public/openapi.json b/docs/public/openapi.json index 8fffbb357..2d1cdcc43 100644 --- a/docs/public/openapi.json +++ b/docs/public/openapi.json @@ -8188,7 +8188,7 @@ ], "operationId": "contacts_lookup", "summary": "Look up a contact by email", - "description": "Resolves a sender address to a contact. Returns 200 with {\"contact\": null} when nothing matches. A display-name wrapped address (`Name `) is accepted and unwrapped. Scope `READ_CONTACTS`.", + "description": "Resolves a sender address to a contact. Returns 200 with {\"contact\": null} when nothing matches. A display-name wrapped address (`Name `) is accepted and unwrapped. With `thread_id`, a sender whose address is not a contact resolves to the lead of the campaign send the thread answers; `match` says which one answered. Scope `READ_CONTACTS`, plus `READ_UNIBOX` with `thread_id`.", "security": [ { "bearerAuth": [] @@ -8198,11 +8198,30 @@ { "name": "email", "in": "query", - "required": true, - "description": "The email address to resolve.", + "required": false, + "description": "The email address to resolve. Required unless thread_id is given.", "schema": { "type": "string" } + }, + { + "name": "thread_id", + "in": "query", + "required": false, + "description": "A unibox thread id. Used when the address is not a contact.", + "schema": { + "type": "string" + } + }, + { + "name": "account_id", + "in": "query", + "required": false, + "description": "The mailbox holding the thread, to limit the thread match to it.", + "schema": { + "type": "string", + "format": "uuid" + } } ], "responses": { @@ -8217,7 +8236,7 @@ } }, "400": { - "description": "Missing or malformed email.", + "description": "Missing email and thread_id, or a malformed account_id.", "content": { "application/json": { "schema": { @@ -8237,7 +8256,7 @@ } }, "403": { - "description": "API key lacks READ_CONTACTS or caller lacks view_contacts.", + "description": "API key lacks READ_CONTACTS (or READ_UNIBOX with thread_id), caller lacks view_contacts (or access_unibox), or account_id is outside the key's email account allowlist.", "content": { "application/json": { "schema": { @@ -31336,6 +31355,14 @@ "$ref": "#/components/schemas/Contact" } ] + }, + "match": { + "type": "string", + "enum": [ + "email", + "thread" + ], + "description": "What resolved the contact: the sender's address, or the campaign send the thread answers. Absent when contact is null." } } }, diff --git a/internal/api/handler/contact_detail.go b/internal/api/handler/contact_detail.go index 8909a8a31..fed9f7644 100644 --- a/internal/api/handler/contact_detail.go +++ b/internal/api/handler/contact_detail.go @@ -45,7 +45,7 @@ func (h *Handler) GetContact(c *gin.Context) { // LookupContactByEmail resolves a sender address to a contact in the caller's // organization, for the unibox CRM panel. Returns {"contact": null} with 200 // when no contact matches, so an unknown sender renders a clean empty state -// rather than a 404. +// rather than a 404. thread_id resolves a sender from an alias to the lead. func (h *Handler) LookupContactByEmail(c *gin.Context) { email := strings.TrimSpace(c.Query("email")) // Senders often arrive as "Display Name " (the unibox @@ -56,20 +56,35 @@ func (h *Handler) LookupContactByEmail(c *gin.Context) { email = strings.TrimSpace(email[i+1 : i+j]) } } - if email == "" { - errx.Handle(c, errx.New(errx.BadRequest, "email is required")) + threadID := strings.TrimSpace(c.Query("thread_id")) + if email == "" && threadID == "" { + errx.Handle(c, errx.New(errx.BadRequest, "email or thread_id is required")) return } + var accountID *uuid.UUID + if v := strings.TrimSpace(c.Query("account_id")); v != "" { + id, err := uuid.Parse(v) + if err != nil { + errx.Handle(c, errx.ErrUuid) + return + } + if !middleware.APIKeyAllowsEmailAccount(c, id) { + errx.Handle(c, errx.New(errx.Forbidden, "email account is not allowed for this API key")) + return + } + accountID = &id + } orgID := middleware.GetOrganizationID(c) - contact, xerr := h.ContactService.GetByEmail(c.Request.Context(), orgID, email) + thread := models.ContactLookupThread{ID: threadID, AccountID: accountID, AllowedAccounts: middleware.GetAPIKeyAllowedEmailAccounts(c)} + res, xerr := h.ContactService.LookupSender(c.Request.Context(), orgID, email, thread) if xerr != nil { errx.Handle(c, xerr) return } - c.JSON(http.StatusOK, gin.H{"contact": contact}) + c.JSON(http.StatusOK, res) } // ListContactEmails returns one row per email we sent (or tried to diff --git a/internal/api/middleware/apikey.go b/internal/api/middleware/apikey.go index d88cf295c..99f011bf9 100644 --- a/internal/api/middleware/apikey.go +++ b/internal/api/middleware/apikey.go @@ -271,6 +271,19 @@ func (h *Handler) RequireAccess(orgPerm models.OrganizationPermission, apiPerm u } } +// RequireAccessWithQuery applies RequireAccess only when the request carries +// the named query parameter. +func (h *Handler) RequireAccessWithQuery(param string, orgPerm models.OrganizationPermission, apiPerm uint64) gin.HandlerFunc { + gate := h.RequireAccess(orgPerm, apiPerm) + return func(c *gin.Context) { + if strings.TrimSpace(c.Query(param)) == "" { + c.Next() + return + } + gate(c) + } +} + // RequireAnyAccess is like RequireAccess but a JWT caller passes if they hold // ANY of the listed organization permissions (the API-key path is unchanged: it // checks the single apiPerm). Use on read routes reachable by multiple roles — diff --git a/internal/api/middleware/apikey_test.go b/internal/api/middleware/apikey_test.go index 227d625d0..341b93b2e 100644 --- a/internal/api/middleware/apikey_test.go +++ b/internal/api/middleware/apikey_test.go @@ -139,3 +139,42 @@ func TestRequireAccessAPIKeyPath(t *testing.T) { }) } } + +// The unibox gate on /contacts/lookup applies only when thread_id is present. +func TestRequireAccessWithQueryOnlyGatesWithTheParam(t *testing.T) { + h := &Handler{} + tests := []struct { + name string + url string + granted uint64 + wantStatus int + }{ + {"no param, no unibox scope", "/x?email=a@b.test", models.APIPermReadContacts, http.StatusOK}, + {"param, no unibox scope", "/x?thread_id=t1", models.APIPermReadContacts, http.StatusForbidden}, + {"blank param", "/x?thread_id=%20", models.APIPermReadContacts, http.StatusOK}, + {"param, unibox scope", "/x?thread_id=t1", models.APIPermReadContacts | models.APIPermReadUnibox, http.StatusOK}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + r := gin.New() + r.Use(func(c *gin.Context) { + c.Set(AuthTypeKey, AuthTypeAPIKey) + c.Set(APIKeyPermissionsKey, tt.granted) + c.Next() + }) + calls := 0 + r.GET("/x", h.RequireAccessWithQuery("thread_id", models.PermAccessUnibox, models.APIPermReadUnibox), func(c *gin.Context) { + calls++ + c.JSON(http.StatusOK, gin.H{}) + }) + w := httptest.NewRecorder() + r.ServeHTTP(w, httptest.NewRequestWithContext(context.Background(), http.MethodGet, tt.url, nil)) + if w.Code != tt.wantStatus { + t.Errorf("status = %d, want %d", w.Code, tt.wantStatus) + } + if want := map[bool]int{true: 1, false: 0}[tt.wantStatus == http.StatusOK]; calls != want { + t.Errorf("handler ran %d times, want %d", calls, want) + } + }) + } +} diff --git a/internal/api/routes.go b/internal/api/routes.go index 4297b14b0..4c61d583d 100644 --- a/internal/api/routes.go +++ b/internal/api/routes.go @@ -809,7 +809,9 @@ func Run( // Resolve a sender address to a contact (unibox CRM panel). // Registered before /:id so the fixed path wins over the catch-all. - contacts.GET("/lookup", m.RequireAccess(models.PermViewContacts, models.APIPermReadContacts), h.LookupContactByEmail) + // A thread_id reads the unibox, so it needs unibox access as well. + contacts.GET("/lookup", m.RequireAccess(models.PermViewContacts, models.APIPermReadContacts), + m.RequireAccessWithQuery("thread_id", models.PermAccessUnibox, models.APIPermReadUnibox), h.LookupContactByEmail) // Distinct custom-field keys across the org's contacts, for the // dashboard variable picker. Fixed path, so before /:id. diff --git a/internal/app/contact/handler.go b/internal/app/contact/handler.go index 44b316c81..f1dd72e8e 100644 --- a/internal/app/contact/handler.go +++ b/internal/app/contact/handler.go @@ -249,6 +249,31 @@ func (s *contactService) GetByEmail(ctx context.Context, orgID *uuid.UUID, email return s.contactRepository.GetByEmailAndOrganization(ctx, *orgID, email) } +func (s *contactService) LookupSender(ctx context.Context, orgID *uuid.UUID, email string, thread models.ContactLookupThread) (*models.ContactLookup, *errx.Error) { + out := &models.ContactLookup{} + if orgID == nil { + return out, nil + } + if strings.TrimSpace(email) != "" { + contact, xerr := s.contactRepository.GetByEmailAndOrganization(ctx, *orgID, email) + if xerr != nil { + return nil, xerr + } + if contact != nil { + out.Contact, out.Match = contact, models.ContactLookupMatchEmail + return out, nil + } + } + contact, xerr := s.contactRepository.GetByThreadAndOrganization(ctx, *orgID, thread) + if xerr != nil { + return nil, xerr + } + if contact != nil { + out.Contact, out.Match = contact, models.ContactLookupMatchThread + } + return out, nil +} + func (s *contactService) ListSentEmails(ctx context.Context, orgID, contactID uuid.UUID, limit int, beforeSentAt *time.Time, beforeTaskID *uuid.UUID) (*models.ContactSentEmailsResult, *errx.Error) { return s.contactRepository.ListSentEmails(ctx, orgID, contactID, limit, beforeSentAt, beforeTaskID) } diff --git a/internal/app/contact/service.go b/internal/app/contact/service.go index 0a76490b7..2d5809dc2 100644 --- a/internal/app/contact/service.go +++ b/internal/app/contact/service.go @@ -87,6 +87,9 @@ type ContactService interface { // non-error outcome used by the unibox CRM panel. GetByEmail(ctx context.Context, orgID *uuid.UUID, email string) (*models.Contact, *errx.Error) + // LookupSender matches the address first, then the thread's campaign send. + LookupSender(ctx context.Context, orgID *uuid.UUID, email string, thread models.ContactLookupThread) (*models.ContactLookup, *errx.Error) + // ListSentEmails enumerates every send (or attempted send) we made // to the contact, newest first. ListSentEmails(ctx context.Context, orgID, contactID uuid.UUID, limit int, beforeSentAt *time.Time, beforeTaskID *uuid.UUID) (*models.ContactSentEmailsResult, *errx.Error) diff --git a/internal/models/contact.go b/internal/models/contact.go index 0fb7d54f4..802d85cd7 100644 --- a/internal/models/contact.go +++ b/internal/models/contact.go @@ -900,3 +900,29 @@ type BulkEditContactsData struct { // than are worth serializing back. Never part of the request body. SkipRows bool `json:"-"` } + +// ContactLookupMatch says how a unibox sender resolved to a contact. +type ContactLookupMatch string + +const ( + // ContactLookupMatchEmail: the sender's address is the contact's. + ContactLookupMatchEmail ContactLookupMatch = "email" + // ContactLookupMatchThread: the thread answers a campaign send to the + // contact, and the reply came from another address. + ContactLookupMatchThread ContactLookupMatch = "thread" +) + +// ContactLookupThread is the unibox thread a sender wrote in. AllowedAccounts +// is an API key's mailbox allowlist; empty means every mailbox. +type ContactLookupThread struct { + ID string + AccountID *uuid.UUID + AllowedAccounts []uuid.UUID +} + +// ContactLookup is the answer to GET /contacts/lookup; Contact is nil when +// nothing matched. +type ContactLookup struct { + Contact *Contact `json:"contact"` + Match ContactLookupMatch `json:"match,omitempty"` +} diff --git a/internal/repository/contact_thread_lookup_live_test.go b/internal/repository/contact_thread_lookup_live_test.go new file mode 100644 index 000000000..b934625d3 --- /dev/null +++ b/internal/repository/contact_thread_lookup_live_test.go @@ -0,0 +1,107 @@ +package repository + +import ( + "context" + "testing" + "time" + + "github.com/google/uuid" + + "github.com/warmbly/warmbly/internal/models" +) + +// A reply from an address that is not the contact's (an alias, a forward) +// still resolves to the lead the campaign wrote to, through the thread. +// +// WARMBLY_TEST_DB=postgres://warmbly:warmbly@localhost:15432/?sslmode=disable \ +// go test ./internal/repository/ -run LiveContactThreadLookup -v + +func newContactThreadLookupFixture(t *testing.T) (*threadParentFixture, ContactRepository) { + t.Helper() + handle, pool := liveContactDB(t) + f := newThreadParentFixture(t, pool) + t.Cleanup(func() { + if _, err := pool.Exec(context.Background(), + `DELETE FROM unibox_emails WHERE email_id IN (SELECT id FROM email_accounts WHERE organization_id = $1)`, f.org); err != nil { + t.Errorf("cleanup unibox: %v", err) + } + }) + return f, NewContactRepostory(handle) +} + +func threadRef(id string, account *uuid.UUID, allowed ...uuid.UUID) models.ContactLookupThread { + return models.ContactLookupThread{ID: id, AccountID: account, AllowedAccounts: allowed} +} + +func TestLiveContactThreadLookupThroughTheSentCopy(t *testing.T) { + f, repo := newContactThreadLookupFixture(t) + ctx := context.Background() + step := f.step(0, "Hello", false) + f.send(step, f.mailbox, "", "", 600) + now := time.Now().UTC() + f.unibox(f.mailbox, "sent", "sent-1@test.local", "imap-thread", "me@test.local", nil, now.Add(-10*time.Minute)) + f.unibox(f.mailbox, "inbox", "", "imap-thread", "Alias ", nil, now.Add(-5*time.Minute)) + + got, xerr := repo.GetByThreadAndOrganization(ctx, f.org, threadRef("imap-thread", nil)) + if xerr != nil || got == nil || got.ID != f.contact { + t.Fatalf("got %+v (%v), want the campaign's lead", got, xerr) + } + mb, other := f.mailbox, f.other + if got, _ := repo.GetByThreadAndOrganization(ctx, f.org, threadRef("imap-thread", &mb)); got == nil || got.ID != f.contact { + t.Fatalf("scoped to the mailbox: got %+v", got) + } + if got, _ := repo.GetByThreadAndOrganization(ctx, f.org, threadRef("imap-thread", nil, mb)); got == nil || got.ID != f.contact { + t.Fatalf("allowlist naming the mailbox: got %+v", got) + } + + // A mailbox that does not hold the thread, an allowlist without it, another + // organization, or an unknown thread resolve nothing. + if got, _ := repo.GetByThreadAndOrganization(ctx, f.org, threadRef("imap-thread", &other)); got != nil { + t.Fatalf("another mailbox resolved %+v", got) + } + if got, _ := repo.GetByThreadAndOrganization(ctx, f.org, threadRef("imap-thread", nil, other)); got != nil { + t.Fatalf("an allowlist without the mailbox resolved %+v", got) + } + if got, _ := repo.GetByThreadAndOrganization(ctx, uuid.New(), threadRef("imap-thread", nil)); got != nil { + t.Fatalf("another organization resolved %+v", got) + } + if got, _ := repo.GetByThreadAndOrganization(ctx, f.org, threadRef("no-such-thread", nil)); got != nil { + t.Fatalf("an unknown thread resolved %+v", got) + } + if got, _ := repo.GetByThreadAndOrganization(ctx, f.org, threadRef("", nil)); got != nil { + t.Fatalf("an empty thread resolved %+v", got) + } +} + +// With no sent copy synced, a Message-ID the reply names is enough, even when +// the reply lands in another workspace mailbox than the one that sent. +func TestLiveContactThreadLookupThroughInReplyTo(t *testing.T) { + f, repo := newContactThreadLookupFixture(t) + ctx := context.Background() + step := f.step(0, "Hello", false) + f.send(step, f.mailbox, "", "", 600) + f.unibox(f.other, "inbox", "", "reply-only", "alias@alias.test", []string{""}, time.Now().UTC()) + + got, xerr := repo.GetByThreadAndOrganization(ctx, f.org, threadRef("reply-only", nil)) + if xerr != nil || got == nil || got.ID != f.contact { + t.Fatalf("got %+v (%v), want the campaign's lead", got, xerr) + } +} + +// On Gmail the thread handle the worker recorded on the send is enough, but +// only for a mailbox that holds the thread. +func TestLiveContactThreadLookupThroughTheGmailThread(t *testing.T) { + f, repo := newContactThreadLookupFixture(t) + ctx := context.Background() + step := f.step(0, "Hello", false) + f.send(step, f.other, "", "gthr-9", 500) + f.unibox(f.other, "inbox", "", "gthr-9", "alias@alias.test", nil, time.Now().UTC()) + + if got, xerr := repo.GetByThreadAndOrganization(ctx, f.org, threadRef("gthr-9", nil)); xerr != nil || got == nil || got.ID != f.contact { + t.Fatalf("got %+v (%v), want the campaign's lead", got, xerr) + } + mb := f.mailbox + if got, _ := repo.GetByThreadAndOrganization(ctx, f.org, threadRef("gthr-9", &mb)); got != nil { + t.Fatalf("a handle from a mailbox without the thread resolved %+v", got) + } +} diff --git a/internal/repository/pg_contact.go b/internal/repository/pg_contact.go index 80549bb20..187289c6b 100644 --- a/internal/repository/pg_contact.go +++ b/internal/repository/pg_contact.go @@ -29,6 +29,8 @@ type ContactRepository interface { Add(ctx context.Context, userID string, orgID uuid.UUID, contacts []models.AddContact) ([]models.Contact, *errx.Error) GetByID(ctx context.Context, contactID uuid.UUID) (*models.Contact, *errx.Error) GetByEmailAndOrganization(ctx context.Context, organizationID uuid.UUID, email string) (*models.Contact, *errx.Error) + // GetByThreadAndOrganization is the lead of the campaign send a unibox thread answers; (nil, nil) when none. + GetByThreadAndOrganization(ctx context.Context, organizationID uuid.UUID, thread models.ContactLookupThread) (*models.Contact, *errx.Error) // GetByIDsAndOrganization fetches the org's contacts for a set of IDs. Used // by the synchronous "push to CRM" action so a member can only push contacts // that belong to their organization. Foreign/missing IDs are omitted. @@ -1014,20 +1016,13 @@ func (r *contactRepository) VerificationCounts(ctx context.Context, orgID uuid.U return c, nil } -func (r *contactRepository) GetByEmailAndOrganization(ctx context.Context, organizationID uuid.UUID, email string) (*models.Contact, *errx.Error) { - query := ` - SELECT - c.id, c.first_name, c.last_name, c.email, c.company, c.phone, - c.custom_fields, c.subscribed, c.updated_at, c.created_at - FROM contacts c - WHERE c.organization_id = $1 - AND LOWER(c.email) = LOWER($2) - ORDER BY c.updated_at DESC - LIMIT 1 - ` +// lookupContactColumns and scanLookupContact are the payload both sender lookups return. +const lookupContactColumns = `c.id, c.first_name, c.last_name, c.email, c.company, c.phone, + c.custom_fields, c.subscribed, c.updated_at, c.created_at` +func (r *contactRepository) scanLookupContact(ctx context.Context, query string, args ...any) (*models.Contact, *errx.Error) { var contact models.Contact - err := r.DB.QueryRow(ctx, query, organizationID, strings.TrimSpace(email)).Scan( + err := r.DB.QueryRow(ctx, query, args...).Scan( &contact.ID, &contact.FirstName, &contact.LastName, &contact.Email, &contact.Company, &contact.Phone, &contact.CustomFields, &contact.Subscribed, &contact.UpdatedAt, &contact.CreatedAt, @@ -1036,7 +1031,7 @@ func (r *contactRepository) GetByEmailAndOrganization(ctx context.Context, organ if err == pgx.ErrNoRows { return nil, nil } - db.CaptureError(err, query, []any{organizationID, email}, "queryrow") + db.CaptureError(err, query, args, "queryrow") return nil, errx.InternalError() } contact.Campaigns = []models.MiniCampaign{} @@ -1044,6 +1039,68 @@ func (r *contactRepository) GetByEmailAndOrganization(ctx context.Context, organ return &contact, nil } +func (r *contactRepository) GetByEmailAndOrganization(ctx context.Context, organizationID uuid.UUID, email string) (*models.Contact, *errx.Error) { + query := ` + SELECT ` + lookupContactColumns + ` + FROM contacts c + WHERE c.organization_id = $1 + AND LOWER(c.email) = LOWER($2) + ORDER BY c.updated_at DESC + LIMIT 1 + ` + return r.scanLookupContact(ctx, query, organizationID, strings.TrimSpace(email)) +} + +func (r *contactRepository) GetByThreadAndOrganization(ctx context.Context, organizationID uuid.UUID, thread models.ContactLookupThread) (*models.Contact, *errx.Error) { + threadID := strings.TrimSpace(thread.ID) + if threadID == "" { + return nil, nil + } + allowed := thread.AllowedAccounts + if allowed == nil { + allowed = []uuid.UUID{} + } + // A Message-ID may name a send from any workspace mailbox; the Gmail thread handle only one holding the thread. + query := ` + WITH msgs AS ( + SELECT ue.email_id, ue.message_id, ue.in_reply_to + FROM unibox_emails ue + JOIN email_accounts ea ON ea.id = ue.email_id + WHERE ea.organization_id = $1 AND ue.thread_id = $2 + AND ($3::uuid IS NULL OR ue.email_id = $3) + AND (cardinality($4::uuid[]) = 0 OR ue.email_id = ANY($4)) + ), + ids AS ( + SELECT DISTINCT x FROM ( + SELECT BTRIM(message_id, '<> ') AS x FROM msgs + UNION ALL + SELECT BTRIM(ref, '<> ') FROM msgs, unnest(COALESCE(msgs.in_reply_to, '{}')) AS ref + ) raw + WHERE x <> '' + ), + matched AS ( + SELECT t.id, 0 AS rank, t.created_at + FROM tasks t + WHERE t.task_type = 'campaign' AND t.message_id <> '' + AND t.message_id IN (SELECT x FROM ids UNION ALL SELECT '<' || x || '>' FROM ids) + AND t.email_account_id IN (SELECT id FROM email_accounts WHERE organization_id = $1) + UNION ALL + SELECT t.id, 1 AS rank, t.created_at + FROM tasks t + WHERE t.task_type = 'campaign' AND t.thread_id = $2 + AND t.email_account_id IN (SELECT email_id FROM msgs) + ) + SELECT ` + lookupContactColumns + ` + FROM matched m + JOIN campaign_tasks ct ON ct.task_id = m.id + JOIN contacts c ON c.id = ct.contact_id + WHERE c.organization_id = $1 + ORDER BY m.rank, m.created_at DESC + LIMIT 1 + ` + return r.scanLookupContact(ctx, query, organizationID, threadID, thread.AccountID, allowed) +} + func (r *contactRepository) OwnerUserID(ctx context.Context, organizationID, contactID uuid.UUID) (*uuid.UUID, error) { var userID uuid.UUID err := r.DB.QueryRow(ctx, diff --git a/web/src/components/app/unibox/ContactContextPanel.tsx b/web/src/components/app/unibox/ContactContextPanel.tsx index b8494d97d..073a046b5 100644 --- a/web/src/components/app/unibox/ContactContextPanel.tsx +++ b/web/src/components/app/unibox/ContactContextPanel.tsx @@ -15,11 +15,13 @@ import { CheckIcon, ChevronDownIcon, CircleDollarSignIcon, + CornerDownRightIcon, ExternalLinkIcon, Loader2Icon, MailWarningIcon, MegaphoneIcon, PlusIcon, + RefreshCwIcon, StickyNoteIcon, UserIcon, UserXIcon, @@ -91,6 +93,9 @@ export default function ContactContextPanel({ email, name: fromName, mailboxId, + threadId, + threadMailboxId, + wroteBack, onClose, }: { email?: string; @@ -98,10 +103,17 @@ export default function ContactContextPanel({ // sender as a contact. name?: string; mailboxId?: string; + // The conversation, so a reply from an alias still finds the campaign's lead. + threadId?: string; + // Set only when the thread itself is scoped to one mailbox. + threadMailboxId?: string; + // The email is a sender in the thread, not only a recipient. + wroteBack?: boolean; onClose?: () => void; }) { - const lookup = useContactByEmail(email); - const contact = lookup.data ?? null; + const lookup = useContactByEmail(email, true, { threadId, mailboxId: threadMailboxId }); + const contact = lookup.data?.contact ?? null; + const repliedFromOther = !!wroteBack && lookup.data?.match === "thread"; const contactId = contact?.id; const [meetingOpen, setMeetingOpen] = React.useState(false); @@ -152,6 +164,20 @@ export default function ContactContextPanel({ Resolving contact… + ) : lookup.isError && !lookup.data ? ( +
+

Couldn't load this contact

+ {email &&

{email}

} + +
) : !contact ? ( ) : ( @@ -170,6 +196,17 @@ export default function ContactContextPanel({ )} + {repliedFromOther && email && ( +
+ + + Replied from {email} + +
+ )}
{contact.subscribed ? ( }>Subscribed diff --git a/web/src/components/app/unibox/ReplyComposer.tsx b/web/src/components/app/unibox/ReplyComposer.tsx index 48039b172..371d7b7af 100644 --- a/web/src/components/app/unibox/ReplyComposer.tsx +++ b/web/src/components/app/unibox/ReplyComposer.tsx @@ -290,7 +290,12 @@ export function ReplyComposer({ threadId, replyTo, mode, seed, onClose }: ReplyC const candidatesQ = useComposeCandidates(primary, wantCandidates); // Holds the recipient's follow-ups once a reply is accepted; a forward goes to someone else. - const followUps = usePauseFollowUps(mode === "reply" && primary ? primary : undefined); + // The thread's lead stands in only for the person who wrote the message being answered. + const answeringSender = !!primary && primary.toLowerCase() === bareEmail(replyTo.from ?? "").toLowerCase(); + const followUps = usePauseFollowUps( + mode === "reply" && primary ? primary : undefined, + answeringSender ? { threadId } : undefined, + ); const [followUpPause, setFollowUpPause] = React.useState(null); // Unticked rather than ticked, so a campaign that appears later is included. const [followUpSkip, setFollowUpSkip] = React.useState([]); diff --git a/web/src/components/app/unibox/ThreadView.tsx b/web/src/components/app/unibox/ThreadView.tsx index bdffafff9..a366fe90c 100644 --- a/web/src/components/app/unibox/ThreadView.tsx +++ b/web/src/components/app/unibox/ThreadView.tsx @@ -331,17 +331,22 @@ export function ThreadView({ threadId, emailId }: ThreadViewProps) { const subject = messages[0]?.subject || "(no subject)"; const mailbox = accounts.find((a) => a.id === messages[0]?.account_id); - // The external party of the thread = the first message address that isn't - // our own mailbox. Headers arrive in all three shapes lib/helper/emailAddress + // The external party of the thread = the first sender that isn't one of + // the workspace's mailboxes, else (nobody has written back yet) the first + // such recipient. Headers arrive in all three shapes lib/helper/emailAddress // parses; reduce to the bare address so the comparison + the lookup work. const mailboxEmail = mailbox?.email?.toLowerCase(); + const ownAddresses = new Set(accounts.map((a) => a.email?.toLowerCase()).filter(Boolean)); + if (mailboxEmail) ownAddresses.add(mailboxEmail); + const isExternal = (addr: string) => { + const e = bareEmail(addr); + return !!e && !ownAddresses.has(e.toLowerCase()); + }; + const externalFrom = messages.map((m) => m.from).find(isExternal); const contactFrom = - messages - .map((m) => m.from) - .find((f) => { - const e = bareEmail(f); - return e && e.toLowerCase() !== mailboxEmail; - }) ?? (messages[0]?.from ?? ""); + externalFrom ?? + messages.flatMap((m) => m.recipients ?? [m.to]).find(isExternal) ?? + (messages[0]?.from ?? ""); const contactEmail = bareEmail(contactFrom); // Display name from the From header, so an "Add as contact" from the // panel does not create a nameless row. Empty when the header is bare. @@ -685,7 +690,10 @@ export function ThreadView({ threadId, emailId }: ThreadViewProps) { setCrmOpen(false)} /> )} diff --git a/web/src/lib/api/client/app/contacts/lookupContact.ts b/web/src/lib/api/client/app/contacts/lookupContact.ts index dd26bccf2..955c97682 100644 --- a/web/src/lib/api/client/app/contacts/lookupContact.ts +++ b/web/src/lib/api/client/app/contacts/lookupContact.ts @@ -1,13 +1,32 @@ import type Contact from "@/lib/api/models/app/contacts/Contact"; import Request from "../../Request"; -// Resolve a sender address to a contact in the current organization. Returns -// null when the address isn't a known contact (a normal, non-error outcome). -export default async function lookupContact(email: string): Promise { - const res = await Request<{ contact: Contact | null }>({ +// "email": the sender's address is the contact's. "thread": the thread answers +// a campaign send to the contact and the reply came from another address. +export type ContactLookupMatch = "email" | "thread"; + +export interface ContactLookup { + contact: Contact | null; + match?: ContactLookupMatch; +} + +// The conversation the sender wrote in, so a reply from an alias still +// resolves to the lead the campaign wrote to. +export interface ContactLookupThread { + threadId?: string; + mailboxId?: string; +} + +// Resolve a sender address to a contact in the current organization. A null +// contact means the sender isn't a known contact (a normal, non-error outcome). +export default async function lookupContact(email: string, thread?: ContactLookupThread): Promise { + const params = new URLSearchParams({ email }); + if (thread?.threadId) params.set("thread_id", thread.threadId); + if (thread?.mailboxId) params.set("account_id", thread.mailboxId); + const res = await Request({ method: "GET", - url: `/contacts/lookup?email=${encodeURIComponent(email)}`, + url: `/contacts/lookup?${params.toString()}`, authorization: true, }); - return res?.contact ?? null; + return { contact: res?.contact ?? null, match: res?.match }; } diff --git a/web/src/lib/api/hooks/app/campaigns/usePauseFollowUps.ts b/web/src/lib/api/hooks/app/campaigns/usePauseFollowUps.ts index 6417fe2ad..c949d5796 100644 --- a/web/src/lib/api/hooks/app/campaigns/usePauseFollowUps.ts +++ b/web/src/lib/api/hooks/app/campaigns/usePauseFollowUps.ts @@ -2,6 +2,7 @@ import React from "react"; import { useQueryClient } from "@tanstack/react-query"; import { usePermission } from "@/hooks/usePermission"; import useContactByEmail from "@/lib/api/hooks/app/contacts/useContactByEmail"; +import type { ContactLookupThread } from "@/lib/api/client/app/contacts/lookupContact"; import useContactCampaignStates from "@/lib/api/hooks/app/contacts/useContactCampaignStates"; import { pauseCampaignLead } from "@/lib/api/client/app/campaigns/leadHold"; import listContactCampaignStates from "@/lib/api/client/app/contacts/listContactCampaignStates"; @@ -14,11 +15,11 @@ export interface FollowUpTargets { } // A reply recipient's campaigns with a step still to send, and a pause for them; empty without MANAGE_CAMPAIGNS. -export default function usePauseFollowUps(email: string | undefined) { +export default function usePauseFollowUps(email: string | undefined, thread?: ContactLookupThread) { const allowed = usePermission("MANAGE_CAMPAIGNS"); const queryClient = useQueryClient(); - const lookup = useContactByEmail(email, allowed); - const contactId = lookup.data?.id ?? ""; + const lookup = useContactByEmail(email, allowed, thread); + const contactId = lookup.data?.contact?.id ?? ""; const statesQ = useContactCampaignStates(contactId, allowed); const targets = React.useMemo( diff --git a/web/src/lib/api/hooks/app/contacts/useContactByEmail.ts b/web/src/lib/api/hooks/app/contacts/useContactByEmail.ts index 13e96687f..cca151eee 100644 --- a/web/src/lib/api/hooks/app/contacts/useContactByEmail.ts +++ b/web/src/lib/api/hooks/app/contacts/useContactByEmail.ts @@ -1,11 +1,12 @@ import { useQuery } from "@tanstack/react-query"; -import lookupContact from "@/lib/api/client/app/contacts/lookupContact"; +import lookupContact, { type ContactLookupThread } from "@/lib/api/client/app/contacts/lookupContact"; -// Resolves a sender email to a contact (or null) for the unibox CRM panel. -export default function useContactByEmail(email: string | undefined, enabled = true) { +// Resolves a sender email to a contact (or null) for the unibox CRM panel; with +// the thread, a reply from an alias resolves to the campaign's lead. +export default function useContactByEmail(email: string | undefined, enabled = true, thread?: ContactLookupThread) { return useQuery({ - queryKey: ["contacts", "by-email", email ?? ""], - queryFn: () => lookupContact(email as string), + queryKey: ["contacts", "by-email", email ?? "", thread?.threadId ?? "", thread?.mailboxId ?? ""], + queryFn: () => lookupContact(email as string, thread), enabled: enabled && !!email, staleTime: 60_000, });