diff --git a/cmd/commands_test.go b/cmd/commands_test.go index 2385daa..d33f691 100644 --- a/cmd/commands_test.go +++ b/cmd/commands_test.go @@ -127,6 +127,24 @@ func TestGmailCommands(t *testing.T) { {"thread", "thread ", true}, {"event-id", "event-id ", true}, {"reply", "reply ", true}, + {"untrash", "untrash ", true}, + {"delete", "delete ", true}, + {"batch-modify", "batch-modify", false}, + {"batch-delete", "batch-delete", false}, + {"trash-thread", "trash-thread ", true}, + {"untrash-thread", "untrash-thread ", true}, + {"delete-thread", "delete-thread ", true}, + {"label-info", "label-info", false}, + {"create-label", "create-label", false}, + {"update-label", "update-label", false}, + {"delete-label", "delete-label", false}, + {"drafts", "drafts", false}, + {"draft", "draft", false}, + {"create-draft", "create-draft", false}, + {"update-draft", "update-draft", false}, + {"send-draft", "send-draft", false}, + {"delete-draft", "delete-draft", false}, + {"attachment", "attachment", false}, } for _, tt := range tests { diff --git a/cmd/gmail.go b/cmd/gmail.go index f06d1de..ab2dd7a 100644 --- a/cmd/gmail.go +++ b/cmd/gmail.go @@ -145,6 +145,141 @@ Examples: RunE: runGmailThread, } +var gmailUntrashCmd = &cobra.Command{ + Use: "untrash ", + Short: "Remove a message from trash", + Long: "Removes a Gmail message from the trash, restoring it to its previous location.", + Args: cobra.ExactArgs(1), + RunE: runGmailUntrash, +} + +var gmailDeleteCmd = &cobra.Command{ + Use: "delete ", + Short: "Permanently delete a message", + Long: "Permanently deletes a Gmail message. This action cannot be undone.", + Args: cobra.ExactArgs(1), + RunE: runGmailDelete, +} + +var gmailBatchModifyCmd = &cobra.Command{ + Use: "batch-modify", + Short: "Modify labels on multiple messages", + Long: `Modifies labels on multiple Gmail messages at once. + +Examples: + gws gmail batch-modify --ids "msg1,msg2,msg3" --add-labels "STARRED" + gws gmail batch-modify --ids "msg1,msg2" --remove-labels "INBOX,UNREAD"`, + RunE: runGmailBatchModify, +} + +var gmailBatchDeleteCmd = &cobra.Command{ + Use: "batch-delete", + Short: "Permanently delete multiple messages", + Long: "Permanently deletes multiple Gmail messages at once. This action cannot be undone.", + RunE: runGmailBatchDelete, +} + +var gmailTrashThreadCmd = &cobra.Command{ + Use: "trash-thread ", + Short: "Move a thread to trash", + Long: "Moves all messages in a Gmail thread to the trash.", + Args: cobra.ExactArgs(1), + RunE: runGmailTrashThread, +} + +var gmailUntrashThreadCmd = &cobra.Command{ + Use: "untrash-thread ", + Short: "Remove a thread from trash", + Long: "Removes all messages in a Gmail thread from the trash.", + Args: cobra.ExactArgs(1), + RunE: runGmailUntrashThread, +} + +var gmailDeleteThreadCmd = &cobra.Command{ + Use: "delete-thread ", + Short: "Permanently delete a thread", + Long: "Permanently deletes all messages in a Gmail thread. This action cannot be undone.", + Args: cobra.ExactArgs(1), + RunE: runGmailDeleteThread, +} + +var gmailLabelInfoCmd = &cobra.Command{ + Use: "label-info", + Short: "Get label details", + Long: "Gets detailed information about a specific Gmail label.", + RunE: runGmailLabelInfo, +} + +var gmailCreateLabelCmd = &cobra.Command{ + Use: "create-label", + Short: "Create a new label", + Long: "Creates a new Gmail label.", + RunE: runGmailCreateLabel, +} + +var gmailUpdateLabelCmd = &cobra.Command{ + Use: "update-label", + Short: "Update a label", + Long: "Updates an existing Gmail label's name or visibility settings.", + RunE: runGmailUpdateLabel, +} + +var gmailDeleteLabelCmd = &cobra.Command{ + Use: "delete-label", + Short: "Delete a label", + Long: "Permanently deletes a Gmail label. Messages with this label are not deleted.", + RunE: runGmailDeleteLabel, +} + +var gmailDraftsCmd = &cobra.Command{ + Use: "drafts", + Short: "List drafts", + Long: "Lists Gmail drafts.", + RunE: runGmailDrafts, +} + +var gmailDraftCmd = &cobra.Command{ + Use: "draft", + Short: "Get a draft by ID", + Long: "Gets the content of a specific Gmail draft.", + RunE: runGmailDraft, +} + +var gmailCreateDraftCmd = &cobra.Command{ + Use: "create-draft", + Short: "Create a draft", + Long: "Creates a new Gmail draft message.", + RunE: runGmailCreateDraft, +} + +var gmailUpdateDraftCmd = &cobra.Command{ + Use: "update-draft", + Short: "Update a draft", + Long: "Replaces the content of an existing Gmail draft.", + RunE: runGmailUpdateDraft, +} + +var gmailSendDraftCmd = &cobra.Command{ + Use: "send-draft", + Short: "Send an existing draft", + Long: "Sends an existing Gmail draft.", + RunE: runGmailSendDraft, +} + +var gmailDeleteDraftCmd = &cobra.Command{ + Use: "delete-draft", + Short: "Delete a draft", + Long: "Permanently deletes a Gmail draft.", + RunE: runGmailDeleteDraft, +} + +var gmailAttachmentCmd = &cobra.Command{ + Use: "attachment", + Short: "Download an attachment", + Long: "Downloads a Gmail message attachment to a local file.", + RunE: runGmailAttachment, +} + func init() { rootCmd.AddCommand(gmailCmd) gmailCmd.AddCommand(gmailListCmd) @@ -187,6 +322,99 @@ func init() { // Label flags gmailLabelCmd.Flags().String("add", "", "Label names to add (comma-separated)") gmailLabelCmd.Flags().String("remove", "", "Label names to remove (comma-separated)") + + // New commands + gmailCmd.AddCommand(gmailUntrashCmd) + gmailCmd.AddCommand(gmailDeleteCmd) + gmailCmd.AddCommand(gmailBatchModifyCmd) + gmailCmd.AddCommand(gmailBatchDeleteCmd) + gmailCmd.AddCommand(gmailTrashThreadCmd) + gmailCmd.AddCommand(gmailUntrashThreadCmd) + gmailCmd.AddCommand(gmailDeleteThreadCmd) + gmailCmd.AddCommand(gmailLabelInfoCmd) + gmailCmd.AddCommand(gmailCreateLabelCmd) + gmailCmd.AddCommand(gmailUpdateLabelCmd) + gmailCmd.AddCommand(gmailDeleteLabelCmd) + gmailCmd.AddCommand(gmailDraftsCmd) + gmailCmd.AddCommand(gmailDraftCmd) + gmailCmd.AddCommand(gmailCreateDraftCmd) + gmailCmd.AddCommand(gmailUpdateDraftCmd) + gmailCmd.AddCommand(gmailSendDraftCmd) + gmailCmd.AddCommand(gmailDeleteDraftCmd) + gmailCmd.AddCommand(gmailAttachmentCmd) + + // Batch modify flags + gmailBatchModifyCmd.Flags().String("ids", "", "Comma-separated message IDs (required)") + gmailBatchModifyCmd.Flags().String("add-labels", "", "Label names to add (comma-separated)") + gmailBatchModifyCmd.Flags().String("remove-labels", "", "Label names to remove (comma-separated)") + gmailBatchModifyCmd.MarkFlagRequired("ids") + + // Batch delete flags + gmailBatchDeleteCmd.Flags().String("ids", "", "Comma-separated message IDs (required)") + gmailBatchDeleteCmd.MarkFlagRequired("ids") + + // Label info flags + gmailLabelInfoCmd.Flags().String("id", "", "Label ID (required)") + gmailLabelInfoCmd.MarkFlagRequired("id") + + // Create label flags + gmailCreateLabelCmd.Flags().String("name", "", "Label name (required)") + gmailCreateLabelCmd.Flags().String("visibility", "", "Message visibility: labelShow, labelShowIfUnread, labelHide") + gmailCreateLabelCmd.Flags().String("list-visibility", "", "Label list visibility: labelShow, labelHide") + gmailCreateLabelCmd.MarkFlagRequired("name") + + // Update label flags + gmailUpdateLabelCmd.Flags().String("id", "", "Label ID (required)") + gmailUpdateLabelCmd.Flags().String("name", "", "New label name") + gmailUpdateLabelCmd.Flags().String("visibility", "", "Message visibility: labelShow, labelShowIfUnread, labelHide") + gmailUpdateLabelCmd.Flags().String("list-visibility", "", "Label list visibility: labelShow, labelHide") + gmailUpdateLabelCmd.MarkFlagRequired("id") + + // Delete label flags + gmailDeleteLabelCmd.Flags().String("id", "", "Label ID (required)") + gmailDeleteLabelCmd.MarkFlagRequired("id") + + // Drafts list flags + gmailDraftsCmd.Flags().Int64("max", 10, "Maximum number of results") + gmailDraftsCmd.Flags().String("query", "", "Gmail search query") + + // Draft get flags + gmailDraftCmd.Flags().String("id", "", "Draft ID (required)") + gmailDraftCmd.MarkFlagRequired("id") + + // Create draft flags + gmailCreateDraftCmd.Flags().String("to", "", "Recipient email address (required)") + gmailCreateDraftCmd.Flags().String("subject", "", "Email subject") + gmailCreateDraftCmd.Flags().String("body", "", "Email body") + gmailCreateDraftCmd.Flags().String("cc", "", "CC recipients (comma-separated)") + gmailCreateDraftCmd.Flags().String("bcc", "", "BCC recipients (comma-separated)") + gmailCreateDraftCmd.Flags().String("thread-id", "", "Thread ID for reply draft") + gmailCreateDraftCmd.MarkFlagRequired("to") + + // Update draft flags + gmailUpdateDraftCmd.Flags().String("id", "", "Draft ID (required)") + gmailUpdateDraftCmd.Flags().String("to", "", "Recipient email address") + gmailUpdateDraftCmd.Flags().String("subject", "", "Email subject") + gmailUpdateDraftCmd.Flags().String("body", "", "Email body") + gmailUpdateDraftCmd.Flags().String("cc", "", "CC recipients (comma-separated)") + gmailUpdateDraftCmd.Flags().String("bcc", "", "BCC recipients (comma-separated)") + gmailUpdateDraftCmd.MarkFlagRequired("id") + + // Send draft flags + gmailSendDraftCmd.Flags().String("id", "", "Draft ID (required)") + gmailSendDraftCmd.MarkFlagRequired("id") + + // Delete draft flags + gmailDeleteDraftCmd.Flags().String("id", "", "Draft ID (required)") + gmailDeleteDraftCmd.MarkFlagRequired("id") + + // Attachment flags + gmailAttachmentCmd.Flags().String("message-id", "", "Message ID (required)") + gmailAttachmentCmd.Flags().String("id", "", "Attachment ID (required)") + gmailAttachmentCmd.Flags().String("output", "", "Output file path (required)") + gmailAttachmentCmd.MarkFlagRequired("message-id") + gmailAttachmentCmd.MarkFlagRequired("id") + gmailAttachmentCmd.MarkFlagRequired("output") } func runGmailList(cmd *cobra.Command, args []string) error { @@ -1033,3 +1261,700 @@ func extractBody(payload *gmail.MessagePart) string { return "" } + +func runGmailUntrash(cmd *cobra.Command, args []string) error { + p := GetPrinter() + ctx := context.Background() + + factory, err := client.NewFactory(ctx) + if err != nil { + return p.PrintError(err) + } + + svc, err := factory.Gmail() + if err != nil { + return p.PrintError(err) + } + + messageID := args[0] + + msg, err := svc.Users.Messages.Untrash("me", messageID).Do() + if err != nil { + return p.PrintError(fmt.Errorf("failed to untrash message: %w", err)) + } + + return p.Print(map[string]interface{}{ + "status": "untrashed", + "message_id": msg.Id, + }) +} + +func runGmailDelete(cmd *cobra.Command, args []string) error { + p := GetPrinter() + ctx := context.Background() + + factory, err := client.NewFactory(ctx) + if err != nil { + return p.PrintError(err) + } + + svc, err := factory.Gmail() + if err != nil { + return p.PrintError(err) + } + + messageID := args[0] + + err = svc.Users.Messages.Delete("me", messageID).Do() + if err != nil { + return p.PrintError(fmt.Errorf("failed to delete message: %w", err)) + } + + return p.Print(map[string]interface{}{ + "status": "deleted", + "message_id": messageID, + }) +} + +func runGmailBatchModify(cmd *cobra.Command, args []string) error { + p := GetPrinter() + ctx := context.Background() + + factory, err := client.NewFactory(ctx) + if err != nil { + return p.PrintError(err) + } + + svc, err := factory.Gmail() + if err != nil { + return p.PrintError(err) + } + + idsStr, _ := cmd.Flags().GetString("ids") + addLabelsStr, _ := cmd.Flags().GetString("add-labels") + removeLabelsStr, _ := cmd.Flags().GetString("remove-labels") + + if addLabelsStr == "" && removeLabelsStr == "" { + return p.PrintError(fmt.Errorf("at least one of --add-labels or --remove-labels is required")) + } + + ids := strings.Split(idsStr, ",") + for i := range ids { + ids[i] = strings.TrimSpace(ids[i]) + } + + // Fetch label map once for both add and remove + labelMap, err := fetchLabelMap(svc) + if err != nil { + return p.PrintError(err) + } + + req := &gmail.BatchModifyMessagesRequest{ + Ids: ids, + } + + if addLabelsStr != "" { + addIDs, err := resolveFromMap(labelMap, strings.Split(addLabelsStr, ",")) + if err != nil { + return p.PrintError(err) + } + req.AddLabelIds = addIDs + } + + if removeLabelsStr != "" { + removeIDs, err := resolveFromMap(labelMap, strings.Split(removeLabelsStr, ",")) + if err != nil { + return p.PrintError(err) + } + req.RemoveLabelIds = removeIDs + } + + err = svc.Users.Messages.BatchModify("me", req).Do() + if err != nil { + return p.PrintError(fmt.Errorf("failed to batch modify messages: %w", err)) + } + + return p.Print(map[string]interface{}{ + "status": "modified", + "count": len(ids), + }) +} + +func runGmailBatchDelete(cmd *cobra.Command, args []string) error { + p := GetPrinter() + ctx := context.Background() + + factory, err := client.NewFactory(ctx) + if err != nil { + return p.PrintError(err) + } + + svc, err := factory.Gmail() + if err != nil { + return p.PrintError(err) + } + + idsStr, _ := cmd.Flags().GetString("ids") + ids := strings.Split(idsStr, ",") + for i := range ids { + ids[i] = strings.TrimSpace(ids[i]) + } + + req := &gmail.BatchDeleteMessagesRequest{ + Ids: ids, + } + + err = svc.Users.Messages.BatchDelete("me", req).Do() + if err != nil { + return p.PrintError(fmt.Errorf("failed to batch delete messages: %w", err)) + } + + return p.Print(map[string]interface{}{ + "status": "deleted", + "count": len(ids), + }) +} + +func runGmailTrashThread(cmd *cobra.Command, args []string) error { + p := GetPrinter() + ctx := context.Background() + + factory, err := client.NewFactory(ctx) + if err != nil { + return p.PrintError(err) + } + + svc, err := factory.Gmail() + if err != nil { + return p.PrintError(err) + } + + threadID := args[0] + + thread, err := svc.Users.Threads.Trash("me", threadID).Do() + if err != nil { + return p.PrintError(fmt.Errorf("failed to trash thread: %w", err)) + } + + return p.Print(map[string]interface{}{ + "status": "trashed", + "thread_id": thread.Id, + }) +} + +func runGmailUntrashThread(cmd *cobra.Command, args []string) error { + p := GetPrinter() + ctx := context.Background() + + factory, err := client.NewFactory(ctx) + if err != nil { + return p.PrintError(err) + } + + svc, err := factory.Gmail() + if err != nil { + return p.PrintError(err) + } + + threadID := args[0] + + thread, err := svc.Users.Threads.Untrash("me", threadID).Do() + if err != nil { + return p.PrintError(fmt.Errorf("failed to untrash thread: %w", err)) + } + + return p.Print(map[string]interface{}{ + "status": "untrashed", + "thread_id": thread.Id, + }) +} + +func runGmailDeleteThread(cmd *cobra.Command, args []string) error { + p := GetPrinter() + ctx := context.Background() + + factory, err := client.NewFactory(ctx) + if err != nil { + return p.PrintError(err) + } + + svc, err := factory.Gmail() + if err != nil { + return p.PrintError(err) + } + + threadID := args[0] + + err = svc.Users.Threads.Delete("me", threadID).Do() + if err != nil { + return p.PrintError(fmt.Errorf("failed to delete thread: %w", err)) + } + + return p.Print(map[string]interface{}{ + "status": "deleted", + "thread_id": threadID, + }) +} + +func runGmailLabelInfo(cmd *cobra.Command, args []string) error { + p := GetPrinter() + ctx := context.Background() + + factory, err := client.NewFactory(ctx) + if err != nil { + return p.PrintError(err) + } + + svc, err := factory.Gmail() + if err != nil { + return p.PrintError(err) + } + + labelID, _ := cmd.Flags().GetString("id") + + label, err := svc.Users.Labels.Get("me", labelID).Do() + if err != nil { + return p.PrintError(fmt.Errorf("failed to get label: %w", err)) + } + + return p.Print(map[string]interface{}{ + "id": label.Id, + "name": label.Name, + "type": label.Type, + "message_list_visibility": label.MessageListVisibility, + "label_list_visibility": label.LabelListVisibility, + "messages_total": label.MessagesTotal, + "messages_unread": label.MessagesUnread, + "threads_total": label.ThreadsTotal, + "threads_unread": label.ThreadsUnread, + }) +} + +func runGmailCreateLabel(cmd *cobra.Command, args []string) error { + p := GetPrinter() + ctx := context.Background() + + factory, err := client.NewFactory(ctx) + if err != nil { + return p.PrintError(err) + } + + svc, err := factory.Gmail() + if err != nil { + return p.PrintError(err) + } + + name, _ := cmd.Flags().GetString("name") + visibility, _ := cmd.Flags().GetString("visibility") + listVisibility, _ := cmd.Flags().GetString("list-visibility") + + label := &gmail.Label{ + Name: name, + } + if visibility != "" { + label.MessageListVisibility = visibility + } + if listVisibility != "" { + label.LabelListVisibility = listVisibility + } + + created, err := svc.Users.Labels.Create("me", label).Do() + if err != nil { + return p.PrintError(fmt.Errorf("failed to create label: %w", err)) + } + + return p.Print(map[string]interface{}{ + "status": "created", + "id": created.Id, + "name": created.Name, + }) +} + +func runGmailUpdateLabel(cmd *cobra.Command, args []string) error { + p := GetPrinter() + ctx := context.Background() + + factory, err := client.NewFactory(ctx) + if err != nil { + return p.PrintError(err) + } + + svc, err := factory.Gmail() + if err != nil { + return p.PrintError(err) + } + + labelID, _ := cmd.Flags().GetString("id") + name, _ := cmd.Flags().GetString("name") + visibility, _ := cmd.Flags().GetString("visibility") + listVisibility, _ := cmd.Flags().GetString("list-visibility") + + current, err := svc.Users.Labels.Get("me", labelID).Do() + if err != nil { + return p.PrintError(fmt.Errorf("failed to get label: %w", err)) + } + + if name != "" { + current.Name = name + } + if visibility != "" { + current.MessageListVisibility = visibility + } + if listVisibility != "" { + current.LabelListVisibility = listVisibility + } + + updated, err := svc.Users.Labels.Update("me", labelID, current).Do() + if err != nil { + return p.PrintError(fmt.Errorf("failed to update label: %w", err)) + } + + return p.Print(map[string]interface{}{ + "status": "updated", + "id": updated.Id, + "name": updated.Name, + }) +} + +func runGmailDeleteLabel(cmd *cobra.Command, args []string) error { + p := GetPrinter() + ctx := context.Background() + + factory, err := client.NewFactory(ctx) + if err != nil { + return p.PrintError(err) + } + + svc, err := factory.Gmail() + if err != nil { + return p.PrintError(err) + } + + labelID, _ := cmd.Flags().GetString("id") + + err = svc.Users.Labels.Delete("me", labelID).Do() + if err != nil { + return p.PrintError(fmt.Errorf("failed to delete label: %w", err)) + } + + return p.Print(map[string]interface{}{ + "status": "deleted", + "id": labelID, + }) +} + +func runGmailDrafts(cmd *cobra.Command, args []string) error { + p := GetPrinter() + ctx := context.Background() + + factory, err := client.NewFactory(ctx) + if err != nil { + return p.PrintError(err) + } + + svc, err := factory.Gmail() + if err != nil { + return p.PrintError(err) + } + + maxResults, _ := cmd.Flags().GetInt64("max") + query, _ := cmd.Flags().GetString("query") + + call := svc.Users.Drafts.List("me").MaxResults(maxResults) + if query != "" { + call = call.Q(query) + } + + resp, err := call.Do() + if err != nil { + return p.PrintError(fmt.Errorf("failed to list drafts: %w", err)) + } + + results := make([]map[string]interface{}, 0, len(resp.Drafts)) + for _, draft := range resp.Drafts { + d := map[string]interface{}{ + "id": draft.Id, + } + if draft.Message != nil { + d["message_id"] = draft.Message.Id + } + results = append(results, d) + } + + return p.Print(map[string]interface{}{ + "drafts": results, + "count": len(results), + }) +} + +func runGmailDraft(cmd *cobra.Command, args []string) error { + p := GetPrinter() + ctx := context.Background() + + factory, err := client.NewFactory(ctx) + if err != nil { + return p.PrintError(err) + } + + svc, err := factory.Gmail() + if err != nil { + return p.PrintError(err) + } + + draftID, _ := cmd.Flags().GetString("id") + + draft, err := svc.Users.Drafts.Get("me", draftID).Format("full").Do() + if err != nil { + return p.PrintError(fmt.Errorf("failed to get draft: %w", err)) + } + + result := map[string]interface{}{ + "id": draft.Id, + } + + if draft.Message != nil { + result["message_id"] = draft.Message.Id + + if draft.Message.Payload != nil { + headers := make(map[string]string) + for _, header := range draft.Message.Payload.Headers { + switch header.Name { + case "Subject", "From", "To", "Date", "Cc", "Bcc": + headers[strings.ToLower(header.Name)] = header.Value + } + } + result["headers"] = headers + result["body"] = extractBody(draft.Message.Payload) + } + } + + return p.Print(result) +} + +func runGmailCreateDraft(cmd *cobra.Command, args []string) error { + p := GetPrinter() + ctx := context.Background() + + factory, err := client.NewFactory(ctx) + if err != nil { + return p.PrintError(err) + } + + svc, err := factory.Gmail() + if err != nil { + return p.PrintError(err) + } + + to, _ := cmd.Flags().GetString("to") + subject, _ := cmd.Flags().GetString("subject") + body, _ := cmd.Flags().GetString("body") + cc, _ := cmd.Flags().GetString("cc") + bcc, _ := cmd.Flags().GetString("bcc") + threadID, _ := cmd.Flags().GetString("thread-id") + + // Build RFC 2822 message + var msgBuilder strings.Builder + msgBuilder.WriteString(fmt.Sprintf("To: %s\r\n", to)) + if cc != "" { + msgBuilder.WriteString(fmt.Sprintf("Cc: %s\r\n", cc)) + } + if bcc != "" { + msgBuilder.WriteString(fmt.Sprintf("Bcc: %s\r\n", bcc)) + } + if subject != "" { + msgBuilder.WriteString(fmt.Sprintf("Subject: %s\r\n", subject)) + } + msgBuilder.WriteString("Content-Type: text/plain; charset=\"UTF-8\"\r\n") + msgBuilder.WriteString("\r\n") + if body != "" { + msgBuilder.WriteString(body) + } + + raw := base64.URLEncoding.EncodeToString([]byte(msgBuilder.String())) + + msg := &gmail.Message{Raw: raw} + if threadID != "" { + msg.ThreadId = threadID + } + + draft := &gmail.Draft{ + Message: msg, + } + + created, err := svc.Users.Drafts.Create("me", draft).Do() + if err != nil { + return p.PrintError(fmt.Errorf("failed to create draft: %w", err)) + } + + result := map[string]interface{}{ + "status": "created", + "draft_id": created.Id, + } + if created.Message != nil { + result["message_id"] = created.Message.Id + } + + return p.Print(result) +} + +func runGmailUpdateDraft(cmd *cobra.Command, args []string) error { + p := GetPrinter() + ctx := context.Background() + + factory, err := client.NewFactory(ctx) + if err != nil { + return p.PrintError(err) + } + + svc, err := factory.Gmail() + if err != nil { + return p.PrintError(err) + } + + draftID, _ := cmd.Flags().GetString("id") + to, _ := cmd.Flags().GetString("to") + subject, _ := cmd.Flags().GetString("subject") + body, _ := cmd.Flags().GetString("body") + cc, _ := cmd.Flags().GetString("cc") + bcc, _ := cmd.Flags().GetString("bcc") + + // Build RFC 2822 message + var msgBuilder strings.Builder + if to != "" { + msgBuilder.WriteString(fmt.Sprintf("To: %s\r\n", to)) + } + if cc != "" { + msgBuilder.WriteString(fmt.Sprintf("Cc: %s\r\n", cc)) + } + if bcc != "" { + msgBuilder.WriteString(fmt.Sprintf("Bcc: %s\r\n", bcc)) + } + if subject != "" { + msgBuilder.WriteString(fmt.Sprintf("Subject: %s\r\n", subject)) + } + msgBuilder.WriteString("Content-Type: text/plain; charset=\"UTF-8\"\r\n") + msgBuilder.WriteString("\r\n") + if body != "" { + msgBuilder.WriteString(body) + } + + raw := base64.URLEncoding.EncodeToString([]byte(msgBuilder.String())) + + draft := &gmail.Draft{ + Message: &gmail.Message{Raw: raw}, + } + + updated, err := svc.Users.Drafts.Update("me", draftID, draft).Do() + if err != nil { + return p.PrintError(fmt.Errorf("failed to update draft: %w", err)) + } + + result := map[string]interface{}{ + "status": "updated", + "draft_id": updated.Id, + } + if updated.Message != nil { + result["message_id"] = updated.Message.Id + } + + return p.Print(result) +} + +func runGmailSendDraft(cmd *cobra.Command, args []string) error { + p := GetPrinter() + ctx := context.Background() + + factory, err := client.NewFactory(ctx) + if err != nil { + return p.PrintError(err) + } + + svc, err := factory.Gmail() + if err != nil { + return p.PrintError(err) + } + + draftID, _ := cmd.Flags().GetString("id") + + draft := &gmail.Draft{ + Id: draftID, + } + + sent, err := svc.Users.Drafts.Send("me", draft).Do() + if err != nil { + return p.PrintError(fmt.Errorf("failed to send draft: %w", err)) + } + + return p.Print(map[string]interface{}{ + "status": "sent", + "message_id": sent.Id, + "thread_id": sent.ThreadId, + }) +} + +func runGmailDeleteDraft(cmd *cobra.Command, args []string) error { + p := GetPrinter() + ctx := context.Background() + + factory, err := client.NewFactory(ctx) + if err != nil { + return p.PrintError(err) + } + + svc, err := factory.Gmail() + if err != nil { + return p.PrintError(err) + } + + draftID, _ := cmd.Flags().GetString("id") + + err = svc.Users.Drafts.Delete("me", draftID).Do() + if err != nil { + return p.PrintError(fmt.Errorf("failed to delete draft: %w", err)) + } + + return p.Print(map[string]interface{}{ + "status": "deleted", + "draft_id": draftID, + }) +} + +func runGmailAttachment(cmd *cobra.Command, args []string) error { + p := GetPrinter() + ctx := context.Background() + + factory, err := client.NewFactory(ctx) + if err != nil { + return p.PrintError(err) + } + + svc, err := factory.Gmail() + if err != nil { + return p.PrintError(err) + } + + messageID, _ := cmd.Flags().GetString("message-id") + attachmentID, _ := cmd.Flags().GetString("id") + output, _ := cmd.Flags().GetString("output") + + attachment, err := svc.Users.Messages.Attachments.Get("me", messageID, attachmentID).Do() + if err != nil { + return p.PrintError(fmt.Errorf("failed to get attachment: %w", err)) + } + + data, err := base64.URLEncoding.DecodeString(attachment.Data) + if err != nil { + return p.PrintError(fmt.Errorf("failed to decode attachment: %w", err)) + } + + err = os.WriteFile(output, data, 0644) + if err != nil { + return p.PrintError(fmt.Errorf("failed to write file: %w", err)) + } + + return p.Print(map[string]interface{}{ + "status": "downloaded", + "file": output, + "size": len(data), + }) +} diff --git a/cmd/gmail_test.go b/cmd/gmail_test.go index f95a7d8..96d5413 100644 --- a/cmd/gmail_test.go +++ b/cmd/gmail_test.go @@ -1542,3 +1542,841 @@ func TestGmailList_IncludeLabels_MockServer(t *testing.T) { t.Error("labels should not be present when include-labels is false") } } + +// === Tests for new Gmail commands === + +// TestGmailUntrashCommand_Help tests untrash command structure +func TestGmailUntrashCommand_Help(t *testing.T) { + cmd := gmailUntrashCmd + if cmd.Use != "untrash " { + t.Errorf("unexpected Use: %s", cmd.Use) + } + if cmd.Args == nil { + t.Error("expected Args validator to be set") + } +} + +// TestGmailUntrash_MockServer tests untrash API integration +func TestGmailUntrash_MockServer(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + + if r.URL.Path == "/gmail/v1/users/me/messages/msg-trash-1/untrash" && r.Method == "POST" { + resp := &gmail.Message{ + Id: "msg-trash-1", + LabelIds: []string{"INBOX"}, + } + json.NewEncoder(w).Encode(resp) + return + } + + t.Logf("Unexpected request: %s %s", r.Method, r.URL.Path) + w.WriteHeader(http.StatusNotFound) + })) + defer server.Close() + + svc, err := gmail.NewService(context.Background(), option.WithoutAuthentication(), option.WithEndpoint(server.URL)) + if err != nil { + t.Fatalf("failed to create gmail service: %v", err) + } + + msg, err := svc.Users.Messages.Untrash("me", "msg-trash-1").Do() + if err != nil { + t.Fatalf("failed to untrash message: %v", err) + } + if msg.Id != "msg-trash-1" { + t.Errorf("unexpected message id: %s", msg.Id) + } +} + +// TestGmailDeleteCommand_Help tests delete command structure +func TestGmailDeleteCommand_Help(t *testing.T) { + cmd := gmailDeleteCmd + if cmd.Use != "delete " { + t.Errorf("unexpected Use: %s", cmd.Use) + } + if cmd.Args == nil { + t.Error("expected Args validator to be set") + } +} + +// TestGmailDelete_MockServer tests permanent delete API integration +func TestGmailDelete_MockServer(t *testing.T) { + deleteCalled := false + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/gmail/v1/users/me/messages/msg-del-1" && r.Method == "DELETE" { + deleteCalled = true + w.WriteHeader(http.StatusNoContent) + return + } + t.Logf("Unexpected request: %s %s", r.Method, r.URL.Path) + w.WriteHeader(http.StatusNotFound) + })) + defer server.Close() + + svc, err := gmail.NewService(context.Background(), option.WithoutAuthentication(), option.WithEndpoint(server.URL)) + if err != nil { + t.Fatalf("failed to create gmail service: %v", err) + } + + err = svc.Users.Messages.Delete("me", "msg-del-1").Do() + if err != nil { + t.Fatalf("failed to delete message: %v", err) + } + if !deleteCalled { + t.Error("delete API was not called") + } +} + +// TestGmailBatchModifyCommand_Flags tests batch-modify command flags +func TestGmailBatchModifyCommand_Flags(t *testing.T) { + cmd := gmailBatchModifyCmd + + idsFlag := cmd.Flags().Lookup("ids") + if idsFlag == nil { + t.Error("expected --ids flag to exist") + } + addFlag := cmd.Flags().Lookup("add-labels") + if addFlag == nil { + t.Error("expected --add-labels flag to exist") + } + removeFlag := cmd.Flags().Lookup("remove-labels") + if removeFlag == nil { + t.Error("expected --remove-labels flag to exist") + } +} + +// TestGmailBatchModify_MockServer tests batch modify API integration +func TestGmailBatchModify_MockServer(t *testing.T) { + var receivedReq gmail.BatchModifyMessagesRequest + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + + // Labels list for resolution + if r.URL.Path == "/gmail/v1/users/me/labels" && r.Method == "GET" { + resp := &gmail.ListLabelsResponse{ + Labels: []*gmail.Label{ + {Id: "STARRED", Name: "STARRED", Type: "system"}, + {Id: "INBOX", Name: "INBOX", Type: "system"}, + }, + } + json.NewEncoder(w).Encode(resp) + return + } + + if r.URL.Path == "/gmail/v1/users/me/messages/batchModify" && r.Method == "POST" { + json.NewDecoder(r.Body).Decode(&receivedReq) + w.WriteHeader(http.StatusNoContent) + return + } + + t.Logf("Unexpected request: %s %s", r.Method, r.URL.Path) + w.WriteHeader(http.StatusNotFound) + })) + defer server.Close() + + svc, err := gmail.NewService(context.Background(), option.WithoutAuthentication(), option.WithEndpoint(server.URL)) + if err != nil { + t.Fatalf("failed to create gmail service: %v", err) + } + + req := &gmail.BatchModifyMessagesRequest{ + Ids: []string{"msg-1", "msg-2"}, + AddLabelIds: []string{"STARRED"}, + RemoveLabelIds: []string{"INBOX"}, + } + + err = svc.Users.Messages.BatchModify("me", req).Do() + if err != nil { + t.Fatalf("failed to batch modify: %v", err) + } +} + +// TestGmailBatchDeleteCommand_Flags tests batch-delete command flags +func TestGmailBatchDeleteCommand_Flags(t *testing.T) { + cmd := gmailBatchDeleteCmd + + idsFlag := cmd.Flags().Lookup("ids") + if idsFlag == nil { + t.Error("expected --ids flag to exist") + } +} + +// TestGmailBatchDelete_MockServer tests batch delete API integration +func TestGmailBatchDelete_MockServer(t *testing.T) { + batchDeleteCalled := false + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/gmail/v1/users/me/messages/batchDelete" && r.Method == "POST" { + batchDeleteCalled = true + w.WriteHeader(http.StatusNoContent) + return + } + t.Logf("Unexpected request: %s %s", r.Method, r.URL.Path) + w.WriteHeader(http.StatusNotFound) + })) + defer server.Close() + + svc, err := gmail.NewService(context.Background(), option.WithoutAuthentication(), option.WithEndpoint(server.URL)) + if err != nil { + t.Fatalf("failed to create gmail service: %v", err) + } + + req := &gmail.BatchDeleteMessagesRequest{ + Ids: []string{"msg-1", "msg-2", "msg-3"}, + } + err = svc.Users.Messages.BatchDelete("me", req).Do() + if err != nil { + t.Fatalf("failed to batch delete: %v", err) + } + if !batchDeleteCalled { + t.Error("batch delete API was not called") + } +} + +// TestGmailTrashThreadCommand_Help tests trash-thread command structure +func TestGmailTrashThreadCommand_Help(t *testing.T) { + cmd := gmailTrashThreadCmd + if cmd.Use != "trash-thread " { + t.Errorf("unexpected Use: %s", cmd.Use) + } + if cmd.Args == nil { + t.Error("expected Args validator to be set") + } +} + +// TestGmailTrashThread_MockServer tests trash thread API integration +func TestGmailTrashThread_MockServer(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + if r.URL.Path == "/gmail/v1/users/me/threads/thread-trash-1/trash" && r.Method == "POST" { + resp := map[string]interface{}{ + "id": "thread-trash-1", + } + json.NewEncoder(w).Encode(resp) + return + } + t.Logf("Unexpected request: %s %s", r.Method, r.URL.Path) + w.WriteHeader(http.StatusNotFound) + })) + defer server.Close() + + svc, err := gmail.NewService(context.Background(), option.WithoutAuthentication(), option.WithEndpoint(server.URL)) + if err != nil { + t.Fatalf("failed to create gmail service: %v", err) + } + + thread, err := svc.Users.Threads.Trash("me", "thread-trash-1").Do() + if err != nil { + t.Fatalf("failed to trash thread: %v", err) + } + if thread.Id != "thread-trash-1" { + t.Errorf("unexpected thread id: %s", thread.Id) + } +} + +// TestGmailUntrashThreadCommand_Help tests untrash-thread command structure +func TestGmailUntrashThreadCommand_Help(t *testing.T) { + cmd := gmailUntrashThreadCmd + if cmd.Use != "untrash-thread " { + t.Errorf("unexpected Use: %s", cmd.Use) + } + if cmd.Args == nil { + t.Error("expected Args validator to be set") + } +} + +// TestGmailUntrashThread_MockServer tests untrash thread API integration +func TestGmailUntrashThread_MockServer(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + if r.URL.Path == "/gmail/v1/users/me/threads/thread-ut-1/untrash" && r.Method == "POST" { + resp := map[string]interface{}{ + "id": "thread-ut-1", + } + json.NewEncoder(w).Encode(resp) + return + } + t.Logf("Unexpected request: %s %s", r.Method, r.URL.Path) + w.WriteHeader(http.StatusNotFound) + })) + defer server.Close() + + svc, err := gmail.NewService(context.Background(), option.WithoutAuthentication(), option.WithEndpoint(server.URL)) + if err != nil { + t.Fatalf("failed to create gmail service: %v", err) + } + + thread, err := svc.Users.Threads.Untrash("me", "thread-ut-1").Do() + if err != nil { + t.Fatalf("failed to untrash thread: %v", err) + } + if thread.Id != "thread-ut-1" { + t.Errorf("unexpected thread id: %s", thread.Id) + } +} + +// TestGmailDeleteThreadCommand_Help tests delete-thread command structure +func TestGmailDeleteThreadCommand_Help(t *testing.T) { + cmd := gmailDeleteThreadCmd + if cmd.Use != "delete-thread " { + t.Errorf("unexpected Use: %s", cmd.Use) + } + if cmd.Args == nil { + t.Error("expected Args validator to be set") + } +} + +// TestGmailDeleteThread_MockServer tests delete thread API integration +func TestGmailDeleteThread_MockServer(t *testing.T) { + deleteCalled := false + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/gmail/v1/users/me/threads/thread-del-1" && r.Method == "DELETE" { + deleteCalled = true + w.WriteHeader(http.StatusNoContent) + return + } + t.Logf("Unexpected request: %s %s", r.Method, r.URL.Path) + w.WriteHeader(http.StatusNotFound) + })) + defer server.Close() + + svc, err := gmail.NewService(context.Background(), option.WithoutAuthentication(), option.WithEndpoint(server.URL)) + if err != nil { + t.Fatalf("failed to create gmail service: %v", err) + } + + err = svc.Users.Threads.Delete("me", "thread-del-1").Do() + if err != nil { + t.Fatalf("failed to delete thread: %v", err) + } + if !deleteCalled { + t.Error("delete API was not called") + } +} + +// TestGmailLabelInfoCommand_Flags tests label-info command flags +func TestGmailLabelInfoCommand_Flags(t *testing.T) { + cmd := gmailLabelInfoCmd + idFlag := cmd.Flags().Lookup("id") + if idFlag == nil { + t.Error("expected --id flag to exist") + } +} + +// TestGmailLabelInfo_MockServer tests label info API integration +func TestGmailLabelInfo_MockServer(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + if r.URL.Path == "/gmail/v1/users/me/labels/Label_1" && r.Method == "GET" { + resp := &gmail.Label{ + Id: "Label_1", + Name: "ActionNeeded", + Type: "user", + MessageListVisibility: "show", + LabelListVisibility: "labelShow", + MessagesTotal: 42, + MessagesUnread: 5, + ThreadsTotal: 30, + ThreadsUnread: 3, + } + json.NewEncoder(w).Encode(resp) + return + } + t.Logf("Unexpected request: %s %s", r.Method, r.URL.Path) + w.WriteHeader(http.StatusNotFound) + })) + defer server.Close() + + svc, err := gmail.NewService(context.Background(), option.WithoutAuthentication(), option.WithEndpoint(server.URL)) + if err != nil { + t.Fatalf("failed to create gmail service: %v", err) + } + + label, err := svc.Users.Labels.Get("me", "Label_1").Do() + if err != nil { + t.Fatalf("failed to get label: %v", err) + } + if label.Name != "ActionNeeded" { + t.Errorf("unexpected label name: %s", label.Name) + } + if label.MessagesTotal != 42 { + t.Errorf("unexpected messages total: %d", label.MessagesTotal) + } +} + +// TestGmailCreateLabelCommand_Flags tests create-label command flags +func TestGmailCreateLabelCommand_Flags(t *testing.T) { + cmd := gmailCreateLabelCmd + nameFlag := cmd.Flags().Lookup("name") + if nameFlag == nil { + t.Error("expected --name flag to exist") + } + visFlag := cmd.Flags().Lookup("visibility") + if visFlag == nil { + t.Error("expected --visibility flag to exist") + } + listVisFlag := cmd.Flags().Lookup("list-visibility") + if listVisFlag == nil { + t.Error("expected --list-visibility flag to exist") + } +} + +// TestGmailCreateLabel_MockServer tests create label API integration +func TestGmailCreateLabel_MockServer(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + if r.URL.Path == "/gmail/v1/users/me/labels" && r.Method == "POST" { + var label gmail.Label + json.NewDecoder(r.Body).Decode(&label) + if label.Name != "TestLabel" { + t.Errorf("unexpected label name: %s", label.Name) + } + resp := &gmail.Label{ + Id: "Label_new", + Name: label.Name, + Type: "user", + } + json.NewEncoder(w).Encode(resp) + return + } + t.Logf("Unexpected request: %s %s", r.Method, r.URL.Path) + w.WriteHeader(http.StatusNotFound) + })) + defer server.Close() + + svc, err := gmail.NewService(context.Background(), option.WithoutAuthentication(), option.WithEndpoint(server.URL)) + if err != nil { + t.Fatalf("failed to create gmail service: %v", err) + } + + label := &gmail.Label{Name: "TestLabel"} + created, err := svc.Users.Labels.Create("me", label).Do() + if err != nil { + t.Fatalf("failed to create label: %v", err) + } + if created.Id != "Label_new" { + t.Errorf("unexpected label id: %s", created.Id) + } +} + +// TestGmailUpdateLabelCommand_Flags tests update-label command flags +func TestGmailUpdateLabelCommand_Flags(t *testing.T) { + cmd := gmailUpdateLabelCmd + for _, flag := range []string{"id", "name", "visibility", "list-visibility"} { + if cmd.Flags().Lookup(flag) == nil { + t.Errorf("expected --%s flag to exist", flag) + } + } +} + +// TestGmailUpdateLabel_MockServer tests update label API integration +func TestGmailUpdateLabel_MockServer(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + + // Get current label + if r.URL.Path == "/gmail/v1/users/me/labels/Label_1" && r.Method == "GET" { + resp := &gmail.Label{ + Id: "Label_1", + Name: "OldName", + Type: "user", + } + json.NewEncoder(w).Encode(resp) + return + } + + // Update label + if r.URL.Path == "/gmail/v1/users/me/labels/Label_1" && r.Method == "PUT" { + var label gmail.Label + json.NewDecoder(r.Body).Decode(&label) + resp := &gmail.Label{ + Id: "Label_1", + Name: label.Name, + Type: "user", + } + json.NewEncoder(w).Encode(resp) + return + } + + t.Logf("Unexpected request: %s %s", r.Method, r.URL.Path) + w.WriteHeader(http.StatusNotFound) + })) + defer server.Close() + + svc, err := gmail.NewService(context.Background(), option.WithoutAuthentication(), option.WithEndpoint(server.URL)) + if err != nil { + t.Fatalf("failed to create gmail service: %v", err) + } + + // Get then update + current, err := svc.Users.Labels.Get("me", "Label_1").Do() + if err != nil { + t.Fatalf("failed to get label: %v", err) + } + current.Name = "NewName" + + updated, err := svc.Users.Labels.Update("me", "Label_1", current).Do() + if err != nil { + t.Fatalf("failed to update label: %v", err) + } + if updated.Name != "NewName" { + t.Errorf("unexpected updated name: %s", updated.Name) + } +} + +// TestGmailDeleteLabelCommand_Flags tests delete-label command flags +func TestGmailDeleteLabelCommand_Flags(t *testing.T) { + cmd := gmailDeleteLabelCmd + if cmd.Flags().Lookup("id") == nil { + t.Error("expected --id flag to exist") + } +} + +// TestGmailDeleteLabel_MockServer tests delete label API integration +func TestGmailDeleteLabel_MockServer(t *testing.T) { + deleteCalled := false + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/gmail/v1/users/me/labels/Label_del" && r.Method == "DELETE" { + deleteCalled = true + w.WriteHeader(http.StatusNoContent) + return + } + t.Logf("Unexpected request: %s %s", r.Method, r.URL.Path) + w.WriteHeader(http.StatusNotFound) + })) + defer server.Close() + + svc, err := gmail.NewService(context.Background(), option.WithoutAuthentication(), option.WithEndpoint(server.URL)) + if err != nil { + t.Fatalf("failed to create gmail service: %v", err) + } + + err = svc.Users.Labels.Delete("me", "Label_del").Do() + if err != nil { + t.Fatalf("failed to delete label: %v", err) + } + if !deleteCalled { + t.Error("delete API was not called") + } +} + +// TestGmailDraftsCommand_Flags tests drafts list command flags +func TestGmailDraftsCommand_Flags(t *testing.T) { + cmd := gmailDraftsCmd + if cmd.Flags().Lookup("max") == nil { + t.Error("expected --max flag to exist") + } + if cmd.Flags().Lookup("query") == nil { + t.Error("expected --query flag to exist") + } +} + +// TestGmailDrafts_MockServer tests drafts list API integration +func TestGmailDrafts_MockServer(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + if r.URL.Path == "/gmail/v1/users/me/drafts" && r.Method == "GET" { + resp := map[string]interface{}{ + "drafts": []map[string]interface{}{ + {"id": "draft-1", "message": map[string]interface{}{"id": "msg-d1"}}, + {"id": "draft-2", "message": map[string]interface{}{"id": "msg-d2"}}, + }, + "resultSizeEstimate": 2, + } + json.NewEncoder(w).Encode(resp) + return + } + t.Logf("Unexpected request: %s %s", r.Method, r.URL.Path) + w.WriteHeader(http.StatusNotFound) + })) + defer server.Close() + + svc, err := gmail.NewService(context.Background(), option.WithoutAuthentication(), option.WithEndpoint(server.URL)) + if err != nil { + t.Fatalf("failed to create gmail service: %v", err) + } + + resp, err := svc.Users.Drafts.List("me").MaxResults(10).Do() + if err != nil { + t.Fatalf("failed to list drafts: %v", err) + } + if len(resp.Drafts) != 2 { + t.Errorf("expected 2 drafts, got %d", len(resp.Drafts)) + } +} + +// TestGmailDraftCommand_Flags tests draft get command flags +func TestGmailDraftCommand_Flags(t *testing.T) { + cmd := gmailDraftCmd + if cmd.Flags().Lookup("id") == nil { + t.Error("expected --id flag to exist") + } +} + +// TestGmailDraft_MockServer tests draft get API integration +func TestGmailDraft_MockServer(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + if r.URL.Path == "/gmail/v1/users/me/drafts/draft-1" && r.Method == "GET" { + body := base64.URLEncoding.EncodeToString([]byte("Draft body content")) + resp := map[string]interface{}{ + "id": "draft-1", + "message": map[string]interface{}{ + "id": "msg-d1", + "threadId": "thread-d1", + "payload": map[string]interface{}{ + "headers": []map[string]string{ + {"name": "Subject", "value": "Draft Subject"}, + {"name": "To", "value": "recipient@example.com"}, + }, + "mimeType": "text/plain", + "body": map[string]interface{}{"data": body}, + }, + }, + } + json.NewEncoder(w).Encode(resp) + return + } + t.Logf("Unexpected request: %s %s", r.Method, r.URL.Path) + w.WriteHeader(http.StatusNotFound) + })) + defer server.Close() + + svc, err := gmail.NewService(context.Background(), option.WithoutAuthentication(), option.WithEndpoint(server.URL)) + if err != nil { + t.Fatalf("failed to create gmail service: %v", err) + } + + draft, err := svc.Users.Drafts.Get("me", "draft-1").Format("full").Do() + if err != nil { + t.Fatalf("failed to get draft: %v", err) + } + if draft.Id != "draft-1" { + t.Errorf("unexpected draft id: %s", draft.Id) + } + if draft.Message == nil { + t.Fatal("expected message to be present") + } + if draft.Message.Id != "msg-d1" { + t.Errorf("unexpected message id: %s", draft.Message.Id) + } +} + +// TestGmailCreateDraftCommand_Flags tests create-draft command flags +func TestGmailCreateDraftCommand_Flags(t *testing.T) { + cmd := gmailCreateDraftCmd + for _, flag := range []string{"to", "subject", "body", "cc", "bcc", "thread-id"} { + if cmd.Flags().Lookup(flag) == nil { + t.Errorf("expected --%s flag to exist", flag) + } + } +} + +// TestGmailCreateDraft_MockServer tests create draft API integration +func TestGmailCreateDraft_MockServer(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + if r.URL.Path == "/gmail/v1/users/me/drafts" && r.Method == "POST" { + resp := map[string]interface{}{ + "id": "draft-new", + "message": map[string]interface{}{"id": "msg-new", "threadId": "thread-new"}, + } + json.NewEncoder(w).Encode(resp) + return + } + t.Logf("Unexpected request: %s %s", r.Method, r.URL.Path) + w.WriteHeader(http.StatusNotFound) + })) + defer server.Close() + + svc, err := gmail.NewService(context.Background(), option.WithoutAuthentication(), option.WithEndpoint(server.URL)) + if err != nil { + t.Fatalf("failed to create gmail service: %v", err) + } + + raw := base64.URLEncoding.EncodeToString([]byte("To: test@example.com\r\nSubject: Test\r\n\r\nBody")) + draft := &gmail.Draft{ + Message: &gmail.Message{Raw: raw}, + } + + created, err := svc.Users.Drafts.Create("me", draft).Do() + if err != nil { + t.Fatalf("failed to create draft: %v", err) + } + if created.Id != "draft-new" { + t.Errorf("unexpected draft id: %s", created.Id) + } +} + +// TestGmailUpdateDraftCommand_Flags tests update-draft command flags +func TestGmailUpdateDraftCommand_Flags(t *testing.T) { + cmd := gmailUpdateDraftCmd + for _, flag := range []string{"id", "to", "subject", "body", "cc", "bcc"} { + if cmd.Flags().Lookup(flag) == nil { + t.Errorf("expected --%s flag to exist", flag) + } + } +} + +// TestGmailUpdateDraft_MockServer tests update draft API integration +func TestGmailUpdateDraft_MockServer(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + if r.URL.Path == "/gmail/v1/users/me/drafts/draft-upd" && r.Method == "PUT" { + resp := map[string]interface{}{ + "id": "draft-upd", + "message": map[string]interface{}{"id": "msg-upd"}, + } + json.NewEncoder(w).Encode(resp) + return + } + t.Logf("Unexpected request: %s %s", r.Method, r.URL.Path) + w.WriteHeader(http.StatusNotFound) + })) + defer server.Close() + + svc, err := gmail.NewService(context.Background(), option.WithoutAuthentication(), option.WithEndpoint(server.URL)) + if err != nil { + t.Fatalf("failed to create gmail service: %v", err) + } + + raw := base64.URLEncoding.EncodeToString([]byte("To: new@example.com\r\nSubject: Updated\r\n\r\nNew body")) + draft := &gmail.Draft{ + Message: &gmail.Message{Raw: raw}, + } + + updated, err := svc.Users.Drafts.Update("me", "draft-upd", draft).Do() + if err != nil { + t.Fatalf("failed to update draft: %v", err) + } + if updated.Id != "draft-upd" { + t.Errorf("unexpected draft id: %s", updated.Id) + } +} + +// TestGmailSendDraftCommand_Flags tests send-draft command flags +func TestGmailSendDraftCommand_Flags(t *testing.T) { + cmd := gmailSendDraftCmd + if cmd.Flags().Lookup("id") == nil { + t.Error("expected --id flag to exist") + } +} + +// TestGmailSendDraft_MockServer tests send draft API integration +func TestGmailSendDraft_MockServer(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + if r.URL.Path == "/gmail/v1/users/me/drafts/send" && r.Method == "POST" { + resp := &gmail.Message{ + Id: "msg-sent", + ThreadId: "thread-sent", + } + json.NewEncoder(w).Encode(resp) + return + } + t.Logf("Unexpected request: %s %s", r.Method, r.URL.Path) + w.WriteHeader(http.StatusNotFound) + })) + defer server.Close() + + svc, err := gmail.NewService(context.Background(), option.WithoutAuthentication(), option.WithEndpoint(server.URL)) + if err != nil { + t.Fatalf("failed to create gmail service: %v", err) + } + + draft := &gmail.Draft{Id: "draft-to-send"} + sent, err := svc.Users.Drafts.Send("me", draft).Do() + if err != nil { + t.Fatalf("failed to send draft: %v", err) + } + if sent.Id != "msg-sent" { + t.Errorf("unexpected message id: %s", sent.Id) + } + if sent.ThreadId != "thread-sent" { + t.Errorf("unexpected thread id: %s", sent.ThreadId) + } +} + +// TestGmailDeleteDraftCommand_Flags tests delete-draft command flags +func TestGmailDeleteDraftCommand_Flags(t *testing.T) { + cmd := gmailDeleteDraftCmd + if cmd.Flags().Lookup("id") == nil { + t.Error("expected --id flag to exist") + } +} + +// TestGmailDeleteDraft_MockServer tests delete draft API integration +func TestGmailDeleteDraft_MockServer(t *testing.T) { + deleteCalled := false + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/gmail/v1/users/me/drafts/draft-del" && r.Method == "DELETE" { + deleteCalled = true + w.WriteHeader(http.StatusNoContent) + return + } + t.Logf("Unexpected request: %s %s", r.Method, r.URL.Path) + w.WriteHeader(http.StatusNotFound) + })) + defer server.Close() + + svc, err := gmail.NewService(context.Background(), option.WithoutAuthentication(), option.WithEndpoint(server.URL)) + if err != nil { + t.Fatalf("failed to create gmail service: %v", err) + } + + err = svc.Users.Drafts.Delete("me", "draft-del").Do() + if err != nil { + t.Fatalf("failed to delete draft: %v", err) + } + if !deleteCalled { + t.Error("delete API was not called") + } +} + +// TestGmailAttachmentCommand_Flags tests attachment command flags +func TestGmailAttachmentCommand_Flags(t *testing.T) { + cmd := gmailAttachmentCmd + for _, flag := range []string{"message-id", "id", "output"} { + if cmd.Flags().Lookup(flag) == nil { + t.Errorf("expected --%s flag to exist", flag) + } + } +} + +// TestGmailAttachment_MockServer tests attachment download API integration +func TestGmailAttachment_MockServer(t *testing.T) { + attachmentData := base64.URLEncoding.EncodeToString([]byte("file content here")) + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + if r.URL.Path == "/gmail/v1/users/me/messages/msg-att/attachments/att-1" && r.Method == "GET" { + resp := map[string]interface{}{ + "data": attachmentData, + "size": len("file content here"), + } + json.NewEncoder(w).Encode(resp) + return + } + t.Logf("Unexpected request: %s %s", r.Method, r.URL.Path) + w.WriteHeader(http.StatusNotFound) + })) + defer server.Close() + + svc, err := gmail.NewService(context.Background(), option.WithoutAuthentication(), option.WithEndpoint(server.URL)) + if err != nil { + t.Fatalf("failed to create gmail service: %v", err) + } + + att, err := svc.Users.Messages.Attachments.Get("me", "msg-att", "att-1").Do() + if err != nil { + t.Fatalf("failed to get attachment: %v", err) + } + + data, err := base64.URLEncoding.DecodeString(att.Data) + if err != nil { + t.Fatalf("failed to decode attachment data: %v", err) + } + if string(data) != "file content here" { + t.Errorf("unexpected attachment data: %s", string(data)) + } +} diff --git a/skills/gmail/SKILL.md b/skills/gmail/SKILL.md index 805f8f4..db806b2 100644 --- a/skills/gmail/SKILL.md +++ b/skills/gmail/SKILL.md @@ -1,7 +1,7 @@ --- name: gws-gmail -version: 1.1.0 -description: "Google Gmail CLI operations via gws. Use when users need to list emails, read messages, send email, manage labels, archive, or trash messages. Triggers: gmail, email, inbox, send email, mail, labels, archive, trash." +version: 1.2.0 +description: "Google Gmail CLI operations via gws. Use when users need to list emails, read messages, send email, manage labels, drafts, attachments, batch operations, or trash messages. Triggers: gmail, email, inbox, send email, mail, labels, archive, trash, drafts, attachments." metadata: short-description: Google Gmail CLI operations compatibility: claude-code, codex-cli @@ -41,14 +41,32 @@ For initial setup, see the `gws-auth` skill. | Read full thread | `gws gmail thread ` | | Send an email | `gws gmail send --to user@example.com --subject "Hi" --body "Hello"` | | List all labels | `gws gmail labels` | +| Get label details | `gws gmail label-info --id Label_1` | +| Create a label | `gws gmail create-label --name "MyLabel"` | +| Update a label | `gws gmail update-label --id Label_1 --name "NewName"` | +| Delete a label | `gws gmail delete-label --id Label_1` | | Add labels | `gws gmail label --add "STARRED"` | | Remove labels | `gws gmail label --remove "UNREAD"` | +| Batch modify labels | `gws gmail batch-modify --ids "msg1,msg2" --add-labels "STARRED"` | | Archive a message | `gws gmail archive ` | | Archive a thread | `gws gmail archive-thread ` | | Trash a message | `gws gmail trash ` | +| Untrash a message | `gws gmail untrash ` | +| Delete a message | `gws gmail delete ` | +| Batch delete | `gws gmail batch-delete --ids "msg1,msg2"` | +| Trash a thread | `gws gmail trash-thread ` | +| Untrash a thread | `gws gmail untrash-thread ` | +| Delete a thread | `gws gmail delete-thread ` | | Reply to a message | `gws gmail reply --body "Thanks!"` | | Reply to all | `gws gmail reply --body "Got it" --all` | | Extract event ID | `gws gmail event-id ` | +| List drafts | `gws gmail drafts` | +| Get a draft | `gws gmail draft --id ` | +| Create a draft | `gws gmail create-draft --to user@example.com --subject "Hi" --body "Hello"` | +| Update a draft | `gws gmail update-draft --id --body "Updated body"` | +| Send a draft | `gws gmail send-draft --id ` | +| Delete a draft | `gws gmail delete-draft --id ` | +| Download attachment | `gws gmail attachment --message-id --id --output file.pdf` | ## Detailed Usage @@ -208,6 +226,213 @@ gws gmail list --format yaml # YAML format gws gmail list --format text # Human-readable text ``` +### untrash — Remove a message from trash + +```bash +gws gmail untrash +``` + +Removes a Gmail message from the trash, restoring it to its previous location. + +### delete — Permanently delete a message + +```bash +gws gmail delete +``` + +Permanently deletes a Gmail message. This action cannot be undone. + +### batch-modify — Modify labels on multiple messages + +```bash +gws gmail batch-modify --ids [flags] +``` + +**Flags:** +- `--ids string` — Comma-separated message IDs (required) +- `--add-labels string` — Label names to add (comma-separated) +- `--remove-labels string` — Label names to remove (comma-separated) + +**Examples:** +```bash +gws gmail batch-modify --ids "msg1,msg2,msg3" --add-labels "STARRED" +gws gmail batch-modify --ids "msg1,msg2" --remove-labels "INBOX,UNREAD" +gws gmail batch-modify --ids "msg1,msg2" --add-labels "ActionNeeded" --remove-labels "INBOX" +``` + +### batch-delete — Permanently delete multiple messages + +```bash +gws gmail batch-delete --ids +``` + +**Flags:** +- `--ids string` — Comma-separated message IDs (required) + +### trash-thread — Move a thread to trash + +```bash +gws gmail trash-thread +``` + +Moves all messages in a Gmail thread to the trash. + +### untrash-thread — Remove a thread from trash + +```bash +gws gmail untrash-thread +``` + +Removes all messages in a Gmail thread from the trash. + +### delete-thread — Permanently delete a thread + +```bash +gws gmail delete-thread +``` + +Permanently deletes all messages in a Gmail thread. This action cannot be undone. + +### label-info — Get label details + +```bash +gws gmail label-info --id +``` + +**Flags:** +- `--id string` — Label ID (required) + +Returns detailed information including message/thread counts and visibility settings. + +### create-label — Create a new label + +```bash +gws gmail create-label --name [flags] +``` + +**Flags:** +- `--name string` — Label name (required) +- `--visibility string` — Message visibility: `labelShow`, `labelShowIfUnread`, `labelHide` +- `--list-visibility string` — Label list visibility: `labelShow`, `labelHide` + +**Examples:** +```bash +gws gmail create-label --name "ProjectX" +gws gmail create-label --name "Archive/2024" --visibility labelHide +``` + +### update-label — Update a label + +```bash +gws gmail update-label --id [flags] +``` + +**Flags:** +- `--id string` — Label ID (required) +- `--name string` — New label name +- `--visibility string` — Message visibility: `labelShow`, `labelShowIfUnread`, `labelHide` +- `--list-visibility string` — Label list visibility: `labelShow`, `labelHide` + +### delete-label — Delete a label + +```bash +gws gmail delete-label --id +``` + +**Flags:** +- `--id string` — Label ID (required) + +Permanently deletes a Gmail label. Messages with this label are not deleted. + +### drafts — List drafts + +```bash +gws gmail drafts [flags] +``` + +**Flags:** +- `--max int` — Maximum number of results (default 10) +- `--query string` — Gmail search query + +### draft — Get a draft by ID + +```bash +gws gmail draft --id +``` + +**Flags:** +- `--id string` — Draft ID (required) + +Returns full draft content including headers and body. + +### create-draft — Create a draft + +```bash +gws gmail create-draft --to [flags] +``` + +**Flags:** +- `--to string` — Recipient email address (required) +- `--subject string` — Email subject +- `--body string` — Email body +- `--cc string` — CC recipients (comma-separated) +- `--bcc string` — BCC recipients (comma-separated) +- `--thread-id string` — Thread ID for reply draft + +**Examples:** +```bash +gws gmail create-draft --to user@example.com --subject "Draft" --body "Work in progress" +gws gmail create-draft --to user@example.com --subject "Re: Topic" --thread-id thread123 +``` + +### update-draft — Update a draft + +```bash +gws gmail update-draft --id [flags] +``` + +**Flags:** +- `--id string` — Draft ID (required) +- `--to string` — Recipient email address +- `--subject string` — Email subject +- `--body string` — Email body +- `--cc string` — CC recipients (comma-separated) +- `--bcc string` — BCC recipients (comma-separated) + +### send-draft — Send an existing draft + +```bash +gws gmail send-draft --id +``` + +**Flags:** +- `--id string` — Draft ID (required) + +### delete-draft — Delete a draft + +```bash +gws gmail delete-draft --id +``` + +**Flags:** +- `--id string` — Draft ID (required) + +### attachment — Download an attachment + +```bash +gws gmail attachment --message-id --id --output +``` + +**Flags:** +- `--message-id string` — Message ID (required) +- `--id string` — Attachment ID (required) +- `--output string` — Output file path (required) + +**Examples:** +```bash +gws gmail attachment --message-id 18abc123 --id ANGjdJ9x --output report.pdf +``` + ## Tips for AI Agents - Always use `--format json` (the default) for programmatic parsing diff --git a/skills/gmail/references/commands.md b/skills/gmail/references/commands.md index 63b83b8..1931cc7 100644 --- a/skills/gmail/references/commands.md +++ b/skills/gmail/references/commands.md @@ -259,3 +259,305 @@ Usage: gws gmail trash ``` No additional flags. Messages in trash are permanently deleted after 30 days. + +--- + +## gws gmail untrash + +Removes a Gmail message from the trash. + +``` +Usage: gws gmail untrash +``` + +No additional flags. Restores the message to its previous location. + +--- + +## gws gmail delete + +Permanently deletes a Gmail message. This action cannot be undone. + +``` +Usage: gws gmail delete +``` + +No additional flags. + +--- + +## gws gmail batch-modify + +Modifies labels on multiple Gmail messages at once. + +``` +Usage: gws gmail batch-modify [flags] +``` + +| Flag | Type | Default | Required | Description | +|------|------|---------|----------|-------------| +| `--ids` | string | | Yes | Comma-separated message IDs | +| `--add-labels` | string | | No | Label names to add (comma-separated) | +| `--remove-labels` | string | | No | Label names to remove (comma-separated) | + +At least one of `--add-labels` or `--remove-labels` is required. + +--- + +## gws gmail batch-delete + +Permanently deletes multiple Gmail messages at once. This action cannot be undone. + +``` +Usage: gws gmail batch-delete [flags] +``` + +| Flag | Type | Default | Required | Description | +|------|------|---------|----------|-------------| +| `--ids` | string | | Yes | Comma-separated message IDs | + +--- + +## gws gmail trash-thread + +Moves all messages in a Gmail thread to the trash. + +``` +Usage: gws gmail trash-thread +``` + +No additional flags. + +--- + +## gws gmail untrash-thread + +Removes all messages in a Gmail thread from the trash. + +``` +Usage: gws gmail untrash-thread +``` + +No additional flags. + +--- + +## gws gmail delete-thread + +Permanently deletes all messages in a Gmail thread. This action cannot be undone. + +``` +Usage: gws gmail delete-thread +``` + +No additional flags. + +--- + +## gws gmail label-info + +Gets detailed information about a specific Gmail label. + +``` +Usage: gws gmail label-info [flags] +``` + +| Flag | Type | Default | Required | Description | +|------|------|---------|----------|-------------| +| `--id` | string | | Yes | Label ID | + +### Output Fields (JSON) + +- `id` — Label ID +- `name` — Label name +- `type` — Label type (`system` or `user`) +- `message_list_visibility` — Visibility in message list +- `label_list_visibility` — Visibility in label list +- `messages_total` — Total messages with this label +- `messages_unread` — Unread messages with this label +- `threads_total` — Total threads with this label +- `threads_unread` — Unread threads with this label + +--- + +## gws gmail create-label + +Creates a new Gmail label. + +``` +Usage: gws gmail create-label [flags] +``` + +| Flag | Type | Default | Required | Description | +|------|------|---------|----------|-------------| +| `--name` | string | | Yes | Label name | +| `--visibility` | string | | No | Message visibility: `labelShow`, `labelShowIfUnread`, `labelHide` | +| `--list-visibility` | string | | No | Label list visibility: `labelShow`, `labelHide` | + +--- + +## gws gmail update-label + +Updates an existing Gmail label. + +``` +Usage: gws gmail update-label [flags] +``` + +| Flag | Type | Default | Required | Description | +|------|------|---------|----------|-------------| +| `--id` | string | | Yes | Label ID | +| `--name` | string | | No | New label name | +| `--visibility` | string | | No | Message visibility: `labelShow`, `labelShowIfUnread`, `labelHide` | +| `--list-visibility` | string | | No | Label list visibility: `labelShow`, `labelHide` | + +--- + +## gws gmail delete-label + +Permanently deletes a Gmail label. Messages with this label are not deleted. + +``` +Usage: gws gmail delete-label [flags] +``` + +| Flag | Type | Default | Required | Description | +|------|------|---------|----------|-------------| +| `--id` | string | | Yes | Label ID | + +--- + +## gws gmail drafts + +Lists Gmail drafts. + +``` +Usage: gws gmail drafts [flags] +``` + +| Flag | Type | Default | Required | Description | +|------|------|---------|----------|-------------| +| `--max` | int | 10 | No | Maximum number of results | +| `--query` | string | | No | Gmail search query | + +### Output Fields (JSON) + +- `drafts` — Array of drafts, each with: + - `id` — Draft ID + - `message_id` — Associated message ID +- `count` — Total number of drafts returned + +--- + +## gws gmail draft + +Gets the content of a specific Gmail draft. + +``` +Usage: gws gmail draft [flags] +``` + +| Flag | Type | Default | Required | Description | +|------|------|---------|----------|-------------| +| `--id` | string | | Yes | Draft ID | + +### Output Fields (JSON) + +- `id` — Draft ID +- `message_id` — Associated message ID +- `headers` — Object with `subject`, `from`, `to`, `date`, `cc`, `bcc` +- `body` — Draft body text + +--- + +## gws gmail create-draft + +Creates a new Gmail draft message. + +``` +Usage: gws gmail create-draft [flags] +``` + +| Flag | Type | Default | Required | Description | +|------|------|---------|----------|-------------| +| `--to` | string | | Yes | Recipient email address | +| `--subject` | string | | No | Email subject | +| `--body` | string | | No | Email body | +| `--cc` | string | | No | CC recipients (comma-separated) | +| `--bcc` | string | | No | BCC recipients (comma-separated) | +| `--thread-id` | string | | No | Thread ID for reply draft | + +--- + +## gws gmail update-draft + +Replaces the content of an existing Gmail draft. + +``` +Usage: gws gmail update-draft [flags] +``` + +| Flag | Type | Default | Required | Description | +|------|------|---------|----------|-------------| +| `--id` | string | | Yes | Draft ID | +| `--to` | string | | No | Recipient email address | +| `--subject` | string | | No | Email subject | +| `--body` | string | | No | Email body | +| `--cc` | string | | No | CC recipients (comma-separated) | +| `--bcc` | string | | No | BCC recipients (comma-separated) | + +--- + +## gws gmail send-draft + +Sends an existing Gmail draft. + +``` +Usage: gws gmail send-draft [flags] +``` + +| Flag | Type | Default | Required | Description | +|------|------|---------|----------|-------------| +| `--id` | string | | Yes | Draft ID | + +### Output Fields (JSON) + +- `status` — Always `"sent"` +- `message_id` — Sent message ID +- `thread_id` — Thread ID + +--- + +## gws gmail delete-draft + +Permanently deletes a Gmail draft. + +``` +Usage: gws gmail delete-draft [flags] +``` + +| Flag | Type | Default | Required | Description | +|------|------|---------|----------|-------------| +| `--id` | string | | Yes | Draft ID | + +--- + +## gws gmail attachment + +Downloads a Gmail message attachment to a local file. + +``` +Usage: gws gmail attachment [flags] +``` + +| Flag | Type | Default | Required | Description | +|------|------|---------|----------|-------------| +| `--message-id` | string | | Yes | Message ID | +| `--id` | string | | Yes | Attachment ID | +| `--output` | string | | Yes | Output file path | + +### Output Fields (JSON) + +- `status` — Always `"downloaded"` +- `file` — Output file path +- `size` — File size in bytes