Compare commits
9 Commits
main
...
feat/gmail
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
149f43f108 | ||
|
|
057493fbc1 | ||
|
|
af504ed8b8 | ||
|
|
5df6394379 | ||
|
|
28a45136b1 | ||
|
|
03037c3ba0 | ||
|
|
3e664bfce3 | ||
|
|
245e05c969 | ||
|
|
310cf16da5 |
@ -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,
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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"`
|
||||
|
||||
Loading…
Reference in New Issue
Block a user