Compare commits

...

9 Commits

Author SHA1 Message Date
Peter Steinberger
149f43f108 fix(secrets): dedupe keychain helpers
Some checks failed
ci / test (push) Has been cancelled
ci / darwin-cgo-build (push) Has been cancelled
2026-01-05 03:24:50 +01:00
Peter Steinberger
057493fbc1 merge origin/main 2026-01-05 03:24:19 +01:00
Peter Steinberger
af504ed8b8 fix(gmail): include inline attachments 2026-01-05 03:22:50 +01:00
salmonumbrella
5df6394379
feat(gmail): add flattened headers to gmail get JSON output
Add a headers map with common email headers (from, to, cc, bcc, subject,
date) to the JSON output of `gog gmail get`. This makes header extraction
much simpler:

  # Before (error-prone due to jq operator precedence)
  jq '.message.payload.headers[] | select(.name == "To") | .value'

  # After
  jq '.headers.to'

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-04 12:14:07 -08:00
salmonumbrella
28a45136b1
feat(cli): add mail and email aliases for gmail command
Enables `gog mail` and `gog email` as shortcuts for `gog gmail`.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-04 09:28:19 -08:00
salmonumbrella
03037c3ba0
fix: address lint issues in store.go
- Wrap keychain access error for wrapcheck linter
- Add blank lines before returns for wsl linter
- Define static error for err113 linter

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-02 07:42:39 -08:00
salmonumbrella
3e664bfce3
style: group const declarations for gofumpt
🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-02 07:32:40 -08:00
salmonumbrella
245e05c969
feat(auth): check keychain before token import
Add pre-flight keychain accessibility check to AuthTokensImportCmd.Run
to fail early if the keychain is locked, rather than after the user has
already completed the import process.

Also adds the EnsureKeychainAccess function to the secrets package which
performs a write/delete test to verify keychain accessibility on macOS.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-02 07:10:44 -08:00
salmonumbrella
310cf16da5
feat(gmail): add thread attachments command
Adds `gog gmail thread attachments <threadId>` command that lists all
attachments in an email thread with human-readable sizes.

Features:
- Lists attachments across all messages in a thread
- Shows filename, size (human-readable), and MIME type
- Optional --download flag to download all attachments
- Optional --out-dir to specify download directory
- Supports both JSON and text output formats

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-02 07:09:06 -08:00
5 changed files with 169 additions and 7 deletions

View File

@ -275,6 +275,12 @@ func (c *AuthTokensImportCmd) Run(ctx context.Context) error {
if err != nil {
return err
}
// Pre-flight: ensure keychain is accessible before storing token
if err := ensureKeychainAccess(); err != nil {
return fmt.Errorf("keychain access: %w", err)
}
if err := store.SetToken(ex.Email, secrets.Token{
Email: ex.Email,
Services: ex.Services,

View File

@ -64,7 +64,20 @@ func (c *GmailGetCmd) Run(ctx context.Context, flags *RootFlags) error {
}
if outfmt.IsJSON(ctx) {
return outfmt.WriteJSON(os.Stdout, map[string]any{"message": msg})
// Include a flattened headers map for easier querying
// (e.g., jq '.headers.to' instead of complex nested queries)
headers := map[string]string{
"from": headerValue(msg.Payload, "From"),
"to": headerValue(msg.Payload, "To"),
"cc": headerValue(msg.Payload, "Cc"),
"bcc": headerValue(msg.Payload, "Bcc"),
"subject": headerValue(msg.Payload, "Subject"),
"date": headerValue(msg.Payload, "Date"),
}
return outfmt.WriteJSON(os.Stdout, map[string]any{
"message": msg,
"headers": headers,
})
}
u.Out().Printf("id\t%s", msg.Id)

View File

@ -41,8 +41,9 @@ func stripHTMLTags(s string) string {
}
type GmailThreadCmd struct {
Get GmailThreadGetCmd `cmd:"" name:"get" help:"Get a thread with all messages (optionally download attachments)"`
Modify GmailThreadModifyCmd `cmd:"" name:"modify" help:"Modify labels on all messages in a thread"`
Get GmailThreadGetCmd `cmd:"" name:"get" help:"Get a thread with all messages (optionally download attachments)"`
Modify GmailThreadModifyCmd `cmd:"" name:"modify" help:"Modify labels on all messages in a thread"`
Attachments GmailThreadAttachmentsCmd `cmd:"" name:"attachments" help:"List all attachments in a thread"`
}
type GmailThreadGetCmd struct {
@ -245,6 +246,137 @@ func (c *GmailThreadModifyCmd) Run(ctx context.Context, flags *RootFlags) error
return nil
}
// GmailThreadAttachmentsCmd lists all attachments in a thread.
type GmailThreadAttachmentsCmd struct {
ThreadID string `arg:"" name:"threadId" help:"Thread ID"`
Download bool `name:"download" help:"Download all attachments"`
OutDir string `name:"out-dir" help:"Directory to write attachments to (default: current directory)"`
}
func (c *GmailThreadAttachmentsCmd) Run(ctx context.Context, flags *RootFlags) error {
u := ui.FromContext(ctx)
account, err := requireAccount(flags)
if err != nil {
return err
}
threadID := strings.TrimSpace(c.ThreadID)
if threadID == "" {
return usage("empty threadId")
}
svc, err := newGmailService(ctx, account)
if err != nil {
return err
}
thread, err := svc.Users.Threads.Get("me", threadID).Format("full").Context(ctx).Do()
if err != nil {
return err
}
if thread == nil || len(thread.Messages) == 0 {
if outfmt.IsJSON(ctx) {
return outfmt.WriteJSON(os.Stdout, map[string]any{
"threadId": threadID,
"attachments": []any{},
})
}
u.Err().Println("Empty thread")
return nil
}
var attachDir string
if c.Download {
if strings.TrimSpace(c.OutDir) == "" {
attachDir = "."
} else {
attachDir = filepath.Clean(c.OutDir)
}
}
type attachmentOutput struct {
MessageID string `json:"messageId"`
AttachmentID string `json:"attachmentId"`
Filename string `json:"filename"`
Size int64 `json:"size"`
SizeHuman string `json:"sizeHuman"`
MimeType string `json:"mimeType"`
Path string `json:"path,omitempty"`
Cached bool `json:"cached,omitempty"`
}
var allAttachments []attachmentOutput
for _, msg := range thread.Messages {
if msg == nil {
continue
}
for _, a := range collectAttachments(msg.Payload) {
att := attachmentOutput{
MessageID: msg.Id,
AttachmentID: a.AttachmentID,
Filename: a.Filename,
Size: a.Size,
SizeHuman: formatBytes(a.Size),
MimeType: a.MimeType,
}
if c.Download {
outPath, cached, err := downloadAttachment(ctx, svc, msg.Id, a, attachDir)
if err != nil {
return err
}
att.Path = outPath
att.Cached = cached
}
allAttachments = append(allAttachments, att)
}
}
if outfmt.IsJSON(ctx) {
return outfmt.WriteJSON(os.Stdout, map[string]any{
"threadId": threadID,
"attachments": allAttachments,
})
}
if len(allAttachments) == 0 {
u.Out().Println("No attachments found")
return nil
}
u.Out().Printf("Found %d attachment(s):\n", len(allAttachments))
for _, a := range allAttachments {
if c.Download {
status := "Saved"
if a.Cached {
status = "Cached"
}
u.Out().Printf(" %s: %s (%s) - %s", status, a.Filename, a.SizeHuman, a.Path)
} else {
u.Out().Printf(" - %s (%s) [%s]", a.Filename, a.SizeHuman, a.MimeType)
}
}
return nil
}
// formatBytes formats bytes into human-readable format.
func formatBytes(bytes int64) string {
const (
KB = 1024
MB = KB * 1024
GB = MB * 1024
)
switch {
case bytes >= GB:
return fmt.Sprintf("%.1f GB", float64(bytes)/float64(GB))
case bytes >= MB:
return fmt.Sprintf("%.1f MB", float64(bytes)/float64(MB))
case bytes >= KB:
return fmt.Sprintf("%.1f KB", float64(bytes)/float64(KB))
default:
return fmt.Sprintf("%d B", bytes)
}
}
type GmailURLCmd struct {
ThreadIDs []string `arg:"" name:"threadId" help:"Thread IDs"`
}
@ -284,9 +416,13 @@ func collectAttachments(p *gmail.MessagePart) []attachmentInfo {
return nil
}
var out []attachmentInfo
if p.Filename != "" && p.Body != nil && p.Body.AttachmentId != "" {
if p.Body != nil && p.Body.AttachmentId != "" {
filename := p.Filename
if strings.TrimSpace(filename) == "" {
filename = "attachment"
}
out = append(out, attachmentInfo{
Filename: p.Filename,
Filename: filename,
Size: p.Body.Size,
MimeType: p.MimeType,
AttachmentID: p.Body.AttachmentId,

View File

@ -15,6 +15,10 @@ func TestCollectAttachments(t *testing.T) {
MimeType: "text/plain",
Body: &gmail.MessagePartBody{AttachmentId: "att1", Size: 123},
},
{
MimeType: "image/png",
Body: &gmail.MessagePartBody{AttachmentId: "att-inline", Size: 42},
},
{
Parts: []*gmail.MessagePart{
{
@ -27,12 +31,15 @@ func TestCollectAttachments(t *testing.T) {
},
}
atts := collectAttachments(p)
if len(atts) != 2 {
if len(atts) != 3 {
t.Fatalf("unexpected: %#v", atts)
}
if atts[0].AttachmentID == "" || atts[1].AttachmentID == "" {
t.Fatalf("missing attachment ids: %#v", atts)
}
if atts[1].Filename != "attachment" {
t.Fatalf("expected fallback filename, got: %#v", atts[1])
}
}
func TestBestBodyTextPrefersPlain(t *testing.T) {

View File

@ -36,7 +36,7 @@ type CLI struct {
Docs DocsCmd `cmd:"" help:"Google Docs (export via Drive)"`
Slides SlidesCmd `cmd:"" help:"Google Slides"`
Calendar CalendarCmd `cmd:"" help:"Google Calendar"`
Gmail GmailCmd `cmd:"" help:"Gmail"`
Gmail GmailCmd `cmd:"" aliases:"mail,email" help:"Gmail"`
Contacts ContactsCmd `cmd:"" help:"Google Contacts"`
Tasks TasksCmd `cmd:"" help:"Google Tasks"`
People PeopleCmd `cmd:"" help:"Google People"`