Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
59 changes: 59 additions & 0 deletions cmd/msx/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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":
Expand Down Expand Up @@ -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 <folder-id|well-known-name> <message-id> [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> [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")
Expand Down
57 changes: 57 additions & 0 deletions cmd/msx/main_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down