Files
warmbly/internal/repository/pg_unibox.go
T
Matthew Meszaros 5845fc3069 feat: inbox tag+multi-account filter + org gate + invite/join flow
Inbox filter:
- Backend: MailSearchParams gained EmailAccountIDs []uuid.UUID; the
  search SQL filters with `email_id = ANY($)`. /unibox handler now
  accepts both `email_id=<uuid>` (legacy) and `email_ids=<csv>`.
- Frontend: UniboxSearchParams gained accountIds[] and a UI-only
  tagId. searchIncoming sends email_ids=csv. UniboxFilterSheet:
  Accounts section is now (a) a row of tag chips backed by user.tags
  with per-tag account counts and (b) a multi-select list of every
  connected mailbox with an inline checkbox + avatar; accounts that
  belong to the active tag get a "via tag" affordance. Picking a tag
  resolves to the underlying account IDs at Apply time. "Select all"
  / "Clear" inline in the SectionBar header.

Org gate + onboarding:
- New /select-org page. Three sections: pending invitations (one-
  click Join), existing memberships (pick one to enter), and a
  Create New Workspace form (slate-900 primary). Routed at
  /select-org.
- OrgGate hook lives inside RealtimeManager. On load, if the user
  has zero orgs and no current org, it navigates to /select-org
  replace. Renders null so it doesn't displace AppLayout.

Invite + join:
- Team page rebuilt with real data: useMembers + usePendingInvitations,
  plus InviteDialog (email + role popover, slate-900 send button).
  Inline remove on member rows (skip "owner"), inline cancel on
  pending invitations.
- Pending invitations show up on /select-org too — a freshly
  invited user can accept without ever entering the dashboard first.

Response unwrapping:
- Org/member/invitation list clients now tolerate the backend's
  {data: T[] | null} envelope (it's the consistent shape across the
  Go handlers). Map nested membership rows into the flat
  Organization shape the rest of the app expects.

Seeder: re-run verified — dev@warmbly.com still gets "Dev's
Organization" so they don't bounce through /select-org.
2026-05-23 10:19:56 +00:00

385 lines
11 KiB
Go

package repository
import (
"context"
"fmt"
"strings"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
"github.com/warmbly/warmbly/internal/infrastructure/db"
"github.com/warmbly/warmbly/internal/models"
)
type UpdateUniboxEntry struct {
UID *uint32 `json:"uid"`
Flags []string `json:"flags"`
ModSeq *uint64 `json:"mod_seq"`
Mailbox *uint32 `json:"mailbox"`
}
type UniboxRepository interface {
CreateEntry(ctx context.Context, userID uuid.UUID, e *models.EmailMessageStoreData) error
UpdateEntry(ctx context.Context, userID, emailID, id uuid.UUID, e *UpdateUniboxEntry) error
GetIncoming(ctx context.Context, userID uuid.UUID, limit int, cursor string) (*models.MailSearchResult, error)
GetByID(ctx context.Context, userID, id uuid.UUID) (*models.EmailMessageStoreData, error)
GetByThread(ctx context.Context, userID, 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, userID uuid.UUID, params *models.MailSearchParams) (*models.MailSearchResult, error)
GetUnseenCount(ctx context.Context, userID uuid.UUID, emailAccountID *uuid.UUID) (int64, error)
MarkSeen(ctx context.Context, userID, id uuid.UUID, seen bool) error
MarkSeenBulk(ctx context.Context, userID uuid.UUID, ids []uuid.UUID, seen bool) error
Delete(ctx context.Context, userID, id uuid.UUID) error
}
type uniboxRepository struct {
db *db.DB
}
func NewUniboxRepository(db *db.DB) UniboxRepository {
return &uniboxRepository{db: db}
}
var mailFieldsFull = []string{
"id", "email_id", "mailbox", "thread_id", "message_id",
"gmail_id", "parent_id", "uid", "mod_seq",
"flags", "bcc", "cc", "from_addr", "in_reply_to", "reply_to",
"to_addr", "subject", "size", "internal_date", "sent_date",
"snippet", "seen", "updated_at", "created_at",
}
var mailFieldsPreview = []string{
"id", "email_id", "thread_id", "subject", "snippet", "internal_date", "seen",
}
func (r *uniboxRepository) CreateEntry(ctx context.Context, userID uuid.UUID, e *models.EmailMessageStoreData) error {
query := `
INSERT INTO unibox_emails (
id, user_id, email_id, mailbox, thread_id, message_id,
gmail_id, parent_id, uid, mod_seq,
flags, bcc, cc, from_addr, in_reply_to, reply_to,
to_addr, subject, size, internal_date, sent_date,
snippet, seen, created_at, updated_at
) VALUES (
$1, $2, $3, $4, $5, $6,
$7, $8, $9, $10,
$11, $12, $13, $14, $15, $16,
$17, $18, $19, $20, $21,
$22, $23, $24, $25
)
ON CONFLICT (id) DO NOTHING
`
_, err := r.db.Exec(ctx, query,
e.ID, userID, e.EmailID, e.Mailbox, e.ThreadID, e.MessageID,
e.GmailID, e.ParentID, e.UID, e.ModSeq,
e.Flags, e.BCC, e.CC, e.FromAddr, e.InReplyTo, e.ReplyTo,
e.ToAddr, e.Subject, e.Size, e.InternalDate, e.SentDate,
e.Snippet, e.Seen, e.CreatedAt, e.UpdatedAt,
)
return err
}
func (r *uniboxRepository) UpdateEntry(ctx context.Context, userID, emailID, id uuid.UUID, e *UpdateUniboxEntry) error {
setClauses := []string{"updated_at = NOW()"}
args := []any{userID, id}
argPos := 3
if e.Flags != nil {
setClauses = append(setClauses, fmt.Sprintf("flags = $%d", argPos))
args = append(args, e.Flags)
argPos++
}
if e.Mailbox != nil {
setClauses = append(setClauses, fmt.Sprintf("mailbox = $%d", argPos))
args = append(args, *e.Mailbox)
argPos++
}
if e.ModSeq != nil {
setClauses = append(setClauses, fmt.Sprintf("mod_seq = $%d", argPos))
args = append(args, *e.ModSeq)
argPos++
}
if e.UID != nil {
setClauses = append(setClauses, fmt.Sprintf("uid = $%d", argPos))
args = append(args, *e.UID)
argPos++
}
if argPos == 3 {
return nil // nothing to update
}
query := fmt.Sprintf(`
UPDATE unibox_emails
SET %s
WHERE user_id = $1 AND id = $2
`, strings.Join(setClauses, ", "))
_, err := r.db.Exec(ctx, query, args...)
return err
}
func (r *uniboxRepository) GetIncoming(ctx context.Context, userID uuid.UUID, limit int, cursor string) (*models.MailSearchResult, error) {
query := fmt.Sprintf(`
SELECT %s
FROM unibox_emails
WHERE user_id = $1
`, strings.Join(mailFieldsPreview, ", "))
args := []any{userID}
argPos := 2
if cursor != "" {
cursorID, err := uuid.Parse(cursor)
if err == nil {
query += fmt.Sprintf(`
AND (internal_date, id) < (
SELECT internal_date, id FROM unibox_emails WHERE id = $%d
)`, argPos)
args = append(args, cursorID)
argPos++
}
}
query += fmt.Sprintf(` ORDER BY internal_date DESC, id DESC LIMIT $%d`, argPos)
args = append(args, limit+1)
return r.queryPreviewList(ctx, query, args, limit)
}
func (r *uniboxRepository) GetByID(ctx context.Context, userID, id uuid.UUID) (*models.EmailMessageStoreData, error) {
query := fmt.Sprintf(`
SELECT %s
FROM unibox_emails
WHERE user_id = $1 AND id = $2
`, strings.Join(mailFieldsFull, ", "))
var e models.EmailMessageStoreData
err := r.db.QueryRow(ctx, query, userID, id).Scan(
&e.ID, &e.EmailID, &e.Mailbox, &e.ThreadID, &e.MessageID,
&e.GmailID, &e.ParentID, &e.UID, &e.ModSeq,
&e.Flags, &e.BCC, &e.CC, &e.FromAddr, &e.InReplyTo, &e.ReplyTo,
&e.ToAddr, &e.Subject, &e.Size, &e.InternalDate, &e.SentDate,
&e.Snippet, &e.Seen, &e.UpdatedAt, &e.CreatedAt,
)
if err != nil {
if err == pgx.ErrNoRows {
return nil, fmt.Errorf("email not found")
}
return nil, err
}
// Auto-mark as seen
if !e.Seen {
_ = r.MarkSeen(ctx, userID, id, true)
e.Seen = true
}
return &e, nil
}
func (r *uniboxRepository) GetByThread(ctx context.Context, userID, emailID uuid.UUID, threadID string, limit int, cursor string) (*models.MailSearchResult, error) {
query := fmt.Sprintf(`
SELECT %s
FROM unibox_emails
WHERE user_id = $1 AND email_id = $2 AND thread_id = $3
`, strings.Join(mailFieldsPreview, ", "))
args := []any{userID, emailID, threadID}
argPos := 4
if cursor != "" {
cursorID, err := uuid.Parse(cursor)
if err == nil {
query += fmt.Sprintf(`
AND (internal_date, id) < (
SELECT internal_date, id FROM unibox_emails WHERE id = $%d
)`, argPos)
args = append(args, cursorID)
argPos++
}
}
query += fmt.Sprintf(` ORDER BY internal_date DESC, id DESC LIMIT $%d`, argPos)
args = append(args, limit+1)
return r.queryPreviewList(ctx, query, args, limit)
}
func (r *uniboxRepository) GetBySender(ctx context.Context, userID uuid.UUID, sender string, limit int, cursor string) (*models.MailSearchResult, error) {
query := fmt.Sprintf(`
SELECT %s
FROM unibox_emails
WHERE user_id = $1 AND $2 = ANY(from_addr)
`, strings.Join(mailFieldsPreview, ", "))
args := []any{userID, sender}
argPos := 3
if cursor != "" {
cursorID, err := uuid.Parse(cursor)
if err == nil {
query += fmt.Sprintf(`
AND (internal_date, id) < (
SELECT internal_date, id FROM unibox_emails WHERE id = $%d
)`, argPos)
args = append(args, cursorID)
argPos++
}
}
query += fmt.Sprintf(` ORDER BY internal_date DESC, id DESC LIMIT $%d`, argPos)
args = append(args, limit+1)
return r.queryPreviewList(ctx, query, args, limit)
}
func (r *uniboxRepository) Search(ctx context.Context, userID uuid.UUID, params *models.MailSearchParams) (*models.MailSearchResult, error) {
query := fmt.Sprintf(`
SELECT %s
FROM unibox_emails
WHERE user_id = $1
`, strings.Join(mailFieldsPreview, ", "))
args := []any{userID}
argPos := 2
if params.Unseen != nil && *params.Unseen {
query += fmt.Sprintf(` AND seen = $%d`, argPos)
args = append(args, false)
argPos++
}
if params.Since != nil {
query += fmt.Sprintf(` AND internal_date >= $%d`, argPos)
args = append(args, *params.Since)
argPos++
}
if params.Until != nil {
query += fmt.Sprintf(` AND internal_date <= $%d`, argPos)
args = append(args, *params.Until)
argPos++
}
if params.Subject != nil && *params.Subject != "" {
query += fmt.Sprintf(` AND search_tsv @@ plainto_tsquery('english', $%d)`, argPos)
args = append(args, *params.Subject)
argPos++
}
if params.Sender != nil && *params.Sender != "" {
query += fmt.Sprintf(` AND $%d = ANY(from_addr)`, argPos)
args = append(args, *params.Sender)
argPos++
}
if len(params.EmailAccountIDs) > 0 {
query += fmt.Sprintf(` AND email_id = ANY($%d)`, argPos)
args = append(args, params.EmailAccountIDs)
argPos++
}
if params.Cursor != "" {
cursorID, err := uuid.Parse(params.Cursor)
if err == nil {
query += fmt.Sprintf(`
AND (internal_date, id) < (
SELECT internal_date, id FROM unibox_emails WHERE id = $%d
)`, argPos)
args = append(args, cursorID)
argPos++
}
}
query += fmt.Sprintf(` ORDER BY internal_date DESC, id DESC LIMIT $%d`, argPos)
args = append(args, params.PageSize+1)
return r.queryPreviewList(ctx, query, args, params.PageSize)
}
func (r *uniboxRepository) GetUnseenCount(ctx context.Context, userID uuid.UUID, emailAccountID *uuid.UUID) (int64, error) {
var count int64
if emailAccountID != nil {
err := r.db.QueryRow(ctx,
`SELECT COUNT(*) FROM unibox_emails WHERE user_id = $1 AND email_id = $2 AND seen = FALSE`,
userID, *emailAccountID,
).Scan(&count)
return count, err
}
err := r.db.QueryRow(ctx,
`SELECT COUNT(*) FROM unibox_emails WHERE user_id = $1 AND seen = FALSE`,
userID,
).Scan(&count)
return count, err
}
func (r *uniboxRepository) MarkSeen(ctx context.Context, userID, id uuid.UUID, seen bool) error {
_, err := r.db.Exec(ctx,
`UPDATE unibox_emails SET seen = $1, updated_at = NOW() WHERE user_id = $2 AND id = $3`,
seen, userID, id,
)
return err
}
func (r *uniboxRepository) MarkSeenBulk(ctx context.Context, userID uuid.UUID, ids []uuid.UUID, seen bool) error {
if len(ids) == 0 {
return nil
}
if len(ids) == 1 {
return r.MarkSeen(ctx, userID, ids[0], seen)
}
_, err := r.db.Exec(ctx,
`UPDATE unibox_emails SET seen = $1, updated_at = NOW() WHERE user_id = $2 AND id = ANY($3)`,
seen, userID, ids,
)
return err
}
func (r *uniboxRepository) Delete(ctx context.Context, userID, id uuid.UUID) error {
_, err := r.db.Exec(ctx,
`DELETE FROM unibox_emails WHERE user_id = $1 AND id = $2`,
userID, id,
)
return err
}
// queryPreviewList executes a query returning preview rows with limit+1 pagination.
func (r *uniboxRepository) queryPreviewList(ctx context.Context, query string, args []any, limit int) (*models.MailSearchResult, error) {
rows, err := r.db.Query(ctx, query, args...)
if err != nil {
return nil, err
}
defer rows.Close()
emails := make([]models.EmailMessageStoreDataPreview, 0, limit)
for rows.Next() {
var e models.EmailMessageStoreDataPreview
if err := rows.Scan(&e.ID, &e.EmailID, &e.ThreadID, &e.Subject, &e.Snippet, &e.InternalDate, &e.Seen); err != nil {
return nil, err
}
emails = append(emails, e)
}
var hasMore bool
var nextCursor *string
if len(emails) > limit {
hasMore = true
cursor := emails[limit].ID.String()
nextCursor = &cursor
emails = emails[:limit]
}
return &models.MailSearchResult{
Data: emails,
Pagination: models.CPagination{
HasMore: hasMore,
NextCursor: nextCursor,
},
}, nil
}