From 1ef0670624ecefdbb3af21f86120702ee16b13f6 Mon Sep 17 00:00:00 2001 From: Andriy Oblivantsev Date: Wed, 19 Aug 2026 12:05:25 +0100 Subject: [PATCH 1/2] Add mail attachment download support for 2dph integration. 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. --- client.go | 4 +++- mails.go | 34 ++++++++++++++++++++++++++++ mails_test.go | 62 +++++++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 99 insertions(+), 1 deletion(-) 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/mails.go b/mails.go index 5689c6c..491f725 100644 --- a/mails.go +++ b/mails.go @@ -7,7 +7,9 @@ import ( "context" "encoding/json" "fmt" + "io" "net/mail" + "net/http" "net/url" "strconv" "strings" @@ -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) { diff --git a/mails_test.go b/mails_test.go index be81255..86b2374 100644 --- a/mails_test.go +++ b/mails_test.go @@ -1,8 +1,13 @@ package onlyoffice import ( + "context" + "net/http" + "net/http/cookiejar" + "net/http/httptest" "strings" "testing" + "time" ) func TestResolveMailFolder(t *testing.T) { @@ -97,3 +102,60 @@ 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) + } +} From 666be883dcb4e7610348e77fe460f88759d94e49 Mon Sep 17 00:00:00 2001 From: Andriy Oblivantsev Date: Wed, 19 Aug 2026 12:10:21 +0100 Subject: [PATCH 2/2] Add oo CLI support for mail attachment downloads. 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. --- cmd/oo/mails.go | 50 ++++++++++++++++++++++++++++++++++++++++ cmd/oo/mails_cli_test.go | 28 ++++++++++++++++++++++ cmd/oo/main.go | 2 +- 3 files changed, 79 insertions(+), 1 deletion(-) create mode 100644 cmd/oo/mails_cli_test.go diff --git a/cmd/oo/mails.go b/cmd/oo/mails.go index 3b90c5a..d6d5b4d 100644 --- a/cmd/oo/mails.go +++ b/cmd/oo/mails.go @@ -22,6 +22,7 @@ func init() { mailsCmd.AddCommand(mailsFoldersCmd()) mailsCmd.AddCommand(mailsListCmd()) mailsCmd.AddCommand(mailsGetCmd()) + mailsCmd.AddCommand(mailsDownloadAttachmentCmd()) mailsCmd.AddCommand(mailsDraftCmd()) mailsCmd.AddCommand(mailsAttachCmd()) mailsCmd.AddCommand(mailsDraftInvoiceCmd()) @@ -131,6 +132,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 +340,13 @@ 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 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