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.
This commit is contained in:
mdx-1
2026-08-22 14:35:57 +00:00
parent 20a09530cd
commit ae776aa396
2 changed files with 312 additions and 0 deletions
+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{}
}