feat: scope the tag, category and folder registries plus unibox conversation labels to the organization instead of the creating user (migration 000147), so a teammate sees and can edit the labels the owner made (#436), and guard every label write in email/campaign/contact/form/unibox repositories against ids belonging to another workspace

This commit is contained in:
Matthew Meszaros
2026-09-12 03:30:36 -07:00
parent 5869a2148f
commit ade86d1b86
60 changed files with 2332 additions and 457 deletions
+21
View File
@@ -336,6 +336,27 @@ Mutating API requests may include an `Idempotency-Key` header. Warmbly stores th
`GET /me` returns who the active credential belongs to: `user_id`, `email`, `name`, `organization_id`, `organization_name`, `auth_type` (`api_key`, `oauth`, or `jwt`), and the granted `scopes`. It requires no specific permission, so it is the right call for validating a connection and rendering a human-readable label. Unlike `/auth/me` (JWT only), it is reachable by API keys and OAuth tokens.
### Labels
Folders (on campaigns), tags (on mailboxes) and categories (on contacts and inbox conversations) are three registries with the same shape. Each belongs to the **workspace**, not to whoever created it: every member sees and can edit the same set. The current set is read from `GET /auth/me`, which returns `folders`, `tags` and `categories` for the session's selected workspace; there is no separate list endpoint.
`position` is the order within its own registry, `0`-based and contiguous. A move returns the full new ordering. A registry holds at most `100` entries.
| Method | Path | API Permission |
|--------|------|----------------|
| POST | `/folders` | `WRITE_CAMPAIGNS` |
| PATCH | `/folders/:id` | `WRITE_CAMPAIGNS` |
| PATCH | `/folders/:id/move` | `WRITE_CAMPAIGNS` |
| DELETE | `/folders/:id` | `WRITE_CAMPAIGNS` |
| POST | `/tags` | `WRITE_EMAILS` |
| PATCH | `/tags/:id` | `WRITE_EMAILS` |
| PATCH | `/tags/:id/move` | `WRITE_EMAILS` |
| DELETE | `/tags/:id` | `WRITE_EMAILS` |
| POST | `/categories` | `WRITE_CONTACTS` |
| PATCH | `/categories/:id` | `WRITE_CONTACTS` |
| PATCH | `/categories/:id/move` | `WRITE_CONTACTS` |
| DELETE | `/categories/:id` | `WRITE_CONTACTS` |
### Reference data
| Method | Path | API Permission |
@@ -372,7 +372,9 @@ Auth: Session only (not available to API keys).
`GET /auth/me`
Returns the signed-in user, including admin flags and the per-user label groups (folders, tags, categories) the dashboard needs on initial load.
Returns the signed-in user, including admin flags and the label registries (folders, tags, categories) the dashboard needs on initial load.
The registries belong to the workspace, not to the caller: every member of an organization sees the same folders, tags and categories, whoever created them. They are read for the session's currently selected workspace, so switching workspaces changes what comes back.
Auth: Session only (not available to API keys).
+3 -3
View File
@@ -5,7 +5,7 @@ description: Read, search, and triage incoming mail across every mailbox, then r
The unified inbox (unibox) is the org-wide view of everything that lands in your connected mailboxes. These endpoints power the dashboard's inbox list, thread view, scope rail, conversation labels, snoozes, and scheduled-send queue. Every route is gated on the unified-inbox feature, so the calling organization needs an active trial or paid subscription; without it the endpoint returns `403`.
The list, thread, overview, and snooze data is org-scoped, not per-user. Two members of the same organization see the same inbox, the same unread badge, and the same threads. Snoozes and conversation labels are attached to the calling user.
The list, thread, overview, conversation-label, and snooze data is org-scoped, not per-user. Two members of the same organization see the same inbox, the same unread badge, the same threads, and the same conversation labels, whoever applied them. Snoozes are the one exception: they are attached to the calling user.
## List incoming mail
@@ -198,7 +198,7 @@ The response is a `data` plus `pagination` envelope. Each item is a full message
`GET /unibox/thread/labels`
Returns the conversation labels (your categories) attached to a thread. Auth: **Scope** `READ_UNIBOX` · **Org permission** `access_unibox`.
Returns the conversation labels (the workspace's categories) attached to a thread. Auth: **Scope** `READ_UNIBOX` · **Org permission** `access_unibox`.
| Parameter | In | Type | Description |
| --- | --- | --- | --- |
@@ -220,7 +220,7 @@ The response wraps the labels in a `data` array.
`PUT /unibox/thread/labels`
Replaces the full conversation-label set on a thread. The body's `category_ids` is the desired set, so the call is idempotent and retries are naturally safe. Only your own categories are attached. Auth: **Scope** `WRITE_UNIBOX` · **Org permission** `access_unibox`.
Replaces the full conversation-label set on a thread, for the whole workspace. The body's `category_ids` is the desired set, so the call is idempotent and retries are naturally safe. Only the workspace's own categories are attached; an id belonging to another organization is dropped. Auth: **Scope** `WRITE_UNIBOX` · **Org permission** `access_unibox`.
### Request body
@@ -30,6 +30,7 @@ Indicators are workspace-scoped. Teammates see only your name, avatar, current p
- Contact imports, edits, and deletes refresh contacts views for the whole team.
- Mailbox health transitions (a warmup quarantine, say) flip status badges live.
- The CRM is live end to end: moving a deal, ticking a task, or editing pipeline stages shows up immediately, and a deal being dragged carries a colored ring so you know it is in motion.
- Mailbox tags, contact categories and campaign folders are one shared set per workspace, so a label anyone creates appears in everybody's pickers and chips as they make it.
- The audit log streams new entries, so the activity trail is itself a live feed.
**Events are permission-aware**: a member without inbox access never receives unibox events, and billing events reach only those who can manage billing. See [Team roles](/guides/team-roles/).
+1 -1
View File
@@ -99,7 +99,7 @@ Colored labels that group and filter contacts (`Warm lead`, `Conference 2026`, `
It supports type-ahead search, and typing an unmatched name offers **Create** to add and select it in one step. Each category keeps its color everywhere its chip appears.
**Contacts > Categories** lists every category with a live contact count. From there you can create one, rename it, change its color, delete it (contacts are kept; the label is removed from them and from inbox threads), or click a row to open the contact list filtered to it.
Categories belong to the workspace, not to whoever made them: every teammate sees the same list and can file contacts under it. **Contacts > Categories** lists every category with a live contact count. From there you can create one, rename it, change its color, delete it (contacts are kept; the label is removed from them and from inbox threads), or click a row to open the contact list filtered to it.
Categories also drive automation: a sequence can run **Add tag** or **Remove tag** as a contact moves through a flow, so a label can be applied automatically on a positive reply. **Add to segment** and **Remove from segment** do the same for [segments](/guides/segments/).
+1 -1
View File
@@ -170,7 +170,7 @@ SMTP mailboxes get a **Keep a copy of sent mail** toggle, on by default. SMTP su
Turn it off when your provider already saves its own copy of anything submitted over SMTP, which Gmail, Fastmail and Zoho do, or the folder ends up with two of every message. Gmail and Outlook mailboxes connected with OAuth never show the toggle: their APIs file the copy themselves. Warmup mail is never filed, since it would bury your real sent mail.
The same tab sets the **display name**, **reply-to** (empty uses the mailbox address), **signature** in plain text and HTML, and **tags** for grouping. A new display name is on the From header of the next message the mailbox sends, campaign, reply or warmup alike; nothing needs to be reconnected.
The same tab sets the **display name**, **reply-to** (empty uses the mailbox address), **signature** in plain text and HTML, and **tags** for grouping. Tags belong to the workspace: one anybody creates is there for every teammate, on every mailbox. A new display name is on the From header of the next message the mailbox sends, campaign, reply or warmup alike; nothing needs to be reconnected.
The HTML signature is placed in a block of its own one line below the body, so it arrives without the stack of blank lines above it that Apple Mail and Outlook used to show. Put any extra spacing you want inside the signature itself. The plain-text signature follows the body after a single blank line.
+1 -1
View File
@@ -65,7 +65,7 @@ HTML mail renders in an isolated frame, so a sender's styling cannot leak into t
## Categories and labels
Categories are your own conversation labels, shared with the rest of Warmbly, so `Interested` means the same thing on a contact as it does here. Label with the **Label** button or `c`: search existing categories, tick what applies, or type a name and **Create**. A conversation can carry several.
Categories are the workspace's conversation labels, shared with the rest of Warmbly, so `Interested` means the same thing on a contact as it does here. They are shared with the rest of the team too: a category anyone creates is available to everyone, and a conversation one member labels shows that label to the next. Label with the **Label** button or `c`: search existing categories, tick what applies, or type a name and **Create**. A conversation can carry several.
Labeled conversations show colored chips on the row and in the header, and each category appears in the rail with its own count for one-click filtering. **Labels are manual**; Warmbly never auto-tags.
@@ -17,14 +17,14 @@ The data is split into groups. Every export includes **Workspace**; the rest are
| Group | Contents |
|-------|----------|
| Workspace | The organization, members, roles, teams, mailboxes, API keys, webhooks, and settings, including the website tracking site key. Always included |
| Workspace | The organization, members, roles, teams, mailboxes, mailbox tags, API keys, webhooks, and settings, including the website tracking site key. Always included |
| Contacts | Contacts, categories, segments with their manual overrides, forms with their images, submissions, personalized link tickets and funnel events, notes, activities, and the suppression list |
| Campaigns | Campaigns, sequences, senders, linked segments, attachments, the email image library, per-campaign settings, and each lead's step progress with its per-link clicks and per-event opens |
| Campaigns | Campaigns, folders, sequences, senders, linked segments, attachments, the email image library, per-campaign settings, and each lead's step progress with its per-link clicks and per-event opens |
| CRM | Pipelines, deals, tasks, and meeting bookings |
| Automations | Automations, connected integrations, and lead sync sources |
| Assistant | Assistant sessions and messages, skills, MCP servers, and AI settings |
| Warmup | Warmup participation, routing rules, statistics, and appeals |
| Inbox | Unified inbox threads, message bodies, and mailbox sync state |
| Inbox | Unified inbox threads, message bodies, conversation labels, and mailbox sync state |
| Send history | Queued and completed send tasks with their payloads |
| Delivery events | Bounces, complaints, opens, clicks, placement tests, and website page views with the browser records that tie them to contacts |
| Verification evidence | What real mail showed about each contact's address (deliveries, opens, replies, bounces), so verdicts and confidence survive the move |
+1144 -1
View File
File diff suppressed because it is too large Load Diff
+21 -13
View File
@@ -10,6 +10,7 @@ import (
"github.com/warmbly/warmbly/internal/api/middleware"
"github.com/warmbly/warmbly/internal/app/auth"
"github.com/warmbly/warmbly/internal/errx"
"github.com/warmbly/warmbly/internal/models"
)
const authRequestTimeout = 15 * time.Second
@@ -151,20 +152,27 @@ func (h *Handler) GetUser(c *gin.Context) {
return
}
// Populate the per-user label groups so the frontend can render
// Populate the workspace's label registries so the frontend can render
// folder/tag pickers on initial page load without three extra
// round-trips. Without this, anything the user created in a
// previous session would disappear after a refresh: the cache
// would optimistic-update from a Create response, but on reload
// the /auth/me payload had empty folders/tags/categories.
if folders, ferr := h.FolderService.List(ctx, uid); ferr == nil {
u.Folders = folders
}
if tags, terr := h.TagService.List(ctx, uid); terr == nil {
u.Tags = tags
}
if cats, cerr := h.CategoryService.List(ctx, uid); cerr == nil {
u.Categories = cats
// round-trips. Without this, anything created in a previous session
// would disappear after a refresh: the cache would optimistic-update
// from a Create response, but on reload the /auth/me payload had empty
// folders/tags/categories.
//
// Scoped to the session's current organization, not the caller: labels
// are workspace assets, so a teammate must see what the owner created
// (issue #436). A session with no workspace selected gets empty lists.
u.Folders, u.Tags, u.Categories = []models.Group{}, []models.Group{}, []models.Group{}
if orgID := middleware.GetOrganizationID(c); orgID != nil {
if folders, ferr := h.FolderService.List(ctx, *orgID); ferr == nil {
u.Folders = folders
}
if tags, terr := h.TagService.List(ctx, *orgID); terr == nil {
u.Tags = tags
}
if cats, cerr := h.CategoryService.List(ctx, *orgID); cerr == nil {
u.Categories = cats
}
}
c.JSON(http.StatusOK, u)
+21 -3
View File
@@ -54,6 +54,11 @@ func (h *Handler) GetEmail(c *gin.Context) {
}
func (h *Handler) UpdateEmail(c *gin.Context) {
orgID := middleware.GetOrganizationID(c)
if orgID == nil {
errx.Handle(c, errx.ErrNoOrganization)
return
}
userIDStr := middleware.GetUserID(c)
emailAccountID := c.Param("id")
@@ -65,7 +70,7 @@ func (h *Handler) UpdateEmail(c *gin.Context) {
return
}
resp, err := h.EmailService.Update(c.Request.Context(), userIDStr, emailAccountID, &data)
resp, err := h.EmailService.Update(c.Request.Context(), orgID.String(), userIDStr, emailAccountID, &data)
if err != nil {
errx.Handle(c, err)
return
@@ -84,7 +89,11 @@ func (h *Handler) UpdateEmail(c *gin.Context) {
// are safe without an Idempotency-Key.
// PATCH /emails/tags
func (h *Handler) BulkTagEmails(c *gin.Context) {
userIDStr := middleware.GetUserID(c)
orgID := middleware.GetOrganizationID(c)
if orgID == nil {
errx.Handle(c, errx.ErrNoOrganization)
return
}
var data models.BulkEmailTags
if err := c.ShouldBindJSON(&data); err != nil {
@@ -114,6 +123,15 @@ func (h *Handler) BulkTagEmails(c *gin.Context) {
errx.Handle(c, errx.ErrUuid)
return
}
// The mailbox scope here is the workspace, so a restricted API key needs
// the same allowlist check the per-id routes get from
// RequireAPIKeyEmailAccountParam; there is no path param to gate on.
for _, id := range emailIDs {
if !middleware.APIKeyAllowsEmailAccount(c, id) {
errx.Handle(c, errx.New(errx.Forbidden, "email account is not allowed for this API key"))
return
}
}
addTags, ok := parse(data.AddTags)
if !ok {
errx.Handle(c, errx.ErrUuid)
@@ -125,7 +143,7 @@ func (h *Handler) BulkTagEmails(c *gin.Context) {
return
}
updated, err := h.EmailService.BulkUpdateTags(c.Request.Context(), userIDStr, emailIDs, addTags, removeTags)
updated, err := h.EmailService.BulkUpdateTags(c.Request.Context(), orgID.String(), emailIDs, addTags, removeTags)
if err != nil {
errx.Handle(c, err)
return
+36 -38
View File
@@ -14,6 +14,27 @@ func GetGroupID(c *gin.Context) string {
return c.Param("gid")
}
// scope resolves the workspace every group call is keyed on, plus the group id
// on the path for the routes that carry one. Labels belong to the organization,
// not to whoever created them, so a request without one is refused rather than
// run against a single member's rows.
func scope(c *gin.Context, withID bool) (uuid.UUID, uuid.UUID, bool) {
orgID := middleware.GetOrganizationID(c)
if orgID == nil {
errx.Handle(c, errx.ErrNoOrganization)
return uuid.Nil, uuid.Nil, false
}
if !withID {
return *orgID, uuid.Nil, true
}
gid, err := uuid.Parse(GetGroupID(c))
if err != nil {
errx.Handle(c, errx.ErrUuid)
return uuid.Nil, uuid.Nil, false
}
return *orgID, gid, true
}
// entityType maps the group's name ("folders"/"tags"/"categories") to the
// matching audit entity type.
func (h *Handler) entityType() models.AuditEntityType {
@@ -45,12 +66,13 @@ func (h *Handler) logAudit(c *gin.Context, action models.AuditAction, entityID *
}
func (h *Handler) Create(c *gin.Context) {
userID := middleware.GetUserID(c)
uid, err := uuid.Parse(userID)
if err != nil {
errx.Handle(c, errx.ErrUser)
orgID, _, ok := scope(c, false)
if !ok {
return
}
// Attribution only; an API key has no human behind it, so a failed parse
// records a nil creator rather than refusing the write.
uid, _ := middleware.GetUserUUID(c)
var data models.GroupCreate
@@ -59,7 +81,7 @@ func (h *Handler) Create(c *gin.Context) {
return
}
group, xerr := h.service.Create(c.Request.Context(), uid, &data)
group, xerr := h.service.Create(c.Request.Context(), orgID, uid, &data)
if xerr != nil {
errx.Handle(c, xerr)
return
@@ -71,16 +93,8 @@ func (h *Handler) Create(c *gin.Context) {
}
func (h *Handler) Update(c *gin.Context) {
userID := middleware.GetUserID(c)
uid, err := uuid.Parse(userID)
if err != nil {
errx.Handle(c, errx.ErrUser)
return
}
groupID := GetGroupID(c)
gid, err := uuid.Parse(groupID)
if err != nil {
errx.Handle(c, errx.ErrUuid)
orgID, gid, ok := scope(c, true)
if !ok {
return
}
@@ -91,7 +105,7 @@ func (h *Handler) Update(c *gin.Context) {
return
}
group, xerr := h.service.Update(c.Request.Context(), uid, gid, &data)
group, xerr := h.service.Update(c.Request.Context(), orgID, gid, &data)
if xerr != nil {
errx.Handle(c, xerr)
return
@@ -103,16 +117,8 @@ func (h *Handler) Update(c *gin.Context) {
}
func (h *Handler) Move(c *gin.Context) {
userID := middleware.GetUserID(c)
uid, err := uuid.Parse(userID)
if err != nil {
errx.Handle(c, errx.ErrUser)
return
}
groupID := GetGroupID(c)
gid, err := uuid.Parse(groupID)
if err != nil {
errx.Handle(c, errx.ErrUuid)
orgID, gid, ok := scope(c, true)
if !ok {
return
}
@@ -123,7 +129,7 @@ func (h *Handler) Move(c *gin.Context) {
return
}
orders, xerr := h.service.Move(c.Request.Context(), uid, gid, data.Position)
orders, xerr := h.service.Move(c.Request.Context(), orgID, gid, data.Position)
if xerr != nil {
errx.Handle(c, xerr)
return
@@ -135,20 +141,12 @@ func (h *Handler) Move(c *gin.Context) {
}
func (h *Handler) Delete(c *gin.Context) {
userID := middleware.GetUserID(c)
uid, err := uuid.Parse(userID)
if err != nil {
errx.Handle(c, errx.ErrUser)
return
}
groupID := GetGroupID(c)
gid, err := uuid.Parse(groupID)
if err != nil {
errx.Handle(c, errx.ErrUuid)
orgID, gid, ok := scope(c, true)
if !ok {
return
}
if xerr := h.service.Delete(c.Request.Context(), uid, gid); xerr != nil {
if xerr := h.service.Delete(c.Request.Context(), orgID, gid); xerr != nil {
errx.Handle(c, xerr)
return
}
+12 -19
View File
@@ -33,13 +33,6 @@ func (h *Handler) gateUnibox(c *gin.Context) bool {
}
func (h *Handler) GetUniboxIncoming(c *gin.Context) {
userID := middleware.GetUserID(c)
uid, err := uuid.Parse(userID)
if err != nil {
errx.Handle(c, errx.ErrUser)
return
}
orgID := middleware.GetOrganizationID(c)
if orgID == nil {
errx.Handle(c, errx.New(errx.BadRequest, "no organization selected"))
@@ -187,7 +180,7 @@ func (h *Handler) GetUniboxIncoming(c *gin.Context) {
}
}
resp, xerr := h.UniboxService.Search(c.Request.Context(), *orgID, uid, params)
resp, xerr := h.UniboxService.Search(c.Request.Context(), *orgID, params)
if xerr != nil {
errx.Handle(c, xerr)
return
@@ -302,10 +295,9 @@ func (h *Handler) GetUniboxThreadLabels(c *gin.Context) {
if !h.gateUnibox(c) {
return
}
userID := middleware.GetUserID(c)
uid, err := uuid.Parse(userID)
if err != nil {
errx.Handle(c, errx.ErrUser)
orgID := middleware.GetOrganizationID(c)
if orgID == nil {
errx.Handle(c, errx.ErrNoOrganization)
return
}
@@ -318,7 +310,7 @@ func (h *Handler) GetUniboxThreadLabels(c *gin.Context) {
return
}
labels, xerr := h.UniboxService.ListThreadLabels(c.Request.Context(), uid, threadID)
labels, xerr := h.UniboxService.ListThreadLabels(c.Request.Context(), *orgID, threadID)
if xerr != nil {
errx.Handle(c, xerr)
return
@@ -328,19 +320,20 @@ func (h *Handler) GetUniboxThreadLabels(c *gin.Context) {
// SetUniboxThreadLabels replaces the full conversation-label set on a
// thread. Idempotent (PUT semantics): the body's category_ids is the
// desired set, so retries are naturally safe. Only the user's own
// desired set, so retries are naturally safe. Only the workspace's own
// categories are attached.
// PUT /unibox/thread/labels
func (h *Handler) SetUniboxThreadLabels(c *gin.Context) {
if !h.gateUnibox(c) {
return
}
userID := middleware.GetUserID(c)
uid, err := uuid.Parse(userID)
if err != nil {
errx.Handle(c, errx.ErrUser)
orgID := middleware.GetOrganizationID(c)
if orgID == nil {
errx.Handle(c, errx.ErrNoOrganization)
return
}
// Attribution only; a label an API key applies has no human behind it.
uid, _ := middleware.GetUserUUID(c)
var req models.UniboxThreadLabels
if err := c.ShouldBindJSON(&req); err != nil {
@@ -348,7 +341,7 @@ func (h *Handler) SetUniboxThreadLabels(c *gin.Context) {
return
}
labels, xerr := h.UniboxService.SetThreadLabels(c.Request.Context(), uid, req.ThreadID, req.CategoryIDs)
labels, xerr := h.UniboxService.SetThreadLabels(c.Request.Context(), *orgID, uid, req.ThreadID, req.CategoryIDs)
if xerr != nil {
errx.Handle(c, xerr)
return
+12 -11
View File
@@ -7,22 +7,23 @@ import (
)
// LabelThread additively applies the given category labels to a unibox
// conversation, on behalf of the thread's owning user. Backs the "label_email"
// automation action. Best-effort: empty input or a missing labeler is a no-op,
// and categories not owned by userID are silently ignored by the repository.
func (s *service) LabelThread(ctx context.Context, userID uuid.UUID, threadID string, categoryIDs []uuid.UUID) error {
// conversation. Backs the "label_email" automation action. Best-effort: empty
// input is a no-op, and categories the workspace does not own are silently
// ignored by the repository.
func (s *service) LabelThread(ctx context.Context, orgID uuid.UUID, threadID string, categoryIDs []uuid.UUID) error {
if s.uniboxRepo == nil || threadID == "" || len(categoryIDs) == 0 {
return nil
}
return s.uniboxRepo.AddThreadLabels(ctx, userID, threadID, categoryIDs)
return s.uniboxRepo.AddThreadLabels(ctx, orgID, threadID, categoryIDs)
}
// LabelLatestThreadForContact resolves the contact's most recent conversation in
// userID's unibox and labels it. Backs the "label_email" campaign step action,
// which knows the contact but not the thread id (off a reply branch the most
// recent thread IS the reply). Returns the labeled thread id, or "" when the
// contact has no conversation yet (a logged no-op for the caller).
func (s *service) LabelLatestThreadForContact(ctx context.Context, userID uuid.UUID, contactEmail string, categoryIDs []uuid.UUID) (string, error) {
// userID's unibox and labels it for the workspace. Backs the "label_email"
// campaign step action, which knows the contact but not the thread id (off a
// reply branch the most recent thread IS the reply). Returns the labeled thread
// id, or "" when the contact has no conversation yet (a logged no-op for the
// caller).
func (s *service) LabelLatestThreadForContact(ctx context.Context, orgID, userID uuid.UUID, contactEmail string, categoryIDs []uuid.UUID) (string, error) {
if s.uniboxRepo == nil || contactEmail == "" || len(categoryIDs) == 0 {
return "", nil
}
@@ -33,7 +34,7 @@ func (s *service) LabelLatestThreadForContact(ctx context.Context, userID uuid.U
if threadID == "" {
return "", nil
}
return threadID, s.uniboxRepo.AddThreadLabels(ctx, userID, threadID, categoryIDs)
return threadID, s.uniboxRepo.AddThreadLabels(ctx, orgID, threadID, categoryIDs)
}
// LatestInboundFromContact returns the subject and snippet of the most recent
+2 -2
View File
@@ -283,14 +283,14 @@ func (s *service) executeInstantActionNode(ctx context.Context, campaign *models
case "label_email":
// Label the conversation the contact just replied on. The most recent
// thread for the contact in the campaign owner's unibox is that reply.
if len(cfg.LabelIDs) == 0 {
if len(cfg.LabelIDs) == 0 || campaign.OrganizationID == nil {
return
}
owner, perr := uuid.Parse(campaign.UserID)
if perr != nil {
return
}
if _, xerr := s.LabelLatestThreadForContact(ctx, owner, contact.Email, cfg.LabelIDs); xerr != nil {
if _, xerr := s.LabelLatestThreadForContact(ctx, *campaign.OrganizationID, owner, contact.Email, cfg.LabelIDs); xerr != nil {
s.logActionErr(campaign, contact, cfg.Type, eventKind, xerr)
}
case "unsubscribe":
+18 -15
View File
@@ -108,21 +108,22 @@ type Service interface {
// just because a deal hasn't been created yet.
MoveContactDealStage(ctx context.Context, orgID, contactID, pipelineID, stageID uuid.UUID) (*models.Deal, *errx.Error)
// LabelThread additively applies unibox conversation labels (categories owned
// by userID) to a thread, for the "label_email" automation action. No-op on
// empty input; categories not owned by userID are silently ignored.
LabelThread(ctx context.Context, userID uuid.UUID, threadID string, categoryIDs []uuid.UUID) error
// LabelThread additively applies unibox conversation labels (categories the
// workspace owns) to a thread, for the "label_email" automation action.
// No-op on empty input; foreign categories are silently ignored.
LabelThread(ctx context.Context, orgID uuid.UUID, threadID string, categoryIDs []uuid.UUID) error
// LabelLatestThreadForContact finds the contact's most recent conversation in
// userID's unibox and labels it, for the "label_email" campaign step action
// (which knows the contact but not the thread id). Returns the labeled thread
// id, or "" when the contact has no conversation yet.
LabelLatestThreadForContact(ctx context.Context, userID uuid.UUID, contactEmail string, categoryIDs []uuid.UUID) (string, error)
// userID's unibox and labels it for the workspace, for the "label_email"
// campaign step action (which knows the contact but not the thread id).
// Returns the labeled thread id, or "" when the contact has no conversation
// yet.
LabelLatestThreadForContact(ctx context.Context, orgID, userID uuid.UUID, contactEmail string, categoryIDs []uuid.UUID) (string, error)
// LatestInboundFromContact returns the subject + snippet of the newest email
// received from the contact ("" when none). Backs the campaign AI step's
// incoming-email context.
LatestInboundFromContact(ctx context.Context, userID uuid.UUID, contactEmail string) (string, string, error)
// ListCategories returns the user's contact categories, which double as
// ListCategories returns the workspace's contact categories, which double as
// unibox conversation labels (same registry). An AI agent step offers these
// by name and resolves the model's pick to an id. CreateCategory mints a new
// one for the agent's create-on-the-fly path (opt-in per step).
@@ -492,12 +493,13 @@ func (s *service) MoveContactDealStage(ctx context.Context, orgID, contactID, pi
return updated, nil
}
// ListCategories returns the user's categories (contact tags == unibox labels).
func (s *service) ListCategories(ctx context.Context, userID uuid.UUID) ([]models.MiniCategory, error) {
// ListCategories returns the workspace's categories (contact tags == unibox
// labels).
func (s *service) ListCategories(ctx context.Context, orgID uuid.UUID) ([]models.MiniCategory, error) {
if s.categoryRepo == nil {
return nil, nil
}
groups, err := s.categoryRepo.List(ctx, userID)
groups, err := s.categoryRepo.List(ctx, orgID)
if err != nil {
return nil, err
}
@@ -510,15 +512,16 @@ func (s *service) ListCategories(ctx context.Context, userID uuid.UUID) ([]model
// CreateCategory mints a new category (tag/label) for the agent's opt-in
// create-on-the-fly path. GroupRepository.Create validates the title (1-50) and
// enforces the per-user cap; color defaults to slate when blank.
func (s *service) CreateCategory(ctx context.Context, userID uuid.UUID, title, color string) (models.MiniCategory, error) {
// enforces the per-workspace cap; color defaults to slate when blank. The
// creator is nil: an automation has no human behind it.
func (s *service) CreateCategory(ctx context.Context, orgID uuid.UUID, title, color string) (models.MiniCategory, error) {
if s.categoryRepo == nil {
return models.MiniCategory{}, errx.New(errx.BadRequest, "categories are not available")
}
if strings.TrimSpace(color) == "" {
color = "#64748b"
}
g, err := s.categoryRepo.Create(ctx, userID, &models.GroupCreate{Title: strings.TrimSpace(title), Color: color})
g, err := s.categoryRepo.Create(ctx, orgID, uuid.Nil, &models.GroupCreate{Title: strings.TrimSpace(title), Color: color})
if err != nil {
return models.MiniCategory{}, err
}
+1 -1
View File
@@ -148,7 +148,7 @@ func (d Deps) setThreadLabels(ctx context.Context, inv Invocation, args json.Raw
}
catIDs = append(catIDs, id)
}
labels, xerr := d.Unibox.SetThreadLabels(ctx, inv.UserID, in.ThreadID, catIDs)
labels, xerr := d.Unibox.SetThreadLabels(ctx, inv.OrgID, inv.UserID, in.ThreadID, catIDs)
if xerr != nil {
return "", fromErrx(xerr)
}
+1 -1
View File
@@ -168,7 +168,7 @@ func (d Deps) updateMailbox(ctx context.Context, inv Invocation, args json.RawMe
WarmupReplyRate: in.WarmupReplyRate,
WarmupDays: in.WarmupDays,
}
mb, xerr := d.Emails.Update(ctx, inv.UserID.String(), in.EmailAccountID, upd)
mb, xerr := d.Emails.Update(ctx, inv.OrgID.String(), inv.UserID.String(), in.EmailAccountID, upd)
if xerr != nil {
return "", fromErrx(xerr)
}
+1 -1
View File
@@ -114,7 +114,7 @@ func (d Deps) listThreads(ctx context.Context, inv Invocation, args json.RawMess
params.AwaitingReply = &t
}
res, xerr := d.Unibox.Search(ctx, inv.OrgID, inv.UserID, params)
res, xerr := d.Unibox.Search(ctx, inv.OrgID, params)
if xerr != nil {
return "", fromErrx(xerr)
}
+2 -2
View File
@@ -309,14 +309,14 @@ func (s *contactService) ImportCommit(
}
// Resolve every category title the file mentions in one round trip,
// creating the ones the user doesn't have yet.
// creating the ones the workspace doesn't have yet.
titleToID := map[string]uuid.UUID{}
var allTitles []string
for i := range parsed {
allTitles = append(allTitles, parsed[i].categories...)
}
if len(allTitles) > 0 {
titleToID, xerr = s.contactRepository.ResolveCategoryNames(ctx, uid, allTitles)
titleToID, xerr = s.contactRepository.ResolveCategoryNames(ctx, orgID, uid, allTitles)
if xerr != nil {
return nil, xerr
}
+3 -3
View File
@@ -84,7 +84,7 @@ func newImportFixture(t *testing.T) *importFixture {
{`DELETE FROM segment_members WHERE segment_id IN (SELECT id FROM segments WHERE organization_id = $1)`, f.org},
{`DELETE FROM segments WHERE organization_id = $1`, f.org},
{`DELETE FROM contacts WHERE organization_id = $1`, f.org},
{`DELETE FROM categories WHERE user_id = $1`, f.user},
{`DELETE FROM categories WHERE organization_id = $1`, f.org},
{`DELETE FROM organization_members WHERE organization_id = $1`, f.org},
{`DELETE FROM organizations WHERE id = $1`, f.org},
{`DELETE FROM users WHERE id = $1`, f.user},
@@ -109,8 +109,8 @@ func (f *importFixture) newCategory(t *testing.T, title string) uuid.UUID {
t.Helper()
id := uuid.New()
if _, err := f.pool.Exec(context.Background(),
`INSERT INTO categories (id, user_id, title, color, position) VALUES ($1, $2, $3, '#38bdf8', 0)`,
id, f.user, title); err != nil {
`INSERT INTO categories (id, organization_id, user_id, title, color, position) VALUES ($1, $2, $3, $4, '#38bdf8', 0)`,
id, f.org, f.user, title); err != nil {
t.Fatalf("create category: %v", err)
}
return id
+4 -4
View File
@@ -46,8 +46,8 @@ func (s *emailService) Get(ctx context.Context, orgID, emailAccountID string) (*
return s.emailRepository.Get(ctx, orgID, emailAccountID)
}
func (s *emailService) Update(ctx context.Context, userID, emailAccountID string, udata *models.UpdateEmail) (*models.Email, *errx.Error) {
account, err := s.emailRepository.Update(ctx, userID, emailAccountID, udata)
func (s *emailService) Update(ctx context.Context, orgID, userID, emailAccountID string, udata *models.UpdateEmail) (*models.Email, *errx.Error) {
account, err := s.emailRepository.Update(ctx, orgID, emailAccountID, udata)
if err != nil {
return nil, err
}
@@ -84,11 +84,11 @@ func (s *emailService) applyStatusToWorker(ctx context.Context, userID string, a
// BulkUpdateTags is a pure tag-link rewrite: no warmup pool or worker state
// depends on tags, so no per-account fanout is needed (the caller audits
// once and the spine refreshes lists).
func (s *emailService) BulkUpdateTags(ctx context.Context, userID string, emailIDs, addTags, removeTags []uuid.UUID) (int, *errx.Error) {
func (s *emailService) BulkUpdateTags(ctx context.Context, orgID string, emailIDs, addTags, removeTags []uuid.UUID) (int, *errx.Error) {
if len(addTags) == 0 && len(removeTags) == 0 {
return 0, errx.ErrNotEnough
}
return s.emailRepository.BulkUpdateTags(ctx, userID, emailIDs, addTags, removeTags)
return s.emailRepository.BulkUpdateTags(ctx, orgID, emailIDs, addTags, removeTags)
}
// SetWarmupLifecycle applies a warmup start/pause/resume/disable transition,
+4 -1
View File
@@ -175,8 +175,11 @@ func (s *emailService) reconnectAccount(ctx context.Context, accountID uuid.UUID
if account == nil {
return nil, errx.ErrNotFound
}
if account.OrganizationID == nil {
return nil, errx.ErrNoOrganization
}
status := "active"
updated, xerr := s.Update(ctx, account.UserID, account.ID.String(), &models.UpdateEmail{Status: &status})
updated, xerr := s.Update(ctx, account.OrganizationID.String(), account.UserID, account.ID.String(), &models.UpdateEmail{Status: &status})
if xerr != nil {
return nil, xerr
}
+7 -4
View File
@@ -25,10 +25,13 @@ import (
type EmailService interface {
Search(ctx context.Context, userID, search, cursor, tag, limit string, allowedAccountIDs []uuid.UUID) (*models.EmailsResult, *errx.Error)
Get(ctx context.Context, userID, emailAccountID string) (*models.Email, *errx.Error)
Update(ctx context.Context, userID, emailAccountID string, udata *models.UpdateEmail) (*models.Email, *errx.Error)
// BulkUpdateTags adds/removes tags across many of the user's mailboxes
// in one call; returns how many of the requested mailboxes were owned.
BulkUpdateTags(ctx context.Context, userID string, emailIDs, addTags, removeTags []uuid.UUID) (int, *errx.Error)
// Update writes a mailbox's settings. orgID scopes the write (the mailbox
// is a workspace asset); userID only names who to tell the worker about.
Update(ctx context.Context, orgID, userID, emailAccountID string, udata *models.UpdateEmail) (*models.Email, *errx.Error)
// BulkUpdateTags adds/removes tags across many of the workspace's
// mailboxes in one call; returns how many of the requested mailboxes the
// workspace owns.
BulkUpdateTags(ctx context.Context, orgID string, emailIDs, addTags, removeTags []uuid.UUID) (int, *errx.Error)
// SetWarmupLifecycle starts, pauses, resumes, or disables warmup for a
// mailbox. start/resume preserve ramp progress; disable turns warmup off.
SetWarmupLifecycle(ctx context.Context, userID, emailAccountID, action string) (*models.Email, *errx.Error)
@@ -126,7 +126,7 @@ func TestLiveDisablingAMailboxRemovesItFromItsWorker(t *testing.T) {
f := newRemovalLiveFixture(t)
inactive := "inactive"
account, xerr := f.svc.Update(context.Background(), f.user.String(), f.mailbox.String(), &models.UpdateEmail{Status: &inactive})
account, xerr := f.svc.Update(context.Background(), f.org.String(), f.user.String(), f.mailbox.String(), &models.UpdateEmail{Status: &inactive})
if xerr != nil {
t.Fatalf("update: %v", xerr)
}
+7 -7
View File
@@ -40,7 +40,7 @@ func (s *stubRemovalRepo) record(step string) {
}
}
func (s *stubRemovalRepo) Update(ctx context.Context, userID, emailAccountID string, udata *models.UpdateEmail) (*models.Email, *errx.Error) {
func (s *stubRemovalRepo) Update(ctx context.Context, orgID, emailAccountID string, udata *models.UpdateEmail) (*models.Email, *errx.Error) {
if udata.Status != nil {
s.statusSet = append(s.statusSet, *udata.Status)
}
@@ -156,7 +156,7 @@ func TestDisablingAMailboxTellsTheWorkerToDropIt(t *testing.T) {
f := newRemovalFixture(t)
inactive := "inactive"
if _, xerr := f.svc.Update(context.Background(), f.user.String(), f.mailbox.String(), &models.UpdateEmail{Status: &inactive}); xerr != nil {
if _, xerr := f.svc.Update(context.Background(), f.org.String(), f.user.String(), f.mailbox.String(), &models.UpdateEmail{Status: &inactive}); xerr != nil {
t.Fatalf("update: %v", xerr)
}
@@ -187,7 +187,7 @@ func TestRevokingAMailboxAlsoDropsItFromTheWorker(t *testing.T) {
f := newRemovalFixture(t)
revoked := "revoked"
if _, xerr := f.svc.Update(context.Background(), f.user.String(), f.mailbox.String(), &models.UpdateEmail{Status: &revoked}); xerr != nil {
if _, xerr := f.svc.Update(context.Background(), f.org.String(), f.user.String(), f.mailbox.String(), &models.UpdateEmail{Status: &revoked}); xerr != nil {
t.Fatalf("update: %v", xerr)
}
if len(f.pub.removed) != 1 {
@@ -202,7 +202,7 @@ func TestReenablingAMailboxShipsItBackToItsWorker(t *testing.T) {
f := newRemovalFixture(t)
active := "active"
if _, xerr := f.svc.Update(context.Background(), f.user.String(), f.mailbox.String(), &models.UpdateEmail{Status: &active}); xerr != nil {
if _, xerr := f.svc.Update(context.Background(), f.org.String(), f.user.String(), f.mailbox.String(), &models.UpdateEmail{Status: &active}); xerr != nil {
t.Fatalf("update: %v", xerr)
}
@@ -220,7 +220,7 @@ func TestAPatchThatLeavesTheStatusAloneDoesNotTouchTheWorker(t *testing.T) {
f := newRemovalFixture(t)
name := "New name"
if _, xerr := f.svc.Update(context.Background(), f.user.String(), f.mailbox.String(), &models.UpdateEmail{Name: &name}); xerr != nil {
if _, xerr := f.svc.Update(context.Background(), f.org.String(), f.user.String(), f.mailbox.String(), &models.UpdateEmail{Name: &name}); xerr != nil {
t.Fatalf("update: %v", xerr)
}
if len(f.pub.removed) != 0 || len(f.pub.added) != 0 {
@@ -235,7 +235,7 @@ func TestAFailedStatusWriteNeverReachesTheWorker(t *testing.T) {
f.repo.updateErr = errx.InternalError()
inactive := "inactive"
if _, xerr := f.svc.Update(context.Background(), f.user.String(), f.mailbox.String(), &models.UpdateEmail{Status: &inactive}); xerr == nil {
if _, xerr := f.svc.Update(context.Background(), f.org.String(), f.user.String(), f.mailbox.String(), &models.UpdateEmail{Status: &inactive}); xerr == nil {
t.Fatal("a failed status write was reported as success")
}
if len(f.pub.removed) != 0 || f.repo.workerCalls != 0 {
@@ -250,7 +250,7 @@ func TestDisablingSucceedsEvenWhenTheBusIsDown(t *testing.T) {
f.pub.removeErr = errBusDown
inactive := "inactive"
if _, xerr := f.svc.Update(context.Background(), f.user.String(), f.mailbox.String(), &models.UpdateEmail{Status: &inactive}); xerr != nil {
if _, xerr := f.svc.Update(context.Background(), f.org.String(), f.user.String(), f.mailbox.String(), &models.UpdateEmail{Status: &inactive}); xerr != nil {
t.Fatalf("a bus failure blocked the status change: %v", xerr)
}
if len(f.pub.removed) != 1 {
+10 -10
View File
@@ -8,22 +8,22 @@ import (
"github.com/warmbly/warmbly/internal/models"
)
func (s *groupService) Create(ctx context.Context, userID uuid.UUID, data *models.GroupCreate) (*models.Group, *errx.Error) {
return s.groupRepository.Create(ctx, userID, data)
func (s *groupService) Create(ctx context.Context, orgID, userID uuid.UUID, data *models.GroupCreate) (*models.Group, *errx.Error) {
return s.groupRepository.Create(ctx, orgID, userID, data)
}
func (s *groupService) Delete(ctx context.Context, userID, id uuid.UUID) *errx.Error {
return s.groupRepository.Delete(ctx, userID, id)
func (s *groupService) Delete(ctx context.Context, orgID, id uuid.UUID) *errx.Error {
return s.groupRepository.Delete(ctx, orgID, id)
}
func (s *groupService) Move(ctx context.Context, userID, id uuid.UUID, position int32) ([]models.Order, *errx.Error) {
return s.groupRepository.Move(ctx, userID, id, position)
func (s *groupService) Move(ctx context.Context, orgID, id uuid.UUID, position int32) ([]models.Order, *errx.Error) {
return s.groupRepository.Move(ctx, orgID, id, position)
}
func (s *groupService) Update(ctx context.Context, userID, id uuid.UUID, data *models.GroupUpdate) (*models.Group, *errx.Error) {
return s.groupRepository.Update(ctx, userID, id, data)
func (s *groupService) Update(ctx context.Context, orgID, id uuid.UUID, data *models.GroupUpdate) (*models.Group, *errx.Error) {
return s.groupRepository.Update(ctx, orgID, id, data)
}
func (s *groupService) List(ctx context.Context, userID uuid.UUID) ([]models.Group, *errx.Error) {
return s.groupRepository.List(ctx, userID)
func (s *groupService) List(ctx context.Context, orgID uuid.UUID) ([]models.Group, *errx.Error) {
return s.groupRepository.List(ctx, orgID)
}
+8 -5
View File
@@ -9,12 +9,15 @@ import (
"github.com/warmbly/warmbly/internal/repository"
)
// GroupService is one label registry (folders, tags or categories). Labels
// belong to the workspace, so every method is scoped by organization; userID
// on Create records who made it and nothing more.
type GroupService interface {
Create(ctx context.Context, userID uuid.UUID, data *models.GroupCreate) (*models.Group, *errx.Error)
Delete(ctx context.Context, userID, id uuid.UUID) *errx.Error
Move(ctx context.Context, userID, id uuid.UUID, position int32) ([]models.Order, *errx.Error)
Update(ctx context.Context, userID, id uuid.UUID, data *models.GroupUpdate) (*models.Group, *errx.Error)
List(ctx context.Context, userID uuid.UUID) ([]models.Group, *errx.Error)
Create(ctx context.Context, orgID, userID uuid.UUID, data *models.GroupCreate) (*models.Group, *errx.Error)
Delete(ctx context.Context, orgID, id uuid.UUID) *errx.Error
Move(ctx context.Context, orgID, id uuid.UUID, position int32) ([]models.Order, *errx.Error)
Update(ctx context.Context, orgID, id uuid.UUID, data *models.GroupUpdate) (*models.Group, *errx.Error)
List(ctx context.Context, orgID uuid.UUID) ([]models.Group, *errx.Error)
}
type groupService struct {
+9 -14
View File
@@ -205,7 +205,7 @@ type aiActionConfig struct {
// AddTags / RemoveTags / Labels are OPTIONAL pools an agent-mode step's
// tag/label tools pick from by name. An empty pool means unrestricted: the
// executor lists the org owner's tags/labels live at run time and the agent
// executor lists the workspace's tags/labels live at run time and the agent
// may use any (tags and unibox labels are the same category registry).
// AllowCreateTags additionally lets an empty-pool pick mint a new tag/label.
AddTags []models.AITagRef `json:"ai_add_tags"`
@@ -477,12 +477,11 @@ func aiToolName(action models.IntegrationAction) string {
// the agent may call, mirroring the campaign AI step. The model supplies the
// specifics (which tag, task title, deal name/pipeline) and the executor
// resolves them live: an optional pool restricts a tag/label choice, an empty
// pool means any of the owner's tags (with optional create). Guarded two ways:
// pool means any of the workspace's tags (with optional create). Guarded two ways:
// only isAllowlistedAIAction ids become tools (never a send/reply or connection
// action), and every tool dispatches through the existing native executor. On a
// dry run each tool reports what it would do without applying anything.
func (s *service) guardedAITools(ctx context.Context, a models.Automation, n models.AutomationNode, cfg aiActionConfig, data map[string]any, feedPause bool) []generation.ToolDef {
// The org owner scopes tag/label reads + writes (categories are per user).
// The live category list (tags == unibox labels) is fetched once, only when a
// tag/label capability is enabled, so an unrestricted pool can offer any.
needCats := false
@@ -492,13 +491,9 @@ func (s *service) guardedAITools(ctx context.Context, a models.Automation, n mod
needCats = true
}
}
var owner uuid.UUID
if o, err := s.native.OrgOwner(ctx, a.OrganizationID); err == nil {
owner = o
}
var liveCats []models.MiniCategory
if needCats && owner != uuid.Nil {
liveCats, _ = s.native.ListCategories(ctx, owner)
if needCats {
liveCats, _ = s.native.ListCategories(ctx, a.OrganizationID)
}
seen := map[models.IntegrationAction]bool{}
@@ -511,11 +506,11 @@ func (s *service) guardedAITools(ctx context.Context, a models.Automation, n mod
seen[action] = true
switch action {
case models.IntegrationActionAddTag:
tools = append(tools, s.aiTagTool(a, n, data, feedPause, owner, action, cfg.AddTags, liveCats, cfg.AllowCreateTags))
tools = append(tools, s.aiTagTool(a, n, data, feedPause, a.OrganizationID, action, cfg.AddTags, liveCats, cfg.AllowCreateTags))
case models.IntegrationActionRemoveTag:
tools = append(tools, s.aiTagTool(a, n, data, feedPause, owner, action, cfg.RemoveTags, liveCats, false))
tools = append(tools, s.aiTagTool(a, n, data, feedPause, a.OrganizationID, action, cfg.RemoveTags, liveCats, false))
case models.IntegrationActionLabelEmail:
tools = append(tools, s.aiTagTool(a, n, data, feedPause, owner, action, cfg.LabelPool, liveCats, cfg.AllowCreateTags))
tools = append(tools, s.aiTagTool(a, n, data, feedPause, a.OrganizationID, action, cfg.LabelPool, liveCats, cfg.AllowCreateTags))
case models.IntegrationActionCreateTask:
tools = append(tools, s.aiTaskTool(a, n, data, feedPause))
case models.IntegrationActionCreateDeal:
@@ -545,7 +540,7 @@ func (s *service) dispatchSynthetic(ctx context.Context, a models.Automation, n
// enum), while an empty pool is unrestricted and resolved against the live
// category list (with optional create for add/label). It can be called
// repeatedly.
func (s *service) aiTagTool(a models.Automation, n models.AutomationNode, data map[string]any, feedPause bool, owner uuid.UUID, action models.IntegrationAction, pool []models.AITagRef, live []models.MiniCategory, allowCreate bool) generation.ToolDef {
func (s *service) aiTagTool(a models.Automation, n models.AutomationNode, data map[string]any, feedPause bool, orgID uuid.UUID, action models.IntegrationAction, pool []models.AITagRef, live []models.MiniCategory, allowCreate bool) generation.ToolDef {
kind := aiToolName(action)
allowCreate = allowCreate && action != models.IntegrationActionRemoveTag
enum := aiagentargs.TagEnum(pool, live)
@@ -580,7 +575,7 @@ func (s *service) aiTagTool(a models.Automation, n models.AutomationNode, data m
return "(test run: would " + kind + " " + strings.TrimSpace(in.Tag) + ")", nil
}
id, err := aiagentargs.ResolveTag(pool, live, allowCreate, in.Tag, func(title string) (uuid.UUID, error) {
c, cerr := s.native.CreateCategory(ctx, owner, title, "")
c, cerr := s.native.CreateCategory(ctx, orgID, title, "")
if cerr != nil {
return uuid.Nil, cerr
}
+9 -10
View File
@@ -173,7 +173,7 @@ type NativeActions interface {
// LabelThread additively applies unibox conversation labels to a thread, on
// behalf of the mailbox-owner userID (categories are per user). Backs the
// "label_email" action; userID + threadID come from the reply event data.
LabelThread(ctx context.Context, userID uuid.UUID, threadID string, categoryIDs []uuid.UUID) error
LabelThread(ctx context.Context, orgID uuid.UUID, threadID string, categoryIDs []uuid.UUID) error
// UpsertContact creates the contact or enriches the one already holding
// its email (the same write the contacts API does), owned by actorID.
@@ -186,11 +186,11 @@ type NativeActions interface {
// ListCategories / CreateCategory / ListPipelines back the AI agent step's
// argument-based tools: the model picks a tag/label/pipeline by name and the
// executor resolves it live (empty pool = any of the owner's tags). Keyed by
// the org OWNER (categories are per user); pipelines are org-scoped with
// stages hydrated in position order. Mirrors the campaign agent tools.
ListCategories(ctx context.Context, ownerID uuid.UUID) ([]models.MiniCategory, error)
CreateCategory(ctx context.Context, ownerID uuid.UUID, title, color string) (models.MiniCategory, error)
// executor resolves it live (empty pool = any of the workspace's tags). All
// three are org-scoped; pipelines hydrate their stages in position order.
// Mirrors the campaign agent tools.
ListCategories(ctx context.Context, orgID uuid.UUID) ([]models.MiniCategory, error)
CreateCategory(ctx context.Context, orgID uuid.UUID, title, color string) (models.MiniCategory, error)
ListPipelines(ctx context.Context, orgID uuid.UUID) ([]models.Pipeline, error)
}
@@ -426,18 +426,17 @@ func (s *service) execNativeAction(ctx context.Context, a models.Automation, n m
}
// label_email tags the conversation the event belongs to; it needs the
// thread + mailbox owner (carried by reply triggers), not a resolved contact.
// thread (carried by reply triggers), not a resolved contact.
if n.Action == models.IntegrationActionLabelEmail {
threadID := stringFromMap(data, "thread_id")
ownerID, perr := uuid.Parse(stringFromMap(data, "_user_id"))
if threadID == "" || perr != nil {
if threadID == "" {
return fmt.Errorf("label-email needs a reply thread (use it on a reply trigger)")
}
catIDs := parseUUIDList(cfg.LabelIDs)
if len(catIDs) == 0 {
return fmt.Errorf("a label action needs at least one label")
}
return s.native.LabelThread(ctx, ownerID, threadID, catIDs)
return s.native.LabelThread(ctx, a.OrganizationID, threadID, catIDs)
}
contactID := stringFromMap(data, "contact_id")
+9 -8
View File
@@ -172,20 +172,21 @@ func (a Adapter) AddToCampaign(ctx context.Context, orgID, actorID, contactID, c
// LabelThread applies unibox conversation labels to a thread on behalf of the
// mailbox owner (the advanced service guards category ownership). The error is
// already a plain error, so it passes straight through.
func (a Adapter) LabelThread(ctx context.Context, userID uuid.UUID, threadID string, categoryIDs []uuid.UUID) error {
return a.Adv.LabelThread(ctx, userID, threadID, categoryIDs)
func (a Adapter) LabelThread(ctx context.Context, orgID uuid.UUID, threadID string, categoryIDs []uuid.UUID) error {
return a.Adv.LabelThread(ctx, orgID, threadID, categoryIDs)
}
// ListCategories / CreateCategory / ListPipelines back the AI agent step's
// argument-based tag/label/deal tools (the model picks a name, resolved live).
// Categories are owner-scoped (tags == unibox labels); pipelines are org-scoped
// with stages hydrated. All three delegate straight to the advanced service.
func (a Adapter) ListCategories(ctx context.Context, ownerID uuid.UUID) ([]models.MiniCategory, error) {
return a.Adv.ListCategories(ctx, ownerID)
// Categories and pipelines are both org-scoped (tags == unibox labels;
// pipelines hydrate their stages). All three delegate straight to the
// advanced service.
func (a Adapter) ListCategories(ctx context.Context, orgID uuid.UUID) ([]models.MiniCategory, error) {
return a.Adv.ListCategories(ctx, orgID)
}
func (a Adapter) CreateCategory(ctx context.Context, ownerID uuid.UUID, title, color string) (models.MiniCategory, error) {
return a.Adv.CreateCategory(ctx, ownerID, title, color)
func (a Adapter) CreateCategory(ctx context.Context, orgID uuid.UUID, title, color string) (models.MiniCategory, error) {
return a.Adv.CreateCategory(ctx, orgID, title, color)
}
func (a Adapter) ListPipelines(ctx context.Context, orgID uuid.UUID) ([]models.Pipeline, error) {
+9 -11
View File
@@ -196,13 +196,11 @@ var Tables = []Table{
Scope: `email_account_id IN ` + orgMailboxes,
},
{
// Below organization_members: user_id names the creator, and the
// importer needs that row present or it blanks the attribution.
Name: "tags", Group: models.OrgDataGroupCore,
// Labels are user-scoped in the schema, so they are collected by what
// the organization's own rows reference. Scoping them by owning user
// instead would drag that user's other workspaces into the archive.
Scope: `id IN (SELECT tag_id FROM email_tags WHERE email_id IN ` + orgMailboxes + `)
OR id IN (SELECT tag_id FROM campaign_email_tags WHERE campaign_id IN ` + orgCampaigns + `)`,
Note: "Only tags this workspace actually uses travel; tags are owned by a user, not an organization.",
Scope: scopeOrg,
Note: "The whole tag registry travels, including tags nothing is filed under yet.",
},
{
Name: "email_tags", Group: models.OrgDataGroupCore,
@@ -246,9 +244,8 @@ var Tables = []Table{
// ---------- contacts ----------
{
Name: "categories", Group: models.OrgDataGroupContacts,
Scope: `id IN (SELECT category_id FROM contact_categories WHERE contact_id IN ` + orgContacts + `)
OR id IN (SELECT category_id FROM unibox_thread_labels WHERE thread_id IN ` + orgThreads + `)`,
Note: "Same user-scoped-label rule as tags.",
Scope: scopeOrg,
Note: "The whole category registry travels, including ones no contact or conversation carries yet.",
},
{
Name: "contacts", Group: models.OrgDataGroupContacts,
@@ -303,7 +300,8 @@ var Tables = []Table{
// ---------- campaigns ----------
{
Name: "folders", Group: models.OrgDataGroupCampaigns,
Scope: `id IN (SELECT folder_id FROM campaign_folders WHERE campaign_id IN ` + orgCampaigns + `)`,
Scope: scopeOrg,
Note: "The whole folder registry travels, including empty folders.",
},
{
Name: "campaigns", Group: models.OrgDataGroupCampaigns,
@@ -576,7 +574,7 @@ var Tables = []Table{
},
{
Name: "unibox_thread_labels", Group: models.OrgDataGroupInbox,
Scope: `thread_id IN ` + orgThreads,
Scope: scopeOrg,
},
{
Name: "unibox_snoozes", Group: models.OrgDataGroupInbox,
+4 -4
View File
@@ -467,7 +467,7 @@ func (s *service) Enroll(ctx context.Context, inst *models.PoolLinkInstance, req
return nil, errx.InternalError()
}
s.applyWarmupSettings(ctx, userID, acc.ID, req.Warmup)
s.applyWarmupSettings(ctx, orgID, userID, acc.ID, req.Warmup)
if _, xerr := s.emailSvc.SetWarmupLifecycle(ctx, userID, acc.ID.String(), "start"); xerr != nil {
log.Warn().Str("account_id", acc.ID.String()).Msg("pool link: warmup start failed after enrollment")
}
@@ -480,7 +480,7 @@ func (s *service) Enroll(ctx context.Context, inst *models.PoolLinkInstance, req
return s.GetMailbox(ctx, inst, req.RemoteID)
}
func (s *service) applyWarmupSettings(ctx context.Context, userID string, accountID uuid.UUID, w models.PoolLinkWarmupSettings) {
func (s *service) applyWarmupSettings(ctx context.Context, orgID uuid.UUID, userID string, accountID uuid.UUID, w models.PoolLinkWarmupSettings) {
upd := &models.UpdateEmail{}
set := false
if w.Base > 0 {
@@ -510,7 +510,7 @@ func (s *service) applyWarmupSettings(ctx context.Context, userID string, accoun
if !set {
return
}
if _, xerr := s.emailSvc.Update(ctx, userID, accountID.String(), upd); xerr != nil {
if _, xerr := s.emailSvc.Update(ctx, orgID.String(), userID, accountID.String(), upd); xerr != nil {
log.Warn().Str("account_id", accountID.String()).Msg("pool link: warmup settings update failed")
}
}
@@ -616,7 +616,7 @@ func (s *service) PatchMailbox(ctx context.Context, inst *models.PoolLinkInstanc
reload = true
}
if patch.Warmup != nil {
s.applyWarmupSettings(ctx, userID, m.EmailAccountID, *patch.Warmup)
s.applyWarmupSettings(ctx, inst.OrganizationID, userID, m.EmailAccountID, *patch.Warmup)
}
switch patch.Lifecycle {
case "pause", "resume":
+7 -6
View File
@@ -10,13 +10,14 @@ import (
)
// SetThreadLabels replaces the conversation's label set with the given
// categories. The repository only attaches categories the user actually
// owns, so callers can pass the picker's selection straight through.
func (s *uniboxService) SetThreadLabels(ctx context.Context, userID uuid.UUID, threadID string, categoryIDs []uuid.UUID) ([]models.MiniCategory, *errx.Error) {
// categories. The repository only attaches categories the workspace actually
// owns, so callers can pass the picker's selection straight through; userID
// records who filed it.
func (s *uniboxService) SetThreadLabels(ctx context.Context, orgID, userID uuid.UUID, threadID string, categoryIDs []uuid.UUID) ([]models.MiniCategory, *errx.Error) {
if threadID == "" {
return nil, errx.New(errx.BadRequest, "thread_id is required")
}
labels, err := s.uniboxRepository.SetThreadLabels(ctx, userID, threadID, categoryIDs)
labels, err := s.uniboxRepository.SetThreadLabels(ctx, orgID, userID, threadID, categoryIDs)
if err != nil {
errs.CaptureException(err)
return nil, errx.InternalError()
@@ -25,11 +26,11 @@ func (s *uniboxService) SetThreadLabels(ctx context.Context, userID uuid.UUID, t
}
// ListThreadLabels returns the conversation's current labels.
func (s *uniboxService) ListThreadLabels(ctx context.Context, userID uuid.UUID, threadID string) ([]models.MiniCategory, *errx.Error) {
func (s *uniboxService) ListThreadLabels(ctx context.Context, orgID uuid.UUID, threadID string) ([]models.MiniCategory, *errx.Error) {
if threadID == "" {
return nil, errx.New(errx.BadRequest, "thread_id is required")
}
labels, err := s.uniboxRepository.ListThreadLabels(ctx, userID, threadID)
labels, err := s.uniboxRepository.ListThreadLabels(ctx, orgID, threadID)
if err != nil {
errs.CaptureException(err)
return nil, errx.InternalError()
+1 -2
View File
@@ -13,7 +13,6 @@ import (
func (s *uniboxService) Search(
ctx context.Context,
orgID uuid.UUID,
userID uuid.UUID,
params *models.MailSearchParams,
) (*models.MailSearchResult, *errx.Error) {
// Validate page size
@@ -29,7 +28,7 @@ func (s *uniboxService) Search(
// sender-only fast path (GetBySender) returned un-collapsed,
// label-less rows, so it can't serve the stacked list anymore — the
// sender filter is handled inside Search via params.Sender.
resp, err := s.uniboxRepository.Search(ctx, orgID, userID, params)
resp, err := s.uniboxRepository.Search(ctx, orgID, params)
if err != nil {
errs.CaptureException(err)
return nil, errx.InternalError()
+2 -3
View File
@@ -22,7 +22,6 @@ type UniboxService interface {
Search(
ctx context.Context,
orgID uuid.UUID,
userID uuid.UUID,
params *models.MailSearchParams,
) (*models.MailSearchResult, *errx.Error)
GetByID(
@@ -57,8 +56,8 @@ type UniboxService interface {
// Conversation labels. SetThreadLabels replaces a thread's full
// label set (idempotent); ListThreadLabels reads the current set.
SetThreadLabels(ctx context.Context, userID uuid.UUID, threadID string, categoryIDs []uuid.UUID) ([]models.MiniCategory, *errx.Error)
ListThreadLabels(ctx context.Context, userID uuid.UUID, threadID string) ([]models.MiniCategory, *errx.Error)
SetThreadLabels(ctx context.Context, orgID, userID uuid.UUID, threadID string, categoryIDs []uuid.UUID) ([]models.MiniCategory, *errx.Error)
ListThreadLabels(ctx context.Context, orgID uuid.UUID, threadID string) ([]models.MiniCategory, *errx.Error)
// Scheduled-sends review + cancel. CancelScheduled is DB-only: we
// flip status to 'cancelled' and let the queued Cloud Task fire as
@@ -0,0 +1,74 @@
-- Reverses 000147. Labels go back to being owned by whoever created them.
--
-- Two things do not come back: the per-user position numbering the up
-- migration collapsed into one sequence per workspace, and the duplicate
-- conversation labels it merged. Both are cosmetic; the labels themselves and
-- everything they are attached to survive the round trip.
BEGIN;
-- A label whose creator was deleted while the workspace owned it has no user
-- to go back to, so it adopts the organization's owner.
UPDATE public.unibox_thread_labels utl
SET user_id = o.owner_user_id
FROM public.organizations o
WHERE o.id = utl.organization_id AND utl.user_id IS NULL;
DELETE FROM public.unibox_thread_labels WHERE user_id IS NULL;
ALTER TABLE public.unibox_thread_labels DROP CONSTRAINT IF EXISTS unibox_thread_labels_organization_id_fkey;
ALTER TABLE public.unibox_thread_labels DROP CONSTRAINT IF EXISTS unibox_thread_labels_pkey;
ALTER TABLE public.unibox_thread_labels DROP COLUMN IF EXISTS organization_id;
ALTER TABLE public.unibox_thread_labels ALTER COLUMN user_id SET NOT NULL;
ALTER TABLE public.unibox_thread_labels
ADD CONSTRAINT unibox_thread_labels_pkey PRIMARY KEY (user_id, thread_id, category_id);
ALTER TABLE public.unibox_thread_labels DROP CONSTRAINT IF EXISTS unibox_thread_labels_user_id_fkey;
ALTER TABLE public.unibox_thread_labels
ADD CONSTRAINT unibox_thread_labels_user_id_fkey
FOREIGN KEY (user_id) REFERENCES public.users(id) ON DELETE CASCADE;
DROP INDEX IF EXISTS idx_unibox_thread_labels_org_thread;
CREATE INDEX IF NOT EXISTS idx_unibox_thread_labels_thread
ON public.unibox_thread_labels USING btree (user_id, thread_id);
UPDATE public.tags g SET user_id = o.owner_user_id FROM public.organizations o WHERE o.id = g.organization_id AND g.user_id IS NULL;
UPDATE public.categories g SET user_id = o.owner_user_id FROM public.organizations o WHERE o.id = g.organization_id AND g.user_id IS NULL;
UPDATE public.folders g SET user_id = o.owner_user_id FROM public.organizations o WHERE o.id = g.organization_id AND g.user_id IS NULL;
DELETE FROM public.tags WHERE user_id IS NULL;
DELETE FROM public.categories WHERE user_id IS NULL;
DELETE FROM public.folders WHERE user_id IS NULL;
DROP INDEX IF EXISTS idx_tags_organization;
DROP INDEX IF EXISTS idx_categories_organization;
DROP INDEX IF EXISTS idx_folders_organization;
ALTER TABLE public.tags DROP CONSTRAINT IF EXISTS tags_organization_id_fkey;
ALTER TABLE public.categories DROP CONSTRAINT IF EXISTS categories_organization_id_fkey;
ALTER TABLE public.folders DROP CONSTRAINT IF EXISTS folders_organization_id_fkey;
ALTER TABLE public.tags DROP COLUMN IF EXISTS organization_id;
ALTER TABLE public.categories DROP COLUMN IF EXISTS organization_id;
ALTER TABLE public.folders DROP COLUMN IF EXISTS organization_id;
ALTER TABLE public.tags ALTER COLUMN user_id SET NOT NULL;
ALTER TABLE public.categories ALTER COLUMN user_id SET NOT NULL;
ALTER TABLE public.folders ALTER COLUMN user_id SET NOT NULL;
ALTER TABLE public.tags DROP CONSTRAINT IF EXISTS tags_user_id_fkey;
ALTER TABLE public.categories DROP CONSTRAINT IF EXISTS categories_user_id_fkey;
ALTER TABLE public.folders DROP CONSTRAINT IF EXISTS folders_user_id_fkey;
ALTER TABLE public.tags
ADD CONSTRAINT tags_user_id_fkey FOREIGN KEY (user_id) REFERENCES public.users(id) ON DELETE CASCADE;
ALTER TABLE public.categories
ADD CONSTRAINT categories_user_id_fkey FOREIGN KEY (user_id) REFERENCES public.users(id) ON DELETE CASCADE;
ALTER TABLE public.folders
ADD CONSTRAINT folders_user_id_fkey FOREIGN KEY (user_id) REFERENCES public.users(id) ON DELETE CASCADE;
COMMENT ON COLUMN public.tags.user_id IS NULL;
COMMENT ON COLUMN public.categories.user_id IS NULL;
COMMENT ON COLUMN public.folders.user_id IS NULL;
COMMENT ON COLUMN public.unibox_thread_labels.user_id IS NULL;
COMMIT;
@@ -0,0 +1,210 @@
-- Tags, categories and folders belong to the workspace, not to whoever typed
-- them in.
--
-- All three label registries keyed on user_id, so a tag the owner created was
-- invisible to every teammate: the mailbox list still carried the tag ids, but
-- /auth/me only listed the caller's own rows, so the chips rendered as nothing
-- (issue #436). The same held for contact categories and campaign folders, and
-- for conversation labels in the shared inbox, where one member's labelling was
-- unreadable to the next.
--
-- A user may also belong to several organizations, so a per-user registry
-- leaked one workspace's vocabulary into another. organization_id fixes both.
--
-- user_id stays as the creator, for attribution only, and no longer cascades a
-- delete: offboarding the person who made a tag must not take the workspace's
-- tag with them.
BEGIN;
ALTER TABLE public.tags ADD COLUMN IF NOT EXISTS organization_id uuid;
ALTER TABLE public.categories ADD COLUMN IF NOT EXISTS organization_id uuid;
ALTER TABLE public.folders ADD COLUMN IF NOT EXISTS organization_id uuid;
-- Backfill pass 1: where the label is already attached to something, that
-- something names the workspace it belongs to. Only unambiguous cases are
-- taken (every use points at one organization), which is what makes this safe
-- for a user who is a member of more than one.
UPDATE public.tags t
SET organization_id = u.org
FROM (
-- (array_agg(DISTINCT ...))[1] because Postgres has no min(uuid); HAVING
-- has already reduced the group to one distinct value anyway.
SELECT x.tag_id, (array_agg(DISTINCT x.org))[1] AS org
FROM (
SELECT et.tag_id, ea.organization_id AS org
FROM public.email_tags et
JOIN public.email_accounts ea ON ea.id = et.email_id
WHERE ea.organization_id IS NOT NULL
UNION ALL
SELECT cet.tag_id, c.organization_id
FROM public.campaign_email_tags cet
JOIN public.campaigns c ON c.id = cet.campaign_id
WHERE c.organization_id IS NOT NULL
) x
GROUP BY x.tag_id
HAVING COUNT(DISTINCT x.org) = 1
) u
WHERE t.id = u.tag_id;
UPDATE public.categories cat
SET organization_id = u.org
FROM (
SELECT cc.category_id, (array_agg(DISTINCT c.organization_id))[1] AS org
FROM public.contact_categories cc
JOIN public.contacts c ON c.id = cc.contact_id
WHERE c.organization_id IS NOT NULL
GROUP BY cc.category_id
HAVING COUNT(DISTINCT c.organization_id) = 1
) u
WHERE cat.id = u.category_id;
UPDATE public.folders f
SET organization_id = u.org
FROM (
SELECT cf.folder_id, (array_agg(DISTINCT c.organization_id))[1] AS org
FROM public.campaign_folders cf
JOIN public.campaigns c ON c.id = cf.campaign_id
WHERE c.organization_id IS NOT NULL
GROUP BY cf.folder_id
HAVING COUNT(DISTINCT c.organization_id) = 1
) u
WHERE f.id = u.folder_id;
-- Backfill pass 2: an unused label has nothing to point at, so it follows its
-- creator — the organization they own, else the first they joined.
UPDATE public.tags SET organization_id = COALESCE(
(SELECT o.id FROM public.organizations o WHERE o.owner_user_id = tags.user_id ORDER BY o.created_at, o.id LIMIT 1),
(SELECT m.organization_id FROM public.organization_members m WHERE m.user_id = tags.user_id ORDER BY m.invited_at, m.id LIMIT 1)
) WHERE organization_id IS NULL;
UPDATE public.categories SET organization_id = COALESCE(
(SELECT o.id FROM public.organizations o WHERE o.owner_user_id = categories.user_id ORDER BY o.created_at, o.id LIMIT 1),
(SELECT m.organization_id FROM public.organization_members m WHERE m.user_id = categories.user_id ORDER BY m.invited_at, m.id LIMIT 1)
) WHERE organization_id IS NULL;
UPDATE public.folders SET organization_id = COALESCE(
(SELECT o.id FROM public.organizations o WHERE o.owner_user_id = folders.user_id ORDER BY o.created_at, o.id LIMIT 1),
(SELECT m.organization_id FROM public.organization_members m WHERE m.user_id = folders.user_id ORDER BY m.invited_at, m.id LIMIT 1)
) WHERE organization_id IS NULL;
-- A label whose creator belongs to no organization at all is reachable from
-- nowhere once the registry is workspace-scoped.
DELETE FROM public.tags WHERE organization_id IS NULL;
DELETE FROM public.categories WHERE organization_id IS NULL;
DELETE FROM public.folders WHERE organization_id IS NULL;
ALTER TABLE public.tags ALTER COLUMN organization_id SET NOT NULL;
ALTER TABLE public.categories ALTER COLUMN organization_id SET NOT NULL;
ALTER TABLE public.folders ALTER COLUMN organization_id SET NOT NULL;
ALTER TABLE public.tags
ADD CONSTRAINT tags_organization_id_fkey
FOREIGN KEY (organization_id) REFERENCES public.organizations(id) ON DELETE CASCADE;
ALTER TABLE public.categories
ADD CONSTRAINT categories_organization_id_fkey
FOREIGN KEY (organization_id) REFERENCES public.organizations(id) ON DELETE CASCADE;
ALTER TABLE public.folders
ADD CONSTRAINT folders_organization_id_fkey
FOREIGN KEY (organization_id) REFERENCES public.organizations(id) ON DELETE CASCADE;
-- Positions were per user and are now per workspace, so two members' rows
-- collided at 0, 1, 2. Renumber each registry once, keeping the existing order
-- and breaking ties by age. Titles are deliberately NOT merged: two members who
-- each made a "Prospects" tag made two tags, and collapsing them would silently
-- retag their data.
WITH renumbered AS (
SELECT id, ROW_NUMBER() OVER (PARTITION BY organization_id ORDER BY "position", created_at, id) - 1 AS pos
FROM public.tags
)
UPDATE public.tags t SET "position" = r.pos FROM renumbered r WHERE t.id = r.id AND t."position" <> r.pos;
WITH renumbered AS (
SELECT id, ROW_NUMBER() OVER (PARTITION BY organization_id ORDER BY "position", created_at, id) - 1 AS pos
FROM public.categories
)
UPDATE public.categories c SET "position" = r.pos FROM renumbered r WHERE c.id = r.id AND c."position" <> r.pos;
WITH renumbered AS (
SELECT id, ROW_NUMBER() OVER (PARTITION BY organization_id ORDER BY "position", created_at, id) - 1 AS pos
FROM public.folders
)
UPDATE public.folders f SET "position" = r.pos FROM renumbered r WHERE f.id = r.id AND f."position" <> r.pos;
CREATE INDEX IF NOT EXISTS idx_tags_organization ON public.tags USING btree (organization_id, "position");
CREATE INDEX IF NOT EXISTS idx_categories_organization ON public.categories USING btree (organization_id, "position");
CREATE INDEX IF NOT EXISTS idx_folders_organization ON public.folders USING btree (organization_id, "position");
-- user_id is attribution now. Deleting the creator must not delete the
-- workspace's label, so the cascade becomes SET NULL and the column nullable.
ALTER TABLE public.tags DROP CONSTRAINT IF EXISTS tags_user_id_fkey;
ALTER TABLE public.categories DROP CONSTRAINT IF EXISTS categories_user_id_fkey;
ALTER TABLE public.folders DROP CONSTRAINT IF EXISTS folders_user_id_fkey;
ALTER TABLE public.tags ALTER COLUMN user_id DROP NOT NULL;
ALTER TABLE public.categories ALTER COLUMN user_id DROP NOT NULL;
ALTER TABLE public.folders ALTER COLUMN user_id DROP NOT NULL;
ALTER TABLE public.tags
ADD CONSTRAINT tags_user_id_fkey
FOREIGN KEY (user_id) REFERENCES public.users(id) ON DELETE SET NULL;
ALTER TABLE public.categories
ADD CONSTRAINT categories_user_id_fkey
FOREIGN KEY (user_id) REFERENCES public.users(id) ON DELETE SET NULL;
ALTER TABLE public.folders
ADD CONSTRAINT folders_user_id_fkey
FOREIGN KEY (user_id) REFERENCES public.users(id) ON DELETE SET NULL;
COMMENT ON COLUMN public.tags.user_id IS 'Who created it. Attribution only; the tag belongs to organization_id.';
COMMENT ON COLUMN public.categories.user_id IS 'Who created it. Attribution only; the category belongs to organization_id.';
COMMENT ON COLUMN public.folders.user_id IS 'Who created it. Attribution only; the folder belongs to organization_id.';
-- ── Conversation labels ────────────────────────────────────────────────
--
-- The unified inbox is already read per organization, so a label that only its
-- author could see made the label rail count threads nobody else could find.
-- It moves to the workspace with the vocabulary it draws on.
ALTER TABLE public.unibox_thread_labels ADD COLUMN IF NOT EXISTS organization_id uuid;
UPDATE public.unibox_thread_labels utl
SET organization_id = c.organization_id
FROM public.categories c
WHERE c.id = utl.category_id AND utl.organization_id IS NULL;
DELETE FROM public.unibox_thread_labels WHERE organization_id IS NULL;
-- Two members who labelled the same thread with the same category collapse to
-- one row under the new key.
DELETE FROM public.unibox_thread_labels a
USING public.unibox_thread_labels b
WHERE a.organization_id = b.organization_id
AND a.thread_id = b.thread_id
AND a.category_id = b.category_id
AND (a.created_at, a.user_id) > (b.created_at, b.user_id);
ALTER TABLE public.unibox_thread_labels ALTER COLUMN organization_id SET NOT NULL;
ALTER TABLE public.unibox_thread_labels DROP CONSTRAINT IF EXISTS unibox_thread_labels_pkey;
ALTER TABLE public.unibox_thread_labels
ADD CONSTRAINT unibox_thread_labels_pkey PRIMARY KEY (organization_id, thread_id, category_id);
ALTER TABLE public.unibox_thread_labels
ADD CONSTRAINT unibox_thread_labels_organization_id_fkey
FOREIGN KEY (organization_id) REFERENCES public.organizations(id) ON DELETE CASCADE;
DROP INDEX IF EXISTS idx_unibox_thread_labels_thread;
CREATE INDEX IF NOT EXISTS idx_unibox_thread_labels_org_thread
ON public.unibox_thread_labels USING btree (organization_id, thread_id);
-- Same rule as the registries: the person who labelled a conversation leaving
-- must not unlabel it.
ALTER TABLE public.unibox_thread_labels DROP CONSTRAINT IF EXISTS unibox_thread_labels_user_id_fkey;
ALTER TABLE public.unibox_thread_labels ALTER COLUMN user_id DROP NOT NULL;
ALTER TABLE public.unibox_thread_labels
ADD CONSTRAINT unibox_thread_labels_user_id_fkey
FOREIGN KEY (user_id) REFERENCES public.users(id) ON DELETE SET NULL;
COMMENT ON COLUMN public.unibox_thread_labels.user_id IS 'Who applied it. Attribution only; the label belongs to organization_id.';
COMMIT;
+3 -3
View File
@@ -118,14 +118,14 @@ type OrgDataGroupInfo struct {
// import, it would abort it. Nullable crossings need no entry — the importer
// blanks those when their target is not part of the run.
var OrgDataGroupCatalog = []OrgDataGroupInfo{
{Key: OrgDataGroupCore, Label: "Workspace", Description: "Organization, members, roles, teams, mailboxes, API keys, webhooks, and settings.", Required: true},
{Key: OrgDataGroupCore, Label: "Workspace", Description: "Organization, members, roles, teams, mailboxes, mailbox tags, API keys, webhooks, and settings.", Required: true},
{Key: OrgDataGroupContacts, Label: "Contacts", Description: "Contacts, categories, notes, activities, and the suppression list."},
{Key: OrgDataGroupCampaigns, Label: "Campaigns", Description: "Campaigns, sequences, senders, attachments, and per-campaign settings.", Requires: []OrgDataGroup{OrgDataGroupContacts}},
{Key: OrgDataGroupCampaigns, Label: "Campaigns", Description: "Campaigns, folders, sequences, senders, attachments, and per-campaign settings.", Requires: []OrgDataGroup{OrgDataGroupContacts}},
{Key: OrgDataGroupCRM, Label: "CRM", Description: "Pipelines, deals, tasks, and meeting bookings."},
{Key: OrgDataGroupAutomations, Label: "Automations", Description: "Automations, connected integrations, and lead sync sources."},
{Key: OrgDataGroupAI, Label: "Assistant", Description: "Assistant sessions and messages, skills, MCP servers, and AI settings."},
{Key: OrgDataGroupWarmup, Label: "Warmup", Description: "Warmup participation, routing rules, statistics, and appeals."},
{Key: OrgDataGroupInbox, Label: "Inbox", Description: "Unified inbox threads, message bodies, and mailbox sync state.", Heavy: true, Requires: []OrgDataGroup{OrgDataGroupContacts}},
{Key: OrgDataGroupInbox, Label: "Inbox", Description: "Unified inbox threads, message bodies, conversation labels, and mailbox sync state.", Heavy: true, Requires: []OrgDataGroup{OrgDataGroupContacts}},
{Key: OrgDataGroupSending, Label: "Send history", Description: "Queued and completed send tasks with their payloads.", Heavy: true},
{Key: OrgDataGroupEvents, Label: "Delivery events", Description: "Bounces, complaints, opens, clicks, and placement tests.", Heavy: true},
{Key: OrgDataGroupLogs, Label: "Logs", Description: "Audit log, campaign logs, and notifications.", Heavy: true, Requires: []OrgDataGroup{OrgDataGroupCampaigns}},
+4 -4
View File
@@ -38,10 +38,10 @@ type User struct {
DeletionScheduledAt *time.Time `json:"deletion_scheduled_at,omitempty"`
DeletionScheduledFor *time.Time `json:"deletion_scheduled_for,omitempty"`
// Per-user label groups. Always serialized as arrays (never null)
// so the frontend can iterate without optional-chaining every
// access. Populated by the /auth/me handler after the base user
// load.
// The workspace's label registries, for the organization the session
// currently has selected. Always serialized as arrays (never null) so the
// frontend can iterate without optional-chaining every access. Populated
// by the /auth/me handler after the base user load.
Folders []Group `json:"folders"`
Tags []Group `json:"tags"`
Categories []Group `json:"categories"`
@@ -37,8 +37,8 @@ func TestLiveContactTimelineLifecycleEvents(t *testing.T) {
repo := NewContactRepostory(handle)
category := uuid.New()
if _, err := pool.Exec(ctx, `INSERT INTO categories (id, user_id, title, color, position) VALUES ($1, $2, 'Warm lead', '#ff8800', 0)`,
category, f.owner); err != nil {
if _, err := pool.Exec(ctx, `INSERT INTO categories (id, organization_id, user_id, title, color, position) VALUES ($1, $2, $3, 'Warm lead', '#ff8800', 0)`,
category, f.org, f.owner); err != nil {
t.Fatalf("category: %v", err)
}
t.Cleanup(func() {
+1 -1
View File
@@ -21,7 +21,7 @@ func TestLiveFormLifecycle(t *testing.T) {
ctx := context.Background()
category := uuid.New()
if _, err := pool.Exec(ctx, `INSERT INTO categories (id, user_id, title, color, position) VALUES ($1, $2, 'Form leads', '#00ff00', 0)`, category, f.owner); err != nil {
if _, err := pool.Exec(ctx, `INSERT INTO categories (id, organization_id, user_id, title, color, position) VALUES ($1, $2, $3, 'Form leads', '#00ff00', 0)`, category, f.org, f.owner); err != nil {
t.Fatalf("fixture category: %v", err)
}
t.Cleanup(func() {
@@ -0,0 +1,289 @@
package repository
import (
"context"
"testing"
"github.com/google/uuid"
"github.com/warmbly/warmbly/internal/models"
)
// Regression cover for issue #436: the folder / tag / category registries were
// keyed on the user who created them, so a tag the workspace owner made was
// invisible to every teammate — the mailbox list still carried the tag ids, but
// /auth/me listed only the caller's own rows, so the chips rendered as nothing.
//
// Labels are organization assets, so the creator has left these signatures
// entirely: every read and write below is keyed on the workspace, and each one
// is checked against a label a DIFFERENT member made, plus a real label
// belonging to another workspace that must never be reachable.
//
// Run against the dev stack:
//
// WARMBLY_TEST_DB=postgres://warmbly:warmbly@localhost:15432/warmbly_dev?sslmode=disable \
// go test ./internal/repository/ -run LiveGroup -v
func TestLiveGroupRegistriesAreOrganizationWide(t *testing.T) {
handle, pool := liveContactDB(t)
f := newSharedOrgFixture(t, pool)
ctx := context.Background()
// A second organization, owned by the same person, whose tag must never
// show up in the first one: a user may belong to several workspaces, which
// is the other half of what a per-user registry got wrong.
foreignOrg := uuid.New()
if _, err := pool.Exec(ctx, `INSERT INTO organizations (id, name, slug, owner_user_id) VALUES ($1, 'Issue 436 other', $2, $3)`,
foreignOrg, "i436-"+foreignOrg.String()[:8], f.owner); err != nil {
t.Fatalf("foreign org: %v", err)
}
t.Cleanup(func() {
_, _ = pool.Exec(context.Background(), `DELETE FROM tags WHERE organization_id = ANY($1)`, []uuid.UUID{f.org, foreignOrg})
_, _ = pool.Exec(context.Background(), `DELETE FROM organizations WHERE id = $1`, foreignOrg)
})
tags := NewGroupRepostory(handle, models.Tags)
// The OWNER creates the tag, exactly as the reporter did.
created, xerr := tags.Create(ctx, f.org, f.owner, &models.GroupCreate{Title: "VIP senders", Color: "#a855f7"})
if xerr != nil {
t.Fatalf("create: %v", xerr)
}
foreign, xerr := tags.Create(ctx, foreignOrg, f.owner, &models.GroupCreate{Title: "Other workspace", Color: "#ef4444"})
if xerr != nil {
t.Fatalf("create foreign: %v", xerr)
}
// The TEAMMATE lists them. This is what /auth/me serves.
listed, xerr := tags.List(ctx, f.org)
if xerr != nil {
t.Fatalf("list: %v", xerr)
}
if len(listed) != 1 || listed[0].ID != created.ID {
t.Fatalf("teammate sees %d tags (%+v), want only %s", len(listed), listed, created.ID)
}
if listed[0].Title != "VIP senders" {
t.Errorf("title = %q, want %q", listed[0].Title, "VIP senders")
}
// ...and the other workspace's tag stays in the other workspace.
other, xerr := tags.List(ctx, foreignOrg)
if xerr != nil {
t.Fatalf("list foreign: %v", xerr)
}
if len(other) != 1 || other[0].ID != foreign.ID {
t.Fatalf("foreign workspace sees %+v, want only %s", other, foreign.ID)
}
// The teammate can rename it: a tag is not the creator's private property.
title := "VIP"
if _, xerr := tags.Update(ctx, f.org, created.ID, &models.GroupUpdate{Title: &title}); xerr != nil {
t.Fatalf("teammate update: %v", xerr)
}
// Another organization cannot, even naming the right id.
if _, xerr := tags.Update(ctx, foreignOrg, created.ID, &models.GroupUpdate{Title: &title}); xerr == nil {
t.Error("a tag was editable from another workspace")
}
if xerr := tags.Delete(ctx, foreignOrg, created.ID); xerr == nil {
t.Error("a tag was deletable from another workspace")
}
}
// A mailbox is a workspace asset and so are its tags, so the member who did not
// connect the mailbox must still be able to tag it and read the tag back.
func TestLiveMailboxTagsAreWritableByAnyMember(t *testing.T) {
handle, pool := liveContactDB(t)
f := newSharedOrgFixture(t, pool)
ctx := context.Background()
mailbox := uuid.New()
if _, err := pool.Exec(ctx, `
INSERT INTO email_accounts (id, user_id, organization_id, email, name, signature_plain, signature_html, provider, warmup_tag)
VALUES ($1, $2, $3, $4, 'Owner mailbox', '', '', 'smtp_imap', '')`,
mailbox, f.owner, f.org, "i436-"+mailbox.String()[:8]+"@test.local"); err != nil {
t.Fatalf("mailbox: %v", err)
}
foreignOrg := uuid.New()
if _, err := pool.Exec(ctx, `INSERT INTO organizations (id, name, slug, owner_user_id) VALUES ($1, 'Issue 436 other', $2, $3)`,
foreignOrg, "i436-"+foreignOrg.String()[:8], f.owner); err != nil {
t.Fatalf("foreign org: %v", err)
}
t.Cleanup(func() {
c := context.Background()
_, _ = pool.Exec(c, `DELETE FROM email_tags WHERE email_id = $1`, mailbox)
_, _ = pool.Exec(c, `DELETE FROM email_accounts WHERE id = $1`, mailbox)
_, _ = pool.Exec(c, `DELETE FROM tags WHERE organization_id = ANY($1)`, []uuid.UUID{f.org, foreignOrg})
_, _ = pool.Exec(c, `DELETE FROM organizations WHERE id = $1`, foreignOrg)
})
tags := NewGroupRepostory(handle, models.Tags)
tag, xerr := tags.Create(ctx, f.org, f.owner, &models.GroupCreate{Title: "Client A", Color: "#38bdf8"})
if xerr != nil {
t.Fatalf("create tag: %v", xerr)
}
foreignTag, xerr := tags.Create(ctx, foreignOrg, f.owner, &models.GroupCreate{Title: "Client B", Color: "#f59e0b"})
if xerr != nil {
t.Fatalf("create foreign tag: %v", xerr)
}
emails := NewEmailRepostory(handle, nil)
// The teammate bulk-tags the owner's mailbox.
owned, xerr := emails.BulkUpdateTags(ctx, f.org.String(), []uuid.UUID{mailbox}, []uuid.UUID{tag.ID}, nil)
if xerr != nil {
t.Fatalf("bulk tag: %v", xerr)
}
if owned != 1 {
t.Fatalf("bulk tag reported %d mailboxes, want 1", owned)
}
got, xerr := emails.Get(ctx, f.org.String(), mailbox.String())
if xerr != nil {
t.Fatalf("get mailbox: %v", xerr)
}
if len(got.Tags) != 1 || got.Tags[0] != tag.ID.String() {
t.Fatalf("mailbox tags = %v, want [%s]", got.Tags, tag.ID)
}
// A real tag belonging to ANOTHER workspace is dropped rather than linked:
// the row exists, so the foreign key alone would have let it through.
if _, xerr := emails.BulkUpdateTags(ctx, f.org.String(), []uuid.UUID{mailbox}, []uuid.UUID{foreignTag.ID}, nil); xerr != nil {
t.Fatalf("bulk tag foreign: %v", xerr)
}
got, xerr = emails.Get(ctx, f.org.String(), mailbox.String())
if xerr != nil {
t.Fatalf("get mailbox again: %v", xerr)
}
if len(got.Tags) != 1 || got.Tags[0] != tag.ID.String() {
t.Fatalf("mailbox tags = %v after a foreign id, want only [%s]", got.Tags, tag.ID)
}
// Same rule on the single-mailbox PATCH, which replaces the whole set.
updated, xerr := emails.Update(ctx, f.org.String(), mailbox.String(), &models.UpdateEmail{
Tags: []string{tag.ID.String(), foreignTag.ID.String()},
})
if xerr != nil {
t.Fatalf("update mailbox: %v", xerr)
}
if len(updated.Tags) != 1 || updated.Tags[0] != tag.ID.String() {
t.Fatalf("update returned tags %v, want only [%s]", updated.Tags, tag.ID)
}
}
// Conversation labels ride the same registry and the same rule: the inbox is
// read per organization, so a thread one member files must read as filed to the
// next, and the label rail must count it.
func TestLiveConversationLabelsAreOrganizationWide(t *testing.T) {
handle, pool := liveContactDB(t)
f := newSharedOrgFixture(t, pool)
ctx := context.Background()
mailbox, message := uuid.New(), uuid.New()
if _, err := pool.Exec(ctx, `
INSERT INTO email_accounts (id, user_id, organization_id, email, name, signature_plain, signature_html, provider, warmup_tag)
VALUES ($1, $2, $3, $4, 'Owner mailbox', '', '', 'smtp_imap', '')`,
mailbox, f.owner, f.org, "i436l-"+mailbox.String()[:8]+"@test.local"); err != nil {
t.Fatalf("mailbox: %v", err)
}
if _, err := pool.Exec(ctx, `
INSERT INTO unibox_emails (id, user_id, email_id, thread_id, subject, folder, seen)
VALUES ($1, $2, $3, 'i436-thread', 'Re: pricing', 'inbox', false)`,
message, f.owner, mailbox); err != nil {
t.Fatalf("message: %v", err)
}
t.Cleanup(func() {
c := context.Background()
_, _ = pool.Exec(c, `DELETE FROM unibox_thread_labels WHERE organization_id = $1`, f.org)
_, _ = pool.Exec(c, `DELETE FROM unibox_emails WHERE email_id = $1`, mailbox)
_, _ = pool.Exec(c, `DELETE FROM email_accounts WHERE id = $1`, mailbox)
_, _ = pool.Exec(c, `DELETE FROM categories WHERE organization_id = $1`, f.org)
})
cats := NewGroupRepostory(handle, models.Categories)
cat, xerr := cats.Create(ctx, f.org, f.owner, &models.GroupCreate{Title: "Interested", Color: "#10b981"})
if xerr != nil {
t.Fatalf("create category: %v", xerr)
}
// A real category in another workspace. Both label writes are handed it
// alongside a legitimate id: the foreign key alone would let it through, so
// only the organization check keeps it out.
foreignOrg := uuid.New()
if _, err := pool.Exec(ctx, `INSERT INTO organizations (id, name, slug, owner_user_id) VALUES ($1, 'Issue 436 label other', $2, $3)`,
foreignOrg, "i436l-"+foreignOrg.String()[:8], f.owner); err != nil {
t.Fatalf("foreign org: %v", err)
}
t.Cleanup(func() {
c := context.Background()
_, _ = pool.Exec(c, `DELETE FROM categories WHERE organization_id = $1`, foreignOrg)
_, _ = pool.Exec(c, `DELETE FROM organizations WHERE id = $1`, foreignOrg)
})
foreignCat, xerr := cats.Create(ctx, foreignOrg, f.owner, &models.GroupCreate{Title: "Theirs", Color: "#ef4444"})
if xerr != nil {
t.Fatalf("create foreign category: %v", xerr)
}
unibox := NewUniboxRepository(handle)
// The OWNER labels the thread.
set, err := unibox.SetThreadLabels(ctx, f.org, f.owner, "i436-thread", []uuid.UUID{cat.ID, foreignCat.ID})
if err != nil {
t.Fatalf("set labels: %v", err)
}
if len(set) != 1 || set[0].ID != cat.ID {
t.Fatalf("set returned %+v, want only this workspace's [%s]", set, cat.ID)
}
// The TEAMMATE reads it back, filters on it, and sees it counted.
labels, err := unibox.ListThreadLabels(ctx, f.org, "i436-thread")
if err != nil {
t.Fatalf("list labels: %v", err)
}
if len(labels) != 1 || labels[0].ID != cat.ID {
t.Fatalf("teammate sees labels %+v, want [%s]", labels, cat.ID)
}
res, err := unibox.Search(ctx, f.org, &models.MailSearchParams{CategoryIDs: []uuid.UUID{cat.ID}, PageSize: 50})
if err != nil {
t.Fatalf("search by label: %v", err)
}
if len(res.Data) != 1 || res.Data[0].ID != message {
t.Fatalf("label filter returned %d rows, want the labelled thread", len(res.Data))
}
// The automation "label email" action adds without clobbering, and drops a
// category another workspace owns rather than borrowing it. The foreign id
// is a real row, so the foreign key alone would have let it through.
second, xerr := cats.Create(ctx, f.org, f.mate, &models.GroupCreate{Title: "Follow up", Color: "#f59e0b"})
if xerr != nil {
t.Fatalf("create second category: %v", xerr)
}
if err := unibox.AddThreadLabels(ctx, f.org, "i436-thread", []uuid.UUID{second.ID, foreignCat.ID}); err != nil {
t.Fatalf("add labels: %v", err)
}
labels, err = unibox.ListThreadLabels(ctx, f.org, "i436-thread")
if err != nil {
t.Fatalf("list labels after add: %v", err)
}
if len(labels) != 2 {
t.Fatalf("additive labelling left %+v, want both categories and nothing else", labels)
}
overview, err := unibox.Overview(ctx, f.org)
if err != nil {
t.Fatalf("overview: %v", err)
}
var found bool
for _, c := range overview.Categories {
if c.ID == cat.ID {
found = true
if c.Total != 1 {
t.Errorf("label rail total = %d, want 1", c.Total)
}
}
}
if !found {
t.Fatalf("the label rail does not list %s: %+v", cat.ID, overview.Categories)
}
}
+37 -16
View File
@@ -560,10 +560,15 @@ func (r *campaignRepository) Create(ctx context.Context, userID string, orgID *u
campaign.OrganizationID = orgID
}
// Sender pool — email tag links.
// Sender pool — email tag links. The tag registry is workspace-scoped, so
// linking one without a workspace would quietly drop every id rather than
// say why.
campaign.EmailTags = make([]string, 0)
if (len(data.EmailTagIDs) > 0 || len(data.FolderIDs) > 0) && orgID == nil {
return nil, errx.ErrNoOrganization
}
if len(data.EmailTagIDs) > 0 {
tags, xerr := SyncCampaignEmailTags(ctx, tx, campaign.ID.String(), data.EmailTagIDs)
tags, xerr := SyncCampaignEmailTags(ctx, tx, orgID, campaign.ID.String(), data.EmailTagIDs)
if xerr != nil {
return nil, xerr
}
@@ -573,7 +578,7 @@ func (r *campaignRepository) Create(ctx context.Context, userID string, orgID *u
// Folder links.
campaign.Folders = make([]string, 0)
if len(data.FolderIDs) > 0 {
folders, xerr := SyncCampaignFolders(ctx, tx, campaign.ID.String(), data.FolderIDs)
folders, xerr := SyncCampaignFolders(ctx, tx, orgID, campaign.ID.String(), data.FolderIDs)
if xerr != nil {
return nil, xerr
}
@@ -1343,20 +1348,36 @@ func (r *campaignRepository) Update(ctx context.Context, userID, campaignID stri
}
campaign.EmailTags = make([]string, 0)
if data.EmailTags != nil {
var err *errx.Error
campaign.EmailTags, err = SyncCampaignEmailTags(ctx, tx, campaignID, data.EmailTags)
if err != nil {
return nil, err
}
}
campaign.Folders = make([]string, 0)
if data.Folders != nil {
var err *errx.Error
campaign.Folders, err = SyncCampaignFolders(ctx, tx, campaignID, data.Folders)
if err != nil {
return nil, err
if data.EmailTags != nil || data.Folders != nil {
// The campaign's own workspace bounds which tags and folders may be
// linked. CAMPAIGN_SELECT does not carry it, so read it here rather
// than trusting ids the client sent.
var orgID *uuid.UUID
if err := tx.QueryRow(ctx, `SELECT organization_id FROM campaigns WHERE id = $1`, campaignID).Scan(&orgID); err != nil {
db.CaptureError(err, "campaign org lookup", []any{campaignID}, "queryrow")
return nil, errx.InternalError()
}
// Without one the scope check matches nothing, which would read as
// "clear every tag and folder" instead of as the refusal it is.
if orgID == nil {
return nil, errx.ErrNoOrganization
}
if data.EmailTags != nil {
var err *errx.Error
campaign.EmailTags, err = SyncCampaignEmailTags(ctx, tx, orgID, campaignID, data.EmailTags)
if err != nil {
return nil, err
}
}
if data.Folders != nil {
var err *errx.Error
campaign.Folders, err = SyncCampaignFolders(ctx, tx, orgID, campaignID, data.Folders)
if err != nil {
return nil, err
}
}
}
+53 -53
View File
@@ -62,9 +62,9 @@ type ContactRepository interface {
SetContactESP(ctx context.Context, contactID uuid.UUID, provider string) error
GetByEmailsAndUser(ctx context.Context, userID uuid.UUID, emails []string) (map[string]models.Contact, *errx.Error)
// ResolveCategoryNames maps category titles (as typed in an imported file)
// to the caller's category IDs, creating the ones that don't exist yet.
// to the workspace's category IDs, creating the ones that don't exist yet.
// Keys of the returned map are the lowercased titles.
ResolveCategoryNames(ctx context.Context, userID uuid.UUID, names []string) (map[string]uuid.UUID, *errx.Error)
ResolveCategoryNames(ctx context.Context, orgID, userID uuid.UUID, names []string) (map[string]uuid.UUID, *errx.Error)
Search(ctx context.Context, userID string, category *string, cursor *paging.SortCursor, filters models.SearchContacts, limit int32) (*models.ContactsResult, *errx.Error)
// SearchIDs returns the ids of every contact matching the same request
// Search runs, capped at max+1 rows so the caller can tell "exactly max"
@@ -466,7 +466,7 @@ func (r *contactRepository) Add(ctx context.Context, userID string, orgID uuid.U
ncontacts[i].Campaigns = linked
}
// Link categories. Scoped to the user's own categories so a
// Link categories. Scoped to the workspace's own categories so a
// malicious or stale ID can't attach foreign data.
for i, cats := range categoryIDs {
if len(cats) == 0 {
@@ -476,10 +476,10 @@ func (r *contactRepository) Add(ctx context.Context, userID string, orgID uuid.U
INSERT INTO contact_categories (contact_id, category_id)
SELECT $1, cat.id
FROM categories cat
WHERE cat.id = ANY($2) AND cat.user_id = $3
WHERE cat.id = ANY($2) AND cat.organization_id = $3
ON CONFLICT (contact_id, category_id) DO NOTHING
RETURNING category_id
`, ncontacts[i].ID, cats, userID)
`, ncontacts[i].ID, cats, orgID)
if err != nil {
db.CaptureError(err, "", nil, "contact_categories insert")
return nil, errx.InternalError()
@@ -495,9 +495,9 @@ func (r *contactRepository) Add(ctx context.Context, userID string, orgID uuid.U
SELECT cat.id, cat.title, cat.color
FROM categories cat
JOIN contact_categories cc ON cc.category_id = cat.id
WHERE cc.contact_id = $1 AND cat.user_id = $2
WHERE cc.contact_id = $1 AND cat.organization_id = $2
ORDER BY cat.position ASC, cat.title ASC
`, ncontacts[i].ID, userID)
`, ncontacts[i].ID, orgID)
if err != nil {
db.CaptureError(err, "", nil, "contact_categories select")
return nil, errx.InternalError()
@@ -1447,11 +1447,13 @@ func (r *contactRepository) Search(
// Main query.
//
// Both the `campaigns` and `categories` agg subqueries need the
// user_id so they can't leak rows from other users that happen to
// share a contact id (theoretically impossible thanks to the outer
// WHERE, but cheap defence-in-depth). They reuse the same $%d
// placeholder so we only append userID once.
// Both the `campaigns` and `categories` agg subqueries scope on the
// organization so they can't leak rows from another workspace that happens
// to share a contact id (theoretically impossible thanks to the outer
// WHERE, but cheap defence-in-depth). They reuse the same $%d placeholder
// so the org id is appended once. The categories one compared that id
// against categories.user_id until #436, which matched nothing, so every
// row in the contact list came back with no categories at all.
query := fmt.Sprintf(`
SELECT
c.id, c.first_name, c.last_name, c.email, c.company, c.phone,
@@ -1473,7 +1475,7 @@ func (r *contactRepository) Search(
FROM contact_categories cc
JOIN categories cat ON cc.category_id = cat.id
WHERE cc.contact_id = c.id
AND cat.user_id = $%d
AND cat.organization_id = $%d
), '[]'::json
) AS categories,
%s AS lead_progress,
@@ -2277,14 +2279,14 @@ func (r *contactRepository) Update(ctx context.Context, userID, contactID string
if perr != nil {
return nil, perr
}
// Drop everything not in the (user-owned) wanted set, then insert the
// rest; RETURNING on both sides is what feeds the timeline.
// Drop everything not in the (workspace-owned) wanted set, then insert
// the rest; RETURNING on both sides is what feeds the timeline.
drows, err := tx.Query(ctx, `
DELETE FROM contact_categories
WHERE contact_id = $1
AND category_id NOT IN (SELECT id FROM categories WHERE id = ANY($2) AND user_id = $3)
AND category_id NOT IN (SELECT id FROM categories WHERE id = ANY($2) AND organization_id = $3)
RETURNING category_id
`, contactID, ids, userID)
`, contactID, ids, orgID)
if err != nil {
db.CaptureError(err, "", nil, "categories wipe")
return nil, errx.InternalError()
@@ -2300,10 +2302,10 @@ func (r *contactRepository) Update(ctx context.Context, userID, contactID string
INSERT INTO contact_categories (contact_id, category_id)
SELECT $1, cat.id
FROM categories cat
WHERE cat.id = ANY($2) AND cat.user_id = $3
WHERE cat.id = ANY($2) AND cat.organization_id = $3
ON CONFLICT (contact_id, category_id) DO NOTHING
RETURNING category_id
`, contactID, ids, userID)
`, contactID, ids, orgID)
if err != nil {
db.CaptureError(err, "", nil, "categories insert")
return nil, errx.InternalError()
@@ -2326,10 +2328,10 @@ func (r *contactRepository) Update(ctx context.Context, userID, contactID string
INSERT INTO contact_categories (contact_id, category_id)
SELECT $1, cat.id
FROM categories cat
WHERE cat.id = ANY($2) AND cat.user_id = $3
WHERE cat.id = ANY($2) AND cat.organization_id = $3
ON CONFLICT (contact_id, category_id) DO NOTHING
RETURNING category_id
`, contactID, ids, userID)
`, contactID, ids, orgID)
if err != nil {
db.CaptureError(err, "", nil, "categories add")
return nil, errx.InternalError()
@@ -2376,11 +2378,11 @@ func (r *contactRepository) Update(ctx context.Context, userID, contactID string
SELECT json_agg(json_build_object('id', cat.id, 'title', cat.title, 'color', cat.color) ORDER BY cat.position ASC, cat.title ASC)
FROM contact_categories cc
JOIN categories cat ON cc.category_id = cat.id
WHERE cc.contact_id = $1 AND cat.user_id = $2
WHERE cc.contact_id = $1 AND cat.organization_id = $2
),
'[]'::json
)
`, contactID, userID).Scan(&catJSON); err != nil {
`, contactID, orgID).Scan(&catJSON); err != nil {
db.CaptureError(err, "", nil, "categories reload")
return nil, errx.InternalError()
}
@@ -2531,10 +2533,10 @@ func (r *contactRepository) BulkUpdate(ctx context.Context, userID string, orgID
WHERE c.organization_id = $1
AND c.id = ANY($2)
AND cat.id = ANY($3::uuid[])
AND cat.user_id = $4
AND cat.organization_id = $1
ON CONFLICT DO NOTHING
RETURNING contact_id, category_id`,
models.ActivityCategoryAdded, logCategoryLinks, orgID, data.Contacts, data.AddCategories, userID); xerr != nil {
models.ActivityCategoryAdded, logCategoryLinks, orgID, data.Contacts, data.AddCategories); xerr != nil {
return nil, xerr
}
}
@@ -2609,7 +2611,7 @@ func (r *contactRepository) BulkUpdate(ctx context.Context, userID string, orgID
SELECT json_agg(json_build_object('id', cam.id, 'name', cam.name))
FROM campaign_leads cl
JOIN campaigns cam ON cl.campaign_id = cam.id
WHERE cl.contact_id =c.id AND cam.organization_id = $3
WHERE cl.contact_id =c.id AND cam.organization_id = $2
),
'[]'::json
) AS campaigns,
@@ -2618,17 +2620,16 @@ func (r *contactRepository) BulkUpdate(ctx context.Context, userID string, orgID
SELECT json_agg(json_build_object('id', cat.id, 'title', cat.title, 'color', cat.color) ORDER BY cat.position ASC, cat.title ASC)
FROM contact_categories cc
JOIN categories cat ON cc.category_id = cat.id
WHERE cc.contact_id = c.id AND cat.user_id = $2
WHERE cc.contact_id = c.id AND cat.organization_id = $2
),
'[]'::json
) AS categories
FROM contacts c
WHERE c.organization_id = $3 AND c.id = ANY($1)
WHERE c.organization_id = $2 AND c.id = ANY($1)
`
params := []any{
data.Contacts,
userID,
orgID,
}
rows, err := tx.Query(
@@ -2753,7 +2754,7 @@ func (r *contactRepository) Delete(ctx context.Context, userID string, orgID uui
// otherwise mint a category per row.
const MaxImportCategoryNames = 100
func (r *contactRepository) ResolveCategoryNames(ctx context.Context, userID uuid.UUID, names []string) (map[string]uuid.UUID, *errx.Error) {
func (r *contactRepository) ResolveCategoryNames(ctx context.Context, orgID, userID uuid.UUID, names []string) (map[string]uuid.UUID, *errx.Error) {
out := make(map[string]uuid.UUID, len(names))
wanted := make([]string, 0, len(names))
seen := make(map[string]string, len(names)) // lowered -> original casing
@@ -2784,8 +2785,8 @@ func (r *contactRepository) ResolveCategoryNames(ctx context.Context, userID uui
rows, err := r.DB.Query(ctx, `
SELECT id, LOWER(title) FROM categories
WHERE user_id = $1 AND LOWER(title) = ANY($2::text[])
`, userID, wanted)
WHERE organization_id = $1 AND LOWER(title) = ANY($2::text[])
`, orgID, wanted)
if err != nil {
db.CaptureError(err, "", nil, "ResolveCategoryNames query")
return nil, errx.InternalError()
@@ -2816,21 +2817,21 @@ func (r *contactRepository) ResolveCategoryNames(ctx context.Context, userID uui
return out, nil
}
// Positions continue after whatever the user already has, so the new
// categories land at the end of their list instead of colliding.
// Positions continue after whatever the workspace already has, so the new
// categories land at the end of the list instead of colliding.
var nextPos int32
if err := r.DB.QueryRow(ctx,
`SELECT COALESCE(MAX(position), -1) + 1 FROM categories WHERE user_id = $1`,
userID).Scan(&nextPos); err != nil {
`SELECT COALESCE(MAX(position), -1) + 1 FROM categories WHERE organization_id = $1`,
orgID).Scan(&nextPos); err != nil {
db.CaptureError(err, "", nil, "ResolveCategoryNames position")
return nil, errx.InternalError()
}
for _, lower := range missing {
id := uuid.New()
if _, err := r.DB.Exec(ctx, `
INSERT INTO categories (id, user_id, title, color, position)
VALUES ($1, $2, $3, $4, $5)
`, id, userID, seen[lower], defaultGroupColor(nextPos), nextPos); err != nil {
INSERT INTO categories (id, organization_id, user_id, title, color, position)
VALUES ($1, $2, $3, $4, $5, $6)
`, id, orgID, userID, seen[lower], defaultGroupColor(nextPos), nextPos); err != nil {
db.CaptureError(err, "", nil, "ResolveCategoryNames insert")
return nil, errx.InternalError()
}
@@ -2985,14 +2986,16 @@ func (r *contactRepository) GetDetail(ctx context.Context, userID uuid.UUID, org
var campaignsJSON, categoriesJSON []byte
// Scope the contact row to the org so teammates can open each other's
// contacts. Without an org (e.g. an API key with no selected org) fall
// back to the legacy user scope. Campaigns are org assets, so the badge
// subselect follows the same scope (issue #187); the category subselect
// stays user-scoped because categories has no organization_id column.
rowScope := "c.user_id = $1"
campScope := "cam.user_id = $1"
// back to the legacy user scope. Campaigns and categories are org assets,
// so their badge subselects follow the same scope (issues #187, #436).
// $1 is the contact and $2 is whichever of the two scopes applies, so the
// statement never carries a parameter nothing references (Postgres cannot
// infer a type for one of those and rejects the whole query).
rowScope, campScope, catScope := "c.user_id = $2", "cam.user_id = $2", "cat.user_id = $2"
scopeArg := any(userID)
if orgID != nil {
rowScope = "c.organization_id = $3"
campScope = "cam.organization_id = $3"
rowScope, campScope, catScope = "c.organization_id = $2", "cam.organization_id = $2", "cat.organization_id = $2"
scopeArg = *orgID
}
mainQuery := fmt.Sprintf(`
SELECT
@@ -3012,16 +3015,13 @@ func (r *contactRepository) GetDetail(ctx context.Context, userID uuid.UUID, org
SELECT json_agg(json_build_object('id', cat.id, 'title', cat.title, 'color', cat.color) ORDER BY cat.position ASC, cat.title ASC)
FROM contact_categories cc
JOIN categories cat ON cat.id = cc.category_id
WHERE cc.contact_id = c.id AND cat.user_id = $1
WHERE cc.contact_id = c.id AND %s
), '[]'::json
) AS categories
FROM contacts c
WHERE c.id = $2 AND %s
`, campScope, rowScope)
mainArgs := []any{userID, contactID}
if orgID != nil {
mainArgs = append(mainArgs, *orgID)
}
WHERE c.id = $1 AND %s
`, campScope, catScope, rowScope)
mainArgs := []any{contactID, scopeArg}
err := r.DB.QueryRow(ctx, mainQuery, mainArgs...).Scan(
&detail.ID, &detail.FirstName, &detail.LastName, &detail.Email,
&detail.Company, &detail.Phone, &detail.CustomFields, &detail.Subscribed,
+32 -21
View File
@@ -89,12 +89,12 @@ type EmailRepository interface {
GetOAuthCredentials(ctx context.Context, emailAccountID uuid.UUID) (*OAuthCredentials, *errx.Error)
GetWorkerID(ctx context.Context, emailAccountID uuid.UUID) (*uuid.UUID, *errx.Error)
SetWorkerID(ctx context.Context, emailAccountID, workerID uuid.UUID) *errx.Error
Update(ctx context.Context, userID, emailAccountID string, udata *models.UpdateEmail) (*models.Email, *errx.Error)
// BulkUpdateTags adds/removes tag links across many of the user's
Update(ctx context.Context, orgID, emailAccountID string, udata *models.UpdateEmail) (*models.Email, *errx.Error)
// BulkUpdateTags adds/removes tag links across many of the workspace's
// mailboxes in one transaction; ownership of both mailboxes and tags is
// enforced in SQL, unknown ids are skipped. Returns how many of the
// requested mailboxes the caller owns.
BulkUpdateTags(ctx context.Context, userID string, emailIDs, addTags, removeTags []uuid.UUID) (int, *errx.Error)
// requested mailboxes the workspace owns.
BulkUpdateTags(ctx context.Context, orgID string, emailIDs, addTags, removeTags []uuid.UUID) (int, *errx.Error)
// SetWarmupLifecycle starts, pauses, resumes, or disables warmup for a
// mailbox. "start"/"resume" preserve ramp progress (a paused mailbox
// resumes where it left off); "pause" keeps progress; "disable" turns
@@ -807,9 +807,13 @@ func (r *emailRepository) Get(ctx context.Context, orgID, emailAccountID string)
return &i, nil
}
func (r *emailRepository) Update(ctx context.Context, userID, emailAccountID string, udata *models.UpdateEmail) (*models.Email, *errx.Error) {
// Update writes a mailbox's settings. Scoped by organization, like Get, Search
// and UpdateTrackingDomain: a mailbox is a workspace asset and the route admits
// any member holding manage_emails, so keying on the user who happened to
// connect it turned that permission into a 404 for everyone else.
func (r *emailRepository) Update(ctx context.Context, orgID, emailAccountID string, udata *models.UpdateEmail) (*models.Email, *errx.Error) {
setClauses := []string{}
args := []any{userID, emailAccountID}
args := []any{orgID, emailAccountID}
argPos := 3
if udata.Name != nil {
@@ -999,7 +1003,11 @@ func (r *emailRepository) Update(ctx context.Context, userID, emailAccountID str
argPos++
}
if argPos == 3 {
// Tags are not a column on the row, so a patch that only moves them still
// leaves setClauses empty. Refusing it made the mailbox drawer's tag
// picker unable to save on its own, which is how the dashboard sends it:
// only the fields that actually changed.
if argPos == 3 && udata.Tags == nil {
return nil, errx.ErrNotEnough
}
@@ -1015,7 +1023,7 @@ func (r *emailRepository) Update(ctx context.Context, userID, emailAccountID str
query := fmt.Sprintf(`
UPDATE email_accounts
SET %s
WHERE user_id = $1 AND id = $2
WHERE organization_id = $1 AND id = $2
RETURNING id, organization_id, email, name, signature_plain, signature_html, signature_sync, signature_code, provider, status,
COALESCE(last_synced_at, created_at) AS last_synced_at, last_id, campaign_limit, min_wait_time, reply_to, tracking_domain, tracking_domain_verified, tracking_domain_verified_at,
auth_state, auth_spf, auth_dkim, auth_dmarc, auth_dmarc_policy, auth_reason, auth_checked_at, auth_failing_since,
@@ -1045,7 +1053,7 @@ func (r *emailRepository) Update(ctx context.Context, userID, emailAccountID str
i.Tags = make([]string, 0)
if udata.Tags != nil {
var err *errx.Error
i.Tags, err = SyncEmailTags(ctx, tx, emailAccountID, udata.Tags)
i.Tags, err = SyncEmailTags(ctx, tx, orgID, emailAccountID, udata.Tags)
if err != nil {
return nil, err
}
@@ -1059,7 +1067,10 @@ func (r *emailRepository) Update(ctx context.Context, userID, emailAccountID str
return &i, nil
}
func (r *emailRepository) BulkUpdateTags(ctx context.Context, userID string, emailIDs, addTags, removeTags []uuid.UUID) (int, *errx.Error) {
// BulkUpdateTags rewrites tag links across many mailboxes at once. Both sides
// are scoped by organization: the mailboxes because they belong to the
// workspace, and the tag definitions because the registry does too.
func (r *emailRepository) BulkUpdateTags(ctx context.Context, orgID string, emailIDs, addTags, removeTags []uuid.UUID) (int, *errx.Error) {
tx, err := r.DB.Begin(ctx)
if err != nil {
db.CaptureError(err, "", nil, "begin")
@@ -1068,25 +1079,25 @@ func (r *emailRepository) BulkUpdateTags(ctx context.Context, userID string, ema
defer tx.Rollback(ctx)
var owned int
countQuery := `SELECT count(*) FROM email_accounts WHERE user_id = $1 AND id = ANY($2)`
if err := tx.QueryRow(ctx, countQuery, userID, emailIDs).Scan(&owned); err != nil {
db.CaptureError(err, countQuery, []any{userID}, "queryrow")
countQuery := `SELECT count(*) FROM email_accounts WHERE organization_id = $1 AND id = ANY($2)`
if err := tx.QueryRow(ctx, countQuery, orgID, emailIDs).Scan(&owned); err != nil {
db.CaptureError(err, countQuery, []any{orgID}, "queryrow")
return 0, errx.InternalError()
}
if len(addTags) > 0 {
// Cross join owned mailboxes with the caller's own tag definitions;
// Cross join the workspace's mailboxes with its tag definitions;
// the composite PK makes re-adding an existing link a no-op.
insertQuery := `
INSERT INTO email_tags (email_id, tag_id)
SELECT a.id, t.id
FROM email_accounts a
CROSS JOIN tags t
WHERE a.user_id = $1 AND a.id = ANY($2)
AND t.user_id = $1 AND t.id = ANY($3)
WHERE a.organization_id = $1 AND a.id = ANY($2)
AND t.organization_id = $1 AND t.id = ANY($3)
ON CONFLICT (email_id, tag_id) DO NOTHING`
if _, err := tx.Exec(ctx, insertQuery, userID, emailIDs, addTags); err != nil {
db.CaptureError(err, insertQuery, []any{userID}, "exec")
if _, err := tx.Exec(ctx, insertQuery, orgID, emailIDs, addTags); err != nil {
db.CaptureError(err, insertQuery, []any{orgID}, "exec")
return 0, errx.InternalError()
}
}
@@ -1095,9 +1106,9 @@ func (r *emailRepository) BulkUpdateTags(ctx context.Context, userID string, ema
deleteQuery := `
DELETE FROM email_tags
WHERE tag_id = ANY($3)
AND email_id IN (SELECT id FROM email_accounts WHERE user_id = $1 AND id = ANY($2))`
if _, err := tx.Exec(ctx, deleteQuery, userID, emailIDs, removeTags); err != nil {
db.CaptureError(err, deleteQuery, []any{userID}, "exec")
AND email_id IN (SELECT id FROM email_accounts WHERE organization_id = $1 AND id = ANY($2))`
if _, err := tx.Exec(ctx, deleteQuery, orgID, emailIDs, removeTags); err != nil {
db.CaptureError(err, deleteQuery, []any{orgID}, "exec")
return 0, errx.InternalError()
}
}
+8 -7
View File
@@ -178,7 +178,7 @@ func (r *formRepository) Create(ctx context.Context, orgID uuid.UUID, createdBy
db.CaptureError(err, "forms create", nil, "insert")
return nil, errx.InternalError()
}
if xerr := setFormCategories(ctx, tx, id, f.CategoryIDs); xerr != nil {
if xerr := setFormCategories(ctx, tx, orgID, id, f.CategoryIDs); xerr != nil {
return nil, xerr
}
if err := tx.Commit(ctx); err != nil {
@@ -220,7 +220,7 @@ func (r *formRepository) Update(ctx context.Context, orgID uuid.UUID, f *models.
db.CaptureError(err, "forms update", nil, "categories clear")
return nil, errx.InternalError()
}
if xerr := setFormCategories(ctx, tx, f.ID, f.CategoryIDs); xerr != nil {
if xerr := setFormCategories(ctx, tx, orgID, f.ID, f.CategoryIDs); xerr != nil {
return nil, xerr
}
if err := tx.Commit(ctx); err != nil {
@@ -230,9 +230,10 @@ func (r *formRepository) Update(ctx context.Context, orgID uuid.UUID, f *models.
return r.Get(ctx, orgID, f.ID)
}
// setFormCategories links the picked categories, quietly dropping ids that
// no longer exist (a stale picker must not fail the save).
func setFormCategories(ctx context.Context, tx pgx.Tx, formID uuid.UUID, categoryIDs []uuid.UUID) *errx.Error {
// setFormCategories links the picked categories, quietly dropping ids that no
// longer exist or belong to another workspace (a stale picker must not fail the
// save, and a borrowed id must not file leads under a foreign category).
func setFormCategories(ctx context.Context, tx pgx.Tx, orgID, formID uuid.UUID, categoryIDs []uuid.UUID) *errx.Error {
if len(categoryIDs) == 0 {
return nil
}
@@ -241,9 +242,9 @@ func setFormCategories(ctx context.Context, tx pgx.Tx, formID uuid.UUID, categor
}
_, err := tx.Exec(ctx, `
INSERT INTO form_categories (form_id, category_id)
SELECT $1, c.id FROM categories c WHERE c.id = ANY($2)
SELECT $1, c.id FROM categories c WHERE c.id = ANY($2) AND c.organization_id = $3
ON CONFLICT (form_id, category_id) DO NOTHING
`, formID, categoryIDs)
`, formID, categoryIDs, orgID)
if err != nil {
db.CaptureError(err, "forms categories", nil, "insert")
return errx.InternalError()
+59 -38
View File
@@ -16,12 +16,16 @@ import (
"github.com/warmbly/warmbly/internal/pkg/encrypt"
)
// GroupRepository is the CRUD for one label registry (folders, tags or
// categories). Every method is scoped by organization: a label is a workspace
// asset, so a teammate must see and edit what anyone else created. userID is
// carried on Create for attribution only.
type GroupRepository interface {
Create(ctx context.Context, userID uuid.UUID, data *models.GroupCreate) (*models.Group, *errx.Error)
Delete(ctx context.Context, userID, id uuid.UUID) *errx.Error
Move(ctx context.Context, userID, id uuid.UUID, position int32) ([]models.Order, *errx.Error)
Update(ctx context.Context, userID, id uuid.UUID, data *models.GroupUpdate) (*models.Group, *errx.Error)
List(ctx context.Context, userID uuid.UUID) ([]models.Group, *errx.Error)
Create(ctx context.Context, orgID, userID uuid.UUID, data *models.GroupCreate) (*models.Group, *errx.Error)
Delete(ctx context.Context, orgID, id uuid.UUID) *errx.Error
Move(ctx context.Context, orgID, id uuid.UUID, position int32) ([]models.Order, *errx.Error)
Update(ctx context.Context, orgID, id uuid.UUID, data *models.GroupUpdate) (*models.Group, *errx.Error)
List(ctx context.Context, orgID uuid.UUID) ([]models.Group, *errx.Error)
}
type groupRepository struct {
@@ -54,7 +58,7 @@ func defaultGroupColor(position int32) string {
return groupDefaultPalette[int(position)%len(groupDefaultPalette)]
}
func (r *groupRepository) Create(ctx context.Context, userID uuid.UUID, data *models.GroupCreate) (*models.Group, *errx.Error) {
func (r *groupRepository) Create(ctx context.Context, orgID, userID uuid.UUID, data *models.GroupCreate) (*models.Group, *errx.Error) {
title := strings.TrimSpace(data.Title)
l := len(title)
if l < 1 || l > 50 {
@@ -79,11 +83,11 @@ func (r *groupRepository) Create(ctx context.Context, userID uuid.UUID, data *mo
var position int32
query := fmt.Sprintf(`
SELECT COUNT(*) FROM %s WHERE user_id = $1
SELECT COUNT(*) FROM %s WHERE organization_id = $1
`, r.Group)
var params = []any{
userID,
orgID,
}
err = tx.QueryRow(
@@ -111,13 +115,21 @@ func (r *groupRepository) Create(ctx context.Context, userID uuid.UUID, data *mo
// right call here. The previous tx.QueryRow + Scan would always
// fail with "sql: no rows in result set" once it got this far.
query = fmt.Sprintf(`
INSERT INTO %s (id, user_id, title, color, position, created_at, updated_at)
VALUES ($1, $2, $3, $4, $5, $6, $6)
INSERT INTO %s (id, organization_id, user_id, title, color, position, created_at, updated_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, $7)
`, r.Group)
// userID is attribution: a uuid.Nil creator (no human behind the call)
// is stored as NULL rather than a uuid that references no user row.
var creator any
if userID != uuid.Nil {
creator = userID
}
params = []any{
id,
userID,
orgID,
creator,
data.Title,
data.Color,
position,
@@ -151,7 +163,7 @@ func (r *groupRepository) Create(ctx context.Context, userID uuid.UUID, data *mo
}, nil
}
func (r *groupRepository) Delete(ctx context.Context, userID, id uuid.UUID) *errx.Error {
func (r *groupRepository) Delete(ctx context.Context, orgID, id uuid.UUID) *errx.Error {
tx, err := r.DB.Begin(ctx)
if err != nil {
db.CaptureError(err, "", nil, "begin")
@@ -163,12 +175,12 @@ func (r *groupRepository) Delete(ctx context.Context, userID, id uuid.UUID) *err
query := fmt.Sprintf(`
DELETE FROM %s
WHERE user_id = $1 AND id = $2
WHERE organization_id = $1 AND id = $2
RETURNING position
`, r.Group)
params := []any{
userID,
orgID,
id,
}
@@ -185,14 +197,16 @@ func (r *groupRepository) Delete(ctx context.Context, userID, id uuid.UUID) *err
return errx.InternalError()
}
query = `
UPDATE tags
// r.Group, not a literal: this used to say "tags", so deleting a folder
// or a category renumbered the tag registry and left a hole in its own.
query = fmt.Sprintf(`
UPDATE %s
SET position = position - 1
WHERE user_id = $1 AND position > $2
`
WHERE organization_id = $1 AND position > $2
`, r.Group)
params = []any{
userID, pos,
orgID, pos,
}
if _, err := tx.Exec(
@@ -212,7 +226,7 @@ func (r *groupRepository) Delete(ctx context.Context, userID, id uuid.UUID) *err
return nil
}
func (r *groupRepository) Move(ctx context.Context, userID, id uuid.UUID, newPos int32) ([]models.Order, *errx.Error) {
func (r *groupRepository) Move(ctx context.Context, orgID, id uuid.UUID, newPos int32) ([]models.Order, *errx.Error) {
tx, err := r.DB.Begin(ctx)
if err != nil {
db.CaptureError(err, "", nil, "begin")
@@ -222,12 +236,12 @@ func (r *groupRepository) Move(ctx context.Context, userID, id uuid.UUID, newPos
query := fmt.Sprintf(`
SELECT id, position FROM %s
WHERE user_id = $1
WHERE organization_id = $1
ORDER BY position FOR UPDATE
`, r.Group)
params := []any{
userID,
orgID,
}
rows, err := tx.Query(
@@ -282,7 +296,7 @@ func (r *groupRepository) Move(ctx context.Context, userID, id uuid.UUID, newPos
UPDATE %s e
SET position = n.pos
FROM new_values n
WHERE e.id = n.id AND e.user_id = $3
WHERE e.id = n.id AND e.organization_id = $3
`, r.Group)
ids := make([]uuid.UUID, len(newOrdered))
poss := make([]int32, len(newOrdered))
@@ -290,7 +304,7 @@ func (r *groupRepository) Move(ctx context.Context, userID, id uuid.UUID, newPos
ids[i] = c.id
poss[i] = int32(i)
}
if _, err := tx.Exec(ctx, updateQuery, ids, poss, userID); err != nil {
if _, err := tx.Exec(ctx, updateQuery, ids, poss, orgID); err != nil {
db.CaptureError(err, query, params, "exec")
return nil, errx.InternalError()
}
@@ -300,7 +314,9 @@ func (r *groupRepository) Move(ctx context.Context, userID, id uuid.UUID, newPos
return nil, errx.InternalError()
}
var resp []models.Order = make([]models.Order, len(newOrdered))
// make(..., 0, n): the length form prefixed the response with n zero
// Orders, so the client rebuilt its ordering from ids it had never seen.
resp := make([]models.Order, 0, len(newOrdered))
for i := range newOrdered {
resp = append(resp, models.Order{
ID: ids[i],
@@ -311,9 +327,9 @@ func (r *groupRepository) Move(ctx context.Context, userID, id uuid.UUID, newPos
return resp, nil
}
func (r *groupRepository) Update(ctx context.Context, userID, id uuid.UUID, data *models.GroupUpdate) (*models.Group, *errx.Error) {
func (r *groupRepository) Update(ctx context.Context, orgID, id uuid.UUID, data *models.GroupUpdate) (*models.Group, *errx.Error) {
setClauses := []string{}
args := []any{userID, id}
args := []any{orgID, id}
argPos := 3
if data.Title != nil {
trimmed := strings.TrimSpace(*data.Title)
@@ -342,7 +358,7 @@ func (r *groupRepository) Update(ctx context.Context, userID, id uuid.UUID, data
query := fmt.Sprintf(
`
UPDATE %s SET %s
WHERE user_id = $1 AND id = $2
WHERE organization_id = $1 AND id = $2
RETURNING id, title, color, position, created_at, updated_at
`,
r.Group,
@@ -358,6 +374,11 @@ func (r *groupRepository) Update(ctx context.Context, userID, id uuid.UUID, data
err := row.Scan(&t.ID, &t.Title, &t.Color, &t.Position, &t.CreatedAt, &t.UpdatedAt)
if err != nil {
// No row means the id is unknown to this workspace, which is a 404 and
// not a server fault; the scope is what decides it either way.
if errors.Is(err, pgx.ErrNoRows) {
return nil, errx.ErrNotFound
}
db.CaptureError(err, "", nil, "scan")
return nil, errx.InternalError()
}
@@ -365,28 +386,28 @@ func (r *groupRepository) Update(ctx context.Context, userID, id uuid.UUID, data
return &t, nil
}
// List returns every group of the repository's type belonging to userID,
// ordered by position. The /auth/me handler calls this once per group
// type (folders, tags, categories) to populate the User payload so the
// frontend doesn't have to issue three extra requests on every page
// load — and so created items still appear after a refresh.
func (r *groupRepository) List(ctx context.Context, userID uuid.UUID) ([]models.Group, *errx.Error) {
// List returns every group of the repository's type belonging to the
// organization, ordered by position. The /auth/me handler calls this once per
// group type (folders, tags, categories) to populate the User payload so the
// frontend doesn't have to issue three extra requests on every page load — and
// so created items still appear after a refresh.
func (r *groupRepository) List(ctx context.Context, orgID uuid.UUID) ([]models.Group, *errx.Error) {
query := fmt.Sprintf(
`SELECT id, title, color, position, created_at, updated_at
FROM %s
WHERE user_id = $1
WHERE organization_id = $1
ORDER BY position ASC, created_at ASC`,
r.Group,
)
rows, err := r.DB.Query(ctx, query, userID)
rows, err := r.DB.Query(ctx, query, orgID)
if err != nil {
db.CaptureError(err, query, []any{userID}, "query")
db.CaptureError(err, query, []any{orgID}, "query")
return nil, errx.InternalError()
}
defer rows.Close()
// Non-nil so JSON marshals as [] not null when the user has none.
// Non-nil so JSON marshals as [] not null when the workspace has none.
out := make([]models.Group, 0)
for rows.Next() {
var g models.Group
+54 -13
View File
@@ -18,6 +18,14 @@ type RelationSyncInput struct {
ColRelated string // e.g. "tag"
MainID any
NewValues []string
// ScopeTable is the label registry the related ids must come from
// ("tags" / "folders" / "categories"), and OrgID the workspace that must
// own them. The FK only says the row exists, so without this an id from
// another workspace links cleanly; ids that fail the check are dropped,
// not refused, matching how every other label write treats a stale id.
ScopeTable string
OrgID any
}
// SyncRelation diffs the desired related-id set against what's stored and
@@ -77,18 +85,45 @@ func SyncRelation(input RelationSyncInput) ([]string, *errx.Error) {
}
}
// inserted, not toInsert: an id the scope check rejected never became a
// row, so reporting it back would tell the client a link exists that does
// not.
var inserted []string
if len(toInsert) > 0 {
queryIns := fmt.Sprintf(`INSERT INTO %s (%s, %s)
SELECT $1::uuid, unnest($2::uuid[])`,
input.Table, input.ColMain, input.ColRelated)
params = []any{
input.MainID,
toInsert,
var queryIns string
var params []any
if input.ScopeTable != "" {
queryIns = fmt.Sprintf(`INSERT INTO %s (%s, %s)
SELECT $1::uuid, g.id FROM %s g
WHERE g.id = ANY($2::uuid[]) AND g.organization_id = $3::uuid
RETURNING %s::text`,
input.Table, input.ColMain, input.ColRelated, input.ScopeTable, input.ColRelated)
params = []any{input.MainID, toInsert, input.OrgID}
} else {
queryIns = fmt.Sprintf(`INSERT INTO %s (%s, %s)
SELECT $1::uuid, unnest($2::uuid[])
RETURNING %s::text`,
input.Table, input.ColMain, input.ColRelated, input.ColRelated)
params = []any{input.MainID, toInsert}
}
if _, err := input.Tx.Exec(input.Ctx, queryIns, input.MainID, toInsert); err != nil {
db.CaptureError(err, queryIns, params, "exec")
rows, err := input.Tx.Query(input.Ctx, queryIns, params...)
if err != nil {
db.CaptureError(err, queryIns, params, "query")
return nil, errx.InternalError()
}
for rows.Next() {
var val string
if err := rows.Scan(&val); err != nil {
rows.Close()
db.CaptureError(err, "", nil, "scan")
return nil, errx.InternalError()
}
inserted = append(inserted, val)
}
rows.Close()
if err := rows.Err(); err != nil {
db.CaptureError(err, queryIns, params, "rows")
return nil, errx.InternalError()
}
}
@@ -98,12 +133,12 @@ func SyncRelation(input RelationSyncInput) ([]string, *errx.Error) {
return !utils.Contains(toDelete, v)
})
final = append(final, toInsert...)
final = append(final, inserted...)
return final, nil
}
func SyncEmailTags(ctx context.Context, tx pgx.Tx, emailAccountID string, newTags []string) ([]string, *errx.Error) {
func SyncEmailTags(ctx context.Context, tx pgx.Tx, orgID any, emailAccountID string, newTags []string) ([]string, *errx.Error) {
tags, err := SyncRelation(RelationSyncInput{
Tx: tx,
Ctx: ctx,
@@ -112,6 +147,8 @@ func SyncEmailTags(ctx context.Context, tx pgx.Tx, emailAccountID string, newTag
ColRelated: "tag_id",
MainID: emailAccountID,
NewValues: newTags,
ScopeTable: "tags",
OrgID: orgID,
})
if err != nil {
return nil, err
@@ -119,7 +156,7 @@ func SyncEmailTags(ctx context.Context, tx pgx.Tx, emailAccountID string, newTag
return tags, nil
}
func SyncCampaignEmailTags(ctx context.Context, tx pgx.Tx, campaignID string, newTags []string) ([]string, *errx.Error) {
func SyncCampaignEmailTags(ctx context.Context, tx pgx.Tx, orgID any, campaignID string, newTags []string) ([]string, *errx.Error) {
tags, err := SyncRelation(RelationSyncInput{
Tx: tx,
Ctx: ctx,
@@ -128,6 +165,8 @@ func SyncCampaignEmailTags(ctx context.Context, tx pgx.Tx, campaignID string, ne
ColRelated: "tag_id",
MainID: campaignID,
NewValues: newTags,
ScopeTable: "tags",
OrgID: orgID,
})
if err != nil {
return nil, err
@@ -135,7 +174,7 @@ func SyncCampaignEmailTags(ctx context.Context, tx pgx.Tx, campaignID string, ne
return tags, nil
}
func SyncCampaignFolders(ctx context.Context, tx pgx.Tx, campaignID string, newFolders []string) ([]string, *errx.Error) {
func SyncCampaignFolders(ctx context.Context, tx pgx.Tx, orgID any, campaignID string, newFolders []string) ([]string, *errx.Error) {
folders, err := SyncRelation(RelationSyncInput{
Tx: tx,
Ctx: ctx,
@@ -144,6 +183,8 @@ func SyncCampaignFolders(ctx context.Context, tx pgx.Tx, campaignID string, newF
ColRelated: "folder_id",
MainID: campaignID,
NewValues: newFolders,
ScopeTable: "folders",
OrgID: orgID,
})
if err != nil {
return nil, err
+59 -49
View File
@@ -40,7 +40,7 @@ type UniboxRepository interface {
GetByIDForOrg(ctx context.Context, orgID, id uuid.UUID) (*models.EmailMessageStoreData, uuid.UUID, error)
GetByThread(ctx context.Context, orgID, emailID uuid.UUID, threadID string, limit int, cursor string) (*models.MailSearchResult, error)
GetBySender(ctx context.Context, userID uuid.UUID, sender string, limit int, cursor string) (*models.MailSearchResult, error)
Search(ctx context.Context, orgID, userID uuid.UUID, params *models.MailSearchParams) (*models.MailSearchResult, error)
Search(ctx context.Context, orgID uuid.UUID, params *models.MailSearchParams) (*models.MailSearchResult, error)
GetUnseenCount(ctx context.Context, orgID uuid.UUID, emailAccountID *uuid.UUID) (int64, error)
MarkSeen(ctx context.Context, userID, id uuid.UUID, seen bool) error
MarkSeenBulk(ctx context.Context, orgID uuid.UUID, ids []uuid.UUID, seen bool) error
@@ -64,17 +64,17 @@ type UniboxRepository interface {
// so the client doesn't fan out N+M queries for each mailbox/tag.
Overview(ctx context.Context, orgID uuid.UUID) (*models.UniboxOverview, error)
// Conversation labels. SetThreadLabels replaces the full label set
// on a thread (idempotent PUT semantics, only the user's own
// categories are attached). ListThreadLabels returns the current
// set for one thread.
SetThreadLabels(ctx context.Context, userID uuid.UUID, threadID string, categoryIDs []uuid.UUID) ([]models.MiniCategory, error)
ListThreadLabels(ctx context.Context, userID uuid.UUID, threadID string) ([]models.MiniCategory, error)
// Conversation labels, workspace-scoped like the inbox they hang off.
// SetThreadLabels replaces the full label set on a thread (idempotent PUT
// semantics, only the workspace's own categories are attached);
// ListThreadLabels returns the current set for one thread.
SetThreadLabels(ctx context.Context, orgID, userID uuid.UUID, threadID string, categoryIDs []uuid.UUID) ([]models.MiniCategory, error)
ListThreadLabels(ctx context.Context, orgID uuid.UUID, threadID string) ([]models.MiniCategory, error)
// AddThreadLabels attaches labels to a thread WITHOUT removing existing ones
// (additive; for automation/step "label email" actions). LatestThreadIDForContact
// finds the user's most recent conversation with an address, so a campaign
// step that knows the contact but not the thread can still label it.
AddThreadLabels(ctx context.Context, userID uuid.UUID, threadID string, categoryIDs []uuid.UUID) error
AddThreadLabels(ctx context.Context, orgID uuid.UUID, threadID string, categoryIDs []uuid.UUID) error
LatestThreadIDForContact(ctx context.Context, userID uuid.UUID, email string) (string, error)
// LatestMessageIDInThread returns the newest RFC Message-ID in a thread, so
// a reply that arrives with only a provider thread id can still carry the
@@ -406,16 +406,16 @@ func (r *uniboxRepository) GetBySender(ctx context.Context, userID uuid.UUID, se
// content filter (the default inbox) that's the whole thread.
// - thread/representative-level filters (awaiting reply, category)
// and keyset pagination run on the collapsed row.
func (r *uniboxRepository) Search(ctx context.Context, orgID, userID uuid.UUID, params *models.MailSearchParams) (*models.MailSearchResult, error) {
func (r *uniboxRepository) Search(ctx context.Context, orgID uuid.UUID, params *models.MailSearchParams) (*models.MailSearchResult, error) {
previewCols := make([]string, len(mailFieldsPreview))
for i, c := range mailFieldsPreview {
previewCols[i] = "ue." + c
}
// $1 = orgID (scope mail to the workspace's mailboxes); $2 = userID
// (per-user thread labels stay personal). Dynamic filters start at $3.
args := []any{orgID, userID}
argPos := 3
// $1 = orgID, which scopes both the mail (through the workspace's
// mailboxes) and the conversation labels. Dynamic filters start at $2.
args := []any{orgID}
argPos := 2
// ── Inner windowed subquery: row-level filters + per-thread aggs ──
// Partition by the thread, but treat an empty thread_id (the column
@@ -549,7 +549,7 @@ func (r *uniboxRepository) Search(ctx context.Context, orgID, userID uuid.UUID,
SELECT json_agg(json_build_object('id', c.id, 'title', c.title, 'color', c.color) ORDER BY c.position ASC, c.title ASC)
FROM unibox_thread_labels utl
JOIN categories c ON c.id = utl.category_id
WHERE utl.user_id = $2 AND utl.thread_id = b.thread_id
WHERE utl.organization_id = $1 AND utl.thread_id = b.thread_id
), '[]'::json
) AS labels
FROM (%s) b
@@ -581,7 +581,7 @@ func (r *uniboxRepository) Search(ctx context.Context, orgID, userID uuid.UUID,
query += fmt.Sprintf(`
AND EXISTS (
SELECT 1 FROM unibox_thread_labels utl
WHERE utl.user_id = $2
WHERE utl.organization_id = $1
AND utl.thread_id = b.thread_id
AND utl.category_id = ANY($%d)
)`, argPos)
@@ -593,7 +593,7 @@ func (r *uniboxRepository) Search(ctx context.Context, orgID, userID uuid.UUID,
query += `
AND NOT EXISTS (
SELECT 1 FROM unibox_thread_labels utl
WHERE utl.user_id = $2
WHERE utl.organization_id = $1
AND utl.thread_id = b.thread_id
)`
}
@@ -793,10 +793,13 @@ func (r *uniboxRepository) queryThreadList(ctx context.Context, query string, ar
// ── Conversation labels ─────────────────────────────────────────────────
// SetThreadLabels replaces the full label set on a thread. Only the
// user's own categories are attached (a SELECT-guarded insert), so a
// bogus or someone else's category_id is silently dropped rather than
// workspace's own categories are attached (a SELECT-guarded insert), so a
// bogus or another workspace's category_id is silently dropped rather than
// trusted. Idempotent: re-sending the same set is a no-op.
func (r *uniboxRepository) SetThreadLabels(ctx context.Context, userID uuid.UUID, threadID string, categoryIDs []uuid.UUID) ([]models.MiniCategory, error) {
//
// The label belongs to the workspace, like the inbox it hangs off: a teammate
// must see how a conversation was filed. userID records who filed it.
func (r *uniboxRepository) SetThreadLabels(ctx context.Context, orgID, userID uuid.UUID, threadID string, categoryIDs []uuid.UUID) ([]models.MiniCategory, error) {
if threadID == "" {
return nil, errors.New("threadID required")
}
@@ -814,21 +817,25 @@ func (r *uniboxRepository) SetThreadLabels(ctx context.Context, userID uuid.UUID
// clears every label (category_id = ANY('{}') is false → NOT false).
if _, err := tx.Exec(ctx, `
DELETE FROM unibox_thread_labels
WHERE user_id = $1 AND thread_id = $2 AND NOT (category_id = ANY($3))
`, userID, threadID, categoryIDs); err != nil {
WHERE organization_id = $1 AND thread_id = $2 AND NOT (category_id = ANY($3))
`, orgID, threadID, categoryIDs); err != nil {
return nil, err
}
// Add the rest, but only categories that actually belong to the
// user. ON CONFLICT keeps the upsert idempotent.
// workspace. ON CONFLICT keeps the upsert idempotent.
if len(categoryIDs) > 0 {
var applier any
if userID != uuid.Nil {
applier = userID
}
if _, err := tx.Exec(ctx, `
INSERT INTO unibox_thread_labels (user_id, thread_id, category_id)
SELECT $1, $2, c.id
INSERT INTO unibox_thread_labels (organization_id, user_id, thread_id, category_id)
SELECT $1, $2, $3, c.id
FROM categories c
WHERE c.user_id = $1 AND c.id = ANY($3)
ON CONFLICT (user_id, thread_id, category_id) DO NOTHING
`, userID, threadID, categoryIDs); err != nil {
WHERE c.organization_id = $1 AND c.id = ANY($4)
ON CONFLICT (organization_id, thread_id, category_id) DO NOTHING
`, orgID, applier, threadID, categoryIDs); err != nil {
return nil, err
}
}
@@ -837,19 +844,19 @@ func (r *uniboxRepository) SetThreadLabels(ctx context.Context, userID uuid.UUID
return nil, err
}
return r.ListThreadLabels(ctx, userID, threadID)
return r.ListThreadLabels(ctx, orgID, threadID)
}
// ListThreadLabels returns the conversation's current labels, ordered to
// match the category palette ordering used everywhere else.
func (r *uniboxRepository) ListThreadLabels(ctx context.Context, userID uuid.UUID, threadID string) ([]models.MiniCategory, error) {
func (r *uniboxRepository) ListThreadLabels(ctx context.Context, orgID uuid.UUID, threadID string) ([]models.MiniCategory, error) {
rows, err := r.db.Query(ctx, `
SELECT c.id, c.title, c.color
FROM unibox_thread_labels utl
JOIN categories c ON c.id = utl.category_id
WHERE utl.user_id = $1 AND utl.thread_id = $2
WHERE utl.organization_id = $1 AND utl.thread_id = $2
ORDER BY c.position ASC, c.title ASC
`, userID, threadID)
`, orgID, threadID)
if err != nil {
return nil, err
}
@@ -868,19 +875,20 @@ func (r *uniboxRepository) ListThreadLabels(ctx context.Context, userID uuid.UUI
// AddThreadLabels additively attaches labels to a thread (never removes any),
// so an automation/step action can tag a conversation without clobbering labels
// a teammate set by hand. Only the user's own categories are attached
// a teammate set by hand. Only the workspace's own categories are attached
// (SELECT-guarded), mirroring SetThreadLabels; a bogus id is silently dropped.
func (r *uniboxRepository) AddThreadLabels(ctx context.Context, userID uuid.UUID, threadID string, categoryIDs []uuid.UUID) error {
// An automation has no human behind it, so the applier is left NULL.
func (r *uniboxRepository) AddThreadLabels(ctx context.Context, orgID uuid.UUID, threadID string, categoryIDs []uuid.UUID) error {
if threadID == "" || len(categoryIDs) == 0 {
return nil
}
_, err := r.db.Exec(ctx, `
INSERT INTO unibox_thread_labels (user_id, thread_id, category_id)
INSERT INTO unibox_thread_labels (organization_id, thread_id, category_id)
SELECT $1, $2, c.id
FROM categories c
WHERE c.user_id = $1 AND c.id = ANY($3)
ON CONFLICT (user_id, thread_id, category_id) DO NOTHING
`, userID, threadID, categoryIDs)
WHERE c.organization_id = $1 AND c.id = ANY($3)
ON CONFLICT (organization_id, thread_id, category_id) DO NOTHING
`, orgID, threadID, categoryIDs)
return err
}
@@ -1168,7 +1176,9 @@ func (r *uniboxRepository) Overview(ctx context.Context, orgID uuid.UUID) (*mode
// Per-tag counters. Mailbox tags live in `tags` + `email_tags`.
// Per THREAD (distinct thread key) so threads aren't over-counted by
// message multiplicity or the email_tags fan-out.
// message multiplicity or the email_tags fan-out. Every join is on the
// workspace: this used to compare the registry's user_id against the org
// id it is handed, so the rail was always empty.
tagRows, err := r.db.Query(ctx, `
SELECT
t.id,
@@ -1177,19 +1187,19 @@ func (r *uniboxRepository) Overview(ctx context.Context, orgID uuid.UUID) (*mode
COUNT(DISTINCT COALESCE(NULLIF(ue.thread_id, ''), ue.id::text)) FILTER (WHERE ue.id IS NOT NULL AND NOT ue.seen
AND NOT EXISTS (
SELECT 1 FROM unibox_snoozes s
WHERE s.user_id = t.user_id AND s.thread_id = ue.thread_id AND s.snoozed_until > NOW()
WHERE s.user_id = ue.user_id AND s.thread_id = ue.thread_id AND s.snoozed_until > NOW()
)) AS unread,
COUNT(DISTINCT COALESCE(NULLIF(ue.thread_id, ''), ue.id::text)) FILTER (WHERE ue.id IS NOT NULL
AND NOT EXISTS (
SELECT 1 FROM unibox_snoozes s
WHERE s.user_id = t.user_id AND s.thread_id = ue.thread_id AND s.snoozed_until > NOW()
WHERE s.user_id = ue.user_id AND s.thread_id = ue.thread_id AND s.snoozed_until > NOW()
)) AS total
FROM tags t
LEFT JOIN email_tags et ON et.tag_id = t.id
LEFT JOIN email_accounts ea ON ea.id = et.email_id AND ea.user_id = t.user_id
LEFT JOIN unibox_emails ue ON ue.email_id = ea.id AND ue.user_id = ea.user_id
LEFT JOIN email_accounts ea ON ea.id = et.email_id AND ea.organization_id = t.organization_id
LEFT JOIN unibox_emails ue ON ue.email_id = ea.id
AND ue.folder NOT IN ('spam', 'trash')
WHERE t.user_id = $1
WHERE t.organization_id = $1
GROUP BY t.id, t.title, t.color, t.position
ORDER BY t.position ASC, t.title ASC
`, orgID)
@@ -1218,24 +1228,24 @@ func (r *uniboxRepository) Overview(ctx context.Context, orgID uuid.UUID) (*mode
overview.Categories = make([]models.UniboxCategoryOverview, 0)
catRows, err := r.db.Query(ctx, `
WITH thread_state AS (
SELECT e.user_id, e.thread_id, bool_or(NOT e.seen) AS has_unread
SELECT e.thread_id, bool_or(NOT e.seen) AS has_unread
FROM unibox_emails e
WHERE e.user_id = $1
WHERE e.email_id IN (SELECT id FROM email_accounts WHERE organization_id = $1)
AND e.folder NOT IN ('spam', 'trash')
AND NOT EXISTS (
SELECT 1 FROM unibox_snoozes s
WHERE s.user_id = e.user_id AND s.thread_id = e.thread_id AND s.snoozed_until > NOW()
)
GROUP BY e.user_id, e.thread_id
GROUP BY e.thread_id
)
SELECT
c.id, c.title, c.color,
COUNT(*) FILTER (WHERE ts.thread_id IS NOT NULL AND ts.has_unread) AS unread,
COUNT(*) FILTER (WHERE ts.thread_id IS NOT NULL) AS total
FROM categories c
LEFT JOIN unibox_thread_labels utl ON utl.category_id = c.id AND utl.user_id = c.user_id
LEFT JOIN thread_state ts ON ts.user_id = utl.user_id AND ts.thread_id = utl.thread_id
WHERE c.user_id = $1
LEFT JOIN unibox_thread_labels utl ON utl.category_id = c.id AND utl.organization_id = c.organization_id
LEFT JOIN thread_state ts ON ts.thread_id = utl.thread_id
WHERE c.organization_id = $1
GROUP BY c.id, c.title, c.color, c.position
ORDER BY c.position ASC, c.title ASC
`, orgID)
+1 -1
View File
@@ -46,7 +46,7 @@ func newSegmentFixture(t *testing.T) (*segmentFixture, SegmentRepository) {
contact(f.bob, "bob", "globex", `{"title":"Engineer"}`, false)
contact(f.carol, "carol", "acme", `{}`, true)
exec(`INSERT INTO categories (id, user_id, title, color, position) VALUES ($1, $2, 'Hot', '#ff0000', 0)`, f.category, f.owner)
exec(`INSERT INTO categories (id, organization_id, user_id, title, color, position) VALUES ($1, $2, $3, 'Hot', '#ff0000', 0)`, f.category, f.org, f.owner)
exec(`INSERT INTO contact_categories (contact_id, category_id) VALUES ($1, $2)`, f.alice, f.category)
exec(`INSERT INTO campaign_leads (campaign_id, contact_id) VALUES ($1, $2)`, f.campaign, f.alice)
seq := uuid.New()
@@ -137,7 +137,7 @@ func TestLiveUniboxMoveFolderLeavesTheProviderPlacementAlone(t *testing.T) {
}
// And the message really has left every default view.
res, err := repo.Search(ctx, f.org, f.user, &models.MailSearchParams{})
res, err := repo.Search(ctx, f.org, &models.MailSearchParams{})
if err != nil {
t.Fatalf("Search: %v", err)
}
+8 -7
View File
@@ -1,6 +1,6 @@
package sandbox
// Label registry for the sandbox user, plus the bindings that make labels
// Label registry for the sandbox workspace, plus the bindings that make labels
// visible across the product: mailbox tags, campaign folders, contact
// categories, and inbox thread labels. Idempotent like the rest of the seeder.
@@ -42,14 +42,15 @@ func seedLabels(ctx context.Context, pool *pgxpool.Pool) error {
}
for _, g := range groups {
if _, err := pool.Exec(ctx, `
INSERT INTO `+g.table+` (id, user_id, title, color, position, created_at, updated_at)
VALUES ($1,$2,$3,$4,$5,NOW(),NOW())
INSERT INTO `+g.table+` (id, organization_id, user_id, title, color, position, created_at, updated_at)
VALUES ($1,$2,$3,$4,$5,$6,NOW(),NOW())
ON CONFLICT (id) DO UPDATE SET
organization_id = EXCLUDED.organization_id,
title = EXCLUDED.title,
color = EXCLUDED.color,
position = EXCLUDED.position,
updated_at = NOW()
`, g.id, sandboxUser, g.title, g.color, g.pos); err != nil {
`, g.id, sandboxOrg, sandboxUser, g.title, g.color, g.pos); err != nil {
return fmt.Errorf("%s %s: %w", g.table, g.title, err)
}
}
@@ -139,10 +140,10 @@ func seedLabels(ctx context.Context, pool *pgxpool.Pool) error {
}
for _, tl := range threadLabels {
if _, err := pool.Exec(ctx, `
INSERT INTO unibox_thread_labels (user_id, thread_id, category_id, created_at)
VALUES ($1,$2,$3,NOW())
INSERT INTO unibox_thread_labels (organization_id, user_id, thread_id, category_id, created_at)
VALUES ($1,$2,$3,$4,NOW())
ON CONFLICT DO NOTHING
`, sandboxUser, tl.threadID, tl.category); err != nil {
`, sandboxOrg, sandboxUser, tl.threadID, tl.category); err != nil {
return fmt.Errorf("thread label %s: %w", tl.threadID, err)
}
}
+2 -2
View File
@@ -84,8 +84,8 @@ func tagMailboxes(t *testing.T, pool *pgxpool.Pool, f *liveFixture, mailboxes ..
ctx := context.Background()
tag := uuid.New()
if _, err := pool.Exec(ctx,
`INSERT INTO tags (id, user_id, title, color, "position") VALUES ($1, $2, 'senders', '#aabbcc', 0)`,
tag, f.user); err != nil {
`INSERT INTO tags (id, organization_id, user_id, title, color, "position") VALUES ($1, $2, $3, 'senders', '#aabbcc', 0)`,
tag, f.org, f.user); err != nil {
t.Fatalf("insert tag: %v", err)
}
for _, mailbox := range mailboxes {
+3 -3
View File
@@ -340,10 +340,10 @@ func seedDevUnibox(ctx context.Context, pool *pgxpool.Pool) error {
}
for _, l := range labels {
if _, err := pool.Exec(ctx, `
INSERT INTO unibox_thread_labels (user_id, thread_id, category_id)
VALUES ($1, $2, $3)
INSERT INTO unibox_thread_labels (organization_id, user_id, thread_id, category_id)
VALUES ($1, $2, $3, $4)
ON CONFLICT DO NOTHING
`, DevUserID, l.threadID, l.category); err != nil {
`, DevOrgID, DevUserID, l.threadID, l.category); err != nil {
return fmt.Errorf("thread label %s: %w", l.threadID, err)
}
}
+5 -4
View File
@@ -193,7 +193,7 @@ func seedDevMailboxes(ctx context.Context, pool *pgxpool.Pool) error {
return nil
}
// seedDevLabels creates the dev user's folders, tags, and categories.
// seedDevLabels creates the dev workspace's folders, tags, and categories.
func seedDevLabels(ctx context.Context, pool *pgxpool.Pool) error {
entries := []struct {
table string
@@ -213,14 +213,15 @@ func seedDevLabels(ctx context.Context, pool *pgxpool.Pool) error {
}
for _, e := range entries {
if _, err := pool.Exec(ctx, `
INSERT INTO `+e.table+` (id, user_id, title, color, position, created_at, updated_at)
VALUES ($1,$2,$3,$4,$5,NOW(),NOW())
INSERT INTO `+e.table+` (id, organization_id, user_id, title, color, position, created_at, updated_at)
VALUES ($1,$2,$3,$4,$5,$6,NOW(),NOW())
ON CONFLICT (id) DO UPDATE SET
organization_id = EXCLUDED.organization_id,
title = EXCLUDED.title,
color = EXCLUDED.color,
position = EXCLUDED.position,
updated_at = NOW()
`, e.id, DevUserID, e.title, e.color, e.pos); err != nil {
`, e.id, DevOrgID, DevUserID, e.title, e.color, e.pos); err != nil {
return fmt.Errorf("%s %s: %w", e.table, e.title, err)
}
}
+11 -9
View File
@@ -11,29 +11,31 @@ func seedGroups(ctx context.Context, pool *pgxpool.Pool, _ *Result) error {
type entry struct {
table string
id uuid.UUID
orgID uuid.UUID
userID uuid.UUID
title string
color string
pos int
}
entries := []entry{
{"folders", FolderInboxID, UserOwnerID, "Inbox", "#3b82f6", 0},
{"folders", FolderClosedID, UserOwnerID, "Closed Won", "#10b981", 1},
{"tags", TagVIPID, UserOwnerID, "VIP", "#a855f7", 0},
{"tags", TagColdID, UserOwnerID, "Cold", "#64748b", 1},
{"categories", CategoryLeadID, UserOwnerID, "Lead", "#f97316", 0},
{"categories", CategoryChurnID, UserOwnerID, "Churn risk", "#ef4444", 1},
{"folders", FolderInboxID, OrgAcmeID, UserOwnerID, "Inbox", "#3b82f6", 0},
{"folders", FolderClosedID, OrgAcmeID, UserOwnerID, "Closed Won", "#10b981", 1},
{"tags", TagVIPID, OrgAcmeID, UserOwnerID, "VIP", "#a855f7", 0},
{"tags", TagColdID, OrgAcmeID, UserOwnerID, "Cold", "#64748b", 1},
{"categories", CategoryLeadID, OrgAcmeID, UserOwnerID, "Lead", "#f97316", 0},
{"categories", CategoryChurnID, OrgAcmeID, UserOwnerID, "Churn risk", "#ef4444", 1},
}
for _, e := range entries {
_, err := pool.Exec(ctx, `
INSERT INTO `+e.table+` (id, user_id, title, color, position, created_at, updated_at)
VALUES ($1,$2,$3,$4,$5,NOW(),NOW())
INSERT INTO `+e.table+` (id, organization_id, user_id, title, color, position, created_at, updated_at)
VALUES ($1,$2,$3,$4,$5,$6,NOW(),NOW())
ON CONFLICT (id) DO UPDATE SET
organization_id = EXCLUDED.organization_id,
title = EXCLUDED.title,
color = EXCLUDED.color,
position = EXCLUDED.position,
updated_at = NOW()
`, e.id, e.userID, e.title, e.color, e.pos)
`, e.id, e.orgID, e.userID, e.title, e.color, e.pos)
if err != nil {
return err
}
+2 -2
View File
@@ -1194,14 +1194,14 @@ func (s *tasksService) executeActionNode(ctx context.Context, campaign *models.C
case "label_email":
// Apply unibox labels to the contact's most recent conversation. A no-op
// when the contact has no thread yet (returns "" thread, nil error).
if len(cfg.LabelIDs) == 0 {
if len(cfg.LabelIDs) == 0 || campaign.OrganizationID == nil {
return nil
}
owner, perr := uuid.Parse(campaign.UserID)
if perr != nil {
return nil
}
if _, xerr := s.advanced.LabelLatestThreadForContact(ctx, owner, contact.Email, cfg.LabelIDs); xerr != nil {
if _, xerr := s.advanced.LabelLatestThreadForContact(ctx, *campaign.OrganizationID, owner, contact.Email, cfg.LabelIDs); xerr != nil {
return xerr
}
return nil
+10 -10
View File
@@ -433,7 +433,7 @@ const (
// empty pool is unrestricted and resolved against the live category list (with
// optional create for add). label_email applies the picked category as a unibox
// label; add_tag/remove_tag as a contact tag. It can be called repeatedly.
func (s *tasksService) campaignTagTool(campaign *models.Campaign, contact *models.Contact, sequenceID uuid.UUID, cfg *models.ActionConfig, owner uuid.UUID, kind string, pool []models.AITagRef, live []models.MiniCategory) generation.ToolDef {
func (s *tasksService) campaignTagTool(campaign *models.Campaign, contact *models.Contact, sequenceID uuid.UUID, cfg *models.ActionConfig, orgID uuid.UUID, kind string, pool []models.AITagRef, live []models.MiniCategory) generation.ToolDef {
allowCreate := cfg.AIAllowCreateTags && kind != "remove_tag"
enum := aiagentargs.TagEnum(pool, live)
verb := map[string]string{"add_tag": "Add the tag", "remove_tag": "Remove the tag", "label_email": "Apply the label"}[kind]
@@ -464,7 +464,7 @@ func (s *tasksService) campaignTagTool(campaign *models.Campaign, contact *model
}
_ = json.Unmarshal(args, &in)
id, err := aiagentargs.ResolveTag(pool, live, allowCreate, in.Tag, func(title string) (uuid.UUID, error) {
c, cerr := s.advanced.CreateCategory(ctx, owner, title, "")
c, cerr := s.advanced.CreateCategory(ctx, orgID, title, "")
if cerr != nil {
return uuid.Nil, cerr
}
@@ -617,10 +617,10 @@ func (s *tasksService) execSequenceAIAgentStep(ctx context.Context, campaign *mo
return errors.New("AI steps need an organization-owned campaign")
}
// Owner scopes the tag/label reads + writes (categories are per-user). The
// live category list (tags == unibox labels) is fetched once, only when a
// tag/label capability is enabled, so an unrestricted pool can offer any.
owner, _ := uuid.Parse(campaign.UserID)
// The workspace scopes the tag/label reads + writes. The live category list
// (tags == unibox labels) is fetched once, only when a tag/label capability
// is enabled, so an unrestricted pool can offer any.
orgID := *campaign.OrganizationID
needCats := false
for _, raw := range cfg.AIAllowedActions {
switch strings.TrimSpace(raw) {
@@ -630,7 +630,7 @@ func (s *tasksService) execSequenceAIAgentStep(ctx context.Context, campaign *mo
}
var liveCats []models.MiniCategory
if needCats {
liveCats, _ = s.advanced.ListCategories(ctx, owner)
liveCats, _ = s.advanced.ListCategories(ctx, orgID)
}
// Argument-based tools: the model supplies the specifics (which tag, task
@@ -646,11 +646,11 @@ func (s *tasksService) execSequenceAIAgentStep(ctx context.Context, campaign *mo
seen[t] = true
switch t {
case "add_tag":
tools = append(tools, s.campaignTagTool(campaign, contact, sequenceID, cfg, owner, "add_tag", cfg.AIAddTags, liveCats))
tools = append(tools, s.campaignTagTool(campaign, contact, sequenceID, cfg, orgID, "add_tag", cfg.AIAddTags, liveCats))
case "remove_tag":
tools = append(tools, s.campaignTagTool(campaign, contact, sequenceID, cfg, owner, "remove_tag", cfg.AIRemoveTags, liveCats))
tools = append(tools, s.campaignTagTool(campaign, contact, sequenceID, cfg, orgID, "remove_tag", cfg.AIRemoveTags, liveCats))
case "label_email":
tools = append(tools, s.campaignTagTool(campaign, contact, sequenceID, cfg, owner, "label_email", cfg.AILabels, liveCats))
tools = append(tools, s.campaignTagTool(campaign, contact, sequenceID, cfg, orgID, "label_email", cfg.AILabels, liveCats))
case "create_task":
tools = append(tools, s.campaignTaskTool(campaign, contact, sequenceID, cfg))
case "create_deal":
@@ -24,7 +24,9 @@ interface UseSwitchOrganizationOptions {
// change) to avoid re-churning OrgGate's own effect.
//
// `auth/*` is preserved in both modes — identity didn't change, just the
// active workspace.
// active workspace. `auth/me` is the exception: its payload carries the
// WORKSPACE's folder/tag/category registries, so it is refetched on every
// switch or the pickers keep offering the previous workspace's labels.
mode?: "reset" | "sync";
}
@@ -41,6 +43,7 @@ export default function useSwitchOrganization({ mode = "reset" }: UseSwitchOrgan
return root !== "auth" && root !== "organizations";
},
});
void queryClient.invalidateQueries({ queryKey: ["auth", "me"] });
return;
}
queryClient.removeQueries({
@@ -49,6 +52,7 @@ export default function useSwitchOrganization({ mode = "reset" }: UseSwitchOrgan
return root !== "auth";
},
});
void queryClient.invalidateQueries({ queryKey: ["auth", "me"] });
},
});
}