Compare commits
1
Commits
v0.20.0
...
ae776aa396
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ae776aa396 |
+175
@@ -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{}
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user