Merge pull request #27 from eSlider/feat/mails-download-attachment-cli
oo CLI: mail attachment downloads
This commit is contained in:
@@ -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,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ 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())
|
||||||
@@ -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 {
|
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 +340,13 @@ 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 mailsDeleteCmd() *cobra.Command {
|
func mailsDeleteCmd() *cobra.Command {
|
||||||
return &cobra.Command{
|
return &cobra.Command{
|
||||||
Use: "delete ID [ID...]",
|
Use: "delete ID [ID...]",
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -7,7 +7,9 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"io"
|
||||||
"net/mail"
|
"net/mail"
|
||||||
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"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))
|
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) {
|
||||||
|
|||||||
@@ -1,8 +1,13 @@
|
|||||||
package onlyoffice
|
package onlyoffice
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
|
"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 +102,60 @@ 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user