From d8acb08782b02cb77df4a142729312d52a58be9d Mon Sep 17 00:00:00 2001 From: Cameron Harris <274923+corruptmem@users.noreply.github.com> Date: Sun, 5 Apr 2026 01:52:34 +0000 Subject: [PATCH] feat(mail): add move and archive commands Resolves #9 --- cmd/msx/main.go | 59 ++++++++++++++++++++++++++++++++++++++++++++ cmd/msx/main_test.go | 57 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 116 insertions(+) diff --git a/cmd/msx/main.go b/cmd/msx/main.go index a52a3de..37909a9 100644 --- a/cmd/msx/main.go +++ b/cmd/msx/main.go @@ -43,6 +43,8 @@ commands: whoami Show the current Graph account mail List mail with optional filters mail-get Fetch one mail message by id + mail-move Move one or more mail messages to another folder + mail-archive Move one or more mail messages to Archive agenda List calendar events in a time range event-get Fetch one calendar event by id files List or search OneDrive files @@ -108,6 +110,10 @@ func run(args []string) error { return cmdMail(s, g, rest) case "mail-get": return cmdMailGet(s, g, rest) + case "mail-move": + return cmdMailMove(s, g, rest) + case "mail-archive": + return cmdMailArchive(s, g, rest) case "agenda": return cmdAgenda(s, g, rest) case "event-get": @@ -433,6 +439,59 @@ func cmdMailGet(s *store.Store, g globalFlags, args []string) error { return emit(g, "mail-get", data) } +func cmdMailMove(s *store.Store, g globalFlags, args []string) error { + fs := flag.NewFlagSet("mail-move", flag.ContinueOnError) + destination := fs.String("destination", "", "destination folder id or well-known folder name") + destinationAlias := fs.String("dest-folder", "", "alias for --destination") + if err := fs.Parse(args); err != nil { + return err + } + if *destination == "" { + *destination = *destinationAlias + } + if *destination == "" || fs.NArg() == 0 { + return fmt.Errorf("usage: msx mail-move --destination [message-id ...]") + } + return runMailMove(s, g, *destination, fs.Args(), "mail-move") +} + +func cmdMailArchive(s *store.Store, g globalFlags, args []string) error { + fs := flag.NewFlagSet("mail-archive", flag.ContinueOnError) + if err := fs.Parse(args); err != nil { + return err + } + if fs.NArg() == 0 { + return fmt.Errorf("usage: msx mail-archive [message-id ...]") + } + return runMailMove(s, g, "archive", fs.Args(), "mail-archive") +} + +func runMailMove(s *store.Store, g globalFlags, destination string, ids []string, command string) error { + client := newGraphClient(s, g.profile) + results := make([]map[string]any, 0, len(ids)) + for _, id := range ids { + id = strings.TrimSpace(id) + if id == "" { + continue + } + body, err := json.Marshal(map[string]any{"destinationId": destination}) + if err != nil { + return err + } + data, err := client.RequestWithBody("POST", "/me/messages/"+url.PathEscape(id)+"/move", nil, body) + if err != nil { + return fmt.Errorf("move %s: %w", id, err) + } + results = append(results, data) + } + return emit(g, command, map[string]any{ + "ok": true, + "destination": destination, + "count": len(results), + "value": results, + }) +} + func cmdAgenda(s *store.Store, g globalFlags, args []string) error { fs := flag.NewFlagSet("agenda", flag.ContinueOnError) top := fs.Int("top", 20, "maximum number of events") diff --git a/cmd/msx/main_test.go b/cmd/msx/main_test.go index e736173..5b69e42 100644 --- a/cmd/msx/main_test.go +++ b/cmd/msx/main_test.go @@ -196,6 +196,63 @@ func TestRunDetailCommandsAndNextAgainstTestServer(t *testing.T) { } } +func TestRunMailMoveAndArchiveCommands(t *testing.T) { + t.Setenv("MSX_HOME", t.TempDir()) + seedProfile(t, os.Getenv("MSX_HOME"), "personal") + + var moved []struct { + Path string + Body string + } + withGraphHTTPClient(t, func(r *http.Request) (*http.Response, error) { + if r.Method != http.MethodPost { + t.Fatalf("expected POST, got %s", r.Method) + } + body, err := io.ReadAll(r.Body) + if err != nil { + t.Fatal(err) + } + moved = append(moved, struct { + Path string + Body string + }{Path: r.URL.Path, Body: string(body)}) + if strings.HasSuffix(r.URL.Path, "/msg-1/move") { + return jsonHTTPResponse(http.StatusOK, `{"id":"msg-1-moved","subject":"hello"}`), nil + } + if strings.HasSuffix(r.URL.Path, "/msg-2/move") { + return jsonHTTPResponse(http.StatusOK, `{"id":"msg-2-moved","subject":"bye"}`), nil + } + t.Fatalf("unexpected request path: %s", r.URL.Path) + return nil, nil + }) + t.Setenv("MSX_GRAPH_BASE_URL", "https://graph.example.test/v1.0") + + stdout, stderr, err := captureRun([]string{"--profile", "personal", "mail-move", "--destination", "Archive", "msg-1"}) + if err != nil { + t.Fatalf("mail-move failed: %v stderr=%s", err, stderr) + } + if !strings.Contains(stdout, `"destination": "Archive"`) || !strings.Contains(stdout, `"count": 1`) { + t.Fatalf("unexpected mail-move stdout: %s", stdout) + } + + stdout, stderr, err = captureRun([]string{"--profile", "personal", "mail-archive", "msg-2"}) + if err != nil { + t.Fatalf("mail-archive failed: %v stderr=%s", err, stderr) + } + if !strings.Contains(stdout, `"destination": "archive"`) || !strings.Contains(stdout, `"count": 1`) { + t.Fatalf("unexpected mail-archive stdout: %s", stdout) + } + if len(moved) != 2 { + t.Fatalf("expected 2 move calls, got %d", len(moved)) + } + if moved[0].Path != "/v1.0/me/messages/msg-1/move" || !strings.Contains(moved[0].Body, `"destinationId":"Archive"`) { + t.Fatalf("unexpected first move call: %+v", moved[0]) + } + if moved[1].Path != "/v1.0/me/messages/msg-2/move" || !strings.Contains(moved[1].Body, `"destinationId":"archive"`) { + t.Fatalf("unexpected second move call: %+v", moved[1]) + } +} + func TestRunMailSubjectFilterPreservesTopLevelShape(t *testing.T) { t.Setenv("MSX_HOME", t.TempDir()) seedProfile(t, os.Getenv("MSX_HOME"), "personal")