Compare commits

...
6 Commits
Author SHA1 Message Date
mdx-1 ae776aa396 feat(mailsync): FetchMailFolder — integration-layer walk for ETL consumers
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 41s
Tests / Test (Go stable) (pull_request) Successful in 1m32s
Adds the high-level mail folder walk that sync pipelines need on top of
the raw mail API (list -> get -> download-attachment), so consumers stop
re-implementing it against private client copies.

  type MailSyncMessage struct { ID, Folder, Subject, From, Date, IsNew,
                                HasAttachments, Attachments }
  type MailSyncAttachment struct { ID, Name, Size, Body }
  func (c *Client) FetchMailFolder(ctx, folderID, MailSyncOptions)
                                   ([]MailSyncMessage, error)

Options: Limit / StartIndex for checkpointed walks, FetchBodies to
eagerly download attachment bytes via download.ashx (session-cookie path).

Hydration details:
- list items may omit the attachment array; when hasAttachments is set
  the full record is fetched and its attachments merged
- attachment ids accepted from id/fileId/attachmentId variants
- timestamps parsed from RFC3339 (any fractional digits) and
  second-precision forms

This is the first step of the 2dph integration layer (#1): the brain's
mail-ingest pipeline can now drop its private OOClient copy and consume
this canonical walk directly.

Tests: httptest-backed coverage for pagination, hydration with
full-record fallback, body download incl. auth-cookie requirement,
Limit/StartIndex windows, timestamp parsing.
2026-08-22 14:35:57 +00:00
eSlider 20a09530cd Merge pull request 'Add oo CLI support for mail attachment downloads' (#3) from feat/mails-download-attachment-cli into main
Release / GoReleaser (push) Skipped
Release Please / Release Please (push) Skipped
Tests / Test (Go 1.25) (push) Skipped
Tests / Test (Go stable) (push) Skipped
Reviewed-on: #3
2026-08-19 12:17:10 +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 65cd3f5c74 Merge pull request #2 from feat/mail-download-for-2dph
Release / GoReleaser (push) Skipped
Release Please / Release Please (push) Skipped
Tests / Test (Go 1.25) (push) Successful in 41s
Tests / Test (Go stable) (push) Successful in 41s
Add mail attachment download support for 2dph integration.
2026-08-19 12:08:04 +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
11 changed files with 493 additions and 3 deletions
+1
View File
@@ -14,6 +14,7 @@ permissions:
jobs:
release-please:
name: Release Please
if: github.server_url == 'https://github.com'
runs-on: ubuntu-latest
steps:
- name: Run release-please
+1
View File
@@ -16,6 +16,7 @@ permissions:
jobs:
goreleaser:
name: GoReleaser
if: github.server_url == 'https://github.com'
runs-on: ubuntu-latest
steps:
# Always clone default branch first. workflow_dispatch often races with
+1 -1
View File
@@ -22,7 +22,7 @@ jobs:
- uses: actions/checkout@v4
- name: Set up Go
uses: actions/setup-go@v5
uses: actions/setup-go@v4
with:
go-version: ${{ matrix.go }}
cache: true
+3 -1
View File
@@ -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,
}
}
+50
View File
@@ -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...]",
+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 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
+34
View File
@@ -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) {
+62
View File
@@ -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)
}
}
+175
View File
@@ -0,0 +1,175 @@
package onlyoffice
// High-level mail folder walk for ETL consumers (2dph brain mail-ingest,
// cv tools). This is the "integration layer" half of reusing the canonical
// client instead of private per-project OOClient copies: the caller gets a
// single hydrated stream instead of hand-rolling list → get → download
// against the raw API.
import (
"context"
"fmt"
"strconv"
"time"
)
// MailSyncAttachment is one attachment of a hydrated mail message.
type MailSyncAttachment struct {
ID string // id accepted by Client.DownloadMailAttachment
Name string
Size int64
Body []byte // non-nil only when MailSyncOptions.FetchBodies is set
}
// MailSyncMessage is a hydrated mail message for sync pipelines.
type MailSyncMessage struct {
ID int64
Folder int
Subject string
From string // raw RFC 5322 header value ("Name" <addr>)
Date time.Time
IsNew bool
HasAttachments bool
Attachments []MailSyncAttachment
}
// MailSyncOptions controls FetchMailFolder.
type MailSyncOptions struct {
Limit int // max messages to hydrate; 0 = whole folder
StartIndex int // skip this many messages before collecting
FetchBodies bool // eagerly download attachment bytes
}
// FetchMailFolder walks a mail folder page by page and hydrates every
// message: list → get → (optionally) download attachments. It is the single
// entry point sync pipelines need on top of the mail API.
//
// Messages are returned in API order (newest first). The folder walk stops
// at the first empty or short page.
func (c *Client) FetchMailFolder(ctx context.Context, folderID int, opts MailSyncOptions) ([]MailSyncMessage, error) {
if folderID <= 0 {
folderID = MailFolderInbox
}
var out []MailSyncMessage
skipped := 0
for page := 1; ; page++ {
batch, err := c.ResponseArray(ctx,
mailMessagesPath(MailMessagesFilter{Folder: folderID}, page, mailMessagesPageSize))
if err != nil {
return nil, fmt.Errorf("FetchMailFolder: %w", err)
}
if len(batch) == 0 {
break
}
for _, raw := range batch {
if skipped < opts.StartIndex {
skipped++
continue
}
msg, err := c.hydrateMailMessage(ctx, raw, opts)
if err != nil {
return nil, err
}
out = append(out, *msg)
if opts.Limit > 0 && len(out) >= opts.Limit {
return out, nil
}
}
if len(batch) < mailMessagesPageSize {
break
}
}
return out, nil
}
// hydrateMailMessage converts one raw API message into a MailSyncMessage,
// fetching the full record when the list item does not carry the attachment
// metadata, and downloading bodies when requested.
func (c *Client) hydrateMailMessage(ctx context.Context, m map[string]any, opts MailSyncOptions) (*MailSyncMessage, error) {
msg := &MailSyncMessage{
ID: Int64FromMap(m, "id"),
Folder: int(Int64FromMap(m, "folder")),
Subject: stringFromMap(m, "subject"),
From: stringFromMap(m, "from"),
IsNew: boolFromMap(m, "isNew") == "true",
}
msg.Date = parseMailTime(stringFromMap(m, "date"))
atts, _ := m["attachments"].([]any)
hasFlag := boolFromMap(m, "hasAttachments") == "true"
if hasFlag && len(atts) == 0 {
// List items may omit the attachment array; pull the full record.
full, err := c.GetMailMessage(ctx, strconv.FormatInt(msg.ID, 10))
if err != nil {
return nil, fmt.Errorf("FetchMailFolder: hydrate message %d: %w", msg.ID, err)
}
atts, _ = full["attachments"].([]any)
}
for _, a := range atts {
am, ok := a.(map[string]any)
if !ok {
continue
}
att := MailSyncAttachment{
ID: mailAttachmentID(am),
Name: stringFromMap(am, "fileName"),
Size: Int64FromMap(am, "size"),
}
if att.Name == "" {
att.Name = stringFromMap(am, "name")
}
if att.ID != "" {
msg.Attachments = append(msg.Attachments, att)
}
}
msg.HasAttachments = hasFlag || len(msg.Attachments) > 0
if opts.FetchBodies {
for i := range msg.Attachments {
body, err := c.DownloadMailAttachment(ctx, msg.Attachments[i].ID)
if err != nil {
return nil, fmt.Errorf("FetchMailFolder: message %d attachment %q: %w",
msg.ID, msg.Attachments[i].Name, err)
}
msg.Attachments[i].Body = body
}
}
return msg, nil
}
// mailAttachmentID extracts the download id from an attachment object.
// OnlyOffice variants use "id", "fileId" or "attachmentId".
func mailAttachmentID(am map[string]any) string {
for _, key := range []string{"id", "fileId", "attachmentId"} {
switch v := am[key].(type) {
case string:
if s := v; s != "" {
return s
}
case float64:
if n := int64(v); n != 0 {
return strconv.FormatInt(n, 10)
}
case int64:
if v != 0 {
return strconv.FormatInt(v, 10)
}
}
}
return ""
}
// parseMailTime accepts the OnlyOffice timestamp shapes seen in the wild:
// RFC3339 (with any fractional digits) and second-precision local form.
func parseMailTime(s string) time.Time {
if s == "" {
return time.Time{}
}
if t, err := time.Parse(time.RFC3339, s); err == nil {
return t
}
if t, err := time.Parse("2006-01-02T15:04:05", s); err == nil {
return t
}
return time.Time{}
}
+137
View File
@@ -0,0 +1,137 @@
package onlyoffice
import (
"context"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
)
// mailsSyncMock serves a two-page inbox: page 1 has two list items (one
// reporting hasAttachments but omitting the attachment array, forcing the
// full-record fetch), page 2 is empty. The full record for message 102
// carries one attachment whose body is served by download.ashx.
func newMailSyncTestServer(t *testing.T, msgsPage1 string) *httptest.Server {
t.Helper()
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch {
case r.URL.Path == "/api/2.0/authentication.json":
http.SetCookie(w, &http.Cookie{Name: "sessionid", Value: "abc", Path: "/"})
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"response":{"token":"tok","expires":"2099-01-01T00:00:00.0000000+00:00"}}`))
case r.URL.Path == "/api/2.0/mail/messages":
w.Header().Set("Content-Type", "application/json")
if r.URL.Query().Get("page") > "1" {
_, _ = w.Write([]byte(`{"response":[]}`))
return
}
_, _ = w.Write([]byte(`{"response":[` + msgsPage1 + `]}`))
case r.URL.Path == "/api/2.0/mail/messages/102":
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"response":{
"id":102,"subject":"Full record","from":"\"A\" <a@b.com>",
"date":"2026-08-22T10:15:00+02:00","folder":1,"isNew":false,
"hasAttachments":true,
"attachments":[{"id":77,"fileName":"report.pdf","size":3}]}}`))
case r.URL.Path == "/addons/mail/httphandlers/download.ashx":
if r.Header.Get("Cookie") == "" {
http.Error(w, "missing cookie", http.StatusUnauthorized)
return
}
_, _ = w.Write([]byte("PDF!"))
default:
http.NotFound(w, r)
}
}))
}
func TestFetchMailFolderHydratesAndDownloads(t *testing.T) {
page1 := `
{"id":101,"subject":"Plain","from":"x@y.z","date":"2026-08-21T09:00:00Z",
"folder":1,"isNew":true,"hasAttachments":false},
{"id":102,"subject":"With attachment (list item)","from":"a@b.com",
"date":"2026-08-22T10:15:00+02:00","folder":1,"isNew":false,
"hasAttachments":true}
`
srv := newMailSyncTestServer(t, page1)
defer srv.Close()
c := NewClient(Credentials{Url: srv.URL, User: "u", Password: "p"})
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
msgs, err := c.FetchMailFolder(ctx, MailFolderInbox, MailSyncOptions{FetchBodies: true})
if err != nil {
t.Fatalf("FetchMailFolder: %v", err)
}
if len(msgs) != 2 {
t.Fatalf("got %d messages, want 2", len(msgs))
}
first := msgs[0]
if first.ID != 101 || first.Subject != "Plain" || !first.IsNew {
t.Fatalf("first = %+v", first)
}
if first.Date.IsZero() || first.Date.Year() != 2026 {
t.Fatalf("first date = %v", first.Date)
}
if first.HasAttachments {
t.Fatalf("first should have no attachments")
}
second := msgs[1]
if !second.HasAttachments || len(second.Attachments) != 1 {
t.Fatalf("second attachments = %+v", second.Attachments)
}
att := second.Attachments[0]
if att.ID != "77" || att.Name != "report.pdf" || att.Size != 3 || string(att.Body) != "PDF!" {
t.Fatalf("attachment = %+v", att)
}
if second.Date.Location() == time.UTC && second.Date.Hour() != 8 {
t.Fatalf("second date = %v (want +02:00 offset preserved)", second.Date)
}
}
func TestFetchMailFolderLimitAndStartIndex(t *testing.T) {
var items []string
for i := 1; i <= 5; i++ {
items = append(items, `{"id":`+string(rune('0'+i))+`,"subject":"m`+string(rune('0'+i))+`",
"from":"x@y.z","date":"2026-08-20T00:00:00Z","folder":1}`)
}
srv := newMailSyncTestServer(t, strings.Join(items, ","))
defer srv.Close()
c := NewClient(Credentials{Url: srv.URL, User: "u", Password: "p"})
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
got, err := c.FetchMailFolder(ctx, MailFolderInbox, MailSyncOptions{StartIndex: 1, Limit: 2})
if err != nil {
t.Fatalf("FetchMailFolder: %v", err)
}
if len(got) != 2 {
t.Fatalf("got %d messages, want 2", len(got))
}
if got[0].ID != 2 || got[1].ID != 3 {
t.Fatalf("ids = %d,%d want 2,3", got[0].ID, got[1].ID)
}
}
func TestParseMailTime(t *testing.T) {
fractions := "2026-08-22T10:15:00.1234567+02:00"
if parseMailTime(fractions).IsZero() {
t.Fatalf("RFC3339 with 7-digit fraction failed: %q", fractions)
}
if parseMailTime("2026-08-22T10:15:00").IsZero() {
t.Fatal("second-precision form failed")
}
if !parseMailTime("").IsZero() || !parseMailTime("garbage").IsZero() {
t.Fatal("unparseable input must yield zero time")
}
}