Merge feat/contact-indexes: crm indexes + history whitelist

This commit is contained in:
2026-08-21 22:02:48 +01:00
8 changed files with 504 additions and 5 deletions
+3 -1
View File
@@ -11,6 +11,7 @@ package onlyoffice
import ( import (
"net/http" "net/http"
"net/http/cookiejar"
"os" "os"
"strings" "strings"
) )
@@ -35,8 +36,9 @@ type Client struct {
// NewClient returns a new Client backed by http.DefaultClient. // NewClient returns a new Client backed by http.DefaultClient.
func NewClient(c Credentials) *Client { func NewClient(c Credentials) *Client {
jar, _ := cookiejar.New(nil)
return &Client{ return &Client{
client: http.DefaultClient, client: &http.Client{Jar: jar},
credentials: &c, credentials: &c,
} }
} }
+116
View File
@@ -22,9 +22,11 @@ func init() {
mailsCmd.AddCommand(mailsFoldersCmd()) mailsCmd.AddCommand(mailsFoldersCmd())
mailsCmd.AddCommand(mailsListCmd()) mailsCmd.AddCommand(mailsListCmd())
mailsCmd.AddCommand(mailsGetCmd()) mailsCmd.AddCommand(mailsGetCmd())
mailsCmd.AddCommand(mailsDownloadAttachmentCmd())
mailsCmd.AddCommand(mailsDraftCmd()) mailsCmd.AddCommand(mailsDraftCmd())
mailsCmd.AddCommand(mailsAttachCmd()) mailsCmd.AddCommand(mailsAttachCmd())
mailsCmd.AddCommand(mailsDraftInvoiceCmd()) mailsCmd.AddCommand(mailsDraftInvoiceCmd())
mailsCmd.AddCommand(mailsSendCmd())
mailsCmd.AddCommand(mailsDeleteCmd()) mailsCmd.AddCommand(mailsDeleteCmd())
} }
@@ -131,6 +133,48 @@ func mailsGetCmd() *cobra.Command {
} }
} }
func mailsDownloadAttachmentCmd() *cobra.Command {
var outPath string
cmd := &cobra.Command{
Use: "download-attachment ATTACHMENT_ID",
Short: "Download a mail attachment by attachment id",
Long: `Download a raw attachment from OnlyOffice Mail's download.ashx handler.
Example:
oo mails download-attachment 12345 --out /tmp/attach.bin
`,
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
if strings.TrimSpace(outPath) == "" {
return fmt.Errorf("--out is required")
}
c, err := newOO(cmd)
if err != nil {
return err
}
body, err := c.DownloadMailAttachment(cmd.Context(), args[0])
if err != nil {
return err
}
if err := writeMailAttachment(outPath, body); err != nil {
return err
}
if outputFormat == "json" {
printObject(map[string]any{
"attachmentId": args[0],
"bytes": len(body),
"path": outPath,
})
return nil
}
fmt.Printf("saved %d bytes to %s\n", len(body), outPath)
return nil
},
}
cmd.Flags().StringVar(&outPath, "out", "", "output file path")
return cmd
}
func mailsDraftCmd() *cobra.Command { func mailsDraftCmd() *cobra.Command {
var from, to, cc, bcc, subject, body, html string var from, to, cc, bcc, subject, body, html string
var id int64 var id int64
@@ -297,6 +341,78 @@ func formatInvoiceCostEUR(v any) string {
return s return s
} }
func writeMailAttachment(path string, body []byte) error {
if strings.TrimSpace(path) == "" {
return fmt.Errorf("attachment output path is required")
}
return os.WriteFile(path, body, 0o644)
}
func mailsSendCmd() *cobra.Command {
var from, to, cc, bcc, subject, body, html string
var id int64
cmd := &cobra.Command{
Use: "send",
Short: "Send a mail message (OnlyOffice Mail)",
Long: `Send via PUT /api/2.0/mail/messages/send.json.
oo mails send --id 7803 --body "…" # send referencing a draft id
oo mails send --to a@b.com --subject "…" --body "…"
oo mails send --id 7803 --to a@b.com --subject "…" --body "…" --cc x@y.com
IMPORTANT: send.json does NOT copy subject/body from the referenced draft — the
content must be in this request (--subject/--body). Cc/Bcc are omitted when empty
(the API 400s on empty strings). The API send does not append the UI signature —
put the chat line in --body if needed.
`,
RunE: func(cmd *cobra.Command, args []string) error {
if to == "" && id == 0 {
return fmt.Errorf("--to is required (or --id of an existing draft)")
}
htmlBody := body
if html != "" {
htmlBody = html
}
if htmlBody == "" && id != 0 {
// The send.json endpoint does NOT copy subject/body from the
// referenced draft — an empty body here sends an empty message.
// Warn instead of silently mailing an empty email.
return fmt.Errorf("--body/--html is required when sending by --id (send.json needs the content in the request)")
}
if htmlBody == "" && to == "" {
return fmt.Errorf("--body is required for a fresh message")
}
c, err := newOO(cmd)
if err != nil {
return err
}
raw, err := c.SendMail(cmd.Context(), onlyoffice.SendMailParams{
ID: id,
From: from,
To: to,
Cc: cc,
Bcc: bcc,
Subject: subject,
Body: htmlBody,
})
if err != nil {
return err
}
fmt.Println(string(raw))
return nil
},
}
cmd.Flags().Int64Var(&id, "id", 0, "existing draft id to send (0 = fresh message)")
cmd.Flags().StringVar(&from, "from", "", "from address (default: first enabled mailbox)")
cmd.Flags().StringVar(&to, "to", "", "recipient (required unless --id)")
cmd.Flags().StringVar(&cc, "cc", "", "cc")
cmd.Flags().StringVar(&bcc, "bcc", "", "bcc")
cmd.Flags().StringVar(&subject, "subject", "", "subject")
cmd.Flags().StringVar(&body, "body", "", "plain text or HTML body")
cmd.Flags().StringVar(&html, "html", "", "HTML body (alias of --body when set)")
return cmd
}
func mailsDeleteCmd() *cobra.Command { func mailsDeleteCmd() *cobra.Command {
return &cobra.Command{ return &cobra.Command{
Use: "delete ID [ID...]", Use: "delete ID [ID...]",
+28
View File
@@ -0,0 +1,28 @@
package main
import (
"os"
"path/filepath"
"testing"
)
func TestWriteMailAttachment(t *testing.T) {
path := filepath.Join(t.TempDir(), "attach.bin")
body := []byte("payload")
if err := writeMailAttachment(path, body); err != nil {
t.Fatalf("writeMailAttachment: %v", err)
}
got, err := os.ReadFile(path)
if err != nil {
t.Fatalf("ReadFile: %v", err)
}
if string(got) != string(body) {
t.Fatalf("body = %q", got)
}
}
func TestWriteMailAttachmentRequiresPath(t *testing.T) {
if err := writeMailAttachment("", []byte("x")); err == nil {
t.Fatal("expected error for empty path")
}
}
+1 -1
View File
@@ -13,7 +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 | draft | attach | draft-invoice | delete // oo mails accounts | folders | list | get | download-attachment | draft | attach | draft-invoice | delete
// oo invoices list | get | create | update | pdf | pdf-cleanup | status | delete | items … // oo invoices list | get | create | update | pdf | pdf-cleanup | status | delete | items …
// //
// CRM association rules: docs/crm-associations.md // CRM association rules: docs/crm-associations.md
+96
View File
@@ -0,0 +1,96 @@
package onlyoffice
import (
"context"
"fmt"
"sort"
"strconv"
"strings"
)
// History entities that OnlyOffice CRM actually accepts for history notes.
// There is NO person/contact history in this API version: POST /api/2.0/crm/history.json
// returns 400 "Value does not fall within the expected range." for entityType
// contact/person/people/client/member. Verified against a live instance (#74).
const (
HistoryEntityOpportunity = "opportunity"
HistoryEntityCase = "case"
)
// IsCompany reports whether a CRM contact row is a company (vs a person).
// The field arrives as JSON bool; be liberal about what we accept.
func IsCompany(person map[string]any) bool {
b, _ := person["isCompany"].(bool)
return b
}
// ContactID returns the CRM id of a contact row as a plain string.
func ContactID(row map[string]any) string {
return fmt.Sprint(row["id"])
}
// BuildContactEmailIndex scans all persons once and maps lowercase email →
// contact id. Use this instead of calling FindPersonByEmail per address:
// the index is O(N) over the whole CRM, the per-address lookup is O(N×M).
func (c *Client) BuildContactEmailIndex(ctx context.Context) (map[string]string, error) {
all, err := c.ListAllContacts(ctx)
if err != nil {
return nil, err
}
index := make(map[string]string, len(all)*2)
for _, person := range all {
if IsCompany(person) {
continue
}
id := ContactID(person)
for _, row := range ContactInfoRows(person) {
if NormalizeContactInfoType(fmt.Sprint(row["infoType"])) != "email" {
continue
}
email := strings.ToLower(strings.TrimSpace(fmt.Sprint(row["data"])))
if email != "" && email != "<nil>" {
index[email] = id
}
}
}
return index, nil
}
// BuildPersonOpportunityIndex maps every opportunity member's contact id to a
// deterministic representative opportunity: the one with the lowest numeric id.
// OnlyOffice has no person-level history, so notes for a person go on their
// deal — this index answers "which deal" in one pass.
func (c *Client) BuildPersonOpportunityIndex(ctx context.Context) (map[string]string, error) {
opps, err := c.ListAllOpportunities(ctx)
if err != nil {
return nil, err
}
index := map[string]string{}
for _, opp := range opps {
oppID := ContactID(opp)
for _, member := range OpportunityMembers(opp) {
pid := ContactID(member)
if cur, ok := index[pid]; !ok || NumericIDLess(oppID, cur) {
index[pid] = oppID
}
}
}
return index, nil
}
// NumericIDLess compares two string ids numerically when possible, falling
// back to lexicographic order so results stay deterministic either way.
func NumericIDLess(a, b string) bool {
na, errA := strconv.Atoi(strings.TrimSpace(a))
nb, errB := strconv.Atoi(strings.TrimSpace(b))
if errA == nil && errB == nil && na != nb {
return na < nb
}
return a < b
}
// SortIDs orders id strings deterministically (numeric first, then lexical).
func SortIDs(ids []string) {
sort.Strings(ids)
sort.SliceStable(ids, func(i, j int) bool { return NumericIDLess(ids[i], ids[j]) })
}
+65
View File
@@ -0,0 +1,65 @@
package onlyoffice
import "testing"
func TestIsCompany(t *testing.T) {
if IsCompany(map[string]any{"isCompany": true}) != true {
t.Fatal("true row not detected")
}
if IsCompany(map[string]any{"isCompany": false}) {
t.Fatal("false row detected as company")
}
if IsCompany(map[string]any{}) {
t.Fatal("missing field detected as company")
}
if IsCompany(nil) {
t.Fatal("nil row detected as company")
}
}
func TestNumericIDLess(t *testing.T) {
cases := []struct {
a, b string
want bool
}{
{"9", "10", true},
{"1747", "1748", true},
{"abc", "abd", true},
{"10", "9", false},
{" 12 ", "13", true},
{"x1", "2", false}, // non-numeric falls back lexical: "x1" > "2"
}
for _, c := range cases {
if got := NumericIDLess(c.a, c.b); got != c.want {
t.Errorf("NumericIDLess(%q,%q)=%v want %v", c.a, c.b, got, c.want)
}
}
}
func TestSortIDs(t *testing.T) {
ids := []string{"20", "3", "100", "1"}
SortIDs(ids)
want := "1 3 20 100"
got := ""
for i, id := range ids {
if i > 0 {
got += " "
}
got += id
}
if got != want {
t.Fatalf("SortIDs=%q want %q", got, want)
}
}
func TestContactID(t *testing.T) {
if ContactID(map[string]any{"id": float64(42)}) != "42" {
t.Fatal("numeric id formatting broken")
}
}
func TestHistoryEntityConstants(t *testing.T) {
if HistoryEntityOpportunity != "opportunity" || HistoryEntityCase != "case" {
t.Fatal("history entity whitelist drifted from live-verified values")
}
}
+77
View File
@@ -7,6 +7,8 @@ import (
"context" "context"
"encoding/json" "encoding/json"
"fmt" "fmt"
"io"
"net/http"
"net/mail" "net/mail"
"net/url" "net/url"
"strconv" "strconv"
@@ -96,6 +98,38 @@ func (c *Client) GetMailMessage(ctx context.Context, messageID string) (map[stri
return c.ResponseObject(ctx, "/api/2.0/mail/messages/"+url.PathEscape(id)) return c.ResponseObject(ctx, "/api/2.0/mail/messages/"+url.PathEscape(id))
} }
// DownloadMailAttachment fetches raw attachment bytes by mail attachment id via
// the mail addon's download.ashx handler. This path relies on the session
// cookie captured during authentication, so NewClient configures a cookie jar.
func (c *Client) DownloadMailAttachment(ctx context.Context, attachmentID string) ([]byte, error) {
id := strings.TrimSpace(attachmentID)
if id == "" {
return nil, fmt.Errorf("DownloadMailAttachment: attachment id is required")
}
auth, err := c.authHeader()
if err != nil {
return nil, err
}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.baseURL()+"/addons/mail/httphandlers/download.ashx?attachid="+url.QueryEscape(id), nil)
if err != nil {
return nil, err
}
req.Header.Set("Authorization", auth)
resp, err := c.client.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
raw, err := io.ReadAll(resp.Body)
if err != nil {
return nil, err
}
if resp.StatusCode >= 400 {
return nil, fmt.Errorf("DownloadMailAttachment %s: %d %s", id, resp.StatusCode, truncate(string(raw), 400))
}
return raw, nil
}
// RemoveMailMessages deletes messages by id (PUT /api/2.0/mail/messages/remove). // RemoveMailMessages deletes messages by id (PUT /api/2.0/mail/messages/remove).
// The API response "response" field may be a number or object; success is HTTP 2xx. // The API response "response" field may be a number or object; success is HTTP 2xx.
func (c *Client) RemoveMailMessages(ctx context.Context, ids ...int) (map[string]any, error) { func (c *Client) RemoveMailMessages(ctx context.Context, ids ...int) (map[string]any, error) {
@@ -159,6 +193,49 @@ func (c *Client) SaveMailDraft(ctx context.Context, p SaveMailDraftParams) (map[
return c.putJSONObject(ctx, "/api/2.0/mail/drafts/save", body) return c.putJSONObject(ctx, "/api/2.0/mail/drafts/save", body)
} }
// SendMailParams describes a message to send via PUT /api/2.0/mail/messages/send.
// ID refers to an existing draft/message id; From falls back to the first enabled
// mailbox. Cc/Bcc are omitted when empty (the API 400s on empty strings). Chat
// line goes into Body (API send does not append the UI signature).
type SendMailParams struct {
ID int64
From string
To string
Cc string
Bcc string
Subject string
Body string // HTML
}
// SendMail sends an existing draft (or a fresh message) via the OnlyOffice Mail
// send endpoint. Returns the raw send response.
func (c *Client) SendMail(ctx context.Context, p SendMailParams) (json.RawMessage, error) {
if strings.TrimSpace(p.To) == "" {
return nil, fmt.Errorf("SendMail: to is required")
}
if strings.TrimSpace(p.From) == "" {
from, err := c.defaultMailFrom(ctx)
if err != nil {
return nil, err
}
p.From = from
}
body := map[string]any{
"id": p.ID,
"from": p.From,
"to": p.To,
"subject": p.Subject,
"body": p.Body,
}
if strings.TrimSpace(p.Cc) != "" {
body["cc"] = p.Cc
}
if strings.TrimSpace(p.Bcc) != "" {
body["bcc"] = p.Bcc
}
return c.putJSON(ctx, "/api/2.0/mail/messages/send.json", body)
}
func (c *Client) defaultMailFrom(ctx context.Context) (string, error) { func (c *Client) defaultMailFrom(ctx context.Context) (string, error) {
accounts, err := c.ListMailAccounts(ctx) accounts, err := c.ListMailAccounts(ctx)
if err != nil { if err != nil {
+115
View File
@@ -1,8 +1,14 @@
package onlyoffice package onlyoffice
import ( import (
"context"
"encoding/json"
"net/http"
"net/http/cookiejar"
"net/http/httptest"
"strings" "strings"
"testing" "testing"
"time"
) )
func TestResolveMailFolder(t *testing.T) { func TestResolveMailFolder(t *testing.T) {
@@ -97,3 +103,112 @@ func TestInt64FromMap(t *testing.T) {
t.Fatal("string") t.Fatal("string")
} }
} }
func TestNewClientSetsCookieJar(t *testing.T) {
c := NewClient(Credentials{Url: "https://example.test", User: "u", Password: "p"})
if c.client == nil {
t.Fatal("client is nil")
}
if c.client.Jar == nil {
t.Fatal("cookie jar is nil")
}
if _, ok := c.client.Jar.(*cookiejar.Jar); !ok {
t.Fatalf("unexpected jar type %T", c.client.Jar)
}
}
func TestDownloadMailAttachmentUsesAuthCookie(t *testing.T) {
var gotAuth, gotCookie, gotPath string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/api/2.0/authentication.json":
http.SetCookie(w, &http.Cookie{Name: "sessionid", Value: "abc123", Path: "/"})
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"response":{"token":"tok","expires":"2099-01-01T00:00:00.0000000+00:00"}}`))
case "/addons/mail/httphandlers/download.ashx":
gotAuth = r.Header.Get("Authorization")
gotCookie = r.Header.Get("Cookie")
gotPath = r.URL.RequestURI()
if gotCookie == "" {
http.Error(w, "missing cookie", http.StatusUnauthorized)
return
}
_, _ = w.Write([]byte("payload"))
default:
http.NotFound(w, r)
}
}))
defer srv.Close()
c := NewClient(Credentials{Url: srv.URL, User: "u", Password: "p"})
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
body, err := c.DownloadMailAttachment(ctx, "42")
if err != nil {
t.Fatalf("DownloadMailAttachment: %v", err)
}
if string(body) != "payload" {
t.Fatalf("body = %q", body)
}
if gotAuth != "tok" {
t.Fatalf("auth header = %q", gotAuth)
}
if !strings.Contains(gotCookie, "sessionid=abc123") {
t.Fatalf("cookie header = %q", gotCookie)
}
if gotPath != "/addons/mail/httphandlers/download.ashx?attachid=42" {
t.Fatalf("path = %q", gotPath)
}
}
func TestSendMailOmitsEmptyCcBcc(t *testing.T) {
var gotBody map[string]any
var gotPath string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/api/2.0/authentication.json":
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"response":{"token":"tok","expires":"2099-01-01T00:00:00.0000000+00:00"}}`))
case "/api/2.0/mail/messages/send.json":
gotPath = r.URL.Path
dec := json.NewDecoder(r.Body)
_ = dec.Decode(&gotBody)
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"response":{"id":1}}`))
default:
http.NotFound(w, r)
}
}))
defer srv.Close()
c := NewClient(Credentials{Url: srv.URL, User: "u", Password: "p"})
ctx := context.Background()
raw, err := c.SendMail(ctx, SendMailParams{
ID: 99,
From: "me@x.com",
To: "a@b.com",
Subject: "hi",
Body: "<p>hello</p>",
})
if err != nil {
t.Fatalf("SendMail: %v", err)
}
if gotPath != "/api/2.0/mail/messages/send.json" {
t.Fatalf("path = %q", gotPath)
}
if _, hasCC := gotBody["cc"]; hasCC {
t.Fatalf("empty cc should be omitted: %v", gotBody)
}
if _, hasBcc := gotBody["bcc"]; hasBcc {
t.Fatalf("empty bcc should be omitted: %v", gotBody)
}
if gotBody["to"] != "a@b.com" {
t.Fatalf("to = %v", gotBody["to"])
}
if gotBody["id"] != float64(99) {
t.Fatalf("id = %v", gotBody["id"])
}
if !strings.Contains(string(raw), `"id"`) {
t.Fatalf("raw = %s", raw)
}
}