mirror of
https://github.com/warmbly/warmbly.git
synced 2026-10-05 00:02:12 +00:00
Merge pull request #728 from warmbly/fix/inbox-sidebar-contact-match
feat: resolve the unibox contact panel's sender through the thread's campaign send when the reply comes from another address
This commit is contained in:
@@ -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` |
|
||||
|
||||
@@ -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 <addr@example.com>`) 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"
|
||||
}
|
||||
```
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 <addr@example.com>`) 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 <addr@example.com>`) 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."
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -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 <addr@example.com>" (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
|
||||
|
||||
@@ -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 —
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"`
|
||||
}
|
||||
|
||||
@@ -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/<db>?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, "<sent-1@test.local>", "", 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", "<reply@alias.test>", "imap-thread", "Alias <alias@alias.test>", 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, "<sent-2@test.local>", "", 600)
|
||||
f.unibox(f.other, "inbox", "<r1@alias.test>", "reply-only", "alias@alias.test", []string{"<sent-2@test.local>"}, 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, "<minted@gmail.com>", "gthr-9", 500)
|
||||
f.unibox(f.other, "inbox", "<r2@alias.test>", "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)
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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({
|
||||
<Loader2Icon className="w-3.5 h-3.5 animate-spin" />
|
||||
Resolving contact…
|
||||
</div>
|
||||
) : lookup.isError && !lookup.data ? (
|
||||
<div className="px-4 py-8 text-center">
|
||||
<p className="text-[12px] font-medium text-slate-700 mb-0.5">Couldn't load this contact</p>
|
||||
{email && <p className="text-[11px] text-slate-400 break-all mb-3">{email}</p>}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void lookup.refetch()}
|
||||
disabled={lookup.isFetching}
|
||||
className="inline-flex items-center gap-1.5 h-7 px-2.5 rounded-md border border-slate-200 hover:border-slate-300 text-[11.5px] text-slate-700 hover:text-slate-900 transition-colors disabled:opacity-60"
|
||||
>
|
||||
<RefreshCwIcon className={`w-3 h-3 ${lookup.isFetching ? "animate-spin" : ""}`} />
|
||||
Try again
|
||||
</button>
|
||||
</div>
|
||||
) : !contact ? (
|
||||
<NotAContact email={email} name={fromName} />
|
||||
) : (
|
||||
@@ -170,6 +196,17 @@ export default function ContactContextPanel({
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{repliedFromOther && email && (
|
||||
<div
|
||||
className="mt-2 flex items-start gap-1.5 text-[10.5px] text-slate-500 leading-snug"
|
||||
title="The reply came from another address than the one this campaign emailed."
|
||||
>
|
||||
<CornerDownRightIcon className="w-3 h-3 text-slate-400 mt-px shrink-0" />
|
||||
<span className="min-w-0 break-all">
|
||||
Replied from {email}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="mt-2.5 flex flex-wrap items-center gap-1.5">
|
||||
{contact.subscribed ? (
|
||||
<Badge tone="emerald" icon={<CheckIcon className="w-2.5 h-2.5" />}>Subscribed</Badge>
|
||||
|
||||
@@ -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<FollowUpPause | null>(null);
|
||||
// Unticked rather than ticked, so a campaign that appears later is included.
|
||||
const [followUpSkip, setFollowUpSkip] = React.useState<string[]>([]);
|
||||
|
||||
@@ -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) {
|
||||
<ContactContextPanel
|
||||
email={contactEmail}
|
||||
name={contactName}
|
||||
mailboxId={mailbox?.id}
|
||||
mailboxId={mailbox?.id ?? messages[0]?.account_id}
|
||||
threadId={threadId}
|
||||
threadMailboxId={emailId}
|
||||
wroteBack={!!externalFrom}
|
||||
onClose={() => setCrmOpen(false)}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -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<Contact | null> {
|
||||
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<ContactLookup> {
|
||||
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<ContactLookup>({
|
||||
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 };
|
||||
}
|
||||
|
||||
@@ -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<FollowUpTargets>(
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user