Compare commits

..
11 Commits
Author SHA1 Message Date
eSlider 77c188c640 Merge origin/main (mail-send PRs, files ops) into local main
Release Please / Release Please (push) Skipped
Tests / Test (Go 1.25) (push) Skipped
Tests / Test (Go stable) (push) Skipped
Release / GoReleaser (push) Skipped
2026-08-21 22:03:04 +01:00
eSlider 2a59817f51 Merge feat/contact-indexes: crm indexes + history whitelist 2026-08-21 22:02:48 +01:00
eSlider 9c450a0352 feat(crm): contact email & person-opportunity indexes, history entity whitelist
- BuildContactEmailIndex: one-pass email→contact id map over all persons
  (O(N) instead of per-address FindPersonByEmail O(N×M))
- BuildPersonOpportunityIndex: person→lowest-id opportunity for history notes
  (OO has no person-level history; verified live)
- HistoryEntity{Opportunity,Case} whitelist constants
- IsCompany/ContactID/NumericIDLess/SortIDs helpers with unit tests
2026-08-21 22:02:47 +01:00
eSliderandGitHub ab7dfbd859 Merge pull request #28 from eSlider/feat/mail-send
feat(mail): oo mails send — SendMail client + guard empty-by-id
2026-08-21 17:21:18 +01:00
eSlider dda2bd3ca5 feat(mail): oo mails send — SendMail + guard empty-by-id send
- Client.SendMail: PUT /api/2.0/mail/messages/send.json, omits empty cc/bcc
- oo mails send subcommand with --id/--to/--subject/--body/--cc/--bcc
- guard: sending by --id without --body errors (send.json does not copy
  subject/body from the draft; prevents empty emails)
- unit test: empty cc/bcc omitted, correct path
2026-08-21 16:43:00 +01:00
eSliderandGitHub ceea6d909e Merge pull request #27 from eSlider/feat/mails-download-attachment-cli
oo CLI: mail attachment downloads
2026-08-21 15:25:08 +01:00
eSlider ecf34ba51a fix(files): rewrite viewUrl host to API base on download
resolveAPIURL now rewrites absolute viewUrl hosts to the configured portal
base so downloads stay on the internal network and keep the Authorization
header (a cross-host HTTP->HTTPS redirect would otherwise strip it, failing
large-file reads).
2026-08-20 10:48:38 +01:00
eSlider ebbd5d5373 feat(files): add WebDAV-oriented Files operations
Add value-oriented Files module operations for filesystem backends:
ListDavFolder, ListDavSections (@root), CreateDavFolder, RenameDavFolder/
RenameDavFile, MoveDavItems, CopyDavItems, DeleteDavItems, UploadDavFile
(streaming), DownloadDavFile. Dependency-free (net/http only).
2026-08-20 10:37:07 +01:00
eSlider 666be883dc Add oo CLI support for mail attachment downloads.
Release Please / Release Please (push) Skipped
Release / GoReleaser (push) Skipped
Tests / Test (Go 1.25) (push) Skipped
Tests / Test (Go stable) (push) Skipped
Tests / Test (Go 1.25) (pull_request) Successful in 24s
Tests / Test (Go stable) (pull_request) Successful in 26s
This exposes the canonical attachment download path via oo mails so downstream tools like 2dph can migrate off local OnlyOffice mail adapters without taking a direct module dependency first.
2026-08-19 12:10:21 +01:00
eSlider 1ef0670624 Add mail attachment download support for 2dph integration.
Release Please / Release Please (push) Skipped
Release / GoReleaser (push) Skipped
Tests / Test (Go 1.25) (push) Skipped
Tests / Test (Go stable) (push) Skipped
Tests / Test (Go 1.25) (pull_request) Successful in 50s
Tests / Test (Go stable) (pull_request) Successful in 51s
This gives go-onlyoffice a cookie-backed attachment download path so 2dph can reuse the canonical OnlyOffice client instead of keeping a local mail adapter.
2026-08-19 12:05:25 +01:00
eSliderandGitHub 10c1cfc6a7 ci: node16-safe actions + GitHub-only gates for Gitea mirror (#25)
Release / GoReleaser (push) Skipped
Release Please / Release Please (push) Skipped
Tests / Test (Go 1.25) (push) Successful in 1m28s
Tests / Test (Go stable) (push) Successful in 1m23s
The Gitea act_runner runs action JS in node:16-bullseye; setup-go@v5 crashes
with 'ReferenceError: ReadableStream is not defined'. Gitea discovers
.github/workflows too, so:

- setup-go@v5 -> @v4 (node16-safe, fine on GitHub)
- gate GoReleaser (release.yml) + release-please jobs to
  github.server_url == https://github.com so the GitHub CD never runs on the
  Gitea mirror (GoReleaser/release-please are GitHub-release-centric)
2026-08-19 11:12:58 +01:00
13 changed files with 940 additions and 8 deletions
+1
View File
@@ -14,6 +14,7 @@ permissions:
jobs: jobs:
release-please: release-please:
name: Release Please name: Release Please
if: github.server_url == 'https://github.com'
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Run release-please - name: Run release-please
+1
View File
@@ -16,6 +16,7 @@ permissions:
jobs: jobs:
goreleaser: goreleaser:
name: GoReleaser name: GoReleaser
if: github.server_url == 'https://github.com'
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
# Always clone default branch first. workflow_dispatch often races with # Always clone default branch first. workflow_dispatch often races with
+1 -1
View File
@@ -22,7 +22,7 @@ jobs:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- name: Set up Go - name: Set up Go
uses: actions/setup-go@v5 uses: actions/setup-go@v4
with: with:
go-version: ${{ matrix.go }} go-version: ${{ matrix.go }}
cache: true cache: true
+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")
}
}
+12 -2
View File
@@ -319,8 +319,18 @@ func (c *Client) resolveAPIURL(ref string) string {
if ref == "" { if ref == "" {
return ref return ref
} }
if strings.HasPrefix(ref, "http://") || strings.HasPrefix(ref, "https://") { // Rewrite any host to the configured API base so downloads stay on the
return ref // 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() base := c.baseURL()
if strings.HasPrefix(ref, "/") { if strings.HasPrefix(ref, "/") {
+421
View File
@@ -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
}
+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 {
+118 -3
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) {
@@ -44,9 +50,9 @@ func TestMailMessagesPath(t *testing.T) {
func TestParseMailAddress(t *testing.T) { func TestParseMailAddress(t *testing.T) {
tests := []struct { tests := []struct {
raw string raw string
wantName string wantName string
wantAddress string wantAddress string
}{ }{
{`"LinkedIn Jobbenachrichtigungen" <jobalerts-noreply@linkedin.com>`, "LinkedIn Jobbenachrichtigungen", "jobalerts-noreply@linkedin.com"}, {`"LinkedIn Jobbenachrichtigungen" <jobalerts-noreply@linkedin.com>`, "LinkedIn Jobbenachrichtigungen", "jobalerts-noreply@linkedin.com"},
{`"Bitfinex" <no-reply@bitfinex.com>`, "Bitfinex", "no-reply@bitfinex.com"}, {`"Bitfinex" <no-reply@bitfinex.com>`, "Bitfinex", "no-reply@bitfinex.com"},
@@ -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)
}
}