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:
Matthew Meszaros
2026-09-28 16:08:46 +00:00
committed by GitHub
19 changed files with 446 additions and 54 deletions
+1 -1
View File
@@ -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` |
+8 -3
View File
@@ -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"
}
```
+2
View File
@@ -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.
+32 -5
View File
@@ -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."
}
}
},
+20 -5
View File
@@ -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
+13
View File
@@ -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 —
+39
View File
@@ -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)
}
})
}
}
+3 -1
View File
@@ -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.
+25
View File
@@ -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)
}
+3
View File
@@ -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)
+26
View File
@@ -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)
}
}
+70 -13
View File
@@ -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[]>([]);
+17 -9
View File
@@ -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,
});