feat: key unibox snoozes by organization as well as user and thread (migration 000259 backfills from each thread's mailbox organization), scope every snooze read and write and the org-transfer scope to it, and resolve unibox list cursors only within the caller's organization or user

This commit is contained in:
Matthew Meszaros
2026-10-04 03:22:09 -07:00
parent 043d877b58
commit ec77a3e3d2
10 changed files with 101 additions and 50 deletions
+18 -3
View File
@@ -688,8 +688,13 @@ func (h *Handler) CreateUniboxSnooze(c *gin.Context) {
return
}
orgID := middleware.GetOrganizationID(c)
if orgID == nil {
errx.Handle(c, errx.ErrNoOrganization)
return
}
threads := req.threads()
rows, xerr := h.UniboxService.Snooze(c.Request.Context(), uid, threads, req.SnoozedUntil)
rows, xerr := h.UniboxService.Snooze(c.Request.Context(), *orgID, uid, threads, req.SnoozedUntil)
if xerr != nil {
errx.Handle(c, xerr)
return
@@ -734,7 +739,12 @@ func (h *Handler) DeleteUniboxSnooze(c *gin.Context) {
return
}
if xerr := h.UniboxService.Unsnooze(c.Request.Context(), uid, threads); xerr != nil {
orgID := middleware.GetOrganizationID(c)
if orgID == nil {
errx.Handle(c, errx.ErrNoOrganization)
return
}
if xerr := h.UniboxService.Unsnooze(c.Request.Context(), *orgID, uid, threads); xerr != nil {
errx.Handle(c, xerr)
return
}
@@ -827,7 +837,12 @@ func (h *Handler) ListUniboxSnoozes(c *gin.Context) {
return
}
resp, xerr := h.UniboxService.ListSnoozes(c.Request.Context(), uid)
orgID := middleware.GetOrganizationID(c)
if orgID == nil {
errx.Handle(c, errx.ErrNoOrganization)
return
}
resp, xerr := h.UniboxService.ListSnoozes(c.Request.Context(), *orgID, uid)
if xerr != nil {
errx.Handle(c, xerr)
return
+2 -2
View File
@@ -175,7 +175,7 @@ func (d Deps) snoozeThread(ctx context.Context, inv Invocation, args json.RawMes
if perr != nil {
return "", ErrInvalidArgs
}
snooze, xerr := d.Unibox.Snooze(ctx, inv.UserID, []string{in.ThreadID}, until)
snooze, xerr := d.Unibox.Snooze(ctx, inv.OrgID, inv.UserID, []string{in.ThreadID}, until)
if xerr != nil {
return "", fromErrx(xerr)
}
@@ -196,7 +196,7 @@ func (d Deps) unsnoozeThread(ctx context.Context, inv Invocation, args json.RawM
if in.ThreadID == "" {
return "", ErrInvalidArgs
}
if xerr := d.Unibox.Unsnooze(ctx, inv.UserID, []string{in.ThreadID}); xerr != nil {
if xerr := d.Unibox.Unsnooze(ctx, inv.OrgID, inv.UserID, []string{in.ThreadID}); xerr != nil {
return "", fromErrx(xerr)
}
d.logAudit(ctx, inv, models.AuditActionUpdate, models.AuditEntityUnibox, nil, nil)
+1 -2
View File
@@ -105,7 +105,6 @@ const (
orgCampaigns = `(SELECT id FROM campaigns WHERE organization_id = $1)`
orgContacts = `(SELECT id FROM contacts WHERE organization_id = $1)`
orgTasks = `(SELECT id FROM tasks WHERE email_account_id IN ` + orgMailboxes + ` AND task_type <> 'placement')`
orgThreads = `(SELECT DISTINCT thread_id FROM unibox_emails WHERE email_id IN ` + orgMailboxes + `)`
orgPipelines = `(SELECT id FROM pipelines WHERE organization_id = $1)`
orgInvitations = `(SELECT id FROM organization_invitations WHERE organization_id = $1)`
orgTeams = `(SELECT id FROM teams WHERE organization_id = $1)`
@@ -713,7 +712,7 @@ var Tables = []Table{
},
{
Name: "unibox_snoozes", Group: models.OrgDataGroupInbox,
Scope: `thread_id IN ` + orgThreads,
Scope: scopeOrg,
},
{
Name: "inbox_tag_results", Group: models.OrgDataGroupInbox,
+3 -3
View File
@@ -52,9 +52,9 @@ type UniboxService interface {
// Snooze hides conversations until `until`. Unsnooze drops the rows. Both
// take a set so the list's selection bar is one call, not one per row.
Snooze(ctx context.Context, userID uuid.UUID, threadIDs []string, until time.Time) ([]models.UniboxSnooze, *errx.Error)
Unsnooze(ctx context.Context, userID uuid.UUID, threadIDs []string) *errx.Error
ListSnoozes(ctx context.Context, userID uuid.UUID) ([]models.UniboxSnooze, *errx.Error)
Snooze(ctx context.Context, orgID, userID uuid.UUID, threadIDs []string, until time.Time) ([]models.UniboxSnooze, *errx.Error)
Unsnooze(ctx context.Context, orgID, userID uuid.UUID, threadIDs []string) *errx.Error
ListSnoozes(ctx context.Context, orgID, userID uuid.UUID) ([]models.UniboxSnooze, *errx.Error)
// Overview powers the scope rail + top metric strip in one call.
Overview(ctx context.Context, orgID, userID uuid.UUID) (*models.UniboxOverview, *errx.Error)
+6 -6
View File
@@ -13,7 +13,7 @@ import (
// Snooze takes a set of conversations so the list's selection bar is one call.
// A single thread is the one-element case.
func (s *uniboxService) Snooze(ctx context.Context, userID uuid.UUID, threadIDs []string, until time.Time) ([]models.UniboxSnooze, *errx.Error) {
func (s *uniboxService) Snooze(ctx context.Context, orgID, userID uuid.UUID, threadIDs []string, until time.Time) ([]models.UniboxSnooze, *errx.Error) {
threadIDs = nonEmpty(threadIDs)
if len(threadIDs) == 0 {
return nil, errx.New(errx.BadRequest, "thread_id is required")
@@ -32,7 +32,7 @@ func (s *uniboxService) Snooze(ctx context.Context, userID uuid.UUID, threadIDs
return nil, errx.New(errx.BadRequest, "snoozed_until is too far in the future (max 90 days)")
}
rows, err := s.uniboxRepository.UpsertSnoozes(ctx, userID, threadIDs, until.UTC())
rows, err := s.uniboxRepository.UpsertSnoozes(ctx, orgID, userID, threadIDs, until.UTC())
if err != nil {
errs.CaptureException(err)
return nil, errx.InternalError()
@@ -40,7 +40,7 @@ func (s *uniboxService) Snooze(ctx context.Context, userID uuid.UUID, threadIDs
return rows, nil
}
func (s *uniboxService) Unsnooze(ctx context.Context, userID uuid.UUID, threadIDs []string) *errx.Error {
func (s *uniboxService) Unsnooze(ctx context.Context, orgID, userID uuid.UUID, threadIDs []string) *errx.Error {
threadIDs = nonEmpty(threadIDs)
if len(threadIDs) == 0 {
return errx.New(errx.BadRequest, "thread_id is required")
@@ -48,7 +48,7 @@ func (s *uniboxService) Unsnooze(ctx context.Context, userID uuid.UUID, threadID
if len(threadIDs) > SnoozeMaxThreads {
return errx.ErrSeenMax
}
if err := s.uniboxRepository.DeleteSnoozes(ctx, userID, threadIDs); err != nil {
if err := s.uniboxRepository.DeleteSnoozes(ctx, orgID, userID, threadIDs); err != nil {
errs.CaptureException(err)
return errx.InternalError()
}
@@ -67,8 +67,8 @@ func nonEmpty(in []string) []string {
return out
}
func (s *uniboxService) ListSnoozes(ctx context.Context, userID uuid.UUID) ([]models.UniboxSnooze, *errx.Error) {
rows, err := s.uniboxRepository.ListSnoozes(ctx, userID)
func (s *uniboxService) ListSnoozes(ctx context.Context, orgID, userID uuid.UUID) ([]models.UniboxSnooze, *errx.Error) {
rows, err := s.uniboxRepository.ListSnoozes(ctx, orgID, userID)
if err != nil {
errs.CaptureException(err)
return nil, errx.InternalError()
@@ -0,0 +1,12 @@
ALTER TABLE public.unibox_snoozes DROP CONSTRAINT IF EXISTS unibox_snoozes_org_user_thread_key;
-- Keep the latest snooze per (user, thread) so the old key can return.
DELETE FROM public.unibox_snoozes s
USING public.unibox_snoozes newer
WHERE s.user_id = newer.user_id AND s.thread_id = newer.thread_id
AND (s.snoozed_until, s.id) < (newer.snoozed_until, newer.id);
ALTER TABLE public.unibox_snoozes
ADD CONSTRAINT unibox_snoozes_user_id_thread_id_key UNIQUE (user_id, thread_id);
ALTER TABLE public.unibox_snoozes DROP COLUMN organization_id;
@@ -0,0 +1,23 @@
-- A snooze belongs to the organization whose inbox it hides a thread in.
ALTER TABLE public.unibox_snoozes
ADD COLUMN organization_id uuid REFERENCES public.organizations (id) ON DELETE CASCADE;
ALTER TABLE public.unibox_snoozes DROP CONSTRAINT unibox_snoozes_user_id_thread_id_key;
DELETE FROM public.unibox_snoozes WHERE snoozed_until <= now();
-- One row per organization the thread's mail sits in, read the way the inbox
-- matches a snooze (the mail's user and thread).
INSERT INTO public.unibox_snoozes (user_id, thread_id, snoozed_until, created_at, updated_at, organization_id)
SELECT DISTINCT s.user_id, s.thread_id, s.snoozed_until, s.created_at, s.updated_at, ea.organization_id
FROM public.unibox_snoozes s
JOIN public.unibox_emails ue ON ue.user_id = s.user_id AND ue.thread_id = s.thread_id
JOIN public.email_accounts ea ON ea.id = ue.email_id
WHERE s.organization_id IS NULL AND ea.organization_id IS NOT NULL;
DELETE FROM public.unibox_snoozes WHERE organization_id IS NULL;
ALTER TABLE public.unibox_snoozes ALTER COLUMN organization_id SET NOT NULL;
ALTER TABLE public.unibox_snoozes
ADD CONSTRAINT unibox_snoozes_org_user_thread_key UNIQUE (organization_id, user_id, thread_id);
+31 -29
View File
@@ -96,15 +96,15 @@ type UniboxRepository interface {
ProcessPendingWarmupVerification(ctx context.Context, id uuid.UUID, process func(*models.JobEventNewEmail) error) error
UpdatePendingEmail(ctx context.Context, userID, id uuid.UUID, update func(*models.EmailMessageStoreData)) (bool, error)
// Snooze: per (user, thread). UpsertSnooze adopts the new
// Snooze: per (organization, user, thread). UpsertSnooze adopts the new
// snoozed_until even if one already exists; DeleteSnooze removes
// the row outright (instant un-snooze). ListSnoozes returns the
// active set for the user.
// The bulk forms back the conversation list's selection bar, so filing a
// screenful of mail is one round trip rather than one per row.
UpsertSnoozes(ctx context.Context, userID uuid.UUID, threadIDs []string, until time.Time) ([]models.UniboxSnooze, error)
DeleteSnoozes(ctx context.Context, userID uuid.UUID, threadIDs []string) error
ListSnoozes(ctx context.Context, userID uuid.UUID) ([]models.UniboxSnooze, error)
UpsertSnoozes(ctx context.Context, orgID, userID uuid.UUID, threadIDs []string, until time.Time) ([]models.UniboxSnooze, error)
DeleteSnoozes(ctx context.Context, orgID, userID uuid.UUID, threadIDs []string) error
ListSnoozes(ctx context.Context, orgID, userID uuid.UUID) ([]models.UniboxSnooze, error)
// Overview powers the scope rail + top metric strip. Single call
// so the client doesn't fan out N+M queries for each mailbox/tag.
@@ -356,7 +356,7 @@ func (r *uniboxRepository) GetIncoming(ctx context.Context, userID uuid.UUID, li
if err == nil {
query += fmt.Sprintf(`
AND (internal_date, id) < (
SELECT internal_date, id FROM unibox_emails WHERE id = $%d
SELECT internal_date, id FROM unibox_emails WHERE id = $%d AND user_id = $1
)`, argPos)
args = append(args, cursorID)
argPos++
@@ -471,7 +471,8 @@ func (r *uniboxRepository) GetByThread(ctx context.Context, orgID, emailID uuid.
if err == nil {
query += fmt.Sprintf(`
AND (internal_date, id) > (
SELECT internal_date, id FROM unibox_emails WHERE id = $%d
SELECT internal_date, id FROM unibox_emails
WHERE id = $%d AND email_id IN (SELECT id FROM email_accounts WHERE organization_id = $1)
)`, argPos)
args = append(args, cursorID)
argPos++
@@ -564,7 +565,7 @@ func (r *uniboxRepository) GetBySender(ctx context.Context, userID uuid.UUID, se
if err == nil {
query += fmt.Sprintf(`
AND (internal_date, id) < (
SELECT internal_date, id FROM unibox_emails WHERE id = $%d
SELECT internal_date, id FROM unibox_emails WHERE id = $%d AND user_id = $1
)`, argPos)
args = append(args, cursorID)
argPos++
@@ -634,7 +635,7 @@ func (r *uniboxRepository) Search(ctx context.Context, orgID uuid.UUID, params *
inner += `
AND NOT EXISTS (
SELECT 1 FROM unibox_snoozes s
WHERE s.user_id = ue.user_id
WHERE s.organization_id = $1 AND s.user_id = ue.user_id
AND s.thread_id = ue.thread_id
AND s.snoozed_until > NOW()
)`
@@ -642,7 +643,7 @@ func (r *uniboxRepository) Search(ctx context.Context, orgID uuid.UUID, params *
inner += `
AND EXISTS (
SELECT 1 FROM unibox_snoozes s
WHERE s.user_id = ue.user_id
WHERE s.organization_id = $1 AND s.user_id = ue.user_id
AND s.thread_id = ue.thread_id
AND s.snoozed_until > NOW()
)`
@@ -825,7 +826,8 @@ func (r *uniboxRepository) Search(ctx context.Context, orgID uuid.UUID, params *
if err == nil {
query += fmt.Sprintf(`
AND (b.internal_date, b.id) < (
SELECT internal_date, id FROM unibox_emails WHERE id = $%d
SELECT internal_date, id FROM unibox_emails
WHERE id = $%d AND email_id IN (SELECT id FROM email_accounts WHERE organization_id = $1)
)`, argPos)
args = append(args, cursorID)
argPos++
@@ -848,7 +850,7 @@ func (r *uniboxRepository) GetUnseenCount(ctx context.Context, orgID uuid.UUID,
AND ue.folder = '` + models.FolderInbox + `'
AND NOT EXISTS (
SELECT 1 FROM unibox_snoozes s
WHERE s.user_id = ue.user_id
WHERE s.organization_id = $1 AND s.user_id = ue.user_id
AND s.thread_id = ue.thread_id
AND s.snoozed_until > NOW()
)
@@ -1431,18 +1433,18 @@ func (r *uniboxRepository) LatestMessageIDInThread(ctx context.Context, orgID uu
// ── Snoozes ────────────────────────────────────────────────────────────
func (r *uniboxRepository) UpsertSnoozes(ctx context.Context, userID uuid.UUID, threadIDs []string, until time.Time) ([]models.UniboxSnooze, error) {
func (r *uniboxRepository) UpsertSnoozes(ctx context.Context, orgID, userID uuid.UUID, threadIDs []string, until time.Time) ([]models.UniboxSnooze, error) {
if len(threadIDs) == 0 {
return nil, errors.New("threadIDs required")
}
rows, err := r.db.Query(ctx, `
INSERT INTO unibox_snoozes (user_id, thread_id, snoozed_until, created_at, updated_at)
SELECT $1, t, $3, NOW(), NOW() FROM unnest($2::text[]) AS t
ON CONFLICT (user_id, thread_id) DO UPDATE SET
INSERT INTO unibox_snoozes (organization_id, user_id, thread_id, snoozed_until, created_at, updated_at)
SELECT $4, $1, t, $3, NOW(), NOW() FROM unnest($2::text[]) AS t
ON CONFLICT (organization_id, user_id, thread_id) DO UPDATE SET
snoozed_until = EXCLUDED.snoozed_until,
updated_at = NOW()
RETURNING id, user_id, thread_id, snoozed_until, created_at, updated_at
`, userID, threadIDs, until)
`, userID, threadIDs, until, orgID)
if err != nil {
return nil, err
}
@@ -1459,24 +1461,24 @@ func (r *uniboxRepository) UpsertSnoozes(ctx context.Context, userID uuid.UUID,
return out, rows.Err()
}
func (r *uniboxRepository) DeleteSnoozes(ctx context.Context, userID uuid.UUID, threadIDs []string) error {
func (r *uniboxRepository) DeleteSnoozes(ctx context.Context, orgID, userID uuid.UUID, threadIDs []string) error {
if len(threadIDs) == 0 {
return nil
}
_, err := r.db.Exec(ctx,
`DELETE FROM unibox_snoozes WHERE user_id = $1 AND thread_id = ANY($2)`,
userID, threadIDs,
`DELETE FROM unibox_snoozes WHERE organization_id = $3 AND user_id = $1 AND thread_id = ANY($2)`,
userID, threadIDs, orgID,
)
return err
}
func (r *uniboxRepository) ListSnoozes(ctx context.Context, userID uuid.UUID) ([]models.UniboxSnooze, error) {
func (r *uniboxRepository) ListSnoozes(ctx context.Context, orgID, userID uuid.UUID) ([]models.UniboxSnooze, error) {
rows, err := r.db.Query(ctx, `
SELECT id, user_id, thread_id, snoozed_until, created_at, updated_at
FROM unibox_snoozes
WHERE user_id = $1 AND snoozed_until > NOW()
WHERE organization_id = $2 AND user_id = $1 AND snoozed_until > NOW()
ORDER BY snoozed_until ASC
`, userID)
`, userID, orgID)
if err != nil {
return nil, err
}
@@ -1532,7 +1534,7 @@ func (r *uniboxRepository) Overview(ctx context.Context, orgID uuid.UUID) (*mode
e.folder = 'archive' AS is_archived,
EXISTS (
SELECT 1 FROM unibox_snoozes s
WHERE s.user_id = e.user_id
WHERE s.organization_id = $1 AND s.user_id = e.user_id
AND s.thread_id = e.thread_id
AND s.snoozed_until > NOW()
) AS is_snoozed
@@ -1599,7 +1601,7 @@ func (r *uniboxRepository) Overview(ctx context.Context, orgID uuid.UUID) (*mode
WHERE e.email_id IN (SELECT id FROM email_accounts WHERE organization_id = $1)
AND NOT EXISTS (
SELECT 1 FROM unibox_snoozes s
WHERE s.user_id = e.user_id
WHERE s.organization_id = $1 AND s.user_id = e.user_id
AND s.thread_id = e.thread_id
AND s.snoozed_until > NOW()
)
@@ -1636,12 +1638,12 @@ 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 = ea.user_id AND s.thread_id = ue.thread_id AND s.snoozed_until > NOW()
WHERE s.organization_id = $1 AND s.user_id = ea.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 = ea.user_id AND s.thread_id = ue.thread_id AND s.snoozed_until > NOW()
WHERE s.organization_id = $1 AND s.user_id = ea.user_id AND s.thread_id = ue.thread_id AND s.snoozed_until > NOW()
)) AS total
FROM email_accounts ea
LEFT JOIN unibox_emails ue ON ue.email_id = ea.id AND ue.user_id = ea.user_id
@@ -1678,12 +1680,12 @@ 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 = ue.user_id AND s.thread_id = ue.thread_id AND s.snoozed_until > NOW()
WHERE s.organization_id = $1 AND 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 = ue.user_id AND s.thread_id = ue.thread_id AND s.snoozed_until > NOW()
WHERE s.organization_id = $1 AND 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
@@ -1726,7 +1728,7 @@ func (r *uniboxRepository) Overview(ctx context.Context, orgID uuid.UUID) (*mode
AND e.folder NOT IN `+foldersOutsideWorkingViews+`
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()
WHERE s.organization_id = $1 AND s.user_id = e.user_id AND s.thread_id = e.thread_id AND s.snoozed_until > NOW()
)
GROUP BY e.thread_id
)
@@ -30,7 +30,7 @@ func TestLiveUniboxNotificationBadgeCountsOnlyWhatInboxLists(t *testing.T) {
f.scopedMessage(t, repo, "thread-sent", "them@example.com", models.FolderSent, now)
f.scopedMessage(t, repo, "thread-draft", "them@example.com", models.FolderDrafts, now)
f.scopedMessage(t, repo, "thread-snoozed", "them@example.com", models.FolderInbox, now)
if _, err := repo.UpsertSnoozes(ctx, f.user, []string{"thread-snoozed"}, now.Add(24*time.Hour)); err != nil {
if _, err := repo.UpsertSnoozes(ctx, f.org, f.user, []string{"thread-snoozed"}, now.Add(24*time.Hour)); err != nil {
t.Fatalf("UpsertSnoozes: %v", err)
}
@@ -304,10 +304,10 @@ func TestLiveUniboxScopeSnoozeTakesASet(t *testing.T) {
f.scopedMessage(t, repo, "thread-a", "them@example.com", models.FolderInbox, now)
f.scopedMessage(t, repo, "thread-b", "them@example.com", models.FolderInbox, now)
t.Cleanup(func() {
_ = repo.DeleteSnoozes(context.Background(), f.user, []string{"thread-a", "thread-b"})
_ = repo.DeleteSnoozes(context.Background(), f.org, f.user, []string{"thread-a", "thread-b"})
})
rows, err := repo.UpsertSnoozes(ctx, f.user, []string{"thread-a", "thread-b"}, now.Add(time.Hour))
rows, err := repo.UpsertSnoozes(ctx, f.org, f.user, []string{"thread-a", "thread-b"}, now.Add(time.Hour))
if err != nil {
t.Fatalf("UpsertSnoozes: %v", err)
}
@@ -324,11 +324,11 @@ func TestLiveUniboxScopeSnoozeTakesASet(t *testing.T) {
}
// A second pass over the same set moves the time instead of erroring.
if _, err := repo.UpsertSnoozes(ctx, f.user, []string{"thread-a", "thread-b"}, now.Add(2*time.Hour)); err != nil {
if _, err := repo.UpsertSnoozes(ctx, f.org, f.user, []string{"thread-a", "thread-b"}, now.Add(2*time.Hour)); err != nil {
t.Fatalf("UpsertSnoozes again: %v", err)
}
if err := repo.DeleteSnoozes(ctx, f.user, []string{"thread-a", "thread-b"}); err != nil {
if err := repo.DeleteSnoozes(ctx, f.org, f.user, []string{"thread-a", "thread-b"}); err != nil {
t.Fatalf("DeleteSnoozes: %v", err)
}
back, err := repo.Search(ctx, f.org, &models.MailSearchParams{PageSize: 50})