diff --git a/app/app.go b/app/app.go index 431a2dae..d8f63625 100644 --- a/app/app.go +++ b/app/app.go @@ -2363,6 +2363,87 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { //nolint:gocyclo } return m, nil + case tui.EditLabelsMsg: + // Provider validation: only allow for Gmail accounts + account := m.config.GetAccountByID(msg.AccountID) + if account == nil || !tui.IsGmailAccount(account) { + return m, m.showErrorCmd("Gmail labels are only available for Gmail accounts") + } + // Recipient validation: the account's email must be in To/Cc/Bcc + fetchEmail := account.FetchEmail + if fetchEmail == "" { + fetchEmail = account.Email + } + if !tui.IsRecipientOfEmail(msg.Email, fetchEmail, account) && !account.CatchAll { + return m, m.showErrorCmd("Cannot modify labels: your email address is not a recipient of this message") + } + // Switch back to folder inbox and forward the message to activate the overlay + tui.ClearKittyGraphics() + if m.folderInbox != nil { + m.current = m.folderInbox + return m.folderInbox.Update(msg) + } + return m, nil + + case tui.GmailLabelModifiedMsg: + account := m.config.GetAccountByID(msg.AccountID) + if account == nil { + return m, m.showErrorCmd("Account not found") + } + if !tui.IsGmailAccount(account) { + return m, m.showErrorCmd("Gmail labels are only available for Gmail accounts") + } + folder := msg.Folder + if folder == "" { + folder = m.folderName() + } + // Update the in-memory store immediately for instant UI feedback + if msg.Add { + m.store.AddGmailLabel(msg.UID, msg.AccountID, msg.Label) + } else { + m.store.RemoveGmailLabel(msg.UID, msg.AccountID, msg.Label) + } + // Refresh the inbox display + if m.folderInbox != nil { + m.folderInbox.GetInbox().SetEmails(m.store.CloneFolder(folder), m.config.Accounts) + } + // Send the command to the server + return m, func() tea.Msg { + var err error + if msg.Add { + err = m.service.AddGmailLabel(msg.AccountID, folder, msg.UID, msg.Label) + } else { + err = m.service.RemoveGmailLabel(msg.AccountID, folder, msg.UID, msg.Label) + } + return tui.GmailLabelResultMsg{ + UID: msg.UID, + AccountID: msg.AccountID, + Label: msg.Label, + Add: msg.Add, + Err: err, + } + } + + case tui.GmailLabelResultMsg: + if msg.Err != nil { + // Revert the local store on failure + account := m.config.GetAccountByID(msg.AccountID) + if account != nil { + if msg.Add { + m.store.RemoveGmailLabel(msg.UID, msg.AccountID, msg.Label) + } else { + m.store.AddGmailLabel(msg.UID, msg.AccountID, msg.Label) + } + folder := m.folderName() + if m.folderInbox != nil { + m.folderInbox.GetInbox().SetEmails(m.store.CloneFolder(folder), m.config.Accounts) + } + } + log.Printf("Gmail label %s failed: %v", map[bool]string{true: "add", false: "remove"}[msg.Add], msg.Err) + return m, m.showErrorCmd(msg.Err.Error()) + } + return m, nil + case tui.DownloadAttachmentMsg: m.previousModel = m.current m.current = tui.NewStatus(fmt.Sprintf("Downloading %s...", msg.Filename)) diff --git a/backend/backend.go b/backend/backend.go index e42ba595..ecc64c4a 100644 --- a/backend/backend.go +++ b/backend/backend.go @@ -90,6 +90,7 @@ type Email struct { References []string Attachments []Attachment AccountID string + Labels []string // Gmail X-GM-LABELS (empty for non-Gmail providers) } // Attachment holds data for an email attachment. diff --git a/backend/imap/imap.go b/backend/imap/imap.go index b7e8e886..b37c2f08 100644 --- a/backend/imap/imap.go +++ b/backend/imap/imap.go @@ -156,6 +156,7 @@ func toBackendEmails(emails []fetcher.Email) []backend.Email { References: e.References, Attachments: toBackendAttachments(e.Attachments), AccountID: e.AccountID, + Labels: e.Labels, } } return result diff --git a/config/cache.go b/config/cache.go index a4f5d507..7aaab329 100644 --- a/config/cache.go +++ b/config/cache.go @@ -22,6 +22,7 @@ type CachedEmail struct { References []string `json:"references,omitempty"` AccountID string `json:"account_id"` IsRead bool `json:"is_read"` + Labels []string `json:"labels,omitempty"` } // EmailCache stores cached emails for all accounts. diff --git a/config/default_keybinds.json b/config/default_keybinds.json index b645534e..ae4df256 100644 --- a/config/default_keybinds.json +++ b/config/default_keybinds.json @@ -17,7 +17,8 @@ "filter": "f", "open": "enter", "next_tab": "l", - "prev_tab": "h" + "prev_tab": "h", + "edit_labels": "L" }, "email": { "reply": "r", @@ -30,7 +31,8 @@ "rsvp_tentative": "3", "focus_attachments": "tab", "apply_patch": "p", - "send_patch": "P" + "send_patch": "P", + "edit_labels": "L" }, "composer": { "external_editor": "ctrl+e", diff --git a/config/keybinds.go b/config/keybinds.go index 8583bbe0..2955ddc6 100644 --- a/config/keybinds.go +++ b/config/keybinds.go @@ -46,6 +46,7 @@ type InboxKeys struct { Open string `json:"open"` NextTab string `json:"next_tab"` PrevTab string `json:"prev_tab"` + EditLabels string `json:"edit_labels"` } type EmailKeys struct { @@ -60,6 +61,7 @@ type EmailKeys struct { FocusAttachments string `json:"focus_attachments"` ApplyPatch string `json:"apply_patch"` SendPatch string `json:"send_patch"` + EditLabels string `json:"edit_labels"` } type ComposerKeys struct { @@ -130,6 +132,7 @@ func ValidateKeybinds(kb KeybindsConfig) []string { "open": kb.Inbox.Open, "next_tab": kb.Inbox.NextTab, "prev_tab": kb.Inbox.PrevTab, + "edit_labels": kb.Inbox.EditLabels, }, "email": { "reply": kb.Email.Reply, @@ -143,6 +146,7 @@ func ValidateKeybinds(kb KeybindsConfig) []string { "focus_attachments": kb.Email.FocusAttachments, "apply_patch": kb.Email.ApplyPatch, "send_patch": kb.Email.SendPatch, + "edit_labels": kb.Email.EditLabels, }, "composer": { "undo_send": kb.Composer.UndoSend, diff --git a/daemon/daemon.go b/daemon/daemon.go index 180e62d5..6d5e8130 100644 --- a/daemon/daemon.go +++ b/daemon/daemon.go @@ -105,6 +105,8 @@ func (d *Daemon) registerHandlers() { d.server.Handle(daemonrpc.MethodUnsubscribe, d.handleUnsubscribe) d.server.Handle(daemonrpc.MethodQueueEmail, d.handleQueueEmail) d.server.Handle(daemonrpc.MethodCancelEmail, d.handleCancelEmail) + d.server.Handle(daemonrpc.MethodAddGmailLabel, d.handleAddGmailLabel) + d.server.Handle(daemonrpc.MethodRemoveGmailLabel, d.handleRemoveGmailLabel) } // Run starts the daemon: creates providers, starts the socket listener, diff --git a/daemon/handler.go b/daemon/handler.go index 99ee4187..9c77fa3b 100644 --- a/daemon/handler.go +++ b/daemon/handler.go @@ -9,6 +9,7 @@ import ( "time" "github.com/floatpane/matcha/daemonrpc" + "github.com/floatpane/matcha/fetcher" "github.com/google/uuid" ) @@ -382,3 +383,51 @@ func (d *Daemon) handleCancelEmail(_ context.Context, _ *daemonrpc.Conn, params log.Printf("daemon: cancelled email %s", args.JobID) return true, nil } + +func (d *Daemon) handleAddGmailLabel(ctx context.Context, _ *daemonrpc.Conn, params json.RawMessage) (any, error) { + args, err := decodeParams[daemonrpc.AddGmailLabelParams](params) + if err != nil { + return nil, parseError(err) + } + + d.mu.RLock() + account := d.config.GetAccountByID(args.AccountID) + d.mu.RUnlock() + + if account == nil { + return nil, fmt.Errorf("no account for %s", args.AccountID) + } + + ctx, cancel := context.WithTimeout(ctx, mutateTimeout) + defer cancel() + _ = ctx + + if err := fetcher.AddGmailLabel(account, args.Folder, args.UID, args.Label); err != nil { + return nil, err + } + return true, nil +} + +func (d *Daemon) handleRemoveGmailLabel(ctx context.Context, _ *daemonrpc.Conn, params json.RawMessage) (any, error) { + args, err := decodeParams[daemonrpc.RemoveGmailLabelParams](params) + if err != nil { + return nil, parseError(err) + } + + d.mu.RLock() + account := d.config.GetAccountByID(args.AccountID) + d.mu.RUnlock() + + if account == nil { + return nil, fmt.Errorf("no account for %s", args.AccountID) + } + + ctx, cancel := context.WithTimeout(ctx, mutateTimeout) + defer cancel() + _ = ctx + + if err := fetcher.RemoveGmailLabel(account, args.Folder, args.UID, args.Label); err != nil { + return nil, err + } + return true, nil +} diff --git a/daemonclient/service.go b/daemonclient/service.go index 00e5f646..27e4ff37 100644 --- a/daemonclient/service.go +++ b/daemonclient/service.go @@ -32,6 +32,8 @@ type Service interface { MoveEmails(accountID string, uids []uint32, src, dst string) error MarkRead(accountID, folder string, uids []uint32) error MarkUnread(accountID, folder string, uids []uint32) error + AddGmailLabel(accountID, folder string, uid uint32, label string) error + RemoveGmailLabel(accountID, folder string, uid uint32, label string) error QueueEmail(accountID string, to, cc, bcc []string, subject, body, htmlBody string, images map[string][]byte, attachments map[string][]byte, inReplyTo string, references []string, signSMIME, encryptSMIME, signPGP, encryptPGP bool, delaySeconds int, prebuiltRaw []byte) (string, error) CancelEmail(jobID string) error FetchFolders(accountID string) ([]backend.Folder, error) @@ -287,6 +289,28 @@ func (s *daemonService) MarkUnread(accountID, folder string, uids []uint32) erro }) } +func (s *daemonService) AddGmailLabel(accountID, folder string, uid uint32, label string) error { + return s.call(func(c *Client) error { + return c.Call(daemonrpc.MethodAddGmailLabel, daemonrpc.AddGmailLabelParams{ + AccountID: accountID, + Folder: folder, + UID: uid, + Label: label, + }, nil) + }) +} + +func (s *daemonService) RemoveGmailLabel(accountID, folder string, uid uint32, label string) error { + return s.call(func(c *Client) error { + return c.Call(daemonrpc.MethodRemoveGmailLabel, daemonrpc.RemoveGmailLabelParams{ + AccountID: accountID, + Folder: folder, + UID: uid, + Label: label, + }, nil) + }) +} + func (s *daemonService) QueueEmail(accountID string, to, cc, bcc []string, subject, body, htmlBody string, images map[string][]byte, attachments map[string][]byte, inReplyTo string, references []string, signSMIME, encryptSMIME, signPGP, encryptPGP bool, delaySeconds int, prebuiltRaw []byte) (string, error) { var result daemonrpc.QueueEmailResult err := s.call(func(c *Client) error { @@ -495,6 +519,22 @@ func (s *directService) MarkUnread(accountID, folder string, uids []uint32) erro return nil } +func (s *directService) AddGmailLabel(accountID, folder string, uid uint32, label string) error { + acct := s.cfg.GetAccountByID(accountID) + if acct == nil { + return fmt.Errorf("no account for %s", accountID) + } + return fetcher.AddGmailLabel(acct, folder, uid, label) +} + +func (s *directService) RemoveGmailLabel(accountID, folder string, uid uint32, label string) error { + acct := s.cfg.GetAccountByID(accountID) + if acct == nil { + return fmt.Errorf("no account for %s", accountID) + } + return fetcher.RemoveGmailLabel(acct, folder, uid, label) +} + func (s *directService) FetchFolders(accountID string) ([]backend.Folder, error) { p, err := s.getProvider(accountID) if err != nil { diff --git a/daemonrpc/protocol.go b/daemonrpc/protocol.go index eba7a72b..20e0cbe5 100644 --- a/daemonrpc/protocol.go +++ b/daemonrpc/protocol.go @@ -29,27 +29,29 @@ const ( // RPC method names. const ( - MethodPing = "Ping" - MethodGetStatus = "GetStatus" - MethodGetAccounts = "GetAccounts" - MethodReloadConfig = "ReloadConfig" - MethodFetchEmails = "FetchEmails" - MethodFetchEmailBody = "FetchEmailBody" - MethodSendEmail = "SendEmail" - MethodDeleteEmails = "DeleteEmails" - MethodArchiveEmails = "ArchiveEmails" - MethodMoveEmails = "MoveEmails" - MethodMarkRead = "MarkRead" - MethodFetchFolders = "FetchFolders" - MethodRefreshFolder = "RefreshFolder" - MethodSubscribe = "Subscribe" - MethodUnsubscribe = "Unsubscribe" - MethodSendRSVP = "SendRSVP" - MethodGetCachedEmails = "GetCachedEmails" - MethodGetCachedBody = "GetCachedBody" - MethodExportContacts = "ExportContacts" - MethodQueueEmail = "QueueEmail" - MethodCancelEmail = "CancelEmail" + MethodPing = "Ping" + MethodGetStatus = "GetStatus" + MethodGetAccounts = "GetAccounts" + MethodReloadConfig = "ReloadConfig" + MethodFetchEmails = "FetchEmails" + MethodFetchEmailBody = "FetchEmailBody" + MethodSendEmail = "SendEmail" + MethodDeleteEmails = "DeleteEmails" + MethodArchiveEmails = "ArchiveEmails" + MethodMoveEmails = "MoveEmails" + MethodMarkRead = "MarkRead" + MethodFetchFolders = "FetchFolders" + MethodRefreshFolder = "RefreshFolder" + MethodSubscribe = "Subscribe" + MethodUnsubscribe = "Unsubscribe" + MethodSendRSVP = "SendRSVP" + MethodGetCachedEmails = "GetCachedEmails" + MethodGetCachedBody = "GetCachedBody" + MethodExportContacts = "ExportContacts" + MethodQueueEmail = "QueueEmail" + MethodCancelEmail = "CancelEmail" + MethodAddGmailLabel = "AddGmailLabel" + MethodRemoveGmailLabel = "RemoveGmailLabel" ) // Event type names. @@ -209,6 +211,20 @@ type ExportContactsParams struct { Format string `json:"format"` // "json" or "csv" } +type AddGmailLabelParams struct { + AccountID string `json:"account_id"` + Folder string `json:"folder"` + UID uint32 `json:"uid"` + Label string `json:"label"` +} + +type RemoveGmailLabelParams struct { + AccountID string `json:"account_id"` + Folder string `json:"folder"` + UID uint32 `json:"uid"` + Label string `json:"label"` +} + // Event data types. type NewMailEvent struct { diff --git a/fetcher/dispatch.go b/fetcher/dispatch.go index d1ab098e..fb1f07c6 100644 --- a/fetcher/dispatch.go +++ b/fetcher/dispatch.go @@ -50,6 +50,7 @@ func backendEmailsToFetcher(in []backend.Email) []Email { References: e.References, Attachments: backendAttachmentsToFetcher(e.Attachments), AccountID: e.AccountID, + Labels: e.Labels, } } return out diff --git a/fetcher/fetcher.go b/fetcher/fetcher.go index 93aa4e81..469b077a 100644 --- a/fetcher/fetcher.go +++ b/fetcher/fetcher.go @@ -116,7 +116,8 @@ type Email struct { InReplyTo string References []string Attachments []Attachment - AccountID string // ID of the account this email belongs to + AccountID string // ID of the account this email belongs to + Labels []string // Gmail X-GM-LABELS (empty for non-Gmail providers) } var headerMessageIDRE = regexp.MustCompile(`<[^>]+>`) @@ -586,6 +587,20 @@ func fetchIMAPEmails(account *config.Account, mailbox string, limit, offset uint batchEmails := filterAndBuildBatchEmails(batchMsgs, fetchEmail, account, isSentMailbox, deliveryHeaderSection) + // For Gmail accounts, fetch X-GM-LABELS for the UIDs in this batch. + if isGmailAccount(account) { + labelMap, err := fetchGmailLabelsForBatch(account, mailbox, batchEmails) + if err != nil { + loglevel.Debugf("gmail labels: fetch failed for batch: %v", err) + } else if labelMap != nil { + for i := range batchEmails { + if labels, ok := labelMap[batchEmails[i].UID]; ok { + batchEmails[i].Labels = labels + } + } + } + } + // Sort batch Newest -> Oldest by UID desc sort.Slice(batchEmails, func(i, j int) bool { return batchEmails[i].UID > batchEmails[j].UID diff --git a/fetcher/gmail_labels.go b/fetcher/gmail_labels.go new file mode 100644 index 00000000..825eb7b8 --- /dev/null +++ b/fetcher/gmail_labels.go @@ -0,0 +1,479 @@ +package fetcher + +import ( + "bufio" + "crypto/tls" + "encoding/base64" + "fmt" + "io" + "net" + "strconv" + "strings" + "sync/atomic" + "time" + + "github.com/emersion/go-imap/v2" + "github.com/floatpane/matcha/config" +) + +// imapQuote wraps a string in double quotes, escaping any backslash and +// double-quote characters, for use in raw IMAP commands. +func imapQuote(s string) string { + s = strings.ReplaceAll(s, "\\", "\\\\") + s = strings.ReplaceAll(s, "\"", "\\\"") + return "\"" + s + "\"" +} + +// isGmailAccount reports whether the account is configured for Gmail. +func isGmailAccount(account *config.Account) bool { + return account != nil && strings.EqualFold(account.ServiceProvider, config.ProviderGmail) +} + +// fetchGmailLabelsForBatch fetches X-GM-LABELS for the UIDs in the given batch +// of emails. It returns a map of UID to labels. +func fetchGmailLabelsForBatch(account *config.Account, mailbox string, emails []Email) (map[uint32][]string, error) { + if len(emails) == 0 { + return nil, nil + } + var uidSet imap.UIDSet + for _, e := range emails { + uidSet.AddNum(imap.UID(e.UID)) + } + return fetchGmailLabels(account, mailbox, uidSet) +} + +// AddGmailLabel adds a Gmail label to the specified email on the server. +func AddGmailLabel(account *config.Account, mailbox string, uid uint32, label string) error { + if !isGmailAccount(account) { + return fmt.Errorf("gmail labels: not a Gmail account") + } + return storeGmailLabels(account, mailbox, uid, "+", []string{label}) +} + +// RemoveGmailLabel removes a Gmail label from the specified email on the server. +func RemoveGmailLabel(account *config.Account, mailbox string, uid uint32, label string) error { + if !isGmailAccount(account) { + return fmt.Errorf("gmail labels: not a Gmail account") + } + return storeGmailLabels(account, mailbox, uid, "-", []string{label}) +} + +// isGmailSystemLabel reports whether the label name (without backslash) is a +// Gmail system label that requires a \ prefix in IMAP commands. +var gmailSystemLabels = map[string]bool{ + "Inbox": true, + "Starred": true, + "Important": true, + "Sent": true, + "Drafts": true, + "Trash": true, + "Spam": true, + "All": true, +} + +func isGmailSystemLabel(label string) bool { + return gmailSystemLabels[label] +} + +// fetchGmailLabels connects to the IMAP server, selects the given mailbox, and +// issues a raw UID FETCH ... X-GM-LABELS command. It returns a map of UID to +// the list of Gmail labels for that message. +// +// The go-imap/v2 library does not understand the X-GM-LABELS extension, so we +// perform a raw IMAP session over a separate TLS connection. This keeps the +// label fetch self-contained and avoids modifying the library. +func fetchGmailLabels(account *config.Account, mailbox string, uidSet imap.UIDSet) (map[uint32][]string, error) { //nolint:gocyclo + conn, br, bw, err := rawIMAPConnect(account) + if err != nil { + return nil, err + } + defer conn.Close() //nolint:errcheck + + // Select mailbox + selectResp, err := rawIMAPCommand(br, bw, "SELECT "+imapQuote(mailbox)) + if err != nil { + return nil, fmt.Errorf("gmail labels: SELECT failed: %w", err) + } + _ = selectResp // we don't need the response data + + // UID FETCH (UID X-GM-LABELS) + fetchResp, err := rawIMAPCommand(br, bw, "UID FETCH "+uidSet.String()+" (UID X-GM-LABELS)") + if err != nil { + return nil, fmt.Errorf("gmail labels: FETCH failed: %w", err) + } + + labelsByUID := make(map[uint32][]string) + for _, line := range fetchResp.untagged { + uid, labels, ok := parseGmailLabelsLine(line) + if ok { + labelsByUID[uid] = labels + } + } + + // Logout + _, _ = rawIMAPCommand(br, bw, "LOGOUT") + return labelsByUID, nil +} + +// storeGmailLabels connects to the IMAP server, selects the mailbox, and issues +// a raw UID STORE command to add or remove Gmail labels. +// op is "+" to add labels or "-" to remove labels. +func storeGmailLabels(account *config.Account, mailbox string, uid uint32, op string, labels []string) error { + conn, br, bw, err := rawIMAPConnect(account) + if err != nil { + return err + } + defer conn.Close() //nolint:errcheck + + if _, err := rawIMAPCommand(br, bw, "SELECT "+imapQuote(mailbox)); err != nil { + return fmt.Errorf("gmail labels: SELECT failed: %w", err) + } + + quoted := make([]string, len(labels)) + for i, l := range labels { + // Restore backslash prefix for Gmail system labels + if isGmailSystemLabel(l) { + l = "\\" + l + } + quoted[i] = imapQuote(l) + } + labelList := "(" + strings.Join(quoted, " ") + ")" + + cmd := fmt.Sprintf("UID STORE %d %sX-GM-LABELS %s", uid, op, labelList) + if _, err := rawIMAPCommand(br, bw, cmd); err != nil { + return fmt.Errorf("gmail labels: STORE failed: %w", err) + } + + _, _ = rawIMAPCommand(br, bw, "LOGOUT") + return nil +} + +// --- raw IMAP protocol helpers --- + +var rawIMAPTagCounter atomic.Uint64 + +type rawIMAPResponse struct { + tagged string + untagged []string +} + +func rawIMAPConnect(account *config.Account) (net.Conn, *bufio.Reader, *bufio.Writer, error) { + imapServer := account.GetIMAPServer() + imapPort := account.GetIMAPPort() + if imapServer == "" { + return nil, nil, nil, fmt.Errorf("unsupported service_provider: %s", account.ServiceProvider) + } + + addr := net.JoinHostPort(imapServer, strconv.Itoa(imapPort)) + tlsConfig := &tls.Config{ + ServerName: imapServer, + InsecureSkipVerify: account.Insecure, //nolint:gosec + MinVersion: tls.VersionTLS12, + } + + var conn net.Conn + var err error + + if imapPort == 1143 || imapPort == 143 { + // Plain TCP + STARTTLS + rawConn, err := net.DialTimeout("tcp", addr, 30*time.Second) + if err != nil { + return nil, nil, nil, err + } + br := bufio.NewReader(rawConn) + bw := bufio.NewWriter(rawConn) + + // Read greeting + greeting, err := br.ReadString('\n') + if err != nil { + rawConn.Close() //nolint:errcheck + return nil, nil, nil, fmt.Errorf("gmail labels: failed to read greeting: %w", err) + } + if !strings.HasPrefix(greeting, "* OK") && !strings.HasPrefix(greeting, "* PREAUTH") { + rawConn.Close() //nolint:errcheck + return nil, nil, nil, fmt.Errorf("gmail labels: bad greeting: %s", strings.TrimSpace(greeting)) + } + + // Send STARTTLS + if _, err := rawIMAPCommand(br, bw, "STARTTLS"); err != nil { + rawConn.Close() //nolint:errcheck + return nil, nil, nil, fmt.Errorf("gmail labels: STARTTLS failed: %w", err) + } + + // Upgrade to TLS + tlsConn := tls.Client(rawConn, tlsConfig) + if err := tlsConn.Handshake(); err != nil { + rawConn.Close() //nolint:errcheck + return nil, nil, nil, fmt.Errorf("gmail labels: TLS handshake failed: %w", err) + } + + conn = tlsConn + br = bufio.NewReader(conn) + bw = bufio.NewWriter(conn) + + // Re-read greeting is not sent after STARTTLS; proceed to auth + // We need to authenticate now + return rawIMAPAuthAndReturn(conn, br, bw, account) + } + + conn, err = tls.Dial("tcp", addr, tlsConfig) + if err != nil { + return nil, nil, nil, err + } + + br := bufio.NewReader(conn) + bw := bufio.NewWriter(conn) + + // Read greeting + greeting, err := br.ReadString('\n') + if err != nil { + conn.Close() //nolint:errcheck + return nil, nil, nil, fmt.Errorf("gmail labels: failed to read greeting: %w", err) + } + if !strings.HasPrefix(greeting, "* OK") && !strings.HasPrefix(greeting, "* PREAUTH") { + conn.Close() //nolint:errcheck + return nil, nil, nil, fmt.Errorf("gmail labels: bad greeting: %s", strings.TrimSpace(greeting)) + } + + return rawIMAPAuthAndReturn(conn, br, bw, account) +} + +// rawIMAPAuthAndReturn authenticates the connection and returns the ready-to-use +// reader/writer. +func rawIMAPAuthAndReturn(conn net.Conn, br *bufio.Reader, bw *bufio.Writer, account *config.Account) (net.Conn, *bufio.Reader, *bufio.Writer, error) { + if account.IsOAuth2() { + token, err := config.GetOAuth2Token(account.Email) + if err != nil { + conn.Close() //nolint:errcheck + return nil, nil, nil, fmt.Errorf("gmail labels: oauth2: %w", err) + } + authStr := fmt.Sprintf("user=%s\x01auth=Bearer %s\x01\x01", account.Email, token) + encoded := base64.StdEncoding.EncodeToString([]byte(authStr)) + if _, err := rawIMAPCommand(br, bw, "AUTHENTICATE XOAUTH2 "+encoded); err != nil { + conn.Close() //nolint:errcheck + return nil, nil, nil, fmt.Errorf("gmail labels: XOAUTH2 failed: %w", err) + } + } else { + if _, err := rawIMAPCommand(br, bw, fmt.Sprintf("LOGIN %s %s", imapQuote(account.Email), imapQuote(account.Password))); err != nil { + conn.Close() //nolint:errcheck + return nil, nil, nil, fmt.Errorf("gmail labels: LOGIN failed: %w", err) + } + } + return conn, br, bw, nil +} + +// rawIMAPCommand sends a tagged IMAP command and reads all responses until the +// tagged OK/NO/BAD response. +func rawIMAPCommand(br *bufio.Reader, bw *bufio.Writer, cmd string) (*rawIMAPResponse, error) { + tag := fmt.Sprintf("GML%d", rawIMAPTagCounter.Add(1)) + fullCmd := tag + " " + cmd + "\r\n" + + if _, err := bw.WriteString(fullCmd); err != nil { + return nil, err + } + if err := bw.Flush(); err != nil { + return nil, err + } + + resp := &rawIMAPResponse{} + for { + line, err := readIMAPLine(br) + if err != nil { + return nil, err + } + + if strings.HasPrefix(line, tag+" ") { + resp.tagged = line + if strings.Contains(line, " OK ") || strings.Contains(line, " OK\r") || strings.HasSuffix(line, " OK") { + return resp, nil + } + return resp, fmt.Errorf("IMAP error: %s", strings.TrimSpace(line)) + } + + if strings.HasPrefix(line, "* ") { + resp.untagged = append(resp.untagged, line) + } + + // Continuation requests (e.g. during AUTHENTICATE) — just send empty line + if strings.HasPrefix(line, "+ ") { + if _, err := bw.WriteString("\r\n"); err != nil { + return nil, err + } + if err := bw.Flush(); err != nil { + return nil, err + } + } + } +} + +// readIMAPLine reads a single line from the IMAP response, handling literal +// strings ({NNN} octet counts) by continuing to read until the full literal is +// consumed. +func readIMAPLine(br *bufio.Reader) (string, error) { + line, err := br.ReadString('\n') + if err != nil { + return "", err + } + + // Handle literal strings: if the line ends with {NNN}\r\n, read that many bytes + // and append them, then continue reading the rest of the line. + for { + idx := strings.LastIndex(line, "{") + if idx < 0 { + break + } + closeIdx := strings.Index(line[idx:], "}") + if closeIdx < 0 { + break + } + numStr := line[idx+1 : idx+closeIdx] + n, err := strconv.Atoi(numStr) + if err != nil { + break + } + // Check if the {NNN} is at the end of the line (before \r\n) + afterBrace := idx + closeIdx + 1 + remainder := strings.TrimRight(line[afterBrace:], "\r\n") + if remainder != "" { + break // {NNN} is not at end of line, not a literal + } + + // Read the literal bytes + literal := make([]byte, n) + if _, err := io.ReadFull(br, literal); err != nil { + return "", err + } + line = line[:afterBrace] + string(literal) + + // Continue reading the rest of the line after the literal + rest, err := br.ReadString('\n') + if err != nil { + return "", err + } + line += rest + } + + return line, nil +} + +// parseGmailLabelsLine parses a single untagged FETCH response line and +// extracts the UID and X-GM-LABELS values. +// Example line: * 123 FETCH (UID 456 X-GM-LABELS (\Inbox "My Label")) +func parseGmailLabelsLine(line string) (uint32, []string, bool) { + // Find "UID " followed by a number + uidIdx := strings.Index(line, "UID ") + if uidIdx < 0 { + return 0, nil, false + } + uidStart := uidIdx + 4 + uidEnd := uidStart + for uidEnd < len(line) && line[uidEnd] >= '0' && line[uidEnd] <= '9' { + uidEnd++ + } + if uidEnd == uidStart { + return 0, nil, false + } + uid, err := strconv.ParseUint(line[uidStart:uidEnd], 10, 32) + if err != nil { + return 0, nil, false + } + + // Find X-GM-LABELS + labelsIdx := strings.Index(line, "X-GM-LABELS") + if labelsIdx < 0 { + return uint32(uid), nil, true + } + + // Find the opening paren after X-GM-LABELS + parenStart := strings.Index(line[labelsIdx:], "(") + if parenStart < 0 { + return uint32(uid), nil, true + } + parenStart += labelsIdx + + // Find the matching closing paren (labels can contain parens inside quotes) + depth := 0 + parenEnd := -1 + inQuote := false + for i := parenStart; i < len(line); i++ { + ch := line[i] + if inQuote { + if ch == '\\' && i+1 < len(line) { + i++ // skip escaped char + continue + } + if ch == '"' { + inQuote = false + } + continue + } + if ch == '"' { + inQuote = true + } else if ch == '(' { + depth++ + } else if ch == ')' { + depth-- + if depth == 0 { + parenEnd = i + break + } + } + } + if parenEnd < 0 { + return uint32(uid), nil, true + } + + labelsContent := line[parenStart+1 : parenEnd] + labels := parseLabelList(labelsContent) + return uint32(uid), labels, true +} + +// parseLabelList parses the space-separated list of labels inside the +// X-GM-LABELS parentheses. Labels can be quoted strings or atoms (system +// labels like \Inbox). +func parseLabelList(s string) []string { + var labels []string + i := 0 + for i < len(s) { + // Skip whitespace + for i < len(s) && (s[i] == ' ' || s[i] == '\r' || s[i] == '\n') { + i++ + } + if i >= len(s) { + break + } + + if s[i] == '"' { + // Quoted string + i++ + start := i + for i < len(s) { + if s[i] == '\\' && i+1 < len(s) { + i += 2 + continue + } + if s[i] == '"' { + break + } + i++ + } + labels = append(labels, s[start:i]) + if i < len(s) { + i++ // skip closing quote + } + } else { + // Atom (e.g. \Inbox, \Sent) — strip leading backslash + start := i + if s[i] == '\\' { + i++ + } + atomStart := i + for i < len(s) && s[i] != ' ' && s[i] != '\r' && s[i] != '\n' { + i++ + } + _ = start + labels = append(labels, s[atomStart:i]) + } + } + return labels +} diff --git a/internal/emailstore/emailstore.go b/internal/emailstore/emailstore.go index 92177494..d5fe822b 100644 --- a/internal/emailstore/emailstore.go +++ b/internal/emailstore/emailstore.go @@ -4,6 +4,7 @@ import ( "fmt" "log" "slices" + "strings" "time" "github.com/floatpane/matcha/config" @@ -258,6 +259,60 @@ func (s *Store) RemoveEmailFromStores(uid uint32, accountID string) { } } +// AddGmailLabel adds a Gmail label to the email with the given UID and account, +// updating all in-memory stores. No-op if the label already exists. +func (s *Store) AddGmailLabel(uid uint32, accountID, label string) { + s.updateEmailLabels(uid, accountID, func(labels []string) []string { + for _, l := range labels { + if strings.EqualFold(l, label) { + return labels + } + } + return append(labels, label) + }) +} + +// RemoveGmailLabel removes a Gmail label from the email with the given UID and +// account, updating all in-memory stores. No-op if the label doesn't exist. +func (s *Store) RemoveGmailLabel(uid uint32, accountID, label string) { + s.updateEmailLabels(uid, accountID, func(labels []string) []string { + var filtered []string + for _, l := range labels { + if !strings.EqualFold(l, label) { + filtered = append(filtered, l) + } + } + return filtered + }) +} + +func (s *Store) updateEmailLabels(uid uint32, accountID string, fn func([]string) []string) { + for i := range s.Emails { + if s.Emails[i].UID == uid && s.Emails[i].AccountID == accountID { + s.Emails[i].Labels = fn(s.Emails[i].Labels) + break + } + } + if emails, ok := s.EmailsByAcct[accountID]; ok { + for i := range emails { + if emails[i].UID == uid { + emails[i].Labels = fn(emails[i].Labels) + break + } + } + } + for folderName, folderEmails := range s.FolderEmails { + for i := range folderEmails { + if folderEmails[i].UID == uid && folderEmails[i].AccountID == accountID { + folderEmails[i].Labels = fn(folderEmails[i].Labels) + s.FolderEmails[folderName] = folderEmails + go SaveFolderEmailsToCache(folderName, folderEmails) + break + } + } + } +} + func (s *Store) RemoveFolderEmail(folderName string, uid uint32, accountID string) []fetcher.Email { emails, ok := s.FolderEmails[folderName] if !ok { @@ -333,6 +388,7 @@ func EmailsToCache(emails []fetcher.Email) []config.CachedEmail { References: email.References, AccountID: email.AccountID, IsRead: email.IsRead, + Labels: email.Labels, }) } return cached @@ -352,6 +408,7 @@ func CacheToEmails(cached []config.CachedEmail) []fetcher.Email { References: c.References, AccountID: c.AccountID, IsRead: c.IsRead, + Labels: c.Labels, }) } return emails diff --git a/tui/email_view.go b/tui/email_view.go index 213eb8a7..a609632d 100644 --- a/tui/email_view.go +++ b/tui/email_view.go @@ -158,6 +158,10 @@ func NewEmailView(email fetcher.Email, emailIndex, width, height int, mailbox Ma // Create header and compute heights that reduce viewport space. header := fmt.Sprintf("From: %s\nSubject: %s", email.From, email.Subject) + labelStr := renderLabelTags(email.Labels) + if labelStr != "" { + header += "\n" + labelStr + } headerHeight := lipgloss.Height(header) + 2 attachmentHeight := 0 @@ -395,6 +399,15 @@ func (m *EmailView) handleEmailBodyKey(msg tea.KeyPressMsg) (bool, func() (tea.M if len(m.email.Attachments) > 0 { m.focusOnAttachments = true } + case kb.Email.EditLabels: + // Only activate for Gmail accounts + if m.accountID != "" { + return true, func() (tea.Model, tea.Cmd) { + return m, func() tea.Msg { + return EditLabelsMsg{Email: m.email, AccountID: m.accountID, Folder: string(m.mailbox)} + } + } + } } return false, nil } @@ -430,6 +443,10 @@ func (m *EmailView) regenerateBody() { // terminal is resized. func (m *EmailView) handleWindowSize(msg tea.WindowSizeMsg) { header := fmt.Sprintf("To: %s\nFrom: %s\nSubject: %s ", strings.Join(m.email.To, ", "), m.email.From, m.email.Subject) + labelStr := renderLabelTags(m.email.Labels) + if labelStr != "" { + header += "\n" + labelStr + } headerHeight := lipgloss.Height(header) + 2 attachmentHeight := 0 if len(m.email.Attachments) > 0 { @@ -511,6 +528,13 @@ func (m *EmailView) renderHeader() string { } header := fmt.Sprintf("To: %s | From: %s | Subject: %s%s", strings.Join(m.email.To, ", "), m.email.From, m.email.Subject, cryptoStatus.String()) + + // Render Gmail labels in the header if present + labelStr := renderLabelTags(m.email.Labels) + if labelStr != "" { + header += "\n" + labelStr + } + return emailHeaderStyle.Width(m.viewport.Width()).Render(header) } @@ -529,7 +553,11 @@ func (m *EmailView) renderHelp() string { if m.isPatch && m.patchInfo != nil && m.patchInfo.HasDiff { shortcuts.WriteString(" • \uf126 p: apply patch") } - shortcuts.WriteString(" • \uf1d3 P: send patch") + shortcuts.WriteString("\uf1d3 P: send patch") + kb := config.Keybinds + if kb.Email.EditLabels != "" { + shortcuts.WriteString(" • \uf02b " + kb.Email.EditLabels + ": labels") + } if view.ImageProtocolSupported() { shortcuts.WriteString("• \uf03e i: toggle images") } diff --git a/tui/folder_inbox.go b/tui/folder_inbox.go index 4e3886e2..20297f7e 100644 --- a/tui/folder_inbox.go +++ b/tui/folder_inbox.go @@ -116,6 +116,9 @@ type FolderInbox struct { jumpFilterInput textinput.Model jumpFiltered []int // indices into folders when filtering + // Gmail label overlay state + labelOverlay *LabelOverlayState + // Image rendering preference, propagated from config. disableImages bool @@ -261,6 +264,11 @@ func (m *FolderInbox) Update(msg tea.Msg) (tea.Model, tea.Cmd) { //nolint:gocycl return m.updateJumpOverlay(msg) } + // If Gmail label overlay is active, handle its input + if m.labelOverlay != nil && m.labelOverlay.active { + return m.updateLabelOverlay(msg) + } + switch msg := msg.(type) { case tea.MouseWheelMsg: if msg.X < sidebarWidth { @@ -408,6 +416,26 @@ func (m *FolderInbox) Update(msg tea.Msg) (tea.Model, tea.Cmd) { //nolint:gocycl m.jumpFilterInput = ti return m, nil + case EditLabelsMsg: + // Start label overlay — only for Gmail accounts + var account *config.Account + for i := range m.accounts { + if m.accounts[i].ID == msg.AccountID { + account = &m.accounts[i] + break + } + } + if account == nil || !IsGmailAccount(account) { + return m, nil + } + folder := msg.Folder + if folder == "" { + folder = m.currentFolder + } + state := NewLabelOverlayState(msg.Email, *account, folder) + m.labelOverlay = &state + return m, textinput.Blink + case tea.WindowSizeMsg: m.width = msg.Width m.height = msg.Height @@ -571,6 +599,15 @@ func (m *FolderInbox) wrapInboxCmd(cmd tea.Cmd) tea.Cmd { } } +func (m *FolderInbox) updateLabelOverlay(msg tea.Msg) (tea.Model, tea.Cmd) { + state, cmd, dismissed := m.labelOverlay.UpdateLabelOverlay(msg) + m.labelOverlay = &state + if dismissed { + m.labelOverlay = nil + } + return m, cmd +} + func (m *FolderInbox) updateMoveOverlay(msg tea.Msg) (tea.Model, tea.Cmd) { kb := config.Keybinds if msg, ok := msg.(tea.KeyPressMsg); ok { @@ -848,6 +885,11 @@ func (m *FolderInbox) View() tea.View { content = m.renderWithJumpOverlay(content) } + // If Gmail label overlay is active, render it on top + if m.labelOverlay != nil && m.labelOverlay.active { + content = m.labelOverlay.RenderLabelOverlay(content, m.width, m.height) + } + // Composite plugin-injected custom components (floating overlays and // anchored blocks) onto the final content. content = renderCustomComponents(content, m.width, m.height) diff --git a/tui/inbox.go b/tui/inbox.go index af7efd88..dd2754e7 100644 --- a/tui/inbox.go +++ b/tui/inbox.go @@ -46,6 +46,7 @@ type item struct { threadChild bool threadDepth int expanded bool + labels []string } func (i item) Title() string { return i.title } @@ -183,7 +184,13 @@ func (d itemDelegate) Render(w io.Writer, m list.Model, index int, listItem list } styledSubject := statusStyle.Render(subject) + // Render Gmail labels as styled tags after the subject + labelStr := renderLabelTags(i.labels) + str := prefix + styledIcon + " " + styledSender + separator + styledSubject + if labelStr != "" { + str += " " + labelStr + } // Pad to push date to the right padding := listWidth - lipgloss.Width(str) - dateWidth - cursorWidth @@ -754,6 +761,7 @@ func (m *Inbox) itemForEmail(email fetcher.Email, index int, showAccountLabel bo accountEmail: accountEmail, date: email.Date, isRead: email.IsRead, + labels: email.Labels, } } @@ -1114,6 +1122,30 @@ func (m *Inbox) Update(msg tea.Msg) (tea.Model, tea.Cmd) { //nolint:gocyclo return ViewEmailMsg{Index: idx, UID: uid, AccountID: accountID, Mailbox: m.mailbox, Email: email} } } + case kb.Inbox.EditLabels: + // Only activate for Gmail accounts — validation done in app.go + selectedItem, ok := m.list.SelectedItem().(item) + if ok && selectedItem.uid != 0 { + // Find the email to get labels + var email fetcher.Email + displayEmails := m.displayEmails() + if selectedItem.originalIndex >= 0 && selectedItem.originalIndex < len(displayEmails) { + email = displayEmails[selectedItem.originalIndex] + } else { + email = fetcher.Email{ + UID: selectedItem.uid, + AccountID: selectedItem.accountID, + Labels: selectedItem.labels, + } + } + return m, func() tea.Msg { + return EditLabelsMsg{ + Email: email, + AccountID: selectedItem.accountID, + Folder: m.folderName, + } + } + } } case tea.WindowSizeMsg: m.width = msg.Width diff --git a/tui/label_overlay.go b/tui/label_overlay.go new file mode 100644 index 00000000..67075176 --- /dev/null +++ b/tui/label_overlay.go @@ -0,0 +1,426 @@ +package tui + +import ( + "fmt" + "hash/fnv" + "image/color" + "strings" + + "charm.land/bubbles/v2/textinput" + tea "charm.land/bubbletea/v2" + "charm.land/lipgloss/v2" + overlay "github.com/floatpane/bubble-overlay" + "github.com/floatpane/matcha/config" + "github.com/floatpane/matcha/fetcher" + "github.com/floatpane/matcha/theme" +) + +// labelOverlayStyle matches the move overlay styling. +var labelOverlayStyle = lipgloss.NewStyle(). + Border(lipgloss.RoundedBorder()). + BorderForeground(lipgloss.Color("#25A065")). + Padding(1, 2) + +var labelOverlayTitleStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color("42")). + Bold(true). + PaddingBottom(1) + +// labelMeta holds the display icon, foreground, and background color for a +// Gmail label. +type labelMeta struct { + icon string + foreground color.Color + background color.Color +} + +// gmailLabelColors maps known Gmail system labels to their Gmail-style colors, +// background colors, and unicode icons. Keys are the canonical label names +// WITHOUT any leading backslash. +var gmailLabelColors = map[string]labelMeta{ + "Inbox": {"\uF01C", lipgloss.Color("#1a73e8"), lipgloss.Color("#e8f0fe")}, + "Starred": {"\u2605", lipgloss.Color("#b45f06"), lipgloss.Color("#fce8b2")}, + "Important": {"\u26A1", lipgloss.Color("#c35a00"), lipgloss.Color("#fedfc2")}, + "Sent": {"\uF1D8", lipgloss.Color("#1e8e3e"), lipgloss.Color("#ceead6")}, + "Drafts": {"\uF040", lipgloss.Color("#5f6368"), lipgloss.Color("#e8eaed")}, + "Trash": {"\uF1F8", lipgloss.Color("#d93025"), lipgloss.Color("#fad2cf")}, + "Spam": {"\uF071", lipgloss.Color("#9aa0a6"), lipgloss.Color("#e8eaed")}, + "All": {"\uF0C0", lipgloss.Color("#1a73e8"), lipgloss.Color("#e8f0fe")}, +} + +// Color palette for user labels (non-system). Each gets a deterministic +// foreground/background pair based on a hash of the label name. +var userLabelPalette = []labelMeta{ + {"", lipgloss.Color("#1a73e8"), lipgloss.Color("#e8f0fe")}, // blue + {"", lipgloss.Color("#9334e6"), lipgloss.Color("#f3e8fd")}, // purple + {"", lipgloss.Color("#e8710a"), lipgloss.Color("#feefe3")}, // orange + {"", lipgloss.Color("#1e8e3e"), lipgloss.Color("#ceead6")}, // green + {"", lipgloss.Color("#d93025"), lipgloss.Color("#fad2cf")}, // red + {"", lipgloss.Color("#c5221f"), lipgloss.Color("#fad2cf")}, // dark red + {"", lipgloss.Color("#b45f06"), lipgloss.Color("#fce8b2")}, // yellow + {"", lipgloss.Color("#137333"), lipgloss.Color("#ceead6")}, // dark green + {"", lipgloss.Color("#8430ce"), lipgloss.Color("#f3e8fd")}, // dark purple + {"", lipgloss.Color("#b31412"), lipgloss.Color("#fad2cf")}, // brick + {"", lipgloss.Color("#185abc"), lipgloss.Color("#e8f0fe")}, // dark blue + {"", lipgloss.Color("#b80672"), lipgloss.Color("#fde7f3")}, // pink +} + +// labelMetaFor returns the icon, foreground, and background for a given Gmail +// label. System labels have fixed styling; user labels get a deterministic +// pair from a hash of the name. The label may or may not have leading +// backslashes (legacy cache data sometimes contains them). +func labelMetaFor(label string) labelMeta { + stripped := stripBackslashes(label) + if meta, ok := gmailLabelColors[stripped]; ok { + return meta + } + // User label: deterministic color from hash + h := fnv1aHash(stripped) + meta := userLabelPalette[int(h)%len(userLabelPalette)] + meta.icon = "\uF02B" // tag icon + return meta +} + +// stripBackslashes removes all leading backslashes from a Gmail label name. +func stripBackslashes(s string) string { + for strings.HasPrefix(s, "\\") { + s = strings.TrimPrefix(s, "\\") + } + return s +} + +// fnv1aHash returns a 32-bit FNV-1a hash of the given string. +func fnv1aHash(s string) uint32 { + h := fnv.New32a() + h.Write([]byte(s)) + return h.Sum32() +} + +// displayLabelName returns the canonical display name for a label: all leading +// backslashes are stripped. +func displayLabelName(label string) string { + return stripBackslashes(label) +} + +// renderLabelTags renders a list of labels as a single space-separated string of +// background-colored pill tags. +func renderLabelTags(labels []string) string { + if len(labels) == 0 { + return "" + } + var parts []string + seen := make(map[string]bool) + for _, l := range labels { + name := displayLabelName(l) + if name == "" || seen[name] { + continue + } + seen[name] = true + parts = append(parts, renderLabelTag(name)) + } + return strings.Join(parts, " ") +} + +// renderLabelTag renders a single label as a background-colored pill tag. +func renderLabelTag(label string) string { + meta := labelMetaFor(label) + name := displayLabelName(label) + style := lipgloss.NewStyle(). + Foreground(meta.foreground). + Background(meta.background). + Padding(0, 1) + return style.Render(meta.icon + " " + name) +} + +// --- Label overlay state (embedded in FolderInbox) --- + +// LabelOverlayState holds the state for the Gmail label editing overlay. It is +// embedded in FolderInbox and rendered as a bubble-overlay on top of the inbox +// content, the same way the move-to-folder overlay works. +type LabelOverlayState struct { + email fetcher.Email + account config.Account + folder string + available []string // all known labels + filtered []string // labels matching the filter + selected int // cursor position + input textinput.Model + active bool +} + +// NewLabelOverlayState creates a label overlay state for the given email. +func NewLabelOverlayState(email fetcher.Email, account config.Account, folder string) LabelOverlayState { + ti := textinput.New() + ti.Placeholder = "Type a label name to add or toggle..." + ti.Prompt = "> " + ti.CharLimit = 80 + ti.SetStyles(ThemedTextInputStyles()) + ti.Focus() + + available := collectKnownLabels(email) + + return LabelOverlayState{ + email: email, + account: account, + folder: folder, + available: available, + filtered: available, + input: ti, + active: true, + } +} + +// UpdateLabelOverlay handles input for the label overlay. It returns the +// updated state, a tea.Cmd, and a bool indicating whether the overlay was +// dismissed (esc pressed). +func (s LabelOverlayState) UpdateLabelOverlay(msg tea.Msg) (LabelOverlayState, tea.Cmd, bool) { //nolint:gocyclo + kb := config.Keybinds + if msg, ok := msg.(tea.KeyPressMsg); ok { + switch msg.String() { + case kb.Global.Cancel: + s.active = false + return s, nil, true + case keyEnter: + input := strings.TrimSpace(s.input.Value()) + if input != "" { + label := input + s.active = false + return s, func() tea.Msg { + return GmailLabelModifiedMsg{ + UID: s.email.UID, + AccountID: s.email.AccountID, + Folder: s.folder, + Label: label, + Add: !s.hasLabel(label), + } + }, true + } + if len(s.filtered) > 0 && s.selected < len(s.filtered) { + label := s.filtered[s.selected] + s.active = false + return s, func() tea.Msg { + return GmailLabelModifiedMsg{ + UID: s.email.UID, + AccountID: s.email.AccountID, + Folder: s.folder, + Label: label, + Add: !s.hasLabel(label), + } + }, true + } + return s, nil, false + case "up", kb.Global.NavUp: + if len(s.filtered) > 0 { + s.selected-- + if s.selected < 0 { + s.selected = len(s.filtered) - 1 + } + } + return s, nil, false + case keyDown, kb.Global.NavDown: + if len(s.filtered) > 0 { + s.selected++ + if s.selected >= len(s.filtered) { + s.selected = 0 + } + } + return s, nil, false + default: + var cmd tea.Cmd + s.input, cmd = s.input.Update(msg) + s.applyFilter() + s.selected = 0 + return s, cmd, false + } + } + return s, nil, false +} + +// RenderLabelOverlay renders the label overlay box on top of the given content +// using bubble-overlay's Center compositor. +func (s LabelOverlayState) RenderLabelOverlay(content string, width, height int) string { + var b strings.Builder + + title := "Gmail Labels" + b.WriteString(labelOverlayTitleStyle.Render(title)) + b.WriteString("\n") + + // Show current labels + if len(s.email.Labels) > 0 { + b.WriteString("Current: ") + for i, l := range s.email.Labels { + if i > 0 { + b.WriteString(" ") + } + b.WriteString(renderLabelTag(l)) + } + b.WriteString("\n\n") + } + + // Show filter input + s.input.SetWidth(50) + b.WriteString(s.input.View()) + b.WriteString("\n\n") + + // Show filtered list + if len(s.filtered) > 0 { + maxVisible := 8 + startIdx := 0 + if s.selected >= maxVisible { + startIdx = s.selected - maxVisible + 1 + } + endIdx := startIdx + maxVisible + if endIdx > len(s.filtered) { + endIdx = len(s.filtered) + } + + for i := startIdx; i < endIdx; i++ { + label := s.filtered[i] + prefix := " " + if i == s.selected { + prefix = "> " + } + + marker := " " + if s.hasLabel(label) { + marker = "✓ " + } + + labelPart := renderLabelTag(label) + + b.WriteString(prefix + marker + labelPart) + if i < endIdx-1 { + b.WriteString("\n") + } + } + } else if strings.TrimSpace(s.input.Value()) != "" { + b.WriteString(lipgloss.NewStyle().Foreground(theme.ActiveTheme.MutedText).Render( + fmt.Sprintf("Press enter to add new label: \"%s\"", strings.TrimSpace(s.input.Value())))) + } + + b.WriteString("\n\n") + b.WriteString(helpStyle.Render("enter: toggle/add • j/k: navigate • esc: cancel")) + + box := labelOverlayStyle.Render(b.String()) + return overlay.Center(content, box, width, height) +} + +// hasLabel reports whether the email currently has the given label. +func (s LabelOverlayState) hasLabel(label string) bool { + stripped := strings.TrimPrefix(label, "\\") + for _, l := range s.email.Labels { + existing := strings.TrimPrefix(l, "\\") + if strings.EqualFold(existing, stripped) { + return true + } + } + return false +} + +// applyFilter filters the available labels based on the input text. +func (s *LabelOverlayState) applyFilter() { + query := strings.ToLower(strings.TrimSpace(s.input.Value())) + if query == "" { + s.filtered = s.available + return + } + + var filtered []string + for _, l := range s.available { + name := strings.ToLower(displayLabelName(l)) + if strings.Contains(name, query) { + filtered = append(filtered, l) + } + } + s.filtered = filtered +} + +// collectKnownLabels returns the list of labels currently on the email plus +// common Gmail system labels, so the user can toggle them. +func collectKnownLabels(email fetcher.Email) []string { + seen := make(map[string]bool) + var labels []string + + // Common Gmail system labels (stored without backslash) + systemLabels := []string{ + "Inbox", + "Starred", + "Important", + "Sent", + "Drafts", + "Trash", + "Spam", + } + + for _, l := range systemLabels { + if !seen[l] { + labels = append(labels, l) + seen[l] = true + } + } + + // Add labels from the current email + for _, l := range email.Labels { + stripped := strings.TrimPrefix(l, "\\") + if !seen[stripped] { + labels = append(labels, stripped) + seen[stripped] = true + } + } + + return labels +} + +// IsGmailAccount reports whether the given account is configured as a Gmail +// provider. Used by TUI views to gate label-related UI. +func IsGmailAccount(account *config.Account) bool { + return account != nil && strings.EqualFold(account.ServiceProvider, config.ProviderGmail) +} + +// IsRecipientOfEmail reports whether the given email address appears in the +// To, Cc, or Bcc recipients of the email. For Gmail accounts, subaddress and +// dot variants also match. +func IsRecipientOfEmail(email fetcher.Email, addr string, account *config.Account) bool { + addr = strings.ToLower(strings.TrimSpace(addr)) + if addr == "" { + return false + } + for _, to := range email.To { + candidate := extractEmailAddress(to) + if addressMatchesGmail(candidate, addr, account) { + return true + } + } + return false +} + +// addressMatchesGmail checks if candidate matches target, using Gmail +// normalization for Gmail accounts. +func addressMatchesGmail(candidate, target string, account *config.Account) bool { + candidate = strings.ToLower(strings.TrimSpace(candidate)) + if candidate == "" || target == "" { + return false + } + if candidate == target { + return true + } + if account != nil && strings.EqualFold(account.ServiceProvider, config.ProviderGmail) { + return normalizeGmailAddressPub(candidate) == normalizeGmailAddressPub(target) + } + return false +} + +// normalizeGmailAddressPub canonicalizes a Gmail address by stripping the +// "+tag" subaddress and removing dots from the local part. +func normalizeGmailAddressPub(addr string) string { + at := strings.LastIndex(addr, "@") + if at < 0 { + return addr + } + local, domain := addr[:at], addr[at:] + if plus := strings.Index(local, "+"); plus >= 0 { + local = local[:plus] + } + local = strings.ReplaceAll(local, ".", "") + return local + domain +} diff --git a/tui/messages.go b/tui/messages.go index 9ad52062..9f6dc597 100644 --- a/tui/messages.go +++ b/tui/messages.go @@ -376,6 +376,31 @@ type EmailOpenedInBrowserMsg struct { Err error } +// GmailLabelModifiedMsg requests adding or removing a Gmail label on an email. +type GmailLabelModifiedMsg struct { + UID uint32 + AccountID string + Folder string + Label string + Add bool // true = add label, false = remove label +} + +// GmailLabelResultMsg signals the result of a Gmail label modification. +type GmailLabelResultMsg struct { + UID uint32 + AccountID string + Label string + Add bool + Err error +} + +// EditLabelsMsg requests opening the label editing overlay for an email. +type EditLabelsMsg struct { + Email fetcher.Email + AccountID string + Folder string +} + // GoToSaveFilePickerMsg signals navigation to the save file picker for // email export. type GoToSaveFilePickerMsg struct {