Skip to content

Commit 1418b11

Browse files
steveyeggeclaude
andcommitted
feat: add gt mail mark-read command for desire path (bd-rjuu6)
Adds mark-read and mark-unread commands that allow marking messages as read without archiving them. Uses a "read" label to track status. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
1 parent 2c73cf3 commit 1418b11

4 files changed

Lines changed: 196 additions & 9 deletions

File tree

internal/cmd/mail.go

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -191,6 +191,38 @@ Examples:
191191
RunE: runMailArchive,
192192
}
193193

194+
var mailMarkReadCmd = &cobra.Command{
195+
Use: "mark-read <message-id> [message-id...]",
196+
Short: "Mark messages as read without archiving",
197+
Long: `Mark one or more messages as read without removing them from inbox.
198+
199+
This adds a 'read' label to the message, which is reflected in the inbox display.
200+
The message remains in your inbox (unlike archive which closes/removes it).
201+
202+
Use case: You've read a message but want to keep it visible in your inbox
203+
for reference or follow-up.
204+
205+
Examples:
206+
gt mail mark-read hq-abc123
207+
gt mail mark-read hq-abc123 hq-def456`,
208+
Args: cobra.MinimumNArgs(1),
209+
RunE: runMailMarkRead,
210+
}
211+
212+
var mailMarkUnreadCmd = &cobra.Command{
213+
Use: "mark-unread <message-id> [message-id...]",
214+
Short: "Mark messages as unread",
215+
Long: `Mark one or more messages as unread.
216+
217+
This removes the 'read' label from the message.
218+
219+
Examples:
220+
gt mail mark-unread hq-abc123
221+
gt mail mark-unread hq-abc123 hq-def456`,
222+
Args: cobra.MinimumNArgs(1),
223+
RunE: runMailMarkUnread,
224+
}
225+
194226
var mailCheckCmd = &cobra.Command{
195227
Use: "check",
196228
Short: "Check for new mail (for hooks)",
@@ -438,6 +470,8 @@ func init() {
438470
mailCmd.AddCommand(mailPeekCmd)
439471
mailCmd.AddCommand(mailDeleteCmd)
440472
mailCmd.AddCommand(mailArchiveCmd)
473+
mailCmd.AddCommand(mailMarkReadCmd)
474+
mailCmd.AddCommand(mailMarkUnreadCmd)
441475
mailCmd.AddCommand(mailCheckCmd)
442476
mailCmd.AddCommand(mailThreadCmd)
443477
mailCmd.AddCommand(mailReplyCmd)

internal/cmd/mail_inbox.go

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -292,6 +292,98 @@ func runMailArchive(cmd *cobra.Command, args []string) error {
292292
return nil
293293
}
294294

295+
func runMailMarkRead(cmd *cobra.Command, args []string) error {
296+
// Determine which inbox
297+
address := detectSender()
298+
299+
// All mail uses town beads (two-level architecture)
300+
workDir, err := findMailWorkDir()
301+
if err != nil {
302+
return fmt.Errorf("not in a Gas Town workspace: %w", err)
303+
}
304+
305+
// Get mailbox
306+
router := mail.NewRouter(workDir)
307+
mailbox, err := router.GetMailbox(address)
308+
if err != nil {
309+
return fmt.Errorf("getting mailbox: %w", err)
310+
}
311+
312+
// Mark all specified messages as read
313+
marked := 0
314+
var errors []string
315+
for _, msgID := range args {
316+
if err := mailbox.MarkReadOnly(msgID); err != nil {
317+
errors = append(errors, fmt.Sprintf("%s: %v", msgID, err))
318+
} else {
319+
marked++
320+
}
321+
}
322+
323+
// Report results
324+
if len(errors) > 0 {
325+
fmt.Printf("%s Marked %d/%d messages as read\n",
326+
style.Bold.Render("⚠"), marked, len(args))
327+
for _, e := range errors {
328+
fmt.Printf(" Error: %s\n", e)
329+
}
330+
return fmt.Errorf("failed to mark %d messages", len(errors))
331+
}
332+
333+
if len(args) == 1 {
334+
fmt.Printf("%s Message marked as read\n", style.Bold.Render("✓"))
335+
} else {
336+
fmt.Printf("%s Marked %d messages as read\n", style.Bold.Render("✓"), marked)
337+
}
338+
return nil
339+
}
340+
341+
func runMailMarkUnread(cmd *cobra.Command, args []string) error {
342+
// Determine which inbox
343+
address := detectSender()
344+
345+
// All mail uses town beads (two-level architecture)
346+
workDir, err := findMailWorkDir()
347+
if err != nil {
348+
return fmt.Errorf("not in a Gas Town workspace: %w", err)
349+
}
350+
351+
// Get mailbox
352+
router := mail.NewRouter(workDir)
353+
mailbox, err := router.GetMailbox(address)
354+
if err != nil {
355+
return fmt.Errorf("getting mailbox: %w", err)
356+
}
357+
358+
// Mark all specified messages as unread
359+
marked := 0
360+
var errors []string
361+
for _, msgID := range args {
362+
if err := mailbox.MarkUnreadOnly(msgID); err != nil {
363+
errors = append(errors, fmt.Sprintf("%s: %v", msgID, err))
364+
} else {
365+
marked++
366+
}
367+
}
368+
369+
// Report results
370+
if len(errors) > 0 {
371+
fmt.Printf("%s Marked %d/%d messages as unread\n",
372+
style.Bold.Render("⚠"), marked, len(args))
373+
for _, e := range errors {
374+
fmt.Printf(" Error: %s\n", e)
375+
}
376+
return fmt.Errorf("failed to mark %d messages", len(errors))
377+
}
378+
379+
if len(args) == 1 {
380+
fmt.Printf("%s Message marked as unread\n", style.Bold.Render("✓"))
381+
} else {
382+
fmt.Printf("%s Marked %d messages as unread\n", style.Bold.Render("✓"), marked)
383+
}
384+
return nil
385+
}
386+
295387
func runMailClear(cmd *cobra.Command, args []string) error {
296388
// Determine which inbox to clear (target arg or auto-detect)
297389
address := ""

internal/mail/mailbox.go

Lines changed: 59 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -371,6 +371,61 @@ func (m *Mailbox) markReadLegacy(id string) error {
371371
return m.rewriteLegacy(messages)
372372
}
373373

374+
// MarkReadOnly marks a message as read WITHOUT archiving/closing it.
375+
// For beads mode, this adds a "read" label to the message.
376+
// For legacy mode, this sets the Read field to true.
377+
// The message remains in the inbox but is displayed as read.
378+
func (m *Mailbox) MarkReadOnly(id string) error {
379+
if m.legacy {
380+
return m.markReadLegacy(id)
381+
}
382+
return m.markReadOnlyBeads(id)
383+
}
384+
385+
func (m *Mailbox) markReadOnlyBeads(id string) error {
386+
// Add "read" label to mark as read without closing
387+
args := []string{"label", "add", id, "read"}
388+
389+
_, err := runBdCommand(args, m.workDir, m.beadsDir)
390+
if err != nil {
391+
if bdErr, ok := err.(*bdError); ok && bdErr.ContainsError("not found") {
392+
return ErrMessageNotFound
393+
}
394+
return err
395+
}
396+
397+
return nil
398+
}
399+
400+
// MarkUnreadOnly marks a message as unread (removes "read" label).
401+
// For beads mode, this removes the "read" label from the message.
402+
// For legacy mode, this sets the Read field to false.
403+
func (m *Mailbox) MarkUnreadOnly(id string) error {
404+
if m.legacy {
405+
return m.markUnreadLegacy(id)
406+
}
407+
return m.markUnreadOnlyBeads(id)
408+
}
409+
410+
func (m *Mailbox) markUnreadOnlyBeads(id string) error {
411+
// Remove "read" label to mark as unread
412+
args := []string{"label", "remove", id, "read"}
413+
414+
_, err := runBdCommand(args, m.workDir, m.beadsDir)
415+
if err != nil {
416+
if bdErr, ok := err.(*bdError); ok && bdErr.ContainsError("not found") {
417+
return ErrMessageNotFound
418+
}
419+
// Ignore error if label doesn't exist
420+
if bdErr, ok := err.(*bdError); ok && bdErr.ContainsError("does not have label") {
421+
return nil
422+
}
423+
return err
424+
}
425+
426+
return nil
427+
}
428+
374429
// MarkUnread marks a message as unread (reopens in beads).
375430
func (m *Mailbox) MarkUnread(id string) error {
376431
if m.legacy {
@@ -686,15 +741,11 @@ func (m *Mailbox) Count() (total, unread int, err error) {
686741
}
687742

688743
total = len(messages)
689-
if m.legacy {
690-
for _, msg := range messages {
691-
if !msg.Read {
692-
unread++
693-
}
744+
// Count messages that are NOT marked as read (including via "read" label)
745+
for _, msg := range messages {
746+
if !msg.Read {
747+
unread++
694748
}
695-
} else {
696-
// For beads, inbox only returns unread
697-
unread = total
698749
}
699750

700751
return total, unread, nil

internal/mail/types.go

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -256,7 +256,7 @@ func (bm *BeadsMessage) ToMessage() *Message {
256256
Subject: bm.Title,
257257
Body: bm.Description,
258258
Timestamp: bm.CreatedAt,
259-
Read: bm.Status == "closed",
259+
Read: bm.Status == "closed" || bm.HasLabel("read"),
260260
Priority: priority,
261261
Type: msgType,
262262
ThreadID: bm.threadID,
@@ -266,6 +266,16 @@ func (bm *BeadsMessage) ToMessage() *Message {
266266
}
267267
}
268268

269+
// HasLabel checks if the message has a specific label.
270+
func (bm *BeadsMessage) HasLabel(label string) bool {
271+
for _, l := range bm.Labels {
272+
if l == label {
273+
return true
274+
}
275+
}
276+
return false
277+
}
278+
269279
// PriorityToBeads converts a GGT Priority to beads priority integer.
270280
// Returns: 0=urgent, 1=high, 2=normal, 3=low
271281
func PriorityToBeads(p Priority) int {

0 commit comments

Comments
 (0)