From 666be883dcb4e7610348e77fe460f88759d94e49 Mon Sep 17 00:00:00 2001 From: Andriy Oblivantsev Date: Wed, 19 Aug 2026 12:10:21 +0100 Subject: [PATCH] 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 -- 2.54.0