diff --git a/cmd/oo/mails.go b/cmd/oo/mails.go index d6d5b4d..c49f362 100644 --- a/cmd/oo/mails.go +++ b/cmd/oo/mails.go @@ -26,6 +26,7 @@ func init() { mailsCmd.AddCommand(mailsDraftCmd()) mailsCmd.AddCommand(mailsAttachCmd()) mailsCmd.AddCommand(mailsDraftInvoiceCmd()) + mailsCmd.AddCommand(mailsSendCmd()) mailsCmd.AddCommand(mailsDeleteCmd()) } @@ -347,6 +348,71 @@ func writeMailAttachment(path string, body []byte) error { 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/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/files.go b/files.go index 794821c..c0d0cdd 100644 --- a/files.go +++ b/files.go @@ -319,8 +319,18 @@ func (c *Client) resolveAPIURL(ref string) string { if ref == "" { return ref } - if strings.HasPrefix(ref, "http://") || strings.HasPrefix(ref, "https://") { - return ref + // Rewrite any host to the configured API base so downloads stay on the + // internal network and keep the Authorization header (no cross-host + // redirect that would strip it). Scheme-relative URLs are handled too. + if strings.HasPrefix(ref, "//") { + ref = "http:" + ref + } + if u, err := url.Parse(ref); err == nil && u.IsAbs() { + if base, err2 := url.Parse(c.baseURL()); err2 == nil { + u.Scheme = base.Scheme + u.Host = base.Host + return u.String() + } } base := c.baseURL() if strings.HasPrefix(ref, "/") { diff --git a/files_webdav.go b/files_webdav.go new file mode 100644 index 0000000..f3fcafd --- /dev/null +++ b/files_webdav.go @@ -0,0 +1,421 @@ +package onlyoffice + +// WebDAV-oriented Files operations. These expose the Documents module through +// value types and cover everything needed to back a filesystem mapping: +// listing (including the virtual @root sections), folder/file CRUD, move/copy, +// and streaming upload/download. They are intentionally small and dependency +// free (only net/http), so callers are not forced to import heavier parts of +// the library. + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "mime/multipart" + "net/http" + "net/url" + "strconv" + "strings" + "time" +) + +// DavFolder is a folder row from the Files module. +type DavFolder struct { + ID string + Title string + ParentID string + RootType int // 1=Common, 3=Trash, 5=My, 6=Share, 8=Projects, ... + FilesCount int + FoldersCount int + Access int + Shared bool + Updated string +} + +// DavFile is a file row from the Files module. +type DavFile struct { + ID string + Title string + Size int64 + Updated string + ViewURL string +} + +// DavListing is the contents of one folder. +type DavListing struct { + Current DavFolder + Files []DavFile + Folders []DavFolder +} + +// ListDavFolder returns the contents of a folder by id, which may be a +// symbolic root such as "@my". For "@root" use ListDavSections. +func (c *Client) ListDavFolder(ctx context.Context, id string) (*DavListing, error) { + raw, err := c.getJSON(ctx, "/api/2.0/files/"+url.PathEscape(id)) + if err != nil { + return nil, err + } + resp, err := responseField(raw, "response") + if err != nil { + return nil, err + } + // @root returns an array with a single blob; a normal folder returns an + // object. Normalize both. + if len(resp) > 0 && resp[0] == '[' { + var arr []*DavListing + if err := json.Unmarshal(resp, &arr); err != nil { + return nil, err + } + if len(arr) == 0 { + return &DavListing{}, nil + } + return arr[0], nil + } + var l DavListing + if err := json.Unmarshal(resp, &l); err != nil { + return nil, err + } + return &l, nil +} + +// ListDavSections returns the virtual top-level sections shown by @root +// ("In projects", "My documents", "Shared with me", "Common", "Favorites", +// "Recent", "Trash"). Each is the `current` folder of one @root element. +func (c *Client) ListDavSections(ctx context.Context) ([]DavFolder, error) { + raw, err := c.getJSON(ctx, "/api/2.0/files/@root") + if err != nil { + return nil, err + } + resp, err := responseField(raw, "response") + if err != nil { + return nil, err + } + var arr []struct { + Current DavFolder `json:"current"` + } + if err := json.Unmarshal(resp, &arr); err != nil { + // Tolerate a non-array (single listing) response. + var single DavListing + if err2 := json.Unmarshal(resp, &single); err2 != nil { + return nil, err + } + return []DavFolder{single.Current}, nil + } + sections := make([]DavFolder, 0, len(arr)) + for i := range arr { + sections = append(sections, arr[i].Current) + } + return sections, nil +} + +// CreateDavFolder creates a folder titled title inside parentID. +func (c *Client) CreateDavFolder(ctx context.Context, parentID, title string) (*DavFolder, error) { + raw, err := c.postJSON(ctx, "/api/2.0/files/folder/"+url.PathEscape(parentID), + map[string]string{"title": title}) + if err != nil { + return nil, err + } + var env struct { + Response *DavFolder `json:"response"` + } + if err := json.Unmarshal(raw, &env); err != nil { + return nil, err + } + if env.Response == nil { + return nil, fmt.Errorf("onlyoffice: empty create-folder response") + } + return env.Response, nil +} + +// RenameDavFolder renames a folder. +func (c *Client) RenameDavFolder(ctx context.Context, id, title string) error { + _, err := c.putJSON(ctx, "/api/2.0/files/folder/"+url.PathEscape(id), + map[string]string{"title": title}) + return err +} + +// RenameDavFile renames a file (title includes the extension). +func (c *Client) RenameDavFile(ctx context.Context, id, title string) error { + _, err := c.putJSON(ctx, "/api/2.0/files/file/"+url.PathEscape(id), + map[string]string{"title": title}) + return err +} + +// MoveDavItems moves the given folders and/or files into destFolderID. +func (c *Client) MoveDavItems(ctx context.Context, folderIDs, fileIDs []string, destFolderID string) error { + _, err := c.putJSON(ctx, "/api/2.0/files/fileops/move", map[string]any{ + "folderIds": nums(folderIDs), + "fileIds": nums(fileIDs), + "destFolderId": num(destFolderID), + "resolveType": "Skip", + "holdResult": true, + }) + return err +} + +// CopyDavItems copies the given folders and/or files into destFolderID. +func (c *Client) CopyDavItems(ctx context.Context, folderIDs, fileIDs []string, destFolderID string) error { + _, err := c.putJSON(ctx, "/api/2.0/files/fileops/copy", map[string]any{ + "folderIds": nums(folderIDs), + "fileIds": nums(fileIDs), + "destFolderId": num(destFolderID), + "conflictResolveType": "Skip", + "deleteAfter": true, + }) + return err +} + +// DeleteDavItems deletes the given folders and/or files. +func (c *Client) DeleteDavItems(ctx context.Context, folderIDs, fileIDs []string) error { + body := map[string]any{"DeleteAfter": true, "Immediately": false} + for _, id := range folderIDs { + if _, err := c.deleteJSON(ctx, "/api/2.0/files/folder/"+url.PathEscape(id), body); err != nil { + return err + } + } + for _, id := range fileIDs { + if _, err := c.deleteJSON(ctx, "/api/2.0/files/file/"+url.PathEscape(id), body); err != nil { + return err + } + } + return nil +} + +// UploadDavFile uploads src (fileName) into folderID, streaming from src. +func (c *Client) UploadDavFile(ctx context.Context, folderID, fileName string, src io.Reader) (*DavFile, error) { + raw, err := c.uploadReader(ctx, "/api/2.0/files/"+url.PathEscape(folderID)+"/upload", "file", fileName, src) + if err != nil { + return nil, err + } + var env struct { + Response *DavFile `json:"response"` + } + if err := json.Unmarshal(raw, &env); err != nil { + return nil, err + } + if env.Response == nil { + return nil, fmt.Errorf("onlyoffice: empty upload response") + } + return env.Response, nil +} + +// DownloadDavFile streams the file identified by id to w, returning bytes copied. +func (c *Client) DownloadDavFile(ctx context.Context, id string, w io.Writer) (int64, error) { + file, err := c.GetFile(ctx, id) + if err != nil { + return 0, err + } + if file.ViewURL == nil || *file.ViewURL == "" { + return 0, fmt.Errorf("onlyoffice: file %s has no viewUrl", id) + } + u := c.resolveAPIURL(*file.ViewURL) + auth, err := c.authHeader() + if err != nil { + return 0, err + } + req, err := http.NewRequestWithContext(ctx, http.MethodGet, u, nil) + if err != nil { + return 0, err + } + req.Header.Set("Authorization", auth) + resp, err := c.client.Do(req) + if err != nil { + return 0, err + } + defer resp.Body.Close() + if resp.StatusCode >= 400 { + return 0, fmt.Errorf("onlyoffice: download: %d", resp.StatusCode) + } + return io.Copy(w, resp.Body) +} + +// --- internal helpers ------------------------------------------------------- + +// deleteJSON performs an authenticated DELETE with an optional JSON body. +func (c *Client) deleteJSON(ctx context.Context, path string, body any) (json.RawMessage, error) { + var reader io.Reader + if body != nil { + buf, err := json.Marshal(body) + if err != nil { + return nil, err + } + reader = bytes.NewReader(buf) + } + auth, err := c.authHeader() + if err != nil { + return nil, err + } + req, err := http.NewRequestWithContext(ctx, http.MethodDelete, c.baseURL()+path, reader) + if err != nil { + return nil, err + } + req.Header.Set("Authorization", auth) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Accept", "application/json") + 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("DELETE %s: %d %s", path, resp.StatusCode, truncate(string(raw), 400)) + } + return raw, nil +} + +// uploadReader uploads a stream to path under the given form field name. +func (c *Client) uploadReader(ctx context.Context, path, fieldName, fileName string, src io.Reader) (json.RawMessage, error) { + var buf bytes.Buffer + mw := multipart.NewWriter(&buf) + part, err := mw.CreateFormFile(fieldName, fileName) + if err != nil { + return nil, err + } + if _, err := io.Copy(part, src); err != nil { + return nil, err + } + if err := mw.Close(); err != nil { + return nil, err + } + auth, err := c.authHeader() + if err != nil { + return nil, err + } + req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.baseURL()+path, &buf) + if err != nil { + return nil, err + } + req.Header.Set("Authorization", auth) + req.Header.Set("Content-Type", mw.FormDataContentType()) + req.Header.Set("Accept", "application/json") + 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("upload %s: %d %s", path, resp.StatusCode, truncate(string(raw), 400)) + } + return raw, nil +} + +func nums(ids []string) []json.Number { + out := make([]json.Number, 0, len(ids)) + for _, id := range ids { + if _, err := strconv.Atoi(id); err == nil { + out = append(out, json.Number(id)) + } + } + return out +} + +func num(id string) any { + if _, err := strconv.Atoi(id); err == nil { + return json.Number(id) + } + return id +} + +// UnmarshalJSON decodes a folder from the portal envelope, including fields +// that the base FolderEntry omits (parentId, rootFolderType, access, ...). +func (f *DavFolder) UnmarshalJSON(b []byte) error { + var raw struct { + ID *json.Number `json:"id"` + Title *string `json:"title"` + ParentID *json.Number `json:"parentId"` + RootType *int `json:"rootFolderType"` + FilesCount *int `json:"filesCount"` + FoldersCount *int `json:"foldersCount"` + Access *int `json:"access"` + Shared *bool `json:"shared"` + Updated *string `json:"updated"` + } + if err := json.Unmarshal(b, &raw); err != nil { + return err + } + if raw.ID != nil { + f.ID = raw.ID.String() + } + if raw.Title != nil { + f.Title = *raw.Title + } + if raw.ParentID != nil { + f.ParentID = raw.ParentID.String() + } + if raw.RootType != nil { + f.RootType = *raw.RootType + } + if raw.FilesCount != nil { + f.FilesCount = *raw.FilesCount + } + if raw.FoldersCount != nil { + f.FoldersCount = *raw.FoldersCount + } + if raw.Access != nil { + f.Access = *raw.Access + } + if raw.Shared != nil { + f.Shared = *raw.Shared + } + if raw.Updated != nil { + f.Updated = *raw.Updated + } + return nil +} + +// UnmarshalJSON decodes a file row, capturing size and timestamps. +func (f *DavFile) UnmarshalJSON(b []byte) error { + var raw struct { + ID *json.Number `json:"id"` + Title *string `json:"title"` + PureSize *int64 `json:"pureContentLength"` + SizeStr *string `json:"contentLength"` + Updated *string `json:"updated"` + ViewURL *string `json:"viewUrl"` + } + if err := json.Unmarshal(b, &raw); err != nil { + return err + } + if raw.ID != nil { + f.ID = raw.ID.String() + } + if raw.Title != nil { + f.Title = *raw.Title + } + if raw.PureSize != nil { + f.Size = *raw.PureSize + } else if raw.SizeStr != nil { + if n, err := strconv.ParseInt(strings.Fields(*raw.SizeStr)[0], 10, 64); err == nil { + f.Size = n + } + } + if raw.Updated != nil { + f.Updated = *raw.Updated + } + if raw.ViewURL != nil { + f.ViewURL = *raw.ViewURL + } + return nil +} + +// ModTime parses the folder's updated timestamp. +func (f *DavFolder) ModTime() time.Time { + t, _ := time.Parse("2006-01-02T15:04:05.0000000-07:00", f.Updated) + return t +} + +// ModTime parses the file's updated timestamp. +func (f *DavFile) ModTime() time.Time { + t, _ := time.Parse("2006-01-02T15:04:05.0000000-07:00", f.Updated) + return t +} diff --git a/mails.go b/mails.go index 491f725..d15f821 100644 --- a/mails.go +++ b/mails.go @@ -8,8 +8,8 @@ import ( "encoding/json" "fmt" "io" - "net/mail" "net/http" + "net/mail" "net/url" "strconv" "strings" @@ -193,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 86b2374..bf9a35c 100644 --- a/mails_test.go +++ b/mails_test.go @@ -2,6 +2,7 @@ package onlyoffice import ( "context" + "encoding/json" "net/http" "net/http/cookiejar" "net/http/httptest" @@ -49,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"}, @@ -159,3 +160,55 @@ func TestDownloadMailAttachmentUsesAuthCookie(t *testing.T) { 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) + } +}