From 1cb228e5d83ab453660bbace5f936a77f9381fc2 Mon Sep 17 00:00:00 2001 From: Andriy Oblivantsev Date: Wed, 24 Jun 2026 14:44:09 +0100 Subject: [PATCH] feat(mails): add Workspace mail CLI with pagination and parsed from fields Manage the OnlyOffice Mail addon via oo mails using existing ONLYOFFICE_* creds, with automatic page fetching past the 25-message API cap and fromName/fromAddress columns. Co-authored-by: Cursor --- .env.example | 2 + AGENTS.md | 2 +- CHANGELOG.md | 4 + README.md | 16 +++ cmd/oo/cli_test.go | 2 +- cmd/oo/mails.go | 156 +++++++++++++++++++++++++ cmd/oo/main.go | 1 + mails.go | 238 ++++++++++++++++++++++++++++++++++++++ mails_integration_test.go | 41 +++++++ mails_test.go | 72 ++++++++++++ 10 files changed, 532 insertions(+), 2 deletions(-) create mode 100644 cmd/oo/mails.go create mode 100644 mails.go create mode 100644 mails_integration_test.go create mode 100644 mails_test.go diff --git a/.env.example b/.env.example index c835c9b..e018a13 100644 --- a/.env.example +++ b/.env.example @@ -19,3 +19,5 @@ ONLYOFFICE_PASS= # Optional defaults for calendar/project helpers: ONLYOFFICE_CALENDAR_ID=1 ONLYOFFICE_PROJECT_ID=33 + +# oo mails uses ONLYOFFICE_URL/USER/PASS above (Workspace Mail addon). diff --git a/AGENTS.md b/AGENTS.md index c570473..06af717 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -9,7 +9,7 @@ Canonical Go client for OnlyOffice Workspace (Projects + Calendar + CRM) and the - `request.go` — `Request`, `Query`, `Time`, `Token`, `MetaResponse`, `Permissions`. - `auth.go` — `Authenticate`, `AuthenticateContext`, `InvalidateToken`, `Auth`, token lifecycle. - `http.go` — transport + DRY response decoders (`ResponseArray`/`ResponseObject`/`postFormObject`/`putFormObject`/`deleteObject`). - - `projects.go`, `tasks.go`, `users.go`, `calendar.go`, `crm.go`, `files.go` — typed / untyped domain methods. **`files.go`** — CRM opportunity upload plus **project/task Documents** (`GetProjectFiles`, `UploadProjectFile`, `GetTaskFiles`, `AttachFilesToTask`, `UploadTaskFile`, `DetachTaskFile`, `GetFile`, `RenameFile`, `DeleteFiles`, `DownloadFile`). + - `projects.go`, `tasks.go`, `users.go`, `calendar.go`, `crm.go`, `files.go`, `mails.go` — typed / untyped domain methods. **`files.go`** — CRM opportunity upload plus **project/task Documents** (`GetProjectFiles`, `UploadProjectFile`, `GetTaskFiles`, `AttachFilesToTask`, `UploadTaskFile`, `DetachTaskFile`, `GetFile`, `RenameFile`, `DeleteFiles`, `DownloadFile`). **`mails.go`** — OnlyOffice Workspace Mail addon (`ListMailAccounts`, `ListMailFolders`, `ListMailMessages`, `GetMailMessage`, `RemoveMailMessages`). - Pure stdlib + `google/go-querystring`; no UI, no dotenv. - **CLI — `cmd/oo/` as `package main`.** Cobra wrapper that loads `.env` via `godotenv` at startup. **Subject-based command tree** mirroring [`tea`](https://gitea.com/gitea/tea): - `main.go` — entry point (docstring lists the command tree). diff --git a/CHANGELOG.md b/CHANGELOG.md index 2f3b46e..0926609 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,11 +17,15 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). `IsOpportunityMember`, `HasContactInfo`, `CleanupCRM`. - Slogan-aware grouping: `StripSloganSuffix`, `CompanyGroupingKey` merge names like `Affirm` and `Affirm — Fraud Engineering` (companies, deals, members). +- **Workspace mail** — [`mails.go`](mails.go): `ListMailAccounts`, `ListMailFolders`, + `ListMailMessages`, `GetMailMessage`, `RemoveMailMessages` against OnlyOffice + Mail addon (`/api/2.0/mail/*`). - [`crm_integration_test.go`](crm_integration_test.go) — live merge, rename, dedupe smoke tests. ### Added — CLI +- `oo mails accounts|folders|list|get|delete` — OnlyOffice Workspace mail (same `ONLYOFFICE_*` creds). - `oo companies dedupe|dedupe-persons`, `oo persons dedupe`, `oo contacts dedupe-info`, `oo opportunities dedupe|dedupe-members|fix-titles`, `oo crm cleanup`. diff --git a/README.md b/README.md index c3a4e6c..9f6795c 100644 --- a/README.md +++ b/README.md @@ -585,6 +585,7 @@ oo tasks files detach 208 12345 | `companies` | `list`, `create`, `delete`, `dedupe`, `dedupe-persons` | | `opportunities` | `list`, `get`, `create`, `delete`, `stages`, `member-add`, `dedupe`, `dedupe-members`, `fix-titles` | | `crm` | `cleanup` | +| `mails` | `accounts`, `folders`, `list`, `get`, `delete` | | `cases` | `list`, `create`, `delete`, `member-add` | | `crm-tasks` | `list`, `create`, `delete`, `categories` | | `applications` | `sync` | @@ -615,6 +616,21 @@ Run `oo --help` or `oo --help` for the full command reference. | `ONLYOFFICE_PROJECT_ID` | Default project id used when omitted (default `33`) | | `OO_URL`, `OO_USER`, `OO_PASS` | CLI-only produktor.io aliases mapped to `ONLYOFFICE_URL`, `ONLYOFFICE_USER`, `ONLYOFFICE_PASS` | +### Workspace mail (`oo mails`) + +Uses the same OnlyOffice credentials as other `oo` commands. Talks to the +built-in Mail addon (`/addons/mail`) via `/api/2.0/mail/*`: + +```bash +oo mails accounts +oo mails folders +oo mails list --folder inbox --limit 20 +oo mails get 5664 +oo mails delete 5664 +``` + +Folder names: `inbox`, `sent`, `drafts`, `trash`, `spam` (or numeric id). + ## Examples | Example | Description | diff --git a/cmd/oo/cli_test.go b/cmd/oo/cli_test.go index 635a14b..4fa8ed8 100644 --- a/cmd/oo/cli_test.go +++ b/cmd/oo/cli_test.go @@ -11,7 +11,7 @@ func TestRootRegistersSubjects(t *testing.T) { want := []string{ "calendar", "projects", "tasks", "users", "whoami", "contacts", "persons", "companies", - "opportunities", "cases", "crm-tasks", "applications", "crm", + "opportunities", "cases", "crm-tasks", "applications", "crm", "mails", } got := make(map[string]bool, len(rootCmd.Commands())) for _, c := range rootCmd.Commands() { diff --git a/cmd/oo/mails.go b/cmd/oo/mails.go new file mode 100644 index 0000000..4e7f254 --- /dev/null +++ b/cmd/oo/mails.go @@ -0,0 +1,156 @@ +package main + +import ( + "strconv" + "strings" + + onlyoffice "github.com/eslider/go-onlyoffice" + "github.com/spf13/cobra" +) + +var mailsCmd = &cobra.Command{ + Use: "mails", + Aliases: []string{"mail"}, + Short: "OnlyOffice Workspace mail — list, read, delete", +} + +func init() { + rootCmd.AddCommand(mailsCmd) + mailsCmd.AddCommand(mailsAccountsCmd()) + mailsCmd.AddCommand(mailsFoldersCmd()) + mailsCmd.AddCommand(mailsListCmd()) + mailsCmd.AddCommand(mailsGetCmd()) + mailsCmd.AddCommand(mailsDeleteCmd()) +} + +func mailsAccountsCmd() *cobra.Command { + return &cobra.Command{ + Use: "accounts", + Short: "List mailboxes linked to your OnlyOffice account", + RunE: func(cmd *cobra.Command, args []string) error { + c, err := newOO(cmd) + if err != nil { + return err + } + accounts, err := c.ListMailAccounts(cmd.Context()) + if err != nil { + return err + } + if outputFormat == "json" { + printObject(accounts) + return nil + } + printTable([]string{"mailboxId", "email", "enabled", "isDefault"}, onlyoffice.MailAccountsAsTableRows(accounts)) + return nil + }, + } +} + +func mailsFoldersCmd() *cobra.Command { + return &cobra.Command{ + Use: "folders", + Short: "List mail folders (inbox, sent, trash, …)", + RunE: func(cmd *cobra.Command, args []string) error { + c, err := newOO(cmd) + if err != nil { + return err + } + folders, err := c.ListMailFolders(cmd.Context()) + if err != nil { + return err + } + if outputFormat == "json" { + printObject(folders) + return nil + } + printTable([]string{"id", "unread", "total_count", "time_modified"}, onlyoffice.MailFoldersAsTableRows(folders)) + return nil + }, + } +} + +func mailsListCmd() *cobra.Command { + var folder string + var limit, offset int + cmd := &cobra.Command{ + Use: "list", + Short: "List messages in a folder (default inbox)", + RunE: func(cmd *cobra.Command, args []string) error { + c, err := newOO(cmd) + if err != nil { + return err + } + folderID, err := onlyoffice.ResolveMailFolder(folder) + if err != nil { + return err + } + msgs, err := c.ListMailMessages(cmd.Context(), onlyoffice.MailMessagesFilter{ + Folder: folderID, + Count: limit, + StartIndex: offset, + }) + if err != nil { + return err + } + if outputFormat == "json" { + printObject(msgs) + return nil + } + printTable([]string{"id", "subject", "fromName", "fromAddress", "date", "folder", "size", "isNew"}, onlyoffice.MailMessagesAsTableRows(msgs)) + return nil + }, + } + cmd.Flags().StringVarP(&folder, "folder", "f", "inbox", "folder name (inbox|sent|drafts|trash|spam) or numeric id") + cmd.Flags().IntVar(&limit, "limit", 50, "max messages to return (paginates past API page size of 25)") + cmd.Flags().IntVar(&offset, "offset", 0, "skip this many messages before listing") + return cmd +} + +func mailsGetCmd() *cobra.Command { + return &cobra.Command{ + Use: "get MESSAGE_ID", + Short: "Read one message by numeric id", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + c, err := newOO(cmd) + if err != nil { + return err + } + out, err := c.GetMailMessage(cmd.Context(), args[0]) + if err != nil { + return err + } + printObject(out) + return nil + }, + } +} + +func mailsDeleteCmd() *cobra.Command { + return &cobra.Command{ + Use: "delete ID [ID...]", + Aliases: []string{"rm"}, + Short: "Remove messages from the mailbox (moves to trash or deletes permanently per server rules)", + Args: cobra.MinimumNArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + c, err := newOO(cmd) + if err != nil { + return err + } + ids := make([]int, 0, len(args)) + for _, a := range args { + id, err := strconv.Atoi(strings.TrimSpace(a)) + if err != nil { + return err + } + ids = append(ids, id) + } + out, err := c.RemoveMailMessages(cmd.Context(), ids...) + if err != nil { + return err + } + printObject(out) + return nil + }, + } +} diff --git a/cmd/oo/main.go b/cmd/oo/main.go index 0660870..778135a 100644 --- a/cmd/oo/main.go +++ b/cmd/oo/main.go @@ -13,6 +13,7 @@ // oo cases list | create | delete | member-add // oo crm-tasks list | create | delete | categories // oo crm cleanup +// oo mails accounts | folders | list | get | delete // oo applications sync // // Every list supports `--output/-o json|table` (table is the default). diff --git a/mails.go b/mails.go new file mode 100644 index 0000000..ac73997 --- /dev/null +++ b/mails.go @@ -0,0 +1,238 @@ +package onlyoffice + +// OnlyOffice Workspace Mail addon (/addons/mail) — list, read, and remove +// messages for the mailbox bound to the authenticated portal user. + +import ( + "context" + "fmt" + "net/mail" + "net/url" + "strconv" + "strings" +) + +// Mail folder IDs in OnlyOffice Workspace (standard mailboxes). +const ( + MailFolderInbox = 1 + MailFolderSent = 2 + MailFolderDrafts = 3 + MailFolderTrash = 4 + MailFolderSpam = 5 +) + +// MailMessagesFilter selects messages from GET /api/2.0/mail/messages. +// The API returns at most 25 messages per request; ListMailMessages paginates +// automatically when Count exceeds that or StartIndex is non-zero. +type MailMessagesFilter struct { + Folder int // folder id (default inbox when zero) + Count int // max messages to return (0 → one API page) + StartIndex int // skip this many messages before collecting Count +} + +const mailMessagesPageSize = 25 + +// ListMailAccounts returns mailboxes linked to the current user. +func (c *Client) ListMailAccounts(ctx context.Context) ([]map[string]any, error) { + return c.ResponseArray(ctx, "/api/2.0/mail/accounts") +} + +// ListMailFolders returns folder counters (id, unread, total_count, …). +func (c *Client) ListMailFolders(ctx context.Context) ([]map[string]any, error) { + return c.ResponseArray(ctx, "/api/2.0/mail/folders") +} + +// ListMailMessages returns messages matching the filter. +func (c *Client) ListMailMessages(ctx context.Context, f MailMessagesFilter) ([]map[string]any, error) { + want := f.Count + if want <= 0 { + want = mailMessagesPageSize + } + if want <= mailMessagesPageSize && f.StartIndex == 0 { + return c.ResponseArray(ctx, mailMessagesPath(f, 1, want)) + } + + var out []map[string]any + toSkip := f.StartIndex + page := 1 + for len(out) < want { + chunk, err := c.ResponseArray(ctx, mailMessagesPath(f, page, mailMessagesPageSize)) + if err != nil { + return nil, err + } + if len(chunk) == 0 { + break + } + if toSkip > 0 { + if toSkip >= len(chunk) { + toSkip -= len(chunk) + page++ + continue + } + chunk = chunk[toSkip:] + toSkip = 0 + } + pageLen := len(chunk) + need := want - len(out) + if len(chunk) > need { + chunk = chunk[:need] + } + out = append(out, chunk...) + if pageLen < mailMessagesPageSize { + break + } + page++ + } + return out, nil +} + +// GetMailMessage returns one message by numeric id. +func (c *Client) GetMailMessage(ctx context.Context, messageID string) (map[string]any, error) { + id := strings.TrimSpace(messageID) + if id == "" { + return nil, fmt.Errorf("GetMailMessage: message id is required") + } + return c.ResponseObject(ctx, "/api/2.0/mail/messages/"+url.PathEscape(id)) +} + +// RemoveMailMessages deletes messages by id (PUT /api/2.0/mail/messages/remove). +func (c *Client) RemoveMailMessages(ctx context.Context, ids ...int) (map[string]any, error) { + if len(ids) == 0 { + return nil, fmt.Errorf("RemoveMailMessages: at least one id is required") + } + return c.putJSONObject(ctx, "/api/2.0/mail/messages/remove", map[string]any{"ids": ids}) +} + +// ResolveMailFolder maps a CLI folder name or numeric string to a folder id. +// Empty input defaults to inbox (1). +func ResolveMailFolder(name string) (int, error) { + s := strings.TrimSpace(strings.ToLower(name)) + if s == "" { + return MailFolderInbox, nil + } + if n, err := strconv.Atoi(s); err == nil && n > 0 { + return n, nil + } + switch s { + case "inbox": + return MailFolderInbox, nil + case "sent": + return MailFolderSent, nil + case "drafts", "draft": + return MailFolderDrafts, nil + case "trash": + return MailFolderTrash, nil + case "spam": + return MailFolderSpam, nil + default: + return 0, fmt.Errorf("unknown mail folder %q (use inbox|sent|drafts|trash|spam or numeric id)", name) + } +} + +func mailMessagesPath(f MailMessagesFilter, page, count int) string { + q := url.Values{} + folder := f.Folder + if folder <= 0 { + folder = MailFolderInbox + } + q.Set("folder", strconv.Itoa(folder)) + if count > 0 { + q.Set("count", strconv.Itoa(count)) + } + if page > 1 { + q.Set("page", strconv.Itoa(page)) + } + return "/api/2.0/mail/messages?" + q.Encode() +} + +// ParseMailAddress splits a RFC 5322 mailbox string into display name and email. +// Examples: +// - `"LinkedIn" ` → name LinkedIn, address a@b.com +// - `eslider@gmail.com` → address only +func ParseMailAddress(raw string) (name, address string) { + raw = strings.TrimSpace(raw) + if raw == "" { + return "", "" + } + a, err := mail.ParseAddress(raw) + if err != nil { + return "", raw + } + return a.Name, a.Address +} + +// MailMessagesAsTableRows converts list results for printTable. +func MailMessagesAsTableRows(msgs []map[string]any) []map[string]any { + rows := make([]map[string]any, len(msgs)) + for i, m := range msgs { + fromName, fromAddress := ParseMailAddress(stringFromMap(m, "from")) + rows[i] = map[string]any{ + "id": idFromMap(m, "id"), + "subject": stringFromMap(m, "subject"), + "fromName": fromName, + "fromAddress": fromAddress, + "date": stringFromMap(m, "date"), + "folder": idFromMap(m, "folder"), + "size": idFromMap(m, "size"), + "isNew": boolFromMap(m, "isNew"), + } + } + return rows +} + +// MailAccountsAsTableRows converts account list results for printTable. +func MailAccountsAsTableRows(accounts []map[string]any) []map[string]any { + rows := make([]map[string]any, len(accounts)) + for i, a := range accounts { + rows[i] = map[string]any{ + "mailboxId": idFromMap(a, "mailboxId"), + "email": stringFromMap(a, "email"), + "enabled": boolFromMap(a, "enabled"), + "isDefault": boolFromMap(a, "isDefault"), + } + } + return rows +} + +// MailFoldersAsTableRows converts folder list results for printTable. +func MailFoldersAsTableRows(folders []map[string]any) []map[string]any { + rows := make([]map[string]any, len(folders)) + for i, f := range folders { + rows[i] = map[string]any{ + "id": idFromMap(f, "id"), + "unread": idFromMap(f, "unread"), + "total_count": idFromMap(f, "total_count"), + "time_modified": stringFromMap(f, "time_modified"), + } + } + return rows +} + +func idFromMap(m map[string]any, key string) string { + switch v := m[key].(type) { + case float64: + return strconv.FormatInt(int64(v), 10) + case int: + return strconv.Itoa(v) + case string: + return v + default: + return fmt.Sprint(m[key]) + } +} + +func stringFromMap(m map[string]any, key string) string { + if s, ok := m[key].(string); ok { + return s + } + return fmt.Sprint(m[key]) +} + +func boolFromMap(m map[string]any, key string) string { + switch v := m[key].(type) { + case bool: + return strconv.FormatBool(v) + default: + return fmt.Sprint(m[key]) + } +} diff --git a/mails_integration_test.go b/mails_integration_test.go new file mode 100644 index 0000000..c6ddc14 --- /dev/null +++ b/mails_integration_test.go @@ -0,0 +1,41 @@ +//go:build integration + +package onlyoffice + +import ( + "context" + "testing" +) + +func TestIntegrationMailList(t *testing.T) { + creds := GetEnvironmentCredentials() + if creds.Url == "" || creds.User == "" || creds.Password == "" { + t.Skip("ONLYOFFICE_URL/USER/PASS not set") + } + c := NewClient(creds) + ctx := context.Background() + if err := c.AuthenticateContext(ctx); err != nil { + t.Fatalf("auth: %v", err) + } + accounts, err := c.ListMailAccounts(ctx) + if err != nil { + t.Fatalf("ListMailAccounts: %v", err) + } + t.Logf("accounts=%d", len(accounts)) + + msgs, err := c.ListMailMessages(ctx, MailMessagesFilter{Folder: MailFolderInbox, Count: 3}) + if err != nil { + t.Fatalf("ListMailMessages: %v", err) + } + if len(msgs) == 0 { + t.Skip("no inbox messages to inspect") + } + id := idFromMap(msgs[0], "id") + msg, err := c.GetMailMessage(ctx, id) + if err != nil { + t.Fatalf("GetMailMessage(%s): %v", id, err) + } + if msg["subject"] == nil { + t.Fatalf("message missing subject: %+v", msg) + } +} diff --git a/mails_test.go b/mails_test.go new file mode 100644 index 0000000..3302f0c --- /dev/null +++ b/mails_test.go @@ -0,0 +1,72 @@ +package onlyoffice + +import "testing" + +func TestResolveMailFolder(t *testing.T) { + tests := []struct { + in string + want int + err bool + }{ + {"", MailFolderInbox, false}, + {"inbox", MailFolderInbox, false}, + {"SENT", MailFolderSent, false}, + {"4", MailFolderTrash, false}, + {"nope", 0, true}, + } + for _, tc := range tests { + got, err := ResolveMailFolder(tc.in) + if tc.err { + if err == nil { + t.Fatalf("%q: expected error", tc.in) + } + continue + } + if err != nil || got != tc.want { + t.Fatalf("%q: got %d err=%v", tc.in, got, err) + } + } +} + +func TestMailMessagesPath(t *testing.T) { + path := mailMessagesPath(MailMessagesFilter{Folder: 1}, 1, 10) + if path != "/api/2.0/mail/messages?count=10&folder=1" { + t.Fatalf("page 1: got %q", path) + } + path = mailMessagesPath(MailMessagesFilter{Folder: 1}, 3, 25) + if path != "/api/2.0/mail/messages?count=25&folder=1&page=3" { + t.Fatalf("page 3: got %q", path) + } +} + +func TestParseMailAddress(t *testing.T) { + tests := []struct { + raw string + wantName string + wantAddress string + }{ + {`"LinkedIn Jobbenachrichtigungen" `, "LinkedIn Jobbenachrichtigungen", "jobalerts-noreply@linkedin.com"}, + {`"Bitfinex" `, "Bitfinex", "no-reply@bitfinex.com"}, + {"eslider@gmail.com", "", "eslider@gmail.com"}, + {`"Glassdoor-Jobs" `, "Glassdoor-Jobs", "noreply@glassdoor.com"}, + {"", "", ""}, + } + for _, tc := range tests { + name, addr := ParseMailAddress(tc.raw) + if name != tc.wantName || addr != tc.wantAddress { + t.Fatalf("%q: got name=%q addr=%q", tc.raw, name, addr) + } + } +} + +func TestMailMessagesAsTableRows(t *testing.T) { + msgs := []map[string]any{{ + "id": float64(42), "subject": "Hi", + "from": `"Acme" `, + "date": "today", "folder": float64(1), "size": float64(100), "isNew": true, + }} + rows := MailMessagesAsTableRows(msgs) + if rows[0]["id"] != "42" || rows[0]["fromName"] != "Acme" || rows[0]["fromAddress"] != "a@b.com" { + t.Fatalf("got %+v", rows[0]) + } +}