Files
warmbly/internal/api/handler/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

312 lines
7.1 KiB
Go

package handler
import (
"net/http"
"strconv"
"strings"
"time"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"github.com/warmbly/warmbly/internal/api/middleware"
"github.com/warmbly/warmbly/internal/app/emailsend"
"github.com/warmbly/warmbly/internal/errx"
"github.com/warmbly/warmbly/internal/models"
)
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
}
// Check if organization can use unibox (paid subscription required)
if h.FeatureGateService != nil {
orgID := middleware.GetOrganizationID(c)
if orgID != nil {
canUse, _ := h.FeatureGateService.CanUseUnibox(c.Request.Context(), *orgID)
if !canUse {
errx.Handle(c, errx.New(errx.Forbidden, "Unibox requires a paid subscription"))
return
}
}
}
// Build search params from query
params := &models.MailSearchParams{
Cursor: c.Query("cursor"),
}
// Parse limit
if limitStr := c.Query("limit"); limitStr != "" {
if l, err := strconv.Atoi(limitStr); err == nil {
params.PageSize = l
}
}
// Parse sender filter
if from := c.Query("from"); from != "" {
params.Sender = &from
}
// Parse subject filter
if subject := c.Query("subject"); subject != "" {
params.Subject = &subject
}
// Parse unseen filter
if unseenStr := c.Query("unseen"); unseenStr == "true" {
unseen := true
params.Unseen = &unseen
}
// Parse date filters
if sinceStr := c.Query("since"); sinceStr != "" {
if since, err := time.Parse("2006-01-02", sinceStr); err == nil {
params.Since = &since
}
}
if untilStr := c.Query("until"); untilStr != "" {
if until, err := time.Parse("2006-01-02", untilStr); err == nil {
params.Until = &until
}
}
// Parse email account filter. Accepts either:
// - email_id=<uuid> single account (legacy)
// - email_ids=<uuid>,<uuid> comma-separated list (used by the
// tag/multi-account filter sheet)
// Invalid UUIDs are silently dropped; an empty resulting list
// behaves the same as "no account filter".
collectAccountIDs := func(raw string) {
for _, s := range strings.Split(raw, ",") {
s = strings.TrimSpace(s)
if s == "" {
continue
}
if id, err := uuid.Parse(s); err == nil {
params.EmailAccountIDs = append(params.EmailAccountIDs, id)
}
}
}
if v := c.Query("email_id"); v != "" {
collectAccountIDs(v)
}
if v := c.Query("email_ids"); v != "" {
collectAccountIDs(v)
}
resp, xerr := h.UniboxService.Search(c.Request.Context(), uid, params)
if xerr != nil {
errx.Handle(c, xerr)
return
}
c.JSON(http.StatusOK, resp)
}
func (h *Handler) GetUniboxEmail(c *gin.Context) {
userID := middleware.GetUserID(c)
uid, err := uuid.Parse(userID)
if err != nil {
errx.Handle(c, errx.ErrUser)
return
}
// Check if organization can use unibox
if h.FeatureGateService != nil {
orgID := middleware.GetOrganizationID(c)
if orgID != nil {
canUse, _ := h.FeatureGateService.CanUseUnibox(c.Request.Context(), *orgID)
if !canUse {
errx.Handle(c, errx.New(errx.Forbidden, "Unibox requires a paid subscription"))
return
}
}
}
mailID := c.Param("id")
mid, err := uuid.Parse(mailID)
if err != nil {
errx.Handle(c, errx.ErrUser)
return
}
resp, xerr := h.UniboxService.GetByID(
c.Request.Context(),
uid, mid,
)
if xerr != nil {
errx.Handle(c, xerr)
return
}
c.JSON(http.StatusOK, resp)
}
func (h *Handler) GetUniboxThread(c *gin.Context) {
userID := middleware.GetUserID(c)
uid, err := uuid.Parse(userID)
if err != nil {
errx.Handle(c, errx.ErrUser)
return
}
// Check if organization can use unibox
if h.FeatureGateService != nil {
orgID := middleware.GetOrganizationID(c)
if orgID != nil {
canUse, _ := h.FeatureGateService.CanUseUnibox(c.Request.Context(), *orgID)
if !canUse {
errx.Handle(c, errx.New(errx.Forbidden, "Unibox requires a paid subscription"))
return
}
}
}
emailID := c.Query("email")
if emailID == "" {
emailID = c.Query("email_id")
}
eid, err := uuid.Parse(emailID)
if err != nil {
errx.Handle(c, errx.ErrUuid)
return
}
threadID := c.Query("id")
if threadID == "" {
threadID = c.Query("thread_id")
}
cursor := c.Query("cursor")
limit := c.Query("limit")
resp, xerr := h.UniboxService.GetByThread(
c.Request.Context(),
uid, eid,
threadID, limit, cursor,
)
if xerr != nil {
errx.Handle(c, xerr)
return
}
c.JSON(http.StatusOK, resp)
}
func (h *Handler) UniboxMarkSeen(c *gin.Context) {
userID := middleware.GetUserID(c)
uid, err := uuid.Parse(userID)
if err != nil {
errx.Handle(c, errx.ErrUser)
return
}
var data models.MarkSeen
if err := c.ShouldBindJSON(&data); err != nil {
errx.Handle(c, errx.ErrInvalid)
return
}
resp, xerr := h.UniboxService.MarkSeenBulk(c.Request.Context(), uid, &data)
if xerr != nil {
errx.Handle(c, xerr)
return
}
c.JSON(http.StatusOK, resp)
}
// GetUnseenCount gets the count of unseen emails
// GET /unibox/count
func (h *Handler) GetUnseenCount(c *gin.Context) {
userID := middleware.GetUserID(c)
uid, err := uuid.Parse(userID)
if err != nil {
errx.Handle(c, errx.ErrUser)
return
}
// Optional email account filter
var emailAccountID *uuid.UUID
if emailIDStr := c.Query("email_id"); emailIDStr != "" {
if id, err := uuid.Parse(emailIDStr); err == nil {
emailAccountID = &id
}
}
count, xerr := h.UniboxService.GetUnseenCount(c.Request.Context(), uid, emailAccountID)
if xerr != nil {
errx.Handle(c, xerr)
return
}
c.JSON(http.StatusOK, gin.H{"count": count})
}
type UniboxReplyRequest struct {
EmailAccountID string `json:"email_account_id" binding:"required"`
To []string `json:"to" binding:"required,min=1"`
CC []string `json:"cc"`
BCC []string `json:"bcc"`
Subject string `json:"subject" binding:"required"`
BodyHTML string `json:"body_html"`
BodyPlain string `json:"body_plain"`
InReplyTo []string `json:"in_reply_to"`
ThreadID string `json:"thread_id"`
SendMode string `json:"send_mode"`
}
// UniboxReply schedules a reply email from Unibox.
// POST /unibox/reply
func (h *Handler) UniboxReply(c *gin.Context) {
orgID := middleware.GetOrganizationID(c)
if orgID == nil {
errx.Handle(c, errx.New(errx.BadRequest, "no organization selected"))
return
}
userID, err := middleware.GetUserUUID(c)
if err != nil {
errx.Handle(c, errx.ErrUser)
return
}
var req UniboxReplyRequest
if err := c.ShouldBindJSON(&req); err != nil {
errx.Handle(c, errx.ErrInvalid)
return
}
accountID, err := uuid.Parse(req.EmailAccountID)
if err != nil {
errx.Handle(c, errx.ErrUuid)
return
}
sendReq := &emailsend.SendEmailRequest{
To: req.To,
CC: req.CC,
BCC: req.BCC,
Subject: req.Subject,
BodyHTML: req.BodyHTML,
BodyPlain: req.BodyPlain,
InReplyTo: req.InReplyTo,
ThreadID: req.ThreadID,
SendMode: req.SendMode,
}
if sendReq.SendMode == "" {
sendReq.SendMode = "instant"
}
resp, xerr := h.EmailSendService.SendEmail(c.Request.Context(), userID, *orgID, accountID, sendReq)
if xerr != nil {
errx.Handle(c, xerr)
return
}
c.JSON(http.StatusOK, resp)
}