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 <cursoragent@cursor.com>
This commit is contained in:
@@ -19,3 +19,5 @@ ONLYOFFICE_PASS=
|
|||||||
# Optional defaults for calendar/project helpers:
|
# Optional defaults for calendar/project helpers:
|
||||||
ONLYOFFICE_CALENDAR_ID=1
|
ONLYOFFICE_CALENDAR_ID=1
|
||||||
ONLYOFFICE_PROJECT_ID=33
|
ONLYOFFICE_PROJECT_ID=33
|
||||||
|
|
||||||
|
# oo mails uses ONLYOFFICE_URL/USER/PASS above (Workspace Mail addon).
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ Canonical Go client for OnlyOffice Workspace (Projects + Calendar + CRM) and the
|
|||||||
- `request.go` — `Request`, `Query`, `Time`, `Token`, `MetaResponse`, `Permissions`.
|
- `request.go` — `Request`, `Query`, `Time`, `Token`, `MetaResponse`, `Permissions`.
|
||||||
- `auth.go` — `Authenticate`, `AuthenticateContext`, `InvalidateToken`, `Auth`, token lifecycle.
|
- `auth.go` — `Authenticate`, `AuthenticateContext`, `InvalidateToken`, `Auth`, token lifecycle.
|
||||||
- `http.go` — transport + DRY response decoders (`ResponseArray`/`ResponseObject`/`postFormObject`/`putFormObject`/`deleteObject`).
|
- `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.
|
- 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):
|
- **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).
|
- `main.go` — entry point (docstring lists the command tree).
|
||||||
|
|||||||
@@ -17,11 +17,15 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|||||||
`IsOpportunityMember`, `HasContactInfo`, `CleanupCRM`.
|
`IsOpportunityMember`, `HasContactInfo`, `CleanupCRM`.
|
||||||
- Slogan-aware grouping: `StripSloganSuffix`, `CompanyGroupingKey` merge names
|
- Slogan-aware grouping: `StripSloganSuffix`, `CompanyGroupingKey` merge names
|
||||||
like `Affirm` and `Affirm — Fraud Engineering` (companies, deals, members).
|
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,
|
- [`crm_integration_test.go`](crm_integration_test.go) — live merge, rename,
|
||||||
dedupe smoke tests.
|
dedupe smoke tests.
|
||||||
|
|
||||||
### Added — CLI
|
### 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 companies dedupe|dedupe-persons`, `oo persons dedupe`, `oo contacts dedupe-info`,
|
||||||
`oo opportunities dedupe|dedupe-members|fix-titles`, `oo crm cleanup`.
|
`oo opportunities dedupe|dedupe-members|fix-titles`, `oo crm cleanup`.
|
||||||
|
|
||||||
|
|||||||
@@ -585,6 +585,7 @@ oo tasks files detach 208 12345
|
|||||||
| `companies` | `list`, `create`, `delete`, `dedupe`, `dedupe-persons` |
|
| `companies` | `list`, `create`, `delete`, `dedupe`, `dedupe-persons` |
|
||||||
| `opportunities` | `list`, `get`, `create`, `delete`, `stages`, `member-add`, `dedupe`, `dedupe-members`, `fix-titles` |
|
| `opportunities` | `list`, `get`, `create`, `delete`, `stages`, `member-add`, `dedupe`, `dedupe-members`, `fix-titles` |
|
||||||
| `crm` | `cleanup` |
|
| `crm` | `cleanup` |
|
||||||
|
| `mails` | `accounts`, `folders`, `list`, `get`, `delete` |
|
||||||
| `cases` | `list`, `create`, `delete`, `member-add` |
|
| `cases` | `list`, `create`, `delete`, `member-add` |
|
||||||
| `crm-tasks` | `list`, `create`, `delete`, `categories` |
|
| `crm-tasks` | `list`, `create`, `delete`, `categories` |
|
||||||
| `applications` | `sync` |
|
| `applications` | `sync` |
|
||||||
@@ -615,6 +616,21 @@ Run `oo --help` or `oo <subject> --help` for the full command reference.
|
|||||||
| `ONLYOFFICE_PROJECT_ID` | Default project id used when omitted (default `33`) |
|
| `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` |
|
| `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
|
## Examples
|
||||||
|
|
||||||
| Example | Description |
|
| Example | Description |
|
||||||
|
|||||||
+1
-1
@@ -11,7 +11,7 @@ func TestRootRegistersSubjects(t *testing.T) {
|
|||||||
want := []string{
|
want := []string{
|
||||||
"calendar", "projects", "tasks", "users", "whoami",
|
"calendar", "projects", "tasks", "users", "whoami",
|
||||||
"contacts", "persons", "companies",
|
"contacts", "persons", "companies",
|
||||||
"opportunities", "cases", "crm-tasks", "applications", "crm",
|
"opportunities", "cases", "crm-tasks", "applications", "crm", "mails",
|
||||||
}
|
}
|
||||||
got := make(map[string]bool, len(rootCmd.Commands()))
|
got := make(map[string]bool, len(rootCmd.Commands()))
|
||||||
for _, c := range rootCmd.Commands() {
|
for _, c := range rootCmd.Commands() {
|
||||||
|
|||||||
+156
@@ -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
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -13,6 +13,7 @@
|
|||||||
// oo cases list | create | delete | member-add
|
// oo cases list | create | delete | member-add
|
||||||
// oo crm-tasks list | create | delete | categories
|
// oo crm-tasks list | create | delete | categories
|
||||||
// oo crm cleanup
|
// oo crm cleanup
|
||||||
|
// oo mails accounts | folders | list | get | delete
|
||||||
// oo applications sync
|
// oo applications sync
|
||||||
//
|
//
|
||||||
// Every list supports `--output/-o json|table` (table is the default).
|
// Every list supports `--output/-o json|table` (table is the default).
|
||||||
|
|||||||
@@ -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" <a@b.com>` → 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])
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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" <jobalerts-noreply@linkedin.com>`, "LinkedIn Jobbenachrichtigungen", "jobalerts-noreply@linkedin.com"},
|
||||||
|
{`"Bitfinex" <no-reply@bitfinex.com>`, "Bitfinex", "no-reply@bitfinex.com"},
|
||||||
|
{"eslider@gmail.com", "", "eslider@gmail.com"},
|
||||||
|
{`"Glassdoor-Jobs" <noreply@glassdoor.com>`, "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" <a@b.com>`,
|
||||||
|
"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])
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user