Skip to content

Commit 84f254a

Browse files
wesmchgeuerclaude
authored
Add export-attachment and export-attachments CLI commands, consolidate shared export logic (#56)
## Summary - Adds `export-attachment` command to export single attachment binaries by content hash (supersedes #4) - Adds `export-attachments` command to export all attachments from a message as individual files - Consolidates attachment export logic into `internal/export` so TUI, CLI, and MCP share one code path ```bash # Single attachment by content hash msgvault export-attachment <hash> -o file.pdf msgvault export-attachment <hash> --base64 msgvault export-attachment <hash> --json # All attachments from a message msgvault export-attachments 45 # all attachments → cwd msgvault export-attachments 45 -o ~/Downloads # all attachments → specific dir msgvault export-attachments 18f0abc123def # by Gmail ID ``` ### Shared export package (`internal/export`) - `AttachmentsToDir()` — export attachments as individual files to a directory (streaming I/O, deduped filenames, `O_EXCL` file creation) - `CreateExclusiveFile()` — atomic file creation with `_1`, `_2` suffix on conflict - `StoragePath()` — content-addressed path construction with hash validation - `ValidateContentHash()`, `SanitizeFilename()` — already existed, now used by all code paths ### MCP consolidation - Removed duplicated `sanitizeFilename`, `createExclusive`, `pathConflict` from MCP handler (~50 lines) - MCP now uses shared `export.SanitizeFilename`, `export.CreateExclusiveFile`, `export.StoragePath` Closes #3 ## Test plan - [x] `make test && make lint` pass - [x] `internal/export`: 8 `TestAttachmentsToDir` subtests, `TestCreateExclusiveFile` (4 subtests), `TestAttachmentsToDir_FilePermissions`, `TestAttachmentsToDir_DiskConflict` - [x] `cmd/.../export_attachment_test.go`: binary, JSON, base64 output modes; missing file; flag exclusivity; hash validation - [x] `cmd/.../export_attachments_test.go`: full flow with real DB, Gmail ID fallback, message not found, output dir validation, not-a-directory - [x] MCP `TestSanitizeFilename` updated to use shared function - [x] Manual: `msgvault export-attachments <id>` with real message 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Christian Geuer-Pollmann <christian@geuer-pollmann.de> Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
1 parent 4f175ff commit 84f254a

File tree

8 files changed

+1174
-62
lines changed

8 files changed

+1174
-62
lines changed
Lines changed: 176 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,176 @@
1+
package cmd
2+
3+
import (
4+
"encoding/base64"
5+
"encoding/json"
6+
"fmt"
7+
"io"
8+
"os"
9+
"path/filepath"
10+
11+
"github.com/spf13/cobra"
12+
"github.com/wesm/msgvault/internal/export"
13+
)
14+
15+
var (
16+
exportAttachmentOutput string
17+
exportAttachmentJSON bool
18+
exportAttachmentBase64 bool
19+
)
20+
21+
var exportAttachmentCmd = &cobra.Command{
22+
Use: "export-attachment <content-hash>",
23+
Short: "Export an attachment by content hash",
24+
Long: `Export an attachment binary by its SHA-256 content hash.
25+
26+
Get the content hash from 'show-message --json':
27+
msgvault show-message 45 --json | jq '.attachments[0].content_hash'
28+
29+
Examples:
30+
msgvault export-attachment 61ccf192b5bd358738802dc2676d3ceab856f47d26dd29681ac3d335bfd5bbd0
31+
msgvault export-attachment 61ccf192... --output invoice.pdf
32+
33+
Export all attachments from a message with original filenames:
34+
msgvault show-message 45 --json | \
35+
jq -r '.attachments[] | "\(.content_hash)\t\(.filename)"' | \
36+
while IFS=$'\t' read -r hash name; do
37+
msgvault export-attachment "$hash" -o "$name"
38+
done
39+
msgvault export-attachment 61ccf192... -o - # stdout (binary)
40+
msgvault export-attachment 61ccf192... --base64 # stdout (base64)
41+
msgvault export-attachment 61ccf192... --json # JSON with base64 data`,
42+
Args: cobra.ExactArgs(1),
43+
RunE: runExportAttachment,
44+
}
45+
46+
func runExportAttachment(cmd *cobra.Command, args []string) error {
47+
contentHash := args[0]
48+
49+
// Validate hash format using shared validation
50+
if err := export.ValidateContentHash(contentHash); err != nil {
51+
return err
52+
}
53+
54+
// Validate flag combinations
55+
if exportAttachmentJSON && exportAttachmentBase64 {
56+
return fmt.Errorf("--json and --base64 are mutually exclusive")
57+
}
58+
if exportAttachmentOutput != "" && exportAttachmentOutput != "-" {
59+
if exportAttachmentJSON {
60+
return fmt.Errorf("--json and --output are mutually exclusive (--json writes to stdout)")
61+
}
62+
if exportAttachmentBase64 {
63+
return fmt.Errorf("--base64 and --output are mutually exclusive (--base64 writes to stdout)")
64+
}
65+
}
66+
67+
// Construct storage path: attachmentsDir/hash[:2]/hash
68+
attachmentsDir := cfg.AttachmentsDir()
69+
storagePath := filepath.Join(attachmentsDir, contentHash[:2], contentHash)
70+
71+
// JSON mode reads the full file into memory for base64 encoding.
72+
// Base64 and binary modes stream directly to avoid loading large files.
73+
if exportAttachmentJSON {
74+
return exportAttachmentAsJSON(storagePath, contentHash)
75+
}
76+
if exportAttachmentBase64 {
77+
return exportAttachmentAsBase64(storagePath)
78+
}
79+
return exportAttachmentBinary(storagePath, contentHash)
80+
}
81+
82+
func exportAttachmentAsJSON(storagePath, contentHash string) error {
83+
data, err := readAttachmentFile(storagePath, contentHash)
84+
if err != nil {
85+
return err
86+
}
87+
88+
output := map[string]any{
89+
"content_hash": contentHash,
90+
"size": len(data),
91+
"data_base64": base64.StdEncoding.EncodeToString(data),
92+
}
93+
enc := json.NewEncoder(os.Stdout)
94+
enc.SetIndent("", " ")
95+
return enc.Encode(output)
96+
}
97+
98+
func exportAttachmentAsBase64(storagePath string) error {
99+
f, err := openAttachmentFile(storagePath)
100+
if err != nil {
101+
return err
102+
}
103+
defer f.Close()
104+
105+
encoder := base64.NewEncoder(base64.StdEncoding, os.Stdout)
106+
if _, err := io.Copy(encoder, f); err != nil {
107+
return fmt.Errorf("encode attachment: %w", err)
108+
}
109+
if err := encoder.Close(); err != nil {
110+
return fmt.Errorf("finalize base64: %w", err)
111+
}
112+
fmt.Println() // trailing newline
113+
return nil
114+
}
115+
116+
func exportAttachmentBinary(storagePath, contentHash string) error {
117+
f, err := openAttachmentFile(storagePath)
118+
if err != nil {
119+
return err
120+
}
121+
defer f.Close()
122+
123+
outputPath := exportAttachmentOutput
124+
if outputPath == "" || outputPath == "-" {
125+
_, err = io.Copy(os.Stdout, f)
126+
return err
127+
}
128+
129+
dst, err := os.OpenFile(outputPath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0600)
130+
if err != nil {
131+
return fmt.Errorf("create output file: %w", err)
132+
}
133+
134+
n, copyErr := io.Copy(dst, f)
135+
closeErr := dst.Close()
136+
if copyErr != nil {
137+
os.Remove(outputPath)
138+
return fmt.Errorf("write file: %w", copyErr)
139+
}
140+
if closeErr != nil {
141+
os.Remove(outputPath)
142+
return fmt.Errorf("close file: %w", closeErr)
143+
}
144+
145+
fmt.Fprintf(os.Stderr, "Exported attachment to: %s (%d bytes)\n", outputPath, n)
146+
return nil
147+
}
148+
149+
func openAttachmentFile(storagePath string) (*os.File, error) {
150+
f, err := os.Open(storagePath)
151+
if err != nil {
152+
if os.IsNotExist(err) {
153+
return nil, fmt.Errorf("attachment not found: %s", filepath.Base(storagePath))
154+
}
155+
return nil, fmt.Errorf("read attachment: %w", err)
156+
}
157+
return f, nil
158+
}
159+
160+
func readAttachmentFile(storagePath, contentHash string) ([]byte, error) {
161+
data, err := os.ReadFile(storagePath)
162+
if err != nil {
163+
if os.IsNotExist(err) {
164+
return nil, fmt.Errorf("attachment not found: no file for hash %s", contentHash)
165+
}
166+
return nil, fmt.Errorf("read attachment: %w", err)
167+
}
168+
return data, nil
169+
}
170+
171+
func init() {
172+
rootCmd.AddCommand(exportAttachmentCmd)
173+
exportAttachmentCmd.Flags().StringVarP(&exportAttachmentOutput, "output", "o", "", "Output file path (default: stdout, use - for stdout)")
174+
exportAttachmentCmd.Flags().BoolVar(&exportAttachmentJSON, "json", false, "Output as JSON with base64-encoded data")
175+
exportAttachmentCmd.Flags().BoolVar(&exportAttachmentBase64, "base64", false, "Output raw base64 to stdout")
176+
}

0 commit comments

Comments
 (0)