feat(mail): Add editor mode for gt mail reply command
When -m flag is not provided, opens $EDITOR to compose the reply. The editor shows a template with the original message for context. Comment lines (starting with #) are filtered out. - Makes -m flag optional instead of required - Falls back to vim/vi/nano/emacs if $EDITOR not set - Updates help text to document editor mode (gt-d46.5) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -262,10 +262,15 @@ This is a convenience command that automatically:
|
|||||||
- Sets the reply-to field to the original message
|
- Sets the reply-to field to the original message
|
||||||
- Prefixes the subject with "Re: " (if not already present)
|
- Prefixes the subject with "Re: " (if not already present)
|
||||||
- Sends to the original sender
|
- Sends to the original sender
|
||||||
|
- Preserves the thread ID for conversation tracking
|
||||||
|
|
||||||
|
If -m is not provided, opens $EDITOR to compose the reply.
|
||||||
|
The editor template shows the original message for context.
|
||||||
|
|
||||||
Examples:
|
Examples:
|
||||||
gt mail reply msg-abc123 -m "Thanks, working on it now"
|
gt mail reply msg-abc123 -m "Thanks, working on it now"
|
||||||
gt mail reply msg-abc123 -s "Custom subject" -m "Reply body"`,
|
gt mail reply msg-abc123 -s "Custom subject" -m "Reply body"
|
||||||
|
gt mail reply msg-abc123 # Opens editor`,
|
||||||
Args: cobra.ExactArgs(1),
|
Args: cobra.ExactArgs(1),
|
||||||
RunE: runMailReply,
|
RunE: runMailReply,
|
||||||
}
|
}
|
||||||
@@ -428,8 +433,7 @@ func init() {
|
|||||||
|
|
||||||
// Reply flags
|
// Reply flags
|
||||||
mailReplyCmd.Flags().StringVarP(&mailReplySubject, "subject", "s", "", "Override reply subject (default: Re: <original>)")
|
mailReplyCmd.Flags().StringVarP(&mailReplySubject, "subject", "s", "", "Override reply subject (default: Re: <original>)")
|
||||||
mailReplyCmd.Flags().StringVarP(&mailReplyMessage, "message", "m", "", "Reply message body (required)")
|
mailReplyCmd.Flags().StringVarP(&mailReplyMessage, "message", "m", "", "Reply message body (opens editor if not provided)")
|
||||||
mailReplyCmd.MarkFlagRequired("message")
|
|
||||||
|
|
||||||
// Delete flags
|
// Delete flags
|
||||||
mailDeleteCmd.Flags().BoolVarP(&mailDeleteForce, "force", "f", false, "Delete without confirmation")
|
mailDeleteCmd.Flags().BoolVarP(&mailDeleteForce, "force", "f", false, "Delete without confirmation")
|
||||||
@@ -1378,12 +1382,26 @@ func runMailReply(cmd *cobra.Command, args []string) error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Get message body - from flag or open editor
|
||||||
|
body := mailReplyMessage
|
||||||
|
if body == "" {
|
||||||
|
// Open editor to compose reply
|
||||||
|
var err error
|
||||||
|
body, err = openEditorForReply(original, subject)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("opening editor: %w", err)
|
||||||
|
}
|
||||||
|
if body == "" {
|
||||||
|
return fmt.Errorf("reply message is empty, aborting")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Create reply message
|
// Create reply message
|
||||||
reply := &mail.Message{
|
reply := &mail.Message{
|
||||||
From: from,
|
From: from,
|
||||||
To: original.From, // Reply to sender
|
To: original.From, // Reply to sender
|
||||||
Subject: subject,
|
Subject: subject,
|
||||||
Body: mailReplyMessage,
|
Body: body,
|
||||||
Type: mail.TypeReply,
|
Type: mail.TypeReply,
|
||||||
Priority: mail.PriorityNormal,
|
Priority: mail.PriorityNormal,
|
||||||
ReplyTo: msgID,
|
ReplyTo: msgID,
|
||||||
@@ -1409,6 +1427,87 @@ func runMailReply(cmd *cobra.Command, args []string) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// openEditorForReply opens the user's editor to compose a reply message.
|
||||||
|
// It creates a temp file with context about the original message.
|
||||||
|
func openEditorForReply(original *mail.Message, subject string) (string, error) {
|
||||||
|
// Create temp file with reply template
|
||||||
|
tmpFile, err := os.CreateTemp("", "gt-mail-reply-*.txt")
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("creating temp file: %w", err)
|
||||||
|
}
|
||||||
|
defer os.Remove(tmpFile.Name())
|
||||||
|
|
||||||
|
// Write template with original message for context
|
||||||
|
template := fmt.Sprintf(`
|
||||||
|
# Reply to: %s
|
||||||
|
# From: %s
|
||||||
|
# Subject: %s
|
||||||
|
# ---
|
||||||
|
# Write your reply below. Lines starting with # are ignored.
|
||||||
|
# Save and close the editor to send, or leave empty to abort.
|
||||||
|
|
||||||
|
`, original.From, original.From, subject)
|
||||||
|
|
||||||
|
// Add quoted original message
|
||||||
|
if original.Body != "" {
|
||||||
|
template += "# Original message:\n"
|
||||||
|
for _, line := range strings.Split(original.Body, "\n") {
|
||||||
|
template += "# > " + line + "\n"
|
||||||
|
}
|
||||||
|
template += "\n"
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := tmpFile.WriteString(template); err != nil {
|
||||||
|
tmpFile.Close()
|
||||||
|
return "", fmt.Errorf("writing template: %w", err)
|
||||||
|
}
|
||||||
|
tmpFile.Close()
|
||||||
|
|
||||||
|
// Determine editor
|
||||||
|
editor := os.Getenv("EDITOR")
|
||||||
|
if editor == "" {
|
||||||
|
editor = os.Getenv("VISUAL")
|
||||||
|
}
|
||||||
|
if editor == "" {
|
||||||
|
// Try common editors
|
||||||
|
for _, e := range []string{"vim", "vi", "nano", "emacs"} {
|
||||||
|
if _, err := exec.LookPath(e); err == nil {
|
||||||
|
editor = e
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if editor == "" {
|
||||||
|
return "", fmt.Errorf("no editor found (set $EDITOR)")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Open editor
|
||||||
|
editorCmd := exec.Command(editor, tmpFile.Name())
|
||||||
|
editorCmd.Stdin = os.Stdin
|
||||||
|
editorCmd.Stdout = os.Stdout
|
||||||
|
editorCmd.Stderr = os.Stderr
|
||||||
|
|
||||||
|
if err := editorCmd.Run(); err != nil {
|
||||||
|
return "", fmt.Errorf("running editor: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read the edited content
|
||||||
|
content, err := os.ReadFile(tmpFile.Name())
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("reading edited file: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filter out comment lines and trim
|
||||||
|
var lines []string
|
||||||
|
for _, line := range strings.Split(string(content), "\n") {
|
||||||
|
if !strings.HasPrefix(strings.TrimSpace(line), "#") {
|
||||||
|
lines = append(lines, line)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return strings.TrimSpace(strings.Join(lines, "\n")), nil
|
||||||
|
}
|
||||||
|
|
||||||
// generateThreadID creates a random thread ID for new message threads.
|
// generateThreadID creates a random thread ID for new message threads.
|
||||||
func generateThreadID() string {
|
func generateThreadID() string {
|
||||||
b := make([]byte, 6)
|
b := make([]byte, 6)
|
||||||
|
|||||||
Reference in New Issue
Block a user