diff --git a/mailsync.go b/mailsync.go new file mode 100644 index 0000000..816752e --- /dev/null +++ b/mailsync.go @@ -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" ) + 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{} +} diff --git a/mailsync_test.go b/mailsync_test.go new file mode 100644 index 0000000..0636978 --- /dev/null +++ b/mailsync_test.go @@ -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\" ", + "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") + } +}