mirror of
https://github.com/warmbly/warmbly.git
synced 2026-09-12 00:05:09 +00:00
Merge remote-tracking branch 'origin/main' into fix/scanner-timing-window
This commit is contained in:
@@ -185,6 +185,7 @@ A source's `column_mapping` is validated when the source is written, not on its
|
||||
| GET | `/unibox/thread` | `READ_UNIBOX` |
|
||||
| GET | `/unibox/:id` | `READ_UNIBOX` |
|
||||
| PATCH | `/unibox/seen` | `WRITE_UNIBOX` |
|
||||
| PATCH | `/unibox/folder` | `WRITE_UNIBOX` |
|
||||
| POST | `/unibox/reply` | `WRITE_UNIBOX` |
|
||||
| POST | `/unibox/reply/draft` | `READ_UNIBOX` |
|
||||
| GET | `/unibox/compose/candidates` | `READ_UNIBOX` |
|
||||
@@ -197,6 +198,8 @@ A source's `column_mapping` is validated when the source is written, not on its
|
||||
| POST | `/unibox/agent-drafts/:id/approve` | `WRITE_UNIBOX` |
|
||||
| POST | `/unibox/agent-drafts/:id/discard` | `WRITE_UNIBOX` |
|
||||
|
||||
`PATCH /unibox/folder` re-files up to 500 messages into `inbox`, `archive` or `trash` at once. The move is Warmbly's own: the copy at the mail provider stays where it is, and the next sync will not undo it, because the provider's placement is tracked separately and followed only when the provider itself moves the message. `sent`, `drafts` and `spam` are placements a provider reaches rather than somewhere a person files mail, so they are rejected with a `400`. See [filing a conversation](/guides/unibox/#filing-a-conversation).
|
||||
|
||||
`POST /unibox/reply/draft` returns an AI-drafted reply (it never sends) grounded in the thread, the contact, and your [voice profile](/guides/unibox/#ai-reply-drafts). It charges AI credits; see [AI credits](/guides/ai-credits/).
|
||||
|
||||
`POST /unibox/compose/draft` returns a grounded AI draft for a new email (it never sends): the recipient's contact record, correspondence history, and the workspace voice profile feed the prompt, and the response carries either `text` or a clarifying `question` plus a `grounding` report. Charges AI credits like the reply draft; see [AI credits](/guides/ai-credits/).
|
||||
|
||||
@@ -14,11 +14,11 @@ The rail opens with the standard mail folders, so direction is visually clear in
|
||||
| Inbox | Inbound mail, plus anything filed in a custom folder at the provider |
|
||||
| Drafts | Messages sitting in a mailbox's drafts folder |
|
||||
| Sent | Outbound mail, campaign and manual |
|
||||
| Archive | Archived at the provider (Gmail's All Mail, the Archive folder elsewhere) |
|
||||
| Archive | Archived (Gmail's All Mail, the Archive folder elsewhere) |
|
||||
| Spam | Junked at the provider |
|
||||
| Trash | Deleted at the provider |
|
||||
| Trash | Deleted |
|
||||
|
||||
Each message's folder follows the provider: IMAP special-use folder attributes, Gmail labels, and Outlook well-known folders all map to the same six. Moves at the provider (junking a message, clearing it out of spam) follow on the next sync. The active folder is highlighted with a grey row and bold label, unread counts sit on the right, and each folder's three-dot menu offers **Mark all as read**.
|
||||
Each message starts in the folder the provider has it in: IMAP special-use folder attributes, Gmail labels, and Outlook well-known folders all map to the same six. Moves at the provider (junking a message, clearing it out of spam) follow on the next sync. You can also file a conversation yourself, which moves it here without moving it at the provider; see [filing a conversation](#filing-a-conversation). The active folder is highlighted with a grey row and bold label, unread counts sit on the right, and each folder's three-dot menu offers **Mark all as read**.
|
||||
|
||||
Spam and Trash stay out of every other view: the **All** scope, the metric strip, and the unread badge only count the folders you actually work.
|
||||
|
||||
@@ -77,6 +77,22 @@ Categories label conversations. Tags label the mailboxes themselves (grouping ac
|
||||
|
||||
A conversation is unread when any message inside is unseen, marked by a bright left edge bar and bolder text. Opening it marks its messages seen and updates the counts. Read state syncs both ways with the mailbox provider.
|
||||
|
||||
## Filing a conversation
|
||||
|
||||
The thread header carries three filing actions, on the row above the message on a wide screen and behind the three-dot menu on a narrow one:
|
||||
|
||||
| Action | Does |
|
||||
| --- | --- |
|
||||
| Mark as unread | Puts every message in the conversation back to unread and closes it |
|
||||
| Archive | Moves the conversation to Archive, so it leaves Inbox |
|
||||
| Delete | Moves the conversation to Trash, so it leaves every view except Trash |
|
||||
|
||||
Archive and Delete both offer **Undo** on the confirmation toast. Open the Trash or Archive folder and the same header offers **Move to inbox**, so nothing filed by accident is stuck.
|
||||
|
||||
<Callout type="info" title="Filing here does not move the message at the provider">
|
||||
Archive and Delete are Warmbly's own filing. The message keeps its place in Gmail, Outlook, or whatever mail client the mailbox belongs to, and deleting a conversation here never deletes mail there. The next sync will not undo your filing either: Warmbly records where the provider has each message separately from where you filed it, and follows the provider only when the provider itself moves the message. So junking a message in Gmail still reaches Warmbly, and an ordinary sync pass does not.
|
||||
</Callout>
|
||||
|
||||
## Replying
|
||||
|
||||
Reply, forward, or hover any single message to reply to it specifically. The composer only appears when you ask for it. Replies go from the mailbox that owns the thread, so conversations stay on one account. You can apply a saved **template** or **Insert booking link**.
|
||||
|
||||
@@ -8215,6 +8215,88 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"/unibox/folder": {
|
||||
"patch": {
|
||||
"operationId": "unibox_move_folder",
|
||||
"summary": "Move messages between folders",
|
||||
"description": "Re-files a batch of messages into Inbox, Archive or Trash, org-wide. Up to 500 ids per call.\n\nThis is a move in Warmbly only. The copy at the mail provider stays where it is, and a later sync will not undo the move: Warmbly tracks the provider's own placement separately and follows it only when the provider itself moves the message. `sent`, `drafts` and `spam` are placements the provider reaches, so they are rejected here with a `400`.",
|
||||
"tags": [
|
||||
"unibox"
|
||||
],
|
||||
"security": [
|
||||
{
|
||||
"bearerAuth": []
|
||||
}
|
||||
],
|
||||
"parameters": [
|
||||
{
|
||||
"$ref": "#/components/parameters/IdempotencyKey"
|
||||
}
|
||||
],
|
||||
"requestBody": {
|
||||
"required": true,
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/UniboxMoveFolderRequest"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Echoes the request back.",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/UniboxMoveFolderRequest"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "Invalid body, more than 500 ids, a folder outside inbox/archive/trash, or no organization selected.",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/Error"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"description": "Missing or invalid credentials.",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/Error"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"403": {
|
||||
"description": "Missing scope/permission, or organization lacks unified-inbox access.",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/Error"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"429": {
|
||||
"description": "Rate limited.",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/Error"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/unibox/reply": {
|
||||
"post": {
|
||||
"operationId": "unibox_reply",
|
||||
@@ -23777,6 +23859,34 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"UniboxMoveFolderRequest": {
|
||||
"type": "object",
|
||||
"description": "Also the echoed response body.",
|
||||
"required": [
|
||||
"email_ids",
|
||||
"folder"
|
||||
],
|
||||
"properties": {
|
||||
"email_ids": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string",
|
||||
"format": "uuid"
|
||||
},
|
||||
"maxItems": 500,
|
||||
"description": "Message UUIDs to move (max 500)."
|
||||
},
|
||||
"folder": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"inbox",
|
||||
"archive",
|
||||
"trash"
|
||||
],
|
||||
"description": "Destination folder. Anything else is a `400`."
|
||||
}
|
||||
}
|
||||
},
|
||||
"UniboxReplyRequest": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
|
||||
@@ -387,6 +387,44 @@ func (h *Handler) UniboxMarkSeen(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, resp)
|
||||
}
|
||||
|
||||
// UniboxMoveFolder re-files messages (Archive = archive, Delete = trash,
|
||||
// Move to inbox = inbox). Org-scoped like /seen: the inbox is shared, so any
|
||||
// member with unibox access may file it. Naturally idempotent, so no
|
||||
// Idempotency-Key: the body names the destination, not a delta.
|
||||
// PATCH /unibox/folder
|
||||
func (h *Handler) UniboxMoveFolder(c *gin.Context) {
|
||||
if !h.gateUnibox(c) {
|
||||
return
|
||||
}
|
||||
orgID := middleware.GetOrganizationID(c)
|
||||
if orgID == nil {
|
||||
errx.Handle(c, errx.ErrUser)
|
||||
return
|
||||
}
|
||||
|
||||
var data models.MoveFolder
|
||||
if err := c.ShouldBindJSON(&data); err != nil {
|
||||
errx.Handle(c, errx.ErrInvalid)
|
||||
return
|
||||
}
|
||||
|
||||
resp, xerr := h.UniboxService.MoveFolderBulk(c.Request.Context(), *orgID, &data)
|
||||
if xerr != nil {
|
||||
errx.Handle(c, xerr)
|
||||
return
|
||||
}
|
||||
|
||||
// Audited so the spine broadcasts it: a teammate looking at the same list
|
||||
// has to lose the thread too, and there is no sync event behind this one.
|
||||
h.auditOrg(c, models.AuditActionUpdate, models.AuditEntityUnibox, nil, nil, map[string]string{
|
||||
"action": "move_folder",
|
||||
"folder": data.Folder,
|
||||
"messages": strconv.Itoa(len(data.EmailIDs)),
|
||||
})
|
||||
|
||||
c.JSON(http.StatusOK, resp)
|
||||
}
|
||||
|
||||
// GetUnseenCount gets the count of unseen emails
|
||||
// GET /unibox/count
|
||||
func (h *Handler) GetUnseenCount(c *gin.Context) {
|
||||
|
||||
@@ -758,6 +758,7 @@ func Run(
|
||||
unibox.PUT("/thread/labels", m.RequireAccess(models.PermAccessUnibox, models.APIPermWriteUnibox), h.SetUniboxThreadLabels)
|
||||
|
||||
unibox.PATCH("/seen", m.RequireAccess(models.PermAccessUnibox, models.APIPermWriteUnibox), h.UniboxMarkSeen)
|
||||
unibox.PATCH("/folder", m.RequireAccess(models.PermAccessUnibox, models.APIPermWriteUnibox), h.UniboxMoveFolder)
|
||||
unibox.POST("/reply", m.RequireOrganization(), m.RequireAccess(models.PermAccessUnibox, models.APIPermWriteUnibox), h.UniboxReply)
|
||||
// Compose: send a brand-new outbound email. The candidates
|
||||
// endpoint scores mailboxes for a recipient (affinity, budget,
|
||||
|
||||
@@ -35,10 +35,14 @@ func (s *JobsService) HandleUpdateEmail(ctx context.Context, e *models.JobEventE
|
||||
if e.FolderPath != "" && email.FolderPath != e.FolderPath {
|
||||
updateData.FolderPath = &e.FolderPath
|
||||
}
|
||||
// A folder move follows the provider. Events from workers predating the
|
||||
// field carry "", which keeps the stored value.
|
||||
if models.ValidFolder(e.Folder) && email.Folder != e.Folder {
|
||||
updateData.Folder = &e.Folder
|
||||
// The store follows the provider only when the provider itself moved the
|
||||
// message; a scan that keeps naming the same folder leaves local filing be.
|
||||
folder, provider, providerMoved := models.ResolveFolderSync(email.Folder, email.ProviderFolder, e.Folder)
|
||||
if providerMoved {
|
||||
updateData.ProviderFolder = &provider
|
||||
if folder != email.Folder {
|
||||
updateData.Folder = &folder
|
||||
}
|
||||
}
|
||||
|
||||
if err := s.UniboxRepository.UpdateEntry(ctx, e.UserID, e.EmailID, e.ID, &updateData); err != nil {
|
||||
@@ -49,8 +53,9 @@ func (s *JobsService) HandleUpdateEmail(ctx context.Context, e *models.JobEventE
|
||||
email.UID = e.UID
|
||||
email.Mailbox = e.Mailbox
|
||||
email.ModSeq = e.ModSeq
|
||||
if models.ValidFolder(e.Folder) {
|
||||
email.Folder = e.Folder
|
||||
if providerMoved {
|
||||
email.Folder = folder
|
||||
email.ProviderFolder = provider
|
||||
}
|
||||
s.publishEmailUpdated(ctx, e.UserID, email)
|
||||
return nil
|
||||
|
||||
@@ -641,9 +641,10 @@ func (s *organizationService) GetInvitationToken(ctx context.Context, orgID, inv
|
||||
// acceptResolved performs the actual join given an already-loaded invitation.
|
||||
func (s *organizationService) acceptResolved(ctx context.Context, inv *models.OrganizationInvitation, userID uuid.UUID, email string) (*models.OrganizationMember, *errx.Error) {
|
||||
|
||||
// Verify email matches
|
||||
if strings.ToLower(email) != strings.ToLower(inv.Email) {
|
||||
return nil, errx.New(errx.Forbidden, "email does not match invitation")
|
||||
// Verify email matches. Name both addresses: the usual cause is a browser
|
||||
// already signed in as someone else, and a bare "does not match" hides it.
|
||||
if !strings.EqualFold(email, inv.Email) {
|
||||
return nil, errx.New(errx.Forbidden, "this invitation is for "+inv.Email+", but you are signed in as "+email+"; sign out and use the invited address")
|
||||
}
|
||||
|
||||
// Check if invitation is expired
|
||||
|
||||
@@ -46,3 +46,22 @@ func (s *uniboxService) MarkSeenBulk(ctx context.Context, orgID uuid.UUID, data
|
||||
|
||||
return data, nil
|
||||
}
|
||||
|
||||
// MoveFolderBulk backs Archive, Delete and Move to inbox in the thread header.
|
||||
// Store-side only: the provider copy stays where it is, and provider_folder is
|
||||
// left alone so the sync can still tell a real provider move from a flag scan.
|
||||
func (s *uniboxService) MoveFolderBulk(ctx context.Context, orgID uuid.UUID, data *models.MoveFolder) (*models.MoveFolder, *errx.Error) {
|
||||
if len(data.EmailIDs) > 500 {
|
||||
return nil, errx.ErrSeenMax
|
||||
}
|
||||
// Only the three a user can file into. sent/drafts/spam are verdicts the
|
||||
// provider reaches, and accepting them here would let a caller forge one.
|
||||
if !models.FilableFolder(data.Folder) {
|
||||
return nil, errx.ErrUniboxFilableFolder
|
||||
}
|
||||
if err := s.uniboxRepository.MoveToFolderBulk(ctx, orgID, data.EmailIDs, data.Folder); err != nil {
|
||||
errs.CaptureException(err)
|
||||
return nil, errx.InternalError()
|
||||
}
|
||||
return data, nil
|
||||
}
|
||||
|
||||
@@ -45,6 +45,7 @@ type UniboxService interface {
|
||||
) (int64, *errx.Error)
|
||||
MarkSeen(ctx context.Context, userID, emailID uuid.UUID, seen bool) *errx.Error
|
||||
MarkSeenBulk(ctx context.Context, orgID uuid.UUID, data *models.MarkSeen) (*models.MarkSeen, *errx.Error)
|
||||
MoveFolderBulk(ctx context.Context, orgID uuid.UUID, data *models.MoveFolder) (*models.MoveFolder, *errx.Error)
|
||||
|
||||
// Snooze hides a thread until `until`. Unsnooze drops the row.
|
||||
Snooze(ctx context.Context, userID uuid.UUID, threadID string, until time.Time) (*models.UniboxSnooze, *errx.Error)
|
||||
|
||||
@@ -185,6 +185,9 @@ var (
|
||||
// Folder scoping (unibox sidebar).
|
||||
ErrUniboxFolder = New(BadRequest, "Folder must be one of inbox, sent, drafts, archive, spam, trash.")
|
||||
ErrSeenFolderAndIDs = New(BadRequest, "Provide either email_ids or folder, not both.")
|
||||
// Filing a message is narrower than scoping a list: the other three are
|
||||
// verdicts the provider reaches, not somewhere a user puts mail.
|
||||
ErrUniboxFilableFolder = New(BadRequest, "Folder must be one of inbox, archive, trash.")
|
||||
|
||||
// Servers
|
||||
ErrIPAddr = New(BadRequest, "Invalid IP Address.")
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
-- Back to one folder column. Any message the user filed in Warmbly rather
|
||||
-- than at the provider keeps that placement until the next provider move
|
||||
-- overwrites it, which is the pre-split behaviour.
|
||||
ALTER TABLE public.unibox_emails
|
||||
DROP CONSTRAINT IF EXISTS unibox_emails_provider_folder_check;
|
||||
|
||||
ALTER TABLE public.unibox_emails
|
||||
DROP COLUMN IF EXISTS provider_folder;
|
||||
@@ -0,0 +1,35 @@
|
||||
-- Where the PROVIDER has the message, tracked apart from where Warmbly shows
|
||||
-- it, so the two can disagree.
|
||||
--
|
||||
-- Until now unibox_emails.folder was both at once: the sync wrote the
|
||||
-- provider's placement into it and every read treated it as the placement.
|
||||
-- That is fine while the provider is the only thing that files mail, and it
|
||||
-- stops being fine the moment the thread header can Archive or Delete a
|
||||
-- conversation. Those actions move the message here and not at the provider,
|
||||
-- so the next flag scan of the provider's INBOX reports inbox again and pulls
|
||||
-- it straight back out.
|
||||
--
|
||||
-- Suppressing "provider says inbox" outright would be worse: it is also what
|
||||
-- an ordinary un-archive at the provider looks like, and refusing it would
|
||||
-- strand the message here forever. With both values stored, the rule is
|
||||
-- exact. The store follows the provider only when the PROVIDER's folder
|
||||
-- actually changed from the one last observed; a scan that keeps reporting
|
||||
-- the same folder changes nothing, and a local filing survives it.
|
||||
--
|
||||
-- Cost note: unibox_emails holds every synced message, so the backfill below
|
||||
-- is the expensive part of this migration. The backend applies migrations at
|
||||
-- boot inside one transaction and blocks until they finish, so deploy this in
|
||||
-- a window rather than alongside traffic. ADD COLUMN with a constant DEFAULT
|
||||
-- is metadata-only on PG 11+ and is not itself a rewrite.
|
||||
ALTER TABLE public.unibox_emails
|
||||
ADD COLUMN provider_folder text NOT NULL DEFAULT '';
|
||||
|
||||
ALTER TABLE public.unibox_emails
|
||||
ADD CONSTRAINT unibox_emails_provider_folder_check
|
||||
CHECK (provider_folder IN ('', 'inbox', 'sent', 'drafts', 'archive', 'spam', 'trash'));
|
||||
|
||||
-- Every existing row was filed by the provider and by nothing else, so the
|
||||
-- two values start out equal and no historical message reads as locally
|
||||
-- filed. '' stays reachable for a row written by a consumer that predates
|
||||
-- this column; the handler treats it as "never observed" and adopts.
|
||||
UPDATE public.unibox_emails SET provider_folder = folder;
|
||||
+59
-20
@@ -105,26 +105,31 @@ type EmailMessageStoreData struct {
|
||||
// Folder is the canonical folder (see the Folder* constants) the message
|
||||
// was in at sync time. Empty on events from workers predating the field;
|
||||
// the consumer normalizes before storing.
|
||||
Folder string `json:"folder,omitempty"`
|
||||
ThreadID string `json:"thread_id"`
|
||||
MessageID string `json:"message_id"`
|
||||
GmailID string `json:"gmail_id"`
|
||||
ParentID string `json:"parent_id"`
|
||||
UID uint32 `json:"uid"`
|
||||
ModSeq uint64 `json:"mod_seq"`
|
||||
Flags []string `json:"flags"`
|
||||
BCC []string `json:"bcc"`
|
||||
CC []string `json:"cc"`
|
||||
FromAddr []string `json:"from_addr"`
|
||||
InReplyTo []string `json:"in_reply_to"`
|
||||
ReplyTo []string `json:"reply_to"`
|
||||
ToAddr []string `json:"to_addr"`
|
||||
Subject string `json:"subject"`
|
||||
Size int64 `json:"size"`
|
||||
InternalDate time.Time `json:"internal_date"`
|
||||
SentDate time.Time `json:"sent_date"`
|
||||
Snippet string `json:"snippet"`
|
||||
Seen bool `json:"seen"`
|
||||
Folder string `json:"folder,omitempty"`
|
||||
// ProviderFolder is where the PROVIDER last reported the message, which
|
||||
// Folder stops tracking once the user files the message in Warmbly. The
|
||||
// two are compared to tell a real provider move from a flag scan that
|
||||
// keeps naming the folder the provider still has it in.
|
||||
ProviderFolder string `json:"provider_folder,omitempty"`
|
||||
ThreadID string `json:"thread_id"`
|
||||
MessageID string `json:"message_id"`
|
||||
GmailID string `json:"gmail_id"`
|
||||
ParentID string `json:"parent_id"`
|
||||
UID uint32 `json:"uid"`
|
||||
ModSeq uint64 `json:"mod_seq"`
|
||||
Flags []string `json:"flags"`
|
||||
BCC []string `json:"bcc"`
|
||||
CC []string `json:"cc"`
|
||||
FromAddr []string `json:"from_addr"`
|
||||
InReplyTo []string `json:"in_reply_to"`
|
||||
ReplyTo []string `json:"reply_to"`
|
||||
ToAddr []string `json:"to_addr"`
|
||||
Subject string `json:"subject"`
|
||||
Size int64 `json:"size"`
|
||||
InternalDate time.Time `json:"internal_date"`
|
||||
SentDate time.Time `json:"sent_date"`
|
||||
Snippet string `json:"snippet"`
|
||||
Seen bool `json:"seen"`
|
||||
// BodyText is a bounded plain-text rendering of the message, carried on the
|
||||
// new-email event so the consumer can make the message findable by what it
|
||||
// says. The full body goes to object storage, never here.
|
||||
@@ -212,6 +217,32 @@ func ValidFolder(f string) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
// ResolveFolderSync decides what one sync event does to a message's placement.
|
||||
//
|
||||
// reported is compared against the folder the PROVIDER was last seen to have
|
||||
// the message in, never against the stored folder. A message the user filed in
|
||||
// Warmbly still turns up in the provider's inbox on every scan, and comparing
|
||||
// against the stored folder would read each of those as a move back and undo
|
||||
// them. An empty storedProvider is a row written before the column existed
|
||||
// (migration 000146), so it adopts. An invalid reported folder is a worker
|
||||
// predating the field and changes nothing.
|
||||
func ResolveFolderSync(storedFolder, storedProvider, reported string) (folder, provider string, changed bool) {
|
||||
if !ValidFolder(reported) || reported == storedProvider {
|
||||
return storedFolder, storedProvider, false
|
||||
}
|
||||
return reported, reported, true
|
||||
}
|
||||
|
||||
// FilableFolder reports whether f is a folder a user may move mail INTO from
|
||||
// the unibox. sent/drafts/spam are provider verdicts, never a user's choice.
|
||||
func FilableFolder(f string) bool {
|
||||
switch f {
|
||||
case FolderInbox, FolderArchive, FolderTrash:
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// NormalizeFolder resolves the folder to persist for a message: the worker's
|
||||
// value when it sent a valid one, otherwise a flag-derived fallback so events
|
||||
// from workers predating the folder field still file spam and drafts sanely.
|
||||
@@ -297,6 +328,14 @@ type MarkSeen struct {
|
||||
Seen bool `json:"seen"`
|
||||
}
|
||||
|
||||
// MoveFolder re-files messages into one canonical folder (Archive = archive,
|
||||
// Delete = trash, Move to inbox = inbox). Store-side only: the provider copy
|
||||
// is not moved, so the message stays where it is in the user's mail client.
|
||||
type MoveFolder struct {
|
||||
EmailIDs []uuid.UUID `json:"email_ids"`
|
||||
Folder string `json:"folder"`
|
||||
}
|
||||
|
||||
// UniboxSnooze hides a thread from the user's inbox until SnoozedUntil
|
||||
// passes. UNIQUE per (user, thread); a second snooze on the same
|
||||
// thread updates SnoozedUntil in place.
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
package models
|
||||
|
||||
import "testing"
|
||||
|
||||
// The rule that makes Archive/Delete in the thread header survive a sync.
|
||||
// Every case below is a real event shape the consumer sees; the third is the
|
||||
// one the feature exists for, and the fourth is the one a naive guard breaks.
|
||||
func TestResolveFolderSync(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
storedFolder string
|
||||
storedProvider string
|
||||
reported string
|
||||
wantFolder string
|
||||
wantProvider string
|
||||
wantChanged bool
|
||||
}{
|
||||
{
|
||||
name: "a worker predating the folder field changes nothing",
|
||||
storedFolder: FolderInbox, storedProvider: FolderInbox, reported: "",
|
||||
wantFolder: FolderInbox, wantProvider: FolderInbox, wantChanged: false,
|
||||
},
|
||||
{
|
||||
name: "an unknown folder changes nothing",
|
||||
storedFolder: FolderInbox, storedProvider: FolderInbox, reported: "starred",
|
||||
wantFolder: FolderInbox, wantProvider: FolderInbox, wantChanged: false,
|
||||
},
|
||||
{
|
||||
name: "a flag scan does not undo a message filed in Warmbly",
|
||||
storedFolder: FolderTrash, storedProvider: FolderInbox, reported: FolderInbox,
|
||||
wantFolder: FolderTrash, wantProvider: FolderInbox, wantChanged: false,
|
||||
},
|
||||
{
|
||||
name: "the provider moving it out of the inbox still wins",
|
||||
storedFolder: FolderArchive, storedProvider: FolderInbox, reported: FolderSpam,
|
||||
wantFolder: FolderSpam, wantProvider: FolderSpam, wantChanged: true,
|
||||
},
|
||||
{
|
||||
name: "un-archiving at the provider reaches a locally archived message",
|
||||
storedFolder: FolderArchive, storedProvider: FolderArchive, reported: FolderInbox,
|
||||
wantFolder: FolderInbox, wantProvider: FolderInbox, wantChanged: true,
|
||||
},
|
||||
{
|
||||
name: "an ordinary provider move is followed",
|
||||
storedFolder: FolderInbox, storedProvider: FolderInbox, reported: FolderTrash,
|
||||
wantFolder: FolderTrash, wantProvider: FolderTrash, wantChanged: true,
|
||||
},
|
||||
{
|
||||
name: "a row written before provider_folder existed adopts",
|
||||
storedFolder: FolderInbox, storedProvider: "", reported: FolderInbox,
|
||||
wantFolder: FolderInbox, wantProvider: FolderInbox, wantChanged: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
folder, provider, changed := ResolveFolderSync(tc.storedFolder, tc.storedProvider, tc.reported)
|
||||
if folder != tc.wantFolder || provider != tc.wantProvider || changed != tc.wantChanged {
|
||||
t.Fatalf("ResolveFolderSync(%q, %q, %q) = (%q, %q, %v), want (%q, %q, %v)",
|
||||
tc.storedFolder, tc.storedProvider, tc.reported,
|
||||
folder, provider, changed, tc.wantFolder, tc.wantProvider, tc.wantChanged)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestFilableFolderRefusesProviderVerdicts(t *testing.T) {
|
||||
for _, f := range []string{FolderInbox, FolderArchive, FolderTrash} {
|
||||
if !FilableFolder(f) {
|
||||
t.Errorf("FilableFolder(%q) = false, want true", f)
|
||||
}
|
||||
}
|
||||
for _, f := range []string{FolderSent, FolderDrafts, FolderSpam, "", "Inbox"} {
|
||||
if FilableFolder(f) {
|
||||
t.Errorf("FilableFolder(%q) = true, want false", f)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -22,6 +22,9 @@ type UpdateUniboxEntry struct {
|
||||
// FolderPath is the source folder's name, the folder's identity.
|
||||
FolderPath *string `json:"folder_path"`
|
||||
Folder *string `json:"folder"`
|
||||
// ProviderFolder is the provider's own placement. It moves on every real
|
||||
// provider move; Folder only follows when the message was not filed here.
|
||||
ProviderFolder *string `json:"provider_folder"`
|
||||
}
|
||||
|
||||
type UniboxRepository interface {
|
||||
@@ -44,6 +47,9 @@ type UniboxRepository interface {
|
||||
// MarkSeenByFolder flips the read state of every message in one canonical
|
||||
// folder for the whole workspace (the sidebar's "mark all as read").
|
||||
MarkSeenByFolder(ctx context.Context, orgID uuid.UUID, folder string, seen bool) error
|
||||
// MoveToFolderBulk re-files the given messages into one canonical folder,
|
||||
// org-scoped like MarkSeenBulk.
|
||||
MoveToFolderBulk(ctx context.Context, orgID uuid.UUID, ids []uuid.UUID, folder string) error
|
||||
Delete(ctx context.Context, userID, id uuid.UUID) error
|
||||
|
||||
// Snooze: per (user, thread). UpsertSnooze adopts the new
|
||||
@@ -111,7 +117,7 @@ var mailFieldsFull = []string{
|
||||
"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", "folder",
|
||||
"snippet", "seen", "updated_at", "created_at", "folder", "provider_folder",
|
||||
}
|
||||
|
||||
var mailFieldsPreview = []string{
|
||||
@@ -126,13 +132,15 @@ func (r *uniboxRepository) CreateEntry(ctx context.Context, userID uuid.UUID, e
|
||||
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, body_text, folder
|
||||
snippet, seen, created_at, updated_at, body_text, folder,
|
||||
provider_folder
|
||||
) 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, $26, $27, $28
|
||||
$23, $24, $25, $26, $27, $28,
|
||||
$28
|
||||
)
|
||||
ON CONFLICT (id) DO NOTHING
|
||||
`
|
||||
@@ -146,6 +154,8 @@ func (r *uniboxRepository) CreateEntry(ctx context.Context, userID uuid.UUID, e
|
||||
textArray(e.InReplyTo), textArray(e.ReplyTo), textArray(e.ToAddr),
|
||||
e.Subject, e.Size, e.InternalDate, e.SentDate,
|
||||
e.Snippet, e.Seen, e.CreatedAt, e.UpdatedAt, e.BodyText,
|
||||
// $28 is both columns: a message starts out where the provider put it,
|
||||
// and only diverges once someone files it in Warmbly.
|
||||
models.NormalizeFolder(e.Folder, e.Flags),
|
||||
)
|
||||
return err
|
||||
@@ -195,6 +205,11 @@ func (r *uniboxRepository) UpdateEntry(ctx context.Context, userID, emailID, id
|
||||
args = append(args, *e.Folder)
|
||||
argPos++
|
||||
}
|
||||
if e.ProviderFolder != nil {
|
||||
setClauses = append(setClauses, fmt.Sprintf("provider_folder = $%d", argPos))
|
||||
args = append(args, *e.ProviderFolder)
|
||||
argPos++
|
||||
}
|
||||
|
||||
if argPos == 3 {
|
||||
return nil // nothing to update
|
||||
@@ -251,7 +266,7 @@ func (r *uniboxRepository) GetByID(ctx context.Context, userID, id uuid.UUID) (*
|
||||
&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, &e.Folder,
|
||||
&e.Snippet, &e.Seen, &e.UpdatedAt, &e.CreatedAt, &e.Folder, &e.ProviderFolder,
|
||||
)
|
||||
if err != nil {
|
||||
if err == pgx.ErrNoRows {
|
||||
@@ -290,7 +305,7 @@ func (r *uniboxRepository) GetByIDForOrg(ctx context.Context, orgID, id uuid.UUI
|
||||
&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, &e.Folder,
|
||||
&e.Snippet, &e.Seen, &e.UpdatedAt, &e.CreatedAt, &e.Folder, &e.ProviderFolder,
|
||||
)
|
||||
if err != nil {
|
||||
if err == pgx.ErrNoRows {
|
||||
@@ -664,6 +679,18 @@ func (r *uniboxRepository) MarkSeenByFolder(ctx context.Context, orgID uuid.UUID
|
||||
return err
|
||||
}
|
||||
|
||||
func (r *uniboxRepository) MoveToFolderBulk(ctx context.Context, orgID uuid.UUID, ids []uuid.UUID, folder string) error {
|
||||
if len(ids) == 0 {
|
||||
return nil
|
||||
}
|
||||
_, err := r.db.Exec(ctx,
|
||||
`UPDATE unibox_emails SET folder = $1, updated_at = NOW()
|
||||
WHERE id = ANY($3) AND email_id IN (SELECT id FROM email_accounts WHERE organization_id = $2)`,
|
||||
folder, orgID, 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`,
|
||||
@@ -877,7 +904,7 @@ func (r *uniboxRepository) LatestThreadIDForContact(ctx context.Context, userID
|
||||
WHERE user_id = $1 AND thread_id <> ''
|
||||
AND EXISTS (
|
||||
SELECT 1 FROM unnest(from_addr) a
|
||||
WHERE lower(coalesce(substring(a from '<([^>]*)>'), btrim(a))) = lower($2)
|
||||
WHERE lower(coalesce(substring(a from '<([^>]*)>'), substring(a from '\(([^()]*)\)\s*$'), btrim(a))) = lower($2)
|
||||
)
|
||||
ORDER BY internal_date DESC
|
||||
LIMIT 1
|
||||
|
||||
@@ -0,0 +1,198 @@
|
||||
package repository
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
|
||||
"github.com/warmbly/warmbly/internal/infrastructure/db"
|
||||
"github.com/warmbly/warmbly/internal/models"
|
||||
)
|
||||
|
||||
// Filing a conversation in the thread header moves it here and not at the
|
||||
// provider, which only works because unibox_emails keeps the two placements in
|
||||
// separate columns (migration 000146). models.ResolveFolderSync covers the
|
||||
// decision; these run the statements it feeds against a real schema, because
|
||||
// the interesting parts are things Go cannot check: that the insert seeds both
|
||||
// columns from one bound value, that the full-row scan still lines up after a
|
||||
// column was appended, and that the move leaves provider_folder alone.
|
||||
//
|
||||
// WARMBLY_TEST_DB=postgres://warmbly:warmbly@localhost:15432/warmbly_dev?sslmode=disable \
|
||||
// go test ./internal/repository/ -run LiveUniboxMoveFolder -v
|
||||
|
||||
type uniboxFolderFixture struct {
|
||||
org uuid.UUID
|
||||
user uuid.UUID
|
||||
mailbox uuid.UUID
|
||||
}
|
||||
|
||||
func newUniboxFolderFixture(t *testing.T, pool *pgxpool.Pool) *uniboxFolderFixture {
|
||||
t.Helper()
|
||||
ctx := context.Background()
|
||||
f := &uniboxFolderFixture{org: uuid.New(), user: uuid.New(), mailbox: uuid.New()}
|
||||
tag := "pr435-" + f.org.String()[:8]
|
||||
|
||||
exec := func(sql string, args ...any) {
|
||||
t.Helper()
|
||||
if _, err := pool.Exec(ctx, sql, args...); err != nil {
|
||||
t.Fatalf("fixture %q: %v", sql[:min(60, len(sql))], err)
|
||||
}
|
||||
}
|
||||
exec(`INSERT INTO users (id, first_name, last_name, email, password_hash)
|
||||
VALUES ($1, 'Folder', 'Live', $2, 'x')`, f.user, tag+"@test.local")
|
||||
exec(`INSERT INTO organizations (id, name, slug, owner_user_id)
|
||||
VALUES ($1, 'PR 435', $2, $3)`, f.org, tag, f.user)
|
||||
exec(`INSERT INTO organization_members (organization_id, user_id, role, accepted_at)
|
||||
VALUES ($1, $2, 'owner', NOW())`, f.org, f.user)
|
||||
exec(`INSERT INTO email_accounts (id, user_id, organization_id, email, name, signature_plain, signature_html, provider)
|
||||
VALUES ($1, $2, $3, $4, 'Folder', '', '', 'smtp_imap')`, f.mailbox, f.user, f.org, tag+"-mb@test.local")
|
||||
|
||||
t.Cleanup(func() {
|
||||
c := context.Background()
|
||||
_, _ = pool.Exec(c, `DELETE FROM unibox_emails WHERE email_id = $1`, f.mailbox)
|
||||
_, _ = pool.Exec(c, `DELETE FROM email_accounts WHERE id = $1`, f.mailbox)
|
||||
_, _ = pool.Exec(c, `DELETE FROM organization_members WHERE organization_id = $1`, f.org)
|
||||
_, _ = pool.Exec(c, `DELETE FROM organizations WHERE id = $1`, f.org)
|
||||
_, _ = pool.Exec(c, `DELETE FROM users WHERE id = $1`, f.user)
|
||||
})
|
||||
return f
|
||||
}
|
||||
|
||||
func liveUniboxFolderDB(t *testing.T) *db.DB {
|
||||
t.Helper()
|
||||
dsn := os.Getenv("WARMBLY_TEST_DB")
|
||||
if dsn == "" {
|
||||
t.Skip("WARMBLY_TEST_DB not set")
|
||||
}
|
||||
handle, err := db.New(context.Background(), dsn)
|
||||
if err != nil {
|
||||
t.Fatalf("connect: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { handle.Pool.Close() })
|
||||
return handle
|
||||
}
|
||||
|
||||
func (f *uniboxFolderFixture) message(t *testing.T, repo UniboxRepository, folder string) uuid.UUID {
|
||||
t.Helper()
|
||||
id := uuid.New()
|
||||
now := time.Now().UTC()
|
||||
err := repo.CreateEntry(context.Background(), f.user, &models.EmailMessageStoreData{
|
||||
ID: id, EmailID: f.mailbox, Folder: folder,
|
||||
ThreadID: "thread-" + id.String(), MessageID: "<" + id.String() + "@test.local>",
|
||||
FromAddr: []string{"Centous Support (support@centous.com)"},
|
||||
ToAddr: []string{"me@test.local"},
|
||||
Subject: "Filing", Snippet: "Filing",
|
||||
InternalDate: now, SentDate: now, CreatedAt: now, UpdatedAt: now,
|
||||
Seen: true,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("CreateEntry: %v", err)
|
||||
}
|
||||
return id
|
||||
}
|
||||
|
||||
// A new message is in one place, so both columns start there. Getting this
|
||||
// wrong would make every row read as locally filed from the moment it arrives.
|
||||
func TestLiveUniboxMoveFolderSeedsBothColumnsOnInsert(t *testing.T) {
|
||||
handle := liveUniboxFolderDB(t)
|
||||
f := newUniboxFolderFixture(t, handle.Pool)
|
||||
repo := NewUniboxRepository(handle)
|
||||
|
||||
id := f.message(t, repo, models.FolderInbox)
|
||||
got, err := repo.GetByID(context.Background(), f.user, id)
|
||||
if err != nil {
|
||||
t.Fatalf("GetByID: %v", err)
|
||||
}
|
||||
if got.Folder != models.FolderInbox || got.ProviderFolder != models.FolderInbox {
|
||||
t.Fatalf("folder=%q provider_folder=%q, want both %q", got.Folder, got.ProviderFolder, models.FolderInbox)
|
||||
}
|
||||
}
|
||||
|
||||
// The move writes folder and nothing else. provider_folder staying put is what
|
||||
// lets the next sync tell this apart from the provider moving the message.
|
||||
func TestLiveUniboxMoveFolderLeavesTheProviderPlacementAlone(t *testing.T) {
|
||||
handle := liveUniboxFolderDB(t)
|
||||
f := newUniboxFolderFixture(t, handle.Pool)
|
||||
repo := NewUniboxRepository(handle)
|
||||
ctx := context.Background()
|
||||
|
||||
id := f.message(t, repo, models.FolderInbox)
|
||||
if err := repo.MoveToFolderBulk(ctx, f.org, []uuid.UUID{id}, models.FolderTrash); err != nil {
|
||||
t.Fatalf("MoveToFolderBulk: %v", err)
|
||||
}
|
||||
|
||||
got, err := repo.GetByID(ctx, f.user, id)
|
||||
if err != nil {
|
||||
t.Fatalf("GetByID: %v", err)
|
||||
}
|
||||
if got.Folder != models.FolderTrash {
|
||||
t.Fatalf("folder = %q, want %q", got.Folder, models.FolderTrash)
|
||||
}
|
||||
if got.ProviderFolder != models.FolderInbox {
|
||||
t.Fatalf("provider_folder = %q, want it untouched at %q", got.ProviderFolder, models.FolderInbox)
|
||||
}
|
||||
|
||||
// And the message really has left every default view.
|
||||
res, err := repo.Search(ctx, f.org, f.user, &models.MailSearchParams{})
|
||||
if err != nil {
|
||||
t.Fatalf("Search: %v", err)
|
||||
}
|
||||
for _, row := range res.Data {
|
||||
if row.ID == id {
|
||||
t.Fatal("a trashed message is still in the unscoped list")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Another organization's ids are not this organization's to file.
|
||||
func TestLiveUniboxMoveFolderIsOrgScoped(t *testing.T) {
|
||||
handle := liveUniboxFolderDB(t)
|
||||
mine := newUniboxFolderFixture(t, handle.Pool)
|
||||
theirs := newUniboxFolderFixture(t, handle.Pool)
|
||||
repo := NewUniboxRepository(handle)
|
||||
ctx := context.Background()
|
||||
|
||||
id := theirs.message(t, repo, models.FolderInbox)
|
||||
if err := repo.MoveToFolderBulk(ctx, mine.org, []uuid.UUID{id}, models.FolderTrash); err != nil {
|
||||
t.Fatalf("MoveToFolderBulk: %v", err)
|
||||
}
|
||||
|
||||
got, err := repo.GetByID(ctx, theirs.user, id)
|
||||
if err != nil {
|
||||
t.Fatalf("GetByID: %v", err)
|
||||
}
|
||||
if got.Folder != models.FolderInbox {
|
||||
t.Fatalf("folder = %q, want another org's message left at %q", got.Folder, models.FolderInbox)
|
||||
}
|
||||
}
|
||||
|
||||
// The thread lookup behind the "label email" automation step reads the address
|
||||
// out of the raw header, and the IMAP sync writes those as "Name (addr)".
|
||||
func TestLiveUniboxLatestThreadIDMatchesTheParenthesisedForm(t *testing.T) {
|
||||
handle := liveUniboxFolderDB(t)
|
||||
f := newUniboxFolderFixture(t, handle.Pool)
|
||||
repo := NewUniboxRepository(handle)
|
||||
ctx := context.Background()
|
||||
|
||||
id := f.message(t, repo, models.FolderInbox)
|
||||
threadID, err := repo.LatestThreadIDForContact(ctx, f.user, "support@centous.com")
|
||||
if err != nil {
|
||||
t.Fatalf("LatestThreadIDForContact: %v", err)
|
||||
}
|
||||
if threadID != "thread-"+id.String() {
|
||||
t.Fatalf("thread = %q, want %q", threadID, "thread-"+id.String())
|
||||
}
|
||||
|
||||
// Still an exact match, never a substring one.
|
||||
other, err := repo.LatestThreadIDForContact(ctx, f.user, "upport@centous.com")
|
||||
if err != nil {
|
||||
t.Fatalf("LatestThreadIDForContact: %v", err)
|
||||
}
|
||||
if other != "" {
|
||||
t.Fatalf("thread = %q, want no match for a partial address", other)
|
||||
}
|
||||
}
|
||||
@@ -14,6 +14,8 @@ import useAcceptInvitation from "@/lib/api/hooks/app/organizations/useAcceptInvi
|
||||
import useOrganizations from "@/lib/api/hooks/app/organizations/useOrganizations";
|
||||
import useSwitchOrganization from "@/lib/api/hooks/app/organizations/useSwitchOrganization";
|
||||
import useAuthConfig from "@/lib/api/hooks/auth/useAuthConfig";
|
||||
import useUser from "@/lib/api/hooks/auth/useUser";
|
||||
import useLogout from "@/lib/api/hooks/auth/useLogout";
|
||||
import { useAppStore } from "@/stores";
|
||||
import { Logo } from "@/components/svg";
|
||||
import type { AppError } from "@/lib/api/client/normalizeError";
|
||||
@@ -32,6 +34,11 @@ export default function InviteAcceptPage() {
|
||||
const setOrganizations = useAppStore((s) => s.setOrganizations);
|
||||
const setCurrentOrganization = useAppStore((s) => s.setCurrentOrganization);
|
||||
const { config: authConfig } = useAuthConfig();
|
||||
// Who this browser is signed in as. The backend only lets the invited
|
||||
// address accept, so a session for anyone else must switch first —
|
||||
// otherwise Accept is a guaranteed 403.
|
||||
const me = useUser(loggedIn);
|
||||
const logout = useLogout();
|
||||
|
||||
const nextPath = `/invite?token=${encodeURIComponent(token ?? "")}`;
|
||||
// The invited address has to travel too: the backend only accepts a signup
|
||||
@@ -44,6 +51,18 @@ export default function InviteAcceptPage() {
|
||||
(invitedEmail ? `&email=${encodeURIComponent(invitedEmail)}` : "") +
|
||||
`&next=${encodeURIComponent(nextPath)}`;
|
||||
const signupClosed = authConfig.registration === "true";
|
||||
const signedInEmail = me.data?.email ?? "";
|
||||
const wrongAccount =
|
||||
loggedIn && !!signedInEmail && !!invitedEmail && signedInEmail.toLowerCase() !== invitedEmail.toLowerCase();
|
||||
// Accept is a guaranteed 403 for the wrong session, so it must not be
|
||||
// clickable before we know which session this is. `me` never resolves when
|
||||
// it was never asked (no token), hence the loggedIn half.
|
||||
const identityPending = loggedIn && (me.isPending || me.isFetching);
|
||||
|
||||
async function onSwitchAccount() {
|
||||
await logout.mutateAsync();
|
||||
navigate(`/auth/login?next=${encodeURIComponent(nextPath)}`, { replace: true });
|
||||
}
|
||||
|
||||
async function onAccept() {
|
||||
if (!token) return;
|
||||
@@ -132,15 +151,34 @@ export default function InviteAcceptPage() {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{loggedIn ? (
|
||||
{wrongAccount ? (
|
||||
<div className="space-y-2">
|
||||
<p className="text-[12px] text-slate-500 leading-relaxed">
|
||||
You're signed in as <span className="font-medium text-slate-700">{signedInEmail}</span>, but this
|
||||
invitation is for <span className="font-medium text-slate-700">{invitedEmail}</span>. Sign out, then sign in
|
||||
or create an account with the invited address.
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onSwitchAccount}
|
||||
disabled={logout.isPending}
|
||||
className="w-full h-9 rounded-md bg-sky-600 hover:bg-sky-700 text-white text-[13px] font-medium inline-flex items-center justify-center gap-1.5 transition-colors disabled:opacity-60"
|
||||
>
|
||||
{logout.isPending && <Loader2Icon className="w-3.5 h-3.5 animate-spin" />}
|
||||
Switch account
|
||||
</button>
|
||||
</div>
|
||||
) : loggedIn ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onAccept}
|
||||
disabled={accept.isPending}
|
||||
disabled={accept.isPending || identityPending}
|
||||
className="w-full h-9 rounded-md bg-sky-600 hover:bg-sky-700 text-white text-[13px] font-medium inline-flex items-center justify-center gap-1.5 transition-colors disabled:opacity-60"
|
||||
>
|
||||
{accept.isPending && <Loader2Icon className="w-3.5 h-3.5 animate-spin" />}
|
||||
Accept invitation
|
||||
{(accept.isPending || identityPending) && (
|
||||
<Loader2Icon className="w-3.5 h-3.5 animate-spin" />
|
||||
)}
|
||||
{identityPending ? "Checking your account…" : "Accept invitation"}
|
||||
</button>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
|
||||
@@ -44,6 +44,8 @@ import useContact from "@/lib/api/hooks/app/contacts/useContact";
|
||||
import useContactDeals from "@/lib/api/hooks/app/contacts/useContactDeals";
|
||||
import useContactNotes from "@/lib/api/hooks/app/contacts/useContactNotes";
|
||||
import useCreateContactNote from "@/lib/api/hooks/app/contacts/useCreateContactNote";
|
||||
import useAddContacts from "@/lib/api/hooks/app/contacts/useAddContacts";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import useCRMTasks from "@/lib/api/hooks/app/crm/tasks/useCRMTasks";
|
||||
import useCreateCRMTask from "@/lib/api/hooks/app/crm/tasks/useCreateCRMTask";
|
||||
import useCreateDeal from "@/lib/api/hooks/app/crm/deals/useCreateDeal";
|
||||
@@ -77,10 +79,14 @@ const PRIORITY_OPTS: { id: CRMTask["priority"]; label: string }[] = [
|
||||
|
||||
export default function ContactContextPanel({
|
||||
email,
|
||||
name: fromName,
|
||||
mailboxId,
|
||||
onClose,
|
||||
}: {
|
||||
email?: string;
|
||||
// Display name from the message's From header, used when adding the
|
||||
// sender as a contact.
|
||||
name?: string;
|
||||
mailboxId?: string;
|
||||
onClose?: () => void;
|
||||
}) {
|
||||
@@ -140,7 +146,7 @@ export default function ContactContextPanel({
|
||||
Resolving contact…
|
||||
</div>
|
||||
) : !contact ? (
|
||||
<NotAContact email={email} />
|
||||
<NotAContact email={email} name={fromName} />
|
||||
) : (
|
||||
<div className="divide-y divide-slate-200/70">
|
||||
{/* Identity */}
|
||||
@@ -788,7 +794,29 @@ function RowSkeleton() {
|
||||
);
|
||||
}
|
||||
|
||||
function NotAContact({ email }: { email?: string }) {
|
||||
// A reply from someone outside the CRM. One click creates the contact from
|
||||
// the From header; the by-email lookup is invalidated so this panel flips to
|
||||
// the full contact view where the rest can be edited.
|
||||
function NotAContact({ email, name }: { email?: string; name?: string }) {
|
||||
const add = useAddContacts();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
async function onAdd() {
|
||||
if (!email) return;
|
||||
const parts = (name ?? "").trim().split(/\s+/).filter(Boolean);
|
||||
const first_name = parts[0] ?? "";
|
||||
const last_name = parts.slice(1).join(" ");
|
||||
try {
|
||||
await toast.promise(
|
||||
add.mutateAsync([{ first_name, last_name, email, company: "", phone: "", campaigns: [], custom_fields: {}, source: "manual" }]),
|
||||
{ loading: "Adding contact…", success: "Contact added", error: "Couldn't add contact" },
|
||||
);
|
||||
await queryClient.invalidateQueries({ queryKey: ["contacts", "by-email", email] });
|
||||
} catch {
|
||||
/* surfaced */
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="px-3 py-8 text-center">
|
||||
<div className="mx-auto size-9 rounded-md bg-white border border-slate-200 flex items-center justify-center mb-2.5">
|
||||
@@ -796,13 +824,25 @@ function NotAContact({ email }: { email?: string }) {
|
||||
</div>
|
||||
<p className="text-[12px] font-medium text-slate-700 mb-0.5">Not a known contact</p>
|
||||
{email && <p className="text-[11px] text-slate-400 break-all mb-3">{email}</p>}
|
||||
<Link
|
||||
to="/app/contacts"
|
||||
className="inline-flex items-center gap-1.5 h-7 px-2.5 rounded-md border border-slate-200 hover:border-slate-300 text-[11.5px] text-slate-700 hover:text-slate-900 transition-colors"
|
||||
>
|
||||
<PlusIcon className="w-3 h-3" />
|
||||
Manage contacts
|
||||
</Link>
|
||||
<div className="flex items-center justify-center gap-1.5">
|
||||
{email && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onAdd}
|
||||
disabled={add.isPending}
|
||||
className="inline-flex items-center gap-1.5 h-7 px-2.5 rounded-md bg-sky-600 hover:bg-sky-700 text-white text-[11.5px] font-medium transition-colors disabled:opacity-60"
|
||||
>
|
||||
{add.isPending ? <Loader2Icon className="w-3 h-3 animate-spin" /> : <PlusIcon className="w-3 h-3" />}
|
||||
Add as contact
|
||||
</button>
|
||||
)}
|
||||
<Link
|
||||
to="/app/contacts"
|
||||
className="inline-flex items-center gap-1.5 h-7 px-2.5 rounded-md border border-slate-200 hover:border-slate-300 text-[11.5px] text-slate-700 hover:text-slate-900 transition-colors"
|
||||
>
|
||||
Manage contacts
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ import type UniboxEmail from "@/lib/api/models/app/unibox/UniboxEmail";
|
||||
import { useAppStore } from "@/stores";
|
||||
import { useResourceViewers } from "@/hooks/PresenceProvider";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { nameFromAddr } from "@/lib/helper/emailAddress";
|
||||
|
||||
function relative(d: Date): string {
|
||||
const diff = Date.now() - d.getTime();
|
||||
@@ -24,9 +25,7 @@ function relative(d: Date): string {
|
||||
|
||||
function fromName(s: string): string {
|
||||
if (!s) return "Unknown sender";
|
||||
const m = s.match(/^"?([^"<]+)"?\s*<.+>$/);
|
||||
if (m) return m[1].trim();
|
||||
return s.replace(/<.+>/, "").trim() || s;
|
||||
return nameFromAddr(s);
|
||||
}
|
||||
|
||||
function initials(s: string): string {
|
||||
|
||||
@@ -7,12 +7,7 @@ import { CalendarPlusIcon } from "lucide-react";
|
||||
import toast from "react-hot-toast";
|
||||
import useIntegrationConnections from "@/lib/api/hooks/app/integrations/useIntegrationConnections";
|
||||
import { bookingURL, prefilledBookingURL } from "@/lib/api/models/app/integrations/Integration";
|
||||
|
||||
function bareEmail(s: string): string {
|
||||
const m = s.match(/<([^>]+)>/);
|
||||
if (m) return m[1].trim();
|
||||
return s.trim();
|
||||
}
|
||||
import { bareEmail } from "@/lib/helper/emailAddress";
|
||||
|
||||
export default function InsertBookingLink({
|
||||
email,
|
||||
|
||||
@@ -18,6 +18,7 @@ import { AlertCircleIcon, CornerUpLeftIcon, ForwardIcon, Loader2Icon } from "luc
|
||||
import EmailBody from "./EmailBody";
|
||||
import useUniboxEmail from "@/lib/api/hooks/app/unibox/useUniboxEmail";
|
||||
import type UniboxEmail from "@/lib/api/models/app/unibox/UniboxEmail";
|
||||
import { nameFromAddr, wrappedEmail } from "@/lib/helper/emailAddress";
|
||||
|
||||
interface MessageBubbleProps {
|
||||
email: UniboxEmail;
|
||||
@@ -27,17 +28,8 @@ interface MessageBubbleProps {
|
||||
onForward?: () => void;
|
||||
}
|
||||
|
||||
function fromName(s: string): string {
|
||||
const m = s.match(/^"?([^"<]+)"?\s*<.+>$/);
|
||||
if (m) return m[1].trim();
|
||||
return s.replace(/<.+>/, "").trim() || s;
|
||||
}
|
||||
|
||||
function fromAddr(s: string): string | null {
|
||||
const m = s.match(/<([^>]+)>/);
|
||||
if (m) return m[1].trim();
|
||||
return null;
|
||||
}
|
||||
const fromName = nameFromAddr;
|
||||
const fromAddr = wrappedEmail;
|
||||
|
||||
function initials(s: string): string {
|
||||
const name = fromName(s);
|
||||
|
||||
@@ -53,6 +53,7 @@ import {
|
||||
} from "@/components/ui/popover-menu";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { plainToHtml } from "@/lib/email/body";
|
||||
import { bareEmail, nameFromAddr } from "@/lib/helper/emailAddress";
|
||||
|
||||
export type ReplyMode = "reply" | "forward";
|
||||
|
||||
@@ -138,18 +139,6 @@ function looksLikeEmail(s: string): boolean {
|
||||
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(s.trim());
|
||||
}
|
||||
|
||||
function nameFromAddr(s: string): string {
|
||||
const m = s.match(/^"?([^"<]+)"?\s*<.+>$/);
|
||||
if (m) return m[1].trim();
|
||||
return s.replace(/<.+>/, "").trim() || s;
|
||||
}
|
||||
|
||||
function bareEmail(s: string): string {
|
||||
const m = s.match(/<([^>]+)>/);
|
||||
if (m) return m[1].trim();
|
||||
return s.trim();
|
||||
}
|
||||
|
||||
// Derive composer defaults from the message the user explicitly chose
|
||||
// to reply to (or forward). Reply takes the message's "from" as the
|
||||
// new "to". Forward leaves "to" empty so the user picks the new
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
// path with a native datetime input.
|
||||
|
||||
import React from "react";
|
||||
import { useParams } from "react-router-dom";
|
||||
import { AnimatePresence, motion } from "framer-motion";
|
||||
import { useQueryClient, useMutation } from "@tanstack/react-query";
|
||||
import toast from "react-hot-toast";
|
||||
@@ -17,6 +18,7 @@ import {
|
||||
ClockIcon,
|
||||
CornerUpLeftIcon,
|
||||
ForwardIcon,
|
||||
InboxIcon,
|
||||
Loader2Icon,
|
||||
MailCheckIcon,
|
||||
MoonIcon,
|
||||
@@ -41,6 +43,9 @@ import { CategoryChip } from "@/components/app/contacts/CategoryPicker";
|
||||
import { SectionBar } from "@/components/layout/Page";
|
||||
import useThread from "@/lib/api/hooks/app/unibox/useThread";
|
||||
import useMarkSeen from "@/lib/api/hooks/app/unibox/useMarkSeen";
|
||||
import useMoveFolder from "@/lib/api/hooks/app/unibox/useMoveFolder";
|
||||
import moveFolderRequest, { type FilableFolder } from "@/lib/api/client/app/unibox/moveFolder";
|
||||
import { bareEmail, nameFromAddr, wrappedEmail } from "@/lib/helper/emailAddress";
|
||||
import useThreadLabels from "@/lib/api/hooks/app/unibox/useThreadLabels";
|
||||
import useThreadScheduled from "@/lib/api/hooks/app/unibox/useThreadScheduled";
|
||||
import cancelScheduled from "@/lib/api/client/app/unibox/cancelScheduled";
|
||||
@@ -84,6 +89,14 @@ function toUniboxEmail(m: UniboxThreadMessage): UniboxEmail {
|
||||
};
|
||||
}
|
||||
|
||||
// Filing copy, per destination. "Deleted" is deliberately not said anywhere:
|
||||
// the message is moved to Trash here and still sits in the mail client.
|
||||
const FILE_COPY: Record<FilableFolder, { loading: string; done: string; failed: string }> = {
|
||||
archive: { loading: "Archiving…", done: "Archived", failed: "Couldn't archive" },
|
||||
trash: { loading: "Moving to Trash…", done: "Moved to Trash", failed: "Couldn't move to Trash" },
|
||||
inbox: { loading: "Moving to Inbox…", done: "Moved to Inbox", failed: "Couldn't move to Inbox" },
|
||||
};
|
||||
|
||||
const SNOOZE_PRESETS: { label: string; until: () => Date }[] = [
|
||||
{ label: "In 1 hour", until: () => offsetHours(1) },
|
||||
{ label: "In 3 hours", until: () => offsetHours(3) },
|
||||
@@ -263,6 +276,68 @@ export function ThreadView({ threadId, emailId }: ThreadViewProps) {
|
||||
markSeenMutate({ ids: unseenIds, threadId });
|
||||
}, [threadId, q.data, markSeenMutate]);
|
||||
|
||||
// Header actions. Each one closes the thread: the effect above would
|
||||
// otherwise re-mark an "unread" thread as seen on the next refetch, and a
|
||||
// filed thread has left the list the reader is looking at.
|
||||
const moveFolder = useMoveFolder();
|
||||
const setSelectedThreadId = useAppStore((s) => s.setSelectedThreadId);
|
||||
const threadIds = () => (q.data?.data ?? []).map((m) => m.id);
|
||||
const markUnread = () => {
|
||||
markSeenMutate({ ids: threadIds(), seen: false, threadId });
|
||||
setSelectedThreadId(null);
|
||||
};
|
||||
|
||||
// One click and the conversation is gone from the list, so the way back
|
||||
// belongs on screen; the Trash scope's Move to inbox is the slow path. This
|
||||
// pane has already closed by the time Undo is clicked, so it calls the
|
||||
// endpoint directly: react-query drops an unmounted observer's callbacks,
|
||||
// and the invalidation is the whole point.
|
||||
const offerUndo = (message: string, ids: string[]) => {
|
||||
toast((t) => (
|
||||
<span className="flex items-center gap-3 text-[12.5px] text-slate-700">
|
||||
{message}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
toast.dismiss(t.id);
|
||||
moveFolderRequest({ ids, folder: "inbox" })
|
||||
.then(() => {
|
||||
queryClient.invalidateQueries({ queryKey: ["unibox"] });
|
||||
toast.success("Moved back to Inbox");
|
||||
})
|
||||
.catch(() => toast.error("Couldn't undo"));
|
||||
}}
|
||||
className="h-6 px-2 rounded-md border border-slate-200 hover:border-slate-300 text-[11.5px] font-medium text-sky-700 hover:bg-sky-50 transition-colors"
|
||||
>
|
||||
Undo
|
||||
</button>
|
||||
</span>
|
||||
));
|
||||
};
|
||||
|
||||
// Filing is store-side: the message keeps its place at the provider, and
|
||||
// the sync knows not to undo this (migration 000146).
|
||||
const fileThread = async (folder: FilableFolder) => {
|
||||
const ids = threadIds();
|
||||
if (ids.length === 0 || moveFolder.isPending) return;
|
||||
const copy = FILE_COPY[folder];
|
||||
const pending = toast.loading(copy.loading);
|
||||
try {
|
||||
await moveFolder.mutateAsync({ ids, folder });
|
||||
setSelectedThreadId(null);
|
||||
toast.dismiss(pending);
|
||||
if (folder === "inbox") toast.success(copy.done);
|
||||
else offerUndo(copy.done, ids);
|
||||
} catch {
|
||||
toast.dismiss(pending);
|
||||
toast.error(copy.failed);
|
||||
}
|
||||
};
|
||||
|
||||
// Restoring is only offered where the user can see what they are restoring.
|
||||
const { scope: urlScope } = useParams<{ scope?: string }>();
|
||||
const filed = urlScope === "trash" || urlScope === "archive";
|
||||
|
||||
const snooze = useMutation({
|
||||
mutationFn: (until: Date) =>
|
||||
snoozeThread({ thread_id: threadId, snoozed_until: until.toISOString() }),
|
||||
@@ -337,18 +412,23 @@ export function ThreadView({ threadId, emailId }: ThreadViewProps) {
|
||||
const mailbox = accounts.find((a) => a.id === messages[0]?.account_id);
|
||||
|
||||
// The external party of the thread = the first message address that isn't
|
||||
// our own mailbox. Addresses arrive as "Name <addr>" or bare "addr"; reduce
|
||||
// to the bare address so the comparison + the CRM panel lookup both work.
|
||||
// our own mailbox. Headers arrive in all three shapes lib/helper/emailAddress
|
||||
// parses; reduce to the bare address so the comparison + the lookup work.
|
||||
const mailboxEmail = mailbox?.email?.toLowerCase();
|
||||
const bareAddr = (s: string) => {
|
||||
const m = s.match(/<([^>]+)>/);
|
||||
return (m ? m[1] : s).trim();
|
||||
};
|
||||
const contactEmail =
|
||||
const contactFrom =
|
||||
messages
|
||||
.map((m) => bareAddr(m.from))
|
||||
.find((e) => e && e.toLowerCase() !== mailboxEmail) ??
|
||||
bareAddr(messages[0]?.from ?? "");
|
||||
.map((m) => m.from)
|
||||
.find((f) => {
|
||||
const e = bareEmail(f);
|
||||
return e && e.toLowerCase() !== mailboxEmail;
|
||||
}) ?? (messages[0]?.from ?? "");
|
||||
const contactEmail = bareEmail(contactFrom);
|
||||
// Display name from the From header, so an "Add as contact" from the
|
||||
// panel does not create a nameless row. Empty when the header is bare.
|
||||
const contactName =
|
||||
wrappedEmail(contactFrom) && nameFromAddr(contactFrom) !== contactEmail
|
||||
? nameFromAddr(contactFrom)
|
||||
: "";
|
||||
|
||||
const submitCustomSnooze = () => {
|
||||
if (!customValue) return;
|
||||
@@ -505,16 +585,32 @@ export function ThreadView({ threadId, emailId }: ThreadViewProps) {
|
||||
<IconAction
|
||||
label="Mark as unread"
|
||||
icon={<MailCheckIcon className="w-3.5 h-3.5" />}
|
||||
onClick={markUnread}
|
||||
/>
|
||||
<IconAction
|
||||
label="Archive thread"
|
||||
icon={<ArchiveIcon className="w-3.5 h-3.5" />}
|
||||
/>
|
||||
<IconAction
|
||||
label="Delete thread"
|
||||
danger
|
||||
icon={<TrashIcon className="w-3.5 h-3.5" />}
|
||||
/>
|
||||
{filed ? (
|
||||
<IconAction
|
||||
label="Move to inbox"
|
||||
icon={<InboxIcon className="w-3.5 h-3.5" />}
|
||||
disabled={moveFolder.isPending}
|
||||
onClick={() => fileThread("inbox")}
|
||||
/>
|
||||
) : (
|
||||
<IconAction
|
||||
label="Archive thread"
|
||||
icon={<ArchiveIcon className="w-3.5 h-3.5" />}
|
||||
disabled={moveFolder.isPending}
|
||||
onClick={() => fileThread("archive")}
|
||||
/>
|
||||
)}
|
||||
{urlScope !== "trash" && (
|
||||
<IconAction
|
||||
label="Delete thread"
|
||||
danger
|
||||
icon={<TrashIcon className="w-3.5 h-3.5" />}
|
||||
disabled={moveFolder.isPending}
|
||||
onClick={() => fileThread("trash")}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<PopoverMenu align="end" side="bottom">
|
||||
<PopoverMenuTrigger asChild>
|
||||
@@ -529,18 +625,37 @@ export function ThreadView({ threadId, emailId }: ThreadViewProps) {
|
||||
<PopoverMenuContent>
|
||||
<PopoverMenuItem
|
||||
icon={<MailCheckIcon className="w-3.5 h-3.5" />}
|
||||
onSelect={markUnread}
|
||||
>
|
||||
Mark as unread
|
||||
</PopoverMenuItem>
|
||||
<PopoverMenuItem icon={<ArchiveIcon className="w-3.5 h-3.5" />}>
|
||||
Archive thread
|
||||
</PopoverMenuItem>
|
||||
<PopoverMenuItem
|
||||
danger
|
||||
icon={<TrashIcon className="w-3.5 h-3.5" />}
|
||||
>
|
||||
Delete thread
|
||||
</PopoverMenuItem>
|
||||
{filed ? (
|
||||
<PopoverMenuItem
|
||||
icon={<InboxIcon className="w-3.5 h-3.5" />}
|
||||
disabled={moveFolder.isPending}
|
||||
onSelect={() => fileThread("inbox")}
|
||||
>
|
||||
Move to inbox
|
||||
</PopoverMenuItem>
|
||||
) : (
|
||||
<PopoverMenuItem
|
||||
icon={<ArchiveIcon className="w-3.5 h-3.5" />}
|
||||
disabled={moveFolder.isPending}
|
||||
onSelect={() => fileThread("archive")}
|
||||
>
|
||||
Archive thread
|
||||
</PopoverMenuItem>
|
||||
)}
|
||||
{urlScope !== "trash" && (
|
||||
<PopoverMenuItem
|
||||
danger
|
||||
icon={<TrashIcon className="w-3.5 h-3.5" />}
|
||||
disabled={moveFolder.isPending}
|
||||
onSelect={() => fileThread("trash")}
|
||||
>
|
||||
Delete thread
|
||||
</PopoverMenuItem>
|
||||
)}
|
||||
</PopoverMenuContent>
|
||||
</PopoverMenu>
|
||||
</div>
|
||||
@@ -640,6 +755,7 @@ export function ThreadView({ threadId, emailId }: ThreadViewProps) {
|
||||
{crmOpen && (
|
||||
<ContactContextPanel
|
||||
email={contactEmail}
|
||||
name={contactName}
|
||||
mailboxId={mailbox?.id}
|
||||
onClose={() => setCrmOpen(false)}
|
||||
/>
|
||||
@@ -652,11 +768,13 @@ function IconAction({
|
||||
label,
|
||||
icon,
|
||||
danger,
|
||||
disabled,
|
||||
onClick,
|
||||
}: {
|
||||
label: string;
|
||||
icon: React.ReactNode;
|
||||
danger?: boolean;
|
||||
disabled?: boolean;
|
||||
onClick?: () => void;
|
||||
}) {
|
||||
return (
|
||||
@@ -665,9 +783,10 @@ function IconAction({
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
disabled={disabled}
|
||||
aria-label={label}
|
||||
className={
|
||||
"size-7 rounded-md inline-flex items-center justify-center transition-colors " +
|
||||
"size-7 rounded-md inline-flex items-center justify-center transition-colors disabled:opacity-40 disabled:pointer-events-none " +
|
||||
(danger
|
||||
? "text-slate-500 hover:text-red-600 hover:bg-red-50"
|
||||
: "text-slate-500 hover:text-slate-900 hover:bg-slate-100")
|
||||
|
||||
@@ -17,6 +17,7 @@ import type { UniboxListRow } from "@/lib/api/client/app/unibox/searchIncoming";
|
||||
import { SearchInput } from "@/components/ui/field";
|
||||
import { useAppStore } from "@/stores";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { bareEmail } from "@/lib/helper/emailAddress";
|
||||
|
||||
type HistoryTab = "all" | "sent";
|
||||
|
||||
@@ -29,12 +30,6 @@ interface ComposeHistoryPanelProps {
|
||||
affinityLine?: string;
|
||||
}
|
||||
|
||||
function bareEmail(s: string): string {
|
||||
const m = s.match(/<([^>]+)>/);
|
||||
if (m) return m[1].trim();
|
||||
return s.trim();
|
||||
}
|
||||
|
||||
function formatWhen(iso: string): string {
|
||||
const d = new Date(iso);
|
||||
const now = new Date();
|
||||
|
||||
@@ -66,6 +66,7 @@ import type { AppError } from "@/lib/api/client/normalizeError";
|
||||
import buildError from "@/lib/helper/buildError";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { plainToHtml } from "@/lib/email/body";
|
||||
import { bareEmail } from "@/lib/helper/emailAddress";
|
||||
|
||||
const MAX_BODY_LEN = 4000;
|
||||
const MAX_SCHEDULE_MS = 29 * 24 * 60 * 60 * 1000;
|
||||
@@ -74,12 +75,6 @@ function looksLikeEmail(s: string): boolean {
|
||||
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(s.trim());
|
||||
}
|
||||
|
||||
function bareEmail(s: string): string {
|
||||
const m = s.match(/<([^>]+)>/);
|
||||
if (m) return m[1].trim();
|
||||
return s.trim();
|
||||
}
|
||||
|
||||
function offsetHours(h: number): Date {
|
||||
const d = new Date();
|
||||
d.setHours(d.getHours() + h);
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
import Request from "../../Request";
|
||||
|
||||
// The three folders a user can file a conversation into. sent/drafts/spam are
|
||||
// verdicts the provider reaches, and the backend refuses them here.
|
||||
export type FilableFolder = "inbox" | "archive" | "trash";
|
||||
|
||||
// PATCH /unibox/folder re-files messages. Archive in the thread header is
|
||||
// "archive", Delete is "trash", Move to inbox is "inbox". Store-side only: the
|
||||
// provider copy stays put, and the sync knows not to undo it.
|
||||
export default async function moveFolder(data: { ids: string[]; folder: FilableFolder }): Promise<void> {
|
||||
return await Request<void>({
|
||||
method: "PATCH",
|
||||
url: `/unibox/folder`,
|
||||
data: { email_ids: data.ids, folder: data.folder },
|
||||
authorization: true,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import moveFolder, { type FilableFolder } from "@/lib/api/client/app/unibox/moveFolder";
|
||||
|
||||
export default function useMoveFolder() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: (data: { ids: string[]; folder: FilableFolder }) => moveFolder(data),
|
||||
// A move changes which scopes the thread belongs to and every folder's
|
||||
// counts, so the whole unibox tree is re-read rather than patched.
|
||||
onSettled: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["unibox"] })
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -5,10 +5,13 @@ import getUser from "../../client/auth/getUser";
|
||||
// long staleTime so navigating between pages never refetches — only
|
||||
// explicit invalidations (avatar upload, onboarding completion) move
|
||||
// it.
|
||||
export default function useUser() {
|
||||
// `enabled: false` lets a public page (the /invite landing) ask only when a
|
||||
// session exists, instead of firing a 401 that clears tokens.
|
||||
export default function useUser(enabled = true) {
|
||||
return useQuery({
|
||||
queryKey: ["auth", "me"],
|
||||
queryFn: () => getUser(),
|
||||
enabled,
|
||||
staleTime: 5 * 60_000,
|
||||
gcTime: 30 * 60_000,
|
||||
refetchOnMount: false,
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { bareEmail, nameFromAddr, wrappedEmail } from "./emailAddress";
|
||||
|
||||
// The three shapes the API actually stores (see the header comment), pinned
|
||||
// so the reply composer's seeded To passes its own validator for each.
|
||||
describe("emailAddress", () => {
|
||||
it("parses the IMAP sync's parenthesised form", () => {
|
||||
const s = "Centous Support (support@centous.com)";
|
||||
expect(bareEmail(s)).toBe("support@centous.com");
|
||||
expect(nameFromAddr(s)).toBe("Centous Support");
|
||||
});
|
||||
|
||||
it("parses the RFC angle-bracket form, quoted or not", () => {
|
||||
expect(bareEmail('"Jane Doe" <jane@x.com>')).toBe("jane@x.com");
|
||||
expect(nameFromAddr('"Jane Doe" <jane@x.com>')).toBe("Jane Doe");
|
||||
expect(nameFromAddr("Jane Doe <jane@x.com>")).toBe("Jane Doe");
|
||||
});
|
||||
|
||||
it("passes a bare address through and names it by itself", () => {
|
||||
expect(wrappedEmail("jane@x.com")).toBeNull();
|
||||
expect(bareEmail(" jane@x.com ")).toBe("jane@x.com");
|
||||
expect(nameFromAddr("jane@x.com")).toBe("jane@x.com");
|
||||
});
|
||||
|
||||
it("falls back to the address when the name is empty", () => {
|
||||
// What GetAddressName renders for an envelope with no display name.
|
||||
expect(nameFromAddr(" (noreply-dmarc-support@google.com)")).toBe("noreply-dmarc-support@google.com");
|
||||
});
|
||||
|
||||
it("takes the last bracket group, so a name with brackets survives", () => {
|
||||
const s = "Acme (UK) Ltd (billing@acme.com)";
|
||||
expect(bareEmail(s)).toBe("billing@acme.com");
|
||||
expect(nameFromAddr(s)).toBe("Acme (UK) Ltd");
|
||||
});
|
||||
|
||||
it("leaves a parenthetical that is not an address alone", () => {
|
||||
expect(wrappedEmail("Nobody (no address here)")).toBeNull();
|
||||
expect(bareEmail("Nobody (no address here)")).toBe("Nobody (no address here)");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,24 @@
|
||||
// One parser for the header-style addresses the API hands the UI. They come in
|
||||
// three shapes: RFC "Name <addr>" (Gmail/Graph sync), "Name (addr)" (the IMAP
|
||||
// sync, internal/client/smtpimap/imap/address.go), or a bare "addr". Every
|
||||
// component used to carry its own angle-bracket-only copy, so an IMAP sender
|
||||
// seeded the reply composer with "Name (addr)" and Send stayed disabled.
|
||||
const WRAPPED = /[<(]\s*([^<>()\s]+@[^<>()\s]+)\s*[>)]\s*$/;
|
||||
|
||||
// The address inside the brackets, or null when there are none.
|
||||
export function wrappedEmail(s: string): string | null {
|
||||
const m = s.match(WRAPPED);
|
||||
return m ? m[1] : null;
|
||||
}
|
||||
|
||||
// The bare address: the bracketed one when present, else the trimmed input.
|
||||
export function bareEmail(s: string): string {
|
||||
return wrappedEmail(s) ?? s.trim();
|
||||
}
|
||||
|
||||
// The display name in front of the brackets; the address when there is none.
|
||||
export function nameFromAddr(s: string): string {
|
||||
const m = s.match(WRAPPED);
|
||||
if (!m) return s.trim();
|
||||
return s.slice(0, m.index).replace(/"/g, "").trim() || m[1];
|
||||
}
|
||||
Reference in New Issue
Block a user