diff --git a/client.go b/client.go index f8805c6..b187cc9 100644 --- a/client.go +++ b/client.go @@ -11,6 +11,7 @@ package onlyoffice import ( "net/http" + "net/http/cookiejar" "os" "strings" ) @@ -35,8 +36,9 @@ type Client struct { // NewClient returns a new Client backed by http.DefaultClient. func NewClient(c Credentials) *Client { + jar, _ := cookiejar.New(nil) return &Client{ - client: http.DefaultClient, + client: &http.Client{Jar: jar}, credentials: &c, } } diff --git a/cmd/oo/mails.go b/cmd/oo/mails.go index 3b90c5a..c49f362 100644 --- a/cmd/oo/mails.go +++ b/cmd/oo/mails.go @@ -22,9 +22,11 @@ func init() { mailsCmd.AddCommand(mailsFoldersCmd()) mailsCmd.AddCommand(mailsListCmd()) mailsCmd.AddCommand(mailsGetCmd()) + mailsCmd.AddCommand(mailsDownloadAttachmentCmd()) mailsCmd.AddCommand(mailsDraftCmd()) mailsCmd.AddCommand(mailsAttachCmd()) mailsCmd.AddCommand(mailsDraftInvoiceCmd()) + mailsCmd.AddCommand(mailsSendCmd()) 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 { var from, to, cc, bcc, subject, body, html string var id int64 @@ -297,6 +341,78 @@ func formatInvoiceCostEUR(v any) string { 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 { return &cobra.Command{ Use: "delete ID [ID...]", diff --git a/cmd/oo/mails_cli_test.go b/cmd/oo/mails_cli_test.go new file mode 100644 index 0000000..dd69516 --- /dev/null +++ b/cmd/oo/mails_cli_test.go @@ -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") + } +} diff --git a/cmd/oo/main.go b/cmd/oo/main.go index 13c38fc..30ba5b6 100644 --- a/cmd/oo/main.go +++ b/cmd/oo/main.go @@ -13,7 +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 | 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 … // // CRM association rules: docs/crm-associations.md diff --git a/contact_indexes.go b/contact_indexes.go new file mode 100644 index 0000000..e056476 --- /dev/null +++ b/contact_indexes.go @@ -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 != "" { + 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]) }) +} diff --git a/contact_indexes_test.go b/contact_indexes_test.go new file mode 100644 index 0000000..50677a9 --- /dev/null +++ b/contact_indexes_test.go @@ -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") + } +} diff --git a/mails.go b/mails.go index 5689c6c..d15f821 100644 --- a/mails.go +++ b/mails.go @@ -7,6 +7,8 @@ import ( "context" "encoding/json" "fmt" + "io" + "net/http" "net/mail" "net/url" "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)) } +// 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). // 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) { @@ -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) } +// 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) { accounts, err := c.ListMailAccounts(ctx) if err != nil { diff --git a/mails_test.go b/mails_test.go index be81255..bf9a35c 100644 --- a/mails_test.go +++ b/mails_test.go @@ -1,8 +1,14 @@ package onlyoffice import ( + "context" + "encoding/json" + "net/http" + "net/http/cookiejar" + "net/http/httptest" "strings" "testing" + "time" ) func TestResolveMailFolder(t *testing.T) { @@ -44,9 +50,9 @@ func TestMailMessagesPath(t *testing.T) { func TestParseMailAddress(t *testing.T) { tests := []struct { - raw string - wantName string - wantAddress string + raw string + wantName string + wantAddress string }{ {`"LinkedIn Jobbenachrichtigungen" `, "LinkedIn Jobbenachrichtigungen", "jobalerts-noreply@linkedin.com"}, {`"Bitfinex" `, "Bitfinex", "no-reply@bitfinex.com"}, @@ -97,3 +103,112 @@ func TestInt64FromMap(t *testing.T) { 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: "

hello

", + }) + 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) + } +}