Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
231 changes: 107 additions & 124 deletions internal/cli/receive.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,165 +12,148 @@ import (
// ReceiveOptions configures the Receive command behavior.
// Used for testing to mock tmux and file system operations.
type ReceiveOptions struct {
SkipTmuxCheck bool // Skip tmux environment check
MockWindows []string // Mock list of tmux windows
MockReceiver string // Mock receiver window name
RepoRoot string // Repository root (defaults to current directory)
HookMode bool // Enable hook mode for Claude Code integration
SkipTmuxCheck bool // Skip tmux environment check
SkipTimestampUpdate bool // Skip updating last_read_at timestamp (for testing)
MockWindows []string // Mock list of tmux windows
MockReceiver string // Mock receiver window name
RepoRoot string // Repository root (defaults to current directory)
HookMode bool // Enable hook mode for Claude Code integration
}

// Receive implements the agentmail receive command.
// T034: Implement Receive command structure
// T035: Add tmux validation (exit code 2 if not in tmux)
// T036: Add message retrieval and display formatting
// T037: Add "No unread messages" handling (exit code 0)
//
// Hook mode behavior (FR-001 through FR-005):
// - FR-001a/b/c: Write notification to STDERR, exit 2, mark as read when messages exist
// - FR-002: Exit 0 with no output when no messages
// - FR-003: Exit 0 with no output when not in tmux
// - FR-004a/b/c: Exit 0 with no output on any error
// - FR-005: All output to STDERR in hook mode
func Receive(stdout, stderr io.Writer, opts ReceiveOptions) int {
// T035: Validate running inside tmux
if !opts.SkipTmuxCheck {
if !tmux.InTmux() {
// FR-003: Hook mode exits silently when not in tmux
if opts.HookMode {
return 0
}
fmt.Fprintln(stderr, "error: agentmail must run inside a tmux session")
return 2
}
}
// receiveError represents an error during receive with context.
type receiveError struct {
msg string
exitCode int
}

// Get receiver identity
var receiver string
// getReceiver returns the receiver window name.
func (opts *ReceiveOptions) getReceiver() (string, error) {
if opts.MockReceiver != "" {
receiver = opts.MockReceiver
} else {
var err error
receiver, err = tmux.GetCurrentWindow()
if err != nil {
// FR-004a: Hook mode exits silently on errors
if opts.HookMode {
return 0
}
fmt.Fprintf(stderr, "error: failed to get current window: %v\n", err)
return 1
}
return opts.MockReceiver, nil
}
return tmux.GetCurrentWindow()
}

// Validate current window exists in tmux session
var receiverExists bool
// windowExists checks if a window exists in the tmux session.
func (opts *ReceiveOptions) windowExists(window string) (bool, error) {
if opts.MockWindows != nil {
for _, w := range opts.MockWindows {
if w == receiver {
receiverExists = true
break
}
}
} else {
var err error
receiverExists, err = tmux.WindowExists(receiver)
if err != nil {
// FR-004a: Hook mode exits silently on errors
if opts.HookMode {
return 0
if w == window {
return true, nil
}
fmt.Fprintf(stderr, "error: failed to check window: %v\n", err)
return 1
}
return false, nil
}
return tmux.WindowExists(window)
}

if !receiverExists {
// FR-004a: Hook mode exits silently on errors
if opts.HookMode {
return 0
}
fmt.Fprintf(stderr, "error: current window '%s' not found in tmux session\n", receiver)
return 1
}

// Determine repository root (find git root, not current directory)
repoRoot := opts.RepoRoot
if repoRoot == "" {
var err error
repoRoot, err = mail.FindGitRoot()
if err != nil {
// FR-004a: Hook mode exits silently on errors
if opts.HookMode {
return 0
}
fmt.Fprintf(stderr, "error: not in a git repository: %v\n", err)
return 1
}
// getRepoRoot returns the repository root path.
func (opts *ReceiveOptions) getRepoRoot() (string, error) {
if opts.RepoRoot != "" {
return opts.RepoRoot, nil
}
return mail.FindGitRoot()
}

// receiveCore contains the core receive logic, returning message or error info.
func (opts *ReceiveOptions) receiveCore() (*mail.Message, *receiveError) {
// Get receiver identity
receiver, err := opts.getReceiver()
if err != nil {
return nil, &receiveError{"failed to get current window: " + err.Error(), 1}
}

// Validate current window exists
exists, err := opts.windowExists(receiver)
if err != nil {
return nil, &receiveError{"failed to check window: " + err.Error(), 1}
}
if !exists {
return nil, &receiveError{fmt.Sprintf("current window '%s' not found in tmux session", receiver), 1}
}

// Get repository root
repoRoot, err := opts.getRepoRoot()
if err != nil {
return nil, &receiveError{"not in a git repository: " + err.Error(), 1}
}

// T036: Find unread messages for receiver
// Find unread messages
unread, err := mail.FindUnread(repoRoot, receiver)
if err != nil {
// FR-004a/b/c: Hook mode exits silently on file/lock/corruption errors
if opts.HookMode {
return 0
}
fmt.Fprintf(stderr, "error: failed to read messages: %v\n", err)
return 1
return nil, &receiveError{"failed to read messages: " + err.Error(), 1}
}

// T037: Handle no unread messages
// No messages case
if len(unread) == 0 {
// FR-002: Hook mode exits silently with no messages
return nil, nil
}

// Get oldest message and mark as read
msg := unread[0]
if err := mail.MarkAsRead(repoRoot, receiver, msg.ID); err != nil {
return nil, &receiveError{"failed to mark message as read: " + err.Error(), 1}
}

// Update last_read_at timestamp (best-effort, skipped in tests)
if !opts.SkipTimestampUpdate {
_ = mail.UpdateLastReadAt(repoRoot, receiver, time.Now().UnixMilli())
}

return &msg, nil
}

// outputMessage writes the message to the given writer.
func outputMessage(w io.Writer, msg *mail.Message, prefix string) {
if prefix != "" {
fmt.Fprintln(w, prefix)
}
fmt.Fprintf(w, "From: %s\n", msg.From)
fmt.Fprintf(w, "ID: %s\n", msg.ID)
fmt.Fprintln(w)
fmt.Fprint(w, msg.Message)
}
Comment on lines +106 to +115
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick | 🔵 Trivial

Consider adding a trailing newline after message body.

Using fmt.Fprint on line 114 means no newline is appended after the message content. If the message itself doesn't end with a newline, the shell prompt will appear immediately after the message body, which may cause display issues.

🔧 Suggested fix
 func outputMessage(w io.Writer, msg *mail.Message, prefix string) {
 	if prefix != "" {
 		fmt.Fprintln(w, prefix)
 	}
 	fmt.Fprintf(w, "From: %s\n", msg.From)
 	fmt.Fprintf(w, "ID: %s\n", msg.ID)
 	fmt.Fprintln(w)
-	fmt.Fprint(w, msg.Message)
+	fmt.Fprintln(w, msg.Message)
 }
🤖 Prompt for AI Agents
In `@internal/cli/receive.go` around lines 106 - 115, The outputMessage function
writes the message body with fmt.Fprint which may leave no trailing newline;
update outputMessage (the function named outputMessage) to ensure a newline
after the message body by using fmt.Fprintln(w, msg.Message) or otherwise
appending "\n" to msg.Message so the shell prompt appears on the next line when
the message lacks a newline.


// Receive implements the agentmail receive command.
// Hook mode: silent on errors/no messages, output to stderr, exit 2 on message.
// Normal mode: verbose errors, output to stdout, exit 0 on success.
func Receive(stdout, stderr io.Writer, opts ReceiveOptions) int {
// Validate tmux environment
if !opts.SkipTmuxCheck && !tmux.InTmux() {
if opts.HookMode {
return 0
}
fmt.Fprintln(stdout, "No unread messages")
return 0
fmt.Fprintln(stderr, "error: agentmail must run inside a tmux session")
return 2
}

// Get oldest unread message (FIFO - first in list)
msg := unread[0]
// Execute core logic
msg, recvErr := opts.receiveCore()

// FR-001c: Mark as read
if err := mail.MarkAsRead(repoRoot, receiver, msg.ID); err != nil {
// FR-004a: Hook mode exits silently on errors
// Handle errors
if recvErr != nil {
if opts.HookMode {
return 0
}
fmt.Fprintf(stderr, "error: failed to mark message as read: %v\n", err)
return 1
fmt.Fprintf(stderr, "error: %s\n", recvErr.msg)
return recvErr.exitCode
}

// FR-017, FR-018: Update last_read_at timestamp when inside tmux
// FR-021: Skip update when outside tmux (SkipTmuxCheck means we're in test mode or outside tmux)
if !opts.SkipTmuxCheck {
// We're inside tmux, update the last-read timestamp
timestamp := time.Now().UnixMilli() // FR-018: Unix timestamp in milliseconds
_ = mail.UpdateLastReadAt(repoRoot, receiver, timestamp) // G104: best-effort, errors don't affect receive
// Handle no messages
if msg == nil {
if opts.HookMode {
return 0
}
fmt.Fprintln(stdout, "No unread messages")
return 0
}

// FR-005: Hook mode writes all output to STDERR
// FR-001a: Hook mode prefixes with "You got new mail\n"
// Output message
if opts.HookMode {
fmt.Fprintln(stderr, "You got new mail")
fmt.Fprintf(stderr, "From: %s\n", msg.From)
fmt.Fprintf(stderr, "ID: %s\n", msg.ID)
fmt.Fprintln(stderr)
fmt.Fprint(stderr, msg.Message)
// FR-001b: Hook mode exits with code 2 when messages exist
outputMessage(stderr, msg, "You got new mail")
return 2
}

// Normal mode: Display message to stdout
// Format:
// From: <sender>
// ID: <id>
//
// <message>
fmt.Fprintf(stdout, "From: %s\n", msg.From)
fmt.Fprintf(stdout, "ID: %s\n", msg.ID)
fmt.Fprintln(stdout)
fmt.Fprint(stdout, msg.Message)

outputMessage(stdout, msg, "")
return 0
}
Loading