Skip to content

Commit 92b68ac

Browse files
rianjsclaude
andauthored
feat: implement mail commands (#13)
Port all Gmail commands from gmail-ro under `gro mail`: - search: Search messages with Gmail query syntax - read: Read a single message - thread: Read full conversation thread - labels: List all labels - attachments: List and download attachments Includes all test coverage from gmail-ro with 17 new files and 1094 lines of code. Closes #5 Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
1 parent e68b022 commit 92b68ac

17 files changed

Lines changed: 1094 additions & 0 deletions

internal/cmd/mail/attachments.go

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
package mail
2+
3+
import (
4+
"github.com/spf13/cobra"
5+
)
6+
7+
func newAttachmentsCommand() *cobra.Command {
8+
cmd := &cobra.Command{
9+
Use: "attachments",
10+
Short: "Manage message attachments",
11+
Long: `List and download attachments from Gmail messages.
12+
13+
This command group provides read-only access to message attachments.
14+
Use 'list' to view attachment metadata and 'download' to save files locally.
15+
16+
Examples:
17+
gro mail attachments list 18abc123def456
18+
gro mail attachments download 18abc123def456 --all
19+
gro mail attachments download 18abc123def456 --filename report.pdf`,
20+
}
21+
22+
cmd.AddCommand(newListAttachmentsCommand())
23+
cmd.AddCommand(newDownloadAttachmentsCommand())
24+
25+
return cmd
26+
}
Lines changed: 139 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,139 @@
1+
package mail
2+
3+
import (
4+
"fmt"
5+
"os"
6+
"path/filepath"
7+
"strings"
8+
9+
"github.com/spf13/cobra"
10+
11+
"github.com/open-cli-collective/google-readonly/internal/gmail"
12+
ziputil "github.com/open-cli-collective/google-readonly/internal/zip"
13+
)
14+
15+
var (
16+
downloadFilename string
17+
downloadDir string
18+
downloadExtract bool
19+
downloadAll bool
20+
)
21+
22+
func newDownloadAttachmentsCommand() *cobra.Command {
23+
cmd := &cobra.Command{
24+
Use: "download <message-id>",
25+
Short: "Download attachments from a message",
26+
Long: `Download attachments from a Gmail message to local disk.
27+
28+
By default, requires --filename to specify which attachment to download,
29+
or --all to download all attachments.
30+
31+
Zip files can be automatically extracted with --extract flag.
32+
33+
Examples:
34+
gro mail attachments download 18abc123def456 --filename report.pdf
35+
gro mail attachments download 18abc123def456 --all
36+
gro mail attachments download 18abc123def456 --all --output ~/Downloads
37+
gro mail attachments download 18abc123def456 --filename archive.zip --extract`,
38+
Args: cobra.ExactArgs(1),
39+
RunE: runDownloadAttachments,
40+
}
41+
42+
cmd.Flags().StringVarP(&downloadFilename, "filename", "f", "",
43+
"Download only attachment with this filename")
44+
cmd.Flags().StringVarP(&downloadDir, "output", "o", ".",
45+
"Directory to save attachments")
46+
cmd.Flags().BoolVarP(&downloadExtract, "extract", "e", false,
47+
"Extract zip files after download")
48+
cmd.Flags().BoolVarP(&downloadAll, "all", "a", false,
49+
"Download all attachments (required if no --filename specified)")
50+
51+
return cmd
52+
}
53+
54+
func runDownloadAttachments(cmd *cobra.Command, args []string) error {
55+
if downloadFilename == "" && !downloadAll {
56+
return fmt.Errorf("must specify --filename or --all")
57+
}
58+
59+
client, err := newGmailClient()
60+
if err != nil {
61+
return err
62+
}
63+
64+
messageID := args[0]
65+
attachments, err := client.GetAttachments(messageID)
66+
if err != nil {
67+
return err
68+
}
69+
70+
if len(attachments) == 0 {
71+
fmt.Println("No attachments found for message.")
72+
return nil
73+
}
74+
75+
// Filter by filename if specified
76+
var toDownload []*gmail.Attachment
77+
for _, att := range attachments {
78+
if downloadFilename == "" || att.Filename == downloadFilename {
79+
toDownload = append(toDownload, att)
80+
}
81+
}
82+
83+
if len(toDownload) == 0 {
84+
return fmt.Errorf("attachment not found: %s", downloadFilename)
85+
}
86+
87+
// Create output directory if needed
88+
if err := os.MkdirAll(downloadDir, 0755); err != nil {
89+
return fmt.Errorf("failed to create output directory: %w", err)
90+
}
91+
92+
// Download each attachment
93+
for _, att := range toDownload {
94+
data, err := downloadAttachment(client, messageID, att)
95+
if err != nil {
96+
fmt.Fprintf(os.Stderr, "Error downloading %s: %v\n", att.Filename, err)
97+
continue
98+
}
99+
100+
outputPath := filepath.Join(downloadDir, att.Filename)
101+
if err := saveAttachment(outputPath, data); err != nil {
102+
fmt.Fprintf(os.Stderr, "Error saving %s: %v\n", att.Filename, err)
103+
continue
104+
}
105+
106+
fmt.Printf("Downloaded: %s (%s)\n", outputPath, formatSize(int64(len(data))))
107+
108+
// Extract if zip and --extract flag
109+
if downloadExtract && isZipFile(att.Filename, att.MimeType) {
110+
extractDir := filepath.Join(downloadDir,
111+
strings.TrimSuffix(att.Filename, filepath.Ext(att.Filename)))
112+
if err := ziputil.Extract(outputPath, extractDir, ziputil.DefaultOptions()); err != nil {
113+
fmt.Fprintf(os.Stderr, "Error extracting %s: %v\n", att.Filename, err)
114+
} else {
115+
fmt.Printf("Extracted to: %s\n", extractDir)
116+
}
117+
}
118+
}
119+
120+
return nil
121+
}
122+
123+
func downloadAttachment(client *gmail.Client, messageID string, att *gmail.Attachment) ([]byte, error) {
124+
if att.AttachmentID != "" {
125+
return client.DownloadAttachment(messageID, att.AttachmentID)
126+
}
127+
return client.DownloadInlineAttachment(messageID, att.PartID)
128+
}
129+
130+
func saveAttachment(path string, data []byte) error {
131+
return os.WriteFile(path, data, 0644)
132+
}
133+
134+
func isZipFile(filename, mimeType string) bool {
135+
ext := strings.ToLower(filepath.Ext(filename))
136+
return ext == ".zip" ||
137+
mimeType == "application/zip" ||
138+
mimeType == "application/x-zip-compressed"
139+
}
Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
package mail
2+
3+
import (
4+
"fmt"
5+
6+
"github.com/spf13/cobra"
7+
)
8+
9+
var listAttachmentsJSON bool
10+
11+
func newListAttachmentsCommand() *cobra.Command {
12+
cmd := &cobra.Command{
13+
Use: "list <message-id>",
14+
Short: "List attachments in a message",
15+
Long: `List all attachments in a Gmail message with their metadata.
16+
17+
Shows filename, MIME type, size, and whether the attachment is inline.
18+
19+
Examples:
20+
gro mail attachments list 18abc123def456
21+
gro mail attachments list 18abc123def456 --json`,
22+
Args: cobra.ExactArgs(1),
23+
RunE: runListAttachments,
24+
}
25+
26+
cmd.Flags().BoolVarP(&listAttachmentsJSON, "json", "j", false, "Output as JSON")
27+
28+
return cmd
29+
}
30+
31+
func runListAttachments(cmd *cobra.Command, args []string) error {
32+
client, err := newGmailClient()
33+
if err != nil {
34+
return err
35+
}
36+
37+
attachments, err := client.GetAttachments(args[0])
38+
if err != nil {
39+
return err
40+
}
41+
42+
if len(attachments) == 0 {
43+
fmt.Println("No attachments found for message.")
44+
return nil
45+
}
46+
47+
if listAttachmentsJSON {
48+
return printJSON(attachments)
49+
}
50+
51+
fmt.Printf("Found %d attachment(s):\n\n", len(attachments))
52+
for i, att := range attachments {
53+
fmt.Printf("%d. %s\n", i+1, att.Filename)
54+
fmt.Printf(" Type: %s\n", att.MimeType)
55+
fmt.Printf(" Size: %s\n", formatSize(att.Size))
56+
if att.IsInline {
57+
fmt.Printf(" Inline: yes\n")
58+
}
59+
fmt.Println()
60+
}
61+
62+
return nil
63+
}
64+
65+
// formatSize converts bytes to human-readable format
66+
func formatSize(bytes int64) string {
67+
const unit = 1024
68+
if bytes < unit {
69+
return fmt.Sprintf("%d B", bytes)
70+
}
71+
div, exp := int64(unit), 0
72+
for n := bytes / unit; n >= unit; n /= unit {
73+
div *= unit
74+
exp++
75+
}
76+
return fmt.Sprintf("%.1f %cB", float64(bytes)/float64(div), "KMGTPE"[exp])
77+
}
Lines changed: 134 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,134 @@
1+
package mail
2+
3+
import (
4+
"testing"
5+
6+
"github.com/stretchr/testify/assert"
7+
)
8+
9+
func TestIsZipFile(t *testing.T) {
10+
tests := []struct {
11+
name string
12+
filename string
13+
mimeType string
14+
expected bool
15+
}{
16+
{"zip extension lowercase", "archive.zip", "", true},
17+
{"zip extension uppercase", "ARCHIVE.ZIP", "", true},
18+
{"zip extension mixed case", "Archive.Zip", "", true},
19+
{"application/zip mime type", "archive", "application/zip", true},
20+
{"application/x-zip-compressed mime type", "archive", "application/x-zip-compressed", true},
21+
{"pdf file", "document.pdf", "application/pdf", false},
22+
{"txt file", "readme.txt", "text/plain", false},
23+
{"no extension with wrong mime", "archive", "application/octet-stream", false},
24+
{"zip extension with different mime", "archive.zip", "application/octet-stream", true},
25+
{"empty filename with zip mime", "", "application/zip", true},
26+
}
27+
28+
for _, tt := range tests {
29+
t.Run(tt.name, func(t *testing.T) {
30+
result := isZipFile(tt.filename, tt.mimeType)
31+
assert.Equal(t, tt.expected, result)
32+
})
33+
}
34+
}
35+
36+
func TestFormatSize(t *testing.T) {
37+
tests := []struct {
38+
name string
39+
bytes int64
40+
expected string
41+
}{
42+
{"zero bytes", 0, "0 B"},
43+
{"small bytes", 500, "500 B"},
44+
{"exactly 1KB", 1024, "1.0 KB"},
45+
{"1.5 KB", 1536, "1.5 KB"},
46+
{"exactly 1MB", 1024 * 1024, "1.0 MB"},
47+
{"2.5 MB", 2621440, "2.5 MB"},
48+
{"exactly 1GB", 1024 * 1024 * 1024, "1.0 GB"},
49+
{"large file", 5368709120, "5.0 GB"},
50+
}
51+
52+
for _, tt := range tests {
53+
t.Run(tt.name, func(t *testing.T) {
54+
result := formatSize(tt.bytes)
55+
assert.Equal(t, tt.expected, result)
56+
})
57+
}
58+
}
59+
60+
func TestAttachmentsCommand(t *testing.T) {
61+
cmd := newAttachmentsCommand()
62+
63+
t.Run("has correct use", func(t *testing.T) {
64+
assert.Equal(t, "attachments", cmd.Use)
65+
})
66+
67+
t.Run("has subcommands", func(t *testing.T) {
68+
subcommands := cmd.Commands()
69+
assert.GreaterOrEqual(t, len(subcommands), 2)
70+
71+
var names []string
72+
for _, cmd := range subcommands {
73+
names = append(names, cmd.Name())
74+
}
75+
assert.Contains(t, names, "list")
76+
assert.Contains(t, names, "download")
77+
})
78+
}
79+
80+
func TestListAttachmentsCommand(t *testing.T) {
81+
cmd := newListAttachmentsCommand()
82+
83+
t.Run("has correct use", func(t *testing.T) {
84+
assert.Equal(t, "list <message-id>", cmd.Use)
85+
})
86+
87+
t.Run("requires exactly one argument", func(t *testing.T) {
88+
err := cmd.Args(cmd, []string{})
89+
assert.Error(t, err)
90+
91+
err = cmd.Args(cmd, []string{"msg123"})
92+
assert.NoError(t, err)
93+
})
94+
95+
t.Run("has json flag", func(t *testing.T) {
96+
flag := cmd.Flags().Lookup("json")
97+
assert.NotNil(t, flag)
98+
assert.Equal(t, "j", flag.Shorthand)
99+
})
100+
}
101+
102+
func TestDownloadAttachmentsCommand(t *testing.T) {
103+
cmd := newDownloadAttachmentsCommand()
104+
105+
t.Run("has correct use", func(t *testing.T) {
106+
assert.Equal(t, "download <message-id>", cmd.Use)
107+
})
108+
109+
t.Run("requires exactly one argument", func(t *testing.T) {
110+
err := cmd.Args(cmd, []string{})
111+
assert.Error(t, err)
112+
113+
err = cmd.Args(cmd, []string{"msg123"})
114+
assert.NoError(t, err)
115+
})
116+
117+
t.Run("has required flags", func(t *testing.T) {
118+
flags := []struct {
119+
name string
120+
shorthand string
121+
}{
122+
{"filename", "f"},
123+
{"output", "o"},
124+
{"extract", "e"},
125+
{"all", "a"},
126+
}
127+
128+
for _, f := range flags {
129+
flag := cmd.Flags().Lookup(f.name)
130+
assert.NotNil(t, flag, "flag %s should exist", f.name)
131+
assert.Equal(t, f.shorthand, flag.Shorthand, "flag %s should have shorthand %s", f.name, f.shorthand)
132+
}
133+
})
134+
}

0 commit comments

Comments
 (0)